from fastapi import FastAPI, Request, Form, HTTPException from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse import os, hmac, hashlib, time, secrets PASSWORD = os.environ.get("HUB_PASSWORD", "changeme") SECRET = os.environ.get("HUB_SECRET", secrets.token_hex(32)) COOKIE_NAME = "darkelite_session" COOKIE_DAYS = 30 COOKIE_TTL = COOKIE_DAYS * 86400 app = FastAPI(redirect_slashes=False) def sign(payload: str) -> str: return hmac.new(SECRET.encode(), payload.encode(), hashlib.sha256).hexdigest() def make_token() -> str: exp = int(time.time()) + COOKIE_TTL payload = f"{exp}" sig = sign(payload) return f"{payload}.{sig}" def verify_token(token: str) -> bool: if not token or "." not in token: return False try: payload, sig = token.rsplit(".", 1) if not hmac.compare_digest(sig, sign(payload)): return False exp = int(payload) return exp > int(time.time()) except Exception: return False @app.get("/api/check") def check(request: Request): """Caddy forward_auth hits this. 200=allowed, 401/redirect=needs login.""" # LAN bypass # Use the FIRST X-Forwarded-For value (real client). Caddy adds this header. # Caddy passes real client IP in X-Real-IP via forward_auth header_up ip = request.headers.get("x-real-ip", "").strip() if not ip: xff = request.headers.get("x-forwarded-for", "") ip = xff.split(",")[0].strip() # Only treat actual LAN subnets as bypass - NOT docker internal 172.x if ip.startswith("10.0.0.") or ip.startswith("192.168.") or ip == "127.0.0.1": return JSONResponse({"ok": True, "via": "lan"}) token = request.cookies.get(COOKIE_NAME) if verify_token(token): return JSONResponse({"ok": True, "via": "cookie"}) # If this looks like a browser (accepts HTML), redirect to login accept = request.headers.get("accept", "") orig_uri = request.headers.get("x-forwarded-uri", "/") if "text/html" in accept: from fastapi.responses import RedirectResponse return RedirectResponse(url=f"/login/?next={orig_uri}", status_code=302) raise HTTPException(401, "auth required") @app.post("/api/login") def login(password: str = Form(...)): if not hmac.compare_digest(password, PASSWORD): raise HTTPException(401, "bad password") token = make_token() resp = JSONResponse({"ok": True}) resp.set_cookie( COOKIE_NAME, token, max_age=COOKIE_TTL, httponly=True, samesite="lax", path="/" ) return resp @app.post("/api/logout") def logout(): resp = JSONResponse({"ok": True}) resp.delete_cookie(COOKIE_NAME, path="/") return resp @app.get("/", response_class=HTMLResponse) def root(): with open("/static/login.html") as f: return f.read()