#!/usr/bin/env python3
"""
claude-code manager — 制御プレーン
責務:
* Web UI(モバイル対応・ビルド不要の素の HTML/JS)
* セッションのライフサイクル API(作成 / 一覧 / 破棄 / touch)
* gateway(nginx) からの auth_request 検証(token モード)
* アイドル / 寿命超過セッションの回収ループ
依存: Python 標準ライブラリのみ(追加 pip 不要)。
Kubernetes API へは in-cluster ServiceAccount トークンで直接アクセスする。
セッション NS 内の Pod / Service のみを操作する(RBAC で制限済み)。
"""
import base64
import hmac
import html
import json
import logging
import os
import re
import secrets
import ssl
import threading
import time
import urllib.error
import urllib.parse
import urllib.request
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
# --------------------------------------------------------------------------
# 設定(すべて環境変数から)
# --------------------------------------------------------------------------
def env(key, default=""):
return os.environ.get(key, default)
def env_json(key, default):
raw = os.environ.get(key, "").strip()
if not raw:
return default
try:
return json.loads(raw)
except ValueError:
logging.error("invalid JSON in env %s", key)
return default
LISTEN_PORT = int(env("LISTEN_PORT", "8080"))
SESSIONS_NS = env("SESSIONS_NAMESPACE", "default")
FULLNAME = env("FULLNAME", "claude-code")
SESSION_SA = env("SESSION_SERVICE_ACCOUNT", "claude-code-session")
SESSION_IMAGE = env("SESSION_IMAGE", "docker.io/library/node:22-alpine")
SESSION_IMAGE_PULL_POLICY = env("SESSION_IMAGE_PULL_POLICY", "IfNotPresent")
SESSION_IMAGE_PULL_SECRETS = env_json("SESSION_IMAGE_PULL_SECRETS_JSON", [])
BOOTSTRAP_CM = env("SESSION_BOOTSTRAP_CONFIGMAP", FULLNAME + "-session")
ANTHROPIC_SECRET = env("ANTHROPIC_SECRET", FULLNAME + "-anthropic")
ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey")
ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json")
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {})
GIT_ALLOWED_HOSTS = [h.strip().lower() for h in env("GIT_ALLOWED_HOSTS", "").split(",") if h.strip()]
GIT_ALLOW_ARBITRARY = env("GIT_ALLOW_ARBITRARY", "false").lower() == "true"
GIT_AUTHOR_NAME = env("GIT_DEFAULT_AUTHOR_NAME", "Claude")
GIT_AUTHOR_EMAIL = env("GIT_DEFAULT_AUTHOR_EMAIL", "claude@cafepieters.com")
IDLE_TIMEOUT = int(env("IDLE_TIMEOUT_SECONDS", "1800"))
MAX_LIFETIME = int(env("MAX_LIFETIME_SECONDS", "43200"))
REAP_INTERVAL = int(env("REAP_INTERVAL_SECONDS", "60"))
MAX_SESSIONS = int(env("MAX_SESSIONS", "10"))
TTYD_PORT = int(env("TTYD_PORT", "7681"))
SESSION_RESOURCES = env_json("SESSION_RESOURCES_JSON", {})
WORKSPACE_DIR = env("WORKSPACE_DIR", "/workspace")
AUTOSTART_CLAUDE = env("AUTOSTART_CLAUDE", "true")
VERIFY_COMMAND = env("VERIFY_COMMAND", "")
AUTOPUSH = env_json("AUTOPUSH_JSON", {"enabled": False})
EXTRA_APK = env("EXTRA_APK_PACKAGES", "")
CLAUDE_CODE_VERSION = env("CLAUDE_CODE_VERSION", "")
ANTHROPIC_EXTRA_ENV = env_json("ANTHROPIC_EXTRA_ENV_JSON", {})
POD_LABELS = env_json("SESSION_POD_LABELS_JSON", {})
POD_ANNOTATIONS = env_json("SESSION_POD_ANNOTATIONS_JSON", {})
NODE_SELECTOR = env_json("SESSION_NODE_SELECTOR_JSON", {})
TOLERATIONS = env_json("SESSION_TOLERATIONS_JSON", [])
PRIORITY_CLASS = env("SESSION_PRIORITY_CLASS", "")
SESSION_RUN_UID = int(env("SESSION_RUN_UID", "10001"))
AUTH_MODE = env("AUTH_MODE", "token")
_auth_token_file = env("AUTH_TOKEN_FILE", "")
if _auth_token_file and os.path.exists(_auth_token_file):
with open(_auth_token_file, "r", encoding="utf-8") as fh:
AUTH_TOKEN = fh.read().strip()
else:
AUTH_TOKEN = env("AUTH_TOKEN", "")
logging.basicConfig(
level=getattr(logging, env("LOG_LEVEL", "info").upper(), logging.INFO),
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("manager")
LABEL_APP = "app.kubernetes.io/name"
LABEL_COMPONENT = "app.kubernetes.io/component"
LABEL_SID = "claude-code.cafepieters.com/session-id"
ANN_PREFIX = "claude-code.cafepieters.com/"
SESSION_SELECTOR = LABEL_COMPONENT + "=session," + LABEL_APP + "=claude-code"
SID_RE = re.compile(r"^[a-f0-9]{10}$")
# 受け入れる repo URL: https://host/... または ssh://git@host/... または git@host:path
REPO_RE = re.compile(
r"^(?:https://(?P
[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|ssh://git@(?P[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
r"|git@(?P[A-Za-z0-9.\-]+):[\w./\-~%]+)$"
)
REF_RE = re.compile(r"^[\w./\-]{1,200}$")
# --------------------------------------------------------------------------
# Kubernetes API クライアント(標準ライブラリのみ)
# --------------------------------------------------------------------------
K8S_HOST = "https://kubernetes.default.svc"
_sa_dir = "/var/run/secrets/kubernetes.io/serviceaccount"
with open(_sa_dir + "/token", "r", encoding="utf-8") as fh:
K8S_TOKEN = fh.read().strip()
_ssl_ctx = ssl.create_default_context(cafile=_sa_dir + "/ca.crt")
def k8s(method, path, body=None, ctype="application/json"):
url = K8S_HOST + path
data = None
if body is not None:
data = body if isinstance(body, bytes) else json.dumps(body).encode("utf-8")
req = urllib.request.Request(url=url, method=method, data=data)
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
req.add_header("Accept", "application/json")
if data is not None:
req.add_header("Content-Type", ctype)
try:
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
raw = resp.read()
return resp.status, (json.loads(raw) if raw else {})
except urllib.error.HTTPError as exc:
raw = exc.read()
try:
payload = json.loads(raw)
except ValueError:
payload = {"message": raw.decode("utf-8", "replace")}
return exc.code, payload
def core(path):
return "/api/v1/namespaces/" + urllib.parse.quote(SESSIONS_NS) + path
# --------------------------------------------------------------------------
# セッション操作
# --------------------------------------------------------------------------
def repo_host(url):
m = REPO_RE.match(url)
if not m:
return None
return (m.group("h1") or m.group("h2") or m.group("h3") or "").lower()
def list_sessions():
status, payload = k8s("GET", core("/pods?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
if status != 200:
log.error("list pods failed: %s %s", status, payload)
return []
out = []
for pod in payload.get("items", []):
meta = pod.get("metadata", {})
ann = meta.get("annotations", {})
st = pod.get("status", {})
out.append({
"id": meta.get("labels", {}).get(LABEL_SID, ""),
"phase": st.get("phase", "Unknown"),
"repo": ann.get(ANN_PREFIX + "repo", ""),
"ref": ann.get(ANN_PREFIX + "ref", ""),
"identity": ann.get(ANN_PREFIX + "git-identity", ""),
"owner": ann.get(ANN_PREFIX + "owner", ""),
"createdAt": ann.get(ANN_PREFIX + "created-at", ""),
"lastActivity": ann.get(ANN_PREFIX + "last-activity", ""),
})
return out
def active_session_count():
return sum(1 for s in list_sessions() if s["phase"] in ("Pending", "Running"))
def build_pod(sid, repo, ref, identity_name, owner):
now = str(int(time.time()))
env_list = [
{"name": "SESSION_ID", "value": sid},
{"name": "REPO_URL", "value": repo},
{"name": "REPO_REF", "value": ref},
{"name": "HOME", "value": "/home/claude"},
{"name": "WORKSPACE_DIR", "value": WORKSPACE_DIR},
{"name": "TTYD_PORT", "value": str(TTYD_PORT)},
{"name": "IDLE_TIMEOUT_SECONDS", "value": str(IDLE_TIMEOUT)},
{"name": "AUTOSTART_CLAUDE", "value": AUTOSTART_CLAUDE},
{"name": "VERIFY_COMMAND", "value": VERIFY_COMMAND},
{"name": "AUTOPUSH_ENABLED", "value": str(bool(AUTOPUSH.get("enabled"))).lower()},
{"name": "AUTOPUSH_BRANCH_PREFIX", "value": AUTOPUSH.get("branchPrefix", "claude/")},
{"name": "AUTOPUSH_PROTECTED", "value": ",".join(AUTOPUSH.get("protectedBranches", []))},
{"name": "EXTRA_APK_PACKAGES", "value": EXTRA_APK},
{"name": "CLAUDE_CODE_VERSION", "value": CLAUDE_CODE_VERSION},
{"name": "GIT_AUTHOR_NAME", "value": GIT_AUTHOR_NAME},
{"name": "GIT_AUTHOR_EMAIL", "value": GIT_AUTHOR_EMAIL},
{"name": "GIT_COMMITTER_NAME", "value": GIT_AUTHOR_NAME},
{"name": "GIT_COMMITTER_EMAIL", "value": GIT_AUTHOR_EMAIL},
]
for key, val in ANTHROPIC_EXTRA_ENV.items():
env_list.append({"name": str(key), "value": str(val)})
volumes = [
{"name": "workspace", "emptyDir": {"sizeLimit": "4Gi"}},
{"name": "home", "emptyDir": {"sizeLimit": "1Gi"}},
{"name": "tmp", "emptyDir": {"medium": "Memory", "sizeLimit": "64Mi"}},
{"name": "npm-cache", "emptyDir": {"sizeLimit": "1Gi"}},
{"name": "bootstrap", "configMap": {"name": BOOTSTRAP_CM, "defaultMode": 0o555}},
]
mounts = [
{"name": "workspace", "mountPath": WORKSPACE_DIR},
{"name": "home", "mountPath": "/home/claude"},
{"name": "tmp", "mountPath": "/tmp"},
{"name": "npm-cache", "mountPath": "/home/claude/.npm"},
{"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True},
]
ident = GIT_IDENTITIES.get(identity_name)
if ident:
env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")})
if ident.get("type") == "ssh":
volumes.append({
"name": "git-identity",
"secret": {
"secretName": ident["secret"],
"defaultMode": 0o400,
"items": [
{"key": ident.get("keyPrivateKey", "id"), "path": "id"},
{"key": ident.get("keyKnownHosts", "known_hosts"), "path": "known_hosts"},
],
},
})
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
elif ident.get("type") == "https-token":
volumes.append({
"name": "git-identity",
"secret": {
"secretName": ident["secret"],
"defaultMode": 0o400,
"items": [{"key": ident.get("keyToken", "token"), "path": "token"}],
},
})
mounts.append({"name": "git-identity", "mountPath": "/git-identity", "readOnly": True})
env_list.append({"name": "GIT_HTTPS_USERNAME", "value": ident.get("username", "x-access-token")})
# ---- Claude 認証情報の渡し方 ----
# credentials モード: Secret をファイルとしてマウントし、エントリポイントが
# $HOME/.claude/.credentials.json へコピーする(書き込み可能にして claude 自身の
# リフレッシュを許す)。env var 名に "." を含められないため envFrom は使わない。
# apiKey / oauth モード: 有効な env var 名なので envFrom でそのまま注入する。
env_from = []
if ANTHROPIC_AUTH_MODE == "credentials":
volumes.append({
"name": "anthropic-creds",
"secret": {
"secretName": ANTHROPIC_SECRET,
"defaultMode": 0o400,
"items": [{"key": ANTHROPIC_CREDENTIALS_KEY, "path": "credentials.json"}],
},
})
mounts.append({"name": "anthropic-creds", "mountPath": "/anthropic", "readOnly": True})
env_list.append({"name": "SEED_CREDENTIALS_FILE", "value": "/anthropic/credentials.json"})
else:
env_from = [{"secretRef": {"name": ANTHROPIC_SECRET}}]
container = {
"name": "claude",
"image": SESSION_IMAGE,
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
"env": env_list,
"envFrom": env_from,
"ports": [{"name": "ttyd", "containerPort": TTYD_PORT}],
"volumeMounts": mounts,
"resources": SESSION_RESOURCES,
"securityContext": {
"allowPrivilegeEscalation": False,
"readOnlyRootFilesystem": True,
"runAsNonRoot": True,
"runAsUser": SESSION_RUN_UID,
"capabilities": {"drop": ["ALL"]},
},
"readinessProbe": {
"tcpSocket": {"port": TTYD_PORT},
"initialDelaySeconds": 5,
"periodSeconds": 5,
"failureThreshold": 60,
},
}
labels = {
LABEL_APP: "claude-code",
LABEL_COMPONENT: "session",
"app.kubernetes.io/managed-by": "claude-code-manager",
LABEL_SID: sid,
}
labels.update({str(k): str(v) for k, v in POD_LABELS.items()})
annotations = {
ANN_PREFIX + "repo": repo,
ANN_PREFIX + "ref": ref,
ANN_PREFIX + "git-identity": identity_name or "",
ANN_PREFIX + "owner": owner or "anon",
ANN_PREFIX + "created-at": now,
ANN_PREFIX + "last-activity": now,
}
annotations.update({str(k): str(v) for k, v in POD_ANNOTATIONS.items()})
spec = {
"serviceAccountName": SESSION_SA,
"automountServiceAccountToken": False,
"restartPolicy": "Never",
"activeDeadlineSeconds": MAX_LIFETIME,
"terminationGracePeriodSeconds": 30,
"enableServiceLinks": False,
"securityContext": {
"runAsNonRoot": True,
"runAsUser": SESSION_RUN_UID,
"runAsGroup": SESSION_RUN_UID,
"fsGroup": SESSION_RUN_UID,
"seccompProfile": {"type": "RuntimeDefault"},
},
"containers": [container],
"volumes": volumes,
}
if SESSION_IMAGE_PULL_SECRETS:
spec["imagePullSecrets"] = SESSION_IMAGE_PULL_SECRETS
if NODE_SELECTOR:
spec["nodeSelector"] = NODE_SELECTOR
if TOLERATIONS:
spec["tolerations"] = TOLERATIONS
if PRIORITY_CLASS:
spec["priorityClassName"] = PRIORITY_CLASS
return {
"apiVersion": "v1",
"kind": "Pod",
"metadata": {"name": "claude-sess-" + sid, "namespace": SESSIONS_NS,
"labels": labels, "annotations": annotations},
"spec": spec,
}
def build_service(sid):
return {
"apiVersion": "v1",
"kind": "Service",
"metadata": {
"name": "claude-sess-" + sid,
"namespace": SESSIONS_NS,
"labels": {LABEL_APP: "claude-code", LABEL_COMPONENT: "session", LABEL_SID: sid},
},
"spec": {
"selector": {LABEL_SID: sid},
"ports": [{"name": "ttyd", "port": TTYD_PORT, "targetPort": TTYD_PORT}],
},
}
def create_session(repo, ref, identity_name, owner):
repo = (repo or "").strip()
ref = (ref or "main").strip()
if not REPO_RE.match(repo):
return None, "リポジトリ URL の形式が不正です"
if not REF_RE.match(ref):
return None, "ref の形式が不正です"
host = repo_host(repo)
if not GIT_ALLOW_ARBITRARY and host not in GIT_ALLOWED_HOSTS:
return None, "許可されていない Git ホストです: " + str(host)
if identity_name and identity_name not in GIT_IDENTITIES:
return None, "未知の git identity: " + identity_name
if active_session_count() >= MAX_SESSIONS:
return None, "同時セッション数の上限(%d)に達しています" % MAX_SESSIONS
sid = secrets.token_hex(5)
status, payload = k8s("POST", core("/pods"), build_pod(sid, repo, ref, identity_name, owner))
if status not in (200, 201):
return None, "Pod 作成に失敗: %s" % payload.get("message", status)
status, payload = k8s("POST", core("/services"), build_service(sid))
if status not in (200, 201):
k8s("DELETE", core("/pods/claude-sess-" + sid))
return None, "Service 作成に失敗: %s" % payload.get("message", status)
log.info("session %s created repo=%s ref=%s owner=%s", sid, repo, ref, owner)
return sid, None
def delete_session(sid):
if not SID_RE.match(sid):
return False
k8s("DELETE", core("/pods/claude-sess-" + sid), {"gracePeriodSeconds": 10})
k8s("DELETE", core("/services/claude-sess-" + sid))
log.info("session %s deleted", sid)
return True
_touch_cache = {}
def touch_session(sid, min_interval=15):
if not SID_RE.match(sid):
return
now = time.time()
if now - _touch_cache.get(sid, 0) < min_interval:
return
_touch_cache[sid] = now
patch = {"metadata": {"annotations": {ANN_PREFIX + "last-activity": str(int(now))}}}
k8s("PATCH", core("/pods/claude-sess-" + sid), patch,
ctype="application/merge-patch+json")
# --------------------------------------------------------------------------
# 回収ループ
# --------------------------------------------------------------------------
def reaper_loop():
while True:
time.sleep(REAP_INTERVAL)
try:
reap_once()
except Exception as exc: # noqa: BLE001 - ループを止めない
log.exception("reaper error: %s", exc)
def reap_once():
now = int(time.time())
live_ids = set()
for s in list_sessions():
sid = s["id"]
live_ids.add(sid)
reason = None
if s["phase"] in ("Succeeded", "Failed"):
reason = "phase=" + s["phase"]
else:
try:
created = int(s["createdAt"] or 0)
last = int(s["lastActivity"] or 0)
except ValueError:
created = last = now
if created and now - created > MAX_LIFETIME:
reason = "max-lifetime"
elif last and now - last > IDLE_TIMEOUT:
reason = "idle"
if reason:
log.info("reaping session %s (%s)", sid, reason)
delete_session(sid)
live_ids.discard(sid)
# 孤児 Service の掃除
status, payload = k8s("GET", core("/services?labelSelector=" + urllib.parse.quote(SESSION_SELECTOR)))
if status == 200:
for svc in payload.get("items", []):
sid = svc.get("metadata", {}).get("labels", {}).get(LABEL_SID, "")
if sid and sid not in live_ids:
log.info("reaping orphan service %s", sid)
k8s("DELETE", core("/services/claude-sess-" + sid))
# --------------------------------------------------------------------------
# 認証(token モード)
# --------------------------------------------------------------------------
def extract_token(handler):
auth = handler.headers.get("Authorization", "")
if auth.startswith("Bearer "):
return auth[7:].strip()
cookie = handler.headers.get("Cookie", "")
for part in cookie.split(";"):
part = part.strip()
if part.startswith("cc_token="):
return urllib.parse.unquote(part[9:])
qs = urllib.parse.urlparse(handler.path).query
params = urllib.parse.parse_qs(qs)
if "token" in params:
return params["token"][0]
return ""
def token_ok(handler):
if AUTH_MODE != "token":
return True
if not AUTH_TOKEN:
return False
return hmac.compare_digest(extract_token(handler), AUTH_TOKEN)
# --------------------------------------------------------------------------
# HTTP ハンドラ
# --------------------------------------------------------------------------
INDEX_HTML = """
Claude Code — Remote Sessions
Claude Code — Remote Sessions
セッション一覧
アイドル 秒で自動破棄されます。
"""
class Handler(BaseHTTPRequestHandler):
server_version = "claude-code-manager"
protocol_version = "HTTP/1.1"
def _send(self, code, body=b"", ctype="application/json; charset=utf-8", extra=None):
if isinstance(body, str):
body = body.encode("utf-8")
self.send_response(code)
self.send_header("Content-Type", ctype)
self.send_header("Content-Length", str(len(body)))
self.send_header("Cache-Control", "no-store")
self.send_header("X-Content-Type-Options", "nosniff")
for key, val in (extra or {}).items():
self.send_header(key, val)
self.end_headers()
if self.command != "HEAD":
self.wfile.write(body)
def _json(self, code, obj, extra=None):
self._send(code, json.dumps(obj), "application/json; charset=utf-8", extra)
def _body(self):
length = int(self.headers.get("Content-Length", "0") or "0")
if length <= 0:
return {}
try:
return json.loads(self.rfile.read(length) or b"{}")
except ValueError:
return {}
def log_message(self, fmt, *args):
log.debug("%s - %s", self.address_string(), fmt % args)
# ---- ルーティング ----
def do_GET(self):
path = urllib.parse.urlparse(self.path).path
if path == "/healthz":
return self._send(200, b'{"ok":true}')
if path == "/auth":
# nginx auth_request 用(token モードのみ意味を持つ)
if token_ok(self):
orig = self.headers.get("X-Original-URI", "")
m = re.search(r"/s/([a-f0-9]{10})/", orig)
if m:
try:
touch_session(m.group(1))
except Exception: # noqa: BLE001
pass
return self._send(200, b"ok", "text/plain")
return self._send(401, b"unauthorized", "text/plain")
if path == "/" or path == "/index.html":
return self._send(200, INDEX_HTML, "text/html; charset=utf-8")
if path == "/api/config":
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {
"identities": sorted(GIT_IDENTITIES.keys()),
"idleTimeoutSeconds": IDLE_TIMEOUT,
"maxLifetimeSeconds": MAX_LIFETIME,
"maxSessions": MAX_SESSIONS,
"authMode": AUTH_MODE,
})
if path == "/api/sessions":
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {"sessions": list_sessions()})
return self._send(404, b'{"error":"not found"}')
def do_POST(self):
path = urllib.parse.urlparse(self.path).path
if path == "/login":
body = self._body()
token = str(body.get("token", ""))
if AUTH_MODE == "token" and AUTH_TOKEN and hmac.compare_digest(token, AUTH_TOKEN):
cookie = ("cc_token=" + urllib.parse.quote(token) +
"; HttpOnly; SameSite=Lax; Path=/; Max-Age=2592000")
return self._json(200, {"ok": True}, {"Set-Cookie": cookie})
return self._json(401, {"error": "invalid token"})
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
if path == "/api/sessions":
body = self._body()
owner = self.headers.get("X-Forwarded-User", "") or "anon"
sid, err = create_session(body.get("repo", ""), body.get("ref", "main"),
body.get("identity", ""), owner)
if err:
return self._json(400, {"error": err})
return self._json(201, {"id": sid, "url": "/s/" + sid + "/"})
m = re.match(r"^/api/sessions/([a-f0-9]{10})/touch$", path)
if m:
touch_session(m.group(1), min_interval=0)
return self._json(200, {"ok": True})
return self._send(404, b'{"error":"not found"}')
def do_DELETE(self):
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
m = re.match(r"^/api/sessions/([a-f0-9]{10})$", urllib.parse.urlparse(self.path).path)
if not m:
return self._send(404, b'{"error":"not found"}')
delete_session(m.group(1))
return self._json(200, {"ok": True})
def main():
log.info("claude-code manager starting: ns=%s image=%s idle=%ds maxLife=%ds maxSessions=%d authMode=%s",
SESSIONS_NS, SESSION_IMAGE, IDLE_TIMEOUT, MAX_LIFETIME, MAX_SESSIONS, AUTH_MODE)
if AUTH_MODE == "token" and not AUTH_TOKEN:
log.warning("AUTH_MODE=token だがトークンが空。全リクエストを拒否します。")
threading.Thread(target=reaper_loop, name="reaper", daemon=True).start()
httpd = ThreadingHTTPServer(("0.0.0.0", LISTEN_PORT), Handler)
httpd.serve_forever()
if __name__ == "__main__":
import sys
if "--reap-once" in sys.argv[1:]:
# 予備の CronJob から呼ばれる一回きりの回収。manager 障害時の保険。
log.info("one-shot reap starting (ns=%s)", SESSIONS_NS)
reap_once()
log.info("one-shot reap done")
else:
main()