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..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 @@ -26,20 +26,54 @@ 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-proxy-prod.onrender.com/systems/{system_id}", + "plan_name": "Nethesis Enterprise", + "company": "-", + "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-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. + "plan_name": "Nethesis Enterprise", + "company": 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..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 @@ -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-proxy-prod.onrender.com/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,42 @@ 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-proxy-prod.onrender.com/backend/api/systems/register", + 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() - 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-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 new file mode 100755 index 0000000000..d3b13be8d1 --- /dev/null +++ b/core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my @@ -0,0 +1,88 @@ +#!/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. +# + +# 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}" +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-proxy-prod.onrender.com/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-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" + 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 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..2adf0bb6df 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,38 @@ 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 \ + --show-error \ + --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..ea47c45edc 100755 --- a/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat +++ b/core/imageroot/var/lib/nethserver/cluster/bin/send-heartbeat @@ -5,15 +5,22 @@ # 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}" -export REDIS_USER="default" -export REDIS_PASSWORD="default" -export REDIS_ADDRESS="127.0.0.1:6379" +# Opportunistically rotate enterprise credentials. +migrate-to-my -# Parse subscription configuration from Redis while IFS='=' read -r key value; do declare "${key}"="${value}" done < <(redis-hgetall cluster/subscription) @@ -26,18 +33,19 @@ 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 || : + # 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:?}" \ + -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 48a5d26816..5b9dcfc3b5 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,22 @@ 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 \ + # 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 @- https://my.nethesis.it/proxy/inventory >/dev/null || : + --data-binary @- \ + -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 () 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 diff --git a/core/ui/public/i18n/en/translation.json b/core/ui/public/i18n/en/translation.json index 8330ac4fc2..e7b43db57a 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", @@ -749,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 aa3f99e12a..911e4ff335 100644 --- a/core/ui/src/views/settings/SettingsSubscription.vue +++ b/core/ui/src/views/settings/SettingsSubscription.vue @@ -139,16 +139,25 @@ - +