Files
netdesk/server/netdesk_server.py
T

4264 lines
190 KiB
Python

#!/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 = (
'<p style="margin:18px 0">'
'<a href="' + link + '" style="background:#3d7eff;color:#fff;padding:11px 22px;'
'border-radius:8px;text-decoration:none;font-weight:600">' + link_text + "</a></p>"
'<p style="color:#8b95a9;font-size:12px">Or paste this link: ' + link + "</p>")
logo = ('<span style="background:#3d7eff;border-radius:8px;width:34px;height:34px;'
'display:inline-flex;align-items:center;justify-content:center">'
'<svg width="20" height="20" viewBox="0 0 32 32">'
'<path d="M8 20l8-12 8 12-8-4z" fill="#fff"/>'
'<rect x="12" y="18" width="8" height="7" rx="2" fill="#fff"/></svg></span>')
return (
'<div style="font-family:Segoe UI,Arial,sans-serif;max-width:520px;margin:0 auto;'
'background:#11151f;color:#e8edf6;border:1px solid #222b3d;border-radius:14px;padding:26px">'
'<div style="display:flex;align-items:center;gap:10px;margin-bottom:16px">' + logo +
'<b style="font-size:19px">NetDesk</b></div>'
'<h2 style="margin:0 0 10px;font-size:17px">' + title + "</h2>"
'<div style="font-size:14px;line-height:1.6;color:#c7cfe0">' + body_html + "</div>"
+ link_html +
'<p style="color:#5d6777;font-size:11.5px;margin-top:22px">'
"If you did not request this, ignore this email.</p></div>")
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=<t> / ?group=<g>. 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"<b>From:</b> {name} &lt;{email}&gt;<br><b>Topic:</b> {topic}"
f"<br><br>{message}".replace(chr(10), '<br>')))
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/<hostid>
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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
.replace('"', "&quot;"))
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 = ('<?xml version="1.0"?>'
'<md:EntityDescriptor xmlns:md="urn:oasis:names:tc:SAML:2.0:metadata" entityID="%s">'
'<md:SPSSODescriptor AuthnRequestsSigned="false" WantAssertionsSigned="true" '
'protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">'
'<md:NameIDFormat>urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress</md:NameIDFormat>'
'<md:AssertionConsumerService Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST" '
'Location="%s" index="0" isDefault="true"/>'
'</md:SPSSODescriptor></md:EntityDescriptor>') % (_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 = ('<samlp:AuthnRequest xmlns:samlp="urn:oasis:names:tc:SAML:2.0:protocol" '
'xmlns:saml="urn:oasis:names:tc:SAML:2.0:assertion" ID="%s" Version="2.0" '
'IssueInstant="%s" Destination="%s" AssertionConsumerServiceURL="%s" '
'ProtocolBinding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST">'
'<saml:Issuer>%s</saml:Issuer>'
'<samlp:NameIDPolicy Format="urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress" '
'AllowCreate="true"/></samlp:AuthnRequest>') % (
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: <public-url>/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'}://<this-host>:{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()