File size: 24,376 Bytes
f8706b0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
"""Standalone reverse proxy / auth gate in front of the self-reflective API.



Two modes, chosen by LIVE_UPSTREAM_PORTS:



Single-upstream (legacy) mode -- LIVE_UPSTREAM_PORTS unset:



    client -> 127.0.0.1:8180 (this)  -> 127.0.0.1:8100 (uvicorn live_app:app)



    One shared drift state. ``POST /live/rotate`` is presenter-only: answered

    on the loopback gate, refused with 403 for anything that arrives through

    a tunnel, and it advances the shared table by running drift_cron.py.



Session mode -- LIVE_UPSTREAM_PORTS is a comma list of N ports:



    client -> :8180 (this) -> one of N upstreams, each pinned at one snapshot



    Each upstream instance is a full copy of the API permanently seeded at

    snapshot version k and never reloaded (see run_live.py). A visitor's

    entire drift state is therefore one small integer -- the ring position --

    kept here against a session cookie. ``POST /live/rotate`` becomes public

    and per-session: it moves that visitor's pointer to the next version

    (``?to=N`` jumps anywhere on the ring) and no other visitor notices.

    ``GET /live/status`` is answered from the visitor's instance and then

    rewritten: rotations become the visitor's own history, and a ``session``

    block reports their position on the ring.



Responsibilities in both modes (none of which touch the upstream app):

  * everything under /admin requires ``Authorization: Bearer $LIVE_ADMIN_TOKEN``,

    otherwise 401;

  * a sliding rate limit per client on ALL paths, otherwise 429;

  * the Authorization header is stripped before forwarding, so the admin token

    never reaches the upstream process.



Standard library only -- no extra dependency beyond what the server needs.



Config (all via environment, no secrets in this file):

    LIVE_ADMIN_TOKEN        required; the proxy refuses to start without it

    LIVE_PROXY_PORT         default 8180

    LIVE_PROXY_BIND         default 127.0.0.1

    LIVE_UPSTREAM_HOST      default 127.0.0.1

    LIVE_UPSTREAM_PORT      default 8100 (single-upstream mode)

    LIVE_UPSTREAM_PORTS     comma list; two or more ports switch session mode on

    LIVE_RATE_LIMIT_PER_MIN default 60

    LIVE_SESSION_TTL        default 14400 s idle before a session is dropped

    LIVE_SESSION_MAX        default 2000 concurrent sessions kept

    LIVE_SNAPSHOT_DIR       default ./snapshots (session-mode diffs)

    LIVE_DRIFT_TABLE        default incompatible_ingredients

    LIVE_DRIFT_FILE         default incompatible_combinations



Run:

    python proxy_gate.py

"""
from __future__ import annotations

import hmac
import http.client
import json
import os
import secrets
import subprocess
import sys
import threading
import time
from collections import defaultdict, deque
from datetime import datetime, timezone
from http.cookies import SimpleCookie
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
from urllib.parse import parse_qs, urlparse

# The startup banner may print filesystem paths, which contain non-ASCII on
# some hosts; a cp1252 console would raise UnicodeEncodeError and abort.
for _stream in (sys.stdout, sys.stderr):
    try:
        _stream.reconfigure(encoding="utf-8", errors="replace")
    except (AttributeError, ValueError):  # pragma: no cover
        pass

RESERVED_PORT = 8000

ADMIN_TOKEN = os.environ.get("LIVE_ADMIN_TOKEN", "")
PROXY_BIND = os.environ.get("LIVE_PROXY_BIND", "127.0.0.1")
PROXY_PORT = int(os.environ.get("LIVE_PROXY_PORT", "8180"))
UPSTREAM_HOST = os.environ.get("LIVE_UPSTREAM_HOST", "127.0.0.1")
UPSTREAM_PORT = int(os.environ.get("LIVE_UPSTREAM_PORT", "8100"))
UPSTREAM_PORTS = [int(p) for p in
                  os.environ.get("LIVE_UPSTREAM_PORTS", "").replace(",", " ").split()]
SESSION_MODE = len(UPSTREAM_PORTS) >= 2
RATE_LIMIT = int(os.environ.get("LIVE_RATE_LIMIT_PER_MIN", "60"))
WINDOW_SECONDS = 60.0
ADMIN_PREFIX = "/admin"
ROTATE_PATH = "/live/rotate"

HERE = Path(__file__).resolve().parent
DRIFT_SCRIPT = HERE / "drift_cron.py"
SEED_INDEX = int(os.environ.get("LIVE_SEED_INDEX", "3"))

SESSION_COOKIE = "live_sid"
COOKIE_SAMESITE = os.environ.get("LIVE_COOKIE_SAMESITE", "auto").strip() or "auto"
SESSION_TTL = float(os.environ.get("LIVE_SESSION_TTL", "14400"))
SESSION_MAX = int(os.environ.get("LIVE_SESSION_MAX", "2000"))
SNAPSHOT_DIR = Path(os.environ.get("LIVE_SNAPSHOT_DIR", HERE / "snapshots")).resolve()
TABLE_NAME = os.environ.get("LIVE_DRIFT_TABLE", "incompatible_ingredients")
FILE_STEM = os.environ.get("LIVE_DRIFT_FILE", "incompatible_combinations")

# In legacy mode a request that arrives through a tunnel is recognisable by the
# forwarding headers the relay stamps on it; the presenter check uses that.
FORWARDED_MARKERS = (
    "cf-connecting-ip", "cf-ray", "cf-ipcountry", "cf-visitor", "cf-warp-tag-id",
    "x-forwarded-for", "x-forwarded-host", "x-real-ip",
)
_rotate_lock = threading.Lock()

# Headers that are connection-specific and must not be relayed.
HOP_BY_HOP = {
    "connection", "keep-alive", "proxy-authenticate", "proxy-authorization",
    "te", "trailer", "trailers", "transfer-encoding", "upgrade", "host",
    "content-length",
}


class SlidingWindowLimiter:
    """~N requests per 60s per key, kept entirely in memory (single process)."""

    def __init__(self, limit: int, window: float = WINDOW_SECONDS) -> None:
        self.limit = limit
        self.window = window
        self._hits: dict[str, deque[float]] = defaultdict(deque)
        self._lock = threading.Lock()

    def check(self, key: str) -> tuple[bool, int]:
        """Return (allowed, retry_after_seconds)."""
        now = time.monotonic()
        with self._lock:
            q = self._hits[key]
            cutoff = now - self.window
            while q and q[0] <= cutoff:
                q.popleft()
            if len(q) >= self.limit:
                return False, max(1, int(q[0] + self.window - now) + 1)
            q.append(now)
            return True, 0


limiter = SlidingWindowLimiter(RATE_LIMIT)


# --------------------------------------------------------------------------
# Session mode: ring snapshots, per-transition diffs, and the session store
# --------------------------------------------------------------------------

def _load_ring() -> dict[int, dict]:
    """Read the snapshot ring once; {index: data dict}."""
    ring: dict[int, dict] = {}
    for path in sorted(SNAPSHOT_DIR.glob(FILE_STEM + "_v*.json")):
        try:
            idx = int(path.stem.rsplit("_v", 1)[1])
            blob = json.loads(path.read_text(encoding="utf-8"))
        except (ValueError, OSError, json.JSONDecodeError):
            continue
        ring[idx] = blob.get("data") or {}
    return ring


def _diff_entries(before: dict, after: dict) -> dict:
    """Same shape live_app.py and the upstream reload endpoint produce."""
    changed: dict = {}
    for key in set(before) | set(after):
        old, new = before.get(key), after.get(key)
        if old == new:
            continue
        if isinstance(old, list) or isinstance(new, list):
            old_rows, new_rows = old or [], new or []
            changed[key] = {
                "removed": [r for r in old_rows if r not in new_rows],
                "added": [r for r in new_rows if r not in old_rows],
            }
        else:
            changed[key] = {"old": old, "new": new}
    return changed


_RING_DATA = _load_ring() if SESSION_MODE else {}
_DIFF_CACHE: dict[tuple[int, int], dict] = {}


def _ring_diff(a: int, b: int) -> dict:
    if (a, b) not in _DIFF_CACHE:
        _DIFF_CACHE[(a, b)] = _diff_entries(
            _RING_DATA.get(a) or {}, _RING_DATA.get(b) or {})
    return _DIFF_CACHE[(a, b)]


def _iso_now() -> str:
    return datetime.now(timezone.utc).isoformat(timespec="seconds").replace("+00:00", "Z")


class SessionStore:
    """sid -> {pos, hist, seen}. TTL-swept, size-capped, thread-safe."""

    def __init__(self, size: int, ttl: float, cap: int) -> None:
        self.size = size
        self.ttl = ttl
        self.cap = cap
        self._d: dict[str, dict] = {}
        self._lock = threading.Lock()
        self._last_sweep = 0.0

    def touch(self, sid: str | None) -> tuple[str, dict, bool]:
        """Return (sid, session, created). Unknown or absent sid makes a new one."""
        now = time.monotonic()
        with self._lock:
            if now - self._last_sweep > 60:
                self._last_sweep = now
                dead = [k for k, v in self._d.items() if now - v["seen"] > self.ttl]
                for k in dead:
                    del self._d[k]
            if sid and sid in self._d:
                sess = self._d[sid]
                sess["seen"] = now
                return sid, sess, False
            if len(self._d) >= self.cap:
                oldest = min(self._d, key=lambda k: self._d[k]["seen"])
                del self._d[oldest]
            sid = secrets.token_urlsafe(16)
            sess = {"pos": 1, "hist": [], "seen": now}
            self._d[sid] = sess
            return sid, sess, True

    def count(self) -> int:
        with self._lock:
            return len(self._d)


SESSIONS = SessionStore(len(UPSTREAM_PORTS) or 1, SESSION_TTL, SESSION_MAX)
_VISITOR_CALLS = {"n": 0}
_visitor_lock = threading.Lock()


def _token_ok(header_value: str | None) -> bool:
    if not header_value:
        return False
    parts = header_value.split(None, 1)
    if len(parts) != 2 or parts[0].lower() != "bearer":
        return False
    return hmac.compare_digest(parts[1].strip(), ADMIN_TOKEN)


class GateHandler(BaseHTTPRequestHandler):
    protocol_version = "HTTP/1.1"
    server_version = "live-gate/2.0"
    sys_version = ""

    # ---------- helpers ----------

    def _client_ip(self) -> str:
        return self.client_address[0] if self.client_address else "unknown"

    def _rate_key(self) -> str:
        """Behind a platform proxy every socket is the proxy; key on the first

        X-Forwarded-For hop there, and on the socket IP when direct."""
        if SESSION_MODE:
            xff = self.headers.get("X-Forwarded-For")
            if xff:
                return xff.split(",")[0].strip() or self._client_ip()
        return self._client_ip()

    def _cookie_sid(self) -> str | None:
        raw = self.headers.get("Cookie")
        if not raw:
            return None
        jar = SimpleCookie()
        try:
            jar.load(raw)
        except Exception:
            return None
        morsel = jar.get(SESSION_COOKIE)
        return morsel.value if morsel else None

    def _send_json(self, status: int, payload: dict, extra: dict | None = None) -> None:
        body = json.dumps(payload).encode("utf-8")
        self.send_response(status)
        self.send_header("Content-Type", "application/json")
        self.send_header("Content-Length", str(len(body)))
        for k, v in (extra or {}).items():
            self.send_header(k, v)
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(body)

    def _read_body(self) -> bytes:
        try:
            length = int(self.headers.get("Content-Length") or 0)
        except ValueError:
            length = 0
        return self.rfile.read(length) if length > 0 else b""

    def _cookie_samesite(self) -> str:
        """SameSite policy for this request.



        A page embedded in a cross-site iframe (the Space view on

        huggingface.co) only keeps its cookie under SameSite=None, which in

        turn requires Secure and therefore HTTPS. Plain-HTTP deployments must

        stay on Lax or the cookie is stored but never sent back. "auto" picks

        per request from the TLS evidence in the headers.

        """
        if COOKIE_SAMESITE.lower() != "auto":
            return COOKIE_SAMESITE
        proto = (self.headers.get("X-Forwarded-Proto") or "").split(",")[0].strip().lower()
        host = (self.headers.get("Host") or "").split(":")[0].strip().lower()
        if proto == "https" or host.endswith(".hf.space"):
            return "None"
        return "Lax"

    def _set_cookie_header(self, sid: str) -> tuple[str, str]:
        samesite = self._cookie_samesite()
        secure = "; Secure" if samesite.lower() == "none" else ""
        return ("Set-Cookie",
                f"{SESSION_COOKIE}={sid}; Path=/; Max-Age={int(SESSION_TTL)}; "
                f"SameSite={samesite}{secure}; HttpOnly")

    def _forward(self, port: int) -> tuple[int, list, bytes] | None:
        """Relay the current request to an upstream; None on connect failure."""
        body = self._read_body()
        fwd_headers = {
            k: v for k, v in self.headers.items()
            if k.lower() not in HOP_BY_HOP and k.lower() != "authorization"
        }
        fwd_headers["Host"] = f"{UPSTREAM_HOST}:{port}"
        prior = self.headers.get("X-Forwarded-For")
        ip = self._client_ip()
        fwd_headers["X-Forwarded-For"] = f"{prior}, {ip}" if prior else ip
        fwd_headers["X-Forwarded-Proto"] = "http"
        if body:
            fwd_headers["Content-Length"] = str(len(body))
        try:
            conn = http.client.HTTPConnection(UPSTREAM_HOST, port, timeout=30)
            conn.request(self.command, self.path, body=body or None, headers=fwd_headers)
            resp = conn.getresponse()
            payload = resp.read()
            status = resp.status
            out_headers = [
                (k, v) for k, v in resp.getheaders()
                if k.lower() not in HOP_BY_HOP
            ]
            conn.close()
        except OSError as exc:
            self._send_json(502, {"error": "bad_gateway",
                                  "detail": f"upstream {UPSTREAM_HOST}:{port}: {exc}"})
            return None
        return status, out_headers, payload

    def _relay(self, status: int, headers: list, payload: bytes,

               extra: list | None = None) -> None:
        self.send_response(status)
        for k, v in headers:
            self.send_header(k, v)
        for k, v in (extra or []):
            self.send_header(k, v)
        self.send_header("Content-Length", str(len(payload)))
        self.end_headers()
        if self.command != "HEAD":
            self.wfile.write(payload)

    # ---------- session mode ----------

    def _session_rotate(self, sess: dict, cookie: tuple[str, str] | None) -> None:
        size = len(UPSTREAM_PORTS)
        want = parse_qs(urlparse(self.path).query).get("to", [None])[0]
        with _rotate_lock:
            src = sess["pos"]
            if want and want.isdigit():
                dst = int(want)
                if not 1 <= dst <= size:
                    self._send_json(400, {"error": "bad_target",
                                          "detail": f"to must be 1..{size}"},
                                    dict([cookie]) if cookie else None)
                    return
            else:
                dst = src % size + 1
            if dst != src:
                sess["pos"] = dst
                sess["hist"].insert(0, {
                    "ts": _iso_now(), "table": TABLE_NAME,
                    "from": f"{src}.0.0", "to": f"{dst}.0.0",
                    "changed": _ring_diff(src, dst),
                })
                del sess["hist"][12:]
        self._send_json(200, {
            "ok": True,
            "version": f"{dst}.0.0",
            "position": dst,
            "detail": f"{src}.0.0 -> {dst}.0.0" if dst != src else "already there",
        }, dict([cookie]) if cookie else None)

    def _session_status(self, sess: dict, port: int,

                        cookie: tuple[str, str] | None) -> None:
        got = self._forward(port)
        if got is None:
            return
        status, headers, payload = got
        if status == 200:
            try:
                doc = json.loads(payload)
            except (ValueError, UnicodeDecodeError):
                doc = None
            if isinstance(doc, dict):
                doc["rotations"] = sess["hist"]
                doc["visitor_requests"] = _VISITOR_CALLS["n"]
                doc["session"] = {"position": sess["pos"], "size": len(UPSTREAM_PORTS)}
                doc["sessions_active"] = SESSIONS.count()
                drift = doc.get("drift")
                if isinstance(drift, dict):
                    drift["schedule"] = "on demand - each visitor drifts an isolated session"
                    drift["reload_pending"] = False
                payload = json.dumps(doc).encode("utf-8")
                headers = [(k, v) for k, v in headers
                           if k.lower() != "content-type"] + \
                          [("Content-Type", "application/json")]
        self._relay(status, headers, payload, [cookie] if cookie else None)

    def _handle_session(self) -> None:
        path = self.path.split("?", 1)[0]
        sid, sess, created = SESSIONS.touch(self._cookie_sid())
        cookie = self._set_cookie_header(sid) if created else None
        port = UPSTREAM_PORTS[sess["pos"] - 1]

        if path == ROTATE_PATH:
            if self.command in ("POST", "GET"):
                self._session_rotate(sess, cookie)
            else:
                self._send_json(405, {"error": "method_not_allowed"})
            return

        if path.startswith(ADMIN_PREFIX):
            if not _token_ok(self.headers.get("Authorization")):
                self._send_json(
                    401,
                    {"error": "unauthorized",
                     "detail": "admin routes require 'Authorization: Bearer <LIVE_ADMIN_TOKEN>'"},
                    {"WWW-Authenticate": 'Bearer realm="live-admin"'},
                )
                return

        if path.startswith("/api/") and "x-live-probe" not in self.headers:
            with _visitor_lock:
                _VISITOR_CALLS["n"] += 1

        if path == "/live/status":
            self._session_status(sess, port, cookie)
            return

        got = self._forward(port)
        if got is None:
            return
        status, headers, payload = got
        self._relay(status, headers, payload, [cookie] if cookie else None)

    # ---------- legacy presenter-only rotate ----------

    def _is_presenter(self) -> bool:
        if any(self.headers.get(h) for h in FORWARDED_MARKERS):
            return False
        host = (self.headers.get("Host") or "").rsplit(":", 1)[0].strip().lower()
        if host not in ("127.0.0.1", "localhost", "[::1]", "::1"):
            return False
        return self._client_ip() in ("127.0.0.1", "::1")

    def _upstream_major(self) -> int | None:
        try:
            conn = http.client.HTTPConnection(UPSTREAM_HOST, UPSTREAM_PORT, timeout=10)
            conn.request("GET", "/live/status")
            payload = json.loads(conn.getresponse().read())
            conn.close()
        except (OSError, ValueError):
            return None
        table = (payload.get("drift") or {}).get("table")
        version = (payload.get("table_versions") or {}).get(table)
        try:
            return int(str(version).split(".")[0])
        except (TypeError, ValueError):
            return None

    def _local_rotate(self) -> None:
        if not self._is_presenter():
            self._send_json(403, {"error": "forbidden",
                                  "detail": "rotate is served on the loopback gate only"})
            return
        want = parse_qs(urlparse(self.path).query).get("to", [None])[0]
        with _rotate_lock:
            argv = [sys.executable, str(DRIFT_SCRIPT)]
            if want and want.isdigit():
                argv += ["--to", want]
            else:
                major = self._upstream_major()
                if major is not None and major > SEED_INDEX:
                    argv += ["--to", str(SEED_INDEX)]
            try:
                proc = subprocess.run(argv, cwd=str(HERE), capture_output=True,
                                      text=True, timeout=90)
            except (OSError, subprocess.SubprocessError) as exc:
                self._send_json(500, {"error": "rotate_failed", "detail": str(exc)})
                return
            after = self._upstream_major()
        tail = [ln for ln in (proc.stdout or "").splitlines() if "->" in ln][-1:]
        self._send_json(200 if proc.returncode == 0 else 500, {
            "ok": proc.returncode == 0,
            "version": f"{after}.0.0" if after is not None else None,
            "detail": tail[0].strip() if tail else (proc.stderr or "").strip()[-200:],
        })

    def _handle_legacy(self) -> None:
        if self.path.split("?", 1)[0] == ROTATE_PATH:
            if self.command in ("POST", "GET"):
                self._local_rotate()
            else:
                self._send_json(405, {"error": "method_not_allowed"})
            return

        if self.path.split("?", 1)[0].startswith(ADMIN_PREFIX):
            if not _token_ok(self.headers.get("Authorization")):
                self._send_json(
                    401,
                    {"error": "unauthorized",
                     "detail": "admin routes require 'Authorization: Bearer <LIVE_ADMIN_TOKEN>'"},
                    {"WWW-Authenticate": 'Bearer realm="live-admin"'},
                )
                return

        got = self._forward(UPSTREAM_PORT)
        if got is None:
            return
        status, headers, payload = got
        self._relay(status, headers, payload)

    # ---------- core ----------

    def _handle(self) -> None:
        allowed, retry_after = limiter.check(self._rate_key())
        if not allowed:
            self._send_json(
                429,
                {"error": "rate_limited",
                 "detail": f"more than {RATE_LIMIT} requests/min from this address"},
                {"Retry-After": str(retry_after)},
            )
            return
        if self.command in ("GET", "HEAD") \
                and self.path.split("?", 1)[0] == "/" \
                and "text/html" in (self.headers.get("Accept") or ""):
            self.send_response(302)
            self.send_header("Location", "/live")
            self.send_header("Content-Length", "0")
            self.end_headers()
            return
        if SESSION_MODE:
            self._handle_session()
        else:
            self._handle_legacy()

    do_GET = do_POST = do_PUT = do_PATCH = do_DELETE = do_HEAD = do_OPTIONS = _handle

    def log_message(self, fmt: str, *args) -> None:  # quieter, one line per request
        sys.stderr.write(f"[gate] {self._client_ip()} {fmt % args}\n")


def main() -> None:
    if not ADMIN_TOKEN:
        raise SystemExit(
            "LIVE_ADMIN_TOKEN is not set. The gate refuses to start without it "
            "(fail closed). See .env.example."
        )
    if PROXY_PORT == RESERVED_PORT or UPSTREAM_PORT == RESERVED_PORT \
            or RESERVED_PORT in UPSTREAM_PORTS:
        raise SystemExit("port 8000 is reserved on this host")

    srv = ThreadingHTTPServer((PROXY_BIND, PROXY_PORT), GateHandler)
    srv.daemon_threads = True
    print(f"[gate] listening on http://{PROXY_BIND}:{PROXY_PORT}")
    if SESSION_MODE:
        print(f"[gate] session mode: {len(UPSTREAM_PORTS)} pinned upstreams "
              f"{UPSTREAM_PORTS} on {UPSTREAM_HOST}")
        print(f"[gate] ring diffs from {SNAPSHOT_DIR} "
              f"({len(_RING_DATA)} snapshots of {FILE_STEM})")
    else:
        print(f"[gate] forwarding to http://{UPSTREAM_HOST}:{UPSTREAM_PORT}")
    print(f"[gate] admin prefix {ADMIN_PREFIX} requires a bearer token")
    print(f"[gate] rate limit {RATE_LIMIT} req/min per client")
    try:
        srv.serve_forever()
    except KeyboardInterrupt:
        print("\n[gate] shutting down")
    finally:
        srv.server_close()


if __name__ == "__main__":
    main()