Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
06226b3
feat(migration): cut appliance over to my collect
edospadoni Apr 23, 2026
6751086
feat(migration): switch alerts to native my collect after cutover
edospadoni Jun 17, 2026
aff27c0
feat(migration): point my endpoints at my-proxy-prod.onrender.com
edospadoni Jun 19, 2026
4dfc096
fix(alert): drop disable_my_alerts opt-out
edospadoni Jun 29, 2026
4484d01
build(ns-ui): bundle nethsecurity-ui#746 backup UI for the cutover
edospadoni Jun 29, 2026
cdcc435
feat(subscription): expose organization and enterprise plan for migra…
edospadoni Jun 29, 2026
e700e64
build(ns-ui): bump pin to include the subscription view fix (nethsecu…
edospadoni Jun 29, 2026
3538ae5
fix(subscription): surface already-registered on re-register
edospadoni Jun 30, 2026
462d511
chore(ns-ui): bump pin to nethsecurity-ui#746 head (899b407)
edospadoni Jun 30, 2026
2728be9
chore(ns-ui): bump pin to nethsecurity-ui#746 head (7402e3b)
edospadoni Jun 30, 2026
53e8a5d
feat(subscription): expose system_url for enterprise units
edospadoni Jun 30, 2026
a9920a2
chore(ns-ui): bump pin to nethsecurity-ui#746 head (4a0f10b)
edospadoni Jun 30, 2026
62f055d
feat(subscription): expose community system_url too (parity with ns8)
edospadoni Jun 30, 2026
50c2278
fix(subscription): send heartbeat before inventory on register
edospadoni Jul 7, 2026
d7d2d01
feat(ns-api): dedalo login via My Nethesis device pairing
edospadoni Jul 8, 2026
607826e
chore(ns-ui): bump pin to the My Nethesis hotspot login
edospadoni Jul 8, 2026
90580b1
chore(ns-ui): bump pin to the hotspot manager link
edospadoni Jul 8, 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
2 changes: 1 addition & 1 deletion packages/ns-api/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ include $(TOPDIR)/rules.mk

PKG_NAME:=ns-api
PKG_VERSION:=3.7.0
PKG_RELEASE:=1
PKG_RELEASE:=2

PKG_BUILD_DIR:=$(BUILD_DIR)/ns-api-$(PKG_VERSION)

Expand Down
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
112 changes: 102 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,98 @@ 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')
if http_code == '404':
# hotspot manager without OIDC device pairing support
return utils.generic_error("oidc_not_supported")
resp = json.loads(body)
except Exception as e:
print(e, file=sys.stderr)
return utils.generic_error("pairing_start_failed")
if 'device_code' not in resp or 'verification_url' not in resp:
return utils.generic_error("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)
return {"status": "failed", "error": 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 +206,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 +223,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 +259,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 +344,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 +372,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
35 changes: 27 additions & 8 deletions packages/ns-api/files/ns.subscription
Original file line number Diff line number Diff line change
Expand Up @@ -20,17 +20,21 @@ 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-is instead of falling back to
# community, so the UI can guide the user to create a new system.
if enterprise.returncode == 2:
return utils.generic_error("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 +65,21 @@ 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", "")
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