feat: git identity を Web UI から登録・削除できるようにする (v2.1.263-f)
Helm Chart Release / release-chart (push) Successful in 7s
Build Session Image / build-push (push) Successful in 2m5s

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 検証済み
This commit is contained in:
2026-09-09 12:47:50 +09:00
parent 6514747b4e
commit c31dd5875c
10 changed files with 345 additions and 15 deletions
+238 -12
View File
@@ -56,7 +56,9 @@ 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_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")
@@ -103,8 +105,13 @@ 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
@@ -173,6 +180,137 @@ def session_log(sid, tail=800):
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}
# --------------------------------------------------------------------------
# セッション操作
# --------------------------------------------------------------------------
@@ -273,7 +411,7 @@ def build_pod(sid, repo, ref, identity_name, owner):
{"name": "bootstrap", "mountPath": "/bootstrap", "readOnly": True},
]
ident = GIT_IDENTITIES.get(identity_name)
ident = resolve_identity(identity_name)
if ident:
env_list.append({"name": "GIT_IDENTITY_TYPE", "value": ident.get("type", "")})
if ident.get("type") == "ssh":
@@ -425,7 +563,7 @@ def create_session(repo, ref, identity_name, owner):
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 identity_name not in GIT_IDENTITIES:
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
@@ -586,6 +724,43 @@ pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-
</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-----&#10;..."></textarea>
<label for="idkh">known_hosts(必須)</label>
<textarea id="idkh" rows="2" style="width:100%;font-family:monospace;font-size:.8rem"
placeholder="ssh-keyscan &lt;host&gt; の出力を貼り付け"></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>
@@ -607,17 +782,55 @@ pre#logbox{white-space:pre-wrap;background:#111;color:#eee;padding:.8rem;border-
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();
identities=c.identities||[];
guiIdents=!!c.guiIdentities;
$('#idle').textContent=c.idleTimeoutSeconds;
const sel=$('#ident');
sel.innerHTML='<option value="">(認証なし / 公開リポジトリ)</option>'+identities.map(i=>`<option>${i}</option>`).join('');
$('#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('トークンが違います');}
@@ -722,12 +935,17 @@ class Handler(BaseHTTPRequestHandler):
if not token_ok(self):
return self._json(401, {"error": "unauthorized"})
return self._json(200, {
"identities": sorted(GIT_IDENTITIES.keys()),
"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"})
@@ -761,6 +979,9 @@ class Handler(BaseHTTPRequestHandler):
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)
@@ -770,11 +991,16 @@ class Handler(BaseHTTPRequestHandler):
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})
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():