Twin Update:一条命令同步更新两台 Linux PC 上的 apt 应用
Twin Update – update the same apt apps on two Linux PCs in one run
Twin Update 可一条命令在两台 Debian/Ubuntu 系 Linux 机器上同步更新同一批 apt 应用,更新前保留经 sha256 校验的回滚副本,并支持一条命令回滚。
译文尚不完整,完整内容请切换到原文。
在一次运行中更新两台 Linux 机器上的相同桌面应用,保留一份经过验证的已安装内容回滚副本,并用一条命令回滚。
它面向 Debian/Ubuntu 系列桌面(已在基于 Ubuntu 24.04、使用 Python 3.12 的系统上测试;需要 Python 3.11+、systemd 用户会话和 apt),以及以来自厂商仓库的 apt 包形式安装的应用。内置条目:Cursor(cursor)、ChatGPT 桌面版(chatgpt)和 Grok Bot 桌面版(grok-bot)。其他以 apt 打包的应用可在配置中添加。Snap、Flatpak、AppImage 和 CLI 不在范围内。
机器标签就是你在配置中给它们起的名字;下面的示例使用 desktop-a 和 laptop-b。
安全模型简述
- 你控制的两台机器,一条可信链路。你启动运行的机器通过
ssh(BatchMode,检查主机密钥)连接到另一台:Tailscale SSH 或普通 ssh 密钥。没有这条链路时,只有--local-only --dry-run可用。 - 每次运行只询问一次 sudo,并在两台机器上校验。密码只通过 stdin 传给
sudo -S(对另一台机器则通过本次运行的 ssh 会话);绝不进入 argv、环境变量、文件、日志或报告。参见 SECURITY.md。 - apt only. Root runs only
apt-get update,apt-get installof a pinned version or of a stored, sha256-checked.deb, andapt-mark hold/unhold. Each machine installs from its own configured apt sources. - 仅优雅关闭。使用 SIGTERM,绝不使用 SIGKILL。如果某个应用未及时关闭,本次运行会在任何更新发生之前于两台机器上中止。
- 每次更新前先做回滚副本,并与 dpkg 安装的内容进行校验;两台机器上任一缺少经过验证的副本,该应用就会在两台机器上都被跳过。
- 无常驻进程。没有守护进程、定时器或监听器;应用只在你启动的运行过程中关闭。
- 它恢复的是程序文件,不是应用数据(见下文)。
一次运行会做什么
twin-update run [--apps cursor,chatgpt] [--dry-run]
- 两台机器上的预检:身份(
whoami,可选的 machine-id)必须与配置匹配;apt/dpkg 必须空闲;每个lock_paths文件在整个运行期间都会被锁定,因此同步或备份任务无法在更新期间运行。只询问一次 sudo 密码并在两台机器上校验,然后在两台机器上执行apt-get update。 - 计划:只有当两台机器都安装了某应用、都提供相同的候选版本,且没有保留标记阻止时,该应用才会被更新。否则它会在两台机器上都被跳过,并给出原因。确切版本会被固定。
- 先做回滚副本:已安装的
.deb会以 sha256 形式存储在~/.local/share/twin-update/rollback/<app>/<version>/中,来源为 apt 缓存、apt-get download <pkg>=<installed>或匹配的本地.deb。它会与 dpkg 对已安装构建的 md5sums 进行校验(对于没有 md5sums 成员的厂商 deb,则以流式方式对 deb 的文件进行哈希并比较)。不匹配则拒绝。两台机器上任一缺少副本 = 该应用在两台机器上都被跳过。 - 保留标记:Twin Update 未设置的、针对这些应用的保留标记会被清除(每条各占一行报告),除非
keep_holds列出了它们。其他软件包上的保留标记仅作报告。 - 两台机器上优雅关闭:通过 pidfd 向应用进程树的主进程发送 SIGTERM,这些进程通过可执行文件路径和软件包的文件列表找到。不强制终止。如果在
close_s之后仍有进程在运行,本次运行会在任何更新之前于两台机器上中止,指明应用和进程,并重新打开它已关闭的内容。 - 更新每台机器从其各自的仓库进行:
apt-get install --only-upgrade <pkg>=<version>。机器之间不复制任何内容。 - 验证:dpkg 版本、
dpkg -V,以及在图形会话中做一次启动冒烟测试(systemd-run --user):保持运行smoke_s秒,journal 中无崩溃,优雅关闭。失败时你会获得回滚选项。 - 重新打开仅限之前处于打开状态的应用。
- 清理:每个应用只保留一份回滚副本(最新更新之前的版本)。旧副本仅在新版本通过检查 且 经过宽限期(7 天或 2 次成功启动)后才会被删除。
- 报告:在两台机器上生成
~/.local/state/twin-update/runs/<ts>-run.json(权限 0600,仅含机器标签),外加一条桌面通知,例如Cursor laptop-b 2.4.1→2.5.0 ✓(通过notify-send,或在未安装notify-send时通过gdbus)。
应用只会在你启动的一次运行中被关闭。没有守护进程、定时器、监视器或监听器;到另一台机器的 ssh 会话仅在运行期间存在。运行过程会将自己移入其专属的 systemd-run --user --scope,因此关闭启动它的终端所在的应用不会使其停止。
其他命令
twin-update check [--notify] [--json] # read-only: are updates available? (no sudo)
twin-update status [--local-only] # per machine/app: installed, candidate, running, rollback copy, hold
twin-update rollback <app> [--machine <label>|both] [--dry-run] [--no-hold]
twin-update holds
twin-update unhold <app> [--machine ...] [--dry-run]
twin-update doctor # identity, sudo group, apt idle, locks, session, ssh; exit 4 on problems
twin-update local --serve # per-machine engine; the peer runs this over ssh
每日检查(可选)
twin-update check 以非 root 身份询问两台机器,已启用的应用是否有更新版本。首先,它仅为应用自身的仓库获取最新的软件包列表到用户拥有的缓存中(~/.cache/twin-update/apt 或 $XDG_CACHE_HOME/twin-update/apt),并将 apt-get update 指向该缓存:仓库现有的 sources.list.d 条目被原样链接进来,因此其 Signed-By 密钥环和 apt 的签名检查按配置生效;系统的 apt update 钩子不会运行;/var/lib/apt 永远不会被写入。然后 apt-cache policy 读取同一缓存。如果获取失败或超时(2 分钟),则使用系统列表并说明这一点,同时告知上次刷新距今多久。--no-fetch 跳过获取。它从不请求 sudo,不关闭任何东西,不安装任何东西,也不存储回滚副本。(twin-update run 仍以 root 身份通过系统 apt 刷新和安装。)
使用 --notify 时,当有可用更新时,它会在其运行的机器上发送桌面通知;当无法联系到另一台机器或发生错误时,发送一条简短的“检查未完成”通知。同一通知每天最多发送一次(状态保存在 ~/.local/state/twin-update/ 中)。
无法访问的机器会以直白的语言报告:名称查找失败、ssh 登录被拒绝、连接被拒绝、Tailscale SSH 要求进行交互式检查,或超时(关机、休眠或离线)。
check 的退出码:0 无更新,10 有可用更新(仅检查了一台机器时也如此),1 检查未完成(出错或另一台机器无法访问,且未发现更新),2 用法或配置错误。
示例用户定时器(无需 root;在你登录时运行,或启用 lingering 后始终运行):
# ~/.config/systemd/user/twin-update-check.service [Unit] Description=Twin Update: daily read-only update check [Service] Type=oneshot ExecStart=%h/.local/bin/twin-update check --notify SuccessExitStatus=10 TimeoutStartSec=5min Nice=10 IOSchedulingClass=idle # ~/.config/systemd/user/twin-update-check.timer [Unit] Description=Twin Update: daily update check [Timer] OnCalendar=*-*-* 09:13:00 Persistent=true [Install] WantedBy=timers.target
systemctl --user daemon-reload && systemctl --user enable --now twin-update-check.timer
rollback 检查所存储副本的 sha256,优雅地关闭应用,使用 apt-get install --allow-downgrades ./<deb> 安装它,设置 apt-mark hold(记录为 Twin Update 自身的 hold;下一次有意执行的 run 会将其释放),进行验证,并在应用之前处于打开状态时重新打开它。
回滚恢复的是程序文件,而非应用数据。如果新版本迁移了其设置或数据库,请保留你自己的数据备份。Twin Update 从不打开、复制或修改应用数据库。
安装(每台机器)
pipx install twin-update # or: uv tool install twin-update
或来自发行版的单文件 zipapp:
install -D -m 0755 twin-update.pyz ~/.local/bin/twin-update
在两台机器上的相同路径安装它(配置中的 remote_command 指向它)。然后从 config.example.toml 创建 ~/.config/twin-update/config.toml(权限 0600)。同一文件在两台机器上均可使用。用 twin-update doctor 检查,然后 twin-update run --dry-run。
传输
你运行的机器通过普通 ssh 访问另一台机器(BatchMode,检查主机密钥,无密码提示)。远程端在运行期间执行 twin-update local --serve。有两种选择:
Tailscale SSH。在每台应接受运行的机器上:
sudo tailscale set --ssh。“Shields up”会阻止传入连接,包括 Tailscale SSH,因此在这些机器上必须关闭它;改为让策略来限制。带标签的策略片段示例(将其合并到你的策略中;给设备打标签会使其成为标签所有而非用户所有):
{
"tagOwners": {
"tag:twin-a": ["autogroup:admin"],
"tag:twin-b": ["autogroup:admin"]
},
"grants": [
{ "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "ip": ["tcp:22"] },
{ "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "ip": ["tcp:22"] }
],
"ssh": [
{ "action": "accept", "src": ["tag:twin-a"], "dst": ["tag:twin-b"], "users": ["alice"] },
{ "action": "accept", "src": ["tag:twin-b"], "dst": ["tag:twin-a"], "users": ["alice"] }
]
}使用 "action": "accept":"check" 需要交互式浏览器登录,而 BatchMode 运行无法响应它。如果你的策略仍有允许全部规则,它也会让 tailnet 中的其他所有内容访问这些机器;请收窄它。如果你总是在同一台机器上启动运行,只保留你需要的方向。
普通 ssh 密钥。在另一台机器的 authorized_keys 中放置一个无需密码提示的专用密钥(或由你的 agent 持有的密钥),最好用 from="<the other machine's address>" 加以限制,并将其主机密钥预先放入 known_hosts。
开发
PYTHONPATH=src:tests python3 -m unittest discover -s tests
python3 tools/build_zipapp.py # -> dist/twin-update.pyz
仅使用标准库。退出码(check 不同,见上文):0 正常 / 试运行,4 doctor 发现问题(包括无法访问的另一台机器;同步锁繁忙只是警告),1 失败,2 用法或配置,3 中止(未更改任何内容)。
许可证
Apache License 2.0。参见 LICENSE。
来源:Hacker News · github.com