---
date: 2026-05-13
tags: [gitea, backup, restore, drill, sandbox, security]
status: documented
---

# Gitea 恢复演练的 4 个坑（差点误报 + 差点公网泄露）

实施完工单 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310) 备份后，做了一次完整恢复演练（同机沙箱），踩到 4 个不直观的坑。沉淀下来，下次月度验证（每月 13 号）少走弯路。

## 1. `gitea dump` 的 `repos/` 用 lowercase 路径

Gitea 数据库 `repository` 表的 `owner_name` 字段保留原始大小写（`CT-Private`、`FFAIWorkspace`），但 dump tarball 里的 `repos/` 目录用 **lowercase**：

```
extracted/repos/ct-private/mybrain.git/      # ← 小写
extracted/repos/ffaiworkspace/workspace.git/
```

生产硬盘的 `/var/lib/gitea/data/git/repositories/` 也是 lowercase——Gitea 按 `lower_name` 匹配文件路径，不是 bug。

**陷阱**：第一次以为 `ct-private/MyBrain.git` 找不到是恢复出错，其实是 case 默认行为。**不要在恢复脚本里改名**。

## 2. 完整性校验必须用 `is_admin=1` 的 token，非 admin 会少算 repo

差点误报"丢了 2 个 CT-Private repo"。实际原因：

- 我们日常用的 `$GITEA_API_TOKEN` 是 `hongwei.zhang` 的 token
- 但 `hongwei.zhang` 在 prod 不是 admin，**也不是 CT-Private org 的成员**
- `/api/v1/repos/search?private=true` 只返回**当前用户能访问的** repo——hongwei.zhang 看不到 CT-Private，所以 search 返回 2 个
- 在 prod 上同一个 token 返回 4 个，是因为某些权限或配置差异（待查；但 drill 表现是正确的"按 ACL 过滤"）

**修法**：用 `gitea admin user generate-access-token --username admin --scopes all` 给真 admin 生成临时 token，校验时用它：

```bash
sudo -u backup-hub bash -c "cd <drill> && ./gitea -c <app.ini> admin user generate-access-token \
  --username admin --token-name drill --scopes all"
```

**更通用的教训**：**用 SQL 直接 count 比 API 更可靠**——`SELECT count(*) FROM repository;` 不受 ACL 干扰。drill 应该 dual-check：API（端到端通路）+ SQL（数据完整性）。

## 3. app.ini parse 失败时 gitea **静默落回 `0.0.0.0:3000` install 模式**

我用 heredoc 透过多层 `ssh + sudo -u + bash -c` 写 `app.ini`，外层引号转义出错，文件被写成空（或干脆没创建）。Gitea 启动看到没有 `INSTALL_LOCK=true`，**自动进入 install 向导，绑 `0.0.0.0:3000`**。

```
[I] Prepare to run install page
[I] Listen: http://0.0.0.0:3000     ← 不是我配的 127.0.0.1:33000
[I] AppURL(ROOT_URL): http://localhost:3000/
```

腾讯云安全组默认拦了 3000（外部 curl 不通），躲过一劫。但**在没硬防火墙的环境（自建机房 / Linode / 默认 0.0.0.0 暴露的云）这就是公网泄露**——任何人可访问 install 页，可能写入恶意配置。

**修法 + 通用规则**：

- 写关键配置文件**别用嵌套 ssh + heredoc**，直接 `scp` 整个文件过去，目的端 `install -m 644` 落到目标路径
- gitea 启动前**强制 grep 校验** `app.ini`：
  ```bash
  grep -q '^INSTALL_LOCK\s*=\s*true' "$APPINI" || { echo "FATAL: INSTALL_LOCK missing"; exit 1; }
  grep -q '^HTTP_ADDR\s*=\s*127\.0\.0\.1' "$APPINI" || { echo "FATAL: HTTP_ADDR not localhost"; exit 1; }
  ```
- **演练沙箱默认拉防火墙**（`iptables -A INPUT -p tcp --dport 3000 -j DROP`），不要依赖云安全组兜底

下次月度验证脚本必须把这些 guard 加上。

## 4. `gitea dump` 双份 DB（`data/gitea.db` + `gitea-db.sql`）的差别

dump 解开有两份数据库相关产物：

| 文件 | 内容 | 用途 |
|---|---|---|
| `data/gitea.db` | SQLite 完整数据库文件（118 MB） | **同 DB 类型恢复直接用**，最快 |
| `gitea-db.sql` | SQL DDL + INSERT dump（144 MB） | **跨 DB 类型迁移**（如 SQLite → PostgreSQL） |

第一次想用 `sqlite3 work/data/gitea.db < extracted/gitea-db.sql` 把 SQL 灌进去，重复工作而且容易引发 UNIQUE constraint。直接 `cp -a data/. work/data/` 就够了，`gitea-db.sql` 不动。

## 关联

- 工单 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310)
- 备份策略 [`docs/ops/10-backup-strategy.md`](../docs/ops/10-backup-strategy.md)
- 同期 learning：[`2026-05-13-ssh-forced-command-log-sudo-trap.md`](./2026-05-13-ssh-forced-command-log-sudo-trap.md)
