From 650d693849d4e90763ec4200ef4e26d1f9076437 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:31:33 +0000 Subject: [PATCH 1/3] Initial plan From 02e40e663483b496946397d5b7cb283ec53991a1 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sun, 12 Apr 2026 15:37:35 +0000 Subject: [PATCH 2/3] Add Hub web authentication: login page, session middleware, logout button Agent-Logs-Url: https://github.com/naturallaw777/staging_alpha/sessions/afb996f6-f6f5-4d4a-9f99-e46e3f89b4d7 Co-authored-by: naturallaw777 <99053422+naturallaw777@users.noreply.github.com> --- app/sovran_systemsos_web/server.py | 186 +++++++++++++++++- .../static/css/header.css | 18 +- app/sovran_systemsos_web/static/js/events.js | 6 + app/sovran_systemsos_web/static/js/state.js | 2 + app/sovran_systemsos_web/templates/index.html | 1 + app/sovran_systemsos_web/templates/login.html | 83 ++++++++ 6 files changed, 291 insertions(+), 5 deletions(-) create mode 100644 app/sovran_systemsos_web/templates/login.html diff --git a/app/sovran_systemsos_web/server.py b/app/sovran_systemsos_web/server.py index 6660154..7723d1f 100644 --- a/app/sovran_systemsos_web/server.py +++ b/app/sovran_systemsos_web/server.py @@ -5,10 +5,12 @@ from __future__ import annotations import asyncio import base64 import hashlib +import hmac import json import os import pwd import re +import secrets import shutil import socket import subprocess @@ -19,7 +21,7 @@ import urllib.parse import urllib.request from fastapi import FastAPI, HTTPException -from fastapi.responses import HTMLResponse, JSONResponse +from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates from fastapi.requests import Request @@ -68,6 +70,27 @@ REBOOT_COMMAND = ["reboot"] ONBOARDING_FLAG = "/var/lib/sovran/onboarding-complete" AUTOLAUNCH_DISABLE_FLAG = "/var/lib/sovran/hub-autolaunch-disabled" +# ── Hub web authentication ──────────────────────────────────────── + +FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" +HUB_SESSION_SECRET_FILE = "/var/lib/secrets/hub-session-secret" +SESSION_COOKIE_NAME = "hub_session" +SESSION_MAX_AGE = 86400 # 24 hours + +# In-memory session store: token → expiry timestamp (float) +_sessions: dict[str, float] = {} + +# Failed login tracking: ip → list of failure timestamps +_login_failures: dict[str, list[float]] = {} +LOGIN_FAIL_DELAY = 2.0 # seconds to sleep after a failed attempt +LOGIN_FAIL_WINDOW = 60.0 # rolling window (seconds) for counting failures +LOGIN_FAIL_MAX = 10 # max failures in window before extra delay + +# Public paths that are accessible without a valid session +_AUTH_EXEMPT_PATHS = {"/login", "/api/login"} +# Prefixes for static assets required by the login page +_AUTH_EXEMPT_PREFIXES = ("/static/css/", "/static/sovran-hub-icon.svg") + # ── Security constants ──────────────────────────────────────────── SECURITY_BANNER_DISMISSED_FLAG = "/var/lib/sovran/security-banner-dismissed" @@ -468,10 +491,118 @@ class NoCacheMiddleware(BaseHTTPMiddleware): response.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0" response.headers["Pragma"] = "no-cache" response.headers["Expires"] = "0" - response.headers["Clear-Site-Data"] = '"cache", "cookies", "storage"' + # Note: "cookies" is intentionally omitted so session cookies are not cleared + response.headers["Clear-Site-Data"] = '"cache", "storage"' return response +# ── Session / authentication helpers ───────────────────────────── + +def _get_or_create_session_secret() -> bytes: + """Return the Hub session secret, generating it on first boot.""" + try: + with open(HUB_SESSION_SECRET_FILE, "rb") as f: + data = f.read().strip() + if len(data) >= 32: + return data + except FileNotFoundError: + pass + secret = secrets.token_hex(32).encode() + try: + os.makedirs(os.path.dirname(HUB_SESSION_SECRET_FILE), exist_ok=True) + with open(HUB_SESSION_SECRET_FILE, "wb") as f: + f.write(secret) + os.chmod(HUB_SESSION_SECRET_FILE, 0o600) + except OSError: + pass + return secret + + +def _create_session() -> str: + """Create a new opaque session token and register it in the store.""" + _purge_expired_sessions() + token = secrets.token_hex(32) + _sessions[token] = time.time() + SESSION_MAX_AGE + return token + + +def _destroy_session(token: str) -> None: + """Remove a session token from the store.""" + _sessions.pop(token, None) + + +def _purge_expired_sessions() -> None: + """Remove all expired sessions from the in-memory store.""" + now = time.time() + expired = [tok for tok, exp in _sessions.items() if exp <= now] + for tok in expired: + del _sessions[tok] + + +def _is_authenticated(request: Request) -> bool: + """Return True if the request carries a valid, unexpired session cookie.""" + token = request.cookies.get(SESSION_COOKIE_NAME) + if not token: + return False + expiry = _sessions.get(token) + if expiry is None or time.time() >= expiry: + _sessions.pop(token, None) + return False + # Slide the expiry window on activity + _sessions[token] = time.time() + SESSION_MAX_AGE + return True + + +def _read_free_password() -> str | None: + """Return the contents of the free-password secrets file, or None.""" + try: + with open(FREE_PASSWORD_FILE, "r") as f: + return f.read().strip() + except Exception: + return None + + +def _check_password(submitted: str) -> bool: + """Constant-time comparison of submitted password against the stored one.""" + stored = _read_free_password() + if stored is None: + return False + return hmac.compare_digest(submitted.encode(), stored.encode()) + + +def _record_failure(client_ip: str) -> None: + """Record a failed login attempt and apply a rate-limit delay.""" + now = time.time() + failures = _login_failures.setdefault(client_ip, []) + # Prune old entries outside the window + _login_failures[client_ip] = [t for t in failures if now - t < LOGIN_FAIL_WINDOW] + _login_failures[client_ip].append(now) + # Always sleep a fixed delay to slow brute force + time.sleep(LOGIN_FAIL_DELAY) + + +# ── Authentication middleware ───────────────────────────────────── + +class AuthMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next): + path = request.url.path + + # Allow public paths and static assets needed by the login page + if path in _AUTH_EXEMPT_PATHS: + return await call_next(request) + if any(path.startswith(prefix) for prefix in _AUTH_EXEMPT_PREFIXES): + return await call_next(request) + + if not _is_authenticated(request): + accept = request.headers.get("accept", "") + if "text/html" in accept: + return RedirectResponse(url="/login", status_code=303) + return JSONResponse({"detail": "Unauthenticated"}, status_code=401) + + return await call_next(request) + + +app.add_middleware(AuthMiddleware) app.add_middleware(NoCacheMiddleware) _ICONS_DIR = os.environ.get( @@ -1448,6 +1579,48 @@ def _verify_support_removed() -> bool: # ── Routes ─────────────────────────────────────────────────────── +@app.get("/login", response_class=HTMLResponse) +async def login_page(request: Request): + return templates.TemplateResponse("login.html", {"request": request}) + + +class LoginRequest(BaseModel): + password: str + + +@app.post("/api/login") +async def api_login(req: LoginRequest, request: Request): + """Validate the Hub password and issue a session cookie.""" + client_ip = request.client.host if request.client else "unknown" + loop = asyncio.get_event_loop() + ok = await loop.run_in_executor(None, _check_password, req.password) + if not ok: + await loop.run_in_executor(None, _record_failure, client_ip) + raise HTTPException(status_code=401, detail="Incorrect password") + token = _create_session() + response = JSONResponse({"ok": True}) + response.set_cookie( + key=SESSION_COOKIE_NAME, + value=token, + max_age=SESSION_MAX_AGE, + httponly=True, + samesite="lax", + secure=False, # LAN-only appliance; no TLS on the Hub port + ) + return response + + +@app.post("/api/logout") +async def api_logout(request: Request): + """Clear the session cookie and destroy the server-side session.""" + token = request.cookies.get(SESSION_COOKIE_NAME) + if token: + _destroy_session(token) + response = JSONResponse({"ok": True}) + response.delete_cookie(key=SESSION_COOKIE_NAME) + return response + + @app.get("/", response_class=HTMLResponse) async def index(request: Request): return templates.TemplateResponse("index.html", { @@ -3301,8 +3474,6 @@ async def api_security_verify_integrity(): # ── System password change ──────────────────────────────────────── -FREE_PASSWORD_FILE = "/var/lib/secrets/free-password" - class ChangePasswordRequest(BaseModel): new_password: str @@ -3740,6 +3911,13 @@ async def _startup_save_ip(): _save_internal_ip(ip) +@app.on_event("startup") +async def _startup_session_secret(): + """Ensure the session secret exists on disk at startup.""" + loop = asyncio.get_event_loop() + await loop.run_in_executor(None, _get_or_create_session_secret) + + # ── Startup: recover stale RUNNING status files ────────────────── _SAFE_UNIT_RE = re.compile(r'^[a-zA-Z0-9@._\-]+\.service$') diff --git a/app/sovran_systemsos_web/static/css/header.css b/app/sovran_systemsos_web/static/css/header.css index 8f3af1b..d4c2f90 100644 --- a/app/sovran_systemsos_web/static/css/header.css +++ b/app/sovran_systemsos_web/static/css/header.css @@ -74,4 +74,20 @@ .ip-separator { color: var(--border-color); -} \ No newline at end of file +} +.btn-logout { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.18); + color: var(--text-secondary); + font-size: 0.78rem; + font-weight: 600; + padding: 4px 12px; + border-radius: var(--radius-btn); + cursor: pointer; + transition: border-color 0.15s, color 0.15s; +} + +.btn-logout:hover { + border-color: var(--accent-color); + color: var(--accent-color); +} diff --git a/app/sovran_systemsos_web/static/js/events.js b/app/sovran_systemsos_web/static/js/events.js index ad1236e..56a164f 100644 --- a/app/sovran_systemsos_web/static/js/events.js +++ b/app/sovran_systemsos_web/static/js/events.js @@ -9,6 +9,12 @@ if ($btnSave) $btnSave.addEventListener("click", saveErrorReport); if ($credsCloseBtn) $credsCloseBtn.addEventListener("click", closeCredsModal); if ($supportCloseBtn) $supportCloseBtn.addEventListener("click", closeSupportModal); +// Logout button +if ($logoutBtn) $logoutBtn.addEventListener("click", function () { + fetch("/api/logout", { method: "POST", credentials: "same-origin" }) + .finally(function () { window.location.replace("/login"); }); +}); + // Rebuild modal if ($rebuildClose) $rebuildClose.addEventListener("click", closeRebuildModal); if ($rebuildReboot) $rebuildReboot.addEventListener("click", doReboot); diff --git a/app/sovran_systemsos_web/static/js/state.js b/app/sovran_systemsos_web/static/js/state.js index 896c372..30b4bff 100644 --- a/app/sovran_systemsos_web/static/js/state.js +++ b/app/sovran_systemsos_web/static/js/state.js @@ -59,6 +59,8 @@ const $supportModal = document.getElementById("support-modal"); const $supportBody = document.getElementById("support-body"); const $supportCloseBtn = document.getElementById("support-close-btn"); +const $logoutBtn = document.getElementById("btn-logout"); + // Feature Manager — rebuild modal const $rebuildModal = document.getElementById("rebuild-modal"); const $rebuildSpinner = document.getElementById("rebuild-spinner"); diff --git a/app/sovran_systemsos_web/templates/index.html b/app/sovran_systemsos_web/templates/index.html index c101dfa..7162290 100644 --- a/app/sovran_systemsos_web/templates/index.html +++ b/app/sovran_systemsos_web/templates/index.html @@ -24,6 +24,7 @@ Sovran_SystemsOS Hub
diff --git a/app/sovran_systemsos_web/templates/login.html b/app/sovran_systemsos_web/templates/login.html new file mode 100644 index 0000000..cb05415 --- /dev/null +++ b/app/sovran_systemsos_web/templates/login.html @@ -0,0 +1,83 @@ + + + + + +