Initial commit
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,79 @@
|
||||
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()
|
||||
@@ -0,0 +1,68 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Login - The Dark Elite</title>
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<style>
|
||||
:root { --bg:#060708; --border:rgba(80,90,100,0.2); --accent:#22d3ee; --text:#e5e7eb; --muted:#9ca3af; --danger:#dc2626; }
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;background:var(--bg);color:var(--text);font-family:system-ui,sans-serif;height:100vh;overflow:hidden}
|
||||
body{display:flex;align-items:center;justify-content:center;background:radial-gradient(ellipse at center,rgba(34,211,238,0.04) 0%,transparent 70%),#060708}
|
||||
.box{width:380px;max-width:90vw;background:linear-gradient(135deg,rgba(18,20,24,0.95),rgba(10,11,13,0.95));border:1px solid var(--border);border-radius:8px;padding:2rem 2.25rem;box-shadow:0 10px 50px rgba(0,0,0,0.7),0 0 30px rgba(34,211,238,0.08)}
|
||||
.title{text-align:center;color:var(--accent);letter-spacing:0.3em;text-transform:uppercase;font-size:1.1rem;text-shadow:0 0 12px rgba(34,211,238,0.4);margin-bottom:0.25rem;font-weight:600}
|
||||
.sub{text-align:center;color:var(--muted);font-family:ui-monospace,monospace;font-size:0.7rem;letter-spacing:0.2em;text-transform:uppercase;margin-bottom:2rem}
|
||||
label{display:block;font-size:0.7rem;color:var(--muted);text-transform:uppercase;letter-spacing:0.2em;margin-bottom:0.4rem}
|
||||
input[type=password]{width:100%;background:rgba(10,11,13,0.7);border:1px solid var(--border);color:var(--text);padding:0.75rem 0.9rem;border-radius:4px;font-size:0.95rem;font-family:ui-monospace,monospace;letter-spacing:0.1em}
|
||||
input[type=password]:focus{outline:none;border-color:var(--accent);box-shadow:0 0 15px rgba(34,211,238,0.15)}
|
||||
button{width:100%;margin-top:1.25rem;background:rgba(34,211,238,0.15);border:1px solid var(--accent);color:var(--accent);padding:0.75rem;border-radius:4px;font-size:0.85rem;cursor:pointer;letter-spacing:0.2em;text-transform:uppercase;font-family:ui-monospace,monospace}
|
||||
button:hover{background:rgba(34,211,238,0.25)}
|
||||
button:disabled{opacity:0.5;cursor:wait}
|
||||
.err{display:none;color:var(--danger);font-size:0.75rem;font-family:ui-monospace,monospace;text-align:center;margin-top:0.85rem;letter-spacing:0.1em;text-transform:uppercase}
|
||||
.err.show{display:block}
|
||||
.footer{text-align:center;color:var(--muted);font-family:ui-monospace,monospace;font-size:0.6rem;letter-spacing:0.2em;text-transform:uppercase;margin-top:1.5rem;opacity:0.6}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="box">
|
||||
<div class="title">The Dark Elite</div>
|
||||
<div class="sub">// Authorization Required //</div>
|
||||
<form id="loginForm">
|
||||
<label>Passphrase</label>
|
||||
<input type="password" id="pw" autofocus autocomplete="current-password">
|
||||
<button type="submit" id="submitBtn">Authenticate</button>
|
||||
<div class="err" id="err">Invalid passphrase</div>
|
||||
</form>
|
||||
<div class="footer">Node: thedarkelite</div>
|
||||
</div>
|
||||
<script>
|
||||
const params = new URLSearchParams(location.search);
|
||||
const next = params.get("next") || "/";
|
||||
|
||||
document.getElementById("loginForm").addEventListener("submit", async e => {
|
||||
e.preventDefault();
|
||||
const pw = document.getElementById("pw").value;
|
||||
const btn = document.getElementById("submitBtn");
|
||||
const err = document.getElementById("err");
|
||||
err.classList.remove("show");
|
||||
btn.disabled = true; btn.textContent = "Verifying...";
|
||||
const fd = new FormData(); fd.append("password", pw);
|
||||
try {
|
||||
const r = await fetch("/auth/api/login", { method: "POST", body: fd });
|
||||
if (r.ok) {
|
||||
btn.textContent = "Authorized";
|
||||
setTimeout(() => location.href = next, 300);
|
||||
} else {
|
||||
err.classList.add("show");
|
||||
btn.disabled = false; btn.textContent = "Authenticate";
|
||||
document.getElementById("pw").select();
|
||||
}
|
||||
} catch(e) {
|
||||
err.textContent = "Connection error";
|
||||
err.classList.add("show");
|
||||
btn.disabled = false; btn.textContent = "Authenticate";
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user