helm --set-file ではなく UI から SSH 秘密鍵 + known_hosts / HTTPS トークンを登録可能に。
- git.guiIdentities.enabled(既定 true)。UI に「認証情報」カード(一覧/追加/削除)
- GET/POST/DELETE /api/identities。登録分はセッション NS の Secret
(ラベル identity-source=gui / identity-type)として保存。Helm 管理分と同一覧に統合
- manager.py: resolve_identity / list_identities / create_gui_identity / delete_gui_identity。
build_pod と create_session は Helm 管理 + GUI 登録の両方を解決。
/api/config の identities を {name,type,source} 配列に変更
- rbac.yaml: guiIdentities.enabled 時のみ manager Role に同 NS secrets の CRUD を付与
- 秘密鍵はリクエストボディで manager へ届くため README に経路 TLS 必須を明記
- helm lint / template 検証済み
1025 lines
44 KiB
Python
1025 lines
44 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")
|
||
ANTHROPIC_AUTH_MODE = env("ANTHROPIC_AUTH_MODE", "apiKey")
|
||
ANTHROPIC_CREDENTIALS_KEY = env("ANTHROPIC_CREDENTIALS_KEY", ".credentials.json")
|
||
GIT_IDENTITIES = env_json("GIT_IDENTITIES_JSON", {}) # Helm 管理(読み取り専用)
|
||
GUI_IDENTITIES_ENABLED = env("GUI_IDENTITIES_ENABLED", "false").lower() == "true"
|
||
GIT_FULLNAME_PREFIX = env("GIT_FULLNAME_PREFIX", FULLNAME)
|
||
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"))
|
||
# 終了済み(Succeeded / Failed)セッション Pod を残す秒数。0 なら即回収。
|
||
# 死因を kubectl logs / UI「ログ」で調べられるよう既定で猶予を持たせる。
|
||
TERMINATED_RETENTION = int(env("TERMINATED_RETENTION_SECONDS", "900"))
|
||
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"
|
||
LABEL_GIT_IDENT = "claude-code.cafepieters.com/git-identity"
|
||
LABEL_IDENT_SOURCE = "claude-code.cafepieters.com/identity-source"
|
||
LABEL_IDENT_TYPE = "claude-code.cafepieters.com/identity-type"
|
||
|
||
SID_RE = re.compile(r"^[a-f0-9]{10}$")
|
||
# identity 名は Secret 名の一部になるため DNS ラベル相当に制限
|
||
IDENT_NAME_RE = re.compile(r"^[a-z0-9]([a-z0-9-]{0,38}[a-z0-9])?$")
|
||
# 受け入れる repo URL:
|
||
# https://host[:port]/path
|
||
# ssh://[user@]host[:port]/path
|
||
# [user@]host:path (SCP 形式。Gitea/GitHub の SSH ユーザーは常に "git")
|
||
_USER = r"(?:[A-Za-z0-9_.\-]+@)?"
|
||
REPO_RE = re.compile(
|
||
r"^(?:https://(?P<h1>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||
r"|ssh://" + _USER + r"(?P<h2>[A-Za-z0-9.\-]+)(?::\d+)?/[\w./\-~%]+"
|
||
r"|" + _USER + r"(?P<h3>[A-Za-z0-9.\-]+):(?!//)[\w./\-~%]+)$"
|
||
)
|
||
REPO_HELP = ("URL の形式: https://<host>/<org>/<repo>.git / "
|
||
"ssh://git@<host>/<org>/<repo>.git / git@<host>:<org>/<repo>.git "
|
||
"(Gitea・GitHub の SSH ユーザーは常に 'git'。あなたのアカウント名ではありません)")
|
||
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 k8s_text(path):
|
||
"""text/plain を返すエンドポイント(Pod ログ)用。(status, text) を返す。"""
|
||
req = urllib.request.Request(url=K8S_HOST + path, method="GET")
|
||
req.add_header("Authorization", "Bearer " + K8S_TOKEN)
|
||
try:
|
||
with urllib.request.urlopen(req, context=_ssl_ctx, timeout=30) as resp:
|
||
return resp.status, resp.read().decode("utf-8", "replace")
|
||
except urllib.error.HTTPError as exc:
|
||
return exc.code, exc.read().decode("utf-8", "replace")
|
||
|
||
|
||
def session_log(sid, tail=800):
|
||
if not SID_RE.match(sid):
|
||
return 400, "bad session id"
|
||
return k8s_text(core("/pods/claude-sess-" + sid + "/log?container=claude&tailLines=" + str(int(tail))))
|
||
|
||
# --------------------------------------------------------------------------
|
||
# git identity(Helm 管理 + GUI 登録)
|
||
# --------------------------------------------------------------------------
|
||
def gui_identity_secret(name):
|
||
return GIT_FULLNAME_PREFIX + "-git-" + name
|
||
|
||
|
||
def list_gui_identities():
|
||
"""GUI から登録された identity(source=gui ラベルの Secret)の一覧。"""
|
||
if not GUI_IDENTITIES_ENABLED:
|
||
return []
|
||
status, payload = k8s("GET", core("/secrets?labelSelector=" +
|
||
urllib.parse.quote(LABEL_IDENT_SOURCE + "=gui")))
|
||
if status != 200:
|
||
log.error("list identity secrets failed: %s", status)
|
||
return []
|
||
out = []
|
||
for sec in payload.get("items", []):
|
||
lbl = sec.get("metadata", {}).get("labels", {})
|
||
nm = lbl.get(LABEL_GIT_IDENT)
|
||
if nm:
|
||
out.append({"name": nm, "type": lbl.get(LABEL_IDENT_TYPE, "ssh"), "source": "gui"})
|
||
return out
|
||
|
||
|
||
def list_identities():
|
||
"""UI 用: Helm 管理 + GUI 登録の identity 一覧。"""
|
||
out = [{"name": n, "type": (v or {}).get("type", "ssh"), "source": "helm"}
|
||
for n, v in GIT_IDENTITIES.items()]
|
||
names = {i["name"] for i in out}
|
||
for i in list_gui_identities():
|
||
if i["name"] not in names:
|
||
out.append(i)
|
||
return sorted(out, key=lambda x: x["name"])
|
||
|
||
|
||
def resolve_identity(name):
|
||
"""build_pod 用にマウント情報を返す。無ければ None。"""
|
||
if not name:
|
||
return None
|
||
if name in GIT_IDENTITIES:
|
||
return GIT_IDENTITIES[name]
|
||
if not GUI_IDENTITIES_ENABLED:
|
||
return None
|
||
status, sec = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
|
||
if status != 200:
|
||
return None
|
||
lbl = sec.get("metadata", {}).get("labels", {})
|
||
if lbl.get(LABEL_IDENT_SOURCE) != "gui":
|
||
return None
|
||
typ = lbl.get(LABEL_IDENT_TYPE, "ssh")
|
||
data = sec.get("data", {}) or {}
|
||
if typ == "https-token":
|
||
user = "x-access-token"
|
||
if data.get("username"):
|
||
try:
|
||
user = base64.b64decode(data["username"]).decode("utf-8").strip() or user
|
||
except Exception: # noqa: BLE001
|
||
pass
|
||
return {"type": "https-token", "secret": gui_identity_secret(name),
|
||
"keyToken": "token", "username": user}
|
||
return {"type": "ssh", "secret": gui_identity_secret(name),
|
||
"keyPrivateKey": "id", "keyKnownHosts": "known_hosts"}
|
||
|
||
|
||
def create_gui_identity(body):
|
||
if not GUI_IDENTITIES_ENABLED:
|
||
return 403, {"error": "GUI からの identity 登録は無効です(git.guiIdentities.enabled)"}
|
||
name = str(body.get("name", "")).strip()
|
||
typ = str(body.get("type", "")).strip()
|
||
if not IDENT_NAME_RE.match(name):
|
||
return 400, {"error": "identity 名は英小文字・数字・ハイフン(先頭末尾は英数字、40 文字以内)"}
|
||
if name in GIT_IDENTITIES:
|
||
return 409, {"error": "Helm 管理の identity と同名です: " + name}
|
||
if typ == "ssh":
|
||
key = str(body.get("privateKey", "")).strip()
|
||
kh = str(body.get("knownHosts", "")).strip()
|
||
if "PRIVATE KEY" not in key:
|
||
return 400, {"error": "privateKey が OpenSSH/PEM 秘密鍵ではありません"}
|
||
if not kh:
|
||
return 400, {"error": "knownHosts は必須です(`ssh-keyscan <host>` の出力)"}
|
||
string_data = {"id": key + ("\n" if not key.endswith("\n") else ""), "known_hosts": kh + "\n"}
|
||
elif typ == "https-token":
|
||
token = str(body.get("token", "")).strip()
|
||
if not token:
|
||
return 400, {"error": "token は必須です"}
|
||
string_data = {"username": str(body.get("username", "") or "x-access-token").strip(),
|
||
"token": token}
|
||
else:
|
||
return 400, {"error": "type は ssh か https-token"}
|
||
|
||
manifest = {
|
||
"apiVersion": "v1", "kind": "Secret", "type": "Opaque",
|
||
"metadata": {
|
||
"name": gui_identity_secret(name),
|
||
"namespace": SESSIONS_NS,
|
||
"labels": {
|
||
LABEL_APP: "claude-code",
|
||
LABEL_GIT_IDENT: name,
|
||
LABEL_IDENT_SOURCE: "gui",
|
||
LABEL_IDENT_TYPE: typ,
|
||
},
|
||
},
|
||
"stringData": string_data,
|
||
}
|
||
# 既存なら置き換え(PUT)、無ければ作成(POST)
|
||
status, _ = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
|
||
if status == 200:
|
||
st, payload = k8s("PUT", core("/secrets/" + gui_identity_secret(name)), manifest)
|
||
else:
|
||
st, payload = k8s("POST", core("/secrets"), manifest)
|
||
if st not in (200, 201):
|
||
return st, {"error": "Secret 保存に失敗: %s" % payload.get("message", st)}
|
||
log.info("gui identity %s (%s) saved", name, typ)
|
||
return 200, {"ok": True, "name": name, "type": typ}
|
||
|
||
|
||
def delete_gui_identity(name):
|
||
if not GUI_IDENTITIES_ENABLED:
|
||
return 403, {"error": "無効です"}
|
||
if not IDENT_NAME_RE.match(name):
|
||
return 400, {"error": "bad name"}
|
||
status, sec = k8s("GET", core("/secrets/" + gui_identity_secret(name)))
|
||
if status != 200:
|
||
return 404, {"error": "見つかりません"}
|
||
if sec.get("metadata", {}).get("labels", {}).get(LABEL_IDENT_SOURCE) != "gui":
|
||
return 403, {"error": "Helm 管理の identity は GUI から削除できません"}
|
||
k8s("DELETE", core("/secrets/" + gui_identity_secret(name)))
|
||
log.info("gui identity %s deleted", name)
|
||
return 200, {"ok": True}
|
||
|
||
# --------------------------------------------------------------------------
|
||
# セッション操作
|
||
# --------------------------------------------------------------------------
|
||
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", {})
|
||
info = {
|
||
"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", ""),
|
||
"reason": st.get("reason", ""), # DeadlineExceeded 等
|
||
"message": st.get("message", ""),
|
||
"finishedAt": "",
|
||
"exitCode": None,
|
||
}
|
||
for cs in st.get("containerStatuses", []) or []:
|
||
term = (cs.get("state") or {}).get("terminated")
|
||
if term:
|
||
info["finishedAt"] = term.get("finishedAt", "")
|
||
info["exitCode"] = term.get("exitCode")
|
||
if not info["reason"]:
|
||
info["reason"] = term.get("reason", "")
|
||
out.append(info)
|
||
return out
|
||
|
||
|
||
def _rfc3339_epoch(value):
|
||
"""RFC3339(...Z)→ epoch 秒。失敗時は 0。"""
|
||
if not value:
|
||
return 0
|
||
try:
|
||
import datetime
|
||
return int(datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp())
|
||
except Exception: # noqa: BLE001
|
||
return 0
|
||
|
||
|
||
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 = resolve_identity(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 の形式が不正です。" + REPO_HELP
|
||
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 ホストです: %s(許可済み: %s)"
|
||
% (host, ", ".join(GIT_ALLOWED_HOSTS) or "なし"))
|
||
if identity_name and resolve_identity(identity_name) is None:
|
||
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"):
|
||
# 終了済み Pod は TERMINATED_RETENTION の猶予後に回収(死因調査のため残す)
|
||
fin = _rfc3339_epoch(s.get("finishedAt")) or (int(s["createdAt"] or 0) + 120)
|
||
if now - fin >= TERMINATED_RETENTION:
|
||
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}
|
||
.st-Running{color:#059669;font-weight:600}
|
||
.st-Failed{color:#dc2626;font-weight:600}
|
||
.st-Pending,.st-Succeeded{color:#d97706;font-weight:600}
|
||
pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-radius:8px;max-height:50vh;overflow:auto;font-size:.8rem}
|
||
</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://host/org/repo.git または git@host:org/repo.git(SSH ユーザーは常に git)" required>
|
||
<div class="muted">SSH は <code>git@<host>:<org>/<repo>.git</code>(ユーザー名は自分のアカウントではなく必ず <code>git</code>)。認証は下で選ぶ identity の鍵で行われます。</div>
|
||
<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" id="identcard" hidden>
|
||
<div class="row" style="justify-content:space-between">
|
||
<strong>認証情報(identity)</strong>
|
||
<button class="sec" style="width:auto;margin:0" onclick="loadIdents()">更新</button>
|
||
</div>
|
||
<table><tbody id="identrows"></tbody></table>
|
||
<details style="margin-top:.6rem">
|
||
<summary style="cursor:pointer;font-weight:600">+ identity を追加</summary>
|
||
<form onsubmit="return addIdent(event)" style="border:0;padding:.4rem 0 0;margin:0">
|
||
<div class="row">
|
||
<div style="flex:1"><label for="idn">名前</label>
|
||
<input id="idn" placeholder="gitea-ssh" pattern="[a-z0-9][a-z0-9-]{0,38}[a-z0-9]" required></div>
|
||
<div style="flex:1"><label for="idt">種類</label>
|
||
<select id="idt" onchange="identFields()">
|
||
<option value="ssh">SSH 秘密鍵</option>
|
||
<option value="https-token">HTTPS トークン</option>
|
||
</select></div>
|
||
</div>
|
||
<div id="f-ssh">
|
||
<label for="idkey">SSH 秘密鍵(PEM / OpenSSH)</label>
|
||
<textarea id="idkey" rows="6" style="width:100%;font-family:monospace;font-size:.8rem"
|
||
placeholder="-----BEGIN OPENSSH PRIVATE KEY----- ..."></textarea>
|
||
<label for="idkh">known_hosts(必須)</label>
|
||
<textarea id="idkh" rows="2" style="width:100%;font-family:monospace;font-size:.8rem"
|
||
placeholder="ssh-keyscan <host> の出力を貼り付け"></textarea>
|
||
</div>
|
||
<div id="f-tok" hidden>
|
||
<label for="idu">ユーザー名</label>
|
||
<input id="idu" value="x-access-token">
|
||
<label for="idtk">トークン(PAT)</label>
|
||
<input id="idtk" type="password" placeholder="Gitea / GitHub の Personal Access Token">
|
||
</div>
|
||
<button type="submit">保存</button>
|
||
<div class="muted">秘密鍵はブラウザ→サーバーへ送信され Secret として保存されます。経路の TLS を有効にしてください。</div>
|
||
</form>
|
||
</details>
|
||
</div>
|
||
<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 class="card" id="logcard" hidden>
|
||
<div class="row" style="justify-content:space-between">
|
||
<strong>ログ: <code id="logid"></code></strong>
|
||
<button class="sec" style="width:auto;margin:0" onclick="$('#logcard').hidden=true">閉じる</button>
|
||
</div>
|
||
<pre id="logbox"></pre>
|
||
</div>
|
||
</div>
|
||
<script>
|
||
const $=s=>document.querySelector(s);
|
||
let identities=[];
|
||
function api(path,opts){opts=opts||{};opts.credentials='same-origin';return fetch(path,opts);}
|
||
let guiIdents=false;
|
||
async function boot(){
|
||
const r=await api('/api/config');
|
||
if(r.status===401){$('#login').hidden=false;return;}
|
||
const c=await r.json();
|
||
guiIdents=!!c.guiIdentities;
|
||
$('#idle').textContent=c.idleTimeoutSeconds;
|
||
$('#app').hidden=false;
|
||
$('#identcard').hidden=!guiIdents;
|
||
applyIdents(c.identities||[]);
|
||
if(guiIdents) loadIdents();
|
||
refresh();
|
||
}
|
||
function applyIdents(list){
|
||
identities=list;
|
||
$('#ident').innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+
|
||
list.map(i=>`<option>${esc(i.name)}</option>`).join('');
|
||
}
|
||
function identFields(){
|
||
const ssh=$('#idt').value==='ssh';
|
||
$('#f-ssh').hidden=!ssh;$('#f-tok').hidden=ssh;
|
||
}
|
||
async function loadIdents(){
|
||
const r=await api('/api/identities');if(!r.ok)return;
|
||
const list=(await r.json()).identities||[];
|
||
applyIdents(list);
|
||
$('#identrows').innerHTML=list.map(i=>`<tr>
|
||
<td><code>${esc(i.name)}</code></td><td class="muted">${i.type}</td>
|
||
<td class="muted">${i.source}</td>
|
||
<td style="text-align:right">${i.source==='gui'
|
||
?`<button class="sec" style="width:auto;margin:0" onclick="delIdent('${esc(i.name)}')">削除</button>`
|
||
:'<span class="muted">Helm 管理</span>'}</td></tr>`).join('')
|
||
||'<tr><td class="muted">未登録</td></tr>';
|
||
}
|
||
async function addIdent(e){e.preventDefault();
|
||
const t=$('#idt').value;
|
||
const body={name:$('#idn').value.trim(),type:t};
|
||
if(t==='ssh'){body.privateKey=$('#idkey').value;body.knownHosts=$('#idkh').value;}
|
||
else{body.username=$('#idu').value.trim();body.token=$('#idtk').value;}
|
||
const r=await api('/api/identities',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
|
||
const j=await r.json();
|
||
if(!r.ok){alert(j.error||'保存に失敗しました');return false;}
|
||
$('#idn').value='';$('#idkey').value='';$('#idkh').value='';$('#idtk').value='';
|
||
loadIdents();return false;
|
||
}
|
||
async function delIdent(name){if(!confirm('identity '+name+' を削除しますか?'))return;
|
||
const r=await api('/api/identities/'+encodeURIComponent(name),{method:'DELETE'});
|
||
if(!r.ok){const j=await r.json();alert(j.error||'削除に失敗しました');}
|
||
loadIdents();}
|
||
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 showLog(id){
|
||
$('#logcard').hidden=false;$('#logid').textContent=id;
|
||
$('#logbox').textContent='読み込み中...';
|
||
const r=await api('/api/sessions/'+id+'/log');
|
||
$('#logbox').textContent=await r.text();
|
||
$('#logbox').scrollTop=$('#logbox').scrollHeight;
|
||
$('#logcard').scrollIntoView({behavior:'smooth'});
|
||
}
|
||
function esc(s){return (s||'').replace(/[&<>]/g,c=>({'&':'&','<':'<','>':'>'}[c]));}
|
||
async function refresh(){
|
||
const r=await api('/api/sessions');if(!r.ok)return;
|
||
const list=(await r.json()).sessions||[];
|
||
$('#rows').innerHTML=list.map(s=>{
|
||
let sub=esc(s.ref);
|
||
if(s.phase==='Failed'||s.reason){
|
||
const d=[s.reason,(s.exitCode!=null?'exit '+s.exitCode:''),esc(s.message)].filter(Boolean).join(' / ');
|
||
if(d) sub+=` — <span class="st-Failed">${d}</span>`;
|
||
}
|
||
const canOpen=s.phase==='Running';
|
||
return `<tr>
|
||
<td><code>${s.id}</code></td>
|
||
<td><span class="st-${s.phase}">${s.phase}</span></td>
|
||
<td style="word-break:break-all">${esc(s.repo)}<div class="muted">${sub}</div></td>
|
||
<td>${canOpen?`<a class="open" href="/s/${s.id}/" target="_blank" rel="noopener">開く</a> `:''}
|
||
<button class="sec" style="width:auto;margin:.3rem .3rem 0 0" onclick="showLog('${s.id}')">ログ</button>
|
||
<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": list_identities(),
|
||
"guiIdentities": GUI_IDENTITIES_ENABLED,
|
||
"idleTimeoutSeconds": IDLE_TIMEOUT,
|
||
"maxLifetimeSeconds": MAX_LIFETIME,
|
||
"maxSessions": MAX_SESSIONS,
|
||
"authMode": AUTH_MODE,
|
||
})
|
||
if path == "/api/identities":
|
||
if not token_ok(self):
|
||
return self._json(401, {"error": "unauthorized"})
|
||
return self._json(200, {"identities": list_identities()})
|
||
if path == "/api/sessions":
|
||
if not token_ok(self):
|
||
return self._json(401, {"error": "unauthorized"})
|
||
return self._json(200, {"sessions": list_sessions()})
|
||
m = re.match(r"^/api/sessions/([a-f0-9]{10})/log$", path)
|
||
if m:
|
||
if not token_ok(self):
|
||
return self._json(401, {"error": "unauthorized"})
|
||
status, text = session_log(m.group(1))
|
||
return self._send(status if status == 200 else 200,
|
||
text or "(ログはまだありません)", "text/plain; charset=utf-8")
|
||
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 + "/"})
|
||
if path == "/api/identities":
|
||
code, payload = create_gui_identity(self._body())
|
||
return self._json(code, payload)
|
||
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"})
|
||
path = urllib.parse.urlparse(self.path).path
|
||
m = re.match(r"^/api/sessions/([a-f0-9]{10})$", path)
|
||
if m:
|
||
delete_session(m.group(1))
|
||
return self._json(200, {"ok": True})
|
||
m = re.match(r"^/api/identities/([a-z0-9][a-z0-9-]{0,39})$", path)
|
||
if m:
|
||
code, payload = delete_gui_identity(m.group(1))
|
||
return self._json(code, payload)
|
||
return self._send(404, b'{"error":"not found"}')
|
||
|
||
|
||
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()
|