# ADR-058: Host Auto-Repair SSH 白名單架構 **狀態**: 已接受 **日期**: 2026-04-05 (台北時區) **起草**: Claude Code (Sprint 3 Host Auto-Repair) **審查**: 首席架構師 (同日) --- ## 背景 Phase O 可觀測性補完後,系統能偵測主機層服務異常(SentryDown、HarborDown、GiteaDown 等),但缺少從 K8s API Pod 向主機層發送修復命令的能力。需要一個安全、可審計的機制讓 AWOOOI API 透過 SSH 執行受限的修復操作。 --- ## 決策 ### D1 — 使用 SSH `command=` forced command 白名單 在目標主機的 `authorized_keys` 中使用 `command=` 選項,強制所有使用特定 key 的 SSH 連線只能執行指定的白名單腳本,無論呼叫方送出什麼命令。 ``` command="/home/wooo/bin/repair-bot-110.sh",no-pty,no-agent-forwarding ssh-ed25519 ... ``` **優點**: - SSH key 洩漏也只能執行白名單操作(最小權限) - 不需要在 API Pod 中安裝任何 agent - 完整日誌記錄(repair-bot 寫 `~/.repair-bot.log`) ### D2 — 命令格式:`repair:` 白名單腳本接受 `repair:` 格式,component 對應預設的修復動作(docker compose up -d 或 systemctl restart)。 Regex 兩端統一:`^repair:([a-z0-9][a-z0-9-]{0,30})$` ### D3 — SSH Key 儲存在 K8s Secret,掛載到 API Pod ```yaml volumes: - name: repair-ssh-key secret: secretName: awoooi-repair-ssh-key defaultMode: 0400 # 八進位 0400 = 十進位 256 = r-------- ``` 掛載路徑:`/etc/repair-ssh/id_ed25519` ### D4 — HostRepairAgent 透過 ActionType.SSH_COMMAND 整合到 Playbook 系統 ``` Prometheus/SigNoz 告警 → webhook → auto_repair_service → 比對 Playbook (symptom_pattern) → _execute_step(ActionType.SSH_COMMAND) → HostRepairAgent.repair(layer, component) → SSH forced command → repair-bot.sh → docker compose up -d ``` ### D5 — layer 路由隔離 | Layer | 主機 | 使用者 | 修復類型 | |-------|------|--------|---------| | `docker-110` | 192.168.0.110 | wooo | Docker Compose | | `docker-188` | 192.168.0.188 | ollama | Docker Compose | | `systemd-188` | 192.168.0.188 | ollama | systemd | | `k8s` | — | — | kubectl(不走 SSH,拒絕) | --- ## 已批准的修復白名單 ### 110 主機 (repair-bot-110.sh) | Component | 修復方式 | 目錄 | |-----------|---------|------| | sentry | docker compose up -d | /opt/sentry | | harbor | docker compose up -d | /home/wooo/harbor/harbor | | gitea | docker compose up -d | /home/wooo/gitea | | gitea-runner | docker compose up -d | /home/wooo/act-runner | | langfuse | docker compose up -d | /home/wooo/langfuse | | alertmanager | docker compose up -d | /home/wooo/monitoring | | signoz | docker compose up -d | /home/wooo/signoz/deploy/docker | | stock-platform | docker compose up -d | /home/wooo/stockPlatform *(2026-04-09 新增)* | ### 188 主機 (repair-bot-188.sh) | Component | 修復方式 | 目錄 | |-----------|---------|------| | openclaw | docker compose up -d | /home/ollama/clawbot-v5 | | minio | docker compose up -d | /home/ollama/minio | | signoz | docker compose up -d | /home/ollama/signoz/deploy/docker | | momo-app | docker compose up -d | /home/ollama/momo-pro *(2026-04-09 新增)* | | tsenyang-website | docker compose up -d | /home/ollama/services/tsenyang *(2026-04-09 新增)* | | bitan-app | docker compose up -d | /home/ollama/services/bitan *(2026-04-09 新增)* | --- ## Appendix A — Bug 修復記錄 (2026-04-09) > **執行者**: Claude Sonnet 4.6 (Asia/Taipei) > **背景**: 自動修復機制從未成功執行(success_count 全部為 0),完整審計後發現下列阻斷性問題 | Bug | 問題描述 | 修復 | Commit | |-----|---------|------|--------| | #5 | `target_resource` 用 `instance` (IP:port) 代替 `component` label → Jaccard 服務相似度為 0 | `webhooks.py` 優先取 `component` label | 1fb0c0c | | #6 | `python:3.11-slim` 無 `openssh-client`,`ssh` binary 缺失 | Dockerfile 生產 stage 加 `openssh-client` | 1fb0c0c | | #11 | NetworkPolicy 未開放 22 (SSH),預設拒絕所有未列 port | `02-network-policy.yaml` 新增 110:22 + 188:22 egress | 07a097c | | #12 | Secret `defaultMode=0400` (root-only),appuser (UID 1000) 無法讀 SSH key | Pod `securityContext.fsGroup: 1000` | 77f2da9 | ### E2E 驗證結果 ``` # docker-110 SentryDown → Jaccard 匹配 sentry-down-repair → SSH wooo@192.168.0.110 repair:sentry → REPAIR_OK:sentry (6208ms) ✅ # docker-188 MoWoooWorkDown → Jaccard 匹配 momo-app-down-repair → SSH ollama@192.168.0.188 repair:momo-app → REPAIR_OK:momo-app (3791ms) ✅ ``` ### Playbook 覆蓋矩陣 (2026-04-09,20 個 Playbook) | 告警 | Playbook | Layer | success_count | |------|---------|-------|---------------| | SentryDown | sentry-down-repair | docker-110 | 1 ✅ | | HarborDown | harbor-down-repair | docker-110 | 0 | | GiteaDown | gitea-down-repair | docker-110 | 0 | | AlertmanagerDown | alertmanager-down-repair | docker-110 | 0 | | OpenClawDown | openclaw-down-repair | **docker-188** | 0 | | MoWoooWorkDown | momo-app-down-repair | docker-188 | 2 ✅ | | TsenyangWebsiteDown | tsenyang-website-down-repair | docker-188 | 0 | | BitanWoooWorkDown | bitan-app-down-repair | docker-188 | 0 | | StockWoooWorkDown | stock-platform-down-repair | docker-110 | 0 | | SignOzDown | signoz-down-repair | docker-188 | 0 | | DockerContainerExited | docker-container-exited-repair | 動態 | 0 | | DockerContainerUnhealthy | docker-container-unhealthy-repair | 動態 | 0 | | KubePodNotReady | k8s-pod-not-ready-restart | k8s | 0 | > **注意**: `openclaw-down-repair` 原錯誤指向 `docker-110`,已於 2026-04-09 修正為 `docker-188` --- ## Appendix B — Backup Failure Route Parity (2026-05-01) `HostBackupFailed` / backup 類告警的 `alert_category` 是 `backup_failure`。它必須在所有 host-layer 自動化路徑與 `host_resource` 同級處理: | Layer | 必須行為 | |-------|----------| | Alertmanager rule-first | YAML `SSH_DIAGNOSE` / `NO_ACTION` 不進 LLM 覆寫 | | AutoRepairService | `backup_failure` 視為 host-layer,拒絕 K8s Playbook fallback | | DecisionManager | 非 `kubectl` 動作在 kubectl parser 前路由 SSH MCP | | DecisionManager K8s guard | `backup_failure` 產生 `kubectl` 時降級 emergency escalation | | Telegram buttons | `backup_failure` 顯示只讀診斷按鈕:主機磁碟、備份 Job、Velero 狀態 | 2026-05-01 根因:DecisionManager SSH route 只含 `infrastructure` / `host_resource`,漏掉 `backup_failure`,導致 `ssh 192.168.0.110 '...;...'` 類只讀診斷動作掉進 `parse_kubectl_action()`,被 `forbidden_shell_metachar` 擋下。 同日按鈕審計也發現 category button 的 friendly provider name 會漂移:`callback_action_spec.yaml` 使用 `k8s` / `ssh`,但 MCP registry 實際名稱是 `kubernetes` / `ssh_host`。Dispatcher 必須正規化 provider alias,否則卡片按鈕會顯示但執行時變成 `provider_not_found`。 2026-05-01 第二輪 live 驗證補充:`ssh_diagnose` 必須是 SSHProvider 的正式 read-only tool;`DecisionManager` 不可呼叫 provider 未註冊工具,也不可把診斷成功當自動修復完成。SSH 執行失敗必須保留 `READY`、標記 `mcp_all_failed=True`,並送 emergency intervention。 ### Runtime 權限基準 - K8s Secret: - `awoooi-repair-ssh-key` 掛載 `/etc/repair-ssh/` - `awoooi-repair-known-hosts` 掛載 `/etc/repair-known-hosts/` - `ssh-mcp-key` 掛載 `/run/secrets/ssh_mcp_key` 與 `/etc/ssh-mcp/known_hosts` - Remote `authorized_keys`: - `wooo@192.168.0.110` - `wooo@192.168.0.120` - `wooo@192.168.0.121` - `ollama@192.168.0.188` - Runtime config: - `SSH_MCP_HOST_USERS=192.168.0.188=ollama` - Runtime known_hosts: - `ssh-mcp-key.data.known_hosts` must be non-empty and mounted at `/etc/ssh-mcp/known_hosts` - Because the file is mounted with `subPath`, updating the Secret requires rolling API/worker pods before asyncssh sees the new trust store - NetworkPolicy egress: - `192.168.0.110:22` - `192.168.0.120:22` - `192.168.0.121:22` - `192.168.0.188:22` - `awoooi-executor` RBAC: - read `jobs.batch`, `cronjobs.batch` - read `persistentvolumeclaims` - read Velero `backups`, `backupstoragelocations`, `backuprepositories`, `podvolumebackups`, `podvolumerestores`, `restores`, `schedules` - patch `statefulsets.apps` / `daemonsets.apps` only for safe rollout restart If SSH MCP fails, the incident must not silently become a manual approval card; it must raise the emergency intervention path with the exact SSH failure reason when available. --- ## Appendix C — LLM Ghost Loop Controls (2026-05-01) Alertmanager 重複 delivery、修復失敗後告警仍 firing、以及 provider timeout/500 會形成「告警 → LLM → 修復失敗 → 20 秒後再 LLM」的鬼循環。這不是 AI 訂閱費用問題,而是飛輪收斂問題。 ### 必須維持的控制 | 控制 | 行為 | |------|------| | Alertmanager in-flight lock | 同一 fingerprint 排入背景 LLM 前必須取得 Redis `alertmanager:llm_inflight:{fingerprint}`,TTL 10 分鐘 | | Stable LLM cache key | 有 alert context 時,OpenClaw cache key 不得使用完整動態 prompt;必須使用 prompt family + stable alert dimensions | | No retry storm | Alertmanager webhook 已完成來源驗證後,內部處理錯誤應回 2xx degraded accepted,不回 500 觸發 Alertmanager 重試 | | Learning after blocked automation | executor 後成功/失敗必須寫 KM;executor 前 guard block 也應優先沉澱成 KM/AOL/timeline,避免下一次同告警重跑完整 LLM | | Paid provider policy | Gemini/Groq/Claude/OpenRouter 只能作為 rate-limited fallback 或高複雜度專家路徑;不得用付費 provider 掩蓋 dedupe/cache/retry 問題 | ### 健康狀態判準 - 同一 fingerprint 10 分鐘內最多一個背景 LLM analysis task。 - 重複告警應命中 DB convergence、Redis in-flight lock、rule-first、Playbook/KM/RAG、或 LLM cache 其中一層。 - 外部 provider 用量應接近「新問題數」而不是「告警 delivery 數」。 - HTTP 500 比例不得由已接收告警的後處理錯誤造成;若有 500,需先確認是否在來源驗證/反序列化前失敗。 --- ## 首席架構師 Review 記錄 (2026-04-05) 評分:**72/100 → 修正後 88/100** 已修正問題: - **C1**: `_ssh_execute` key_path 改為直接傳入,不反查 LAYER_SSH_CONFIG - **C2**: `PlaybookService.create()` proxy 方法,Router 不直接呼叫 `_repository` - **C3**: CD Step 1b `sed` 替換 IMAGE_TAG_PLACEHOLDER,消除中斷風險 - **M3**: repair-bot regex 統一為 `[a-z0-9][a-z0-9-]{0,30}`,禁止底線 - **m1**: defaultMode 加八進位說明注釋 - **m2**: `_ssh_execute` 用 deadline 計算剩餘 timeout 待觀察問題(已知、可接受): - **M1**: 測試使用 AsyncMock(豁免原因:`_ssh_execute` 是真實 subprocess,無法在 CI 無 SSH 環境直接測試;已透過 E2E 驗收補充覆蓋) - **M2**: 短期使用 `accept-new`,後續改為 pinned known_hosts ConfigMap --- ## 安全注意事項 1. **SSH key 輪換**:若 `awoooi-repair-ssh-key` 洩漏,立即執行: - 110: `sed -i '/repair-bot/d' ~/.ssh/authorized_keys` - 188: `sed -i '/repair-bot/d' ~/.ssh/authorized_keys` - 重建 keypair 並更新 K8s Secret 2. **修復命令只能 `docker compose up -d` 或 `systemctl restart`**,無法執行任意命令 3. **日誌位置**:`~/.repair-bot.log`(各主機各自記錄) --- ## 相關文件 - `scripts/repair-bot/repair-bot-110.sh` - `scripts/repair-bot/repair-bot-188.sh` - `apps/api/src/services/host_repair_agent.py` - `k8s/awoooi-prod/04-repair-ssh-key-template.yaml` - `ops/monitoring/alerts-unified.yml`