4264 lines
190 KiB
Python
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} <{email}><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("&", "&").replace("<", "<").replace(">", ">")
|
|
.replace('"', """))
|
|
|
|
|
|
def _saml_parsedt(s):
|
|
import datetime
|
|
s = (s or "").strip().replace("Z", "")
|
|
if "." in s:
|
|
s = s.split(".")[0]
|
|
return datetime.datetime.strptime(s, "%Y-%m-%dT%H:%M:%S")
|
|
|
|
|
|
def _saml_by_id(root, el_id):
|
|
"""Find the single element with ID==el_id; reject duplicates (XSW defence)."""
|
|
found = [e for e in root.iter() if e.get("ID") == el_id]
|
|
if len(found) != 1:
|
|
raise SamlError("ambiguous or missing ID reference")
|
|
return found[0]
|
|
|
|
|
|
def _saml_validate(xml_bytes, acs_url, idp_cert_pem, sp_entity_id, idp_entity_id=None, skew=180):
|
|
from lxml import etree
|
|
from cryptography import x509
|
|
from cryptography.hazmat.primitives.asymmetric import padding
|
|
from cryptography.hazmat.primitives import hashes
|
|
import base64, hashlib, hmac, copy, datetime
|
|
NS = _SAML_NS
|
|
|
|
parser = etree.XMLParser(resolve_entities=False, no_network=True, load_dtd=False, huge_tree=False)
|
|
try:
|
|
root = etree.fromstring(xml_bytes, parser)
|
|
except Exception as e:
|
|
raise SamlError("malformed SAML XML: %s" % e)
|
|
if root.getroottree().docinfo.doctype:
|
|
raise SamlError("DOCTYPE is not allowed")
|
|
|
|
# Response-level Status must be Success (when this is a Response)
|
|
if root.tag.endswith("}Response"):
|
|
sc = root.find("./samlp:Status/samlp:StatusCode", NS)
|
|
if sc is not None and not (sc.get("Value", "").endswith(":status:Success")):
|
|
raise SamlError("IdP returned a non-Success status")
|
|
|
|
cert = x509.load_pem_x509_certificate(idp_cert_pem)
|
|
pub = cert.public_key()
|
|
|
|
sigs = root.findall(".//ds:Signature", NS)
|
|
if not sigs:
|
|
raise SamlError("response is not signed")
|
|
|
|
verified_ids = set()
|
|
for sig in sigs:
|
|
si = sig.find("./ds:SignedInfo", NS)
|
|
if si is None:
|
|
continue
|
|
cm = si.find("./ds:CanonicalizationMethod", NS)
|
|
si_excl = "exc-c14n" in (cm.get("Algorithm", "") if cm is not None else "")
|
|
si_c14n = etree.tostring(si, method="c14n", exclusive=si_excl, with_comments=False)
|
|
sv_el = sig.find("./ds:SignatureValue", NS)
|
|
sig_value = base64.b64decode((sv_el.text or "").strip())
|
|
sm = si.find("./ds:SignatureMethod", NS)
|
|
smeth = sm.get("Algorithm", "") if sm is not None else ""
|
|
if "sha256" in smeth:
|
|
halg = hashes.SHA256()
|
|
elif "sha1" in smeth:
|
|
halg = hashes.SHA1()
|
|
else:
|
|
raise SamlError("unsupported signature algorithm")
|
|
try:
|
|
pub.verify(sig_value, si_c14n, padding.PKCS1v15(), halg)
|
|
except Exception:
|
|
raise SamlError("signature verification failed (untrusted or tampered)")
|
|
|
|
for ref in si.findall("./ds:Reference", NS):
|
|
uri = ref.get("URI", "")
|
|
if not uri.startswith("#"):
|
|
raise SamlError("only same-document references are allowed")
|
|
target = _saml_by_id(root, uri[1:])
|
|
node = copy.deepcopy(target)
|
|
for s in node.findall(".//ds:Signature", NS):
|
|
s.getparent().remove(s)
|
|
ref_excl = True
|
|
incl = None
|
|
for tr in ref.findall("./ds:Transforms/ds:Transform", NS):
|
|
alg = tr.get("Algorithm", "")
|
|
if "exc-c14n" in alg:
|
|
ref_excl = True
|
|
elif "xml-c14n" in alg:
|
|
ref_excl = False
|
|
inc_el = tr.find("./ec:InclusiveNamespaces", NS)
|
|
if inc_el is not None and inc_el.get("PrefixList"):
|
|
incl = inc_el.get("PrefixList").split()
|
|
node_c14n = etree.tostring(node, method="c14n", exclusive=ref_excl,
|
|
with_comments=False, inclusive_ns_prefixes=incl)
|
|
dm = ref.find("./ds:DigestMethod", NS)
|
|
dmeth = dm.get("Algorithm", "") if dm is not None else ""
|
|
dv = base64.b64decode((ref.find("./ds:DigestValue", NS).text or "").strip())
|
|
if "sha256" in dmeth:
|
|
calc = hashlib.sha256(node_c14n).digest()
|
|
elif "sha1" in dmeth:
|
|
calc = hashlib.sha1(node_c14n).digest()
|
|
else:
|
|
raise SamlError("unsupported digest algorithm")
|
|
if not hmac.compare_digest(calc, dv):
|
|
raise SamlError("digest mismatch (assertion was tampered)")
|
|
verified_ids.add(target.get("ID"))
|
|
|
|
# Use ONLY a signed assertion (its ID was covered by a verified signature).
|
|
assertion = None
|
|
for a in root.findall(".//saml:Assertion", NS):
|
|
if a.get("ID") in verified_ids:
|
|
assertion = a
|
|
break
|
|
if assertion is None and root.tag.endswith("}Response") and root.get("ID") in verified_ids:
|
|
assertion = root.find("./saml:Assertion", NS)
|
|
if assertion is None:
|
|
raise SamlError("no signed assertion present")
|
|
|
|
now = datetime.datetime.utcnow()
|
|
cond = assertion.find("./saml:Conditions", NS)
|
|
if cond is not None:
|
|
nb, na = cond.get("NotBefore"), cond.get("NotOnOrAfter")
|
|
if nb and _saml_parsedt(nb) - datetime.timedelta(seconds=skew) > now:
|
|
raise SamlError("assertion is not yet valid")
|
|
if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now:
|
|
raise SamlError("assertion has expired")
|
|
auds = [(e.text or "").strip() for e in cond.findall("./saml:AudienceRestriction/saml:Audience", NS)]
|
|
if auds and sp_entity_id not in auds:
|
|
raise SamlError("audience restriction does not match this SP")
|
|
|
|
iss = assertion.find("./saml:Issuer", NS)
|
|
if idp_entity_id and iss is not None and (iss.text or "").strip() != idp_entity_id:
|
|
raise SamlError("assertion issuer does not match the configured IdP")
|
|
|
|
scd = assertion.find("./saml:Subject/saml:SubjectConfirmation/saml:SubjectConfirmationData", NS)
|
|
if scd is not None:
|
|
na = scd.get("NotOnOrAfter")
|
|
if na and _saml_parsedt(na) + datetime.timedelta(seconds=skew) < now:
|
|
raise SamlError("subject confirmation has expired")
|
|
rcpt = scd.get("Recipient")
|
|
if rcpt and acs_url and rcpt.rstrip("/") != acs_url.rstrip("/"):
|
|
raise SamlError("subject confirmation recipient mismatch")
|
|
|
|
nameid = assertion.find("./saml:Subject/saml:NameID", NS)
|
|
email = ((nameid.text or "").strip().lower()) if nameid is not None else ""
|
|
if "@" not in email:
|
|
email_attrs = ("email", "mail", "urn:oid:0.9.2342.19200300.100.1.3",
|
|
"http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress")
|
|
for at in assertion.findall(".//saml:Attribute", NS):
|
|
if at.get("Name", "").lower() in email_attrs:
|
|
v = at.find("./saml:AttributeValue", NS)
|
|
if v is not None and "@" in (v.text or ""):
|
|
email = v.text.strip().lower()
|
|
break
|
|
if "@" not in email:
|
|
raise SamlError("assertion carries no email NameID/attribute")
|
|
return email
|
|
|
|
|
|
def _saml_provision(email):
|
|
now = int(time.time())
|
|
with _acct_db() as conn:
|
|
row = conn.execute("SELECT COALESCE(disabled,0) FROM users WHERE lower(email)=lower(?)",
|
|
(email,)).fetchone()
|
|
if row is None:
|
|
salt = _secrets.token_bytes(16)
|
|
pw = _secrets.token_urlsafe(24)
|
|
role = getattr(ARGS, "saml_default_role", "member") or "member"
|
|
conn.execute("INSERT INTO users(email,pw_salt,pw_hash,verified,created,role) "
|
|
"VALUES(?,?,?,1,?,?)", (email, salt.hex(), _pbkdf(pw, salt), now, role))
|
|
conn.commit()
|
|
log_event("auth", f"SAML provisioned account {email}")
|
|
return True
|
|
return not bool(row[0]) # True unless disabled
|
|
|
|
|
|
async def saml_metadata(req):
|
|
if not _saml_enabled():
|
|
return web.Response(status=404, text="SAML is not configured on this server")
|
|
acs, sp = _saml_acs_url(req), _saml_sp_entity_id(req)
|
|
xml = ('<?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()
|