# DNS + 通配 TLS 上线 runbook（3 环境）

> **module**: internal-app-platform
> **doc_type**: ops runbook
> **status**: ready to execute（等 IT 配 DNS + 签发通配证书）
> **owner**: lijian.dai → IT / 域名管理员
> **last_verified**: 2026-05-15

## 1. 环境拓扑（事实源）

| 环境 | 通配域名 | IP | 角色 |
|---|---|---|---|
| 测试 | `*.apps.ffworkspace.test.faradayfuturecn.com` | `170.106.161.71` | 平台开发者自用 |
| UAT  | `*.apps.ffworkspace.test.faradayfuture.com`    | `43.153.69.73`（与 FFOA UAT 共机）| 平台 staging 验证 |
| 生产 | `*.apps.ffworkspace.faradayfuture.com`         | `43.130.6.44`（与 FFOA 生产共机）  | **员工唯一感知环境** |

**员工只感知生产**：测试/UAT 不开放员工账号，仅平台开发者 + IT 自用验证；详见 [01-prd.md](./01-prd.md) 头部环境表与 [05-ui-interaction-spec.md §1](./05-ui-interaction-spec.md) 环境可见性。

## 2. 触发条件

任一即可：
- PoC 候选员工首次 onboarding 前
- 领导/HR 演示需要从公网用真域名访问
- 测试 → UAT → 生产 任一环境的拓扑就绪、需要切真域名

## 3. 工单模板（复制粘贴到 IT 工单系统 / Lark）

```
申请 3 套环境的通配 DNS + 通配证书 — internal-app-platform

【背景】
internal-app-platform（员工 AI 自助部署内部小工具）的 3 套环境拓扑：

  【测试】*.apps.ffworkspace.test.faradayfuturecn.com → 170.106.161.71
  【UAT】 *.apps.ffworkspace.test.faradayfuture.com   → 43.153.69.73
  【生产】*.apps.ffworkspace.faradayfuture.com        → 43.130.6.44

测试/UAT 用 ffworkspace.test.* 由国内 IT 批；生产用 ffworkspace.faradayfuture.com
需转美国 IT。

【需求】
每套环境各一条：
1. 通配 A 记录（按上表 域名 → IP），TTL 300（便于回滚）
2. 通配证书（覆盖对应通配域名）：
   - 按你们标准签发方式（公司内部 CA 长效证书优先），把 cert + key 文件给我，
     我自己挂到 Caddy
   - 不需要给我 DNS provider API token——内部 CA 的归属鉴权我们这边能配合做

【验证（IT 配完后我自己跑）】
  dig +short <某子>.apps.ffworkspace.faradayfuture.com A → 应返回对应 IP

【影响范围】
- 仅新增 3 条通配 A 记录 + 3 张通配证书，不影响现有任何记录
- 测试服 170.106.161.71、UAT 43.153.69.73、生产 43.130.6.44 已就绪
- 故障回滚 ≤ 5 分钟（DNS 撤记录 + Caddy 退回 HTTP）

【时间窗】
- TTL 短，工作日任意非高峰即可
- 不需停服务、不需通知员工

【联系】
- 主联系：lijian.dai
- 配置变更后我会在 30 分钟内验证并通报结果
```

## 4. DNS 验证（IT 配完后 lijian 自己跑）

```bash
# 等 TTL 后（≤ 5 min）
for domain in \
  example.apps.ffworkspace.test.faradayfuturecn.com \
  example.apps.ffworkspace.test.faradayfuture.com \
  example.apps.ffworkspace.faradayfuture.com; do
  echo "[$domain] $(dig +short $domain A)"
done

# 三个 resolver 命中（Cloudflare/Google/Quad9）
for resolver in 1.1.1.1 8.8.8.8 9.9.9.9; do
  for domain in \
    example.apps.ffworkspace.test.faradayfuturecn.com \
    example.apps.ffworkspace.test.faradayfuture.com \
    example.apps.ffworkspace.faradayfuture.com; do
    echo "[$resolver/$domain] $(dig @$resolver +short $domain A)"
  done
done
```

## 5. Caddy 挂载手工证书（每台服务器都跑一次）

> ⚠️ **2026-05-16 起本节在 Phase 1 三环境（test/UAT/生产）废弃**：三环境与 FFOA 主站共机，nginx 已占 :80/:443，
> 改走 [`12-nginx-caddy-coexist.md`](./12-nginx-caddy-coexist.md) 方案 B（nginx 终止 TLS，Caddy 走 127.0.0.1:8080 内网明文）。
> 本节内容保留供 dev 沙箱 `43.166.182.155`（Caddy 独占 :80/:443 的旧拓扑）历史参考。


拿到 IT 给的 `cert.pem` + `key.pem` 后：

```bash
# 1. 通过对应环境的 SSH 登入对应服务器
# 测试  ssh lijian@170.106.161.71
# UAT   ssh ubuntu@43.153.69.73
# 生产  ssh srvadmin@43.130.6.44
#
# 后续步骤每台服务器都跑一次（cert 内容因环境而异）。

# 2. 备份 Caddy state
sudo cp -a /srv/caddy /srv/caddy.bak-$(date +%s)

# 3. 放 cert + key（路径每环境一致，cert 内容不同）
sudo mkdir -p /srv/caddy/certs
sudo install -m 600 ~/uploaded-cert.pem /srv/caddy/certs/wildcard-apps.crt
sudo install -m 600 ~/uploaded-key.pem  /srv/caddy/certs/wildcard-apps.key

# 4. 切 Caddy scheme：从 http:// 切空，启用 HTTPS（不走 LE auto-HTTPS）
sudo tee /etc/profile.d/ffoa-caddy-scheme.sh >/dev/null <<EOF
export INTERNAL_APP_CADDY_SCHEME=
EOF

# 5. 改所有 site files：把 http:// 前缀去掉、加 tls 指令
for f in /srv/caddy/sites/*.caddy; do
  [[ -e "$f" ]] || continue
  sudo sed -i 's|^http://||' "$f"
  # 给每个 site 加 tls 指令（如果没有的话）
  if ! grep -q "^\s*tls\s" "$f"; then
    sudo sed -i '/^\s*reverse_proxy/i \    tls /srv/caddy/certs/wildcard-apps.crt /srv/caddy/certs/wildcard-apps.key' "$f"
  fi
done

# 6. Caddy 重载 + 验
docker exec ffoa-caddy caddy validate --config /etc/caddy/Caddyfile
docker exec ffoa-caddy caddy reload   --config /etc/caddy/Caddyfile

# 7. 验 HTTPS（替换为对应环境域名）
curl -sI https://example.apps.ffworkspace.faradayfuture.com | head -3
# 期望: HTTP/2 200 + 浏览器小锁正常（curl 不需 -k）
```

> **CI/CD 自动化（可选 Phase 1.5）**：把上述步骤包成 `scripts/ops/deploy-wildcard-cert.sh`，
> IT 续发证书时一条命令搞定。MVP 阶段手工即可。

## 6. 后续业务侧调整（每个环境都要做）

DNS + TLS 升好后 4 件配置改动：

### 6.1 Gitea webhook URL 改 HTTPS（每环境的 Gitea repo 不同）

```bash
# Gitea 当前对所有 ffoa-deploy 的 webhook target 都是 http://<IP>:3000/...
# 切到 https://<env-base>/api/v1/internal-apps/webhook/gitea
# 但 Gitea organization 是单一 FFAIApps —— 只有一个生产 webhook，测试/UAT 不连同一 org
```

实际只需改**生产**那条（员工 app 仓库都在 FFAIApps org，所有部署都进生产）：

```bash
TOKEN='<service token>'
curl -s -X PATCH -H "Authorization: token $TOKEN" -H "Content-Type: application/json" \
  http://43.130.59.228/api/v1/orgs/FFAIApps/hooks/1 \
  -d '{"config":{"url":"https://api.apps.ffworkspace.faradayfuture.com/api/v1/internal-apps/webhook/gitea","content_type":"json","secret":"<existing-secret>"}}'
```

> 同时给生产服务器的 Caddy 加 `api.apps.ffworkspace.faradayfuture.com → backend:3000` 的 site（如果 backend 不暴露在主 ffworkspace 域）。

### 6.2 backend `INTERNAL_APP_MCP_PUBLIC_URL` 改公网 URL

每个环境的 `.env` 设各自的值（**员工只用生产**这一行；测试/UAT 平台自测不发员工）：

| 环境 | `.env` 的 INTERNAL_APP_MCP_PUBLIC_URL |
|---|---|
| 测试 | `https://api.apps.ffworkspace.test.faradayfuturecn.com/api/v1/internal-apps/mcp` |
| UAT  | `https://api.apps.ffworkspace.test.faradayfuture.com/api/v1/internal-apps/mcp` |
| 生产 | `https://api.apps.ffworkspace.faradayfuture.com/api/v1/internal-apps/mcp` |

```bash
ssh <env-host> 'cd /srv/internal-apps/backend-repo/backend
sed -i "s|^INTERNAL_APP_MCP_PUBLIC_URL=.*|INTERNAL_APP_MCP_PUBLIC_URL=<env-url>|" .env
pm2 restart ffoa-backend --update-env'
```

### 6.3 frontend `NEXT_PUBLIC_API_URL`

如果前端站点也走 https://apps.ffworkspace.*，每环境对应配 `frontend/.env.local`：
```
NEXT_PUBLIC_API_URL=https://api.apps.ffworkspace.faradayfuture.com/api/v1   # 生产示例
```
然后 `npm run build && pm2 restart ffoa-frontend`。

### 6.4 删 PoC 临时演示路由

```bash
# 在测试服 170.106.161.71 上保留 demo route 即可；UAT/生产删
ssh <uat-or-prod> 'sudo rm -f /srv/caddy/sites/_demo-direct-ip.caddy
docker exec ffoa-caddy caddy reload --config /etc/caddy/Caddyfile'
```

裸 IP 在 UAT/生产改成显示一个"请用真域名"的引导页 / 或直接 404。

## 7. 回滚（DNS / 证书出问题时）

```bash
ssh <env-host> 'sudo cp -a /srv/caddy.bak-<timestamp> /srv/caddy
unset INTERNAL_APP_CADDY_SCHEME
# 改回所有 sites 加 http://
for f in /srv/caddy/sites/*.caddy; do
  grep -q "^http://" "$f" || sed -i "1s|^|http://|" "$f"
done
docker exec ffoa-caddy caddy reload --config /etc/caddy/Caddyfile'
# 同时 IT 把对应 *.apps.ffworkspace.* 记录撤回（或保留——Caddy http:// 模式不依赖 DNS）
```

## 8. 验收 checklist（每个环境分别过）

每个环境（测试 / UAT / 生产）独立过一遍：

- [ ] `dig` 三家 resolver 都解析到对应 IP
- [ ] `curl https://example.apps.ffworkspace.<env>` 返 200 / 404（取决于 site 是否存在）
- [ ] 一个真实 app `https://<emp>-<app>.apps.ffworkspace.<env>` 返真 app 内容
- [ ] 浏览器开 https URL，**地址栏小锁正常**（无证书警告 / 无 mixed content）
- [ ] **仅生产**：Gitea webhook 改 HTTPS 后真 push 触发 deploy
- [ ] **仅 UAT/生产**：撤 `_demo-direct-ip.caddy` 后裸 IP 不再返 app 内容

## 9. 证书续期（年度任务）

内部 CA 长效证书一般有效期 1-2 年。设一个 systemd timer（或 cron）每月跑一次 `openssl x509 -enddate -noout < /srv/caddy/certs/wildcard-apps.crt`，到期前 30 天写 alert 到 Discord，提醒申请新证书 + 重跑 §5。

```bash
# 检查脚本（保存到每台服务器 /srv/internal-apps/scripts/check-cert-expiry.sh）
EXP=$(openssl x509 -enddate -noout < /srv/caddy/certs/wildcard-apps.crt | sed 's/notAfter=//')
EXP_TS=$(date -d "$EXP" +%s)
NOW=$(date +%s)
DAYS_LEFT=$(( (EXP_TS - NOW) / 86400 ))
if [ $DAYS_LEFT -lt 30 ]; then
  curl -X POST $DISCORD_WEBHOOK -d "{\"content\":\"⚠️ wildcard cert expires in $DAYS_LEFT days on $(hostname)\"}"
fi
```

## 10. 相关文档

- [01-prd.md](./01-prd.md) — 头部环境拓扑表 + 设计原则（员工只感知生产）
- [03-architecture §5.1](./03-architecture.md) — DNS/TLS 三档降级表
- [05-ui-interaction-spec.md §1](./05-ui-interaction-spec.md) — 菜单环境可见性
- [08-phase-0-poc-runbook](./08-phase-0-poc-runbook.md) — Phase 0 期间 Host header / hosts 临时方案
- `.learnings/2026-05-14-caddy-auto-https-308-without-dns.md` — Caddy auto-HTTPS 坑
- `.learnings/2026-05-14-uat-git-fetch-refspec-restricted.md` — UAT git config refspec 单分支锁死
