Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
c478c01
feat(migration): cut appliance over to my collect
edospadoni Apr 23, 2026
b72cdd8
feat(migration): switch alerts to native my collect after cutover
edospadoni Jun 17, 2026
dcb31dc
feat(migration): point my endpoints at my-proxy-prod.onrender.com
edospadoni Jun 19, 2026
81960e0
fix(alert): drop disable_my_alerts opt-out
edospadoni Jun 29, 2026
673ad6e
build(ns-ui): bundle nethsecurity-ui#746 backup UI for the cutover
edospadoni Jun 29, 2026
908d6b3
feat(subscription): expose organization and enterprise plan for migra…
edospadoni Jun 29, 2026
3f2a819
build(ns-ui): bump pin to include the subscription view fix (nethsecu…
edospadoni Jun 29, 2026
e7a054e
fix(subscription): surface already-registered on re-register
edospadoni Jun 30, 2026
5ebe172
chore(ns-ui): bump pin to nethsecurity-ui#746 head (899b407)
edospadoni Jun 30, 2026
6ee2e67
chore(ns-ui): bump pin to nethsecurity-ui#746 head (7402e3b)
edospadoni Jun 30, 2026
244f420
feat(subscription): expose system_url for enterprise units
edospadoni Jun 30, 2026
85df58a
chore(ns-ui): bump pin to nethsecurity-ui#746 head (4a0f10b)
edospadoni Jun 30, 2026
ee2f0b2
feat(subscription): expose community system_url too (parity with ns8)
edospadoni Jun 30, 2026
60ff1bc
fix(subscription): send heartbeat before inventory on register
edospadoni Jul 7, 2026
d99fd2d
feat(ns-api): dedalo login via My Nethesis device pairing
edospadoni Jul 8, 2026
d88737d
chore(ns-ui): bump pin to the My Nethesis hotspot login
edospadoni Jul 8, 2026
ded72ca
chore(ns-ui): bump pin to the hotspot manager link
edospadoni Jul 8, 2026
3cceb0b
chore(ns-ui): bump pin to the refined My Nethesis login UX
edospadoni Jul 9, 2026
22c1918
fix(ns-plug): log send failures to syslog
edospadoni Jul 9, 2026
f0e0bb4
fix(ns-api): return expected register/oidc errors as validation errors
edospadoni Jul 9, 2026
0dfd225
chore(ns-ui): bump pin to the inline login errors fix
edospadoni Jul 9, 2026
f9999a1
chore(ns-ui): bump pin to the session status card
edospadoni Jul 13, 2026
545e81a
feat(ns-api): expose the my system name in subscription info
edospadoni Jul 14, 2026
01b1fc8
chore(ns-ui): bump pin to the system name on subscription
edospadoni Jul 14, 2026
949538a
chore(ns-ui): bump pin to nethsecurity-ui#746 head (c6afb3a)
edospadoni Jul 20, 2026
ec8ade5
chore(ns-ui): bump pin to nethsecurity-ui#746 head (2cacc72)
edospadoni Jul 20, 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