gateway(nginx) + manager(python 標準ライブラリ) + セッションごとの使い捨て Pod + reaper CronJob の構成。ブラウザ(PC/スマホ)からアクセスし、セッション単位で 独立した Alpine コンテナに Git リポジトリ(GitHub/Gitea など)をチェックアウトして 編集・検証・プッシュする。無操作が続いたセッションは Pod ごと自動破棄。 - セキュリティ: セッション NS 分離 / restricted PodSecurity / RBAC はセッション NS の pods,services のみの Role / セッション Pod は SA トークン非マウント・非 root・ readOnlyRootFilesystem・drop ALL caps・seccomp / NetworkPolicy で egress を DNS+HTTPS+SSH に限定 / Git ホスト allow-list / ResourceQuota+LimitRange - ライフサイクル: idleTimeout(Pod 内ウォッチドッグ + manager 回収) / maxLifetime (activeDeadlineSeconds) / maxSessions / 予備の孤児掃除 CronJob - 認証: token(既定) / basic / forward(外部SSO) / none。WebSocket は Cookie トークン - Claude 認証: ANTHROPIC_API_KEY または CLAUDE_CODE_OAUTH_TOKEN - CI: helm-release.yaml(未リリース版のみ tag+release+publish) / image-update-and-release.yaml(週次 npm 追従) / session-image.yaml(multi-arch build) - files/ 実体を .Files.Get で ConfigMap 化。helm lint / template 検証済み
686 lines
28 KiB
Python
686 lines
28 KiB
Python
#!/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")
|
||
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<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||
r"|ssh://git@(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||
r"|git@(?P<h3>[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")})
|
||
|
||
container = {
|
||
"name": "claude",
|
||
"image": SESSION_IMAGE,
|
||
"imagePullPolicy": SESSION_IMAGE_PULL_POLICY,
|
||
"command": ["/bin/sh", "/bootstrap/entrypoint.sh"],
|
||
"env": env_list,
|
||
"envFrom": [{"secretRef": {"name": ANTHROPIC_SECRET}}],
|
||
"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 = """<!doctype html>
|
||
<html lang="ja"><head><meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
|
||
<title>Claude Code — Remote Sessions</title>
|
||
<style>
|
||
:root{color-scheme:light dark}
|
||
*{box-sizing:border-box}
|
||
body{font:15px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,sans-serif;margin:0;padding:1rem;max-width:760px;margin-inline:auto}
|
||
h1{font-size:1.15rem;margin:.2rem 0 1rem}
|
||
form,.card{border:1px solid #8884;border-radius:10px;padding:1rem;margin-bottom:1rem}
|
||
label{display:block;font-weight:600;margin:.5rem 0 .2rem}
|
||
input,select,button{font:inherit;padding:.55rem .6rem;border-radius:8px;border:1px solid #8886;width:100%}
|
||
button{background:#4338ca;color:#fff;border:0;font-weight:600;cursor:pointer;margin-top:.8rem}
|
||
button.sec{background:#8883;color:inherit}
|
||
.row{display:flex;gap:.5rem;align-items:center}
|
||
.row>*{width:auto}
|
||
table{width:100%;border-collapse:collapse;font-size:.92rem}
|
||
td,th{text-align:left;padding:.45rem .3rem;border-bottom:1px solid #8883;vertical-align:top}
|
||
a.open{display:inline-block;background:#0891b2;color:#fff;padding:.3rem .7rem;border-radius:6px;text-decoration:none}
|
||
.muted{opacity:.65;font-size:.85rem}
|
||
code{background:#8882;padding:.1rem .3rem;border-radius:4px}
|
||
</style></head><body>
|
||
<h1>Claude Code — Remote Sessions</h1>
|
||
<div id="login" hidden>
|
||
<form onsubmit="return doLogin(event)">
|
||
<label for="tk">アクセストークン</label>
|
||
<input id="tk" type="password" autocomplete="current-password" placeholder="Bearer token">
|
||
<button type="submit">ログイン</button>
|
||
</form>
|
||
</div>
|
||
<div id="app" hidden>
|
||
<form onsubmit="return doCreate(event)">
|
||
<label for="repo">リポジトリ URL</label>
|
||
<input id="repo" placeholder="https://github.com/owner/repo.git または git@host:owner/repo.git" required>
|
||
<div class="row">
|
||
<div style="flex:1"><label for="ref">ブランチ / タグ</label><input id="ref" value="main"></div>
|
||
<div style="flex:1"><label for="ident">認証 identity</label><select id="ident"></select></div>
|
||
</div>
|
||
<button type="submit">セッション作成</button>
|
||
</form>
|
||
<div class="card">
|
||
<div class="row" style="justify-content:space-between">
|
||
<strong>セッション一覧</strong>
|
||
<button class="sec" style="width:auto;margin:0" onclick="refresh()">更新</button>
|
||
</div>
|
||
<table><thead><tr><th>ID</th><th>状態</th><th>リポジトリ</th><th></th></tr></thead>
|
||
<tbody id="rows"></tbody></table>
|
||
<p class="muted">アイドル <span id="idle"></span> 秒で自動破棄されます。</p>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
const $=s=>document.querySelector(s);
|
||
let identities=[];
|
||
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
|
||
async function boot(){
|
||
const r=await api('/api/config');
|
||
if(r.status===401){$('#login').hidden=false;return;}
|
||
const c=await r.json();
|
||
identities=c.identities||[];
|
||
$('#idle').textContent=c.idleTimeoutSeconds;
|
||
const sel=$('#ident');
|
||
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
|
||
$('#app').hidden=false;
|
||
refresh();
|
||
}
|
||
async function doLogin(e){e.preventDefault();
|
||
const r=await api('/login',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({token:$('#tk').value})});
|
||
if(r.ok){location.reload();}else{alert('トークンが違います');}
|
||
return false;
|
||
}
|
||
async function doCreate(e){e.preventDefault();
|
||
const body={repo:$('#repo').value.trim(),ref:$('#ref').value.trim()||'main',identity:$('#ident').value};
|
||
const r=await api('/api/sessions',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||
const j=await r.json();
|
||
if(!r.ok){alert(j.error||'作成に失敗しました');return false;}
|
||
$('#repo').value='';refresh();return false;
|
||
}
|
||
async function del(id){if(!confirm('セッション '+id+' を破棄しますか?'))return;
|
||
await api('/api/sessions/'+id,{method:'DELETE'});refresh();}
|
||
async function refresh(){
|
||
const r=await api('/api/sessions');if(!r.ok)return;
|
||
const list=(await r.json()).sessions||[];
|
||
$('#rows').innerHTML=list.map(s=>`<tr>
|
||
<td><code>${s.id}</code></td>
|
||
<td>${s.phase}</td>
|
||
<td style="word-break:break-all">${s.repo||''}<div class="muted">${s.ref||''}</div></td>
|
||
<td><a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a>
|
||
<button class="sec" style="width:auto;margin:.3rem 0 0" onclick="del('${s.id}')">破棄</button></td>
|
||
</tr>`).join('')||'<tr><td colspan=4 class="muted">セッションはありません</td></tr>';
|
||
}
|
||
boot();
|
||
</script></body></html>
|
||
"""
|
||
|
||
|
||
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()
|