# 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:<component>`

白名單腳本接受 `repair:<component>` 格式，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`
