NetDesk 1.5.0 — source release
This commit is contained in:
commit
40318aafdd
227 files changed
+80798
No files matched your search
@@ -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")
|
||||
@@ -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")
|
||||
@@ -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())
|
||||
Reference in new issue
Block a user