Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -78,37 +78,70 @@ 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 {}

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_<public>.<secret>" 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',
})
Expand Down
88 changes: 88 additions & 0 deletions core/imageroot/var/lib/nethserver/cluster/bin/migrate-to-my
Original file line number Diff line number Diff line change
@@ -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
15 changes: 15 additions & 0 deletions core/imageroot/var/lib/nethserver/cluster/bin/print-phonehome
Original file line number Diff line number Diff line change
Expand Up @@ -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"):
Expand All @@ -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))
Expand Down
79 changes: 49 additions & 30 deletions core/imageroot/var/lib/nethserver/cluster/bin/send-cluster-backup
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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
Expand All @@ -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
Expand Down
Loading
Loading