diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 63b318bd7..d0b9dfb52 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2113,12 +2113,28 @@ jobs: # 後續 patch 推進缺漏的 known_hosts → asyncssh 拒所有 SSH。 # 修法:scan 完驗證所有 allowlisted 主機都在;缺任何一台就 abort, # 不能覆蓋現有 secret,防止 production SSH 自動修復路徑癱瘓。 - ssh-keyscan 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 > /tmp/known_hosts_repair 2>/tmp/known_hosts_scan_err || true + : > /tmp/known_hosts_repair + : > /tmp/known_hosts_scan_err EXPECTED_HOSTS=5 PRESENT=0 + for scan_attempt in 1 2 3; do + ssh-keyscan -T 5 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 \ + >> /tmp/known_hosts_repair 2>>/tmp/known_hosts_scan_err || true + sort -u /tmp/known_hosts_repair -o /tmp/known_hosts_repair + PRESENT=0 + for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do + if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then + PRESENT=\$((PRESENT + 1)) + fi + done + if [ "\$PRESENT" -eq "\$EXPECTED_HOSTS" ]; then + break + fi + sleep "\$scan_attempt" + done for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then - PRESENT=\$((PRESENT + 1)) + : else echo "⚠️ ssh-keyscan 缺主機 \${ip}" fi @@ -2137,10 +2153,27 @@ jobs: || { echo "❌ ssh-mcp-key known_hosts 更新失敗,停止部署"; exit 1; } rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err else - echo "❌ ssh-keyscan 只抓到 \${PRESENT}/\${EXPECTED_HOSTS} 台主機,跳過 patch(保留現有 secret)" - cat /tmp/known_hosts_scan_err 2>/dev/null | head -10 - rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err - exit 1 + # A transient guest SSH outage must not overwrite trust data, but it + # also must not blindly green-light the deploy. Decode only on the + # K3s controller, verify all five public host entries, then delete + # the temporary file without printing its contents. + EXISTING_KNOWN_HOSTS=/tmp/known_hosts_existing + EXISTING_PRESENT=0 + if \$KUBECTL get secret ssh-mcp-key -n awoooi-prod \ + -o jsonpath='{.data.known_hosts}' | base64 -d > "\$EXISTING_KNOWN_HOSTS"; then + for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do + if grep -qE "^\${ip}[[:space:]]" "\$EXISTING_KNOWN_HOSTS" 2>/dev/null; then + EXISTING_PRESENT=\$((EXISTING_PRESENT + 1)) + fi + done + fi + rm -f "\$EXISTING_KNOWN_HOSTS" /tmp/known_hosts_repair /tmp/known_hosts_scan_err + if [ "\$EXISTING_PRESENT" -eq "\$EXPECTED_HOSTS" ]; then + echo "⚠️ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key verified 5/5 and preserved" + else + echo "❌ fresh host-key scan \${PRESENT}/\${EXPECTED_HOSTS}; existing ssh-mcp-key coverage \${EXISTING_PRESENT}/\${EXPECTED_HOSTS}" + exit 1 + fi fi echo "✅ 所有 Secrets 注入完成" diff --git a/apps/api/tests/test_cd_known_hosts_recovery_gate.py b/apps/api/tests/test_cd_known_hosts_recovery_gate.py new file mode 100644 index 000000000..10d612c2f --- /dev/null +++ b/apps/api/tests/test_cd_known_hosts_recovery_gate.py @@ -0,0 +1,20 @@ +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = ROOT / ".gitea/workflows/cd.yaml" + + +def test_cd_known_hosts_gate_retries_and_verifies_preserved_secret() -> None: + workflow = WORKFLOW.read_text(encoding="utf-8") + gate = workflow[workflow.index(": > /tmp/known_hosts_repair") :] + gate = gate[: gate.index('echo "✅ 所有 Secrets 注入完成"')] + + assert "for scan_attempt in 1 2 3" in gate + assert 'sleep "\\$scan_attempt"' in gate + assert "existing ssh-mcp-key verified 5/5 and preserved" in gate + assert "EXISTING_PRESENT=\\$((EXISTING_PRESENT + 1))" in gate + assert 'if [ "\\$EXISTING_PRESENT" -eq "\\$EXPECTED_HOSTS" ]' in gate + assert 'rm -f "\\$EXISTING_KNOWN_HOSTS"' in gate + assert "cat /tmp/known_hosts_scan_err" not in gate + assert "existing ssh-mcp-key coverage" in gate