Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
c046c59
feat(migration): cut appliance over to my collect
edospadoni Apr 23, 2026
b268ef8
feat(migration): switch alerts to native my collect after cutover
edospadoni Jun 17, 2026
66fe5df
feat(migration): point my endpoints at my-proxy-prod.onrender.com
edospadoni Jun 19, 2026
9b5368b
fix(alert): drop disable_my_alerts opt-out
edospadoni Jun 29, 2026
fbde2ce
build(ns-ui): bundle nethsecurity-ui#746 backup UI for the cutover
edospadoni Jun 29, 2026
7644517
feat(subscription): expose organization and enterprise plan for migra…
edospadoni Jun 29, 2026
c300a32
build(ns-ui): bump pin to include the subscription view fix (nethsecu…
edospadoni Jun 29, 2026
fb11bb6
fix(subscription): surface already-registered on re-register
edospadoni Jun 30, 2026
835fc5c
chore(ns-ui): bump pin to nethsecurity-ui#746 head (899b407)
edospadoni Jun 30, 2026
3e0ea7d
chore(ns-ui): bump pin to nethsecurity-ui#746 head (7402e3b)
edospadoni Jun 30, 2026
c7b4aa8
feat(subscription): expose system_url for enterprise units
edospadoni Jun 30, 2026
f97bb62
chore(ns-ui): bump pin to nethsecurity-ui#746 head (4a0f10b)
edospadoni Jun 30, 2026
1946918
feat(subscription): expose community system_url too (parity with ns8)
edospadoni Jun 30, 2026
44c3977
fix(subscription): send heartbeat before inventory on register
edospadoni Jul 7, 2026
18e45c9
feat(ns-api): dedalo login via My Nethesis device pairing
edospadoni Jul 8, 2026
1a3a187
chore(ns-ui): bump pin to the My Nethesis hotspot login
edospadoni Jul 8, 2026
e3926d7
chore(ns-ui): bump pin to the hotspot manager link
edospadoni Jul 8, 2026
efc923b
chore(ns-ui): bump pin to the refined My Nethesis login UX
edospadoni Jul 9, 2026
570c2dd
fix(ns-plug): log send failures to syslog
edospadoni Jul 9, 2026
137a90b
fix(ns-api): return expected register/oidc errors as validation errors
edospadoni Jul 9, 2026
e2e071a
chore(ns-ui): bump pin to the inline login errors fix
edospadoni Jul 9, 2026
3ee30ae
chore(ns-ui): bump pin to the session status card
edospadoni Jul 13, 2026
e1eae75
feat(ns-api): expose the my system name in subscription info
edospadoni Jul 14, 2026
fc39103
chore(ns-ui): bump pin to the system name on subscription
edospadoni Jul 14, 2026
c262660
chore(ns-ui): bump pin to nethsecurity-ui#746 head (c6afb3a)
edospadoni Jul 20, 2026
b557c56
chore(ns-ui): bump pin to nethsecurity-ui#746 head (2cacc72)
edospadoni Jul 20, 2026
09202a4
chore(ns-ui): bump pin to nethsecurity-ui#746 head (5b489e2b)
edospadoni Jul 27, 2026
53ddd8c
chore(ns-ui): bump pin to nethsecurity-ui#746 head (f910296)
edospadoni Jul 28, 2026
956f083
chore(ns-ui): bump pin to nethsecurity-ui#746 head (e17c6fa)
edospadoni Aug 26, 2026
e5c9286
fix(migration): rotate the enterprise feed credentials too
edospadoni Aug 27, 2026
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
43 changes: 25 additions & 18 deletions packages/ns-api/files/ns.backup
Original file line number Diff line number Diff line change
Expand Up @@ -169,21 +169,26 @@ elif cmd == 'call':

elif action == 'registered-backup':
if not os.path.exists(PASSPHRASE_PATH):
print(utils.validation_error('passphrase', 'missing'))
else:
try:
# create backup
file_name = create_backup()
backup_path = f'{DOWNLOAD_PATH}{file_name}'
# upload backup to server and remove it from filesystem
completed_process = subprocess.run(['/usr/sbin/remote-backup', 'upload', backup_path], check=True,
capture_output=True)
os.remove(backup_path)
print(json.dumps({'message': 'success'}))
except subprocess.CalledProcessError as error:
print(json.dumps(utils.generic_error(f'remote upload failed')))
except RuntimeError as error:
print(json.dumps(utils.generic_error(error.args[0])))
# Refuse the call before running sysupgrade/uploading, and emit
# valid JSON so the HTTP API wraps it as a 422 ValidationError
# the UI can render (the previous form printed a Python dict
# repr, which was silently dropped upstream and caused the run
# modal to stay open after a successful upload).
Comment on lines +172 to +176

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

AI generated a way verbose comment to address a simple validation error, that's the case for all the changes, we should keep it simple

print(json.dumps(utils.validation_error('passphrase', 'missing')))
sys.exit(0)
try:
# create backup
file_name = create_backup()
backup_path = f'{DOWNLOAD_PATH}{file_name}'
# upload backup to server and remove it from filesystem
completed_process = subprocess.run(['/usr/sbin/remote-backup', 'upload', backup_path], check=True,
capture_output=True)
os.remove(backup_path)
print(json.dumps({'message': 'success'}))
except subprocess.CalledProcessError as error:
print(json.dumps(utils.generic_error(f'remote upload failed')))
except RuntimeError as error:
print(json.dumps(utils.generic_error(error.args[0])))

elif action == 'registered-restore':
try:
Expand Down Expand Up @@ -224,10 +229,12 @@ elif cmd == 'call':
elif action == 'registered-delete-backup':
try:
data = json.load(sys.stdin)
p = subprocess.run(['/usr/sbin/remote-backup', 'delete', data['id']],
subprocess.run(['/usr/sbin/remote-backup', 'delete', data['id']],
check=True, capture_output=True, text=True)
# return content
print(p.stdout)
# The remote side returns a structured JSON response; the UI
# only needs a success flag, matching the pattern of the
# other registered-* handlers (backup, restore).
print(json.dumps({'message': 'success'}))
except subprocess.CalledProcessError as error:
print(json.dumps(utils.generic_error('remote backup delete failed')))
except KeyError as error:
Expand Down
118 changes: 108 additions & 10 deletions packages/ns-api/files/ns.dedalo
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ from euci import EUci

tmp_dir = "/var/run/"
token_file = f"{tmp_dir}/dedalo_token"
pairing_file = f"{tmp_dir}/dedalo_pairing.json"
opts = ["network", "hotspot_id", "unit_name", "unit_description", "interface"]

## Utilities
Expand All @@ -45,24 +46,104 @@ def setup(u):
def login(args):
u = EUci()
try:
p = subprocess.run(['curl', '-L', '--url', f'https://{args["host"]}/api/login', '--header', 'Content-Type: application/json', '--data-binary', json.dumps(args)], check=True, capture_output=True, text=True)
p = subprocess.run(['curl', '-L', '-m', '15', '--connect-timeout', '5', '--url', f'https://{args["host"]}/api/login', '--header', 'Content-Type: application/json', '--data-binary', json.dumps(args)], check=True, capture_output=True, text=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subprocess will leak credentials, switch to python own requests package. Will likely handle most of the work you need to do to adjust the response

@gsanchietti gsanchietti Aug 28, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Alternative: read the data from a file or stdin

resp = json.loads(p.stdout)
if 'token' in resp:
setup(u)
u.set("dedalo", "config", "splash_page", f'http://{args["host"]}/wings')
u.set("dedalo", "config", "aaa_url", f'https://{args["host"]}/wax/aaa')
u.set("dedalo", "config", "api_url", f'https://{args["host"]}/api')
u.commit("dedalo")
os.makedirs(tmp_dir, exist_ok = True)
with open(token_file, "w") as fp:
fp.write(resp["token"])
_connect_to_host(u, args["host"], resp["token"])
return {"response": "success"}
else:
return utils.generic_error("login_failed")
except Exception as e:
print(e, file=sys.stderr)
return {"success": False}


def _connect_to_host(u, host, token, account_name="", account_user=""):
# same side effects as a successful password login: point the unit at
# the chosen hotspot manager and store the session token; the account
# info (from OIDC pairing) is kept to show who the unit is linked to
setup(u)
u.set("dedalo", "config", "splash_page", f'http://{host}/wings')
u.set("dedalo", "config", "aaa_url", f'https://{host}/wax/aaa')
u.set("dedalo", "config", "api_url", f'https://{host}/api')
for opt, value in (("account_name", account_name), ("account_user", account_user)):
if value:
u.set("dedalo", "config", opt, value)
else:
try:
u.delete("dedalo", "config", opt)
except:
pass
u.commit("dedalo")
os.makedirs(tmp_dir, exist_ok = True)
with open(token_file, "w") as fp:
fp.write(token)

def oidc_start(args):
host = args.get("host") or "my.nethspot.com"
u = EUci()
unit_name = u.get("dedalo", "config", "unit_name", default="")
if not unit_name:
with open('/proc/sys/kernel/hostname', 'r') as fp:
unit_name = fp.read().strip()
try:
p = subprocess.run(['curl', '-s', '-L', '-m', '15', '--connect-timeout', '5', '-X', 'POST', '-w', '\n%{http_code}', '--url', f'https://{host}/api/auth/oidc/device/start', '--header', 'Content-Type: application/json', '--data-binary', json.dumps({"unit_name": unit_name})], check=True, capture_output=True, text=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subprocess will leak credentials, switch to python own requests package. Will likely handle most of the work you need to do to adjust the response

body, _, http_code = p.stdout.rpartition('\n')
# Expected outcomes of the user-provided host (manager without OIDC
# support, wrong/unreachable host) are validation errors: the UI
# shows them inline without the global error toast.
if http_code == '404':
# hotspot manager without OIDC device pairing support
return utils.validation_error("host", "oidc_not_supported")
resp = json.loads(body)
except Exception as e:
print(e, file=sys.stderr)
return utils.validation_error("host", "pairing_start_failed")
if 'device_code' not in resp or 'verification_url' not in resp:
return utils.validation_error("host", "pairing_start_failed")
# the device_code stays on the unit: the browser only ever sees the
# verification_url (carrying the public pair_id)
os.makedirs(tmp_dir, exist_ok = True)
fd = os.open(pairing_file, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, 'w') as fp:
json.dump({"host": host, "device_code": resp["device_code"]}, fp)
return {
"verification_url": resp["verification_url"],
"expires_in": resp.get("expires_in", 600),
"interval": resp.get("interval", 2),
}

def oidc_poll():
u = EUci()
try:
with open(pairing_file, 'r') as fp:
pairing = json.load(fp)
except:
return utils.generic_error("no_pairing_in_progress")
host = pairing["host"]
try:
p = subprocess.run(['curl', '-s', '-L', '-m', '15', '--connect-timeout', '5', '--url', f'https://{host}/api/auth/oidc/device/poll', '--header', 'Content-Type: application/json', '--data-binary', json.dumps({"device_code": pairing["device_code"]})], check=True, capture_output=True, text=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subprocess will leak credentials, switch to python own requests package. Will likely handle most of the work you need to do to adjust the response

resp = json.loads(p.stdout)
except Exception as e:
# transient error talking to the hotspot manager: keep polling
print(e, file=sys.stderr)
return {"status": "pending"}
status = resp.get("status", "")
if status == "ready":
os.remove(pairing_file)
_connect_to_host(u, host, resp["token"], resp.get("account_name", ""), resp.get("logged_by", ""))
return {"status": "success", "account_name": resp.get("account_name", "")}
if status == "failed":
os.remove(pairing_file)
# NB: don't name the key "error" — a top-level "error" key makes
# nethsecurity-api reply 500 (application-error convention) and the
# failed status would never reach the UI as data.
return {"status": "failed", "reason": resp.get("error", "unknown")}
if status == "expired":
os.remove(pairing_file)
return {"status": "expired"}
return {"status": "pending"}

def list_sessions():
process = subprocess.run(["/usr/bin/dedalo", "query", "list"], capture_output=True, text=True)
if not process.stdout:
Expand Down Expand Up @@ -131,7 +212,7 @@ def list_parents():
u = EUci()
try:
api_url = u.get("dedalo", "config", "api_url")
p = subprocess.run(['curl', '-L', '-s', '--url', f'{api_url}/hotspots', '--header', f"Token: {_get_token()}"], capture_output=True, text=True)
p = subprocess.run(['curl', '-L', '-s', '-m', '15', '--connect-timeout', '5', '--url', f'{api_url}/hotspots', '--header', f"Token: {_get_token()}"], capture_output=True, text=True)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Subprocess will leak credentials, switch to python own requests package. Will likely handle most of the work you need to do to adjust the response

resp = json.loads(p.stdout)
for p in resp["data"]:
parents.append({"id": p["id"], "name": p["name"], "description": p["description"]})
Expand All @@ -148,6 +229,12 @@ def unregister():
except Exception as e:
print(e, file=sys.stderr)
return utils.generic_error("unregister_failed")
try:
u.delete("dedalo", "config", "account_name")
u.delete("dedalo", "config", "account_user")
u.commit("dedalo")
except:
pass
try:
firewall.delete_linked_sections(EUci(), "dedalo/config")
subprocess.run(["/sbin/ifdown", "dedalo"], capture_output=True, check=True)
Expand Down Expand Up @@ -178,6 +265,10 @@ def get_configuration():
with open('/proc/sys/kernel/hostname', 'r') as fp:
ret["unit_name"] = fp.read().strip()
ret["connected"] = os.path.exists(token_file)
ret["account_name"] = u.get("dedalo", "config", "account_name", default="")
ret["account_user"] = u.get("dedalo", "config", "account_user", default="")
api_url = u.get("dedalo", "config", "api_url", default="")
ret["manager_host"] = api_url.replace("https://", "").replace("/api", "")
return {"configuration": ret}

def set_configuration(args):
Expand Down Expand Up @@ -259,6 +350,8 @@ cmd = sys.argv[1]
if cmd == 'list':
print(json.dumps({
"login": {"host": "my.nethspot.com", "username": "myuser", "password": "mypassword"},
"oidc-start": {"host": "my.nethspot.com"},
"oidc-poll": {},
"list-sessions": {},
"list-parents": {},
"list-devices": {},
Expand All @@ -285,6 +378,11 @@ else:
elif action == "login":
args = json.loads(sys.stdin.read())
ret = login(args)
elif action == "oidc-start":
args = json.loads(sys.stdin.read())
ret = oidc_start(args)
elif action == "oidc-poll":
ret = oidc_poll()
elif action == "set-configuration":
args = json.loads(sys.stdin.read())
ret = set_configuration(args)
Expand Down
40 changes: 32 additions & 8 deletions packages/ns-api/files/ns.subscription
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,23 @@ def register(args):

secret = args["secret"]

try:
subprocess.run(["/usr/sbin/register", "enterprise", secret, '5'], check=True, capture_output=True)
enterprise = subprocess.run(["/usr/sbin/register", "enterprise", secret, '5'], capture_output=True)
if enterprise.returncode == 0:
return {"result": "success"}
except:
pass

try:
subprocess.run(["/usr/sbin/register", "community", secret, '5'], check=True, capture_output=True)
# Exit code 2: the system is already registered on my and its key cannot be
# reused (one-shot registration). Report it as a validation error on the
# secret (expected outcome of user input, no global error toast) instead of
# falling back to community, so the UI can guide the user to create a new
# system.
if enterprise.returncode == 2:
return utils.validation_error("secret", "system_already_registered")

community = subprocess.run(["/usr/sbin/register", "community", secret, '5'], capture_output=True)
if community.returncode == 0:
return {"result": "success"}
except:
return utils.generic_error("invalid_secret_or_server_not_found")

return utils.generic_error("invalid_secret_or_server_not_found")

def unregister():
try:
Expand Down Expand Up @@ -61,6 +67,24 @@ def info():
type = u.get('ns-plug', 'config', 'type', default='')

ret = {"server_id": data["id"], "systemd_id": data["uuid"], "plan": data["subscription"]["subscription_plan"]["name"], "expiration": expiration, "active": active, "type": type}

# The new my has no per-system commercial plan. For enterprise units expose
# the organization explicitly (subscription-info threads organization.name)
# and default the plan label to "Nethesis Enterprise". Community units keep
# the real plan name untouched.
if type == "enterprise":
ret["organization"] = data.get("organization", "")
# The system name given on my at creation time (threaded by
# subscription-info from the collect /info payload).
ret["system_name"] = data.get("system_name", "")
ret["plan"] = "Nethesis Enterprise"
ret["system_url"] = f"https://my-proxy-prod.onrender.com/systems/{data['uuid']}"
else:
# Community: link the system to its my.nethserver.com page, matching ns8.
sub_id = (data.get("subscription") or {}).get("id")
if sub_id:
ret["system_url"] = f"https://my.nethserver.com/servers/{sub_id}"

return ret


Expand Down
12 changes: 11 additions & 1 deletion packages/ns-phonehome/files/phonehome

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the "migration" section should end up inside the python3-nethsec facts library

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 +telegraf +victoria-metrics
DEPENDS:=+openvpn +lscpu +python3-nethsec +python3-yaml +telegraf +victoria-metrics +jq
PKGARCH:=all
endef

Expand Down Expand Up @@ -82,6 +82,7 @@ define Package/ns-plug/install
$(INSTALL_BIN) ./files/ns-plug-alert-proxy $(1)/usr/sbin/ns-plug-alert-proxy
$(INSTALL_BIN) ./files/distfeed-setup $(1)/usr/sbin/distfeed-setup
$(INSTALL_BIN) ./files/apk-official $(1)/usr/sbin/apk-official
$(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-proxy-prod.onrender.com/collect/api/systems'
option repository_url 'https://updates.nethsecurity.nethserver.org'
option channel ''
option tun_mtu ''
Expand Down
Loading
Loading