From c478c011dea534ac3ad3f591e087ab7d5a9efae9 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 23 Apr 2026 13:48:42 +0200 Subject: [PATCH 01/26] 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 5442e0146..a54e4a130 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 b72cdd81dd83edc4755bb3070e9d3082093e35d6 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 17 Jun 2026 10:37:00 +0200 Subject: [PATCH 02/26] 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 3abf258f9..a907c75b5 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 dcb31dce65994f43e920b29775b76c465d9f69b6 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 19 Jun 2026 09:45:31 +0200 Subject: [PATCH 03/26] 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 a907c75b5..0151c2702 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 81960e02a441e15cdb6d16652b61911cbefc142c Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 12:41:15 +0200 Subject: [PATCH 04/26] 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 0151c2702..c3e35cbbd 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 673ad6e82c14b59aad147f85eb4b2fed16066fae Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 12:54:12 +0200 Subject: [PATCH 05/26] 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 | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index 911e4a8d5..e70253baa 100644 --- a/packages/ns-ui/Makefile +++ b/packages/ns-ui/Makefile @@ -12,7 +12,12 @@ PKG_RELEASE:=3 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 908d6b39a1655348be1ecbafb7f778a9ce5522ab Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 15:59:52 +0200 Subject: [PATCH 06/26] 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 3f2a819682da834281dd353dd789136dc7b8e057 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 29 Jun 2026 15:59:52 +0200 Subject: [PATCH 07/26] 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 e70253baa..10d2b42d7 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 e7a054e6a01cab89dc1f7abe9cd59427b3f25172 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:06:24 +0200 Subject: [PATCH 08/26] 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 5ebe172101026737c6e93c5ff7ad6da3597d5094 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:09:35 +0200 Subject: [PATCH 09/26] 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 | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/ns-ui/Makefile b/packages/ns-ui/Makefile index 10d2b42d7..2501040eb 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:=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 6ee2e67494a1137597f3690e644da588ce9f7168 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 12:22:00 +0200 Subject: [PATCH 10/26] 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 2501040eb..d068b0933 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.23.1 -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 244f420c1b5e54a2499b20c2eba66449d3277add Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:46:54 +0200 Subject: [PATCH 11/26] 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 85df58ac0aae9e00079f36e350f82c17db32f965 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:46:54 +0200 Subject: [PATCH 12/26] 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 d068b0933..3b00a5495 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.23.1 -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 ee2f0b225bef68157b4ea26e9a0474a64146453a Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 14:24:40 +0200 Subject: [PATCH 13/26] 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 From 60ff1bcc1357fd2010c1d6e30601468142d38859 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 7 Jul 2026 11:06:00 +0200 Subject: [PATCH 14/26] fix(subscription): send heartbeat before inventory on register register sent the inventory before the heartbeat, so a freshly-registered unit stayed unknown/pending on my until the (slower) inventory completed. Send the near-instant heartbeat first so the system flips to active right away; the inventory follows. Registration result is still reported on the heartbeat, as before. --- packages/ns-plug/files/register | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/ns-plug/files/register b/packages/ns-plug/files/register index 33fe153ff..2e1b2b723 100755 --- a/packages/ns-plug/files/register +++ b/packages/ns-plug/files/register @@ -101,10 +101,12 @@ uci set ns-plug.config.secret="$secret" uci set ns-plug.config.repository_url="https://$system_id:$secret@distfeed.nethesis.it/repository/$type/nethsecurity" uci commit ns-plug reload_config -# Register the machine by sending the inventory for the first time -send-inventory +# Send the heartbeat first: it is near-instant and flips the system to +# "active" on my right away; the (slower) inventory follows. Registration +# success is still reported on the heartbeat result, as before. send-heartbeat exit_code=$? +send-inventory # Execute register hooks for script in $(find /usr/share/ns-plug/hooks/register -maxdepth 1 -executable -type f,l | sort); do From d99fd2d55098aeeaee0574fdd51cf00ddbcf9337 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 8 Jul 2026 12:13:57 +0200 Subject: [PATCH 15/26] feat(ns-api): dedalo login via My Nethesis device pairing ns.dedalo gains oidc-start/oidc-poll: the unit starts a device pairing on the hotspot manager, hands the verification URL to the browser and polls for the session token, then connects exactly like the password login did. Remote curl calls get timeouts; account name and user are stored in uci so the UI can show who the unit is linked to, and are cleared on password login and unregister. --- packages/ns-api/files/ns.dedalo | 112 +++++++++++++++++++++++++++++--- 1 file changed, 102 insertions(+), 10 deletions(-) diff --git a/packages/ns-api/files/ns.dedalo b/packages/ns-api/files/ns.dedalo index 210257c11..b91e7d51c 100755 --- a/packages/ns-api/files/ns.dedalo +++ b/packages/ns-api/files/ns.dedalo @@ -19,6 +19,7 @@ from euci import EUci tmp_dir = "/var/run/" token_file = f"{tmp_dir}/dedalo_token" +pairing_file = f"{tmp_dir}/dedalo_pairing.json" opts = ["network", "hotspot_id", "unit_name", "unit_description", "interface"] ## Utilities @@ -45,17 +46,10 @@ def setup(u): def login(args): u = EUci() try: - p = subprocess.run(['curl', '-L', '--url', f'https://{args["host"]}/api/login', '--header', 'Content-Type: application/json', '--data-binary', json.dumps(args)], check=True, capture_output=True, text=True) + p = subprocess.run(['curl', '-L', '-m', '15', '--connect-timeout', '5', '--url', f'https://{args["host"]}/api/login', '--header', 'Content-Type: application/json', '--data-binary', json.dumps(args)], check=True, capture_output=True, text=True) resp = json.loads(p.stdout) if 'token' in resp: - setup(u) - u.set("dedalo", "config", "splash_page", f'http://{args["host"]}/wings') - u.set("dedalo", "config", "aaa_url", f'https://{args["host"]}/wax/aaa') - u.set("dedalo", "config", "api_url", f'https://{args["host"]}/api') - u.commit("dedalo") - os.makedirs(tmp_dir, exist_ok = True) - with open(token_file, "w") as fp: - fp.write(resp["token"]) + _connect_to_host(u, args["host"], resp["token"]) return {"response": "success"} else: return utils.generic_error("login_failed") @@ -63,6 +57,87 @@ def login(args): print(e, file=sys.stderr) return {"success": False} + +def _connect_to_host(u, host, token, account_name="", account_user=""): + # same side effects as a successful password login: point the unit at + # the chosen hotspot manager and store the session token; the account + # info (from OIDC pairing) is kept to show who the unit is linked to + setup(u) + u.set("dedalo", "config", "splash_page", f'http://{host}/wings') + u.set("dedalo", "config", "aaa_url", f'https://{host}/wax/aaa') + u.set("dedalo", "config", "api_url", f'https://{host}/api') + for opt, value in (("account_name", account_name), ("account_user", account_user)): + if value: + u.set("dedalo", "config", opt, value) + else: + try: + u.delete("dedalo", "config", opt) + except: + pass + u.commit("dedalo") + os.makedirs(tmp_dir, exist_ok = True) + with open(token_file, "w") as fp: + fp.write(token) + +def oidc_start(args): + host = args.get("host") or "my.nethspot.com" + u = EUci() + unit_name = u.get("dedalo", "config", "unit_name", default="") + if not unit_name: + with open('/proc/sys/kernel/hostname', 'r') as fp: + unit_name = fp.read().strip() + try: + p = subprocess.run(['curl', '-s', '-L', '-m', '15', '--connect-timeout', '5', '-X', 'POST', '-w', '\n%{http_code}', '--url', f'https://{host}/api/auth/oidc/device/start', '--header', 'Content-Type: application/json', '--data-binary', json.dumps({"unit_name": unit_name})], check=True, capture_output=True, text=True) + body, _, http_code = p.stdout.rpartition('\n') + if http_code == '404': + # hotspot manager without OIDC device pairing support + return utils.generic_error("oidc_not_supported") + resp = json.loads(body) + except Exception as e: + print(e, file=sys.stderr) + return utils.generic_error("pairing_start_failed") + if 'device_code' not in resp or 'verification_url' not in resp: + return utils.generic_error("pairing_start_failed") + # the device_code stays on the unit: the browser only ever sees the + # verification_url (carrying the public pair_id) + os.makedirs(tmp_dir, exist_ok = True) + fd = os.open(pairing_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) + with os.fdopen(fd, 'w') as fp: + json.dump({"host": host, "device_code": resp["device_code"]}, fp) + return { + "verification_url": resp["verification_url"], + "expires_in": resp.get("expires_in", 600), + "interval": resp.get("interval", 2), + } + +def oidc_poll(): + u = EUci() + try: + with open(pairing_file, 'r') as fp: + pairing = json.load(fp) + except: + return utils.generic_error("no_pairing_in_progress") + host = pairing["host"] + try: + p = subprocess.run(['curl', '-s', '-L', '-m', '15', '--connect-timeout', '5', '--url', f'https://{host}/api/auth/oidc/device/poll', '--header', 'Content-Type: application/json', '--data-binary', json.dumps({"device_code": pairing["device_code"]})], check=True, capture_output=True, text=True) + resp = json.loads(p.stdout) + except Exception as e: + # transient error talking to the hotspot manager: keep polling + print(e, file=sys.stderr) + return {"status": "pending"} + status = resp.get("status", "") + if status == "ready": + os.remove(pairing_file) + _connect_to_host(u, host, resp["token"], resp.get("account_name", ""), resp.get("logged_by", "")) + return {"status": "success", "account_name": resp.get("account_name", "")} + if status == "failed": + os.remove(pairing_file) + return {"status": "failed", "error": resp.get("error", "unknown")} + if status == "expired": + os.remove(pairing_file) + return {"status": "expired"} + return {"status": "pending"} + def list_sessions(): process = subprocess.run(["/usr/bin/dedalo", "query", "list"], capture_output=True, text=True) if not process.stdout: @@ -131,7 +206,7 @@ def list_parents(): u = EUci() try: api_url = u.get("dedalo", "config", "api_url") - p = subprocess.run(['curl', '-L', '-s', '--url', f'{api_url}/hotspots', '--header', f"Token: {_get_token()}"], capture_output=True, text=True) + p = subprocess.run(['curl', '-L', '-s', '-m', '15', '--connect-timeout', '5', '--url', f'{api_url}/hotspots', '--header', f"Token: {_get_token()}"], capture_output=True, text=True) resp = json.loads(p.stdout) for p in resp["data"]: parents.append({"id": p["id"], "name": p["name"], "description": p["description"]}) @@ -148,6 +223,12 @@ def unregister(): except Exception as e: print(e, file=sys.stderr) return utils.generic_error("unregister_failed") + try: + u.delete("dedalo", "config", "account_name") + u.delete("dedalo", "config", "account_user") + u.commit("dedalo") + except: + pass try: firewall.delete_linked_sections(EUci(), "dedalo/config") subprocess.run(["/sbin/ifdown", "dedalo"], capture_output=True, check=True) @@ -178,6 +259,10 @@ def get_configuration(): with open('/proc/sys/kernel/hostname', 'r') as fp: ret["unit_name"] = fp.read().strip() ret["connected"] = os.path.exists(token_file) + ret["account_name"] = u.get("dedalo", "config", "account_name", default="") + ret["account_user"] = u.get("dedalo", "config", "account_user", default="") + api_url = u.get("dedalo", "config", "api_url", default="") + ret["manager_host"] = api_url.replace("https://", "").replace("/api", "") return {"configuration": ret} def set_configuration(args): @@ -259,6 +344,8 @@ cmd = sys.argv[1] if cmd == 'list': print(json.dumps({ "login": {"host": "my.nethspot.com", "username": "myuser", "password": "mypassword"}, + "oidc-start": {"host": "my.nethspot.com"}, + "oidc-poll": {}, "list-sessions": {}, "list-parents": {}, "list-devices": {}, @@ -285,6 +372,11 @@ else: elif action == "login": args = json.loads(sys.stdin.read()) ret = login(args) + elif action == "oidc-start": + args = json.loads(sys.stdin.read()) + ret = oidc_start(args) + elif action == "oidc-poll": + ret = oidc_poll() elif action == "set-configuration": args = json.loads(sys.stdin.read()) ret = set_configuration(args) From d88737d5ec73ea72656d7c239596b2b1b2ae9832 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 8 Jul 2026 12:13:57 +0200 Subject: [PATCH 16/26] chore(ns-ui): bump pin to the My Nethesis hotspot login --- 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 3b00a5495..2c163be0c 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.23.1 -PKG_RELEASE:=5 +PKG_RELEASE:=6 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:=4a0f10b68f8b4be6e719fb24ae638263da001fc5 +PKG_SOURCE_VERSION:=9209ee9bdc426f109700df33bdd9ebdaf5c3ac3c PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From ded72ca3fb772889c7a0b7af492de3513f24b6c1 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 8 Jul 2026 17:49:53 +0200 Subject: [PATCH 17/26] chore(ns-ui): bump pin to the hotspot manager link --- 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 2c163be0c..b1d8aa1d6 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.23.1 -PKG_RELEASE:=6 +PKG_RELEASE:=7 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:=9209ee9bdc426f109700df33bdd9ebdaf5c3ac3c +PKG_SOURCE_VERSION:=29d98833ea1dd94c2916abfd17f70b3f973c38ed PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 3cceb0bc952711c251b5ceab9a308fd405d0e003 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 9 Jul 2026 10:39:58 +0200 Subject: [PATCH 18/26] chore(ns-ui): bump pin to the refined My Nethesis login UX --- 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 b1d8aa1d6..e572e9ef7 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.23.1 -PKG_RELEASE:=7 +PKG_RELEASE:=8 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:=29d98833ea1dd94c2916abfd17f70b3f973c38ed +PKG_SOURCE_VERSION:=536b2fd2b10bcc29735c73a5d91b26d41c8f9ab5 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 22c191829e7ce2d3ca404f26f2f6a3f583be8aae Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 9 Jul 2026 12:08:39 +0200 Subject: [PATCH 19/26] fix(ns-plug): log send failures to syslog --- packages/ns-plug/files/remote-backup | 5 +++-- packages/ns-plug/files/send-backup | 10 ++++++++-- packages/ns-plug/files/send-heartbeat | 8 ++++++-- packages/ns-plug/files/send-inventory | 10 ++++++++-- 4 files changed, 25 insertions(+), 8 deletions(-) diff --git a/packages/ns-plug/files/remote-backup b/packages/ns-plug/files/remote-backup index 829ee40b3..b54d087d3 100755 --- a/packages/ns-plug/files/remote-backup +++ b/packages/ns-plug/files/remote-backup @@ -55,8 +55,9 @@ if [ "$TYPE" = "enterprise" ]; then 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" + # so the caller can inspect the error payload. --show-error keeps + # curl's own message (DNS, TLS, timeout) on stderr despite --silent. + curl_args="--silent --show-error --location-trusted --fail-with-body --user $SYSTEM_ID:$SYSTEM_SECRET" case "$cmd" in list) diff --git a/packages/ns-plug/files/send-backup b/packages/ns-plug/files/send-backup index 873525eb2..7a501ae1a 100755 --- a/packages/ns-plug/files/send-backup +++ b/packages/ns-plug/files/send-backup @@ -20,14 +20,20 @@ function send { if [ -s $PASSPHRASE ]; then # send encrypted backup gpg --batch -c --yes --passphrase-file $PASSPHRASE $BACKUP - remote-backup upload "$BACKUP.gpg" + # cron discards job output: surface upload failures in syslog. + # The md5 marker is not saved, so the next run retries. + if ! err=$(remote-backup upload "$BACKUP.gpg" 2>&1); then + logger -t send-backup "backup upload failed: $err" + rm -f "$BACKUP" "$BACKUP.gpg" + exit 1 + fi mv $MD5 $MD5_LAST else # password not set, abort upload exit 4 fi rm -f "$BACKUP" "$BACKUP.gpg" - exit $? + exit 0 } SYSTEM_ID=$(uci -q get ns-plug.config.system_id) diff --git a/packages/ns-plug/files/send-heartbeat b/packages/ns-plug/files/send-heartbeat index 8f4d02c0e..a7706e750 100755 --- a/packages/ns-plug/files/send-heartbeat +++ b/packages/ns-plug/files/send-heartbeat @@ -35,9 +35,13 @@ case "$TYPE" in exit 0 fi - /usr/bin/curl -m 30 --retry 3 -L -s -X POST \ + # cron discards job output: surface failures (DNS, TLS, + # timeouts, HTTP errors) in syslog via logger. + err=$(/usr/bin/curl -m 30 --retry 3 -L -sSf -X POST \ --user "$SYSTEM_ID:$SYSTEM_SECRET" \ - "$COLLECT_URL/heartbeat" >/dev/null + -o /dev/null -w 'HTTP %{http_code}' \ + "$COLLECT_URL/heartbeat" 2>&1) || \ + logger -t send-heartbeat "heartbeat send failed: $err" ;; community) URL=$(uci -q get ns-plug.config.alerts_url)"heartbeats/store" diff --git a/packages/ns-plug/files/send-inventory b/packages/ns-plug/files/send-inventory index b6658f672..eee533c32 100755 --- a/packages/ns-plug/files/send-inventory +++ b/packages/ns-plug/files/send-inventory @@ -34,13 +34,19 @@ case "$TYPE" in exit 0 fi - /usr/sbin/phonehome | /usr/bin/curl -m 180 --retry 3 -L -s -X POST \ + # cron discards job output: surface failures (DNS, TLS, + # timeouts, HTTP errors) in syslog via logger. + err=$(/usr/sbin/phonehome | /usr/bin/curl -m 180 --retry 3 -L -sSf -X POST \ --user "$SYSTEM_ID:$SYSTEM_SECRET" \ -H "Content-Type: application/json" \ - --data-binary @- "$COLLECT_URL/inventory" >/dev/null + --data-binary @- \ + -o /dev/null -w 'HTTP %{http_code}' \ + "$COLLECT_URL/inventory" 2>&1) if [ $? -eq 0 ]; then status="success" + else + logger -t send-inventory "inventory send failed: $err" fi ;; community) From f0e0bb4af84fee208f14e5ebb0a354d00a2c3fe7 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 9 Jul 2026 14:49:18 +0200 Subject: [PATCH 20/26] fix(ns-api): return expected register/oidc errors as validation errors --- packages/ns-api/files/ns.dedalo | 14 ++++++++++---- packages/ns-api/files/ns.subscription | 8 +++++--- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/ns-api/files/ns.dedalo b/packages/ns-api/files/ns.dedalo index b91e7d51c..546eb7f53 100755 --- a/packages/ns-api/files/ns.dedalo +++ b/packages/ns-api/files/ns.dedalo @@ -89,15 +89,18 @@ def oidc_start(args): try: p = subprocess.run(['curl', '-s', '-L', '-m', '15', '--connect-timeout', '5', '-X', 'POST', '-w', '\n%{http_code}', '--url', f'https://{host}/api/auth/oidc/device/start', '--header', 'Content-Type: application/json', '--data-binary', json.dumps({"unit_name": unit_name})], check=True, capture_output=True, text=True) body, _, http_code = p.stdout.rpartition('\n') + # Expected outcomes of the user-provided host (manager without OIDC + # support, wrong/unreachable host) are validation errors: the UI + # shows them inline without the global error toast. if http_code == '404': # hotspot manager without OIDC device pairing support - return utils.generic_error("oidc_not_supported") + return utils.validation_error("host", "oidc_not_supported") resp = json.loads(body) except Exception as e: print(e, file=sys.stderr) - return utils.generic_error("pairing_start_failed") + return utils.validation_error("host", "pairing_start_failed") if 'device_code' not in resp or 'verification_url' not in resp: - return utils.generic_error("pairing_start_failed") + return utils.validation_error("host", "pairing_start_failed") # the device_code stays on the unit: the browser only ever sees the # verification_url (carrying the public pair_id) os.makedirs(tmp_dir, exist_ok = True) @@ -132,7 +135,10 @@ def oidc_poll(): return {"status": "success", "account_name": resp.get("account_name", "")} if status == "failed": os.remove(pairing_file) - return {"status": "failed", "error": resp.get("error", "unknown")} + # NB: don't name the key "error" — a top-level "error" key makes + # nethsecurity-api reply 500 (application-error convention) and the + # failed status would never reach the UI as data. + return {"status": "failed", "reason": resp.get("error", "unknown")} if status == "expired": os.remove(pairing_file) return {"status": "expired"} diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index 3d37207b7..3dc66039e 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -25,10 +25,12 @@ def register(args): return {"result": "success"} # 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. + # reused (one-shot registration). Report it as a validation error on the + # secret (expected outcome of user input, no global error toast) 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") + return utils.validation_error("secret", "system_already_registered") community = subprocess.run(["/usr/sbin/register", "community", secret, '5'], capture_output=True) if community.returncode == 0: From 0dfd225c84ed1cb1c08a5e7cbdc0ffb1d6d22ba1 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 9 Jul 2026 14:51:21 +0200 Subject: [PATCH 21/26] chore(ns-ui): bump pin to the inline login errors 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 e572e9ef7..bad63d915 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.23.1 -PKG_RELEASE:=8 +PKG_RELEASE:=9 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:=536b2fd2b10bcc29735c73a5d91b26d41c8f9ab5 +PKG_SOURCE_VERSION:=0ef3710d553fb883213ce85dbbfc5ffb865b4b5a PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From f9999a1dad537b490167fba9df79f1367576b3d1 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 13 Jul 2026 12:22:57 +0200 Subject: [PATCH 22/26] chore(ns-ui): bump pin to the session status card --- 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 bad63d915..09d4a4152 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.23.1 -PKG_RELEASE:=9 +PKG_RELEASE:=10 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:=0ef3710d553fb883213ce85dbbfc5ffb865b4b5a +PKG_SOURCE_VERSION:=11561d797cd17d8beadb457090e701eb95645be4 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 545e81a5ffd350064535d8d2b012e3abe48c7131 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 14 Jul 2026 14:05:44 +0200 Subject: [PATCH 23/26] feat(ns-api): expose the my system name in subscription info --- packages/ns-api/files/ns.subscription | 3 +++ packages/ns-plug/files/subscription-info | 1 + 2 files changed, 4 insertions(+) diff --git a/packages/ns-api/files/ns.subscription b/packages/ns-api/files/ns.subscription index 3dc66039e..82f12bdb3 100755 --- a/packages/ns-api/files/ns.subscription +++ b/packages/ns-api/files/ns.subscription @@ -74,6 +74,9 @@ def info(): # the real plan name untouched. if type == "enterprise": ret["organization"] = data.get("organization", "") + # The system name given on my at creation time (threaded by + # subscription-info from the collect /info payload). + ret["system_name"] = data.get("system_name", "") ret["plan"] = "Nethesis Enterprise" ret["system_url"] = f"https://my-proxy-prod.onrender.com/systems/{data['uuid']}" else: diff --git a/packages/ns-plug/files/subscription-info b/packages/ns-plug/files/subscription-info index 363f2ff67..59478f717 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 // ""), + system_name: ($s.name // ""), organization: ($s.organization.name // ""), subscription: { status: (if $s.registered and (($s.suspended // false) | not) then "valid" else "invalid" end), From 01b1fc8f3085f589392f75ae320146c6fd92e8bb Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 14 Jul 2026 14:06:26 +0200 Subject: [PATCH 24/26] chore(ns-ui): bump pin to the system name on subscription --- 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 09d4a4152..8cef1ecb5 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.23.1 -PKG_RELEASE:=10 +PKG_RELEASE:=11 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:=11561d797cd17d8beadb457090e701eb95645be4 +PKG_SOURCE_VERSION:=22329204b8f9011f8295661505d9755fa2ab0364 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From 949538affb9c7440c7b812e23bbfc0b24c774bdb Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 20 Jul 2026 15:31:21 +0200 Subject: [PATCH 25/26] chore(ns-ui): bump pin to nethsecurity-ui#746 head (c6afb3a) Follow the rebase of nethsecurity-ui#746 onto its current main. --- 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 8cef1ecb5..f3ff08227 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.23.1 -PKG_RELEASE:=11 +PKG_RELEASE:=12 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:=22329204b8f9011f8295661505d9755fa2ab0364 +PKG_SOURCE_VERSION:=c6afb3a79d0db8bfb098ff23a56fa453e912b883 PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip From ec8ade5b0df43c60905d6405201ec2c8b5a37720 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 20 Jul 2026 16:23:04 +0200 Subject: [PATCH 26/26] chore(ns-ui): bump pin to nethsecurity-ui#746 head (2cacc72) Follow the lint fix on 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 f3ff08227..529053f02 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.23.1 -PKG_RELEASE:=12 +PKG_RELEASE:=13 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:=c6afb3a79d0db8bfb098ff23a56fa453e912b883 +PKG_SOURCE_VERSION:=2cacc724f98367ed39b04d496ca9cca42702825d PKG_SOURCE_SUBDIR:=nethsecurity-ui-$(PKG_SOURCE_VERSION) PKG_BUILD_DIR:=$(BUILD_DIR)/$(PKG_SOURCE_SUBDIR) PKG_MIRROR_HASH:=skip