#!/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 = ( '

' '' + link_text + "

" '

Or paste this link: ' + link + "

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

' + title + "

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

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

") def send_mail(to, subject, html): if not ARGS.mail_from: raise RuntimeError("email not configured on this server") msg = _MailMsg() msg["From"] = "NetDesk <" + ARGS.mail_from + ">" msg["To"] = to msg["Subject"] = subject msg.set_content("Your mail client does not support HTML.") msg.add_alternative(html, subtype="html") host = ARGS.smtp_host or "127.0.0.1" with smtplib.SMTP(host, 25, timeout=20) as smtp: smtp.send_message(msg) def user_from_cookie(req): 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")