#!/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")