From 06226b361808f38411e276b79e5dffe8206bcc2b Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 23 Apr 2026 13:48:42 +0200 Subject: [PATCH 01/13] feat(migration): cut appliance over to my collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the migration started by the dual-send PR. After this commit, type=enterprise units talk directly to the my collect API with native my credentials; neither the legacy my.nethesis.it /isa/ and /api/ endpoints nor the /proxy/* translation routes remain on the hot path. type=community units are left on the legacy my.nethserver.com / backupd infrastructure — that is explicitly out of scope for the my migration. Credential rotation (existing enterprise units): - New /usr/sbin/migrate-to-my: idempotent one-shot, gated on type=enterprise. Calls the translation proxy's /proxy/credentials with the legacy Basic-Auth pair, reads back the mapped my system_key/system_secret and atomically rotates ns-plug.config. Preserves the legacy pair under legacy_system_id / legacy_secret for audit and manual rollback, (re)asserts collect_url because /etc/config/ns-plug is a conffile, and sets the migrated='1' marker that stops the helper from running again. - send-heartbeat / send-inventory / send-backup / remote-backup invoke migrate-to-my up front on the enterprise branch so the first successful cron tick flips a pre-migration unit over. Native my registration (fresh enterprise subscriptions): - /usr/sbin/register enterprise branch now POSTs my.nethesis.it/backend/api/systems/register with {system_secret: } and reads back system_key. The unit lands on the new my with its native credentials, collect_url is written alongside the legacy URLs, and migrated='1' is set so migrate-to-my is a no-op. Community register is untouched; it keeps calling my.nethserver.com /api/machine/info. - /usr/sbin/subscription-info enterprise branch reads from collect /info with the rotated credentials and emits a legacy-shaped envelope so ns.subscription info and the existing Vue UI keep parsing the same keys. The new my data model no longer tracks a subscription plan at the system level, so plan_name falls back to the organization name and valid_until is null (the UI treats that as "no expiration"). Single-path send scripts: - send-heartbeat enterprise: POST $collect_url/heartbeat with rotated Basic-Auth. The old my-old /isa/ primary + proxy shadow dual-send is gone; community continues on my.nethserver.com/api/machine/heartbeats/store. - send-inventory enterprise: POST $collect_url/inventory with a phonehome payload. The my-old /isa/ primary, the /api/systems/info registration-date refresh and the proxy shadow are gone; community continues on my.nethserver.com. - send-backup / remote-backup enterprise: single upload to $collect_url/backups with native creds, via remote-backup's upload/download/list/delete (which includes the backup UI mapping). Community remote-backup keeps the legacy $backup_url/$TYPE/api/v2/backup/ layout untouched. Config & packaging: - ns-plug.config.collect_url default added so fresh enterprise images carry the new endpoint; backup_url default restored so fresh community installs keep the legacy backupd URL. - DEPENDS: +jq (used by remote-backup's list parser, migrate-to-my and subscription-info). Failure mode: - A /proxy/credentials outage during an enterprise upgrade window leaves the unit on legacy credentials against collect, which returns 401. migrate-to-my is re-invoked every 10 minutes via send-heartbeat's cron entry, so the unit recovers automatically once the proxy is back up. Accepted trade-off: no dual-mode in the scripts; the simpler single-send path is preferred. --- packages/ns-api/files/ns.backup | 43 ++++--- packages/ns-phonehome/files/phonehome | 12 +- packages/ns-plug/Makefile | 3 +- packages/ns-plug/files/config | 1 + packages/ns-plug/files/migrate-to-my | 91 +++++++++++++++ packages/ns-plug/files/register | 22 +++- packages/ns-plug/files/remote-backup | 139 +++++++++++++++-------- packages/ns-plug/files/send-backup | 6 + packages/ns-plug/files/send-heartbeat | 44 +++++-- packages/ns-plug/files/send-inventory | 67 +++++++---- packages/ns-plug/files/subscription-info | 51 +++++++-- packages/ns-plug/files/unregister | 26 ++++- 12 files changed, 388 insertions(+), 117 deletions(-) create mode 100644 packages/ns-plug/files/migrate-to-my diff --git a/packages/ns-api/files/ns.backup b/packages/ns-api/files/ns.backup index edadeb97e..de578ebed 100755 --- a/packages/ns-api/files/ns.backup +++ b/packages/ns-api/files/ns.backup @@ -169,21 +169,26 @@ elif cmd == 'call': elif action == 'registered-backup': if not os.path.exists(PASSPHRASE_PATH): - print(utils.validation_error('passphrase', 'missing')) - else: - try: - # create backup - file_name = create_backup() - backup_path = f'{DOWNLOAD_PATH}{file_name}' - # upload backup to server and remove it from filesystem - completed_process = subprocess.run(['/usr/sbin/remote-backup', 'upload', backup_path], check=True, - capture_output=True) - os.remove(backup_path) - print(json.dumps({'message': 'success'})) - except subprocess.CalledProcessError as error: - print(json.dumps(utils.generic_error(f'remote upload failed'))) - except RuntimeError as error: - print(json.dumps(utils.generic_error(error.args[0]))) + # Refuse the call before running sysupgrade/uploading, and emit + # valid JSON so the HTTP API wraps it as a 422 ValidationError + # the UI can render (the previous form printed a Python dict + # repr, which was silently dropped upstream and caused the run + # modal to stay open after a successful upload). + print(json.dumps(utils.validation_error('passphrase', 'missing'))) + sys.exit(0) + try: + # create backup + file_name = create_backup() + backup_path = f'{DOWNLOAD_PATH}{file_name}' + # upload backup to server and remove it from filesystem + completed_process = subprocess.run(['/usr/sbin/remote-backup', 'upload', backup_path], check=True, + capture_output=True) + os.remove(backup_path) + print(json.dumps({'message': 'success'})) + except subprocess.CalledProcessError as error: + print(json.dumps(utils.generic_error(f'remote upload failed'))) + except RuntimeError as error: + print(json.dumps(utils.generic_error(error.args[0]))) elif action == 'registered-restore': try: @@ -224,10 +229,12 @@ elif cmd == 'call': elif action == 'registered-delete-backup': try: data = json.load(sys.stdin) - p = subprocess.run(['/usr/sbin/remote-backup', 'delete', data['id']], + subprocess.run(['/usr/sbin/remote-backup', 'delete', data['id']], check=True, capture_output=True, text=True) - # return content - print(p.stdout) + # The remote side returns a structured JSON response; the UI + # only needs a success flag, matching the pattern of the + # other registered-* handlers (backup, restore). + print(json.dumps({'message': 'success'})) except subprocess.CalledProcessError as error: print(json.dumps(utils.generic_error('remote backup delete failed'))) except KeyError as error: diff --git a/packages/ns-phonehome/files/phonehome b/packages/ns-phonehome/files/phonehome index 08f64b71a..95e92e1e2 100755 --- a/packages/ns-phonehome/files/phonehome +++ b/packages/ns-phonehome/files/phonehome @@ -36,6 +36,15 @@ for func in dir(inventory): if func.startswith("info_"): info[func.removeprefix('info_')] = method(EUci()) +# Migration fingerprint. Populated only on enterprise units that went +# through migrate-to-my or the native my register — my uses this to +# track which units have already rotated off the translation proxy +# and decide when the proxy can be decommissioned. +migration = { + "from_legacy_system_id": u.get('ns-plug', 'config', 'legacy_system_id', default='') or None, + "migrated_at": u.get('ns-plug', 'config', 'migrated_at', default='') or None, +} + data = { "$schema": "https://schema.nethserver.org/facts/2022-12.json", "uuid": sid, @@ -61,7 +70,8 @@ data = { }, "pci": list(pci.values()), "mountpoints": mount_points, - "features": features + "features": features, + "migration": migration } } diff --git a/packages/ns-plug/Makefile b/packages/ns-plug/Makefile index 14bfd5d87..1a9d5c00b 100644 --- a/packages/ns-plug/Makefile +++ b/packages/ns-plug/Makefile @@ -21,7 +21,7 @@ define Package/ns-plug CATEGORY:=NethSecurity TITLE:=NethSecurity controller client URL:=https://github.com/NethServer/nethsecurity-controller/ - DEPENDS:=+openvpn +lscpu +python3-nethsec +python3-yaml +telegraf +victoria-metrics + DEPENDS:=+openvpn +lscpu +python3-nethsec +python3-yaml +telegraf +victoria-metrics +jq PKGARCH:=all endef @@ -81,6 +81,7 @@ define Package/ns-plug/install $(INSTALL_BIN) ./files/ns-plug $(1)/usr/sbin/ns-plug $(INSTALL_BIN) ./files/ns-plug-alert-proxy $(1)/usr/sbin/ns-plug-alert-proxy $(INSTALL_BIN) ./files/distfeed-setup $(1)/usr/sbin/distfeed-setup + $(INSTALL_BIN) ./files/migrate-to-my $(1)/usr/sbin $(INSTALL_BIN) ./files/remote-backup $(1)/usr/sbin $(INSTALL_BIN) ./files/send-backup $(1)/usr/sbin $(INSTALL_BIN) ./files/send-heartbeat $(1)/usr/sbin diff --git a/packages/ns-plug/files/config b/packages/ns-plug/files/config index 9f32f0262..fd208ddf7 100644 --- a/packages/ns-plug/files/config +++ b/packages/ns-plug/files/config @@ -5,6 +5,7 @@ config main 'config' option unit_name '' option tls_verify '1' option backup_url 'https://backupd.nethesis.it' + option collect_url 'https://my.nethesis.it/collect/api/systems' option repository_url 'https://updates.nethsecurity.nethserver.org' option channel '' option tun_mtu '' diff --git a/packages/ns-plug/files/migrate-to-my b/packages/ns-plug/files/migrate-to-my new file mode 100644 index 000000000..c8f96d02d --- /dev/null +++ b/packages/ns-plug/files/migrate-to-my @@ -0,0 +1,91 @@ +#!/bin/sh + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-2.0-only +# + +# +# Idempotent one-shot migration from legacy my.nethesis.it / backupd +# credentials to the my collect native credentials, so the unit can +# authenticate directly against the my collect endpoints. +# +# Only enterprise units are migrated. Community (my.nethserver.com) +# has its own infrastructure and keeps using the legacy send-* +# endpoints — no credential rotation is applicable there. +# +# ns-plug.config.migrated='1' is the persistent marker. It is written +# by this script after a successful rotation, and also by +# /usr/sbin/register when it registers a fresh unit directly against +# the my collect endpoint (so a brand new install never triggers the +# rotation path and never hits /proxy/credentials with unmapped +# credentials). +# +# On the first successful invocation the script: +# 1. Calls the my translation proxy's /proxy/credentials endpoint +# with the legacy Basic-Auth pair and retrieves the mapped my +# system key / secret. +# 2. Writes the new credentials to ns-plug.config.system_id / secret +# and preserves the legacy pair under legacy_system_id / +# legacy_secret (for audit and manual rollback). +# 3. Re-asserts ns-plug.config.collect_url, because /etc/config/ +# ns-plug is a conffile: on registered units opkg keeps the +# user-modified copy across upgrades, so a new default alone +# would not reach them. +# 4. Sets the migrated='1' marker. +# +# The uci commit is atomic — a partial write cannot leave the unit in +# an inconsistent half-migrated state. +# + +# Marker: set only after a successful rotation or a native my register. +[ "$(uci -q get ns-plug.config.migrated)" = "1" ] && exit 0 + +# Community units stay on the legacy my.nethserver.com infrastructure. +TYPE=$(uci -q get ns-plug.config.type) +if [ "$TYPE" != "enterprise" ]; then + exit 0 +fi + +SYSTEM_ID=$(uci -q get ns-plug.config.system_id) +SYSTEM_SECRET=$(uci -q get ns-plug.config.secret) +if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then + # Unregistered unit — nothing to migrate yet. + exit 0 +fi + +# Fetch the mapped my credentials via the translation proxy. +resp=$(/usr/bin/curl --silent --location-trusted --fail-with-body \ + --max-time 30 --retry 2 \ + --user "$SYSTEM_ID:$SYSTEM_SECRET" \ + https://my.nethesis.it/proxy/credentials 2>/dev/null) || { + logger -t migrate-to-my "credential fetch failed; will retry on next run" + exit 0 +} + +new_key=$(echo "$resp" | jq -r '.data.system_key // empty' 2>/dev/null) +new_secret=$(echo "$resp" | jq -r '.data.system_secret // empty' 2>/dev/null) +if [ -z "$new_key" ] || [ -z "$new_secret" ]; then + logger -t migrate-to-my "credentials missing in response" + exit 0 +fi + +# Timestamp the rotation so phonehome can publish the event and my +# can plot the fleet migration curve / decide when the translation +# proxy can be decommissioned. +migrated_at=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +# Rotate atomically; legacy pair preserved for audit / rollback. +uci -q batch </dev/null) ;; enterprise) - url="https://my.nethesis.it/api/" + url="https://my.nethesis.it/backend/api/" - system_id=$(curl -s -m $timeout --retry 3 -L \ + register_resp=$(curl -s -m $timeout --retry 3 -L \ -H "Content-Type: application/json" -H "Accept: application/json" \ - -d '{"secret": "'$secret'"}' "${url}systems/info" | jq -r ".uuid" 2>/dev/null) + -d '{"system_secret": "'$secret'"}' "${url}systems/register") + + system_id=$(echo "$register_resp" | jq -r '.data.system_key // empty' 2>/dev/null) ;; *) exit_error "Invalid type '$type'" @@ -68,9 +75,12 @@ case "$type" in ;; enterprise) uci set ns-plug.config.type="enterprise" - uci set ns-plug.config.alerts_url="https://my.nethesis.it/isa/" uci set ns-plug.config.api_url="$url" - uci set ns-plug.config.inventory_url="https://my.nethesis.it/isa/inventory/store/" + uci set ns-plug.config.collect_url="https://my.nethesis.it/collect/api/systems" + # Native my register: no legacy credentials to rotate, so + # mark the unit as already migrated. migrate-to-my will be a + # no-op on every subsequent run. + uci set ns-plug.config.migrated="1" ;; esac diff --git a/packages/ns-plug/files/remote-backup b/packages/ns-plug/files/remote-backup index 9a12d0ef4..829ee40b3 100755 --- a/packages/ns-plug/files/remote-backup +++ b/packages/ns-plug/files/remote-backup @@ -6,8 +6,20 @@ # # -# Manage remote backup +# Manage configuration backups. # +# Enterprise units (type=enterprise) talk to my collect after the +# migrate-to-my credential rotation. Community units (type=community) +# keep using the legacy backupd.nethesis.it endpoint with the same +# URL layout they have always used — backupd still accepts both +# tenants behind the $TYPE/api/v2/backup/ path. +# +# Pipefail so the curl exit status survives the jq stage in `list`; +# without it a HTTP error on the server would be masked by a successful +# jq parse and ns.backup would report success to the UI. +# + +set -o pipefail function exit_error { >&2 echo "[ERROR] $@" @@ -15,68 +27,105 @@ function exit_error { } function help { - >&2 echo "Usage: $0 " + >&2 echo "Usage: $0 " >&2 echo "Commands:" - >&2 echo " - list: retrieve the list of available backups from remote server" - >&2 echo " - download [output]: download the given backup, if 'output' is empty downloaded file will be named as as 'file'" - >&2 echo " - upload : upload the given backup" + >&2 echo " - list: fetch the list of backups stored for this system" + >&2 echo " - download [output]: download the backup ; defaults to writing to a file named " + >&2 echo " - upload : upload a backup file" + >&2 echo " - delete : remove the backup " } SYSTEM_ID=$(uci -q get ns-plug.config.system_id) SYSTEM_SECRET=$(uci -q get ns-plug.config.secret) TYPE=$(uci -q get ns-plug.config.type) -URL=$(uci -q get ns-plug.config.backup_url) -if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ] || [ -z "$URL" ]; then - exit_error "System ID, system secret or backup url not found. Please configure ns-plug." +if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then + exit_error "System ID or system secret not found. Please configure ns-plug." +fi + +cmd=${1:-list} + +if [ "$TYPE" = "enterprise" ]; then + /usr/sbin/migrate-to-my + + COLLECT_URL=$(uci -q get ns-plug.config.collect_url) + if [ -z "$COLLECT_URL" ]; then + exit_error "Collect URL not set. Pre-migration unit — retry later." + fi + + BASE="$COLLECT_URL/backups" + # --fail-with-body: exit 22 on 4xx/5xx while still writing the body + # so the caller can inspect the error payload. + curl_args="--silent --location-trusted --fail-with-body --user $SYSTEM_ID:$SYSTEM_SECRET" + + case "$cmd" in + list) + # my returns {code, message, data: {backups: [...]}} on + # success. Unwrap `data` so ns.backup can pass it through as + # {values: } without double-nesting. Fall back to an + # empty list on failure. + response=$(curl $curl_args "$BASE") + echo "$response" | jq 'if .data and (.data.backups // empty) then .data else {backups: []} end' + ;; + download) + file=$2 + [ -z "$file" ] && exit_error "No file specified" + output=${3-$file} + curl $curl_args -o "$output" "$BASE/$file" + ;; + upload) + file=$2 + [ -z "$file" ] && exit_error "No file specified" + curl $curl_args -X POST \ + -H "Content-Type: application/octet-stream" \ + -H "X-Filename: $(basename "$file")" \ + --data-binary "@$file" \ + "$BASE" + ;; + delete) + file=$2 + [ -z "$file" ] && exit_error "No file specified" + curl $curl_args -X DELETE "$BASE/$file" + ;; + *) + help + ;; + esac + + exit $? +fi + +# Community (legacy): backupd.nethesis.it with the /$TYPE/api/v2/backup/ +# URL layout. Unchanged from the pre-migration behaviour. +URL=$(uci -q get ns-plug.config.backup_url) +if [ -z "$URL" ]; then + exit_error "Backup URL not set. Please configure ns-plug." fi curl_args="--silent --location-trusted --user $SYSTEM_ID:$SYSTEM_SECRET" base_url="$URL/$TYPE/api/v2/backup/" -cmd=${1:-list} - case "$cmd" in list) curl $curl_args $base_url ;; download) file=$2 - if [ -z "$file" ]; then - exit_error "No file specified" - fi + [ -z "$file" ] && exit_error "No file specified" output=${3-$file} curl $curl_args $base_url$file -J -o "$output" - ;; - upload) - file=$2 - if [ -z "$file" ]; then - exit_error "No file specified" - fi - curl $curl_args $base_url --upload-file $file - rc=$? - # Temporary dual-send to new my.nethesis.it via the translation - # proxy, same pattern used by send-heartbeat / send-inventory. - # To be removed once the migration is complete. - if [ "$TYPE" = "enterprise" ]; then - # Strip the directory path first to get just the filename - filename="${file##*/}" - curl $curl_args -X POST \ - -H "Content-Type: application/octet-stream" \ - -H "X-Filename: ${filename}" \ - --data-binary "@$file" https://my.nethesis.it/proxy/backup >/dev/null || : - fi - exit $rc - ;; - delete) - file=$2 - if [ -z "$file" ]; then - exit_error "No file specified" - fi - curl $curl_args -X DELETE $base_url$file - ;; - - *) - help - ;; + ;; + upload) + file=$2 + [ -z "$file" ] && exit_error "No file specified" + curl $curl_args $base_url --upload-file $file + ;; + delete) + file=$2 + [ -z "$file" ] && exit_error "No file specified" + curl $curl_args -X DELETE $base_url$file + ;; + *) + help + ;; esac diff --git a/packages/ns-plug/files/send-backup b/packages/ns-plug/files/send-backup index 0cf33f2f0..873525eb2 100755 --- a/packages/ns-plug/files/send-backup +++ b/packages/ns-plug/files/send-backup @@ -38,6 +38,12 @@ if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then exit 0 fi +# remote-backup handles the enterprise/community branching and calls +# migrate-to-my when needed; this script just prepares the payload and +# delegates the upload. An enterprise unit still waiting on the +# migration surfaces its error through remote-backup, which is caught +# by set -e above. + # hack: avoid to backup non-config file if [ -f /etc/acme/http.header ]; then mv /etc/acme/http.header /tmp diff --git a/packages/ns-plug/files/send-heartbeat b/packages/ns-plug/files/send-heartbeat index 36b328349..8f4d02c0e 100755 --- a/packages/ns-plug/files/send-heartbeat +++ b/packages/ns-plug/files/send-heartbeat @@ -5,11 +5,19 @@ # SPDX-License-Identifier: GPL-2.0-only # -# Send the heartbeat +# Send the heartbeat. +# +# Enterprise units (type=enterprise) post to the my collect endpoint +# with the rotated my credentials; migrate-to-my runs up front so a +# unit upgraded from the legacy my.nethesis.it path transparently +# flips over on the first successful rotation. +# +# Community units (type=community) are left on the legacy +# my.nethserver.com heartbeat path — that infrastructure has no +# counterpart on the new my and is out of scope for this migration. SYSTEM_ID=$(uci -q get ns-plug.config.system_id) SYSTEM_SECRET=$(uci -q get ns-plug.config.secret) -URL=$(uci -q get ns-plug.config.alerts_url)"heartbeats/store" TYPE=$(uci -q get ns-plug.config.type) if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then @@ -17,14 +25,26 @@ if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then exit 0 fi -/usr/bin/curl -m 180 --retry 3 -L -s \ - --header "Authorization: token $SYSTEM_SECRET" --header "Content-Type: application/json" --header "Accept: application/json" \ - --data-raw '{"lk": "'$SYSTEM_ID'"}' "$URL" >/dev/null +case "$TYPE" in + enterprise) + /usr/sbin/migrate-to-my -# Temporary send data to new endpoint -# To be removed when the migration to new my.nethesis.it will be completed -if [ "$TYPE" = "enterprise" ]; then - /usr/bin/curl -m 180 --retry 3 -L -s -X POST \ - --user "$SYSTEM_ID:$SYSTEM_SECRET" https://my.nethesis.it/proxy/heartbeat >/dev/null - exit 0 -fi + COLLECT_URL=$(uci -q get ns-plug.config.collect_url) + if [ -z "$COLLECT_URL" ]; then + # Pre-migration — retry on next tick. + exit 0 + fi + + /usr/bin/curl -m 30 --retry 3 -L -s -X POST \ + --user "$SYSTEM_ID:$SYSTEM_SECRET" \ + "$COLLECT_URL/heartbeat" >/dev/null + ;; + community) + URL=$(uci -q get ns-plug.config.alerts_url)"heartbeats/store" + /usr/bin/curl -m 180 --retry 3 -L -s \ + --header "Authorization: token $SYSTEM_SECRET" \ + --header "Content-Type: application/json" \ + --header "Accept: application/json" \ + --data-raw '{"lk": "'$SYSTEM_ID'"}' "$URL" >/dev/null + ;; +esac diff --git a/packages/ns-plug/files/send-inventory b/packages/ns-plug/files/send-inventory index a670e9925..b6658f672 100755 --- a/packages/ns-plug/files/send-inventory +++ b/packages/ns-plug/files/send-inventory @@ -5,11 +5,16 @@ # SPDX-License-Identifier: GPL-2.0-only # -# Send the inventory +# Send the inventory. +# +# Enterprise units post to my collect with the rotated my credentials; +# community units keep using the legacy my.nethserver.com inventory +# path. See send-heartbeat for the rationale — the two flows are kept +# parallel so the community infrastructure is never touched by the my +# migration. SYSTEM_ID=$(uci -q get ns-plug.config.system_id) SYSTEM_SECRET=$(uci -q get ns-plug.config.secret) -URL=$(uci -q get ns-plug.config.inventory_url) TYPE=$(uci -q get ns-plug.config.type) if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then @@ -17,28 +22,40 @@ if [ -z "$SYSTEM_ID" ] || [ -z "$SYSTEM_SECRET" ]; then exit 0 fi -echo "{\"data\": {\"lk\": \"$SYSTEM_ID\", \"data\": $(/usr/sbin/inventory) }}" | \ -/usr/bin/curl -m 180 --retry 5 -L -s \ - --header "Authorization: token $SYSTEM_SECRET" --header "Content-Type: application/json" --header "Accept: application/json" \ - --data-binary @- "$URL" > /dev/null - -if [ $? -ne 0 ]; then - status="error" -else - status="success" -fi +status="error" + +case "$TYPE" in + enterprise) + /usr/sbin/migrate-to-my + + COLLECT_URL=$(uci -q get ns-plug.config.collect_url) + if [ -z "$COLLECT_URL" ]; then + # Pre-migration — retry on next tick. + exit 0 + fi + + /usr/sbin/phonehome | /usr/bin/curl -m 180 --retry 3 -L -s -X POST \ + --user "$SYSTEM_ID:$SYSTEM_SECRET" \ + -H "Content-Type: application/json" \ + --data-binary @- "$COLLECT_URL/inventory" >/dev/null + + if [ $? -eq 0 ]; then + status="success" + fi + ;; + community) + URL=$(uci -q get ns-plug.config.inventory_url) + echo "{\"data\": {\"lk\": \"$SYSTEM_ID\", \"data\": $(/usr/sbin/inventory) }}" | \ + /usr/bin/curl -m 180 --retry 5 -L -s \ + --header "Authorization: token $SYSTEM_SECRET" \ + --header "Content-Type: application/json" \ + --header "Accept: application/json" \ + --data-binary @- "$URL" > /dev/null + + if [ $? -eq 0 ]; then + status="success" + fi + ;; +esac echo '{"status": "'$status'", "last_attempt": "'$(date -Iseconds)'"}' > /tmp/inventory-sent.json - -if [ "$TYPE" = "enterprise" ]; then - # Update registration date - /usr/bin/curl -m 180 --retry 5 -L -s \ - --header "Content-Type: application/json" --header "Accept: application/json" \ - -d '{"secret":"'$SYSTEM_SECRET'"}' https://my.nethesis.it/api/systems/info >/dev/null - - # Temporary send data to new endpoint - # To be removed when the migration to new my.nethesis.it will be completed - /usr/sbin/phonehome | /usr/bin/curl -m 180 --retry 3 -L -s --user "$SYSTEM_ID:$SYSTEM_SECRET" \ - -H "Content-Type: application/json" \ - --data-binary @- https://my.nethesis.it/proxy/inventory >/dev/null || : -fi diff --git a/packages/ns-plug/files/subscription-info b/packages/ns-plug/files/subscription-info index d78d3cbdd..e0d60ecad 100755 --- a/packages/ns-plug/files/subscription-info +++ b/packages/ns-plug/files/subscription-info @@ -6,8 +6,16 @@ # # -# Retrieve subscription information -# The script takes an optional timeout parameter +# Retrieve subscription information. +# +# Enterprise units query the my collect /info endpoint with their +# native credentials and synthesise a payload shaped like the legacy +# my-old /api/systems/info response the UI expects. The subscription +# plan / expiration fields are left empty — the new my data model +# no longer tracks them at the system level; the ns.subscription +# info handler falls back to "-" / 0 / "active" in those cases. +# +# Community units keep hitting my.nethserver.com as before. # timeout=${1:-20} @@ -15,20 +23,49 @@ timeout=${1:-20} system_id=$(uci -q get ns-plug.config.system_id) if [ -z "$system_id" ]; then - # no subscription echo '{"uuid": ""}' exit 0 fi type=$(uci -q get ns-plug.config.type) secret=$(uci -q get ns-plug.config.secret) -url=$(uci -q get ns-plug.config.api_url | sed 's/\/$//') if [ "$type" = "enterprise" ]; then - curl -f -s -m $timeout --retry-delay 1 --retry 2 -L \ - -H "Content-Type: application/json" -H "Accept: application/json" \ - -d '{"secret": "'$secret'"}' "$url/systems/info" + /usr/sbin/migrate-to-my + + collect_url=$(uci -q get ns-plug.config.collect_url) + if [ -z "$collect_url" ]; then + # Pre-migration unit — nothing to report yet; caller falls + # back to a default payload built from ns-plug.config. + exit 1 + fi + + # /info returns {code, message, data: {system_id, registered, + # registered_at, suspended, organization: {name, ...}, ...}}. + # Translate to the legacy shape the UI/info handler parses. + resp=$(/usr/bin/curl -f -s -m $timeout --retry-delay 1 --retry 2 -L \ + -H "Accept: application/json" \ + --user "$system_id:$secret" \ + "$collect_url/info") || exit $? + + jq -c ' + .data as $s | + { + uuid: ($s.system_id // ""), + id: ($s.system_key // ""), + subscription: { + status: (if $s.registered and (($s.suspended // false) | not) then "valid" else "invalid" end), + valid_until: null, + subscription_plan: { name: ($s.organization.name // "-") } + } + } + ' </dev/null +# Release the legacy slot on my-old for migrated enterprise units — +# the /api/Utils/freekey PHP endpoint still exists on my-ent and lets +# the old dashboard record the unit as gone. Native enterprise units +# have no legacy slot to release (registered directly on my collect), +# and community has no freekey equivalent on dartagnan. +if [ "$TYPE" = "enterprise" ]; then + LEGACY_ID=$(uci -q get ns-plug.config.legacy_system_id) + LEGACY_SECRET=$(uci -q get ns-plug.config.legacy_secret) + if [ -n "$LEGACY_ID" ] && [ -n "$LEGACY_SECRET" ]; then + curl -s -m 180 --retry 3 -L \ + -H "Content-type: application/json" -H "Accept: application/json" \ + -d "{\"lk\":\"$LEGACY_ID\",\"secret\":\"$LEGACY_SECRET\"}" \ + https://my.nethesis.it/api/Utils/freekey >/dev/null + fi +fi # Reset ns-plug configuration uci set ns-plug.config.type="" @@ -28,6 +42,14 @@ uci set ns-plug.config.inventory_url="" uci set ns-plug.config.system_id="" uci set ns-plug.config.secret="" uci set ns-plug.config.repository_url="https://updates.nethsecurity.nethserver.org/$(cat /etc/repo-channel)" +# Drop the enterprise migration fingerprint so a subsequent register +# starts from a clean slate and migrate-to-my can run again if the +# unit re-registers with legacy credentials. +uci -q delete ns-plug.config.collect_url +uci -q delete ns-plug.config.migrated +uci -q delete ns-plug.config.migrated_at +uci -q delete ns-plug.config.legacy_system_id +uci -q delete ns-plug.config.legacy_secret # Save config uci commit ns-plug From 67510860324f37dff526631e7445e506eefaf19a Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 17 Jun 2026 10:37:00 +0200 Subject: [PATCH 02/13] feat(migration): switch alerts to native my collect after cutover vmalert.initd: when migrated=1 and collect_url is set, derive the native Mimir alertmanager URL from collect_url and notify with the rotated credentials; pre-cutover units stay on /proxy/alerts. migrate-to-my: reload vmalert right after the rotation so the alert path flips together with the credentials instead of at the next reload. --- packages/ns-plug/files/migrate-to-my | 8 ++++ packages/victoria-metrics/files/vmalert.initd | 37 +++++++++++++------ 2 files changed, 34 insertions(+), 11 deletions(-) diff --git a/packages/ns-plug/files/migrate-to-my b/packages/ns-plug/files/migrate-to-my index c8f96d02d..d22a5f6a5 100644 --- a/packages/ns-plug/files/migrate-to-my +++ b/packages/ns-plug/files/migrate-to-my @@ -87,5 +87,13 @@ set ns-plug.config.migrated_at=$migrated_at commit ns-plug EOI +# Repoint vmalert at the native Mimir endpoint right away. vmalert reloads on +# ns-plug changes, but migrate-to-my does not trigger reload_config, so force a +# targeted reload here: the running vmalert otherwise keeps its boot-time +# (legacy) creds and POSTs to /proxy/alerts until the next reload. Reloading +# now makes the alert path flip together with the credential rotation. Safe +# no-op if victoria-metrics is not installed. +/etc/init.d/vmalert reload 2>/dev/null || true + logger -t migrate-to-my "migrated to my collect credentials" exit 0 diff --git a/packages/victoria-metrics/files/vmalert.initd b/packages/victoria-metrics/files/vmalert.initd index 636202aa2..7e8b5e65d 100644 --- a/packages/victoria-metrics/files/vmalert.initd +++ b/packages/victoria-metrics/files/vmalert.initd @@ -19,25 +19,40 @@ start_service() { config_get datasource_url main datasource_url "http://localhost:8428" config_get http_listen_addr main http_listen_addr "127.0.0.1:8081" - # Forward alerts to the new my.nethesis.it during the migration window, - # mirroring send-heartbeat / send-inventory: enterprise systems POST to the - # credential-translation proxy at my.nethesis.it/proxy/alerts using the - # ns-plug credentials (system_id:secret), which the proxy maps to the new my - # credentials. vmalert appends /api/v2/alerts to the notifier URL. The my - # switch-off release will repoint this to the native collect path. - local system_id system_secret system_type alerts_disabled notifier_url notifier_user notifier_pass + # Forward alerts to the new my for enterprise systems. Two windows: + # - Pre-cutover (migrated!=1): POST to the credential-translation proxy at + # my.nethesis.it/proxy/alerts with the still-legacy ns-plug credentials + # (system_id:secret); the proxy maps them to the new my credentials. + # - Post-cutover (migrated=1 + collect_url): migrate-to-my has rotated the + # credentials to native my credentials, so POST straight to the native + # Mimir alertmanager derived from collect_url — the legacy-only proxy + # would 401 the rotated credentials. vmalert appends /api/v2/alerts, so + # the notifier URL is the alertmanager base WITHOUT that suffix. + # Opt-out (disable_my_alerts=1) keeps the unit on the local alert-proxy only. + local system_id system_secret system_type alerts_disabled migrated collect_url + local notifier_url notifier_user notifier_pass config_load ns-plug 2>/dev/null && { config_get system_id config system_id "" config_get system_secret config secret "" config_get system_type config type "" + config_get migrated config migrated "" + config_get collect_url config collect_url "" + # opt-out: set ns-plug.config.disable_my_alerts=1 for alert-proxy only mode + config_get_bool alerts_disabled config disable_my_alerts 0 } - if [ "$system_type" = "enterprise" ] && [ -n "$system_id" ] && [ -n "$system_secret" ]; then - notifier_url="https://my.nethesis.it/proxy/alerts" + notifier_url="" + if [ "$system_type" = "enterprise" ] && [ -n "$system_id" ] && [ -n "$system_secret" ] && [ "$alerts_disabled" = "0" ]; then + if [ "$migrated" = "1" ] && [ -n "$collect_url" ]; then + # Native my Mimir alertmanager, derived from collect_url, e.g. + # https://my.nethesis.it/collect/api/systems + # -> https://my.nethesis.it/collect/api/services/mimir/alertmanager + notifier_url="${collect_url%%/collect/*}/collect/api/services/mimir/alertmanager" + else + notifier_url="https://my.nethesis.it/proxy/alerts" + fi notifier_user="$system_id" notifier_pass="$system_secret" - else - notifier_url="" fi procd_open_instance From aff27c0f7e82fc434cf75e38d8e5519ef15ac652 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 19 Jun 2026 09:45:31 +0200 Subject: [PATCH 03/13] feat(migration): point my endpoints at my-proxy-prod.onrender.com Reach the new my via the Render prod proxy so migrated units work before the my.nethesis.it DNS flip; reverts at the flip. --- packages/ns-plug/files/config | 2 +- packages/ns-plug/files/migrate-to-my | 4 ++-- packages/ns-plug/files/register | 4 ++-- packages/ns-plug/files/unregister | 2 +- packages/victoria-metrics/files/vmalert.initd | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/packages/ns-plug/files/config b/packages/ns-plug/files/config index fd208ddf7..71e511bbc 100644 --- a/packages/ns-plug/files/config +++ b/packages/ns-plug/files/config @@ -5,7 +5,7 @@ config main 'config' option unit_name '' option tls_verify '1' option backup_url 'https://backupd.nethesis.it' - option collect_url 'https://my.nethesis.it/collect/api/systems' + option collect_url 'https://my-proxy-prod.onrender.com/collect/api/systems' option repository_url 'https://updates.nethsecurity.nethserver.org' option channel '' option tun_mtu '' diff --git a/packages/ns-plug/files/migrate-to-my b/packages/ns-plug/files/migrate-to-my index d22a5f6a5..9b88551ad 100644 --- a/packages/ns-plug/files/migrate-to-my +++ b/packages/ns-plug/files/migrate-to-my @@ -58,7 +58,7 @@ fi resp=$(/usr/bin/curl --silent --location-trusted --fail-with-body \ --max-time 30 --retry 2 \ --user "$SYSTEM_ID:$SYSTEM_SECRET" \ - https://my.nethesis.it/proxy/credentials 2>/dev/null) || { + https://my-proxy-prod.onrender.com/proxy/credentials 2>/dev/null) || { logger -t migrate-to-my "credential fetch failed; will retry on next run" exit 0 } @@ -81,7 +81,7 @@ set ns-plug.config.legacy_system_id=$SYSTEM_ID set ns-plug.config.legacy_secret=$SYSTEM_SECRET set ns-plug.config.system_id=$new_key set ns-plug.config.secret=$new_secret -set ns-plug.config.collect_url=https://my.nethesis.it/collect/api/systems +set ns-plug.config.collect_url=https://my-proxy-prod.onrender.com/collect/api/systems set ns-plug.config.migrated=1 set ns-plug.config.migrated_at=$migrated_at commit ns-plug diff --git a/packages/ns-plug/files/register b/packages/ns-plug/files/register index ac6d3a666..1d877c416 100755 --- a/packages/ns-plug/files/register +++ b/packages/ns-plug/files/register @@ -47,7 +47,7 @@ case "$type" in "${url}machine/info" | jq -r ".uuid" 2>/dev/null) ;; enterprise) - url="https://my.nethesis.it/backend/api/" + url="https://my-proxy-prod.onrender.com/backend/api/" register_resp=$(curl -s -m $timeout --retry 3 -L \ -H "Content-Type: application/json" -H "Accept: application/json" \ @@ -76,7 +76,7 @@ case "$type" in enterprise) uci set ns-plug.config.type="enterprise" uci set ns-plug.config.api_url="$url" - uci set ns-plug.config.collect_url="https://my.nethesis.it/collect/api/systems" + uci set ns-plug.config.collect_url="https://my-proxy-prod.onrender.com/collect/api/systems" # Native my register: no legacy credentials to rotate, so # mark the unit as already migrated. migrate-to-my will be a # no-op on every subsequent run. diff --git a/packages/ns-plug/files/unregister b/packages/ns-plug/files/unregister index 21041c4dd..5fe069815 100755 --- a/packages/ns-plug/files/unregister +++ b/packages/ns-plug/files/unregister @@ -30,7 +30,7 @@ if [ "$TYPE" = "enterprise" ]; then curl -s -m 180 --retry 3 -L \ -H "Content-type: application/json" -H "Accept: application/json" \ -d "{\"lk\":\"$LEGACY_ID\",\"secret\":\"$LEGACY_SECRET\"}" \ - https://my.nethesis.it/api/Utils/freekey >/dev/null + https://my-proxy-prod.onrender.com/api/Utils/freekey >/dev/null fi fi diff --git a/packages/victoria-metrics/files/vmalert.initd b/packages/victoria-metrics/files/vmalert.initd index 7e8b5e65d..3cfae084d 100644 --- a/packages/victoria-metrics/files/vmalert.initd +++ b/packages/victoria-metrics/files/vmalert.initd @@ -45,11 +45,11 @@ start_service() { if [ "$system_type" = "enterprise" ] && [ -n "$system_id" ] && [ -n "$system_secret" ] && [ "$alerts_disabled" = "0" ]; then if [ "$migrated" = "1" ] && [ -n "$collect_url" ]; then # Native my Mimir alertmanager, derived from collect_url, e.g. - # https://my.nethesis.it/collect/api/systems - # -> https://my.nethesis.it/collect/api/services/mimir/alertmanager + # https://my-proxy-prod.onrender.com/collect/api/systems + # -> https://my-proxy-prod.onrender.com/collect/api/services/mimir/alertmanager notifier_url="${collect_url%%/collect/*}/collect/api/services/mimir/alertmanager" else - notifier_url="https://my.nethesis.it/proxy/alerts" + notifier_url="https://my-proxy-prod.onrender.com/proxy/alerts" fi notifier_user="$system_id" notifier_pass="$system_secret" From 4dfc096cdf7755281aba62d9c88b1fc5cd3e07de Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 12:41:15 +0200 Subject: [PATCH 04/13] fix(alert): drop disable_my_alerts opt-out MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Align with main cf2d3052 — the option was added during a transition phase and is no longer required. The my-cutover branch predated that removal, so the rebase re-introduced it; remove it again to avoid resurrecting code dropped from main. --- packages/victoria-metrics/files/vmalert.initd | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/victoria-metrics/files/vmalert.initd b/packages/victoria-metrics/files/vmalert.initd index 3cfae084d..c2c935bf2 100644 --- a/packages/victoria-metrics/files/vmalert.initd +++ b/packages/victoria-metrics/files/vmalert.initd @@ -28,8 +28,7 @@ start_service() { # Mimir alertmanager derived from collect_url — the legacy-only proxy # would 401 the rotated credentials. vmalert appends /api/v2/alerts, so # the notifier URL is the alertmanager base WITHOUT that suffix. - # Opt-out (disable_my_alerts=1) keeps the unit on the local alert-proxy only. - local system_id system_secret system_type alerts_disabled migrated collect_url + local system_id system_secret system_type migrated collect_url local notifier_url notifier_user notifier_pass config_load ns-plug 2>/dev/null && { config_get system_id config system_id "" @@ -37,12 +36,10 @@ start_service() { config_get system_type config type "" config_get migrated config migrated "" config_get collect_url config collect_url "" - # opt-out: set ns-plug.config.disable_my_alerts=1 for alert-proxy only mode - config_get_bool alerts_disabled config disable_my_alerts 0 } notifier_url="" - if [ "$system_type" = "enterprise" ] && [ -n "$system_id" ] && [ -n "$system_secret" ] && [ "$alerts_disabled" = "0" ]; then + if [ "$system_type" = "enterprise" ] && [ -n "$system_id" ] && [ -n "$system_secret" ]; then if [ "$migrated" = "1" ] && [ -n "$collect_url" ]; then # Native my Mimir alertmanager, derived from collect_url, e.g. # https://my-proxy-prod.onrender.com/collect/api/systems From 4484d0179bdc0c880fc7c17c345ea8701f57475b Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 12:54:12 +0200 Subject: [PATCH 05/13] build(ns-ui): bundle nethsecurity-ui#746 backup UI for the cutover MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pin ns-ui to the my-native backup UI (nethsecurity-ui#746) so a migrated appliance can browse/restore the backups it now sends to my collect. Temporary git-ref pin — replace with the released tag before merging (see Makefile note). --- packages/ns-ui/Makefile | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index ead3a2f41..03a14783d 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -8,11 +8,16 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-ui # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui PKG_VERSION:=2.22.2 -PKG_RELEASE:=1 +PKG_RELEASE:=2 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git -PKG_SOURCE_VERSION:=$(PKG_VERSION) +# TEMP (my cutover): pinned to nethsecurity-ui#746 (feat/backup-my-api) so the image +# bundles the my-native backup UI alongside this appliance cutover. The UI reads the +# new collect backup payload; on a device still on backupd it would break, hence it must +# ship together with this PR. BEFORE MERGING: revert to PKG_SOURCE_VERSION:=$(PKG_VERSION) +# and bump PKG_VERSION to the nethsecurity-ui release that carries #746. +PKG_SOURCE_VERSION:=661fd064ac5e24a7552b81a2916c0dbf327b1c87 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From cdcc4357b9cc6b679150ed3ae9db81390e1a7e92 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 15:59:52 +0200 Subject: [PATCH 06/13] feat(subscription): expose organization and enterprise plan for migrated units subscription-info threads organization.name explicitly; ns.subscription info returns it as the organization and defaults the plan to "Nethesis Enterprise" for enterprise units. Community units keep the real plan name untouched. --- packages/ns-api/files/ns.subscription | 9 +++++++++ packages/ns-plug/files/subscription-info | 1 + 2 files changed, 10 insertions(+) diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index dacadeb2c..c8f5c294e 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -61,6 +61,15 @@ def info(): type = u.get('ns-plug', 'config', 'type', default='') ret = {"server_id": data["id"], "systemd_id": data["uuid"], "plan": data["subscription"]["subscription_plan"]["name"], "expiration": expiration, "active": active, "type": type} + + # The new my has no per-system commercial plan. For enterprise units expose + # the organization explicitly (subscription-info threads organization.name) + # and default the plan label to "Nethesis Enterprise". Community units keep + # the real plan name untouched. + if type == "enterprise": + ret["organization"] = data.get("organization", "") + ret["plan"] = "Nethesis Enterprise" + return ret diff --git a/packages/ns-plug/files/subscription-info b/packages/ns-plug/files/subscription-info index e0d60ecad..363f2ff67 100755 --- a/packages/ns-plug/files/subscription-info +++ b/packages/ns-plug/files/subscription-info @@ -53,6 +53,7 @@ if [ "$type" = "enterprise" ]; then { uuid: ($s.system_id // ""), id: ($s.system_key // ""), + organization: ($s.organization.name // ""), subscription: { status: (if $s.registered and (($s.suspended // false) | not) then "valid" else "invalid" end), valid_until: null, From e700e64aad68913a40f89c90324fa63d3ae39256 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 15:59:52 +0200 Subject: [PATCH 07/13] build(ns-ui): bump pin to include the subscription view fix (nethsecurity-ui#746) --- packages/ns-ui/Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index 03a14783d..a730ef729 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -17,7 +17,7 @@ PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git # new collect backup payload; on a device still on backupd it would break, hence it must # ship together with this PR. BEFORE MERGING: revert to PKG_SOURCE_VERSION:=$(PKG_VERSION) # and bump PKG_VERSION to the nethsecurity-ui release that carries #746. -PKG_SOURCE_VERSION:=661fd064ac5e24a7552b81a2916c0dbf327b1c87 +PKG_SOURCE_VERSION:=bc03487db4673e9a497cd79b9de65addb39392f0 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 3538ae52296322f64ed4309d578c45c1de59ccb7 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:06:24 +0200 Subject: [PATCH 08/13] fix(subscription): surface already-registered on re-register register exits 2 on the backend 409 (the my system key is one-shot and never freed) and ns.subscription returns 'system_already_registered' instead of collapsing every failure into the generic 'invalid_secret_or_server_not_found', so the UI can explain a new system is needed. --- packages/ns-api/files/ns.subscription | 20 ++++++++++++-------- packages/ns-plug/files/register | 14 +++++++++++++- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index c8f5c294e..3f983f86c 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -20,17 +20,21 @@ def register(args): secret = args["secret"] - try: - subprocess.run(["/usr/sbin/register", "enterprise", secret, '5'], check=True, capture_output=True) + enterprise = subprocess.run(["/usr/sbin/register", "enterprise", secret, '5'], capture_output=True) + if enterprise.returncode == 0: return {"result": "success"} - except: - pass - try: - subprocess.run(["/usr/sbin/register", "community", secret, '5'], check=True, capture_output=True) + # Exit code 2: the system is already registered on my and its key cannot be + # reused (one-shot registration). Report it as-is instead of falling back to + # community, so the UI can guide the user to create a new system. + if enterprise.returncode == 2: + return utils.generic_error("system_already_registered") + + community = subprocess.run(["/usr/sbin/register", "community", secret, '5'], capture_output=True) + if community.returncode == 0: return {"result": "success"} - except: - return utils.generic_error("invalid_secret_or_server_not_found") + + return utils.generic_error("invalid_secret_or_server_not_found") def unregister(): try: diff --git a/packages/ns-plug/files/register b/packages/ns-plug/files/register index 1d877c416..33fe153ff 100755 --- a/packages/ns-plug/files/register +++ b/packages/ns-plug/files/register @@ -49,9 +49,21 @@ case "$type" in enterprise) url="https://my-proxy-prod.onrender.com/backend/api/" - register_resp=$(curl -s -m $timeout --retry 3 -L \ + register_resp=$(curl -s -m $timeout --retry 3 -L -w '\n%{http_code}' \ -H "Content-Type: application/json" -H "Accept: application/json" \ -d '{"system_secret": "'$secret'"}' "${url}systems/register") + http_code=$(echo "$register_resp" | sed -n '$p') + register_resp=$(echo "$register_resp" | sed '$d') + + # A system key is one-shot: once a system is registered on my it + # cannot be re-registered, because the key is never freed (licensing + # safeguard). The backend answers 409 in that case; surface it with a + # dedicated exit code (2) so ns.subscription can show a specific message + # instead of the generic "invalid secret / server not found". + if [ "$http_code" = "409" ]; then + >&2 echo "[ERROR] system already registered on my" + exit 2 + fi system_id=$(echo "$register_resp" | jq -r '.data.system_key // empty' 2>/dev/null) ;; From 462d511fba8455e587617addc96ec2a448b7dcd2 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:09:35 +0200 Subject: [PATCH 09/13] chore(ns-ui): bump pin to nethsecurity-ui#746 head (899b407) Align the bundled ns-ui with the re-register UX fix in nethsecurity-ui#746. --- packages/ns-ui/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index a730ef729..00dbf780b 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-ui # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui PKG_VERSION:=2.22.2 -PKG_RELEASE:=2 +PKG_RELEASE:=3 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git @@ -17,7 +17,7 @@ PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git # new collect backup payload; on a device still on backupd it would break, hence it must # ship together with this PR. BEFORE MERGING: revert to PKG_SOURCE_VERSION:=$(PKG_VERSION) # and bump PKG_VERSION to the nethsecurity-ui release that carries #746. -PKG_SOURCE_VERSION:=bc03487db4673e9a497cd79b9de65addb39392f0 +PKG_SOURCE_VERSION:=899b407dc9ab774fc1b45e4b727c9c3b4bdf1198 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 2728be96b1c16dd1d641b3643fff98dd400012f7 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 12:22:00 +0200 Subject: [PATCH 10/13] chore(ns-ui): bump pin to nethsecurity-ui#746 head (7402e3b) Realign the bundled ns-ui after the prettier formatting fix. --- packages/ns-ui/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index 00dbf780b..a3c2cb12d 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-ui # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui PKG_VERSION:=2.22.2 -PKG_RELEASE:=3 +PKG_RELEASE:=4 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git @@ -17,7 +17,7 @@ PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git # new collect backup payload; on a device still on backupd it would break, hence it must # ship together with this PR. BEFORE MERGING: revert to PKG_SOURCE_VERSION:=$(PKG_VERSION) # and bump PKG_VERSION to the nethsecurity-ui release that carries #746. -PKG_SOURCE_VERSION:=899b407dc9ab774fc1b45e4b727c9c3b4bdf1198 +PKG_SOURCE_VERSION:=7402e3b0969c1ba933cff3e874469a7d5b6a165a PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 53e8a5d7c2bed53f5fa995c261a4c0e6420fbad5 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:46:54 +0200 Subject: [PATCH 11/13] feat(subscription): expose system_url for enterprise units MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ns.subscription info now returns system_url (my-proxy-prod.onrender.com/systems/) for enterprise, so the UI can link the System ID to the portal — parity with ns8. --- packages/ns-api/files/ns.subscription | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index 3f983f86c..44d8b44ad 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -73,6 +73,7 @@ def info(): if type == "enterprise": ret["organization"] = data.get("organization", "") ret["plan"] = "Nethesis Enterprise" + ret["system_url"] = f"https://my-proxy-prod.onrender.com/systems/{data['uuid']}" return ret From a9920a236c47645555e5983bac73690c368f3006 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:46:54 +0200 Subject: [PATCH 12/13] chore(ns-ui): bump pin to nethsecurity-ui#746 head (4a0f10b) --- packages/ns-ui/Makefile | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index a3c2cb12d..6f671c058 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -8,7 +8,7 @@ include $(TOPDIR)/rules.mk PKG_NAME:=ns-ui # renovate: datasource=github-releases depName=NethServer/nethsecurity-ui PKG_VERSION:=2.22.2 -PKG_RELEASE:=4 +PKG_RELEASE:=5 PKG_SOURCE_PROTO:=git PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git @@ -17,7 +17,7 @@ PKG_SOURCE_URL:=https://github.com/NethServer/nethsecurity-ui.git # new collect backup payload; on a device still on backupd it would break, hence it must # ship together with this PR. BEFORE MERGING: revert to PKG_SOURCE_VERSION:=$(PKG_VERSION) # and bump PKG_VERSION to the nethsecurity-ui release that carries #746. -PKG_SOURCE_VERSION:=7402e3b0969c1ba933cff3e874469a7d5b6a165a +PKG_SOURCE_VERSION:=4a0f10b68f8b4be6e719fb24ae638263da001fc5 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 62f055d88e12be22843179ca9809f39b9a5d17e2 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 14:24:40 +0200 Subject: [PATCH 13/13] feat(subscription): expose community system_url too (parity with ns8) Community units now get system_url=my.nethserver.com/servers/ so the UI links the System ID for community as well, matching the enterprise/ns8 behavior. --- packages/ns-api/files/ns.subscription | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index 44d8b44ad..3d37207b7 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -74,6 +74,11 @@ def info(): ret["organization"] = data.get("organization", "") ret["plan"] = "Nethesis Enterprise" ret["system_url"] = f"https://my-proxy-prod.onrender.com/systems/{data['uuid']}" + else: + # Community: link the system to its my.nethserver.com page, matching ns8. + sub_id = (data.get("subscription") or {}).get("id") + if sub_id: + ret["system_url"] = f"https://my.nethserver.com/servers/{sub_id}" return ret