Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
28 commits
Select commit Hold shift + click to select a range
d3894dd
feat(migration): cut appliance over to my collect
edospadoni Apr 23, 2026
8bfc7dc
feat(migration): switch alerts to native my collect after cutover
edospadoni Jun 17, 2026
5c969ef
feat(migration): point my endpoints at my-proxy-prod.onrender.com
edospadoni Jun 19, 2026
d669e81
fix(alert): drop disable_my_alerts opt-out
edospadoni Jun 29, 2026
d336bbb
build(ns-ui): bundle nethsecurity-ui#746 backup UI for the cutover
edospadoni Jun 29, 2026
4c548f6
feat(subscription): expose organization and enterprise plan for migra…
edospadoni Jun 29, 2026
4672658
build(ns-ui): bump pin to include the subscription view fix (nethsecu…
edospadoni Jun 29, 2026
72ca1c0
fix(subscription): surface already-registered on re-register
edospadoni Jun 30, 2026
eff659a
chore(ns-ui): bump pin to nethsecurity-ui#746 head (899b407)
edospadoni Jun 30, 2026
853ee1c
chore(ns-ui): bump pin to nethsecurity-ui#746 head (7402e3b)
edospadoni Jun 30, 2026
c99dbfb
feat(subscription): expose system_url for enterprise units
edospadoni Jun 30, 2026
041a40b
chore(ns-ui): bump pin to nethsecurity-ui#746 head (4a0f10b)
edospadoni Jun 30, 2026
acc226b
feat(subscription): expose community system_url too (parity with ns8)
edospadoni Jun 30, 2026
a2b1949
fix(subscription): send heartbeat before inventory on register
edospadoni Jul 7, 2026
f64e288
feat(ns-api): dedalo login via My Nethesis device pairing
edospadoni Jul 8, 2026
c48dc58
chore(ns-ui): bump pin to the My Nethesis hotspot login
edospadoni Jul 8, 2026
cc87502
chore(ns-ui): bump pin to the hotspot manager link
edospadoni Jul 8, 2026
0190d3d
chore(ns-ui): bump pin to the refined My Nethesis login UX
edospadoni Jul 9, 2026
12dd8d6
fix(ns-plug): log send failures to syslog
edospadoni Jul 9, 2026
45b68db
fix(ns-api): return expected register/oidc errors as validation errors
edospadoni Jul 9, 2026
809543d
chore(ns-ui): bump pin to the inline login errors fix
edospadoni Jul 9, 2026
ab89cb7
chore(ns-ui): bump pin to the session status card
edospadoni Jul 13, 2026
53444d1
feat(ns-api): expose the my system name in subscription info
edospadoni Jul 14, 2026
aeb6f20
chore(ns-ui): bump pin to the system name on subscription
edospadoni Jul 14, 2026
1031ea9
chore(ns-ui): bump pin to nethsecurity-ui#746 head (c6afb3a)
edospadoni Jul 20, 2026
e7b4329
chore(ns-ui): bump pin to nethsecurity-ui#746 head (2cacc72)
edospadoni Jul 20, 2026
49ef14c
chore(ns-ui): bump pin to nethsecurity-ui#746 head (5b489e2b)
edospadoni Jul 27, 2026
8bcd72f
chore(ns-ui): bump pin to nethsecurity-ui#746 head (f910296)
edospadoni Jul 28, 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).
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)
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)
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)
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)
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
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 @@ -81,6 +81,7 @@ define Package/ns-plug/install
$(INSTALL_BIN) ./files/ns-plug $(1)/usr/sbin/ns-plug
$(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/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