253 lines
11 KiB
Python
253 lines
11 KiB
Python
#!/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")
|