From 5ecc7d11176cd82bc671de94d156d17a0e734d3d Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 23 Apr 2026 15:21:28 +0200 Subject: [PATCH 01/12] feat(migration): cut cluster over to my collect MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Final migration PR for ns8-core, mirror of the nethsecurity cutover on NethServer/nethsecurity#1609. After this commit, nsent clusters talk directly to the my collect API with native my credentials; the legacy my.nethesis.it /api/ and /isa/ endpoints and the /proxy/* translation routes are gone from the hot path. nscom clusters keep using the legacy my.nethserver.com / backupd.nethesis.it infrastructure — that is explicitly out of scope for the my migration. Credential rotation (existing nsent clusters): - New /var/lib/nethserver/cluster/bin/migrate-to-my: idempotent bash one-shot, gated on provider=nsent. Calls the translation proxy's /proxy/credentials with the legacy Basic-Auth pair, reads back the mapped my system_key/system_secret and atomically HSETs cluster/subscription in Redis. Preserves the legacy pair under legacy_system_id / legacy_auth_token for audit and manual rollback, (re)asserts collect_url, and sets the migrated='1' marker that stops the helper from running again. A single HSET guarantees no half-migrated state. - send-cluster-backup / send-heartbeat / send-inventory invoke migrate-to-my up front on the nsent branch so the first successful cron/timer tick flips a pre-migration cluster over. Native my registration (fresh nsent subscriptions): - set-subscription subscribe_nsent now POSTs my.nethesis.it/backend/api/systems/register with {system_secret: } and stores the returned system_key as cluster/subscription system_id. collect_url is written alongside the VPN metadata and migrated='1' is set so migrate-to-my is a no-op. Community subscribe is untouched; it keeps using the dartagnan endpoint on my.nethserver.com. - terminate_nsent routes /api/Utils/freekey through the preserved legacy_system_id / legacy_auth_token pair when available, so migrated clusters can still release their slot on my-old at unregister time. On pre-migration clusters behaviour is unchanged. - get-subscription fetch_subscription_info_nsent queries collect /info with the rotated credentials and synthesises the legacy envelope the UI consumes (system_url, plan_name, expires, expire_date, status, with_remote_support). 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 expires/expire_date to an "unbounded" default — the UI keeps rendering the same row layout without a KeyError. A pre-migration cluster falls back to a "pending" snapshot instead of raising, so the subscription page stays usable during the first rotation window. Single-path send scripts: - send-heartbeat nsent: POST $collect_url/heartbeat with native Basic-Auth. The primary my.nethesis.it/isa/heartbeats/store and the proxy shadow are gone; nscom continues on ${dartagnan_url}/machine/heartbeats/store. - send-inventory nsent: POST $collect_url/inventory with a phonehome payload. The primary my.nethesis.it/isa/inventory path, the my-old /api/systems/info registration-date refresh and the proxy shadow are gone; nscom continues on ${dartagnan_url}/machine/inventories/store. - send-cluster-backup: POST $collect_url/backups for nsent, $TYPE-less community path preserved via a dedicated nscom branch (backupd.nethesis.it/community/api/v2/backup/). Failure mode: - A /proxy/credentials outage during an nsent cluster's upgrade window leaves the cluster on legacy credentials against collect, which returns 401. migrate-to-my is re-invoked every time one of the send-* services fires, so the cluster recovers automatically once the proxy is back up. Accepted trade-off: no dual-mode in the scripts; the simpler single-send path is preferred. --- .../get-subscription/10get_subscription | 43 ++++++++-- .../set-subscription/10set_subscription | 54 +++++++++---- .../lib/nethserver/cluster/bin/migrate-to-my | 81 +++++++++++++++++++ .../nethserver/cluster/bin/print-phonehome | 15 ++++ .../cluster/bin/send-cluster-backup | 78 +++++++++++------- .../lib/nethserver/cluster/bin/send-heartbeat | 31 ++++--- .../lib/nethserver/cluster/bin/send-inventory | 44 +++++----- 7 files changed, 258 insertions(+), 88 deletions(-) create mode 100755 core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription index e1c535d52a..e1371232c5 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription @@ -26,20 +26,49 @@ def _get_http_session(): return osession def fetch_subscription_info_nsent(rdb, attributes): + system_id = attributes['system_id'] auth_token = attributes['auth_token'] + collect_url = attributes.get('collect_url') + if not collect_url: + # Pre-migration cluster — migrate-to-my hasn't rotated yet. + # Return a minimal "unknown" snapshot rather than raising, so + # the UI still shows the system_id row instead of a 500. + return { + "auth_token": "********", + "system_url": f"https://my.nethesis.it/systems/{system_id}", + "plan_name": "-", + "expires": False, + "expire_date": "", + "status": "pending", + "with_remote_support": True, + } + + # The new my data model no longer exposes a subscription_plan or + # a valid_until per system: plan / expiration are an organization- + # level concern and are not surfaced by collect's /info yet. We + # fall back to "-" / "" / no expiration so the UI keeps rendering + # the usual row layout without hitting a KeyError. psession = _get_http_session() - myresp = psession.post("https://my.nethesis.it/api/systems/info", data={"secret":auth_token}) + myresp = psession.get( + f"{collect_url}/info", + auth=(system_id, auth_token), + headers={"Accept": "application/json"}, + ) myresp.raise_for_status() - dinfo = myresp.json() + sinfo = myresp.json().get("data", {}) + + registered = bool(sinfo.get("registered")) + suspended = bool(sinfo.get("suspended")) + org_name = (sinfo.get("organization") or {}).get("name") or "-" info = { "auth_token": "********", - "system_url": f"https://my.nethesis.it/#/server/{dinfo['id']}", - "plan_name": dinfo['subscription']['subscription_plan']['name'], - "expires": dinfo['subscription']['valid_until'] > 0, - "expire_date": str(dinfo['subscription']['valid_until']), - "status": "active", + "system_url": f"https://my.nethesis.it/systems/{sinfo.get('system_id', '')}", + "plan_name": org_name, + "expires": False, + "expire_date": "", + "status": "active" if registered and not suspended else "inactive", "with_remote_support": True, } return info diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription index aa98090a56..2721e23eb2 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription @@ -78,18 +78,27 @@ def terminate_subscription(rdb): return output def terminate_nsent(rdb, attributes): - psession = _get_http_session() - - freekeyresp = psession.post("https://my.nethesis.it/api/Utils/freekey", data={ - "lk": attributes["system_id"], - "secret":attributes["auth_token"], - }) - try: - ofreekey = freekeyresp.json() - except: - ofreekey = {} # Ignore remote request failures and disable client services, always. - - return ofreekey + # my collect does not expose an appliance-side unregister; + # destroying the subscription is an admin-driven action on the + # my UI. For clusters that came from a legacy rotation we still + # have the pre-rotation pair under legacy_system_id / + # legacy_auth_token — call the my-old /api/Utils/freekey so the + # old dashboard records the unit as gone. Native clusters have + # no legacy slot to release and skip this. The outer caller + # (terminate_subscription) clears cluster/subscription and + # publishes the event either way. + legacy_id = attributes.get("legacy_system_id") + legacy_secret = attributes.get("legacy_auth_token") + if legacy_id and legacy_secret: + psession = _get_http_session() + try: + psession.post( + "https://my.nethesis.it/api/Utils/freekey", + data={"lk": legacy_id, "secret": legacy_secret}, + ) + except Exception: + pass # best-effort; local cleanup still happens below + return {} def terminate_nscom(rdb, attributes): return {} @@ -97,18 +106,35 @@ def terminate_nscom(rdb, attributes): def subscribe_nsent(rdb, osubscription): auth_token = osubscription['auth_token'] + # New enterprise subscriptions go straight to the my collect + # registration endpoint. The my token the customer pastes is the + # full "my_." string: /systems/register validates + # it and returns the matching system_key, which we store as the + # system_id for compatibility with downstream callers (cluster + # events, send-* scripts, UI). Since the cluster now lands on + # the new my natively, migrated='1' is set from the start so the + # migrate-to-my helper never attempts a rotation through the + # translation proxy with credentials it cannot map. psession = _get_http_session() - myresp = psession.post("https://my.nethesis.it/api/systems/info", data={"secret": auth_token}) + myresp = psession.post( + "https://my.nethesis.it/backend/api/systems/register", + json={"system_secret": auth_token}, + headers={"Content-Type": "application/json", "Accept": "application/json"}, + ) myresp.raise_for_status() dinfo = myresp.json() - system_id = dinfo['uuid'] + system_id = dinfo.get("data", {}).get("system_key") + if not system_id: + raise KeyError("system_key") trx = rdb.pipeline() trx.hset("cluster/subscription", mapping={ "provider": "nsent", 'system_id': system_id, 'auth_token': auth_token, + 'collect_url': "https://my.nethesis.it/collect/api/systems", + 'migrated': "1", 'vpn_cert_cn': "C=IT, ST=PU, L=Pesaro, O=Nethesis, OU=Support, CN=Nethesis CA, name=sos, emailAddress=support@nethesis.it", 'support_user': 'nethsupport', }) diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my new file mode 100755 index 0000000000..419ee23115 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my @@ -0,0 +1,81 @@ +#!/bin/bash + +# +# Copyright (C) 2026 Nethesis S.r.l. +# SPDX-License-Identifier: GPL-3.0-or-later +# + +# +# Idempotent migration from legacy my.nethesis.it / backupd credentials +# to my collect native credentials, so the cluster can authenticate +# directly against the new my collect endpoints. +# +# Only nsent subscriptions are migrated. Community (nscom) has its own +# infrastructure (my.nethserver.com) and keeps using the legacy +# endpoints — no credential rotation is applicable there. +# +# cluster/subscription migrated='1' is the persistent marker. It is +# written by this script after a successful rotation, and also by +# set-subscription when a cluster subscribes with a brand-new my +# token (in that case no rotation is needed — the cluster lands on +# the new my directly). +# +# The HSET is a single call, so a partial write cannot leave the +# cluster in a half-migrated state. +# + +export REDIS_USER="default" +export REDIS_PASSWORD="default" +export REDIS_ADDRESS="127.0.0.1:6379" + +while IFS='=' read -r key value; do + declare "${key}"="${value}" +done < <(redis-hgetall cluster/subscription) + +# Already migrated — exit before touching the network. +[ "${migrated:-}" = "1" ] && exit 0 + +# Only enterprise subscriptions go through the my collect rotation. +[ "${provider:-}" = "nsent" ] || exit 0 + +# Unsubscribed clusters have nothing to rotate yet. +if [[ -z "${system_id:-}" || -z "${auth_token:-}" ]]; then + exit 0 +fi + +# Fetch mapped my credentials via the translation proxy. +resp=$(curl --silent --location-trusted --fail-with-body \ + --max-time 30 --retry 2 \ + --user "${system_id}:${auth_token}" \ + 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') +new_secret=$(echo "$resp" | jq -r '.data.system_secret // empty') +if [[ -z "$new_key" || -z "$new_secret" ]]; then + logger -t migrate-to-my "credentials missing in response" + exit 0 +fi + +# Timestamp the rotation so print-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. +redis-exec hset cluster/subscription \ + legacy_system_id "${system_id}" \ + legacy_auth_token "${auth_token}" \ + system_id "$new_key" \ + auth_token "$new_secret" \ + collect_url "https://my.nethesis.it/collect/api/systems" \ + migrated "1" \ + migrated_at "$migrated_at" >/dev/null || { + logger -t migrate-to-my "redis hset failed; will retry on next run" + exit 0 +} + +logger -t migrate-to-my "migrated to my collect credentials" +exit 0 diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome b/core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome index 75d65ad47b..3fbf834d9a 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome +++ b/core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome @@ -167,6 +167,20 @@ def node_facts(rdb): return ret +def migration_facts(rdb): + """Fingerprint of the my migration state. + + Populated only on nsent clusters that rotated through the + translation proxy or subscribed directly against the new my; used + by my to track fleet migration progress and decide when the proxy + can be decommissioned. Nscom clusters leave both fields null. + """ + sub = rdb.hgetall('cluster/subscription') or {} + return { + 'from_legacy_system_id': sub.get('legacy_system_id') or None, + 'migrated_at': sub.get('migrated_at') or None, + } + def main(): rdb = agent.redis_connect(privileged=True) if not rdb.get("cluster/anon_seed"): @@ -183,6 +197,7 @@ def main(): 'cluster': cluster_facts(), 'nodes': node_facts(rdb), 'modules': modules_facts(rdb), + 'migration': migration_facts(rdb), } } print(json.dumps(facts)) diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup b/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup index cf3090b32b..0fd6d4c0fb 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup @@ -5,9 +5,26 @@ # SPDX-License-Identifier: GPL-3.0-or-later # +# +# Upload the encrypted cluster backup to the subscription provider. +# +# Enterprise clusters (provider=nsent) post to the my collect +# endpoint with native my credentials. migrate-to-my runs up front +# so a cluster upgraded from the legacy path transparently flips +# over on the first successful rotation. +# +# Community clusters (provider=nscom) keep posting to +# backupd.nethesis.it exactly as before — that infrastructure has +# no counterpart on the new my and is out of scope for the +# migration. +# + set -e -# Parse subscription configuration from Redis +# Opportunistically rotate enterprise credentials before we use them. +# A no-op for nscom or already-migrated nsent clusters. +migrate-to-my + while IFS='=' read -r key value; do declare "${key}"="${value}" done < <(redis-hgetall cluster/subscription) @@ -18,14 +35,6 @@ function exit_error () exit 1 } -if [[ $provider == "nsent" ]]; then - type="enterprise" -elif [[ $provider == "nscom" ]]; then - type="community" -else - exit_error "Unsupported provider: ${provider}" -fi - if [[ -z "${system_id}" || -z "${auth_token}" ]]; then exit_error "Missing system_id or auth_token for provider" fi @@ -43,28 +52,37 @@ backup_hash=$(md5sum "${backup_file}" | awk '{ print $1 }') if [[ "${prev_backup_hash}" == "${backup_hash}" ]]; then echo "Backup file already uploaded, nothing to do" 1>&2 elif [[ -f "${encrypted_file}" ]]; then - curl \ - --silent \ - --location-trusted \ - --user "${system_id}:${auth_token}" \ - "https://backupd.nethesis.it/${type}/api/v2/backup/" \ - --upload-file "${encrypted_file}" >/dev/null - - # Temporary dual-send to new my.nethesis.it via the translation - # proxy, same pattern used by send-heartbeat / send-inventory. - # Enterprise only; best-effort (|| :) so a proxy outage does not - # block the primary upload already completed above. - # To be removed once the migration is complete. - if [[ "${provider}" == "nsent" ]]; then - /usr/bin/curl -m 900 --retry 3 -L -s -X POST \ - --user "${system_id}:${auth_token}" \ - -H "Content-Type: application/octet-stream" \ - -H "X-Filename: $(basename "${encrypted_file}")" \ - --data-binary "@${encrypted_file}" \ - https://my.nethesis.it/proxy/backup >/dev/null || : - fi + case "${provider}" in + nsent) + if [[ -z "${collect_url:-}" ]]; then + exit_error "Pre-migration cluster: collect_url not set; migrate-to-my will retry on next schedule" + fi + curl \ + --silent \ + --location-trusted \ + --fail-with-body \ + --max-time 900 \ + --retry 3 \ + --user "${system_id}:${auth_token}" \ + -X POST \ + -H "Content-Type: application/octet-stream" \ + -H "X-Filename: $(basename "${encrypted_file}")" \ + --data-binary "@${encrypted_file}" \ + "${collect_url}/backups" >/dev/null + ;; + nscom) + curl \ + --silent \ + --location-trusted \ + --user "${system_id}:${auth_token}" \ + "https://backupd.nethesis.it/community/api/v2/backup/" \ + --upload-file "${encrypted_file}" >/dev/null + ;; + *) + exit_error "Unsupported provider: ${provider}" + ;; + esac - # Backup upload successful, update the reference file echo "${backup_hash}" > backup/dump.md5 echo "Backup uploaded to provider ${provider} with hash ${backup_hash}" 1>&2 else diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat index 3e9090f47b..ed35ad6706 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat @@ -5,6 +5,15 @@ # SPDX-License-Identifier: GPL-3.0-or-later # +# +# Send the cluster heartbeat to the subscription provider. +# +# Enterprise clusters (provider=nsent) post to the my collect +# endpoint with native my credentials; see send-cluster-backup for +# the migrate-to-my flow. Community clusters (provider=nscom) keep +# hitting my.nethserver.com's dartagnan_url as before. +# + set -e period="${1:-0}" @@ -13,7 +22,9 @@ export REDIS_USER="default" export REDIS_PASSWORD="default" export REDIS_ADDRESS="127.0.0.1:6379" -# Parse subscription configuration from Redis +# Opportunistically rotate enterprise credentials. +migrate-to-my + while IFS='=' read -r key value; do declare "${key}"="${value}" done < <(redis-hgetall cluster/subscription) @@ -26,18 +37,14 @@ function exit_error () function send_heartbeat_nsent () { - jq -n -c --arg system_id "${system_id:?}" '{"lk":$system_id}' | \ - curl -m 180 --retry 3 -L -s \ - --header "Accept: application/json" \ - --header "Authorization: token ${auth_token:?}" \ - --header "Content-Type: application/json" \ - --data @- \ - "https://my.nethesis.it/isa/heartbeats/store" >/dev/null || : + if [[ -z "${collect_url:-}" ]]; then + # Pre-migration — migrate-to-my will retry on the next tick. + return 0 + fi - # Temporary send data to new endpoint - # To be removed when the migration to new my.nethesis.it will be completed - /usr/bin/curl -m 180 --retry 3 -L -s -X POST \ - --user "${system_id:?}:${auth_token:?}" https://my.nethesis.it/proxy/heartbeat >/dev/null || : + curl -m 30 --retry 3 -L -s -X POST \ + --user "${system_id:?}:${auth_token:?}" \ + "${collect_url}/heartbeat" >/dev/null || : } function send_heartbeat_nscom () diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory b/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory index 48a5d26816..8445d1ebaf 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory @@ -5,9 +5,20 @@ # SPDX-License-Identifier: GPL-3.0-or-later # +# +# Send the cluster inventory to the subscription provider. +# +# Enterprise clusters (provider=nsent) post a phonehome payload to +# the my collect endpoint with native my credentials. Community +# (provider=nscom) keeps hitting my.nethserver.com — out of scope +# for the my migration. +# + set -e -# Parse subscription configuration from Redis +# Opportunistically rotate enterprise credentials. +migrate-to-my + while IFS='=' read -r key value; do declare "${key}"="${value}" done < <(redis-hgetall cluster/subscription) @@ -20,34 +31,17 @@ function exit_error () function send_inventory_nsent () { - print-inventory-ns | jq -c \ - --arg system_id "${system_id:?}" \ - '{"data":{"lk":$system_id,"data":.}}' | \ - curl -m 180 --retry 3 -L -s \ - --header "Accept: application/json" \ - --header "Authorization: token ${auth_token:?}" \ - --header "Content-Type: application/json" \ - --data @- \ - "https://my.nethesis.it/isa/inventory/store/" >>/dev/null || : + if [[ -z "${collect_url:-}" ]]; then + # Pre-migration — migrate-to-my will retry on the next tick. + return 0 + fi - # Update the registration date after inventory was submitted for the - # first time: - jq -n -c \ - --arg auth_token "${auth_token:?}" \ - '{"secret":$auth_token}' | \ - curl -m 180 --retry 3 -L -s \ - --header "Content-Type: application/json" \ - --header "Accept: application/json" \ - --data @- \ - 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 print-phonehome | \ - curl -m 180 --retry 3 -L -s \ + curl -m 180 --retry 3 -L -s -X POST \ --user "${system_id:?}:${auth_token:?}" \ -H "Content-Type: application/json" \ - --data-binary @- https://my.nethesis.it/proxy/inventory >/dev/null || : + --data-binary @- \ + "${collect_url}/inventory" >/dev/null || : } function send_inventory_nscom () From e56e03317fd851b565199c9fd8b362904c9af002 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Wed, 17 Jun 2026 10:37:00 +0200 Subject: [PATCH 02/12] feat(migration): publish subscription-changed after my rotation So the metrics module rebuilds alert-proxy.env immediately and alerts flip to the native Mimir endpoint with the rotated credentials, instead of waiting for the next prometheus restart. --- core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my index 419ee23115..7b3e0e2ee9 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my +++ b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my @@ -77,5 +77,11 @@ redis-exec hset cluster/subscription \ exit 0 } +# Notify subscribers so the metrics module's subscription-changed handler +# rebuilds alert-proxy.env now (flipping alerts to the native Mimir endpoint +# with the rotated creds) instead of waiting for the next prometheus restart. +# Same channel/shape used by set-subscription. Best-effort. +redis-exec publish cluster/event/subscription-changed '{"action":"migrated"}' >/dev/null 2>&1 || : + logger -t migrate-to-my "migrated to my collect credentials" exit 0 From 13d26b082122d0519907eaaf1e0e6906f14952f5 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Fri, 19 Jun 2026 09:45:31 +0200 Subject: [PATCH 03/12] 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. --- .../cluster/actions/set-subscription/10set_subscription | 6 +++--- core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription index 2721e23eb2..0999c85eee 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription @@ -93,7 +93,7 @@ def terminate_nsent(rdb, attributes): psession = _get_http_session() try: psession.post( - "https://my.nethesis.it/api/Utils/freekey", + "https://my-proxy-prod.onrender.com/api/Utils/freekey", data={"lk": legacy_id, "secret": legacy_secret}, ) except Exception: @@ -117,7 +117,7 @@ def subscribe_nsent(rdb, osubscription): # translation proxy with credentials it cannot map. psession = _get_http_session() myresp = psession.post( - "https://my.nethesis.it/backend/api/systems/register", + "https://my-proxy-prod.onrender.com/backend/api/systems/register", json={"system_secret": auth_token}, headers={"Content-Type": "application/json", "Accept": "application/json"}, ) @@ -133,7 +133,7 @@ def subscribe_nsent(rdb, osubscription): "provider": "nsent", 'system_id': system_id, 'auth_token': auth_token, - 'collect_url': "https://my.nethesis.it/collect/api/systems", + 'collect_url': "https://my-proxy-prod.onrender.com/collect/api/systems", 'migrated': "1", 'vpn_cert_cn': "C=IT, ST=PU, L=Pesaro, O=Nethesis, OU=Support, CN=Nethesis CA, name=sos, emailAddress=support@nethesis.it", 'support_user': 'nethsupport', diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my index 7b3e0e2ee9..37edb51023 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my +++ b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my @@ -47,7 +47,7 @@ fi resp=$(curl --silent --location-trusted --fail-with-body \ --max-time 30 --retry 2 \ --user "${system_id}:${auth_token}" \ - 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 } @@ -70,7 +70,7 @@ redis-exec hset cluster/subscription \ legacy_auth_token "${auth_token}" \ system_id "$new_key" \ auth_token "$new_secret" \ - collect_url "https://my.nethesis.it/collect/api/systems" \ + collect_url "https://my-proxy-prod.onrender.com/collect/api/systems" \ migrated "1" \ migrated_at "$migrated_at" >/dev/null || { logger -t migrate-to-my "redis hset failed; will retry on next run" From cf0ccc3845ca5896e6dedcf127b05de5f33a75fc Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 09:50:14 +0200 Subject: [PATCH 04/12] fix(migration): rotate redis as privileged user, not read-only default MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit migrate-to-my and send-heartbeat exported REDIS_USER=default, a read-only user, so the rotation HSET failed with NoPermission. The send-* units run via runagent with the privileged cluster user — inherit it instead. --- .../imageroot/var/lib/nethserver/cluster/bin/migrate-to-my | 7 ++++--- .../var/lib/nethserver/cluster/bin/send-heartbeat | 4 ---- 2 files changed, 4 insertions(+), 7 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my index 37edb51023..d3b13be8d1 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my +++ b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my @@ -24,9 +24,10 @@ # cluster in a half-migrated state. # -export REDIS_USER="default" -export REDIS_PASSWORD="default" -export REDIS_ADDRESS="127.0.0.1:6379" +# Redis credentials are inherited from the invoking send-* unit's +# runagent environment (the privileged 'cluster' user and the leader +# address). Do NOT pin them to the read-only 'default' user here — the +# rotation HSET below needs write access. while IFS='=' read -r key value; do declare "${key}"="${value}" diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat index ed35ad6706..185b9e9f24 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat @@ -18,10 +18,6 @@ set -e period="${1:-0}" -export REDIS_USER="default" -export REDIS_PASSWORD="default" -export REDIS_ADDRESS="127.0.0.1:6379" - # Opportunistically rotate enterprise credentials. migrate-to-my From bd8dc8e5d8fde1b8ecfa5359ca4e57eedfb4281b Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:38:51 +0200 Subject: [PATCH 05/12] fix(subscription): render cluster subscription view on the new my get-subscription returns expire_date='' (the new my has no per-system expiry); the UI formatted it unconditionally so date-fns threw and the page stayed on the loading skeleton. Show 'No expiration' for empty/-1 expiry. Also expose the organization as company and default the plan to 'Nethesis Enterprise', mirroring the nsec subscription view. --- .../cluster/actions/get-subscription/10get_subscription | 9 +++++++-- core/ui/public/i18n/en/translation.json | 1 + core/ui/src/views/settings/SettingsSubscription.vue | 8 ++++++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription index e1371232c5..3e37dd053f 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription @@ -37,7 +37,8 @@ def fetch_subscription_info_nsent(rdb, attributes): return { "auth_token": "********", "system_url": f"https://my.nethesis.it/systems/{system_id}", - "plan_name": "-", + "plan_name": "Nethesis Enterprise", + "company": "-", "expires": False, "expire_date": "", "status": "pending", @@ -65,7 +66,11 @@ def fetch_subscription_info_nsent(rdb, attributes): info = { "auth_token": "********", "system_url": f"https://my.nethesis.it/systems/{sinfo.get('system_id', '')}", - "plan_name": org_name, + # The new my has no per-system plan; enterprise units are all + # "Nethesis Enterprise". The organization name is surfaced as the + # company, mirroring the nsec subscription view. + "plan_name": "Nethesis Enterprise", + "company": org_name, "expires": False, "expire_date": "", "status": "active" if registered and not suspended else "inactive", diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index 8330ac4fc2..1959a0c8c3 100644 --- a/core/ui/public/i18n/en/translation.json +++ b/core/ui/public/i18n/en/translation.json @@ -735,6 +735,7 @@ "authentication_token_placeholder": "Paste the token here", "system_id": "System ID", "plan_name": "Plan", + "company": "Company", "expire_date": "Expiration", "status": "Status", "remote_support": "Remote support", diff --git a/core/ui/src/views/settings/SettingsSubscription.vue b/core/ui/src/views/settings/SettingsSubscription.vue index aa3f99e12a..9ea4029440 100644 --- a/core/ui/src/views/settings/SettingsSubscription.vue +++ b/core/ui/src/views/settings/SettingsSubscription.vue @@ -156,11 +156,19 @@ }} {{ subscription.plan_name }} +
+ {{ + $t("settings_subscription.company") + }} + {{ subscription.company }} +
{{ $t("settings_subscription.expire_date") }} {{ + !subscription.expires || + !subscription.expire_date || subscription.expire_date === "-1" ? $t("settings_subscription.no_expiration") : formatDate( From 3953da91f62e4365c7e0baae4b3b43e8ff68e9cf Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 11:52:03 +0200 Subject: [PATCH 06/12] feat(subscription): align re-register and remove UX with nsec set-subscription surfaces the backend 409 as 'system_already_registered' (the my key is one-shot and never freed) instead of falling back to the community provider and reporting a generic 'unknown token'. The remove-subscription modal now warns the action is irreversible: the token cannot be reused, a new system is needed to subscribe again. --- .../actions/set-subscription/10set_subscription | 7 +++++++ core/ui/public/i18n/en/translation.json | 3 +++ core/ui/src/views/settings/SettingsSubscription.vue | 12 +++++++++++- 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription index 0999c85eee..5fea8b54c5 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/set-subscription/10set_subscription @@ -121,6 +121,13 @@ def subscribe_nsent(rdb, osubscription): json={"system_secret": auth_token}, headers={"Content-Type": "application/json", "Accept": "application/json"}, ) + # 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; surface a specific validation error instead of + # falling back to the community provider and reporting a generic + # "unknown token", so the UI can explain a new system is needed. + if myresp.status_code == 409: + _exit_validation_error("auth_token", "***", "system_already_registered", 3) myresp.raise_for_status() dinfo = myresp.json() diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index 1959a0c8c3..e7b43db57a 100644 --- a/core/ui/public/i18n/en/translation.json +++ b/core/ui/public/i18n/en/translation.json @@ -750,6 +750,9 @@ "remove_cluster_subscription_description": "You are going to remove the subscription plan. This will disable the subscription features.", "no_expiration": "No expiration", "unknown_token": "Unknown token", + "system_already_registered": "This system is already registered and its token cannot be reused. To subscribe again, create a new system on my.nethesis.it and register with its new token.", + "remove_cluster_subscription_irreversible_title": "This action cannot be undone", + "remove_cluster_subscription_irreversible": "Once removed, this system's token cannot be reused to register again. To subscribe this cluster again you will need to create a new system on my.nethesis.it and use its new token.", "must_be_32_chars_but_less_than_128": "Must be between 32 and 128 characters", "os_not_supported": "Operating system is not supported", "subscription_cannot_be_enabled": "Subscription is not available", diff --git a/core/ui/src/views/settings/SettingsSubscription.vue b/core/ui/src/views/settings/SettingsSubscription.vue index 9ea4029440..9c35600fc8 100644 --- a/core/ui/src/views/settings/SettingsSubscription.vue +++ b/core/ui/src/views/settings/SettingsSubscription.vue @@ -337,7 +337,17 @@ $t("settings_subscription.remove_cluster_subscription_title") }} - +
@import "../../styles/carbon-utils"; +// Two-column layout so all values line up regardless of label length: +// the label column auto-sizes to the widest label, values share one edge. +.subscription-details { + display: grid; + grid-template-columns: max-content 1fr; + column-gap: 2rem; + row-gap: 0.75rem; + align-items: center; + margin-bottom: 1rem; +} +.subscription-details .key-value-setting { + display: contents; +} + .icon-and-text { justify-content: flex-start; } From 0bc7bad2b76d26f56f203fa61584498c3b356ce5 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:06:07 +0200 Subject: [PATCH 08/12] chore(subscription): point system_url at the new my (onrender) Enterprise system_url linked to my.nethesis.it (= old my pre-flip); point it at my-proxy-prod.onrender.com/systems/ like the other F1 endpoints. nscom community link unchanged. --- .../cluster/actions/get-subscription/10get_subscription | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription index 3e37dd053f..00ab63694a 100755 --- a/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription +++ b/core/imageroot/var/lib/nethserver/cluster/actions/get-subscription/10get_subscription @@ -36,7 +36,7 @@ def fetch_subscription_info_nsent(rdb, attributes): # the UI still shows the system_id row instead of a 500. return { "auth_token": "********", - "system_url": f"https://my.nethesis.it/systems/{system_id}", + "system_url": f"https://my-proxy-prod.onrender.com/systems/{system_id}", "plan_name": "Nethesis Enterprise", "company": "-", "expires": False, @@ -65,7 +65,7 @@ def fetch_subscription_info_nsent(rdb, attributes): info = { "auth_token": "********", - "system_url": f"https://my.nethesis.it/systems/{sinfo.get('system_id', '')}", + "system_url": f"https://my-proxy-prod.onrender.com/systems/{sinfo.get('system_id', '')}", # The new my has no per-system plan; enterprise units are all # "Nethesis Enterprise". The organization name is surfaced as the # company, mirroring the nsec subscription view. From 72d50e1688d6607eefafd86fa4c673d9c8a56c0a Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 30 Jun 2026 13:46:56 +0200 Subject: [PATCH 09/12] feat(subscription): link System ID to the system page on the portal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Render system_url (already emitted by get-subscription) as a cv-link on the System ID — parity with nsec. --- core/ui/src/views/settings/SettingsSubscription.vue | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/core/ui/src/views/settings/SettingsSubscription.vue b/core/ui/src/views/settings/SettingsSubscription.vue index 9e082b5262..911e4ff335 100644 --- a/core/ui/src/views/settings/SettingsSubscription.vue +++ b/core/ui/src/views/settings/SettingsSubscription.vue @@ -149,7 +149,15 @@ {{ $t("settings_subscription.system_id") }} - {{ subscription.system_id }} + + {{ subscription.system_id }} + +
{{ From b905a01eacc1ca68a0c9717ee1aed0764aca507c Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Mon, 6 Jul 2026 16:24:23 +0200 Subject: [PATCH 10/12] docs(subscription): document native-my cluster/subscription keys and credential formats Add collect_url, migrated, migrated_at, legacy_system_id/legacy_auth_token (nsent) and dartagnan_url (nscom) to the key list; new "Credential formats" section covering the NETH-* system_id, the my_. token, and the legacy UUID/hex pair preserved on migration. --- docs/core/subscription.md | 31 +++++++++++++++++++++++++++++-- 1 file changed, 29 insertions(+), 2 deletions(-) diff --git a/docs/core/subscription.md b/docs/core/subscription.md index 769c3acfda..7d98fc24d6 100644 --- a/docs/core/subscription.md +++ b/docs/core/subscription.md @@ -12,8 +12,18 @@ The core provides builtin features to subscribe both free and paid support servi The Redis HASH key `cluster/subscription` holds the attributes of the subscription. - `provider` The subscription provider identifier. Possible values: `nsent`, `nscom` -- `system_id` System subscription identifier -- `auth_token` Authentication token for subscription APIs +- `system_id` System subscription identifier (see [Credential formats](#credential-formats)) +- `auth_token` Authentication token for subscription APIs (see [Credential formats](#credential-formats)) +- `collect_url` (`nsent` only) Base URL of the *my* collect API the cluster sends heartbeat, inventory + and backup to, e.g. `https://my.nethesis.it/collect/api/systems` +- `migrated` (`nsent` only) Set to `1` when the cluster holds **native** *my* credentials — either + registered directly on the new *my* or rotated from the legacy ones by `migrate-to-my`. While it is + `1`, `migrate-to-my` is a no-op +- `migrated_at` (`nsent` only) RFC3339 UTC timestamp of the credential rotation. Set only when a legacy + cluster is migrated, not on a fresh native registration +- `legacy_system_id`, `legacy_auth_token` (`nsent` only) The pre-migration credential pair, preserved for + audit / rollback when `migrate-to-my` rotates a legacy cluster to native *my* credentials +- `dartagnan_url` (`nscom` only) Community subscription API base URL (default `https://my.nethserver.com/api`) - `vpn_cert_cn` X509 Common Name for server certificate validation - `vpn_peer_host` (default "sos.nethesis.it") - `vpn_peer_port` (default "1194") @@ -41,6 +51,23 @@ The subscription status and running services are checked when: - the cluster subscription is enabled or disabled - the cluster leader node changes +## Credential formats + +The format of `system_id` and `auth_token` depends on the provider: + +- **`nsent` (Nethesis Enterprise, new `my`)** — the customer pastes the full *my* token, of the form + `my_.`, which is stored as `auth_token`. `set-subscription` posts it to the *my* + `POST /systems/register` API, which validates it and returns the `system_key`, stored as `system_id`, + of the form `NETH-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX` (`NETH-` followed by eight 4-hex-digit + groups). A `system_id` is **one-shot**: once registered on *my* it cannot be re-registered, and a new + registration attempt is rejected with HTTP `409` (`system_already_subscribed`). +- **`nscom` (Community)** — `auth_token` is the community token; `system_id` is the machine UUID returned + by dartagnan (`my.nethserver.com/api/machine/info`). +- **Legacy `nsent` (before migration)** — clusters registered on the old `my.nethesis.it` used a UUID + `system_id` and a hex `auth_token`. During the transition to the new *my*, `migrate-to-my` rotates the + pair to native `NETH-…` / `my_…` credentials, stores `collect_url`, sets `migrated=1`, and preserves + the old pair under `legacy_system_id` / `legacy_auth_token`. + ## APIs Cluster: From 584b1e7cadd211097de7cc336776a0761a618a1f Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Tue, 7 Jul 2026 11:03:48 +0200 Subject: [PATCH 11/12] fix(subscription): send heartbeat before inventory/backup on enable check-subscription started the oneshot send-inventory (~30s) and send-backup (builds + uploads the full cluster backup, can take minutes) before enabling send-heartbeat, so a freshly-subscribed cluster stayed unknown/pending on my until both finished. Enable the instantaneous heartbeat first, then run the slow inventory/backup, so the system flips to active immediately. --- .../var/lib/nethserver/node/bin/check-subscription | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/node/bin/check-subscription b/core/imageroot/var/lib/nethserver/node/bin/check-subscription index 35faf2a3f1..973ea3d6ca 100755 --- a/core/imageroot/var/lib/nethserver/node/bin/check-subscription +++ b/core/imageroot/var/lib/nethserver/node/bin/check-subscription @@ -33,12 +33,17 @@ function is_leader() function enable_nsent() { if is_leader ; then + # Heartbeat first: it is instantaneous and flips the system to "active" + # on my right away. Otherwise the oneshot inventory (~30s) and backup + # (builds + uploads the full cluster backup, can take minutes) below gate + # the first heartbeat, leaving the system "unknown/pending" in the meantime. + systemctl enable --now send-heartbeat.service if ! systemctl -q is-active send-inventory.timer; then # First time, send inventory and backup immediately systemctl start send-inventory systemctl start send-backup fi - systemctl enable --now send-heartbeat.service send-inventory.timer send-backup.timer + systemctl enable --now send-inventory.timer send-backup.timer else # Some services must be disabled in worker nodes systemctl disable --now send-heartbeat.service send-inventory.timer send-backup.timer @@ -50,11 +55,14 @@ function enable_nscom() { # similar to nsent, without send-backup.timer if is_leader ; then + # Heartbeat first (see enable_nsent): flip to "active" immediately, + # before the slow oneshot inventory below. + systemctl enable --now send-heartbeat.service if ! systemctl -q is-active send-inventory.timer; then - # First time, send inventory and backup immediately + # First time, send inventory immediately systemctl start send-inventory fi - systemctl enable --now send-heartbeat.service send-inventory.timer + systemctl enable --now send-inventory.timer else # Some services must be disabled in worker nodes systemctl disable --now send-heartbeat.service send-inventory.timer From 6bebbbb5825b9044451f3ea825158e2c9f2744e6 Mon Sep 17 00:00:00 2001 From: Edoardo Spadoni Date: Thu, 9 Jul 2026 12:08:42 +0200 Subject: [PATCH 12/12] fix(subscription): log send failures to the journal --- .../lib/nethserver/cluster/bin/send-cluster-backup | 1 + .../var/lib/nethserver/cluster/bin/send-heartbeat | 9 +++++++-- .../var/lib/nethserver/cluster/bin/send-inventory | 11 ++++++++--- 3 files changed, 16 insertions(+), 5 deletions(-) diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup b/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup index 0fd6d4c0fb..2adf0bb6df 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup @@ -59,6 +59,7 @@ elif [[ -f "${encrypted_file}" ]]; then fi curl \ --silent \ + --show-error \ --location-trusted \ --fail-with-body \ --max-time 900 \ diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat index 185b9e9f24..ea47c45edc 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat @@ -38,9 +38,14 @@ function send_heartbeat_nsent () return 0 fi - curl -m 30 --retry 3 -L -s -X POST \ + # Surface failures (DNS, TLS, timeouts, HTTP errors) in the journal + # instead of swallowing them; the || keeps set -e and the loop alive. + local err + err=$(curl -m 30 --retry 3 -L -sSf -X POST \ --user "${system_id:?}:${auth_token:?}" \ - "${collect_url}/heartbeat" >/dev/null || : + -o /dev/null -w 'HTTP %{http_code}' \ + "${collect_url}/heartbeat" 2>&1) || \ + echo "<4>[WARNING] heartbeat send failed: ${err}" 1>&2 } function send_heartbeat_nscom () diff --git a/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory b/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory index 8445d1ebaf..5b9dcfc3b5 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-inventory @@ -36,12 +36,17 @@ function send_inventory_nsent () return 0 fi - print-phonehome | \ - curl -m 180 --retry 3 -L -s -X POST \ + # Surface failures (DNS, TLS, timeouts, HTTP errors) in the journal + # instead of swallowing them; the || keeps set -e satisfied. + local err + err=$(print-phonehome | \ + curl -m 180 --retry 3 -L -sSf -X POST \ --user "${system_id:?}:${auth_token:?}" \ -H "Content-Type: application/json" \ --data-binary @- \ - "${collect_url}/inventory" >/dev/null || : + -o /dev/null -w 'HTTP %{http_code}' \ + "${collect_url}/inventory" 2>&1) || \ + echo "<4>[WARNING] inventory send failed: ${err}" 1>&2 } function send_inventory_nscom ()