---
date: 2026-05-13
tags: [ssh, sudo, rsync, backup, dispatcher, set-e]
status: documented
---

# SSH ForceCommand dispatcher 的 `log()` 用 `sudo tee` → rsync 永远 exec 不上

## 现象

实施 Gitea pull-based 备份（工单 #310）时，dispatcher 跑得起 `trigger-dump`，但 `rsync` 拉数据死活报：

```
sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper
sudo: a password is required
rsync: connection unexpectedly closed (0 bytes received so far) [Receiver]
rsync error: error in rsync protocol data stream (code 12) at io.c(232)
```

错误信息极具误导性——看着像 **本地（备份枢纽）的 sudo 在要密码**。花了 20+ 分钟调本地 sudoers、加 NOPASSWD、改 `sudo -n`、换 `su -`，都没用。

## 根因（不直观）

源机（Gitea）的 ForceCommand dispatcher 长这样：

```bash
#!/bin/bash
set -e
LOG=/var/log/backup-dispatch.log
log() { echo "[$(date)] $*" | sudo tee -a "$LOG" >/dev/null; }  # ❌ 坑

case "$SSH_ORIGINAL_COMMAND" in
  'trigger-dump') log "..."; sudo -u git gitea dump ...; ;;     # ✅ 这条 sudo 在 sudoers 里授权了
  'rsync --server '*)
    log "rsync pull"                                            # ❌ 这里
    exec $SSH_ORIGINAL_COMMAND ;;
esac
```

执行链：
1. 枢纽 ssh 进源机 → sshd 走 ForceCommand 跑 dispatcher（身份 `gitea-backup`）
2. 进 rsync 分支，先 `log "rsync pull"`
3. `log` 内部跑 `sudo tee` —— **但 `gitea-backup` 没 `tee` 的 sudo 授权**（sudoers 只给了 `gitea dump` 和 `chmod`）
4. `sudo` 找不到 NOPASSWD 规则 → 试图开 TTY 读密码 → SSH 没 TTY → 报 "a password is required" 写到 stderr
5. `set -e` 看到 sudo exit non-zero → **整个脚本退出**
6. `exec rsync` 那一行 **从来没执行**
7. 枢纽端 rsync 等不到 server 端响应 → "connection unexpectedly closed"

`trigger-dump` 分支没踩到这个坑，是因为它的 sudo 走的是被授权的 `gitea dump`——但 log() 调用一样会 sudo tee 失败……等等，那为啥 trigger-dump 还能成？看日志：

```
[2026-05-13T13:53:42] trigger-dump ok: ...
```

trigger-dump 的 log **写进去了**。原因是 `gitea-backup` 用户**碰巧**在那个时机被 `pam_systemd` 或上一次会话留下了 sudo timestamp 缓存（systemd 会跨会话保留几分钟），所以 sudo 没问密码就过了。等过 5 分钟来测 rsync 时缓存过期，立刻露馅。**间歇性问题**是这个坑最毒的地方。

## 修法

dispatcher 的日志文件 chown 给本用户，`log()` 直接 `>>` 写，**绝不走 sudo**：

```bash
# 安装阶段：
sudo touch /var/log/backup-dispatch.log
sudo chown gitea-backup:adm /var/log/backup-dispatch.log
sudo chmod 664 /var/log/backup-dispatch.log

# dispatcher：
log() { echo "[$(date -Iseconds)] [$USER] $*" >> "$LOG"; }
```

## 通用规则

**SSH ForceCommand dispatcher 三条铁律**（写进 [`docs/ops/10-backup-strategy.md § 1.2`](../docs/ops/10-backup-strategy.md)）：

1. **`log()` 不能用 sudo**——日志文件必须 chown 给 SSH 用户本身
2. **`set -e` 下，`exec` 之前的每一步都可能让 dispatcher 静默退出**，client 端只能看到模糊的 "connection closed"。每个 pre-exec 步骤要么用 `command || true` 兜底，要么确认零失败可能
3. **调试 "remote sudo password required" 时，不要只看显式 `sudo` 调用**——dispatcher / 用户 login script / motd / pam hook 都可能隐式起 sudo。最快的定位手段：在源机上 `tail -f /var/log/auth.log` 同步看 sudo 决策

## 调试该用的姿势（事后总结）

错的调试路径（我走的）：
- 怀疑本地 sudo → 检查本地 sudoers → 加 NOPASSWD → 换 `sudo -n`
- 一连串都白搭，因为错误根本不在本地

对的调试路径（应该走的）：
1. 看错误时序：`trigger-dump` 成功 → `rsync` 失败 → 失败前夹了什么逻辑？
2. 直接 `cat` 源机 dispatcher 看实际部署内容（我中途 heredoc 嵌套 escape 失败导致部署的不是预期版本，又花了 10 分钟）
3. 源机开 `tail -f /var/log/auth.log` 跟同步观察
4. dispatcher 头上加 `exec 2>>/tmp/dispatch.err; set -x` 看真实执行路径

## 关联

- 工单 [#310](http://43.130.59.228/FFAIWorkspace/workspace/issues/310)
- 架构文档 [`docs/ops/10-backup-strategy.md`](../docs/ops/10-backup-strategy.md)
