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
18 changes: 13 additions & 5 deletions packages/ns-api/files/ns.backup
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,14 @@ elif cmd == 'call':
print(json.dumps(utils.generic_error(f'remote list failed')))

elif action == 'registered-backup':
if os.path.exists(PASSPHRASE_PATH):
print(utils.validation_error('passphrase', 'missing'))
if not os.path.exists(PASSPHRASE_PATH):
# 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()
Expand Down Expand Up @@ -225,10 +231,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:
Expand Down
12 changes: 11 additions & 1 deletion packages/ns-phonehome/files/phonehome
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -61,7 +70,8 @@ data = {
},
"pci": list(pci.values()),
"mountpoints": mount_points,
"features": features
"features": features,
"migration": migration
}
}

Expand Down
3 changes: 2 additions & 1 deletion packages/ns-plug/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
DEPENDS:=+openvpn +lscpu +python3-nethsec +python3-yaml +jq
PKGARCH:=all
endef

Expand Down Expand Up @@ -75,6 +75,7 @@ define Package/ns-plug/install
$(INSTALL_BIN) ./files/ns-plug.init $(1)/etc/init.d/ns-plug
$(INSTALL_BIN) ./files/ns-plug $(1)/usr/sbin/ns-plug
$(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
Expand Down
1 change: 1 addition & 0 deletions packages/ns-plug/files/config
Original file line number Diff line number Diff line change
Expand Up @@ -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 ''
Expand Down
91 changes: 91 additions & 0 deletions packages/ns-plug/files/migrate-to-my
Original file line number Diff line number Diff line change
@@ -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 <<EOI
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.migrated=1
set ns-plug.config.migrated_at=$migrated_at
commit ns-plug
EOI

logger -t migrate-to-my "migrated to my collect credentials"
exit 0
22 changes: 16 additions & 6 deletions packages/ns-plug/files/register
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,12 @@ if [ -z "$secret" ]; then
exit_error "Invalid secret"
fi

# Setup URLs
# Resolve the system identity from the pasted secret.
#
# Enterprise uses the my collect registration API directly — the
# unit lands on the new my with its native credentials and never
# needs the /proxy/credentials rotation helper. Community keeps
# using my.nethserver.com as before.
case "$type" in
community)
url="https://my.nethserver.com/api/"
Expand All @@ -42,11 +47,13 @@ case "$type" in
"${url}machine/info" | jq -r ".uuid" 2>/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'"
Expand All @@ -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

Expand Down
126 changes: 94 additions & 32 deletions packages/ns-plug/files/remote-backup
Original file line number Diff line number Diff line change
Expand Up @@ -6,64 +6,126 @@
#

#
# 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] $@"
exit 1
}

function help {
>&2 echo "Usage: $0 <list|download|upload>"
>&2 echo "Usage: $0 <list|download|upload|delete>"
>&2 echo "Commands:"
>&2 echo " - list: retrieve the list of available backups from remote server"
>&2 echo " - download <file> [output]: download the given backup, if 'output' is empty downloaded file will be named as as 'file'"
>&2 echo " - upload <file>: upload the given backup"
>&2 echo " - list: fetch the list of backups stored for this system"
>&2 echo " - download <id> [output]: download the backup <id>; defaults to writing to a file named <id>"
>&2 echo " - upload <file>: upload a backup file"
>&2 echo " - delete <id>: remove the backup <id>"
}

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: <output>} 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
;;
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
6 changes: 6 additions & 0 deletions packages/ns-plug/files/send-backup
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading
Loading