# 备份策略与运维

> 适用范围：FFOA 所有需要异地备份的源（Gitea / 应用 DB / 上传文件 / …）
> 上游：工单 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310)
> 实施日期：2026-05-13

---

## 1. 模型：Pull-based 集中备份枢纽

```
                         ┌─────────────────────────────────┐
                         │  备份枢纽 43.166.182.155 (offsite-1) │
                         │  backup-hub system user             │
                         │  cron: 30 4 * * *                   │
                         │  /opt/backup-hub/bin/backup-all.sh  │
                         │  /backups/<source>/   (异地 30d)    │
                         │  /etc/backup-hub/keys/<source>_backup│
                         └─────────┬───────────────────────────┘
                                   │ SSH key auth + ForceCommand
                                   │ ① 触发 dump
                                   │ ② rsync --server --sender (read-only)
                                   ▼
              ┌──────────────────────┬─────────────────────┐
              │                      │                     │
        ┌─────────────┐      ┌─────────────┐      ┌─────────────┐
        │ Gitea       │      │ FFAI Prod   │      │ AIxC Prod   │
        │ 43.130.59.228│      │ 43.130.6.44 │      │ ...         │
        │ /var/backups/│      │ (planned)   │      │ (planned)   │
        │  gitea/(7d) │      │             │      │             │
        └─────────────┘      └─────────────┘      └─────────────┘
```

### 1.1 为什么选 pull 不选 push

| 维度 | Pull（采用） | Push |
|---|---|---|
| 密钥位置 | 枢纽持所有源机 key；源机无 outbound 凭据 | 每台源机持自己 key |
| 源机被攻破后果 | 攻击者拿不到任何东西 | 攻击者可往枢纽写脏数据/删旧备份 |
| 加新源 | **只改枢纽** | 改源机 + 改枢纽两边 |
| 集中日志 | 枢纽一处 | 散在各源机 |
| 枢纽被攻破后果 | 持所有源 read key（但只能读，加 ForceCommand 后连 shell 都进不去） | 影响小（仅能写） |

权衡：pull 把"信任集中点"挪到枢纽，再用 ForceCommand 把"信任"限制到最小（只能跑特定备份命令），是更优的安全模型。

### 1.2 不变量

1. **枢纽机不持有任何业务凭据**（只持各源的 backup read key）
2. **每个 source 用独立系统用户 + 独立 keypair**，互不通用
3. **源机的 `authorized_keys` 必须用 `command="..."` 强制走 dispatcher**，dispatcher 用 case 白名单匹配 `$SSH_ORIGINAL_COMMAND`，只放行 `trigger-dump` 和 `rsync --server --sender . /var/backups/<source>/*`
4. **dispatcher 的 `log()` 不能用 sudo**——会导致 `set -e` 在 exec rsync 之前退出（详见 [.learnings/2026-05-13-ssh-forced-command-log-sudo-trap.md](../../.learnings/2026-05-13-ssh-forced-command-log-sudo-trap.md)）
5. **每个 source 在源机本地保留 7 天，枢纽保留 30 天**（双副本错开窗口）
6. **备份时间错开应用 DB 备份的 03:00 一小时以上**（Gitea 主机 1.9G 内存极紧，并发会 OOM）

---

## 2. 当前已接入：Gitea (43.130.59.228)

| 项 | 值 |
|---|---|
| 源机用户 | `gitea-backup`（system uid 996，shell `/bin/bash` 但被 ForceCommand 锁死） |
| 源机 dispatcher | `/usr/local/bin/backup-dispatch.sh` |
| 源机 sudoers | `/etc/sudoers.d/gitea-backup` 授权 `(git) NOPASSWD: gitea dump *, chmod 640 /var/backups/gitea/*` |
| 源机备份目录 | `/var/backups/gitea/` （chmod 770, owner `gitea-backup:git`，本地保留 7 天） |
| 源机日志 | `/var/log/backup-dispatch.log`（owner `gitea-backup:adm` chmod 664）|
| 枢纽 key | `/etc/backup-hub/keys/gitea_backup`（ed25519） |
| 枢纽落点 | `/backups/gitea/`（异地保留 30 天） |
| 调度 | 备份枢纽 `crontab -u backup-hub` `30 4 * * *` |
| 实测耗时 | dump 17s + rsync 44s = ~1 min |
| 实测体积 | 单份约 456 MB |
| 实测内存峰值（Gitea 主机）| 基线 +35 MB（毫无压力） |

### 2.1 端到端时序

```
04:30:00  cron 触发 backup-all.sh
          │
          ├─ SSH backup-hub → gitea-backup@43.130.59.228 "trigger-dump"
          │  └─ dispatcher 跑 `sudo -u git gitea dump …` → /var/backups/gitea/gitea-dump-YYYYMMDD-HHMM.tar.gz
          │     `sudo -u git chmod 640` 让 gitea-backup（同 git group）能读
          │     find -mtime +7 -delete 清本地老的
          │
          └─ rsync -a 拉 /var/backups/gitea/ → /backups/gitea/
             dispatcher 的 ForceCommand 把 rsync --sender 透传给系统 rsync

04:31:?? 备份枢纽 find /backups -mtime +30 -delete 清异地老的
```

---

## 3. 加新 source 的 SOP

> 以 "FFAI Prod DB" 为例（`43.130.6.44`），实际名字按 source 替换。

### 3.1 枢纽机（43.166.182.155）操作

```bash
# 1. 生成 keypair
sudo -u backup-hub ssh-keygen -t ed25519 \
  -f /etc/backup-hub/keys/ffws_pro_db_backup -N '' \
  -C "backup-hub@offsite-1 -> ffws-pro-db-backup@43.130.6.44"

# 2. 拿公钥
sudo cat /etc/backup-hub/keys/ffws_pro_db_backup.pub

# 3. 建落点
sudo install -d -o backup-hub -g backup-hub -m 750 /backups/ffws-pro-db
```

### 3.2 源机操作（这里是 FFAI Prod）

```bash
# 1. 建用户 + 落点
sudo useradd -r -s /bin/bash -d /var/lib/ffws-pro-db-backup -m ffws-pro-db-backup
sudo install -d -o ffws-pro-db-backup -g <db-group> -m 770 /var/backups/ffws-pro-db
sudo usermod -aG <db-group> ffws-pro-db-backup  # 让 backup 用户能读 dump

# 2. dispatcher（模板见下方 §3.4）
sudo install -m 755 /tmp/backup-dispatch-ffws-pro-db.sh /usr/local/bin/

# 3. sudoers（仅 dump 命令）
sudo tee /etc/sudoers.d/ffws-pro-db-backup >/dev/null <<'SUDO'
ffws-pro-db-backup ALL=(postgres) NOPASSWD: /usr/bin/pg_dump *, /usr/bin/pg_dumpall *
ffws-pro-db-backup ALL=(postgres) NOPASSWD: /bin/chmod 640 /var/backups/ffws-pro-db/*
SUDO
sudo chmod 440 /etc/sudoers.d/ffws-pro-db-backup
sudo visudo -c -f /etc/sudoers.d/ffws-pro-db-backup

# 4. authorized_keys
sudo -u ffws-pro-db-backup mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo 'command="/usr/local/bin/backup-dispatch-ffws-pro-db.sh",no-pty,no-port-forwarding,no-X11-forwarding,no-agent-forwarding <pubkey>' \
  | sudo -u ffws-pro-db-backup tee ~/.ssh/authorized_keys
sudo chmod 600 ~ffws-pro-db-backup/.ssh/authorized_keys
```

### 3.3 在枢纽 `backup-all.sh` 加段

```bash
# ─── FFAI Prod DB ──────────────────────────────────────
{
  log "=== ffws-pro-db: trigger-dump ==="
  ssh -i /etc/backup-hub/keys/ffws_pro_db_backup \
      -o StrictHostKeyChecking=accept-new -o ConnectTimeout=15 \
      ffws-pro-db-backup@43.130.6.44 trigger-dump \
    && log "ffws-pro-db: dump triggered" \
    || fail "ffws-pro-db: trigger-dump failed"

  rsync -a --stats \
    -e "ssh -i /etc/backup-hub/keys/ffws_pro_db_backup -o ConnectTimeout=15" \
    ffws-pro-db-backup@43.130.6.44:/var/backups/ffws-pro-db/ /backups/ffws-pro-db/ \
    >> "$LOG" 2>&1 \
    && log "ffws-pro-db: pull ok" \
    || fail "ffws-pro-db: rsync failed"
} || true
```

### 3.4 dispatcher 模板（**禁止改 log 函数加 sudo**）

```bash
#!/bin/bash
set -e
LOG=/var/log/backup-dispatch-<source>.log
log() { echo "[$(date -Iseconds)] [$USER] $*" >> "$LOG"; }
# ↑ 不要用 sudo tee。日志文件必须 chown 给本用户，让 echo >> 直接能写。

case "$SSH_ORIGINAL_COMMAND" in
  'trigger-dump')
    log "trigger-dump start"
    TS=$(date +%Y%m%d-%H%M)
    OUT=/var/backups/<source>/<source>-dump-${TS}.<ext>
    sudo -u <runtime-user> <dump-command> --output="$OUT"
    sudo -u <runtime-user> /bin/chmod 640 "$OUT"
    log "trigger-dump ok: $OUT"
    echo "$OUT"
    find /var/backups/<source> -name '<source>-dump-*' -mtime +7 -delete 2>/dev/null || true
    ;;
  'rsync --server --sender '*' . /var/backups/<source>/'*)
    log "rsync pull"
    exec $SSH_ORIGINAL_COMMAND
    ;;
  *)
    log "DENIED: $SSH_ORIGINAL_COMMAND"
    echo 'denied: command not allowed' >&2
    exit 1
    ;;
esac
```

### 3.5 验证

```bash
# 枢纽机上手工跑
sudo -u backup-hub /opt/backup-hub/bin/backup-all.sh
sudo ls -lah /backups/<source>/
# 安全屏障测试
sudo -u backup-hub ssh -i /etc/backup-hub/keys/<source>_backup \
  <source>-backup@<source-host> "ls /"  # 应返回 "denied: command not allowed"
```

---

## 4. 月度验证 SOP

每月 13 号检查一次（首次演练日 2026-05-13），**不验证 = 备份不算完成**。

### 4.1 沙箱启动 guards（必须满足才启动 gitea）

下次月度验证前 **scripts/ops/gitea-restore-drill.sh**（TODO 待新建）应做：

```bash
# 沙箱 app.ini 必须含的关键安全配置
grep -q '^INSTALL_LOCK\s*=\s*true'   "$APPINI" || die "INSTALL_LOCK missing — 会落回 install 模式"
grep -q '^HTTP_ADDR\s*=\s*127\.0\.0\.1' "$APPINI" || die "HTTP_ADDR 不是 127.0.0.1 — 公网暴露风险"
grep -q '^DISABLE_SSH\s*=\s*true'    "$APPINI" || die "DISABLE_SSH missing"
grep -q '^OFFLINE_MODE\s*=\s*true'   "$APPINI" || die "OFFLINE_MODE missing"

# 防火墙兜底，不依赖云安全组
sudo iptables -I INPUT -p tcp --dport 33000 ! -s 127.0.0.1 -j DROP
```

### 4.2 流程

1. 抓最近一份 dump → 异地辅助机 `/tmp/gitea-drill/`
2. 解压 + 复制（`cp -a extracted/repos/. work/repos/`，**注意一次性 clean cp，多次叠加会把目录扁平化**）
3. scp 沙箱 app.ini 到 work（**不要用嵌套 ssh + heredoc 写文件**——容易丢内容）
4. 跑 guards 校验（§4.1）
5. 启动 gitea on `127.0.0.1:33000`
6. **用 admin token**（`gitea admin user generate-access-token --username admin --scopes all`）做 API 对比；**别用日常 `$GITEA_API_TOKEN`**（非 admin 看不到所有 org）
7. 对比矩阵：所有 repo 的 HEAD SHA + size + open_issues + 关键 issue body SHA256
8. **dual-check**：API 校 + SQL 直接 count 一遍（`SELECT count(*) FROM repository; FROM issue; FROM \"user\";`）
9. teardown：`pkill gitea && rm -rf /tmp/gitea-drill/`
10. 在 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310) 评论记日期 + 验证矩阵结果

### 4.3 历次演练记录

| 日期 | dump 时间戳 | 结果 | issue 评论 |
|---|---|---|---|
| 2026-05-13 | `gitea-dump-20260513-1704.tar.gz` | ✅ 4/4 repos HEAD SHA 全等 | [#2948](http://43.130.59.228/FFAIWorkspace/workspace/issues/310#issuecomment-2948) |
| 2026-06-13 | TBD | TBD | TBD |

### 4.4 不通过的处理

立刻禁用 cron（`crontab -e -u backup-hub` 注释行），定位是 dump 还是 rsync 还是落点损坏，修复后重新跑端到端验证才能恢复 cron。

---

## 5. 灾难恢复演练

### 5.1 Gitea 主机全挂

```bash
# 1. 新机 / 旧机重装 Gitea binary + create gitea user/group
# 2. 从枢纽机拉最新 dump
ssh ubuntu@<new-gitea> sudo mkdir -p /restore
rsync -av /backups/gitea/gitea-dump-<latest>.tar.gz ubuntu@<new-gitea>:/restore/
# 3. 解压 + 按 Gitea 官方 restore 流程
#    https://docs.gitea.com/administration/backup-and-restore
ssh ubuntu@<new-gitea> "cd /restore && sudo tar xzf gitea-dump-*.tar.gz"
ssh ubuntu@<new-gitea> sudo -u git gitea restore ...
```

### 5.2 枢纽机挂了

- 影响：当天 cron 不会跑，源机本地仍有 7 天份额可临时手工拉
- 恢复：新机 / 重装枢纽 → 重跑 [§ 3.1 枢纽机操作] 为每个 source 重新生成 key → 通知源机更新各自 `authorized_keys`
- 教训：**异地保留的 30 天份额本身就是源机+枢纽双挂时的最后兜底**

---

## 6. 相关链接

- 上游 saga: [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310) / [#273](http://43.130.59.228/FFAIWorkspace/workspace/issues/273)
- 枢纽机文档：[`01-server-infrastructure.md § 3.5`](./01-server-infrastructure.md#35-异地辅助机offsite-1)
- Gitea 备份 SOP：[`02-gitea-config.md § 8`](./02-gitea-config.md#8-gitea-数据备份)
- 实施踩坑：[`.learnings/2026-05-13-ssh-forced-command-log-sudo-trap.md`](../../.learnings/2026-05-13-ssh-forced-command-log-sudo-trap.md)
