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

---

## 首席架構師 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**: `StrictHostKeyChecking=no`（內網環境可接受，未來加 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`
