14ae67f0b8
gates / gates (push) Successful in 25s
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
142 lines
6.6 KiB
Python
142 lines
6.6 KiB
Python
# SPIKE ONLY (2026-09-29, decision 46) — a throwaway prototype of the setup gate, run on scratch guest 9202.
|
|
# It is NOT product code. In a build, these handlers would live in the controller itself.
|
|
#
|
|
# GET /auth traefik forwardAuth target for a GATED app host.
|
|
# GET /__gate/start on the DASHBOARD host (felhom.<domain>): checks the household's dashboard session
|
|
# (host-only cookie, never widened) by asking the controller, then hands the app host a
|
|
# 60-second one-use token.
|
|
# <app>/__felhom_gate/cb?t=… answered by /auth: swaps the token for a host-only gate cookie on the app host.
|
|
#
|
|
# The dashboard cookie never reaches an app host. The gate cookie is host-only, HMAC-bound to ONE app host.
|
|
import hashlib, hmac, html, http.client, json, os, secrets, sys, time, urllib.parse
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
DOMAIN = os.environ["GATE_DOMAIN"]
|
|
GATED = set(h.strip() for h in os.environ["GATE_HOSTS"].split(",") if h.strip()) # full host names
|
|
KEY = secrets.token_bytes(32)
|
|
USED = {}
|
|
COOKIE = "felhom_gate"
|
|
DASH = "felhom." + DOMAIN
|
|
COUNT = {"auth": 0}
|
|
|
|
|
|
def log(*a):
|
|
print(time.strftime("%H:%M:%S"), *a, flush=True)
|
|
|
|
|
|
def sign(*parts):
|
|
return hmac.new(KEY, "|".join(parts).encode(), hashlib.sha256).hexdigest()
|
|
|
|
|
|
def dashboard_session_ok(cookie_header):
|
|
"""Ask the controller whether this dashboard session is valid (401 without one, 200 with one)."""
|
|
sess = ""
|
|
for c in (cookie_header or "").split(";"):
|
|
k, _, v = c.strip().partition("=")
|
|
if k == "felhom_session":
|
|
sess = v
|
|
if not sess:
|
|
return False
|
|
conn = http.client.HTTPConnection("felhom-controller", 8080, timeout=5)
|
|
conn.request("GET", "/api/stacks", headers={"Host": DASH, "Cookie": "felhom_session=" + sess})
|
|
ok = conn.getresponse().status == 200
|
|
conn.close()
|
|
return ok
|
|
|
|
|
|
PAGE = """<!doctype html><html lang="hu"><meta charset="utf-8"><title>Felhom</title>
|
|
<body style="font-family:sans-serif;max-width:32rem;margin:4rem auto">
|
|
<p>Ez az alkalmazás még beállításra vár. Jelentkezz be a Felhom vezérlőpultba.</p>
|
|
<p lang="en">This app is waiting for its first setup. Sign in to the Felhom dashboard.</p>
|
|
<p><a href="{login}">Bejelentkezés / Sign in</a></p></body></html>"""
|
|
|
|
|
|
class H(BaseHTTPRequestHandler):
|
|
def log_message(self, *a):
|
|
pass
|
|
|
|
def send(self, code, body=b"", headers=()):
|
|
self.send_response(code)
|
|
for k, v in headers:
|
|
self.send_header(k, v)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def do_GET(self):
|
|
u = urllib.parse.urlsplit(self.path)
|
|
if u.path == "/auth":
|
|
return self.auth()
|
|
if u.path == "/__gate/start":
|
|
return self.start(urllib.parse.parse_qs(u.query))
|
|
self.send(404)
|
|
|
|
# forwardAuth may call with the original method; the body is never needed.
|
|
def _any(self):
|
|
n = int(self.headers.get("Content-Length") or 0)
|
|
if n:
|
|
self.rfile.read(n)
|
|
self.do_GET()
|
|
do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _any
|
|
|
|
# forwardAuth: traefik forwards every method as a GET here, with X-Forwarded-* headers.
|
|
def auth(self):
|
|
COUNT["auth"] += 1
|
|
host = self.headers.get("X-Forwarded-Host", "")
|
|
uri = self.headers.get("X-Forwarded-Uri", "/")
|
|
method = self.headers.get("X-Forwarded-Method", "GET")
|
|
if host not in GATED:
|
|
log("auth", host, "not gated -> 403 (fail closed)")
|
|
return self.send(403)
|
|
u = urllib.parse.urlsplit(uri)
|
|
if u.path == "/__felhom_gate/cb":
|
|
t = urllib.parse.parse_qs(u.query).get("t", [""])[0]
|
|
try:
|
|
h, exp, nonce, rd, mac = json.loads(urllib.parse.unquote(t))
|
|
except Exception:
|
|
return self.send(400, b"bad token")
|
|
if (h != host or int(exp) < time.time() or nonce in USED
|
|
or not hmac.compare_digest(mac, sign("tok", h, str(exp), nonce, rd))):
|
|
log("cb", host, "token refused")
|
|
return self.send(403, b"token refused")
|
|
USED[nonce] = 1
|
|
cexp = str(int(time.time()) + 7 * 86400)
|
|
val = cexp + "." + sign("cookie", host, cexp)
|
|
log("cb", host, "token ok -> gate cookie, back to", rd)
|
|
return self.send(302, headers=[("Location", rd), ("Set-Cookie",
|
|
f"{COOKIE}={val}; Path=/; Max-Age=604800; HttpOnly; Secure; SameSite=Lax")])
|
|
for c in (self.headers.get("Cookie") or "").split(";"):
|
|
k, _, v = c.strip().partition("=")
|
|
if k == COOKIE and "." in v:
|
|
cexp, mac = v.split(".", 1)
|
|
if cexp.isdigit() and int(cexp) > time.time() and hmac.compare_digest(mac, sign("cookie", host, cexp)):
|
|
return self.send(200)
|
|
wants_html = "text/html" in (self.headers.get("Accept") or "") and method == "GET"
|
|
log("auth", host, method, uri[:60], "no gate cookie ->", "302 to dashboard" if wants_html else "401")
|
|
if wants_html:
|
|
rd = f"https://{host}{uri}"
|
|
return self.send(302, headers=[("Location", f"https://{DASH}/__gate/start?" +
|
|
urllib.parse.urlencode({"rd": rd}))])
|
|
return self.send(401, b'{"error":"this app is waiting for its first setup"}',
|
|
[("Content-Type", "application/json")])
|
|
|
|
def start(self, q):
|
|
rd = (q.get("rd") or [""])[0]
|
|
host = urllib.parse.urlsplit(rd).hostname or ""
|
|
if host not in GATED or not rd.startswith("https://" + host + "/"):
|
|
return self.send(400, b"not a gated app")
|
|
if not dashboard_session_ok(self.headers.get("Cookie")):
|
|
login = "/login?" + urllib.parse.urlencode({"next": "/__gate/start?" + urllib.parse.urlencode({"rd": rd})})
|
|
log("start", host, "no dashboard session -> gate page")
|
|
return self.send(200, PAGE.format(login=html.escape(login)).encode(),
|
|
[("Content-Type", "text/html; charset=utf-8")])
|
|
exp, nonce = str(int(time.time()) + 60), secrets.token_hex(8)
|
|
t = urllib.parse.quote(json.dumps([host, exp, nonce, rd, sign("tok", host, exp, nonce, rd)]))
|
|
log("start", host, "dashboard session ok -> token")
|
|
return self.send(302, headers=[("Location", f"https://{host}/__felhom_gate/cb?t={t}")])
|
|
|
|
|
|
if __name__ == "__main__":
|
|
log("gate spike up; domain", DOMAIN, "gated", sorted(GATED))
|
|
ThreadingHTTPServer(("0.0.0.0", 8000), H).serve_forever()
|