NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/env python3
"""One-shot patch: add user accounts + email auth to netdesk_server.py."""
import re
p = "server/netdesk_server.py"
s = open(p, encoding="utf-8").read()
ANCHOR = "TUNNELS: dict[int, web.WebSocketResponse] = {} # tid -> viewer-side socket\nTUNNEL_TID = [1]"
assert ANCHOR in s, "tunnel anchor missing"
BLOCK = '''
# ---------------------------------------------------------------- user accounts (email sign-in for viewers)
import sqlite3
import smtplib
from email.message import EmailMessage as _MailMsg
ACCOUNTS_DB = os.path.join(_server_root, "accounts.db")
USER_SESSIONS = {} # token -> email
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)")
return conn
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):
if not ARGS.mail_from:
return None
tok = req.cookies.get("nd_user", "")
return USER_SESSIONS.get(tok)
async def auth_signup(req):
body = await req.json()
email = str(body.get("email", "")).strip().lower()
pw = str(body.get("password", ""))
if "@" not in email 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:
row = conn.execute("SELECT verified FROM users WHERE email=?", (email,)).fetchone()
return web.json_response(
{"error": "email already registered - sign in or reset password",
"unverified": bool(row and not row[0])}, status=409)
link = ARGS.public_url + "/api/auth/verify?token=" + vtok
send_mail(email, "Verify your NetDesk account",
_mail_html("Confirm your email",
"Welcome to NetDesk. Click below to activate your account "
"and start connecting to your computers.", link, "Verify my email"))
log_event("auth", "signup: " + email)
return web.json_response({"ok": True, "msg": "verification email sent"})
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(ARGS.public_url + "/?verified=1")
async def auth_login(req):
body = await req.json()
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 FROM users WHERE email=?", (email,)).fetchone()
if not row:
return web.json_response({"error": "wrong email or password"}, status=401)
if _pbkdf(pw, bytes.fromhex(row[0])) != row[1]:
return web.json_response({"error": "wrong email or password"}, status=401)
if not row[2]:
return web.json_response({"error": "check your inbox - email not verified yet"}, status=403)
tok = _secrets.token_urlsafe(24)
USER_SESSIONS[tok] = email
resp = web.json_response({"ok": True, "email": email})
resp.set_cookie("nd_user", tok, httponly=True, samesite="Lax", max_age=30 * 86400)
log_event("auth", "viewer signed in: " + email)
return resp
async def auth_me(req):
email = user_from_cookie(req)
if not email:
return web.json_response({"error": "not signed in"}, status=401)
return web.json_response({"email": email})
async def auth_logout(req):
tok = req.cookies.get("nd_user", "")
USER_SESSIONS.pop(tok, None)
resp = web.json_response({"ok": True})
resp.del_cookie("nd_user")
return resp
async def auth_forgot(req):
body = await req.json()
email = str(body.get("email", "")).strip().lower()
with _acct_db() as conn:
row = conn.execute("SELECT id FROM users WHERE email=? AND verified=1", (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 = ARGS.public_url + "/?reset=" + tok
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"))
return web.json_response(
{"ok": True, "msg": "if that account exists, a reset email was sent"})
async def auth_reset(req):
body = await req.json()
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})
'''
# insert block before portal section
s = s.replace(ANCHOR, BLOCK + ANCHOR, 1)
# CLI args
s = s.replace(
' ap.add_argument("--access-path", dest="access_path",\n'
' help="secret URL segment hosting the UI (random hash); root serves 404")',
' ap.add_argument("--access-path", dest="access_path",\n'
' help="secret URL segment hosting the UI (random hash); root serves 404")\n'
' ap.add_argument("--mail-from", dest="mail_from", default="",\n'
' help="sender address enabling email accounts, e.g. noreply@netdesk.space")\n'
' ap.add_argument("--smtp-host", dest="smtp_host", default="127.0.0.1",\n'
' help="SMTP relay for verification mail (default localhost postfix)")\n'
' ap.add_argument("--public-url", dest="public_url", default="",\n'
' help="public base URL used in email links, e.g. https://netdesk.space")')
# middleware-free auth routes (public)
s = s.replace(
' app.router.add_get("/tunnel/{hid}", tunnel_ws)',
' app.router.add_get("/tunnel/{hid}", tunnel_ws)\n'
' # viewer email accounts\n'
' app.router.add_post("/api/auth/signup", auth_signup)\n'
' app.router.add_post("/api/auth/login", auth_login)\n'
' app.router.add_post("/api/auth/forgot", auth_forgot)\n'
' app.router.add_post("/api/auth/reset", auth_reset)\n'
' app.router.add_get("/api/auth/verify", auth_verify)\n'
' app.router.add_get("/api/auth/me", auth_me)\n'
' app.router.add_post("/api/auth/logout", auth_logout)')
# print line in main
s = s.replace(
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")',
' print(f"[netdesk] web portal enabled at {base}/portal (downloads: {FILES_DIR})")\n'
' if ARGS.mail_from:\n'
' print(f"[netdesk] viewer accounts ON - mail from {ARGS.mail_from} via {ARGS.smtp_host}:25")')
open(p, "w", encoding="utf-8").write(s)
print("server accounts patch applied")
+252
View File
@@ -0,0 +1,252 @@
#!/usr/bin/env python3
"""Patch: guest/user/owner plans with server-side session limits,
owner admin access, keyless host registration support, host/user
management endpoints, public host download."""
import re
p = "server/netdesk_server.py"
s = open(p, encoding="utf-8").read()
# ---------- 1. plans + owner ----------
old = "TUNNEL_TID = [1]"
new = """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"]"""
assert old in s, "p1"
s = s.replace(old, new, 1)
# ---------- 2. owner + plan on session.begin; drop hard signin gate ----------
old = """ elif t == "session.begin":
if ARGS.mail_from and not user_from_cookie(req):
log_event("auth", "connect blocked: viewer not signed in")
await send_json(ws, {"t": "session.deny", "sid": None, "reason": "signin"})
continue
hid = str(m.get("id", "")).strip()
h = HOSTS.get(hid)"""
new = """ elif t == "session.begin":
vuser = user_from_cookie(req)
vplan, vlimit = plan_for(vuser)
hid = str(m.get("id", "")).strip()
h = HOSTS.get(hid)"""
assert old in s, "p2"
s = s.replace(old, new, 1)
# after sess created, attach plan info
old = """ sess = Session(sid, h, ws)
h.session = sess
viewer_sid = sid
STATS.sessions_total += 1
log_event("session", f"viewer {peer} connecting to host {hid}")
log.info("session %s: viewer %s -> host %s", sid, peer, hid)
await send_json(h.ws, {"t": "session.begin", "sid": sid})"""
new = """ sess = Session(sid, h, ws)
sess.plan, sess.vlimit, sess.vemail = vplan, vlimit, vuser
h.session = sess
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)
await send_json(h.ws, {"t": "session.begin", "sid": sid})"""
assert old in s, "p3"
s = s.replace(old, new, 1)
# on accept: tell viewer the plan + start the enforcement timer
old = """ if t == "session.accept" and host is not None:
sess.active = True
log_event("session", f"session accepted on host {sess.host.id}")
await send_json(sess.viewer_ws, m)"""
new = """ if t == "session.accept" and host is not None:
sess.active = True
log_event("session", f"session accepted on host {sess.host.id}")
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)"""
assert old in s, "p4"
s = s.replace(old, new, 1)
# ---------- 3. Session fields ----------
old = """class Session:
def __init__(self, sid, host, viewer_ws):
self.sid = sid
self.host = host
self.viewer_ws = viewer_ws
self.active = False"""
new = """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.started = time.time()"""
assert old in s, "p5"
s = s.replace(old, new, 1)
# ---------- 4. owner-aware portal auth + admin endpoints ----------
old = """def portal_authorized(req) -> bool:
if not ARGS.portal_password:
return False
tok = req.cookies.get("ndportal", "")
return tok in PORTAL_SESSIONS"""
new = """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 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"""
assert old in s, "p6"
s = s.replace(old, new, 1)
# admin endpoints
old = """async def portal_download(req):"""
new = """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,))
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_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):"""
assert old in s, "p7"
s = s.replace(old, new, 1)
# overview: users + richer host info
old = """ return web.json_response({
"version": VERSION,
"reg_key": ARGS.reg_key or "",
"uptime": int(now - STATS.started),"""
new = """ 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 ""
return web.json_response({
"version": VERSION,
"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),"""
assert old in s, "p8"
s = s.replace(old, new, 1)
# routes
old = """ app.router.add_get("/portal/api/overview", portal_overview)"""
new = """ app.router.add_get("/portal/api/overview", portal_overview)
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/session/disconnect", portal_session_disconnect)"""
assert old in s, "p9"
s = s.replace(old, new, 1)
# public host download (homepage button)
old = """async def tunnel_ws(req):"""
new = """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):"""
assert old in s, "p10"
s = s.replace(old, new, 1)
old = """ app.router.add_get("/tunnel/{hid}", tunnel_ws)"""
new = """ app.router.add_get("/tunnel/{hid}", tunnel_ws)
app.router.add_get("/download/{name}", public_download)"""
assert old in s; s = s.replace(old, new, 1)
# ---------- 5. CLI owner arg ----------
old = """ ap.add_argument("--mail-from", dest="mail_from", default="","""
new = """ ap.add_argument("--owner-email", dest="owner_email", default="",
help="email with unrestricted access + admin panel")
ap.add_argument("--mail-from", dest="mail_from", default="","""
assert old in s; s = s.replace(old, new, 1)
open(p, "w", encoding="utf-8").write(s)
print("plans/owner/admin patch applied")
+407
View File
@@ -0,0 +1,407 @@
#!/usr/bin/env python3
"""
NetDesk smoke test - validates the full pipeline against a live server+host:
connect -> challenge/auth (with E2E) -> accept -> H.264 stream decode -> PNG
+ ping/pong, clipboard sync, quality change, monitor info, clean session end.
Run: python tools/smoke_test.py [server_ws_url]
Assumes server on 127.0.0.1:8443 and a host already registered
(host's config.json supplies the ID + verifier).
"""
import asyncio
import base64
import hashlib
import json
import os
import sys
import time
from pathlib import Path
import aiohttp
import av
import numpy as np
from cryptography.hazmat.primitives.asymmetric import ec
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "host"))
from netdesk_host import appdata_dir, clip_get, clip_set, totp_now # noqa: E402
SERVER = sys.argv[1] if len(sys.argv) > 1 else "ws://127.0.0.1:8443/ws"
OUT_PNG = Path(__file__).resolve().parent / "smoke_frame.png"
TEST_TOTP_SECRET = "JBSWY3DPEHPK3PXPJBSWY3DPEHPK3PXP"
# point this at a portable exe's folder to test the packaged build
_cfg_path = (Path(os.environ["ND_CONFIG_DIR"]) / "config.json") if os.environ.get("ND_CONFIG_DIR") \
else (appdata_dir() / "config.json")
cfg = json.loads(_cfg_path.read_text("utf-8"))
HOST_ID = cfg["id"]
VERIFIER = cfg["verifier"]
PASS, FAIL = [], []
def check(name, ok, detail=""):
(PASS if ok else FAIL).append(name)
print(f" [{'PASS' if ok else 'FAIL'}] {name} {('- ' + str(detail)) if detail else ''}")
async def recv_until(ws, want_t, timeout=8, pred=None):
"""Receive JSON messages until one matches t (and pred); skip others."""
deadline = time.time() + timeout
while time.time() < deadline:
try:
msg = await ws.receive(timeout=max(0.1, deadline - time.time()))
except asyncio.TimeoutError:
break
if msg.type == aiohttp.WSMsgType.TEXT:
try:
m = json.loads(msg.data)
except Exception:
continue
if m.get("t") == want_t and (pred is None or pred(m)):
return m
return None
async def connect_and_auth(ws, totp_code=None, strict=True):
"""session.begin -> challenge -> auth -> accept/deny. Returns
(sid, info_or_None, e2e_key_or_None, deny_reason)."""
await ws.send_json({"t": "session.begin", "id": HOST_ID, "direct": False})
ch = await recv_until(ws, "session.challenge")
if strict:
check("session.begin -> challenge", ch is not None)
if not ch:
raise SystemExit(1)
check("challenge advertises 2FA", ch.get("totp") is bool(cfg.get("totp")))
if not ch:
return None, None, None, "no challenge"
proof = hashlib.sha256((ch["nonce"] + VERIFIER).encode()).hexdigest()
priv = ec.generate_private_key(ec.SECP256R1())
pub_b = priv.public_key().public_bytes(
serialization.Encoding.X962, serialization.PublicFormat.UncompressedPoint)
await ws.send_json({
"t": "session.auth", "sid": ch["sid"], "proof": proof,
"pub": base64.b64encode(pub_b).decode(),
"totp": totp_code or "",
})
# wait for accept or deny, keeping media messages out of the way
deadline = time.time() + 8
deny_reason = None
while time.time() < deadline:
try:
msg = await ws.receive(timeout=max(0.1, deadline - time.time()))
except asyncio.TimeoutError:
break
if msg.type != aiohttp.WSMsgType.TEXT:
continue
m = json.loads(msg.data)
if m.get("t") == "session.accept":
host_pub = ec.EllipticCurvePublicKey.from_encoded_point(
ec.SECP256R1(), base64.b64decode(ch["pub"]))
shared = priv.exchange(ec.ECDH(), host_pub)
e2e = AESGCM(hashlib.sha256(shared + b"e2e").digest())
return ch["sid"], m.get("info", {}), e2e, None
if m.get("t") == "session.deny":
deny_reason = m.get("reason")
break
return ch["sid"], None, None, deny_reason
async def main():
print(f"smoke test against {SERVER} (host ID {HOST_ID})")
saved_clip = clip_get()
try:
# ---- shared login (viewer accounts required when mail is on) -------
async with aiohttp.ClientSession() as http:
import sqlite3
acct_db = Path(os.environ.get(
"ND_ACCOUNTS_DB",
str(Path(__file__).resolve().parents[1] / "server" / "accounts.db")))
try:
c = sqlite3.connect(acct_db)
c.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)")
salt = os.urandom(16).hex()
import hashlib as _hh
h = _hh.pbkdf2_hmac("sha256", b"testpass123", bytes.fromhex(salt), 200_000).hex()
c.execute("INSERT OR IGNORE INTO users(email,pw_salt,pw_hash,verified,created) "
"VALUES('smoketest@netdesk.space',?,?,1,strftime('%s','now'))", (salt, h))
c.execute("UPDATE users SET verified=1, verify_token=NULL, "
"pw_salt=?, pw_hash=? WHERE email='smoketest@netdesk.space'",
(salt, h))
c.commit(); c.close()
except Exception:
pass
base = SERVER.split("/ws")[0]
base = base.replace("wss://", "https://").replace("ws://", "http://")
try:
async with http.post(base + "/api/auth/login",
json={"email": "smoketest@netdesk.space",
"password": "testpass123"},
allow_redirects=False) as lr:
USER_COOKIE = lr.headers.get("Set-Cookie", "").split(";")[0]
except Exception:
USER_COOKIE = ""
# 2FA wrong-code rejection test
if cfg.get("totp"):
async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024,
headers={"Cookie": USER_COOKIE}) as ws:
sid, info, key, reason = await connect_and_auth(ws, totp_code="000000")
check("2FA wrong code rejected", info is None and reason == "bad 2FA code",
str(reason))
async with aiohttp.ClientSession() as http:
async with http.ws_connect(SERVER, max_msg_size=64 * 1024 * 1024,
headers={"Cookie": USER_COOKIE}) as ws:
code = totp_now(cfg["totp"]) if cfg.get("totp") else None
sid, info, e2e_key, reason = await connect_and_auth(ws, totp_code=code)
check("session.auth -> accept", info is not None, reason)
if info is None:
raise SystemExit(1)
check("session info has dims", info.get("w", 0) > 0 and info.get("h", 0) > 0,
f"{info.get('w')}x{info.get('h')}")
check("monitors reported", len(info.get("monitors", [])) >= 1,
f"{len(info.get('monitors', []))} monitor(s)")
check("E2E negotiated", info.get("e2e") is True)
check("lock_on_end setting present", "lock_on_end" in info)
# normalize state leaked from previous runs (never touches the
# display unless ND_DISPLAY_TESTS=1)
await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False,
"lock_on_end": False})
await recv_until(ws, "sys.res", timeout=6, pred=lambda x: x.get("msg") == "config saved")
# 3. ping/pong
t0 = time.time()
await ws.send_json({"t": "ping", "sid": sid, "c": t0})
m = await recv_until(ws, "pong")
rtt = (time.time() - t0) * 1000
check("ping/pong", m is not None, f"{rtt:.0f} ms")
check("control latency sane", rtt < 1500, f"{rtt:.0f} ms < 1500 ms")
# 4. clipboard host->viewer (host polls and pushes changes)
marker = f"netdesk-smoke-{int(time.time())}"
clip_set(marker)
m = await recv_until(ws, "clip.set", timeout=6,
pred=lambda x: x.get("text") == marker)
check("clipboard host->viewer", m is not None)
# 5. quality change -> expect session.info
await ws.send_json({"t": "set.quality", "sid": sid,
"fps": 15, "bitrate": 2000, "scale": 0.75})
m = await recv_until(ws, "session.info", timeout=6)
check("quality change -> session.info", m is not None,
f"{m['info'].get('w')}x{m['info'].get('h')} @ {m['info'].get('fps')}fps"
if m else "timeout")
# 6. collect + decode video (E2E decrypt each frame).
# Static screens correctly send nothing, so force keyframes.
dec = av.CodecContext.create("h264", "r")
frames = []
video_msgs = 0
video_bytes = 0
for _ in range(30):
await ws.send_json({"t": "keyframe", "sid": sid})
await asyncio.sleep(0.25)
for _ in range(20):
try:
msg = await ws.receive(timeout=0.15)
except asyncio.TimeoutError:
break
if msg.type != aiohttp.WSMsgType.BINARY:
continue
data = bytes(msg.data)
data = e2e_key.decrypt(data[:12], data[12:], None)
if not data or data[0] != 0x01:
continue
video_msgs += 1
video_bytes += len(data)
try:
for f in dec.decode(av.Packet(data[2:])):
frames.append(f)
except Exception:
pass # waiting for keyframe
if len(frames) >= 3:
break
check("E2E video packets received", video_msgs >= 3,
f"{video_msgs} msgs, {video_bytes//1024} KB")
check("H.264 decodes", len(frames) >= 1, f"{len(frames)} frame(s)")
if frames:
img = frames[-1].to_image()
img.save(OUT_PNG)
arr = np.asarray(img.convert("L"))
check("frame not black", float(arr.std()) > 3.0,
f"std={arr.std():.1f} size={img.size}")
print(f" saved {OUT_PNG} ({img.size[0]}x{img.size[1]})")
# 7. mouse move to current cursor position (harmless)
import ctypes
from ctypes import wintypes
pt = wintypes.POINT()
ctypes.windll.user32.GetCursorPos(ctypes.byref(pt))
await ws.send_json({"t": "i.mouse", "sid": sid,
"x": pt.x, "y": pt.y, "b": 0, "w": 0, "hw": 0})
await asyncio.sleep(0.3)
check("input event accepted", True)
# 7b. process manager
await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "processes"})
m = await recv_until(ws, "sys.res", timeout=20,
pred=lambda x: x.get("kind") == "processes")
n_proc = len(m["items"]) if m and m.get("items") else 0
check("process list", m is not None and m.get("ok") and n_proc > 3,
f"{n_proc} processes")
# 7c/d. display-affecting tests are OPT-IN (ND_DISPLAY_TESTS=1)
# so the suite never blanks the screen of a machine in use
if os.environ.get("ND_DISPLAY_TESTS") == "1":
await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False,
"privacy_on_session": True, "allow_display_power": True})
m = await recv_until(ws, "sys.res", timeout=6,
pred=lambda x: x.get("msg") == "config saved")
check("set.cfg saved", m is not None and m.get("ok") is True)
await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"})
m = await recv_until(ws, "sys.res", timeout=8,
pred=lambda x: x.get("kind") == "privacy")
check("privacy mode on", m is not None and m.get("on") is True)
await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"})
m = await recv_until(ws, "sys.res", timeout=8,
pred=lambda x: x.get("kind") == "privacy")
check("privacy mode off", m is not None and m.get("on") is False)
await ws.send_json({"t": "set.cfg", "sid": sid, "privacy_on_session": False,
"allow_display_power": False})
await recv_until(ws, "sys.res", timeout=6,
pred=lambda x: x.get("msg") == "config saved")
else:
# gate check: with display power disabled the host must refuse
await ws.send_json({"t": "sys.cmd", "sid": sid, "a": "privacy"})
m = await recv_until(ws, "sys.res", timeout=8,
pred=lambda x: x.get("kind") == "privacy")
check("display control gated off by default",
m is not None and m.get("ok") is False,
str(m and m.get("msg")))
await ws.send_json({"t": "set.cfg", "sid": sid, "lock_on_end": False,
"privacy_on_session": False})
m = await recv_until(ws, "sys.res", timeout=6,
pred=lambda x: x.get("msg") == "config saved")
check("set.cfg saved", m is not None and m.get("ok") is True)
# 7e. system audio: host should advertise it and stream Opus
check("audio advertised", info.get("audio") is True, str(info.get("audio")))
audio_pkts = 0
dec_a = av.CodecContext.create("opus", "r")
aframes = 0
deadline = time.time() + 8
while time.time() < deadline and audio_pkts < 5:
try:
msg = await ws.receive(timeout=1)
except asyncio.TimeoutError:
# nudge video traffic so the ws stays warm; audio flows regardless
await ws.send_json({"t": "keyframe", "sid": sid})
continue
if msg.type != aiohttp.WSMsgType.BINARY:
continue
data = bytes(msg.data)
data = e2e_key.decrypt(data[:12], data[12:], None)
if data and data[0] == 0xA1:
audio_pkts += 1
try:
aframes += len(dec_a.decode(av.Packet(data[1:])))
except Exception:
pass
check("system audio packets received", audio_pkts >= 5, f"{audio_pkts} pkts")
check("audio decodes (Opus)", aframes >= 1, f"{aframes} frames")
# 7f. TCP tunnel round trip: echo server on this PC, through
# server -> (broker) -> host -> TCP -> back
async def _handle(reader, writer):
try:
while True:
d = await reader.read(4096)
if not d:
break
writer.write(d)
await writer.drain()
finally:
writer.close()
echo_srv = await asyncio.start_server(_handle, "127.0.0.1", 0)
echo_port = echo_srv.sockets[0].getsockname()[1]
portal_pw = os.environ.get("ND_PORTAL_PW") or os.environ.get("PORTAL_PASSWORD", "portaltest123")
async with http.post(f"{SERVER.replace('/ws', '')}/portal/login".replace("ws://", "http://"),
data={"password": portal_pw},
allow_redirects=False) as resp:
if resp.status != 303:
raise SystemExit(f"portal login failed: {resp.status}")
cookie = resp.headers.get("Set-Cookie", "").split(";")[0]
tunnel_ws = await http.ws_connect(
f"{SERVER.replace('/ws', '')}/tunnel/{HOST_ID}".replace("ws://", "http://"),
headers={"Cookie": cookie})
await tunnel_ws.send_json({"t": "open", "port": echo_port})
# wait for tunnel.ok or err
tok = None
deadline = time.time() + 10
while time.time() < deadline and tok is None:
try:
msg = await tunnel_ws.receive(timeout=1)
except asyncio.TimeoutError:
continue
if msg.type == aiohttp.WSMsgType.TEXT:
m = json.loads(msg.data)
if m.get("t") in ("tunnel.ok", "tunnel.err"):
tok = m
check("tunnel opened", tok is not None and tok.get("t") == "tunnel.ok", str(tok))
if tok and tok.get("t") == "tunnel.ok":
payload = b"netdesk-tunnel-ping-12345"
await tunnel_ws.send_bytes(payload)
got = b""
deadline = time.time() + 8
while time.time() < deadline and len(got) < len(payload):
try:
msg = await tunnel_ws.receive(timeout=1)
except asyncio.TimeoutError:
break
if msg.type == aiohttp.WSMsgType.BINARY:
got += msg.data
check("TCP tunnel round trip", got == payload, f"{len(got)}/{len(payload)} bytes")
await tunnel_ws.close()
echo_srv.close()
# 8. clean end, then reconnect (auto-privacy reconnect test
# only when display tests are explicitly enabled)
await ws.send_json({"t": "session.end", "sid": sid})
await asyncio.sleep(0.6)
sid2, info2, e2e2, reason2 = None, None, None, None
for attempt, wait in enumerate((0, 3, 6), start=1):
if attempt > 1:
await asyncio.sleep(wait)
sid2, info2, e2e2, reason2 = await connect_and_auth(ws, strict=False)
if info2 is not None:
break
check("reconnect works", info2 is not None, reason2)
if info2 is not None:
check("no display sleep without opt-in",
info2.get("privacy_active") is False,
str(info2.get("privacy_active")))
await ws.send_json({"t": "session.end", "sid": sid2})
await ws.send_json({"t": "session.end", "sid": sid})
check("session.end sent", True)
finally:
if saved_clip is not None:
clip_set(saved_clip)
print()
print(f"RESULT: {len(PASS)} passed, {len(FAIL)} failed")
if FAIL:
print("failed:", ", ".join(FAIL))
sys.exit(1)
if __name__ == "__main__":
asyncio.run(main())