#!/usr/bin/env python3 """ NetDesk Server - signaling + relay + web viewer host. Runs on a VPS (Linux/Windows) or locally. Stateless: nothing is stored on disk. - Hosts register by ID and keep one WebSocket open (outbound only, NAT-friendly). - Viewers load the web client from this server, authenticate through a host-generated challenge/response (the server never sees the password), then audio/video/input/file traffic is relayed opaquely between the two. - With end-to-end encryption enabled (default), even this server cannot see the screen: media is encrypted host->viewer with an ephemeral E2E key. Usage: python netdesk_server.py [--port 8443] [--cert cert.pem --key key.pem] [--reg-key SECRET] [--status-key SECRET] Without --cert the server runs plain HTTP/WS (fine for localhost/LAN). For internet use, put a real certificate behind it (Let's Encrypt) or use --gen-cert --domain your.domain for a self-signed one (browser warning). """ import argparse import asyncio import json import logging import os import ssl import time import uuid from aiohttp import web, WSMsgType log = logging.getLogger("netdesk.server") VERSION = "1.5.0-netdesk" import sys _web_base = getattr(sys, "_MEIPASS", None) or os.path.dirname(os.path.abspath(__file__)) WEB_DIR = os.path.join(_web_base, "web") # ---------------------------------------------------------------- metrics class Stats: def __init__(self): self.started = time.time() self.bytes_relayed = 0 self.sessions_total = 0 STATS = Stats() class Host: """A registered host connection.""" def __init__(self, ws, hid, name, ver): self.ws = ws self.id = hid self.name = name self.ver = ver self.connected_at = time.time() self.session = None # active/pending Session self.telemetry = {} # latest CPU/RAM/disk/battery sample (portal) class Session: def __init__(self, sid, host, viewer_ws): self.sid = sid self.host = host self.viewer_ws = viewer_ws self.active = False self.plan = "guest" self.vlimit = None self.vemail = "" self.vip = "" self.vgeo = {} self.started = time.time() def other(self, ws): return self.viewer_ws if ws is self.host.ws else self.host.ws HOSTS: dict[str, Host] = {} # id -> Host (online right now) # ---------------------------------------------------------------- portal state import secrets as _secrets import collections KNOWN_HOSTS: dict[str, dict] = {} # id -> registry entry (survives disconnects, in-memory) EVENTS = collections.deque(maxlen=200) PORTAL_SESSIONS: set[str] = set() # ---------------------------------------------------------------- brute-force throttle # Per-(scope, source-IP) failed-attempt counter with decay + lockout, mirroring # RustDesk's LOGIN_FAILURES model (MAX_CONSECUTIVE_FAILURES=10). A failed # password/portal login records a failure; too many within the window locks the # IP out for a cooldown; a success clears it. In-memory (resets on restart), # which is fine for an abuse brake. Scopes keep account- and portal-login # counters independent so one can't lock the other out. _LOGIN_FAILS: dict[tuple, list] = {} # (scope, ip) -> [count, first_ts, locked_until] LOGIN_MAX_FAILS = 10 # failures before lockout LOGIN_WINDOW = 300.0 # seconds the count accumulates over LOGIN_LOCKOUT = 300.0 # seconds locked out once tripped def login_locked(scope: str, ip: str) -> float: """Seconds remaining on an active lockout for (scope, ip), else 0.0.""" rec = _LOGIN_FAILS.get((scope, ip)) if not rec: return 0.0 now = time.time() if rec[2] > now: return rec[2] - now # expired lockout, or a stale window → clear it if rec[2] and rec[2] <= now: _LOGIN_FAILS.pop((scope, ip), None) elif now - rec[1] > LOGIN_WINDOW: _LOGIN_FAILS.pop((scope, ip), None) return 0.0 def login_fail(scope: str, ip: str) -> None: """Record one failed attempt; trip a lockout at LOGIN_MAX_FAILS in-window.""" now = time.time() rec = _LOGIN_FAILS.get((scope, ip)) if not rec or (now - rec[1] > LOGIN_WINDOW and rec[2] <= now): _LOGIN_FAILS[(scope, ip)] = [1, now, 0.0] return rec[0] += 1 if rec[0] >= LOGIN_MAX_FAILS: rec[2] = now + LOGIN_LOCKOUT log_event("auth", f"lockout: {scope} from {ip} after {rec[0]} failures") def login_ok(scope: str, ip: str) -> None: """Clear the counter for (scope, ip) after a successful login.""" _LOGIN_FAILS.pop((scope, ip), None) def req_ip(req) -> str: """Client IP, preferring the forwarded header (behind nginx req.remote is 127.0.0.1).""" return ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() or req.remote or "?") # ---------------------------------------------------------------- user accounts (email sign-in for viewers) import sqlite3 import smtplib from email.message import EmailMessage as _MailMsg def _nd_data_root(): """Directory holding persistent state (accounts.db, audit.log, files/). `ND_DATA_DIR` overrides it — used by the Docker image to point at a mounted volume — otherwise it's the executable/script dir, exactly as before, so the systemd deployment is unaffected.""" d = os.environ.get("ND_DATA_DIR") if d: os.makedirs(d, exist_ok=True) return d return (os.path.dirname(sys.executable) if getattr(sys, "frozen", False) else os.path.dirname(os.path.abspath(__file__))) ACCOUNTS_DB = os.path.join(_nd_data_root(), "accounts.db") USER_SESSIONS = {} # token -> email (cache; tokens persist in the db) def _acct_db(): conn = sqlite3.connect(ACCOUNTS_DB) conn.execute( "CREATE TABLE IF NOT EXISTS users(id INTEGER PRIMARY KEY AUTOINCREMENT," " email TEXT UNIQUE NOT NULL, pw_salt TEXT NOT NULL, pw_hash TEXT NOT NULL," " verified INTEGER NOT NULL DEFAULT 0, verify_token TEXT," " reset_token TEXT, reset_exp INTEGER, created INTEGER NOT NULL)") # login tokens survive server restarts (browser cookie + desktop app bearer) conn.execute( "CREATE TABLE IF NOT EXISTS tokens(tok TEXT PRIMARY KEY, email TEXT NOT NULL," " created INTEGER NOT NULL)") # computers bound to an account (like RustDesk's address book / device list) conn.execute( "CREATE TABLE IF NOT EXISTS devices(id TEXT PRIMARY KEY, owner TEXT NOT NULL," " name TEXT, ver TEXT, last_seen INTEGER)") try: # WoL support (added 1.5) - MAC of the host's primary NIC conn.execute("ALTER TABLE devices ADD COLUMN mac TEXT DEFAULT ''") except Exception: pass try: # portal: hide a host from the guest/public surface conn.execute("ALTER TABLE devices ADD COLUMN hidden INTEGER DEFAULT 0") except Exception: pass try: # address book: comma-separated tags (e.g. "prod,linux") conn.execute("ALTER TABLE devices ADD COLUMN tags TEXT DEFAULT ''") except Exception: pass try: # address book: a group / folder name conn.execute("ALTER TABLE devices ADD COLUMN grp TEXT DEFAULT ''") except Exception: pass try: # RBAC: 'admin' sees/manages all devices; default 'member' conn.execute("ALTER TABLE users ADD COLUMN role TEXT DEFAULT 'member'") except Exception: pass try: # account 2FA: base32 TOTP secret (empty = disabled) conn.execute("ALTER TABLE users ADD COLUMN totp TEXT DEFAULT ''") except Exception: pass try: # SCIM: IdP-deactivated accounts cannot sign in conn.execute("ALTER TABLE users ADD COLUMN disabled INTEGER DEFAULT 0") except Exception: pass try: # SCIM externalId (IdP correlation) conn.execute("ALTER TABLE users ADD COLUMN scim_ext TEXT DEFAULT ''") except Exception: pass # REST API keys (X-API-Key header -> owner account) + outbound webhooks conn.execute( "CREATE TABLE IF NOT EXISTS api_keys(key TEXT PRIMARY KEY, owner TEXT NOT NULL," " name TEXT, created INTEGER NOT NULL, last_used INTEGER DEFAULT 0)") conn.execute( "CREATE TABLE IF NOT EXISTS webhooks(id INTEGER PRIMARY KEY AUTOINCREMENT," " owner TEXT NOT NULL, url TEXT NOT NULL, events TEXT DEFAULT '*', created INTEGER NOT NULL)") # seat/licensing: per-account cap on concurrently-registered hosts (0 = unlimited) conn.execute( "CREATE TABLE IF NOT EXISTS licenses(owner TEXT PRIMARY KEY, seats INTEGER NOT NULL DEFAULT 0," " plan TEXT DEFAULT '', updated INTEGER NOT NULL DEFAULT 0)") # MDM-lite: per-account managed policy pushed to hosts at registration conn.execute( "CREATE TABLE IF NOT EXISTS account_policy(owner TEXT PRIMARY KEY," " policy TEXT NOT NULL DEFAULT '{}', updated INTEGER NOT NULL DEFAULT 0)") # teams: shared address books / device lists across multiple operators conn.execute( "CREATE TABLE IF NOT EXISTS teams(id INTEGER PRIMARY KEY AUTOINCREMENT," " name TEXT NOT NULL, created_by TEXT NOT NULL, created INTEGER NOT NULL)") conn.execute( "CREATE TABLE IF NOT EXISTS team_members(team_id INTEGER NOT NULL, email TEXT NOT NULL," " role TEXT NOT NULL DEFAULT 'member', added INTEGER NOT NULL," " PRIMARY KEY(team_id,email))") conn.execute( "CREATE TABLE IF NOT EXISTS team_devices(team_id INTEGER NOT NULL, device_id TEXT NOT NULL," " added_by TEXT, added INTEGER NOT NULL, PRIMARY KEY(team_id,device_id))") # conditional-access rules: first-match-wins allow/deny, evaluated at session.begin conn.execute( "CREATE TABLE IF NOT EXISTS access_rules(id INTEGER PRIMARY KEY AUTOINCREMENT," " owner TEXT NOT NULL, priority INTEGER NOT NULL DEFAULT 100," " action TEXT NOT NULL DEFAULT 'deny', subject_type TEXT NOT NULL DEFAULT 'any'," " subject TEXT DEFAULT '', cidr TEXT DEFAULT '', weekdays TEXT DEFAULT ''," " time_start INTEGER, time_end INTEGER, tz_offset INTEGER DEFAULT 0," " reason TEXT DEFAULT '', enabled INTEGER NOT NULL DEFAULT 1," " created_by TEXT, created INTEGER NOT NULL DEFAULT 0)") # SCIM 2.0 provisioning tokens (Bearer) minted by an admin conn.execute( "CREATE TABLE IF NOT EXISTS scim_tokens(token TEXT PRIMARY KEY, label TEXT," " created_by TEXT, created INTEGER NOT NULL DEFAULT 0, last_used INTEGER DEFAULT 0)") # central session recording: stored files + short-lived upload tokens conn.execute( "CREATE TABLE IF NOT EXISTS recordings(id INTEGER PRIMARY KEY AUTOINCREMENT," " owner TEXT, host_id TEXT, session_id TEXT, name TEXT, path TEXT, size INTEGER DEFAULT 0," " duration INTEGER DEFAULT 0, viewer TEXT, mime TEXT, created INTEGER NOT NULL DEFAULT 0)") conn.execute( "CREATE TABLE IF NOT EXISTS rec_tokens(token TEXT PRIMARY KEY, host_id TEXT, owner TEXT," " session_id TEXT, created INTEGER, exp INTEGER)") return conn # ---------------------------------------------------------------- portal intel RECENT_VISITORS = collections.deque(maxlen=120) # recent viewer sessions (+geo) GEO_CACHE: dict[str, dict] = {} # ip -> geo (best-effort, cached) async def geo_lookup(ip: str) -> dict: """Best-effort IP geolocation via ip-api.com, cached. Local/failed -> {}.""" try: import ipaddress a = ipaddress.ip_address(ip) if a.is_private or a.is_loopback or a.is_link_local: return {} except Exception: if not ip: return {} if ip in GEO_CACHE: return GEO_CACHE[ip] GEO_CACHE[ip] = {} # mark in-flight so we don't hammer the API try: import aiohttp url = ("http://ip-api.com/json/" + ip + "?fields=status,country,countryCode,regionName,city,isp,lat,lon") async with aiohttp.ClientSession() as s: async with s.get(url, timeout=aiohttp.ClientTimeout(total=5)) as r: d = await r.json() if isinstance(d, dict) and d.get("status") == "success": GEO_CACHE[ip] = { "country": d.get("country", ""), "cc": d.get("countryCode", ""), "region": d.get("regionName", ""), "city": d.get("city", ""), "isp": d.get("isp", ""), "lat": d.get("lat"), "lon": d.get("lon"), } except Exception: pass return GEO_CACHE.get(ip, {}) # ---------------------------------------------- relay tiers / nearest-relay geo _RELAYS_CACHE = None def _relays(): """Configured relay nodes [{id,region,url,lat,lon,tier}] for nearest-relay geo selection. Falls back to a single self-entry (this server) when --relays is unset, so selection always returns something on a one-node deployment.""" global _RELAYS_CACHE if _RELAYS_CACHE is not None: return _RELAYS_CACHE out = [] raw = (ARGS.relays or "").strip() if ARGS else "" if raw: try: arr = json.loads(raw) if isinstance(arr, list): for r in arr: if not isinstance(r, dict) or not r.get("url"): continue out.append({ "id": str(r.get("id", r.get("region", "relay"))), "region": str(r.get("region", "")), "url": str(r["url"]), "lat": float(r.get("lat", 0.0)), "lon": float(r.get("lon", 0.0)), "tier": str(r.get("tier", "")), }) except Exception as e: log.warning("relays: bad --relays JSON: %s", e) if not out: base = ARGS.public_url.rstrip("/") if (ARGS and ARGS.public_url) else "https://netdesk.space" ws = base.replace("https://", "wss://").replace("http://", "ws://") + "/ws" out = [{"id": "default", "region": "eu-central", "url": ws, "lat": 50.11, "lon": 8.68, "tier": ""}] # Frankfurt VPS _RELAYS_CACHE = out return out def _haversine_km(lat1, lon1, lat2, lon2): import math r = 6371.0 p1, p2 = math.radians(lat1), math.radians(lat2) dphi = math.radians(lat2 - lat1) dlmb = math.radians(lon2 - lon1) a = math.sin(dphi / 2) ** 2 + math.cos(p1) * math.cos(p2) * math.sin(dlmb / 2) ** 2 return 2 * r * math.asin(min(1.0, math.sqrt(a))) def _nearest_relay(lat, lon, tier=None): cands = _relays() if tier: filtered = [r for r in cands if not r.get("tier") or r["tier"] == tier] if filtered: cands = filtered best, bestd = None, None for r in cands: d = _haversine_km(lat, lon, r["lat"], r["lon"]) if bestd is None or d < bestd: best, bestd = r, d return best, bestd async def api_relays(req): """Public: the configured relay nodes (clients cache + may choose).""" return web.json_response({"relays": _relays()}) async def api_relay_best(req): """Nearest relay for the caller. Explicit ?lat=&lon= wins; else geo-locate the client IP; else the first/default relay. ?tier= filters by tier.""" tier = req.query.get("tier") or None lat = lon = None via = "default" try: if "lat" in req.query and "lon" in req.query: lat, lon = float(req.query["lat"]), float(req.query["lon"]) via = "param" except (TypeError, ValueError): lat = lon = None if lat is None: ip = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() or (req.remote or "")) geo = await geo_lookup(ip) if geo.get("lat") is not None and geo.get("lon") is not None: lat, lon = float(geo["lat"]), float(geo["lon"]) via = "geo" relays = _relays() if lat is None: return web.json_response({"relay": relays[0] if relays else None, "via": "default", "dist_km": None, "count": len(relays)}) best, dist = _nearest_relay(lat, lon, tier) return web.json_response({"relay": best, "via": via, "dist_km": round(dist, 1) if dist is not None else None, "count": len(relays)}) def is_hidden(hid: str) -> bool: try: with _acct_db() as conn: row = conn.execute("SELECT hidden FROM devices WHERE id=?", (hid,)).fetchone() return bool(row and row[0]) except Exception: return False def device_owner(hid: str) -> str: try: with _acct_db() as conn: row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() return (row[0] or "") if row else "" except Exception: return "" def set_hidden(hid: str, hidden: bool): with _acct_db() as conn: conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen) VALUES(?,?,?,?,?) " "ON CONFLICT(id) DO NOTHING", (hid, "", "", "", int(time.time()))) conn.execute("UPDATE devices SET hidden=? WHERE id=?", (1 if hidden else 0, hid)) conn.commit() def _new_token(email): tok = _secrets.token_urlsafe(32) with _acct_db() as conn: conn.execute("INSERT INTO tokens(tok,email,created) VALUES(?,?,?)", (tok, email, int(time.time()))) USER_SESSIONS[tok] = email return tok def email_for_token(tok): if not tok: return None if tok in USER_SESSIONS: return USER_SESSIONS[tok] with _acct_db() as conn: row = conn.execute("SELECT email FROM tokens WHERE tok=?", (tok,)).fetchone() if row: USER_SESSIONS[tok] = row[0] return row[0] return None def _drop_token(tok): USER_SESSIONS.pop(tok, None) with _acct_db() as conn: conn.execute("DELETE FROM tokens WHERE tok=?", (tok,)) def _req_token(req): auth = req.headers.get("Authorization", "") if auth.lower().startswith("bearer "): return auth[7:].strip() return req.cookies.get("nd_user", "") def _pbkdf(password, salt): import hashlib as _h return _h.pbkdf2_hmac("sha256", password.encode(), salt, 200_000).hex() def _mail_html(title, body_html, link=None, link_text=None): link_html = "" if link: link_html = ( '

' '' + link_text + "

" '

Or paste this link: ' + link + "

") logo = ('' '' '' '') return ( '
' '
' + logo + 'NetDesk
' '

' + title + "

" '
' + body_html + "
" + link_html + '

' "If you did not request this, ignore this email.

") def send_mail(to, subject, html): if not ARGS.mail_from: raise RuntimeError("email not configured on this server") msg = _MailMsg() msg["From"] = "NetDesk <" + ARGS.mail_from + ">" msg["To"] = to msg["Subject"] = subject msg.set_content("Your mail client does not support HTML.") msg.add_alternative(html, subtype="html") host = ARGS.smtp_host or "127.0.0.1" with smtplib.SMTP(host, 25, timeout=20) as smtp: smtp.send_message(msg) def user_from_cookie(req): """Signed-in account email from the nd_user cookie or a Bearer token.""" return email_for_token(_req_token(req)) def public_base(req): if ARGS.public_url: return ARGS.public_url.rstrip("/") proto = req.headers.get("X-Forwarded-Proto") or req.scheme return f"{proto}://{req.host}" async def _json_body(req): try: body = await req.json() return body if isinstance(body, dict) else {} except Exception: return {} def _login_response(email, extra=None): tok = _new_token(email) d = {"ok": True, "email": email, "token": tok, "owner": bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower())} d.update(extra or {}) resp = web.json_response(d) resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) return resp # ------------------------------------------------- TOTP (account + portal 2FA) # RFC 6238 (HMAC-SHA1, 30s step, 6 digits), pure-stdlib so there is no pyotp dep. def _totp_new_secret(): import base64 as _b64 return _b64.b32encode(os.urandom(20)).decode("ascii").rstrip("=") def _b32_decode(x): import base64 as _b64 x = (x or "").strip().replace(" ", "").upper() x += "=" * ((8 - len(x) % 8) % 8) return _b64.b32decode(x) def _totp_at(secret_b32, counter): import hmac as _hm, hashlib as _hl, struct as _st key = _b32_decode(secret_b32) h = _hm.new(key, _st.pack(">Q", counter), _hl.sha1).digest() o = h[-1] & 0x0F num = _st.unpack(">I", h[o:o + 4])[0] & 0x7FFF_FFFF return "%06d" % (num % 1_000_000) def _totp_verify(secret_b32, code, step=30, skew=1): code = (code or "").strip() if not secret_b32 or not code.isdigit() or len(code) != 6: return False counter = int(time.time()) // step for d in range(-skew, skew + 1): try: if _secrets.compare_digest(_totp_at(secret_b32, counter + d), code): return True except Exception: return False return False def _otpauth_uri(account, secret_b32, issuer="NetDesk"): from urllib.parse import quote label = quote(issuer + ":" + account) return ("otpauth://totp/" + label + "?secret=" + secret_b32 + "&issuer=" + quote(issuer) + "&algorithm=SHA1&digits=6&period=30") async def auth_signup(req): body = await _json_body(req) email = str(body.get("email", "")).strip().lower() pw = str(body.get("password", "")) if "@" not in email or "." not in email.split("@")[-1] or len(email) < 5: return web.json_response({"error": "enter a valid email"}, status=400) if len(pw) < 8: return web.json_response({"error": "password needs 8+ characters"}, status=400) salt = _secrets.token_bytes(16) vtok = _secrets.token_urlsafe(24) now = int(time.time()) with _acct_db() as conn: try: conn.execute( "INSERT INTO users(email,pw_salt,pw_hash,verified,verify_token,created) " "VALUES(?,?,?,?,?,?)", (email, salt.hex(), _pbkdf(pw, salt), 0, vtok, now)) except sqlite3.IntegrityError: return web.json_response( {"error": "email already registered - sign in or reset password"}, status=409) log_event("auth", "signup: " + email) # verification mail is best-effort and never blocks sign-up (RustDesk-style: # the account works immediately; verifying only confirms the address) if ARGS.mail_from: link = public_base(req) + "/api/auth/verify?token=" + vtok def _send(): try: send_mail(email, "Verify your NetDesk account", _mail_html("Confirm your email", "Welcome to NetDesk. Your account is active - click below " "to confirm this email address.", link, "Verify my email")) except Exception as e: log.warning("verify mail to %s failed: %s", email, e) asyncio.get_running_loop().run_in_executor(None, _send) return _login_response(email, {"msg": "account created - you are signed in"}) async def auth_verify(req): tok = req.query.get("token", "") with _acct_db() as conn: row = conn.execute("SELECT id FROM users WHERE verify_token=? AND verified=0", (tok,)).fetchone() if not row: return web.Response(status=400, text="invalid or used link") conn.execute("UPDATE users SET verified=1, verify_token=NULL WHERE id=?", (row[0],)) log_event("auth", "email verified") raise web.HTTPFound(public_base(req) + "/app?verified=1") # ---------------------------------------------------------------- LDAP / AD # Minimal pure-stdlib LDAPv3 simple-bind authentication (no ldap3 dependency). # Enable with --ldap-url; the user's own credentials are bound against the # directory, and a local account is auto-provisioned on first success. def _ber_len(n): if n < 0x80: return bytes([n]) out = b"" while n: out = bytes([n & 0xFF]) + out n >>= 8 return bytes([0x80 | len(out)]) + out def _ber_tlv(tag, value): return bytes([tag]) + _ber_len(len(value)) + value def _ber_read_tlv(buf, off=0): """Read one TLV at buf[off:] -> (tag, value_bytes, next_off).""" tag = buf[off] off += 1 l0 = buf[off] off += 1 if l0 < 0x80: length = l0 else: nb = l0 & 0x7F length = int.from_bytes(buf[off:off + nb], "big") off += nb return tag, buf[off:off + length], off + length def _recvn(sock, n): out = b"" while len(out) < n: chunk = sock.recv(n - len(out)) if not chunk: raise IOError("LDAP connection closed") out += chunk return out def _ldap_read_result_code(sock): """Read one LDAPMessage, return the BindResponse resultCode (int).""" tag = _recvn(sock, 1)[0] l0 = _recvn(sock, 1)[0] if l0 < 0x80: length = l0 else: nb = l0 & 0x7F length = int.from_bytes(_recvn(sock, nb), "big") body = _recvn(sock, length) # SEQUENCE contents: messageID + protocolOp _t, _mid, off = _ber_read_tlv(body, 0) # messageID INTEGER optag, opval, _ = _ber_read_tlv(body, off) # protocolOp (BindResponse 0x61) rtag, rval, _ = _ber_read_tlv(opval, 0) # resultCode ENUMERATED (0x0a) return rval[0] if rval else -1 def _ldap_bind_dn(email): tmpl = (ARGS.ldap_bind_template or "{email}") local = email.split("@")[0] domain = email.split("@", 1)[1] if "@" in email else "" return tmpl.replace("{user}", local).replace("{email}", email).replace("{domain}", domain) def _ldap_simple_bind(host, port, use_tls, dn, password, timeout=6, insecure=False): """Perform an LDAPv3 simple bind. Returns True iff resultCode == success(0).""" import socket, ssl version = _ber_tlv(0x02, bytes([3])) # version INTEGER 3 name = _ber_tlv(0x04, dn.encode("utf-8")) # LDAPDN auth = _ber_tlv(0x80, password.encode("utf-8")) # [0] simple bindreq = _ber_tlv(0x60, version + name + auth) # [APPLICATION 0] msg = _ber_tlv(0x30, _ber_tlv(0x02, bytes([1])) + bindreq) s = socket.create_connection((host, port), timeout=timeout) try: if use_tls: ctx = ssl.create_default_context() if insecure: ctx.check_hostname = False ctx.verify_mode = ssl.CERT_NONE s = ctx.wrap_socket(s, server_hostname=host) s.settimeout(timeout) s.sendall(msg) return _ldap_read_result_code(s) == 0 finally: try: s.close() except Exception: pass def _ldap_login_sync(email, pw): """Blocking LDAP auth (run in an executor). Empty password is always rejected so an LDAP 'unauthenticated bind' can never pass as success.""" if not ARGS.ldap_url or not email or not pw: return False try: import urllib.parse u = urllib.parse.urlparse(ARGS.ldap_url) use_tls = (u.scheme == "ldaps") host = u.hostname port = u.port or (636 if use_tls else 389) if not host: return False dn = _ldap_bind_dn(email) return _ldap_simple_bind(host, port, use_tls, dn, pw, ARGS.ldap_timeout or 6, bool(ARGS.ldap_insecure)) except Exception as e: log.warning("LDAP bind error for %s: %s", email, e) return False def _ldap_provision(email): """Ensure a local shadow account exists for an LDAP-authenticated user.""" now = int(time.time()) with _acct_db() as conn: if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (email,)).fetchone(): salt = _secrets.token_bytes(16) pw = _secrets.token_urlsafe(24) # unused; LDAP is the authority role = (ARGS.ldap_default_role or "member") conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) conn.commit() log_event("auth", f"LDAP provisioned account {email}") async def _ldap_try(email, pw): """True if LDAP is configured and the credentials bind successfully.""" if not ARGS.ldap_url: return False ok = await asyncio.get_running_loop().run_in_executor(None, _ldap_login_sync, email, pw) if ok: _ldap_provision(email) return ok async def auth_login(req): ip = req_ip(req) locked = login_locked("account", ip) if locked: return web.json_response( {"error": f"too many attempts — try again in {int(locked) + 1}s"}, status=429) body = await _json_body(req) email = str(body.get("email", "")).strip().lower() pw = str(body.get("password", "")) with _acct_db() as conn: row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() local_ok = bool(row) and _secrets.compare_digest(_pbkdf(pw, bytes.fromhex(row[0])), row[1]) ldap_ok = False if not local_ok: # LDAP / Active Directory: bind the user's own credentials; on success # a local shadow account is auto-provisioned and we re-read the row. ldap_ok = await _ldap_try(email, pw) if ldap_ok: with _acct_db() as conn: row = conn.execute("SELECT pw_salt,pw_hash,verified,totp," "COALESCE(disabled,0) FROM users WHERE email=?", (email,)).fetchone() if not (local_ok or ldap_ok): login_fail("account", ip) await asyncio.sleep(0.4) return web.json_response({"error": "wrong email or password"}, status=401) if row and row[4]: # SCIM/IdP-deactivated account return web.json_response({"error": "this account has been deactivated"}, status=403) # Account 2FA: when enrolled, a valid TOTP code is also required. The client # re-POSTs with "totp" after seeing the twofa flag. sec = (row[3] if len(row) > 3 else "") or "" if sec: code = str(body.get("totp", "")).strip() if not code: return web.json_response({"error": "2FA code required", "twofa": True}, status=401) if not _totp_verify(sec, code): login_fail("account", ip) await asyncio.sleep(0.4) return web.json_response({"error": "invalid 2FA code", "twofa": True}, status=401) login_ok("account", ip) log_event("auth", "signed in: " + email + (" (ldap)" if ldap_ok else "") + (" (app)" if body.get("app") else "")) return _login_response(email) async def auth_me(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) owner = bool(ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) return web.json_response({"email": email, "owner": owner, "plan": plan_for(email)[0], "limit": plan_for(email)[1] or 0}) async def auth_logout(req): _drop_token(_req_token(req)) resp = web.json_response({"ok": True}) resp.del_cookie("nd_user") return resp # ------------------------------------------------- account 2FA (TOTP) endpoints async def auth_2fa_status(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() return web.json_response({"enabled": bool(row and row[0])}) async def auth_2fa_setup(req): """Generate a NEW (pending) secret + otpauth URI. Not saved until enable confirms a code, so the client holds the secret between setup and enable.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) secret = _totp_new_secret() return web.json_response({"secret": secret, "otpauth": _otpauth_uri(email, secret)}) async def auth_2fa_enable(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) secret = str(body.get("secret", "")).strip() code = str(body.get("code", "")).strip() if not secret or not _totp_verify(secret, code): return web.json_response({"error": "invalid code"}, status=400) with _acct_db() as conn: conn.execute("UPDATE users SET totp=? WHERE email=?", (secret, email)) conn.commit() log_event("auth", "2FA enabled: " + email) return web.json_response({"ok": True, "enabled": True}) async def auth_2fa_disable(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) with _acct_db() as conn: row = conn.execute("SELECT totp FROM users WHERE email=?", (email,)).fetchone() sec = (row[0] if row else "") or "" if sec and not _totp_verify(sec, str(body.get("code", "")).strip()): return web.json_response({"error": "invalid code"}, status=400) with _acct_db() as conn: conn.execute("UPDATE users SET totp='' WHERE email=?", (email,)) conn.commit() log_event("auth", "2FA disabled: " + email) return web.json_response({"ok": True, "enabled": False}) # ------------------------------------------------- SSO: OpenID Connect (OIDC) # Google / Azure / Okta / any OIDC provider. Configure with --oidc (or ND_OIDC): # {"google":{"issuer":"https://accounts.google.com","client_id":"...","client_secret":"..."}} # Discovery (/.well-known/openid-configuration) gives the authorize/token/jwks # endpoints; the ID token is verified (RS256 via JWKS + iss/aud/exp/nonce), then # the account is linked by email. PKCE (S256) + state/nonce guard the flow. _OIDC_STATE = {} # state -> (provider, nonce, pkce_verifier, ts) _OIDC_DISCO = {} # issuer -> (doc, ts) _OIDC_JWKS = {} # jwks_uri -> (keys_by_kid, ts) def _oidc_providers(): raw = getattr(ARGS, "oidc", "") or os.environ.get("ND_OIDC", "") try: return json.loads(raw) if raw else {} except Exception: return {} def _b64url_decode(s): import base64 s += "=" * (-len(s) % 4) return base64.urlsafe_b64decode(s) async def _http_json(url, method="GET", data=None): import aiohttp async with aiohttp.ClientSession() as s: if method == "POST": cm = s.post(url, data=data, timeout=aiohttp.ClientTimeout(total=8)) else: cm = s.get(url, timeout=aiohttp.ClientTimeout(total=8)) async with cm as r: return await r.json(content_type=None) async def _oidc_discovery(issuer): now = time.time() c = _OIDC_DISCO.get(issuer) if c and now - c[1] < 3600: return c[0] doc = await _http_json(issuer.rstrip("/") + "/.well-known/openid-configuration") _OIDC_DISCO[issuer] = (doc, now) return doc async def _oidc_jwks(jwks_uri): now = time.time() c = _OIDC_JWKS.get(jwks_uri) if c and now - c[1] < 3600: return c[0] doc = await _http_json(jwks_uri) keys = {k.get("kid"): k for k in doc.get("keys", [])} _OIDC_JWKS[jwks_uri] = (keys, now) return keys def _verify_oidc_jwt(id_token, jwks, issuer, client_id, nonce): """Verify an OIDC ID token: RS256 signature (via JWKS) + iss/aud/exp/nonce. Returns the claims dict, or raises ValueError.""" from cryptography.hazmat.primitives.asymmetric import rsa, padding from cryptography.hazmat.primitives import hashes parts = id_token.split(".") if len(parts) != 3: raise ValueError("malformed") header = json.loads(_b64url_decode(parts[0])) payload = json.loads(_b64url_decode(parts[1])) sig = _b64url_decode(parts[2]) kid = header.get("kid") jwk = jwks.get(kid) or (next(iter(jwks.values())) if len(jwks) == 1 else None) if not jwk: raise ValueError("no JWKS key") n = int.from_bytes(_b64url_decode(jwk["n"]), "big") e = int.from_bytes(_b64url_decode(jwk["e"]), "big") pub = rsa.RSAPublicNumbers(e, n).public_key() try: pub.verify(sig, (parts[0] + "." + parts[1]).encode(), padding.PKCS1v15(), hashes.SHA256()) except Exception: raise ValueError("bad signature") if issuer and payload.get("iss", "").rstrip("/") != issuer.rstrip("/"): raise ValueError("bad iss") aud = payload.get("aud") if client_id and client_id not in (aud if isinstance(aud, list) else [aud]): raise ValueError("bad aud") if float(payload.get("exp", 0)) < time.time(): raise ValueError("expired") if nonce and payload.get("nonce") != nonce: raise ValueError("bad nonce") return payload async def auth_oidc_start(req): """Begin SSO: redirect the browser to the provider's authorize endpoint.""" provider = req.match_info.get("provider", "") cfg = _oidc_providers().get(provider) if not cfg: return web.json_response({"error": "SSO provider not configured"}, status=404) import hashlib import base64 as _b from urllib.parse import urlencode disco = await _oidc_discovery(cfg["issuer"]) state = _secrets.token_urlsafe(24) nonce = _secrets.token_urlsafe(16) verifier = _secrets.token_urlsafe(48) challenge = _b.urlsafe_b64encode(hashlib.sha256(verifier.encode()).digest()).rstrip(b"=").decode() now = time.time() _OIDC_STATE[state] = (provider, nonce, verifier, now) for k in [k for k, v in _OIDC_STATE.items() if now - v[3] > 600]: _OIDC_STATE.pop(k, None) redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" url = disco["authorization_endpoint"] + "?" + urlencode({ "client_id": cfg["client_id"], "redirect_uri": redirect_uri, "response_type": "code", "scope": "openid email profile", "state": state, "nonce": nonce, "code_challenge": challenge, "code_challenge_method": "S256", }) raise web.HTTPFound(url) async def auth_oidc_callback(req): """Finish SSO: exchange the code, verify the ID token, link the account.""" provider = req.match_info.get("provider", "") cfg = _oidc_providers().get(provider) if not cfg: return web.json_response({"error": "SSO provider not configured"}, status=404) code = req.query.get("code", "") state = req.query.get("state", "") st = _OIDC_STATE.pop(state, None) if not st or st[0] != provider: return web.json_response({"error": "invalid state"}, status=400) _, nonce, verifier, _ = st disco = await _oidc_discovery(cfg["issuer"]) redirect_uri = public_base(req) + f"/api/auth/oidc/{provider}/callback" tok = await _http_json(disco["token_endpoint"], "POST", { "grant_type": "authorization_code", "code": code, "redirect_uri": redirect_uri, "client_id": cfg["client_id"], "client_secret": cfg.get("client_secret", ""), "code_verifier": verifier, }) id_token = tok.get("id_token") if not id_token: return web.json_response({"error": "no id_token from provider"}, status=502) jwks = await _oidc_jwks(disco["jwks_uri"]) try: payload = _verify_oidc_jwt(id_token, jwks, cfg["issuer"], cfg["client_id"], nonce) except ValueError as ex: return web.json_response({"error": f"id token invalid: {ex}"}, status=401) email = (payload.get("email") or "").strip().lower() if not email: return web.json_response({"error": "no email in id token"}, status=401) with _acct_db() as conn: row = conn.execute("SELECT email FROM users WHERE email=?", (email,)).fetchone() if not row: salt = _secrets.token_bytes(16) pw = _secrets.token_urlsafe(24) # random; SSO users never use a password conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created) VALUES(?,?,?,1,?)", (email, salt.hex(), _pbkdf(pw, salt), int(time.time()))) log_event("auth", f"SSO sign-in ({provider}): {email}") tokv = _new_token(email) resp = web.HTTPFound(public_base(req) + "/app") resp.set_cookie("nd_user", tokv, httponly=True, samesite="Lax", max_age=365 * 86400) return resp # ------------------------------------------- REST API (X-API-Key) + webhooks def owner_from_api_key(req): """Account email behind an X-API-Key header, or None. Touches last_used.""" key = req.headers.get("X-API-Key", "").strip() if not key: return None with _acct_db() as conn: row = conn.execute("SELECT owner FROM api_keys WHERE key=?", (key,)).fetchone() if row: conn.execute("UPDATE api_keys SET last_used=? WHERE key=?", (int(time.time()), key)) return row[0] return None async def _post_webhook(url, body): try: import aiohttp async with aiohttp.ClientSession() as s: await s.post(url, data=body, headers={"Content-Type": "application/json"}, timeout=aiohttp.ClientTimeout(total=5)) except Exception: pass def fire_webhook(owner, event, payload): """Deliver an event (connect/disconnect/file/test) to the owner's webhooks that subscribe to it. Fire-and-forget on the running loop.""" if not owner: return try: with _acct_db() as conn: hooks = conn.execute("SELECT url,events FROM webhooks WHERE owner=?", (owner,)).fetchall() except Exception: return urls = [u for (u, ev) in hooks if not ev or ev == "*" or event in [e.strip() for e in ev.split(",")]] if not urls: return body = json.dumps({"event": event, "ts": int(time.time()), **payload}) try: loop = asyncio.get_running_loop() except RuntimeError: return for u in urls: loop.create_task(_post_webhook(u, body)) def _device_owner(hid): with _acct_db() as conn: row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() return row[0] if row else None # ---- seat/licensing + MDM-lite policy (relay admission + central push) ---- # Known policy keys the native host honors (viewer/src/host_ctl.rs apply_policy): # server, reg_key, allow_ips, totp (strings), lock_on_end, approve_click (bools), # perms{input,clipboard,files,audio}. The server stores + pushes the SAME json an # admin would otherwise drop at %ProgramData%/NetDesk/policy.json on each machine. _POLICY_KEYS = {"server", "reg_key", "allow_ips", "totp", "lock_on_end", "approve_click", "perms", "record_required"} def _owner_seats(owner): """Max concurrently-registered hosts for an account; 0 = unlimited.""" if not owner: return 0 try: with _acct_db() as conn: row = conn.execute("SELECT seats FROM licenses WHERE lower(owner)=lower(?)", (owner,)).fetchone() return int(row[0]) if row and row[0] else 0 except Exception: return 0 def _owner_online_hosts(owner, exclude=None): """Set of currently-connected host IDs owned by `owner` (minus `exclude`).""" if not owner: return set() try: with _acct_db() as conn: rows = conn.execute("SELECT id FROM devices WHERE lower(owner)=lower(?)", (owner,)).fetchall() except Exception: return set() owned = {r[0] for r in rows} out = set() for hid in list(HOSTS.keys()): if hid == exclude or hid not in owned: continue h = HOSTS.get(hid) if h is None: continue try: if h.ws.closed: continue except Exception: pass out.add(hid) return out def _seat_ok(owner, hid): """True if admitting host `hid` for `owner` stays within the seat limit. Re-registration of an already-online hid keeps its own seat (exclude=hid).""" seats = _owner_seats(owner) if seats <= 0: return True return len(_owner_online_hosts(owner, exclude=hid)) < seats def _valid_policy(obj): """Drop unrecognized keys so a push can't inject junk the host ignores anyway.""" if not isinstance(obj, dict): return {} return {k: v for k, v in obj.items() if k in _POLICY_KEYS} def _account_policy(owner): """Owner's MDM-lite policy dict (empty if unset).""" if not owner: return {} try: with _acct_db() as conn: row = conn.execute("SELECT policy FROM account_policy WHERE lower(owner)=lower(?)", (owner,)).fetchone() return json.loads(row[0]) if row and row[0] else {} except Exception: return {} def _set_account_policy(owner, policy): """Store a validated policy for `owner`; returns what was saved.""" pj = json.dumps(_valid_policy(policy)) with _acct_db() as conn: conn.execute("INSERT INTO account_policy(owner,policy,updated) VALUES(?,?,?) " "ON CONFLICT(owner) DO UPDATE SET policy=excluded.policy," " updated=excluded.updated", ((owner or "").strip().lower(), pj, int(time.time()))) conn.commit() return json.loads(pj) # ---- API-key management (session-authed, from the logged-in portal) ---- async def api_keys_create(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) name = str(body.get("name", "")).strip()[:64] or "key" key = "ndk_" + _secrets.token_urlsafe(32) with _acct_db() as conn: conn.execute("INSERT INTO api_keys(key,owner,name,created) VALUES(?,?,?,?)", (key, email, name, int(time.time()))) log_event("apikey", f"{email} created API key '{name}'") return web.json_response({"ok": True, "key": key, "name": name}) # shown once async def api_keys_list(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: rows = conn.execute("SELECT key,name,created,last_used FROM api_keys WHERE owner=? " "ORDER BY created DESC", (email,)).fetchall() out = [{"name": n, "created": c, "last_used": lu, "key_preview": k[:12] + "…" + k[-4:]} for (k, n, c, lu) in rows] return web.json_response({"keys": out}) async def api_keys_revoke(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) k = str(body.get("key", "")).strip() with _acct_db() as conn: if k.startswith("ndk_") and "…" not in k: cur = conn.execute("DELETE FROM api_keys WHERE key=? AND owner=?", (k, email)) else: pref = k.split("…")[0] cur = conn.execute("DELETE FROM api_keys WHERE owner=? AND key LIKE ?", (email, pref + "%")) return web.json_response({"ok": True, "revoked": cur.rowcount}) # ---- /api/v1 (X-API-Key authed REST API) ---- async def v1_devices(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) with _acct_db() as conn: rows = conn.execute("SELECT id,name,ver,last_seen,tags,grp FROM devices WHERE owner=? " "ORDER BY name", (owner,)).fetchall() out = [] for hid, name, ver, seen, tags, grp in rows: h = HOSTS.get(hid) out.append({"id": hid, "name": name or hid, "ver": ver or "", "online": h is not None and not h.ws.closed, "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], "group": grp or "", "last_seen": seen or 0}) return web.json_response({"devices": out}) async def v1_webhooks_list(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) with _acct_db() as conn: rows = conn.execute("SELECT id,url,events,created FROM webhooks WHERE owner=? ORDER BY id", (owner,)).fetchall() return web.json_response({"webhooks": [{"id": i, "url": u, "events": ev, "created": c} for (i, u, ev, c) in rows]}) async def v1_webhooks_add(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) body = await _json_body(req) url = str(body.get("url", "")).strip() if not (url.startswith("http://") or url.startswith("https://")): return web.json_response({"error": "url must be http(s)"}, status=400) events = str(body.get("events", "*")).strip() or "*" with _acct_db() as conn: cur = conn.execute("INSERT INTO webhooks(owner,url,events,created) VALUES(?,?,?,?)", (owner, url, events, int(time.time()))) wid = cur.lastrowid log_event("webhook", f"{owner} added webhook {url} [{events}]") return web.json_response({"ok": True, "id": wid, "url": url, "events": events}) async def v1_webhooks_remove(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) body = await _json_body(req) with _acct_db() as conn: cur = conn.execute("DELETE FROM webhooks WHERE id=? AND owner=?", (body.get("id"), owner)) return web.json_response({"ok": True, "removed": cur.rowcount}) async def v1_webhooks_test(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "invalid or missing X-API-Key"}, status=401) fire_webhook(owner, "test", {"message": "NetDesk webhook test", "owner": owner}) return web.json_response({"ok": True, "fired": "test"}) def _user_role(conn, email): """RBAC role of an account ('admin' or 'member').""" row = conn.execute("SELECT role FROM users WHERE email=?", (email,)).fetchone() return row[0] if row and row[0] else "member" async def api_devices(req): """Computers bound to the signed-in account (address book), with live online state + tags/group. Filters: ?tag= / ?group=. An admin may pass ?scope=all to list every account's devices (RBAC).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) tag = req.query.get("tag", "").strip().lower() group = req.query.get("group", "").strip() scope_all = req.query.get("scope", "") == "all" with _acct_db() as conn: role = _user_role(conn, email) admin_all = scope_all and role == "admin" if admin_all: rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " "ORDER BY grp,name").fetchall() else: rows = conn.execute("SELECT id,name,ver,last_seen,mac,tags,grp,owner FROM devices " "WHERE owner=? ORDER BY grp,name", (email,)).fetchall() out = [] groups = set() for hid, name, ver, seen, mac, tags, grp, owner in rows: tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] if tag and tag not in [t.lower() for t in tag_list]: continue if group and (grp or "") != group: continue groups.add(grp or "") h = HOSTS.get(hid) out.append({"id": hid, "name": name or hid, "ver": ver or "", "online": h is not None and not h.ws.closed, "busy": bool(h and h.session is not None), "mac": mac or "", "tags": tag_list, "group": grp or "", "owner": owner if admin_all else None, "last_seen": seen or 0}) # also surface devices shared into any team the user belongs to (shared # address book) - deduped vs the user's own devices, flagged shared + team. if not admin_all: have = {d["id"] for d in out} with _acct_db() as tconn: trows = tconn.execute( "SELECT d.id,d.name,d.ver,d.last_seen,d.mac,d.tags,d.grp,d.owner,t.name " "FROM team_members m JOIN team_devices td ON td.team_id=m.team_id " "JOIN devices d ON d.id=td.device_id JOIN teams t ON t.id=m.team_id " "WHERE lower(m.email)=lower(?) ORDER BY t.name,d.grp,d.name", (email,)).fetchall() for hid, name, ver, seen, mac, tags, grp, owner, tname in trows: if hid in have: continue have.add(hid) tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] if tag and tag not in [t.lower() for t in tag_list]: continue if group and (grp or "") != group: continue groups.add(grp or "") h = HOSTS.get(hid) out.append({"id": hid, "name": name or hid, "ver": ver or "", "online": h is not None and not h.ws.closed, "busy": bool(h and h.session is not None), "mac": mac or "", "tags": tag_list, "group": grp or "", "owner": owner, "shared": True, "team": tname, "last_seen": seen or 0}) return web.json_response({"devices": out, "role": role, "groups": sorted(g for g in groups if g)}) # ===================== teams (shared address books) ===================== def _user_teams(conn, email): """[(team_id, team_name, my_role)] for every team the account belongs to.""" return conn.execute( "SELECT t.id, t.name, m.role FROM teams t JOIN team_members m ON m.team_id=t.id " "WHERE lower(m.email)=lower(?) ORDER BY t.name", (email,)).fetchall() def _team_role(conn, team_id, email): row = conn.execute("SELECT role FROM team_members WHERE team_id=? AND lower(email)=lower(?)", (team_id, email)).fetchone() return row[0] if row else None def _can_manage_team(conn, team_id, email): """True if the caller may administer the team: its owner, or an account admin.""" if _user_role(conn, email) == "admin": return True return _team_role(conn, team_id, email) == "owner" def _team_id_arg(v): try: return int(v) except (TypeError, ValueError): return None async def api_teams(req): """Teams the signed-in account belongs to (shared address books).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: rows = _user_teams(conn, email) out = [] for tid, name, myrole in rows: nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] cb = conn.execute("SELECT created_by, created FROM teams WHERE id=?", (tid,)).fetchone() out.append({"id": tid, "name": name, "role": myrole, "members": nm, "devices": nd, "created_by": cb[0] if cb else "", "created": cb[1] if cb else 0}) return web.json_response({"teams": out}) async def api_team_create(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) name = str(body.get("name", "")).strip()[:80] if not name: return web.json_response({"error": "team name required"}, status=400) now = int(time.time()) with _acct_db() as conn: cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", (name, email, now)) tid = cur.lastrowid conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", (tid, email, "owner", now)) conn.commit() log_event("team", f"{email} created team '{name}' (#{tid})", email) return web.json_response({"ok": True, "id": tid, "name": name}) async def api_team_rename(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) name = str(body.get("name", "")).strip()[:80] if tid is None or not name: return web.json_response({"error": "team_id + name required"}, status=400) with _acct_db() as conn: if not _can_manage_team(conn, tid, email): return web.json_response({"error": "not a team owner"}, status=403) conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) conn.commit() return web.json_response({"ok": True, "id": tid, "name": name}) async def api_team_delete(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) if tid is None: return web.json_response({"error": "team_id required"}, status=400) with _acct_db() as conn: if not _can_manage_team(conn, tid, email): return web.json_response({"error": "not a team owner"}, status=403) conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) conn.execute("DELETE FROM teams WHERE id=?", (tid,)) conn.commit() log_event("team", f"{email} deleted team #{tid}", email) return web.json_response({"ok": True}) async def api_team_members(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) tid = _team_id_arg(req.query.get("team_id")) if tid is None: return web.json_response({"error": "team_id required"}, status=400) with _acct_db() as conn: if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": return web.json_response({"error": "not a team member"}, status=403) rows = conn.execute("SELECT email, role, added FROM team_members WHERE team_id=? " "ORDER BY role DESC, email", (tid,)).fetchall() can = _can_manage_team(conn, tid, email) return web.json_response({"members": [{"email": e, "role": r, "added": a} for (e, r, a) in rows], "can_manage": can}) async def api_team_member_add(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) target = str(body.get("email", "")).strip().lower() role = str(body.get("role", "member")).strip().lower() if role not in ("owner", "member"): role = "member" if tid is None or not target: return web.json_response({"error": "team_id + email required"}, status=400) now = int(time.time()) with _acct_db() as conn: if not _can_manage_team(conn, tid, email): return web.json_response({"error": "not a team owner"}, status=403) if not conn.execute("SELECT 1 FROM users WHERE lower(email)=lower(?)", (target,)).fetchone(): return web.json_response({"error": "no NetDesk account with that email"}, status=404) conn.execute("INSERT INTO team_members(team_id,email,role,added) VALUES(?,?,?,?) " "ON CONFLICT(team_id,email) DO UPDATE SET role=excluded.role", (tid, target, role, now)) conn.commit() log_event("team", f"{email} added {target} to team #{tid} as {role}", email) return web.json_response({"ok": True}) async def api_team_member_remove(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) target = str(body.get("email", "")).strip().lower() if tid is None or not target: return web.json_response({"error": "team_id + email required"}, status=400) with _acct_db() as conn: # a member can remove themselves (leave); an owner/admin can remove anyone if target != email.lower() and not _can_manage_team(conn, tid, email): return web.json_response({"error": "not a team owner"}, status=403) owners = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=? AND role='owner'", (tid,)).fetchone()[0] if _team_role(conn, tid, target) == "owner" and owners <= 1: return web.json_response({"error": "a team needs at least one owner"}, status=400) conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?)", (tid, target)) conn.commit() log_event("team", f"{email} removed {target} from team #{tid}", email) return web.json_response({"ok": True}) async def api_team_devices(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) tid = _team_id_arg(req.query.get("team_id")) if tid is None: return web.json_response({"error": "team_id required"}, status=400) with _acct_db() as conn: if _team_role(conn, tid, email) is None and _user_role(conn, email) != "admin": return web.json_response({"error": "not a team member"}, status=403) rows = conn.execute( "SELECT d.id,d.name,d.ver,d.last_seen,d.tags,d.grp,d.owner FROM team_devices td " "JOIN devices d ON d.id=td.device_id WHERE td.team_id=? ORDER BY d.grp,d.name", (tid,)).fetchall() can = _can_manage_team(conn, tid, email) out = [] for hid, name, ver, seen, tags, grp, owner in rows: h = HOSTS.get(hid) out.append({"id": hid, "name": name or hid, "ver": ver or "", "online": h is not None and not h.ws.closed, "busy": bool(h and h.session is not None), "tags": [t.strip() for t in (tags or "").split(",") if t.strip()], "group": grp or "", "owner": owner, "last_seen": seen or 0}) return web.json_response({"devices": out, "can_manage": can}) async def api_team_device_add(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) hid = str(body.get("device_id", "")).strip() if tid is None or not hid: return web.json_response({"error": "team_id + device_id required"}, status=400) now = int(time.time()) with _acct_db() as conn: role = _user_role(conn, email) if _team_role(conn, tid, email) is None and role != "admin": return web.json_response({"error": "not a team member"}, status=403) drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() if not drow: return web.json_response({"error": "unknown device"}, status=404) if drow[0] != email and role != "admin": return web.json_response({"error": "not your device"}, status=403) conn.execute("INSERT OR IGNORE INTO team_devices(team_id,device_id,added_by,added) " "VALUES(?,?,?,?)", (tid, hid, email, now)) conn.commit() log_event("team", f"{email} shared device {hid} into team #{tid}", email) return web.json_response({"ok": True}) async def api_team_device_remove(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) tid = _team_id_arg(body.get("team_id")) hid = str(body.get("device_id", "")).strip() if tid is None or not hid: return web.json_response({"error": "team_id + device_id required"}, status=400) with _acct_db() as conn: drow = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() owns = bool(drow) and drow[0] == email if not owns and not _can_manage_team(conn, tid, email): return web.json_response({"error": "not allowed"}, status=403) conn.execute("DELETE FROM team_devices WHERE team_id=? AND device_id=?", (tid, hid)) conn.commit() return web.json_response({"ok": True}) # ===================== conditional-access rule engine ===================== def _device_owner_group(hid): try: with _acct_db() as conn: row = conn.execute("SELECT owner, grp FROM devices WHERE id=?", (hid,)).fetchone() if row: return (row[0] or ""), (row[1] or "") except Exception: pass return "", "" def _access_rules_for(conn, owner): rows = conn.execute( "SELECT id,priority,action,subject_type,subject,cidr,weekdays,time_start,time_end," "tz_offset,reason,enabled,created,created_by FROM access_rules " "WHERE lower(owner)=lower(?) ORDER BY priority, id", (owner,)).fetchall() out = [] for r in rows: out.append({"id": r[0], "priority": r[1], "action": r[2], "subject_type": r[3], "subject": r[4] or "", "cidr": r[5] or "", "weekdays": r[6] or "", "time_start": r[7], "time_end": r[8], "tz_offset": r[9] or 0, "reason": r[10] or "", "enabled": bool(r[11]), "created": r[12] or 0, "created_by": r[13] or ""}) return out def _ip_in_cidr(ip, cidr): try: import ipaddress return ipaddress.ip_address(ip) in ipaddress.ip_network(cidr, strict=False) except Exception: return False def _rule_matches(rule, viewer_email, host_id, host_group, ip, now_ts): st = rule["subject_type"] sub = (rule["subject"] or "").strip() if st == "user": if not viewer_email or viewer_email.lower() != sub.lower(): return False elif st == "device": if host_id != sub: return False elif st == "group": if (host_group or "") != sub: return False if rule["cidr"]: if not ip or not _ip_in_cidr(ip, rule["cidr"]): return False wd = (rule["weekdays"] or "").strip() ts, te = rule["time_start"], rule["time_end"] if wd or (ts is not None and te is not None): import datetime local = datetime.datetime.utcfromtimestamp(now_ts + (rule["tz_offset"] or 0) * 60) if wd: days = set(int(x) for x in wd.split(",") if x.strip().isdigit()) if local.weekday() not in days: # Monday=0 .. Sunday=6 return False if ts is not None and te is not None: minute = local.hour * 60 + local.minute if ts <= te: if not (ts <= minute <= te): return False else: # overnight window, e.g. 22:00-06:00 if not (minute >= ts or minute <= te): return False return True def _eval_access(owner, viewer_email, host_id, host_group, ip, now_ts): """First-match-wins, default ALLOW. Returns (allowed, reason, rule_id).""" if not owner: return True, "", None try: with _acct_db() as conn: rules = _access_rules_for(conn, owner) except Exception: return True, "", None for r in rules: if not r["enabled"]: continue if _rule_matches(r, viewer_email, host_id, host_group, ip, now_ts): if r["action"] == "deny": return False, (r["reason"] or "Access denied by policy"), r["id"] return True, "", r["id"] return True, "", None def _rule_int(v): try: return int(v) except (TypeError, ValueError): return None async def api_access_rules(req): """List the conditional-access rules protecting an account's hosts.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) target = (req.query.get("owner", "").strip().lower() or email.lower()) if target != email.lower(): with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only for other accounts"}, status=403) with _acct_db() as conn: rules = _access_rules_for(conn, target) return web.json_response({"owner": target, "rules": rules}) async def api_access_rule_set(req): """Create (no id) or update (id) an access rule. Owner-self, or admin for any.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) target = str(body.get("owner", "")).strip().lower() or email.lower() if target != email.lower(): with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only for other accounts"}, status=403) action = str(body.get("action", "deny")).strip().lower() if action not in ("allow", "deny"): return web.json_response({"error": "action must be allow|deny"}, status=400) st = str(body.get("subject_type", "any")).strip().lower() if st not in ("any", "user", "device", "group"): return web.json_response({"error": "bad subject_type"}, status=400) prio = _rule_int(body.get("priority")) if prio is None: prio = 100 ts = _rule_int(body.get("time_start")) te = _rule_int(body.get("time_end")) tz = _rule_int(body.get("tz_offset")) or 0 cidr = str(body.get("cidr", "")).strip() if cidr: try: import ipaddress ipaddress.ip_network(cidr, strict=False) except Exception: return web.json_response({"error": "invalid CIDR"}, status=400) wd = str(body.get("weekdays", "")).strip() subject = str(body.get("subject", "")).strip() reason = str(body.get("reason", "")).strip()[:200] enabled = 1 if body.get("enabled", True) else 0 rid = _rule_int(body.get("id")) now = int(time.time()) with _acct_db() as conn: if rid: row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() if not row or (row[0] or "").lower() != target: return web.json_response({"error": "unknown rule"}, status=404) conn.execute("UPDATE access_rules SET priority=?,action=?,subject_type=?,subject=?,cidr=?," "weekdays=?,time_start=?,time_end=?,tz_offset=?,reason=?,enabled=? WHERE id=?", (prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, rid)) else: cur = conn.execute("INSERT INTO access_rules(owner,priority,action,subject_type,subject," "cidr,weekdays,time_start,time_end,tz_offset,reason,enabled,created_by,created) " "VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?)", (target, prio, action, st, subject, cidr, wd, ts, te, tz, reason, enabled, email, now)) rid = cur.lastrowid conn.commit() log_event("access", f"{email} {'updated' if body.get('id') else 'added'} rule #{rid} " f"({action} {st} {subject or '*'}) for {target}", email) return web.json_response({"ok": True, "id": rid}) async def api_access_rule_delete(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) rid = _rule_int(body.get("id")) if rid is None: return web.json_response({"error": "id required"}, status=400) with _acct_db() as conn: row = conn.execute("SELECT owner FROM access_rules WHERE id=?", (rid,)).fetchone() if not row: return web.json_response({"error": "unknown rule"}, status=404) if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": return web.json_response({"error": "not your rule"}, status=403) conn.execute("DELETE FROM access_rules WHERE id=?", (rid,)) conn.commit() log_event("access", f"{email} deleted access rule #{rid}", email) return web.json_response({"ok": True}) async def api_access_test(req): """Dry-run the access decision for a hypothetical connection (rule tester).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) target = (req.query.get("owner", "").strip().lower() or email.lower()) if target != email.lower(): with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only for other accounts"}, status=403) viewer = req.query.get("viewer", "").strip() host_id = req.query.get("host", "").strip() group = req.query.get("group", "").strip() ip = req.query.get("ip", "").strip() now_ts = _rule_int(req.query.get("at")) or int(time.time()) ok, reason, rid = _eval_access(target, viewer, host_id, group, ip, now_ts) return web.json_response({"allowed": ok, "reason": reason, "rule_id": rid}) async def api_device_meta(req): """Set a device's address-book tags / group (and optional rename). Owner-only, or an admin for any device.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) try: data = await req.json() except Exception: data = dict(await req.post()) hid = (data.get("id") or "").strip() if not hid: return web.json_response({"error": "missing id"}, status=400) tags = (data.get("tags") or "").strip() grp = (data.get("group") or data.get("grp") or "").strip() name = data.get("name") with _acct_db() as conn: role = _user_role(conn, email) row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() if not row: return web.json_response({"error": "unknown device"}, status=404) if row[0] != email and role != "admin": return web.json_response({"error": "not your device"}, status=403) sets, vals = ["tags=?", "grp=?"], [tags, grp] if name is not None: sets.append("name=?") vals.append(str(name)) vals.append(hid) conn.execute("UPDATE devices SET " + ",".join(sets) + " WHERE id=?", vals) log_event("devmeta", f"{email} set {hid} tags='{tags}' group='{grp}'") return web.json_response({"ok": True, "id": hid, "tags": tags, "group": grp}) async def api_admin_role(req): """RBAC: an admin sets another account's role ('admin' | 'member').""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) try: data = await req.json() except Exception: data = dict(await req.post()) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) target = (data.get("email") or "").strip().lower() role = (data.get("role") or "").strip().lower() if role not in ("admin", "member"): return web.json_response({"error": "role must be admin|member"}, status=400) cur = conn.execute("UPDATE users SET role=? WHERE email=?", (role, target)) if cur.rowcount == 0: return web.json_response({"error": "unknown user"}, status=404) log_event("rbac", f"{email} set role of {target} -> {role}") return web.json_response({"ok": True, "email": target, "role": role}) # ---- MDM-lite policy + seat/licensing (REST, X-API-Key / admin session) ---- async def v1_policy_get(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "valid X-API-Key required"}, status=401) return web.json_response({ "policy": _account_policy(owner), "seats": _owner_seats(owner), "seats_in_use": len(_owner_online_hosts(owner)), "policy_keys": sorted(_POLICY_KEYS), }) async def v1_policy_set(req): owner = owner_from_api_key(req) if not owner: return web.json_response({"error": "valid X-API-Key required"}, status=401) body = await _json_body(req) pol = body.get("policy", body) # accept {"policy": {...}} or a bare object saved = _set_account_policy(owner, pol) log_event("policy", f"{owner} set MDM policy ({len(saved)} keys)") return web.json_response({"ok": True, "policy": saved}) async def api_admin_license(req): """Admin sets an account's seat limit (0 = unlimited).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) data = await _json_body(req) target = str(data.get("email", "")).strip().lower() try: seats = int(data.get("seats")) except (TypeError, ValueError): return web.json_response({"error": "seats must be an integer (0 = unlimited)"}, status=400) if not target or seats < 0: return web.json_response({"error": "email + seats>=0 required"}, status=400) with _acct_db() as conn: conn.execute("INSERT INTO licenses(owner,seats,updated) VALUES(?,?,?) " "ON CONFLICT(owner) DO UPDATE SET seats=excluded.seats," " updated=excluded.updated", (target, seats, int(time.time()))) conn.commit() log_event("license", f"{email} set seats of {target} -> {seats}") return web.json_response({"ok": True, "email": target, "seats": seats}) async def api_wake(req): """Wake-on-LAN: relay a wake request for an offline computer through any other ONLINE computer on the same account (which broadcasts the magic packet on its local network - works when both are on the same LAN).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) hid = str((await _json_body(req)).get("id", "")) with _acct_db() as conn: row = conn.execute("SELECT mac FROM devices WHERE id=? AND owner=?", (hid, email)).fetchone() others = conn.execute("SELECT id FROM devices WHERE owner=? AND id<>?", (email, hid)).fetchall() if row is None: return web.json_response({"error": "not one of your computers"}, status=404) h = HOSTS.get(hid) if h is not None and not h.ws.closed: return web.json_response({"ok": True, "already_online": True}) mac = row[0] or "" if not mac: return web.json_response({"error": "no MAC recorded for this computer yet " "(it is stored the next time it comes online)"}) sent = 0 for (oid,) in others: oh = HOSTS.get(oid) if oh is not None and not oh.ws.closed: try: await send_json(oh.ws, {"t": "wol", "mac": mac}) sent += 1 except Exception: pass if not sent: return web.json_response({"error": "no other online computer on your account " "can broadcast the wake packet"}) log_event("wake", f"WoL for {hid} relayed via {sent} computer(s)") return web.json_response({"ok": True, "relayed": sent}) async def api_admin_users(req): """All accounts (admin console): role, verified, 2FA, seat limit, device count.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) rows = conn.execute( "SELECT email, role, verified, created, " "CASE WHEN totp IS NOT NULL AND totp<>'' THEN 1 ELSE 0 END, " "(SELECT seats FROM licenses WHERE lower(owner)=lower(users.email)) " "FROM users ORDER BY created DESC").fetchall() devc = dict(conn.execute("SELECT owner, COUNT(*) FROM devices GROUP BY owner").fetchall()) out = [{"email": em, "role": role or "member", "verified": bool(ver), "created": created or 0, "twofa": bool(t), "seats": se or 0, "devices": devc.get(em, 0)} for (em, role, ver, created, t, se) in rows] return web.json_response({"users": out, "me": email}) async def api_admin_overview(req): """Org counts for the admin console dashboard.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) nusers = conn.execute("SELECT COUNT(*) FROM users").fetchone()[0] nadmins = conn.execute("SELECT COUNT(*) FROM users WHERE role='admin'").fetchone()[0] ndevices = conn.execute("SELECT COUNT(*) FROM devices").fetchone()[0] nkeys = conn.execute("SELECT COUNT(*) FROM api_keys").fetchone()[0] online = sum(1 for h in HOSTS.values() if not h.ws.closed) insess = sum(1 for h in HOSTS.values() if h.session is not None) return web.json_response({"users": nusers, "admins": nadmins, "devices": ndevices, "api_keys": nkeys, "hosts_online": online, "in_session": insess, "relays": len(_relays())}) async def api_admin_teams(req): """All teams in the org (admin console). Any team can then be managed via the team endpoints, which honour an account-admin override.""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) rows = conn.execute("SELECT id,name,created_by,created FROM teams ORDER BY name").fetchall() out = [] for tid, name, cb, created in rows: nm = conn.execute("SELECT COUNT(*) FROM team_members WHERE team_id=?", (tid,)).fetchone()[0] nd = conn.execute("SELECT COUNT(*) FROM team_devices WHERE team_id=?", (tid,)).fetchone()[0] out.append({"id": tid, "name": name, "created_by": cb, "created": created, "members": nm, "devices": nd}) return web.json_response({"teams": out}) async def api_admin_policy(req): """Session-admin view/set of an account's MDM-lite policy (the operator equivalent of the X-API-Key /api/v1/policy, for the admin console UI).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) if req.method == "GET": target = (req.query.get("email", "").strip().lower() or email) return web.json_response({"email": target, "policy": _account_policy(target), "seats": _owner_seats(target), "keys": sorted(_POLICY_KEYS)}) body = await _json_body(req) target = str(body.get("email", "")).strip().lower() if not target: return web.json_response({"error": "email required"}, status=400) saved = _set_account_policy(target, body.get("policy", {})) log_event("policy", f"{email} set MDM policy for {target} ({len(saved)} keys)") return web.json_response({"ok": True, "email": target, "policy": saved}) async def api_device_remove(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) hid = str((await _json_body(req)).get("id", "")) with _acct_db() as conn: conn.execute("DELETE FROM devices WHERE id=? AND owner=?", (hid, email)) return web.json_response({"ok": True}) def bind_device(hid, email, name, ver, mac=""): with _acct_db() as conn: if email: conn.execute("INSERT INTO devices(id,owner,name,ver,last_seen,mac) VALUES(?,?,?,?,?,?) " "ON CONFLICT(id) DO UPDATE SET owner=excluded.owner, name=excluded.name," " ver=excluded.ver, last_seen=excluded.last_seen," " mac=CASE WHEN excluded.mac<>'' THEN excluded.mac ELSE devices.mac END", (hid, email, name, ver, int(time.time()), mac)) else: conn.execute("DELETE FROM devices WHERE id=?", (hid,)) def touch_device(hid): try: with _acct_db() as conn: conn.execute("UPDATE devices SET last_seen=? WHERE id=?", (int(time.time()), hid)) except Exception: pass async def api_info(req): """Public server info - lets clients auto-configure (ws URL, features).""" base = public_base(req) return web.json_response({ "name": "NetDesk", "version": VERSION, "ws": base.replace("https://", "wss://").replace("http://", "ws://") + "/ws", "web": base, "reg_key_required": bool(ARGS.reg_key), "accounts": True, "mail": bool(ARGS.mail_from)}) async def api_contact(req): """Website contact form -> email + audit log.""" body = await _json_body(req) name = str(body.get("name", "")).strip()[:120] email = str(body.get("email", "")).strip()[:160] topic = str(body.get("topic", "General")).strip()[:60] message = str(body.get("message", "")).strip()[:4000] if not name or "@" not in email or len(message) < 2: return web.json_response({"error": "please fill in every field"}, status=400) log_event("contact", f"{topic}: {name} <{email}>") dest = ARGS.owner_email or ARGS.mail_from if ARGS.mail_from and dest: def _send(): try: send_mail(dest, f"[NetDesk contact] {topic} — {name}", _mail_html("New contact message", f"From: {name} <{email}>
Topic: {topic}" f"

{message}".replace(chr(10), '
'))) except Exception as e: log.warning("contact mail failed: %s", e) try: asyncio.get_running_loop().run_in_executor(None, _send) except Exception: pass return web.json_response({"ok": True}) async def auth_forgot(req): body = await _json_body(req) email = str(body.get("email", "")).strip().lower() with _acct_db() as conn: row = conn.execute("SELECT id FROM users WHERE email=?", (email,)).fetchone() if row: tok = _secrets.token_urlsafe(24) conn.execute("UPDATE users SET reset_token=?, reset_exp=? WHERE id=?", (tok, int(time.time()) + 1800, row[0])) link = public_base(req) + "/app?reset=" + tok try: send_mail(email, "Reset your NetDesk password", _mail_html("Password reset", "Click below to choose a new password. " "The link expires in 30 minutes.", link, "Reset password")) except Exception as e: log.warning("reset mail to %s failed: %s", email, e) return web.json_response( {"ok": True, "msg": "if that account exists, a reset email was sent"}) async def auth_reset(req): body = await _json_body(req) tok, pw = str(body.get("token", "")), str(body.get("password", "")) if len(pw) < 8: return web.json_response({"error": "password needs 8+ characters"}, status=400) with _acct_db() as conn: row = conn.execute("SELECT id FROM users WHERE reset_token=? AND reset_exp>?", (tok, int(time.time()))).fetchone() if not row: return web.json_response({"error": "link expired or invalid"}, status=400) salt = _secrets.token_bytes(16) conn.execute("UPDATE users SET pw_salt=?, pw_hash=?, reset_token=NULL, reset_exp=NULL " "WHERE id=?", (salt.hex(), _pbkdf(pw, salt), row[0])) log_event("auth", "password reset") return web.json_response({"ok": True}) TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket TUNNEL_TID = [1] # session plans: guests 20 min, verified users 45 min, owner unlimited PLAN_LIMITS = {"owner": None, "user": 45 * 60, "guest": 20 * 60} def plan_for(email): if email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower(): return "owner", None if email: return "user", PLAN_LIMITS["user"] return "guest", PLAN_LIMITS["guest"] def host_is_owners(hid): """The server owner's OWN computers are never time-limited - whoever holds the unattended password already has full control; the limit is a lever for third parties using the free relay, not a security control.""" if not ARGS.owner_email: return False try: with _acct_db() as conn: row = conn.execute("SELECT owner FROM devices WHERE id=?", (hid,)).fetchone() return bool(row and row[0] and row[0].lower() == ARGS.owner_email.lower()) except Exception: return False # ---- ICE servers for the hybrid transport (cached Cloudflare TURN creds) ---- _ICE_CACHE = {"servers": None, "exp": 0.0} _DEFAULT_ICE = [{"urls": ["stun:stun.cloudflare.com:3478", "stun:stun.l.google.com:19302"]}] def _mint_cloudflare_ice(): import urllib.request req = urllib.request.Request( "https://rtc.live.cloudflare.com/v1/turn/keys/%s/credentials/generate-ice-servers" % ARGS.turn_key_id, data=json.dumps({"ttl": 86400}).encode(), headers={"Authorization": "Bearer " + ARGS.turn_token, "Content-Type": "application/json"}, method="POST") with urllib.request.urlopen(req, timeout=8) as r: d = json.loads(r.read().decode()) servers = d.get("iceServers") if isinstance(servers, dict): # older response shape servers = [servers] return servers or None def _mint_secret_ice(): """Our own coturn (use-auth-secret): TURN REST creds = 'expiry:nd' + base64(HMAC-SHA1(secret, username)). Valid 12h, no API call needed.""" import hmac, hashlib, base64 user = "%d:nd" % (int(time.time()) + 12 * 3600) cred = base64.b64encode(hmac.new(ARGS.turn_secret.encode(), user.encode(), hashlib.sha1).digest()).decode() host = ARGS.turn_host return [{"urls": ["stun:%s:3478" % host]}, {"urls": ["turn:%s:3478?transport=udp" % host, "turn:%s:3478?transport=tcp" % host, "turns:%s:5349?transport=tcp" % host], "username": user, "credential": cred}] async def ice_servers(): """STUN always; TURN when configured. Cloudflare creds are minted once and reused for ~20h (they live 24h) - one API call per day, not per call. With --always-use-relay the server hands out NO ICE servers, so clients cannot gather P2P candidates and every session stays on the WSS relay — a privacy/compliance control (no peer IP exposure, all traffic through the operator's server), matching RustDesk's ALWAYS_USE_RELAY.""" if getattr(ARGS, "always_use_relay", False): return [] now_t = time.time() if _ICE_CACHE["servers"] and now_t < _ICE_CACHE["exp"]: return _ICE_CACHE["servers"] servers = None if ARGS.turn_secret and ARGS.turn_host: servers = _mint_secret_ice() _ICE_CACHE.update(servers=servers, exp=now_t + 6 * 3600) return servers if ARGS.turn_key_id and ARGS.turn_token: try: servers = await asyncio.get_event_loop().run_in_executor(None, _mint_cloudflare_ice) except Exception as e: log.warning("cloudflare TURN mint failed: %s", e) elif ARGS.turn_urls: servers = _DEFAULT_ICE + [{"urls": [u.strip() for u in ARGS.turn_urls.split(",") if u.strip()], "username": ARGS.turn_user, "credential": ARGS.turn_pass}] if not servers: return _DEFAULT_ICE _ICE_CACHE.update(servers=servers, exp=now_t + 20 * 3600) return servers # binaries offered for download from the portal (put e.g. NetDeskHost.exe here) _server_root = _nd_data_root() FILES_DIR = os.path.join(_server_root, "files") AUDIT_LOG = os.path.join(_server_root, "audit.log") NL = chr(10) def log_event(kind: str, msg: str, actor: str = ""): """In-memory ring for the portal + persistent JSONL audit file (survives restarts, like RustDesk 1.4.9 audit logs).""" EVENTS.appendleft({"ts": int(time.time()), "kind": kind, "msg": msg, "actor": actor}) try: with open(AUDIT_LOG, "a", encoding="utf-8") as f: f.write(json.dumps({"ts": int(time.time()), "kind": kind, "msg": msg, "actor": actor}) + NL) except Exception: pass def owner_from_cookie(req) -> bool: email = user_from_cookie(req) return bool(email and ARGS.owner_email and email.lower() == ARGS.owner_email.lower()) def _load_audit(): try: with open(AUDIT_LOG, "r", encoding="utf-8") as f: for line in f.readlines()[-200:]: try: EVENTS.appendleft(json.loads(line)) except Exception: pass except FileNotFoundError: pass def _read_audit(kind=None, q=None, actor=None, since=None, until=None, limit=500): """Full persistent audit.log (JSONL), newest first, filtered. This is the enterprise/compliance report source (RustDesk/TeamViewer-style connection + event history), beyond the 200-entry in-memory ring the overview shows.""" try: with open(AUDIT_LOG, "r", encoding="utf-8") as f: lines = f.readlines() except Exception: lines = [] ql = q.lower() if q else None al = actor.lower() if actor else None out = [] for line in reversed(lines): line = line.strip() if not line: continue try: e = json.loads(line) except Exception: continue ts = e.get("ts", 0) if since and ts < since: continue if until and ts > until: continue if kind and e.get("kind") != kind: continue if al and al not in (e.get("actor") or "").lower(): continue if ql: hay = (str(e.get("msg", "")) + " " + str(e.get("kind", "")) + " " + str(e.get("actor") or "")).lower() if ql not in hay: continue out.append(e) if len(out) >= limit: break return out def _audit_query(req): q = req.query def _int(name): try: return int(q[name]) if q.get(name) else None except (TypeError, ValueError): return None return dict(kind=(q.get("kind") or None), q=(q.get("q") or None), actor=(q.get("actor") or None), since=_int("since"), until=_int("until")) async def portal_audit(req): """Filterable audit/connection history for the operator console.""" if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) qp = _audit_query(req) try: limit = min(int(req.query.get("limit") or 500), 5000) except (TypeError, ValueError): limit = 500 events = _read_audit(limit=limit, **qp) # the distinct event kinds present, for the filter dropdown try: with open(AUDIT_LOG, "r", encoding="utf-8") as f: kinds = sorted({json.loads(l).get("kind", "") for l in f.readlines()[-4000:] if l.strip()} - {""}) except Exception: kinds = sorted({e.get("kind", "") for e in events} - {""}) return web.json_response({"events": events, "count": len(events), "kinds": kinds}) async def portal_audit_csv(req): """CSV export of the (filtered) audit log — SIEM / compliance import.""" if not portal_authorized(req): return web.Response(status=401, text="unauthorized") import csv as _csv, io as _io, datetime as _dt qp = _audit_query(req) try: limit = min(int(req.query.get("limit") or 50000), 200000) except (TypeError, ValueError): limit = 50000 events = _read_audit(limit=limit, **qp) buf = _io.StringIO() w = _csv.writer(buf) w.writerow(["timestamp_utc", "unix_ts", "kind", "actor", "message"]) for e in events: ts = e.get("ts", 0) iso = (_dt.datetime.utcfromtimestamp(ts).isoformat() + "Z") if ts else "" w.writerow([iso, ts, e.get("kind", ""), e.get("actor", ""), e.get("msg", "")]) fname = "netdesk-audit-%d.csv" % int(time.time()) return web.Response( body=buf.getvalue().encode("utf-8"), headers={"Content-Type": "text/csv; charset=utf-8", "Content-Disposition": 'attachment; filename="%s"' % fname}) def portal_authorized(req) -> bool: if owner_from_cookie(req): return True if not ARGS.portal_password: return False tok = req.cookies.get("ndportal", "") return tok in PORTAL_SESSIONS # ---------------------------------------------------------------- HTTP MIME = { ".html": "text/html", ".js": "application/javascript", ".css": "text/css", ".svg": "image/svg+xml", ".png": "image/png", ".ico": "image/x-icon", ".xml": "application/xml", ".webmanifest": "application/manifest+json", } # marketing pages are public & indexable; the app/console are not NOINDEX_FILES = {"app.html", "portal.html", "portal.js", "app.js", "admin.html", "admin.js"} async def serve_file(name): path = os.path.normpath(os.path.join(WEB_DIR, name)) if not path.startswith(WEB_DIR) or not os.path.isfile(path): return web.Response(status=404, text="not found") with open(path, "rb") as f: body = f.read() ext = os.path.splitext(path)[1] resp = web.Response( body=body, content_type=MIME.get(ext, "application/octet-stream"), charset="utf-8" if MIME.get(ext, "").startswith("text/") or MIME.get(ext, "").endswith(("javascript", "xml", "json")) else None, ) if os.path.basename(path) in NOINDEX_FILES: resp.headers["X-Robots-Tag"] = "noindex, nofollow" resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate" else: # marketing pages / assets: allow indexing + sane caching resp.headers["Cache-Control"] = "public, max-age=600" return resp async def http_index(req): return await serve_file("index.html") async def http_app(req): return await serve_file("app.html") async def http_health(req): return web.json_response({"ok": True, "version": VERSION, "uptime": int(time.time() - STATS.started)}) async def http_status(req): if ARGS.status_key: tok = req.query.get("token", "") import hmac if not hmac.compare_digest(tok, ARGS.status_key): return web.json_response({"error": "bad token"}, status=403) hosts = [ { "id": h.id, "name": h.name, "version": h.ver, "connected_for": int(time.time() - h.connected_at), "in_session": h.session is not None, } for h in HOSTS.values() ] return web.json_response( { "version": VERSION, "uptime": int(time.time() - STATS.started), "hosts_online": len(HOSTS), "hosts": hosts, "sessions_total": STATS.sessions_total, "bytes_relayed": STATS.bytes_relayed, } ) # ---------------------------------------------------------------- portal async def http_robots(req): base = public_base(req) txt = ("User-agent: *\n" "Allow: /\n" "Disallow: /app\n" "Disallow: /portal\n" "Disallow: /api/\n" "Disallow: /ws\n" "Disallow: /dl/\n" f"Sitemap: {base}/sitemap.xml\n") return web.Response(text=txt, content_type="text/plain") async def http_sitemap(req): return await serve_file("sitemap.xml") async def portal_page(req): if not ARGS.portal_password: return web.Response(status=404, text="Portal disabled. Start the server with --portal-password.") return await serve_file("portal.html") async def portal_login(req): if not ARGS.portal_password: return web.json_response({"error": "disabled"}, status=404) ip = req_ip(req) locked = login_locked("portal", ip) if locked: return web.json_response( {"error": f"too many attempts — try again in {int(locked) + 1}s"}, status=429) form = await req.post() pw = str(form.get("password", "")) if not _secrets.compare_digest(pw, ARGS.portal_password): login_fail("portal", ip) await asyncio.sleep(0.4) return web.json_response({"error": "wrong password"}, status=401) if ARGS.portal_totp and not _totp_verify(ARGS.portal_totp, str(form.get("totp", "")).strip()): login_fail("portal", ip) await asyncio.sleep(0.4) return web.json_response({"error": "wrong or missing 2FA code"}, status=401) login_ok("portal", ip) tok = _secrets.token_urlsafe(32) PORTAL_SESSIONS.add(tok) log_event("portal", "portal login") resp = web.Response(status=303, headers={"Location": "/portal"}) resp.set_cookie("ndportal", tok, httponly=True, samesite="Lax", max_age=7 * 24 * 3600) return resp async def portal_logout(req): tok = req.cookies.get("ndportal", "") PORTAL_SESSIONS.discard(tok) resp = web.Response(status=303, headers={"Location": "/portal"}) resp.del_cookie("ndportal") return resp async def portal_overview(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) now = time.time() known = [] for hid, k in sorted(KNOWN_HOSTS.items()): entry = dict(k) h = HOSTS.get(hid) entry["online"] = h is not None and not h.ws.closed entry["in_session"] = bool(h and h.session is not None) entry["hidden"] = is_hidden(hid) entry["telemetry"] = (h.telemetry if h else {}) or {} known.append(entry) files = [] if os.path.isdir(FILES_DIR): for n in os.listdir(FILES_DIR): p = os.path.join(FILES_DIR, n) if os.path.isfile(p): files.append({"name": n, "size": os.path.getsize(p), "mtime": int(os.path.getmtime(p))}) files.sort(key=lambda f: f["name"]) users = [] with _acct_db() as conn: for row in conn.execute("SELECT email, verified, created FROM users " "ORDER BY created DESC LIMIT 500"): users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) for k in known: h = HOSTS.get(k["id"]) k["plan"] = h.session.plan if (h and h.session) else "" # Live visitors: everyone currently connected to one of the hosts. visitors = [] for hid2, h2 in HOSTS.items(): if h2.session is not None: s = h2.session visitors.append({ "ip": getattr(s, "vip", ""), "geo": getattr(s, "vgeo", {}) or {}, "host": hid2, "host_name": h2.name, "plan": s.plan, "email": s.vemail or "", "since": int(s.started), "active": bool(s.active), }) return web.json_response({ "version": VERSION, "visitors": visitors, "visitors_recent": list(RECENT_VISITORS)[:50], "owner": bool(ARGS.owner_email and user_from_cookie(req) and user_from_cookie(req).lower() == ARGS.owner_email.lower()), "users": users, "reg_key": ARGS.reg_key or "", "uptime": int(now - STATS.started), "hosts_online": sum(1 for k in known if k["online"]), "hosts": known, "sessions_total": STATS.sessions_total, "bytes_relayed": STATS.bytes_relayed, "events": list(EVENTS)[:60], "files": files, "portal": True, }) async def public_download(req): name = os.path.basename(req.match_info.get("name", "")) path = os.path.join(FILES_DIR, name) if not os.path.isfile(path): return web.Response(status=404, text="not found") with open(path, "rb") as f: body = f.read() resp = web.Response(body=body, content_type="application/octet-stream") resp.headers["Content-Disposition"] = f'attachment; filename="{name}"' return resp async def tunnel_ws(req): """Viewer-side endpoint: wss://server/tunnel/ Client sends {"t":"open","port":N}; then raw binary both ways is the TCP stream. The server asks the host to open the TCP connection and pipes.""" if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) hid = req.match_info.get("hid", "") h = HOSTS.get(hid) if h is None or h.ws.closed: return web.json_response({"error": "host offline"}, status=404) ws = web.WebSocketResponse(max_msg_size=64 * 1024 * 1024) await ws.prepare(req) my_tids = [] try: async for msg in ws: if msg.type == WSMsgType.TEXT: try: m = json.loads(msg.data) except Exception: continue if m.get("t") != "open": continue tid = TUNNEL_TID[0] TUNNEL_TID[0] += 1 TUNNELS[tid] = ws my_tids.append(tid) await h.ws.send_json({"t": "tunnel.open", "tid": tid, "thost": m.get("host", "127.0.0.1"), "port": int(m.get("port", 0))}) elif msg.type == WSMsgType.BINARY: if my_tids: await h.ws.send_bytes(b"\xf3" + my_tids[0].to_bytes(4, "little") + msg.data) finally: for tid in my_tids: TUNNELS.pop(tid, None) try: if h and not h.ws.closed: await h.ws.send_json({"t": "tunnel.close", "tid": tid}) except Exception: pass return ws async def portal_users(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) users = [] with _acct_db() as conn: for row in conn.execute("SELECT email, verified, created FROM users " "ORDER BY created DESC LIMIT 500"): users.append({"email": row[0], "verified": bool(row[1]), "created": row[2]}) return web.json_response({"users": users}) async def portal_user_delete(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) body = await req.json() email = str(body.get("email", "")).lower() if ARGS.owner_email and email == ARGS.owner_email.lower(): return web.json_response({"error": "cannot delete the owner"}, status=400) with _acct_db() as conn: conn.execute("DELETE FROM users WHERE email=?", (email,)) conn.execute("DELETE FROM tokens WHERE email=?", (email,)) conn.execute("DELETE FROM devices WHERE owner=?", (email,)) for tok, em in list(USER_SESSIONS.items()): if em == email: USER_SESSIONS.pop(tok, None) log_event("admin", f"user deleted: {email}") return web.json_response({"ok": True}) async def portal_host_delete(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) hid = str((await req.json()).get("id", "")) h = HOSTS.get(hid) if h is None: return web.json_response({"error": "host offline (removed from registry)"}, status=404) try: await h.ws.close() except Exception: pass log_event("admin", f"host {hid} removed by admin") return web.json_response({"ok": True, "msg": "host removed; it must re-register"}) async def portal_host_hide(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) body = await req.json() hid = str(body.get("id", "")).strip() hidden = bool(body.get("hidden", True)) if not hid: return web.json_response({"error": "no host id"}, status=400) set_hidden(hid, hidden) log_event("admin", f"host {hid} {'hidden' if hidden else 'unhidden'}") return web.json_response({"ok": True, "hidden": hidden}) async def portal_session_disconnect(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) hid = str((await req.json()).get("id", "")) h = HOSTS.get(hid) if h is None or not h.session: return web.json_response({"error": "no active session"}, status=404) await relay_session_end(h.session, "disconnected by admin", None) h.session = None log_event("admin", f"session on {hid} disconnected by admin") return web.json_response({"ok": True}) async def portal_download(req): if not portal_authorized(req): return web.json_response({"error": "unauthorized"}, status=401) name = os.path.basename(req.match_info.get("name", "")) path = os.path.join(FILES_DIR, name) if not os.path.isfile(path): return web.Response(status=404, text="not found") with open(path, "rb") as f: body = f.read() return web.Response( body=body, headers={"Content-Disposition": f'attachment; filename="{name}"'}, content_type="application/octet-stream", ) # ===================== SCIM 2.0 provisioning (RFC 7643/7644) ===================== # An IdP (Okta/Azure AD/OneLogin) auto-creates/updates/deactivates NetDesk # accounts (Users) and teams (Groups) with a Bearer provisioning token. SCIM_USER = "urn:ietf:params:scim:schemas:core:2.0:User" SCIM_GROUP = "urn:ietf:params:scim:schemas:core:2.0:Group" SCIM_LIST = "urn:ietf:params:scim:api:messages:2.0:ListResponse" SCIM_ERR = "urn:ietf:params:scim:api:messages:2.0:Error" SCIM_PATCH = "urn:ietf:params:scim:api:messages:2.0:PatchOp" def _scim_json(data, status=200, location=None): headers = {"Location": location} if location else None return web.json_response(data, status=status, headers=headers, content_type="application/scim+json") def _scim_err(status, detail, scim_type=None): body = {"schemas": [SCIM_ERR], "detail": detail, "status": str(status)} if scim_type: body["scimType"] = scim_type return _scim_json(body, status=status) def _scim_auth(req): """Validate the Bearer provisioning token -> {token, admin} or None.""" auth = req.headers.get("Authorization", "") if not auth.lower().startswith("bearer "): return None tok = auth[7:].strip() if not tok: return None with _acct_db() as conn: row = conn.execute("SELECT token, created_by FROM scim_tokens WHERE token=?", (tok,)).fetchone() if not row: return None conn.execute("UPDATE scim_tokens SET last_used=? WHERE token=?", (int(time.time()), tok)) conn.commit() return {"token": row[0], "admin": row[1] or ""} def _iso(ts): import datetime return datetime.datetime.utcfromtimestamp(int(ts or 0)).strftime("%Y-%m-%dT%H:%M:%SZ") def _scim_user_row(conn, uid=None, email=None): if uid is not None: return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," "COALESCE(scim_ext,'') FROM users WHERE id=?", (uid,)).fetchone() return conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", (email,)).fetchone() def _scim_user_json(row, req): uid, email, verified, created, disabled, ext = row base = public_base(req) d = {"schemas": [SCIM_USER], "id": str(uid), "userName": email, "name": {"formatted": email.split("@")[0]}, "emails": [{"value": email, "primary": True, "type": "work"}], "active": not bool(disabled), "meta": {"resourceType": "User", "location": f"{base}/scim/v2/Users/{uid}", "created": _iso(created), "lastModified": _iso(created)}} if ext: d["externalId"] = ext return d def _scim_group_json(conn, team_row, req): tid, name = team_row[0], team_row[1] base = public_base(req) members = [] for em, in conn.execute("SELECT email FROM team_members WHERE team_id=? ORDER BY email", (tid,)).fetchall(): ur = conn.execute("SELECT id FROM users WHERE lower(email)=lower(?)", (em,)).fetchone() members.append({"value": str(ur[0]) if ur else em, "display": em}) return {"schemas": [SCIM_GROUP], "id": str(tid), "displayName": name, "members": members, "meta": {"resourceType": "Group", "location": f"{base}/scim/v2/Groups/{tid}"}} async def scim_spc(req): """ServiceProviderConfig: advertise PATCH + filter support.""" base = public_base(req) return _scim_json({ "schemas": ["urn:ietf:params:scim:schemas:core:2.0:ServiceProviderConfig"], "documentationUri": base + "/docs", "patch": {"supported": True}, "bulk": {"supported": False, "maxOperations": 0, "maxPayloadSize": 0}, "filter": {"supported": True, "maxResults": 200}, "changePassword": {"supported": False}, "sort": {"supported": False}, "etag": {"supported": False}, "authenticationSchemes": [{"type": "oauthbearertoken", "name": "OAuth Bearer Token", "description": "Provisioning token as an HTTP Authorization: Bearer header"}], "meta": {"resourceType": "ServiceProviderConfig", "location": base + "/scim/v2/ServiceProviderConfig"}, }) def _filter_username(req): """Parse a SCIM `userName eq "x"` (or displayName eq) filter -> value or None.""" import re f = req.query.get("filter", "") m = re.search(r'(?:userName|displayName)\s+eq\s+"([^"]+)"', f, re.I) return m.group(1) if m else None async def scim_users_list(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") uname = _filter_username(req) with _acct_db() as conn: if uname: rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," "COALESCE(scim_ext,'') FROM users WHERE lower(email)=lower(?)", (uname,)).fetchall() else: rows = conn.execute("SELECT id,email,verified,created,COALESCE(disabled,0)," "COALESCE(scim_ext,'') FROM users ORDER BY id").fetchall() res = [_scim_user_json(r, req) for r in rows] return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) async def scim_user_create(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") body = await _json_body(req) email = str(body.get("userName", "")).strip().lower() if "@" not in email: # some IdPs put the address only in emails[] for e in (body.get("emails") or []): if isinstance(e, dict) and e.get("value"): email = str(e["value"]).strip().lower(); break if "@" not in email or "." not in email.split("@")[-1]: return _scim_err(400, "userName must be an email address", "invalidValue") active = body.get("active", True) ext = str(body.get("externalId", "")).strip() now = int(time.time()) with _acct_db() as conn: if _scim_user_row(conn, email=email): return _scim_err(409, "user already exists", "uniqueness") salt = _secrets.token_bytes(16) pw = _secrets.token_urlsafe(24) # IdP-managed account; local login unused until reset conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,disabled,scim_ext,role) " "VALUES(?,?,?,1,?,?,?,'member')", (email, salt.hex(), _pbkdf(pw, salt), now, 0 if active else 1, ext)) conn.commit() row = _scim_user_row(conn, email=email) log_event("scim", f"provisioned user {email}" + ("" if active else " (inactive)")) j = _scim_user_json(row, req) return _scim_json(j, status=201, location=j["meta"]["location"]) async def scim_user_get(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") uid = _rule_int(req.match_info.get("id")) with _acct_db() as conn: row = _scim_user_row(conn, uid=uid) if not row: return _scim_err(404, "user not found") return _scim_json(_scim_user_json(row, req)) def _apply_user_active(conn, uid, active): conn.execute("UPDATE users SET disabled=? WHERE id=?", (0 if active else 1, uid)) if not active: # deactivation revokes live sessions em = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() if em: conn.execute("DELETE FROM tokens WHERE email=?", (em[0],)) async def scim_user_put(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") uid = _rule_int(req.match_info.get("id")) body = await _json_body(req) with _acct_db() as conn: row = _scim_user_row(conn, uid=uid) if not row: return _scim_err(404, "user not found") active = body.get("active", True) _apply_user_active(conn, uid, active) ext = body.get("externalId") if ext is not None: conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(ext), uid)) conn.commit() row = _scim_user_row(conn, uid=uid) log_event("scim", f"updated user #{uid} (active={bool(body.get('active', True))})") return _scim_json(_scim_user_json(row, req)) async def scim_user_patch(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") uid = _rule_int(req.match_info.get("id")) body = await _json_body(req) with _acct_db() as conn: row = _scim_user_row(conn, uid=uid) if not row: return _scim_err(404, "user not found") for op in (body.get("Operations") or []): if not isinstance(op, dict): continue path = str(op.get("path", "")).strip().lower() val = op.get("value") # active may arrive as {"active": false} (no path) or path="active" if path == "active": _apply_user_active(conn, uid, _scim_truthy(val)) elif path == "externalid": conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val or ""), uid)) elif path in ("", None) and isinstance(val, dict): if "active" in val: _apply_user_active(conn, uid, _scim_truthy(val.get("active"))) if "externalId" in val: conn.execute("UPDATE users SET scim_ext=? WHERE id=?", (str(val.get("externalId") or ""), uid)) conn.commit() row = _scim_user_row(conn, uid=uid) return _scim_json(_scim_user_json(row, req)) def _scim_truthy(v): if isinstance(v, bool): return v return str(v).strip().lower() in ("true", "1", "yes") async def scim_user_delete(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") uid = _rule_int(req.match_info.get("id")) with _acct_db() as conn: row = conn.execute("SELECT email FROM users WHERE id=?", (uid,)).fetchone() if not row: return _scim_err(404, "user not found") em = row[0] conn.execute("DELETE FROM users WHERE id=?", (uid,)) conn.execute("DELETE FROM tokens WHERE email=?", (em,)) conn.execute("DELETE FROM team_members WHERE lower(email)=lower(?)", (em,)) conn.execute("DELETE FROM api_keys WHERE lower(owner)=lower(?)", (em,)) conn.commit() log_event("scim", f"deprovisioned user {em} (#{uid})") return web.Response(status=204) # ---- SCIM Groups (mapped to teams) ---- async def scim_groups_list(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") dname = _filter_username(req) with _acct_db() as conn: if dname: trows = conn.execute("SELECT id,name FROM teams WHERE name=? ORDER BY id", (dname,)).fetchall() else: trows = conn.execute("SELECT id,name FROM teams ORDER BY id").fetchall() res = [_scim_group_json(conn, t, req) for t in trows] return _scim_json({"schemas": [SCIM_LIST], "totalResults": len(res), "startIndex": 1, "itemsPerPage": len(res), "Resources": res}) def _scim_member_emails(conn, value): """Resolve a SCIM members value list -> [emails].""" out = [] for mv in (value or []): if not isinstance(mv, dict): continue v = str(mv.get("value", "")).strip() if not v: continue ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None out.append(ur[0] if ur else v) return out async def scim_group_create(req): sa = _scim_auth(req) if not sa: return _scim_err(401, "invalid or missing provisioning token") body = await _json_body(req) name = str(body.get("displayName", "")).strip() if not name: return _scim_err(400, "displayName required", "invalidValue") now = int(time.time()) with _acct_db() as conn: cur = conn.execute("INSERT INTO teams(name,created_by,created) VALUES(?,?,?)", (name, sa["admin"] or "scim", now)) tid = cur.lastrowid if sa["admin"]: conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", (tid, sa["admin"], "owner", now)) for em in _scim_member_emails(conn, body.get("members")): conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", (tid, em, "member", now)) conn.commit() row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() j = _scim_group_json(conn, row, req) log_event("scim", f"provisioned group '{name}' (#{tid})") return _scim_json(j, status=201, location=j["meta"]["location"]) async def scim_group_get(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") tid = _rule_int(req.match_info.get("id")) with _acct_db() as conn: row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() if not row: return _scim_err(404, "group not found") return _scim_json(_scim_group_json(conn, row, req)) async def scim_group_put(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") tid = _rule_int(req.match_info.get("id")) body = await _json_body(req) now = int(time.time()) with _acct_db() as conn: row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() if not row: return _scim_err(404, "group not found") name = str(body.get("displayName", "")).strip() if name: conn.execute("UPDATE teams SET name=? WHERE id=?", (name, tid)) if "members" in body: # PUT replaces the member set (owners preserved) conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) for em in _scim_member_emails(conn, body.get("members")): conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", (tid, em, "member", now)) conn.commit() row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() return _scim_json(_scim_group_json(conn, row, req)) async def scim_group_patch(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") import re tid = _rule_int(req.match_info.get("id")) body = await _json_body(req) now = int(time.time()) with _acct_db() as conn: row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() if not row: return _scim_err(404, "group not found") for op in (body.get("Operations") or []): if not isinstance(op, dict): continue action = str(op.get("op", "")).strip().lower() path = str(op.get("path", "")).strip() val = op.get("value") if path.lower() == "displayname" or (not path and isinstance(val, dict) and "displayName" in val): nm = val if path else val.get("displayName") if isinstance(nm, list): nm = nm[0].get("value") if nm and isinstance(nm[0], dict) else None if nm: conn.execute("UPDATE teams SET name=? WHERE id=?", (str(nm), tid)) elif path.lower().startswith("members") or (not path and isinstance(val, dict) and "members" in val): mfilter = re.search(r'members\[\s*value\s+eq\s+"([^"]+)"\s*\]', path, re.I) if action == "remove" and mfilter: v = mfilter.group(1) ur = conn.execute("SELECT email FROM users WHERE id=?", (_rule_int(v),)).fetchone() if v.isdigit() else None em = ur[0] if ur else v conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) else: members = val.get("members") if (not path and isinstance(val, dict)) else val emails = _scim_member_emails(conn, members) if action == "add" or action == "replace": if action == "replace": conn.execute("DELETE FROM team_members WHERE team_id=? AND role<>'owner'", (tid,)) for em in emails: conn.execute("INSERT OR IGNORE INTO team_members(team_id,email,role,added) VALUES(?,?,?,?)", (tid, em, "member", now)) elif action == "remove": for em in emails: conn.execute("DELETE FROM team_members WHERE team_id=? AND lower(email)=lower(?) AND role<>'owner'", (tid, em)) conn.commit() row = conn.execute("SELECT id,name FROM teams WHERE id=?", (tid,)).fetchone() return _scim_json(_scim_group_json(conn, row, req)) async def scim_group_delete(req): if not _scim_auth(req): return _scim_err(401, "invalid or missing provisioning token") tid = _rule_int(req.match_info.get("id")) with _acct_db() as conn: if not conn.execute("SELECT 1 FROM teams WHERE id=?", (tid,)).fetchone(): return _scim_err(404, "group not found") conn.execute("DELETE FROM team_members WHERE team_id=?", (tid,)) conn.execute("DELETE FROM team_devices WHERE team_id=?", (tid,)) conn.execute("DELETE FROM teams WHERE id=?", (tid,)) conn.commit() log_event("scim", f"deprovisioned group #{tid}") return web.Response(status=204) # ---- SCIM provisioning-token management (admin session) ---- async def api_scim_tokens_list(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) rows = conn.execute("SELECT token,label,created,last_used,created_by FROM scim_tokens " "ORDER BY created DESC").fetchall() out = [{"label": lb, "created": c, "last_used": lu, "created_by": cb, "token_preview": tk[:10] + "…" + tk[-4:]} for (tk, lb, c, lu, cb) in rows] return web.json_response({"tokens": out, "base": public_base(req) + "/scim/v2"}) async def api_scim_token_create(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) body = await _json_body(req) label = str(body.get("label", "")).strip()[:64] or "SCIM" token = "scim_" + _secrets.token_urlsafe(32) with _acct_db() as conn: conn.execute("INSERT INTO scim_tokens(token,label,created_by,created,last_used) VALUES(?,?,?,?,0)", (token, label, email, int(time.time()))) conn.commit() log_event("scim", f"{email} minted SCIM token '{label}'", email) return web.json_response({"ok": True, "token": token, "label": label, "base": public_base(req) + "/scim/v2"}) async def api_scim_token_revoke(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) with _acct_db() as conn: if _user_role(conn, email) != "admin": return web.json_response({"error": "admin only"}, status=403) body = await _json_body(req) k = str(body.get("token", "")).strip() with _acct_db() as conn: if k.startswith("scim_") and "…" not in k: cur = conn.execute("DELETE FROM scim_tokens WHERE token=?", (k,)) else: cur = conn.execute("DELETE FROM scim_tokens WHERE token LIKE ?", (k.split("…")[0] + "%",)) conn.commit() return web.json_response({"ok": True, "revoked": cur.rowcount}) # ===================== central session recording (server half) ===================== # Policy flag record_required (pushed to hosts) tells the host to record the # session and upload it here. The host authenticates the upload with a short- # lived token minted at session.begin. Org-controlled storage + retention + # admin/owner list/download/delete. REC_DIR = os.path.join(_server_root, "recordings") _REC_CAP = 2 * 1024 * 1024 * 1024 # 2 GB per recording def _rec_retention_days(): try: return int(getattr(ARGS, "recording_retention_days", 0) or 0) except Exception: return 0 def _rec_mint(host_id, owner, session_id, ttl=6 * 3600): token = "rec_" + _secrets.token_urlsafe(24) now = int(time.time()) with _acct_db() as conn: conn.execute("INSERT INTO rec_tokens(token,host_id,owner,session_id,created,exp) " "VALUES(?,?,?,?,?,?)", (token, host_id, owner, session_id, now, now + ttl)) conn.commit() return token def _rec_purge_expired(): """Drop stale upload tokens and, when retention is set, old recordings.""" now = int(time.time()) days = _rec_retention_days() try: with _acct_db() as conn: conn.execute("DELETE FROM rec_tokens WHERE exp>0 AND exp < ?", (now,)) if days > 0: cutoff = now - days * 86400 for rid, path in conn.execute("SELECT id,path FROM recordings WHERE created < ?", (cutoff,)).fetchall(): try: if path: os.remove(path) except OSError: pass conn.execute("DELETE FROM recordings WHERE created < ?", (cutoff,)) conn.commit() except Exception as e: log.warning("recording purge: %s", e) async def api_rec_upload(req): """Host uploads a finished session recording (token-authed, streamed to disk).""" token = req.query.get("token", "") or req.headers.get("X-Rec-Token", "") now = int(time.time()) with _acct_db() as conn: row = conn.execute("SELECT host_id,owner,session_id,exp FROM rec_tokens WHERE token=?", (token,)).fetchone() if not row or (row[3] and row[3] < now): return web.json_response({"error": "invalid or expired recording token"}, status=401) host_id, owner, session_id, _exp = row ext = "".join(c for c in (req.query.get("ext", "webm") or "webm") if c.isalnum())[:8] or "webm" os.makedirs(REC_DIR, exist_ok=True) name = f"rec-{host_id}-{_secrets.token_hex(6)}.{ext}" path = os.path.join(REC_DIR, name) tmp = path + ".part" size = 0 try: with open(tmp, "wb") as f: async for chunk in req.content.iter_chunked(256 * 1024): size += len(chunk) if size > _REC_CAP: f.close(); os.remove(tmp) return web.json_response({"error": "recording too large"}, status=413) f.write(chunk) except Exception as e: try: os.remove(tmp) except OSError: pass return web.json_response({"error": "upload failed: %s" % e}, status=500) if size == 0: try: os.remove(tmp) except OSError: pass return web.json_response({"error": "empty upload"}, status=400) os.replace(tmp, path) duration = 0 try: duration = int(req.query.get("duration", 0) or 0) except ValueError: duration = 0 with _acct_db() as conn: cur = conn.execute("INSERT INTO recordings(owner,host_id,session_id,name,path,size,duration," "viewer,mime,created) VALUES(?,?,?,?,?,?,?,?,?,?)", (owner, host_id, session_id, name, path, size, duration, (req.query.get("viewer", "") or "")[:120], "video/" + ext, now)) rid = cur.lastrowid # single-use token conn.execute("DELETE FROM rec_tokens WHERE token=?", (token,)) conn.commit() log_event("recording", f"stored recording #{rid} for host {host_id} " f"({size} bytes)", owner or "") _rec_purge_expired() return web.json_response({"ok": True, "id": rid, "size": size}) async def api_recordings(req): """List recordings for the signed-in account (admin sees all).""" email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) _rec_purge_expired() with _acct_db() as conn: if _user_role(conn, email) == "admin": rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " "FROM recordings ORDER BY created DESC").fetchall() else: rows = conn.execute("SELECT id,owner,host_id,session_id,name,size,duration,viewer,created " "FROM recordings WHERE lower(owner)=lower(?) ORDER BY created DESC", (email,)).fetchall() out = [{"id": r[0], "owner": r[1], "host_id": r[2], "session_id": r[3], "name": r[4], "size": r[5], "duration": r[6], "viewer": r[7], "created": r[8]} for r in rows] return web.json_response({"recordings": out, "retention_days": _rec_retention_days()}) def _rec_authz_row(conn, rid, email): row = conn.execute("SELECT owner,path,name,mime FROM recordings WHERE id=?", (rid,)).fetchone() if not row: return None, "not found" if (row[0] or "").lower() != email.lower() and _user_role(conn, email) != "admin": return None, "forbidden" return row, None async def api_rec_download(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) rid = _rule_int(req.match_info.get("id")) with _acct_db() as conn: row, err = _rec_authz_row(conn, rid, email) if err == "not found": return web.json_response({"error": "recording not found"}, status=404) if err == "forbidden": return web.json_response({"error": "not your recording"}, status=403) owner, path, name, mime = row if not path or not os.path.exists(path): return web.json_response({"error": "recording file missing"}, status=404) return web.FileResponse(path, headers={ "Content-Disposition": f'attachment; filename="{name}"', "Content-Type": mime or "application/octet-stream"}) async def api_rec_delete(req): email = user_from_cookie(req) if not email: return web.json_response({"error": "not signed in"}, status=401) body = await _json_body(req) rid = _rule_int(body.get("id")) if rid is None: return web.json_response({"error": "id required"}, status=400) with _acct_db() as conn: row, err = _rec_authz_row(conn, rid, email) if err == "not found": return web.json_response({"error": "recording not found"}, status=404) if err == "forbidden": return web.json_response({"error": "not your recording"}, status=403) path = row[1] conn.execute("DELETE FROM recordings WHERE id=?", (rid,)) conn.commit() try: if path: os.remove(path) except OSError: pass log_event("recording", f"{email} deleted recording #{rid}", email) return web.json_response({"ok": True}) # ===================== SAML 2.0 SSO (Service Provider) ===================== # A secure, spec-compliant SP: SP-initiated AuthnRequest (HTTP-Redirect), # ServiceProviderConfig metadata, and an ACS that validates the IdP's XML-DSig # signature (exclusive-C14N via lxml + RSA verify via cryptography), defends # against signature-wrapping, and checks conditions/audience/issuer/recipient. _SAML_NS = {"samlp": "urn:oasis:names:tc:SAML:2.0:protocol", "saml": "urn:oasis:names:tc:SAML:2.0:assertion", "ds": "http://www.w3.org/2000/09/xmldsig#", "ec": "http://www.w3.org/2001/10/xml-exc-c14n#"} class SamlError(Exception): pass def _saml_enabled(): return bool(getattr(ARGS, "saml_idp_sso_url", "") and getattr(ARGS, "saml_idp_cert", "")) def _saml_acs_url(req): return public_base(req) + "/saml/acs" def _saml_sp_entity_id(req): return getattr(ARGS, "saml_sp_entity_id", "") or (public_base(req) + "/saml/metadata") def _saml_idp_cert_pem(): v = getattr(ARGS, "saml_idp_cert", "") or "" try: if os.path.exists(v): with open(v, "rb") as f: return f.read() except Exception: pass if "BEGIN CERTIFICATE" not in v: v = "-----BEGIN CERTIFICATE-----\n" + v + "\n-----END CERTIFICATE-----\n" return v.encode() def _xmlesc(s): return (str(s).replace("&", "&").replace("<", "<").replace(">", ">") .replace('"', """)) def _saml_parsedt(s): import datetime s = (s or "").strip().replace("Z", "") if "." in s: s = s.split(".")[0] return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S") def _saml_by_id(root, el_id): """Find the single element with ID==el_id; reject duplicates (XSW defence).""" found = [e for e in root.iter() if e.get("ID") == el_id] if len(found) != 1: raise SamlError("ambiguous or missing ID reference") return found[0] def _saml_validate(xml_bytes, acs_url, idp_cert_pem, sp_entity_id, idp_entity_id=None, skew=180): from lxml import etree from cryptography import x509 from cryptography.hazmat.primitives.asymmetric import padding from cryptography.hazmat.primitives import hashes import base64, hashlib, hmac, copy, datetime NS = _SAML_NS parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False) try: root = etree.fromstring(xml_bytes, parser) except Exception as e: raise SamlError("malformed SAML XML: %s" % e) if root.getroottree().docinfo.doctype: raise SamlError("DOCTYPE is not allowed") # Response-level Status must be Success (when this is a Response) if root.tag.endswith("}Response"): sc = root.find("./samlp:Status/samlp:StatusCode", NS) if sc is not None and not (sc.get("Value", "").endswith(":status:Success")): raise SamlError("IdP returned a non-Success status") cert = x509.load_pem_x509_certificate(idp_cert_pem) pub = cert.public_key() sigs = root.findall(".//ds:Signature", NS) if not sigs: raise SamlError("response is not signed") verified_ids = set() for sig in sigs: si = sig.find("./ds:SignedInfo", NS) if si is None: continue cm = si.find("./ds:CanonicalizationMethod", NS) si_excl = "exc-c14n" in (cm.get("Algorithm", "") if cm is not None else "") si_c14n = etree.tostring(si, method="c14n", exclusive=si_excl, with_comments=False) sv_el = sig.find("./ds:SignatureValue", NS) sig_value = base64.b64decode((sv_el.text or "").strip()) sm = si.find("./ds:SignatureMethod", NS) smeth = sm.get("Algorithm", "") if sm is not None else "" if "sha256" in smeth: halg = hashes.SHA256() elif "sha1" in smeth: halg = hashes.SHA1() else: raise SamlError("unsupported signature algorithm") try: pub.verify(sig_value, si_c14n, padding.PKCS1v15(), halg) except Exception: raise SamlError("signature verification failed (untrusted or tampered)") for ref in si.findall("./ds:Reference", NS): uri = ref.get("URI", "") if not uri.startswith("#"): raise SamlError("only same-document references are allowed") target = _saml_by_id(root, uri[1:]) node = copy.deepcopy(target) for s in node.findall(".//ds:Signature", NS): s.getparent().remove(s) ref_excl = True incl = None for tr in ref.findall("./ds:Transforms/ds:Transform", NS): alg = tr.get("Algorithm", "") if "exc-c14n" in alg: ref_excl = True elif "xml-c14n" in alg: ref_excl = False inc_el = tr.find("./ec:InclusiveNamespaces", NS) if inc_el is not None and inc_el.get("PrefixList"): incl = inc_el.get("PrefixList").split() node_c14n = etree.tostring(node, method="c14n", exclusive=ref_excl, with_comments=False, inclusive_ns_prefixes=incl) dm = ref.find("./ds:DigestMethod", NS) dmeth = dm.get("Algorithm", "") if dm is not None else "" dv = base64.b64decode((ref.find("./ds:DigestValue", NS).text or "").strip()) if "sha256" in dmeth: calc = hashlib.sha256(node_c14n).digest() elif "sha1" in dmeth: calc = hashlib.sha1(node_c14n).digest() else: raise SamlError("unsupported digest algorithm") if not hmac.compare_digest(calc, dv): raise SamlError("digest mismatch (assertion was tampered)") verified_ids.add(target.get("ID")) # Use ONLY a signed assertion (its ID was covered by a verified signature). assertion = None for a in root.findall(".//saml:Assertion", NS): if a.get("ID") in verified_ids: assertion = a break if assertion is None and root.tag.endswith("}Response") and root.get("ID") in verified_ids: assertion = root.find("./saml:Assertion", NS) if assertion is None: raise SamlError("no signed assertion present") now = datetime.datetime.utcnow() cond = assertion.find("./saml:Conditions", NS) if cond is not None: nb, na = cond.get("NotBefore"), cond.get("NotOnOrAfter") if nb and _saml_parsedt(nb) - datetime.timedelta(seconds=skew) > now: raise SamlError("assertion is not yet valid") if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: raise SamlError("assertion has expired") auds = [(e.text or "").strip() for e in cond.findall("./saml:AudienceRestriction/saml:Audience", NS)] if auds and sp_entity_id not in auds: raise SamlError("audience restriction does not match this SP") iss = assertion.find("./saml:Issuer", NS) if idp_entity_id and iss is not None and (iss.text or "").strip() != idp_entity_id: raise SamlError("assertion issuer does not match the configured IdP") scd = assertion.find("./saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", NS) if scd is not None: na = scd.get("NotOnOrAfter") if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now: raise SamlError("subject confirmation has expired") rcpt = scd.get("Recipient") if rcpt and acs_url and rcpt.rstrip("/") != acs_url.rstrip("/"): raise SamlError("subject confirmation recipient mismatch") nameid = assertion.find("./saml:Subject/saml:NameID", NS) email = ((nameid.text or "").strip().lower()) if nameid is not None else "" if "@" not in email: email_attrs = ("email", "mail", "urn:oid:0.9.2342.19200300.100.1.3", "http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress") for at in assertion.findall(".//saml:Attribute", NS): if at.get("Name", "").lower() in email_attrs: v = at.find("./saml:AttributeValue", NS) if v is not None and "@" in (v.text or ""): email = v.text.strip().lower() break if "@" not in email: raise SamlError("assertion carries no email NameID/attribute") return email def _saml_provision(email): now = int(time.time()) with _acct_db() as conn: row = conn.execute("SELECT COALESCE(disabled,0) FROM users WHERE lower(email)=lower(?)", (email,)).fetchone() if row is None: salt = _secrets.token_bytes(16) pw = _secrets.token_urlsafe(24) role = getattr(ARGS, "saml_default_role", "member") or "member" conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) " "VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role)) conn.commit() log_event("auth", f"SAML provisioned account {email}") return True return not bool(row[0]) # True unless disabled async def saml_metadata(req): if not _saml_enabled(): return web.Response(status=404, text="SAML is not configured on this server") acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) xml = ('' '' '' 'urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress' '' '') % (_xmlesc(sp), _xmlesc(acs)) return web.Response(text=xml, content_type="application/samlmetadata+xml") async def saml_login(req): if not _saml_enabled(): return web.Response(status=404, text="SAML is not configured on this server") import base64, zlib, urllib.parse, uuid, datetime acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req) rid = "_" + uuid.uuid4().hex inst = datetime.datetime.utcnow().strftime("%Y-%m-%dT%H:%M:%SZ") xml = ('' '%s' '') % ( rid, inst, _xmlesc(ARGS.saml_idp_sso_url), _xmlesc(acs), _xmlesc(sp)) co = zlib.compressobj(9, zlib.DEFLATED, -15) deflated = co.compress(xml.encode()) + co.flush() relay = req.query.get("RelayState", "/app") q = urllib.parse.urlencode({"SAMLRequest": base64.b64encode(deflated).decode(), "RelayState": relay}) sep = "&" if "?" in ARGS.saml_idp_sso_url else "?" raise web.HTTPFound(ARGS.saml_idp_sso_url + sep + q) async def saml_acs(req): if not _saml_enabled(): return web.Response(status=404, text="SAML is not configured on this server") import base64 form = await req.post() b64 = form.get("SAMLResponse", "") relay = form.get("RelayState", "") if not b64: return web.Response(status=400, text="missing SAMLResponse") try: xml = base64.b64decode(b64) except Exception: return web.Response(status=400, text="bad SAMLResponse encoding") try: email = _saml_validate(xml, _saml_acs_url(req), _saml_idp_cert_pem(), _saml_sp_entity_id(req), getattr(ARGS, "saml_idp_entity_id", "") or None) except SamlError as e: log_event("authfail", "SAML rejected: %s" % e) return web.Response(status=403, text="SAML validation failed: %s" % e) except Exception as e: log_event("authfail", "SAML error: %s" % e) return web.Response(status=400, text="SAML processing error") active = _saml_provision(email) if not active: return web.Response(status=403, text="this account has been deactivated") tok = _new_token(email) log_event("auth", "signed in via SAML: " + email) dest = relay if (relay and relay.startswith("/")) else "/app" resp = web.HTTPFound(dest) resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=365 * 86400) return resp async def api_auth_sso(req): """Tell the login UI whether SSO is available (so it can show a button).""" return web.json_response({"saml": _saml_enabled(), "login_url": "/saml/login", "enforce": bool(getattr(ARGS, "saml_enforce", False))}) # ---------------------------------------------------------------- WebSocket async def relay_session_end(sess, reason, source_ws): """Tear down a session and notify both sides.""" try: if sess.host is not None and sess.host.ws is not source_ws and not sess.host.ws.closed: await sess.host.ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) except Exception: pass try: if sess.viewer_ws is not source_ws and not sess.viewer_ws.closed: await sess.viewer_ws.send_str(json.dumps({"t": "session.end", "sid": sess.sid, "reason": reason})) except Exception: pass try: fire_webhook(_device_owner(sess.host.id) if sess.host else None, "disconnect", {"host_id": sess.host.id if sess.host else "", "reason": reason}) except Exception: pass async def send_json(ws, obj): try: if not ws.closed: await ws.send_str(json.dumps(obj)) except Exception: pass async def ws_handler(req): ws = web.WebSocketResponse(heartbeat=20.0, max_msg_size=64 * 1024 * 1024, autoping=True) await ws.prepare(req) # behind nginx req.remote is 127.0.0.1 - prefer the forwarded client IP peer = ((req.headers.get("X-Forwarded-For") or "").split(",")[0].strip() or req.remote) host: Host | None = None viewer_sid: str | None = None log.info("connection from %s", peer) try: async for msg in ws: if msg.type == WSMsgType.TEXT: try: m = json.loads(msg.data) except Exception: continue if not isinstance(m, dict): continue t = m.get("t") # tunnel lifecycle JSON from a host: route to the tunnel socket if t in ("tunnel.ok", "tunnel.err", "tunnel.closed") and TUNNELS: tws = TUNNELS.get(m.get("tid", -1)) if tws is not None and not tws.closed: try: await tws.send_str(msg.data) except Exception: pass continue # ---- host registration -------------------------------- if t == "reg.host": hid = str(m.get("id", "")).strip() if not hid or not hid.isdigit() or not (100000000 <= int(hid) <= 999999999): await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad id"}) await ws.close() break if ARGS.reg_key and m.get("key") != ARGS.reg_key: await send_json(ws, {"t": "host_status", "ok": False, "msg": "bad registration key"}) await ws.close() break old = HOSTS.get(hid) if old is not None and old.ws is not ws and not old.ws.closed: # A live connection already holds this ID. if old.session is not None: # There's an active/pending session - NEVER displace it. # This is what stops the "viewer drops 2-3s after # connecting" flapping: a duplicate/stuck engine can # never kill a live session. log.info("host %s: rejecting registration from %s " "(holder has an active session)", hid, peer) await send_json(ws, {"t": "host_status", "ok": False, "msg": "this ID is busy with a session"}) await ws.close() break if not m.get("takeover"): # Idle holder, plain duplicate: reject so two engines # never ping-pong the ID. The holder keeps it. log.info("host %s: rejecting duplicate registration from %s " "(a live connection already holds this idle ID)", hid, peer) await send_json(ws, {"t": "host_status", "ok": False, "msg": "this ID is already connected elsewhere"}) await ws.close() break # Idle holder + explicit takeover from a (fresh) engine: # replace the old connection (lets a new/updated engine # cleanly take over a stuck one without a session running). log.info("host %s: takeover from %s replacing idle holder", hid, peer) try: await old.ws.close() except Exception: pass elif old is not None and old.ws is not ws: # holder's socket is already closed: clean it up and take over if old.session: await relay_session_end(old.session, "host reconnected", ws) # seat / licensing admission (0 seats = unlimited; owner from # the signed-in token, else the device's bound owner). _tok = str(m.get("token") or "") _ro = (email_for_token(_tok) if _tok else "") or device_owner(hid) or "" if _ro and not _seat_ok(_ro, hid): _sc = _owner_seats(_ro) log_event("deny", f"host {hid} register: seat limit {_sc} reached " f"for {_ro}") await send_json(ws, {"t": "host_status", "ok": False, "msg": f"seat limit reached ({_sc}) - free a " f"device or add seats"}) await ws.close() break host = Host(ws, hid, str(m.get("name", ""))[:64], str(m.get("ver", "?"))[:32]) HOSTS[hid] = host prev = KNOWN_HOSTS.get(hid) KNOWN_HOSTS[hid] = { "id": hid, "name": host.name, "ver": host.ver, "first_seen": prev["first_seen"] if prev else int(time.time()), "last_seen": int(time.time()), } # account binding: the desktop app signs in and sends its token owner = None if "token" in m: owner = email_for_token(str(m.get("token") or "")) try: bind_device(hid, owner, host.name, host.ver, str(m.get("mac") or "")[:32]) except Exception as e: log.warning("device bind failed: %s", e) else: touch_device(hid) log_event("host", f"host {hid} ({host.name}) online") log.info("host %s registered from %s (name=%s ver=%s owner=%s)", hid, peer, host.name, host.ver, owner or "-") _pol = _account_policy(owner) if owner else {} await send_json(ws, {"t": "host_status", "ok": True, "msg": "registered", "account": owner or "", "policy": _pol}) elif t == "session.begin" and host is not None and "sid" not in m: # Host is never the initiator of session.begin without sid; # this branch intentionally ignores it. continue # ---- viewer: start a session -------------------------- elif t == "session.begin": vuser = user_from_cookie(req) vplan, vlimit = plan_for(vuser) hid = str(m.get("id", "")).strip() h = HOSTS.get(hid) if h is None or h.ws.closed: log_event("deny", f"connect to {hid}: host offline") await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) continue # Hidden host: invisible to everyone but its signed-in owner. # Denied as "offline" so its existence isn't revealed. if is_hidden(hid): ow = device_owner(hid) if not (vuser and ow and vuser.lower() == ow.lower()): log_event("deny", f"connect to {hid}: host hidden") await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host offline"}) continue if h.session is not None: # Same user hopping devices can explicitly TAKE OVER the # existing session (TeamViewer-style) instead of being # locked out. Without the flag we still reject. if m.get("takeover"): log.info("session takeover on host %s by %s", hid, peer) await relay_session_end(h.session, "taken over from another device", None) h.session = None else: log_event("deny", f"connect to {hid}: host busy") await send_json(ws, {"t": "session.deny", "sid": None, "reason": "host busy"}) continue # conditional-access rules (per host-owner account): first # matching deny stops the connection, with its reason shown # to the viewer. Default-allow when no rule matches. _aowner, _agroup = _device_owner_group(hid) _aok, _areason, _arid = _eval_access( _aowner, vuser, hid, _agroup, peer, int(time.time())) if not _aok: log_event("deny", f"connect to {hid} by {vuser or peer}: " f"{_areason} (rule #{_arid})", vuser or "") await send_json(ws, {"t": "session.deny", "sid": None, "reason": _areason}) continue sid = uuid.uuid4().hex sess = Session(sid, h, ws) if vlimit and host_is_owners(hid): vlimit = None # owner's own PC: unlimited sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser sess.attribution = vuser or ("guest@" + peer) sess.vip = peer or "" h.session = sess # Live-visitor intel: record + geo-locate the viewer's IP. _vrec = {"ip": sess.vip, "host": hid, "host_name": h.name, "plan": vplan, "email": vuser or "", "t": int(time.time()), "geo": {}} RECENT_VISITORS.appendleft(_vrec) async def _vgeo(s=sess, r=_vrec, ip=sess.vip): g = await geo_lookup(ip) s.vgeo = g r["geo"] = g asyncio.ensure_future(_vgeo()) viewer_sid = sid STATS.sessions_total += 1 log_event("session", f"viewer {peer} ({vplan}) connecting to host {hid}") log.info("session %s: viewer %s (%s) -> host %s", sid, peer, vplan, hid) _sbmsg = {"t": "session.begin", "sid": sid, "peer": peer or "", "user": vuser or ""} try: if _aowner and _account_policy(_aowner).get("record_required"): _rtok = _rec_mint(hid, _aowner, sid) _sbmsg["rec"] = {"token": _rtok, "url": "/api/recordings/upload"} log_event("recording", f"session {sid} on {hid} is policy-recorded", _aowner) except Exception as _e: log.warning("rec token mint: %s", _e) await send_json(h.ws, _sbmsg) async def _auth_timeout(s=sess, hh=h): # a viewer that never finishes auth must not block the host. # 25s (was 120): a half-open slot frees fast so a dropped # viewer's own reconnect isn't locked out for two minutes. await asyncio.sleep(25) if hh.session is s and not s.active: hh.session = None await relay_session_end(s, "authentication timed out", None) asyncio.ensure_future(_auth_timeout()) # ---- host telemetry (CPU/RAM/disk/battery) -> portal ---- elif t == "host.telemetry" and host is not None: host.telemetry = { "cpu": m.get("cpu"), "ram_used": m.get("ram_used"), "ram_total": m.get("ram_total"), "disk_used": m.get("disk_used"), "disk_total": m.get("disk_total"), "battery": m.get("battery"), "charging": m.get("charging"), "uptime": m.get("uptime"), "ts": int(time.time()), } # ---- everything else: relay between the two peers ----- else: sid = m.get("sid") sess = None if host is not None: sess = host.session if (host.session and host.session.sid == sid) else None if sess is None and not sid and host.session and t == "session.deny": sess = host.session # older hosts deny without a sid if sess is None and viewer_sid and sid == viewer_sid: # find via host registry (viewer side of the link) for h in HOSTS.values(): if h.session and h.session.sid == sid and h.session.viewer_ws is ws: sess = h.session break if sess is None: continue # viewer asks for ICE servers (STUN + nearest TURN) for the # P2P fast path - answered by the server, never relayed if t == "rtc.config.req" and host is None and sess.viewer_ws is ws: await send_json(ws, {"t": "rtc.config", "sid": sess.sid, "iceServers": await ice_servers()}) continue if t == "session.accept" and host is not None: sess.active = True log_event("session", f"session accepted on host {sess.host.id}", actor=sess.attribution) fire_webhook(_device_owner(sess.host.id), "connect", {"host_id": sess.host.id, "viewer": sess.vemail or ""}) plan_msg = {"t": "session.plan", "plan": sess.plan, "limit": sess.vlimit or 0, "email": sess.vemail or ""} await send_json(sess.viewer_ws, plan_msg) if sess.vlimit: async def _limit_killer(s=sess): await asyncio.sleep(s.vlimit) if s.active: log_event("session", f"time limit on host {s.host.id}") await relay_session_end( s, "session time limit reached - reconnect or sign up", None) if s.host: s.host.session = None asyncio.ensure_future(_limit_killer(sess)) await send_json(sess.viewer_ws, m) elif t == "session.deny": reason = m.get("reason", "denied") if reason in ("wrong password", "bad 2FA code"): log_event("auth-fail", f"{reason} on host {sess.host.id}") else: log_event("deny", f"connect to {sess.host.id}: {reason}") sess.host.session = None await send_json(sess.viewer_ws, m) elif t == "session.end": await relay_session_end(sess, m.get("reason", "closed"), ws) log_event("session", f"session on host {sess.host.id} ended ({m.get('reason', 'closed')})") if sess.host: sess.host.session = None else: dest = sess.other(ws) if dest is not None: await send_json(dest, m) elif msg.type == WSMsgType.BINARY: # TCP-tunnel frames from a host: [0xF3][tid u32][payload] if msg.data and msg.data[0] == 0xF3 and len(msg.data) >= 5: tid = int.from_bytes(msg.data[1:5], "little") tws = TUNNELS.get(tid) if tws is not None and not tws.closed: try: await tws.send_bytes(msg.data[5:]) except Exception: pass continue # Opaque relay: find session for this socket, forward bytes. sess = None if host is not None and host.session and host.session.active: sess = host.session elif viewer_sid: for h in HOSTS.values(): if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws and h.session.active: sess = h.session break if sess is not None: dest = sess.other(ws) if dest is not None: STATS.bytes_relayed += len(msg.data) try: await dest.send_bytes(msg.data) except Exception: pass elif msg.type == WSMsgType.ERROR: break finally: if host is not None and HOSTS.get(host.id) is host: HOSTS.pop(host.id, None) k = KNOWN_HOSTS.get(host.id) if k: k["last_seen"] = int(time.time()) log_event("host", f"host {host.id} ({host.name}) offline") if host.session: await relay_session_end(host.session, "host disconnected", ws) if viewer_sid: for h in list(HOSTS.values()): if h.session and h.session.sid == viewer_sid and h.session.viewer_ws is ws: await relay_session_end(h.session, "viewer disconnected", ws) h.session = None log.info("connection closed from %s", peer) return ws # ---------------------------------------------------------------- self-signed cert def gen_selfsigned(cert_path, key_path, domain): from cryptography import x509 from cryptography.x509.oid import NameOID from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import ec import datetime, ipaddress key = ec.generate_private_key(ec.SECP256R1()) name = x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, domain or "netdesk")]) now = datetime.datetime.now(datetime.timezone.utc) san = x509.SubjectAlternativeName( [x509.DNSName(domain)] if domain else [x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1"))] ) cert = ( x509.CertificateBuilder() .subject_name(name) .issuer_name(name) .public_key(key.public_key()) .serial_number(x509.random_serial_number()) .not_valid_before(now - datetime.timedelta(days=1)) .not_valid_after(now + datetime.timedelta(days=3650)) .add_extension(san, critical=False) .sign(key, hashes.SHA256()) ) with open(cert_path, "wb") as f: f.write(cert.public_bytes(serialization.Encoding.PEM)) with open(key_path, "wb") as f: f.write(key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.TraditionalOpenSSL, serialization.NoEncryption(), )) fp = cert.fingerprint(hashes.SHA256()).hex() print(f"[netdesk] self-signed cert written: {cert_path}") print(f"[netdesk] SHA256 fingerprint: {fp}") print("[netdesk] Browsers will warn until you accept it (or use Let's Encrypt in production).") # ---------------------------------------------------------------- main ARGS = None def build_app(): app = web.Application() # public infra (protected by reg-key / cookies / tokens) app.router.add_get("/health", http_health) app.router.add_get("/status", http_status) app.router.add_get("/ws", ws_handler) app.router.add_get("/robots.txt", http_robots) app.router.add_post("/portal/login", portal_login) app.router.add_get("/portal/logout", portal_logout) app.router.add_get("/portal/api/overview", portal_overview) app.router.add_get("/portal/api/audit", portal_audit) app.router.add_get("/portal/api/audit.csv", portal_audit_csv) app.router.add_get("/portal/api/users", portal_users) app.router.add_post("/portal/api/user/delete", portal_user_delete) app.router.add_post("/portal/api/host/delete", portal_host_delete) app.router.add_post("/portal/api/host/hide", portal_host_hide) app.router.add_post("/portal/api/session/disconnect", portal_session_disconnect) app.router.add_get("/dl/{name}", portal_download) app.router.add_get("/tunnel/{hid}", tunnel_ws) app.router.add_get("/download/{name}", public_download) # viewer email accounts app.router.add_post("/api/auth/signup", auth_signup) app.router.add_post("/api/auth/login", auth_login) app.router.add_post("/api/auth/forgot", auth_forgot) app.router.add_post("/api/auth/reset", auth_reset) app.router.add_get("/api/auth/verify", auth_verify) app.router.add_get("/api/auth/me", auth_me) app.router.add_post("/api/auth/logout", auth_logout) app.router.add_get("/api/auth/oidc/{provider}/start", auth_oidc_start) app.router.add_get("/api/auth/oidc/{provider}/callback", auth_oidc_callback) app.router.add_post("/api/keys", api_keys_create) app.router.add_get("/api/keys", api_keys_list) app.router.add_post("/api/keys/revoke", api_keys_revoke) app.router.add_get("/api/v1/devices", v1_devices) app.router.add_get("/api/v1/webhooks", v1_webhooks_list) app.router.add_post("/api/v1/webhooks", v1_webhooks_add) app.router.add_post("/api/v1/webhooks/remove", v1_webhooks_remove) app.router.add_post("/api/v1/webhooks/test", v1_webhooks_test) app.router.add_get("/api/devices", api_devices) app.router.add_post("/api/devices/remove", api_device_remove) app.router.add_post("/api/devices/meta", api_device_meta) app.router.add_get("/api/teams", api_teams) app.router.add_post("/api/teams", api_team_create) app.router.add_post("/api/teams/rename", api_team_rename) app.router.add_post("/api/teams/delete", api_team_delete) app.router.add_get("/api/teams/members", api_team_members) app.router.add_post("/api/teams/members", api_team_member_add) app.router.add_post("/api/teams/members/remove", api_team_member_remove) app.router.add_get("/api/teams/devices", api_team_devices) app.router.add_post("/api/teams/devices", api_team_device_add) app.router.add_post("/api/teams/devices/remove", api_team_device_remove) app.router.add_get("/api/access/rules", api_access_rules) app.router.add_post("/api/access/rules", api_access_rule_set) app.router.add_post("/api/access/rules/delete", api_access_rule_delete) app.router.add_get("/api/access/test", api_access_test) app.router.add_post("/api/recordings/upload", api_rec_upload) app.router.add_get("/api/recordings", api_recordings) app.router.add_get("/api/recordings/{id}/download", api_rec_download) app.router.add_post("/api/recordings/delete", api_rec_delete) app.router.add_get("/saml/metadata", saml_metadata) app.router.add_get("/saml/login", saml_login) app.router.add_post("/saml/acs", saml_acs) app.router.add_get("/api/auth/sso", api_auth_sso) # SCIM 2.0 provisioning (Bearer-token authed) app.router.add_get("/scim/v2/ServiceProviderConfig", scim_spc) app.router.add_get("/scim/v2/Users", scim_users_list) app.router.add_post("/scim/v2/Users", scim_user_create) app.router.add_get("/scim/v2/Users/{id}", scim_user_get) app.router.add_put("/scim/v2/Users/{id}", scim_user_put) app.router.add_route("PATCH", "/scim/v2/Users/{id}", scim_user_patch) app.router.add_delete("/scim/v2/Users/{id}", scim_user_delete) app.router.add_get("/scim/v2/Groups", scim_groups_list) app.router.add_post("/scim/v2/Groups", scim_group_create) app.router.add_get("/scim/v2/Groups/{id}", scim_group_get) app.router.add_put("/scim/v2/Groups/{id}", scim_group_put) app.router.add_route("PATCH", "/scim/v2/Groups/{id}", scim_group_patch) app.router.add_delete("/scim/v2/Groups/{id}", scim_group_delete) app.router.add_get("/api/admin/scim/tokens", api_scim_tokens_list) app.router.add_post("/api/admin/scim/tokens", api_scim_token_create) app.router.add_post("/api/admin/scim/tokens/revoke", api_scim_token_revoke) app.router.add_post("/api/admin/role", api_admin_role) app.router.add_get("/api/admin/users", api_admin_users) app.router.add_get("/api/admin/overview", api_admin_overview) app.router.add_get("/api/admin/teams", api_admin_teams) app.router.add_get("/api/admin/policy", api_admin_policy) app.router.add_post("/api/admin/policy", api_admin_policy) app.router.add_get("/api/v1/policy", v1_policy_get) app.router.add_post("/api/v1/policy", v1_policy_set) app.router.add_post("/api/admin/license", api_admin_license) app.router.add_get("/api/relays", api_relays) app.router.add_get("/api/relay/best", api_relay_best) app.router.add_get("/api/auth/2fa/status", auth_2fa_status) app.router.add_post("/api/auth/2fa/setup", auth_2fa_setup) app.router.add_post("/api/auth/2fa/enable", auth_2fa_enable) app.router.add_post("/api/auth/2fa/disable", auth_2fa_disable) app.router.add_post("/api/wake", api_wake) app.router.add_get("/api/info", api_info) app.router.add_post("/api/contact", api_contact) app.router.add_get("/sitemap.xml", http_sitemap) # a static-serve helper for the marketing pages def serve(fname): return lambda r: serve_file(fname) marketing = ["features", "pricing", "security", "download", "contact", "about", "terms", "privacy", "refund"] # UI pages: marketing site at root, web app under /app pages = [("/", http_index), ("/index.html", http_index), ("/app", http_app), ("/app.html", http_app), ("/app.js", serve("app.js")), ("/host.js", serve("host.js")), ("/app.css", serve("app.css")), ("/site.css", serve("site.css")), ("/site.js", serve("site.js")), ("/admin", serve("admin.html")), ("/admin.js", serve("admin.js")), ("/portal", portal_page), ("/portal.js", serve("portal.js")), ("/manifest.webmanifest", serve("manifest.webmanifest")), ("/ndicon.svg", serve("ndicon.svg"))] for slug in marketing: pages.append(("/" + slug, serve(slug + ".html"))) pages.append(("/" + slug + ".html", serve(slug + ".html"))) if ARGS.access_path: for path, handler in pages: app.router.add_get("/" + ARGS.access_path + ("" if path == "/" else path), handler) else: for path, handler in pages: app.router.add_get(path, handler) return app def main(): global ARGS ap = argparse.ArgumentParser(description="NetDesk signaling/relay server") ap.add_argument("--port", type=int, default=8443) ap.add_argument("--bind", default="0.0.0.0") ap.add_argument("--cert", help="TLS certificate PEM") ap.add_argument("--key", help="TLS private key PEM") ap.add_argument("--gen-cert", action="store_true", help="generate a self-signed cert and use it") ap.add_argument("--domain", help="DNS name for --gen-cert SAN") ap.add_argument("--reg-key", help="secret hosts must present to register (recommended)") ap.add_argument("--status-key", help="secret required for /status details") ap.add_argument("--portal-password", dest="portal_password", help="password for the web management portal") ap.add_argument("--access-path", dest="access_path", help="secret URL segment hosting the UI (random hash); root serves 404") ap.add_argument("--owner-email", dest="owner_email", default="", help="email with unrestricted access + admin panel") # TURN tier of the hybrid transport (P2P -> TURN -> WSS relay). # Cloudflare Realtime TURN is anycast: peers relay via their NEAREST city. ap.add_argument("--turn-key-id", dest="turn_key_id", default=os.environ.get("ND_TURN_KEY_ID", ""), help="Cloudflare Realtime TURN key id (mints short-lived creds)") ap.add_argument("--turn-token", dest="turn_token", default=os.environ.get("ND_TURN_TOKEN", ""), help="Cloudflare Realtime TURN key API token") ap.add_argument("--turn-urls", dest="turn_urls", default=os.environ.get("ND_TURN_URLS", ""), help="static TURN urls (comma sep), e.g. turn:host:3478?transport=udp") ap.add_argument("--turn-user", dest="turn_user", default=os.environ.get("ND_TURN_USER", "")) ap.add_argument("--turn-pass", dest="turn_pass", default=os.environ.get("ND_TURN_PASS", "")) # Force every session onto the WSS relay (no ICE handed out -> no P2P). A # privacy/compliance control: peer IPs are never exposed and all traffic # flows through this server. (RustDesk's ALWAYS_USE_RELAY.) ap.add_argument("--always-use-relay", dest="always_use_relay", action="store_true", default=os.environ.get("ND_ALWAYS_USE_RELAY", "") not in ("", "0", "false", "no"), help="hand out no STUN/TURN so all sessions stay on the relay") # self-hosted coturn with use-auth-secret: the server mints 12h HMAC creds ap.add_argument("--turn-host", dest="turn_host", default=os.environ.get("ND_TURN_HOST", ""), help="hostname of our own coturn (3478 udp/tcp + 5349 tls)") ap.add_argument("--turn-secret", dest="turn_secret", default=os.environ.get("ND_TURN_SECRET", ""), help="coturn static-auth-secret (TURN REST API credentials)") ap.add_argument("--oidc", default=os.environ.get("ND_OIDC", ""), help='SSO providers as JSON, e.g. ' '{"google":{"issuer":"https://accounts.google.com","client_id":"..","client_secret":".."}}') ap.add_argument("--mail-from", dest="mail_from", default="", help="sender address enabling email accounts, e.g. noreply@netdesk.space") ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1", help="SMTP relay for verification mail (default localhost postfix)") ap.add_argument("--public-url", dest="public_url", default="", help="public base URL used in email links, e.g. https://netdesk.space") ap.add_argument("--portal-totp", dest="portal_totp", default=os.environ.get("ND_PORTAL_TOTP", ""), help="base32 TOTP secret to also require at portal login (optional 2FA)") ap.add_argument("--relays", default=os.environ.get("ND_RELAYS", ""), help="JSON array of relay nodes [{id,region,url,lat,lon,tier}] " "for nearest-relay geo selection (default: a single self-entry)") ap.add_argument("--ldap-url", dest="ldap_url", default=os.environ.get("ND_LDAP_URL", ""), help="ldap://host:389 or ldaps://host:636 to enable LDAP/AD login") ap.add_argument("--ldap-bind-template", dest="ldap_bind_template", default=os.environ.get("ND_LDAP_BIND", "{email}"), help="bind DN template; {user}=local part, {email}=full, {domain}=domain " "(AD UPN: '{email}'; OpenLDAP: 'uid={user},ou=people,dc=ex,dc=com')") ap.add_argument("--ldap-default-role", dest="ldap_default_role", default=os.environ.get("ND_LDAP_ROLE", "member")) ap.add_argument("--ldap-timeout", dest="ldap_timeout", type=int, default=int(os.environ.get("ND_LDAP_TIMEOUT", "6"))) ap.add_argument("--ldap-insecure", dest="ldap_insecure", action="store_true", default=bool(os.environ.get("ND_LDAP_INSECURE")), help="skip TLS cert validation for ldaps:// (internal CA self-signed only)") ap.add_argument("--recording-retention-days", dest="recording_retention_days", type=int, default=int(os.environ.get("ND_REC_RETENTION", "0")), help="auto-delete central recordings older than N days (0 = keep forever)") ap.add_argument("--saml-idp-sso-url", dest="saml_idp_sso_url", default=os.environ.get("ND_SAML_SSO_URL", ""), help="IdP SSO (SingleSignOnService) URL to enable SAML 2.0 SSO") ap.add_argument("--saml-idp-cert", dest="saml_idp_cert", default=os.environ.get("ND_SAML_IDP_CERT", ""), help="IdP X.509 signing certificate (PEM file path or inline PEM)") ap.add_argument("--saml-idp-entity-id", dest="saml_idp_entity_id", default=os.environ.get("ND_SAML_IDP_ENTITY", ""), help="expected IdP entityID (Issuer) to validate (optional)") ap.add_argument("--saml-sp-entity-id", dest="saml_sp_entity_id", default=os.environ.get("ND_SAML_SP_ENTITY", ""), help="this SP's entityID (default: /saml/metadata)") ap.add_argument("--saml-default-role", dest="saml_default_role", default=os.environ.get("ND_SAML_ROLE", "member")) ap.add_argument("--saml-enforce", dest="saml_enforce", action="store_true", default=bool(os.environ.get("ND_SAML_ENFORCE")), help="advertise SSO as mandatory to the login UI") ap.add_argument("--verbose", action="store_true") ARGS = ap.parse_args() logging.basicConfig( level=logging.DEBUG if ARGS.verbose else logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) ssl_ctx = None if ARGS.gen_cert: d = os.path.dirname(os.path.abspath(__file__)) cert_path, key_path = os.path.join(d, "selfsigned.crt"), os.path.join(d, "selfsigned.key") gen_selfsigned(cert_path, key_path, ARGS.domain) ARGS.cert, ARGS.key = cert_path, key_path if ARGS.cert and ARGS.key: ssl_ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) ssl_ctx.load_cert_chain(ARGS.cert, ARGS.key) print(f"[netdesk] TLS enabled ({ARGS.cert})") else: print("[netdesk] WARNING: running without TLS - fine on localhost/LAN, " "NOT for the public internet. Use --cert/--key or --gen-cert.") if ARGS.reg_key: print("[netdesk] host registration key required (good).") if ARGS.portal_password: os.makedirs(FILES_DIR, exist_ok=True) print(f"[netdesk] web portal enabled at /portal (downloads: {FILES_DIR})") _load_audit() app = build_app() scheme = "https/wss" if ssl_ctx else "http/ws" print(f"[netdesk] server v{VERSION} listening on {scheme}://{ARGS.bind}:{ARGS.port}") print(f"[netdesk] web viewer: {'https' if ssl_ctx else 'http'}://:{ARGS.port}/") web.run_app(app, host=ARGS.bind, port=ARGS.port, ssl_context=ssl_ctx, print=None, access_log=None) if __name__ == "__main__": main()