小米 NAS(RP05)开 root SSH 并容器化 iCloud 照片同步实录

Xiaomi NAS get root SSH & dockerize icloudpd, from zero to CI

Posted by motorao on September 14, 2026

小米 NAS(RP05)开 root SSH 并容器化 iCloud 照片同步实录

背景:两个刚性需求

我有一台小米 NAS,主要用途之一是归档 iCloud 照片(149G,从 2008 年到今天)。原来只有一条路:

1
Mac 挂载 NAS 的 SMB 共享 → 在 Mac 上跑 icloudpd 写进挂载目录

痛点很明显:

  1. Mac 必须常开,笔记本合盖/睡眠就断;
  2. 每次同步会触发 macOS 的 TCC 权限弹窗(照片目录、网络卷),烦且容易误点;
  3. 149G 走 SMB 往返,慢,且出问题排查链路长。

所以我想要两件事:

  • A. 拿到 NAS 的 root SSH —— 没有 root,一切都得绕;
  • B. 把 icloudpd 容器化,直接跑在 NAS 上,顺带把镜像构建搬到 CI。

下面按顺序记录全过程,包括所有踩过的坑(有几个坑很阴,不写下来下次还会踩)。


一、环境与前提

型号 小米 NAS(RP05 系列)
系统 Yocto 5.0.3 (scarthgap) / kernel 6.6.35 / aarch64
内存 3.9G
存储 pool cfs 7.3T(已用约 750G)+ /data 分区仅 9.7G
Docker 已有 dockerd 20.10.17(vendor 装的,二进制在 /data/docker/docker
已知通道 智能存储 App 已绑定账号(设备在内网)

关键前提:设备的「智能存储 App」能通过 WebDAV 访问设备文件系统——这正是后面所有操作的唯一入口。设备本身不开放 SSH(22 端口关着),也没有 telnet。


二、拿到 root SSH

2.1 思路(不是漏洞,是顺着厂商机制走)

这类封闭设备的开 root 套路通常是三步:

  1. 写文件:借 App 的文件管理通道(WebDAV)往设备里塞脚本;
  2. 触发执行:找一个「开机/挂载时会自动执行某个目录下脚本」的厂商机制;
  3. 固化:把执行入口落在持久化分区上,重启不丢。

我用的载体是社区现成的开源工具(CNB 上有一份工程化不错的),但真正跑通靠的是自己补的两个东西(见 2.2、2.4)。

2.2 第一个坎:macOS 的 curl 用不了

开 root 的脚本要用 WebDAV 通道传文件,需要带客户端证书走 HTTPS + EC 客户端密钥。而 macOS 自带的 curlLibreSSL 编译的,读到 EC 私钥直接报:

1
unsupported algorithm

brew install curl 又因为我的 brew 元数据过期内部报错(undefined method '[]' for nil)。

解法:不折腾系统 curl,用 Homebrew 的 python3.12(链的是 OpenSSL 3)写一个最小的 curl 替身,只实现脚本用到的那几个参数:

1
2
3
# nas_curlshim.py(节选,思路)
# 支持 -X / -H / -d / -u / --connect-timeout / --max-time / -w / -o
# 证书走 ssl.SSLContext.load_cert_chain(cert, key)  ← OpenSSL 3 才能读 EC key

写完做自检(--selftest),然后用环境变量指给原脚本:

1
2
NAS_IP=<NAS-IP> CURL=~/.hermes/scripts/nas_curlshim.py \
  bash scripts/enable-xiaomi-nas-ssh.sh

这里有个值得学的细节:原脚本把 curl 做成可注入的(CURL=${CURL:-curl}),所以换实现不用改脚本。写自动化脚本时把外部依赖做成参数,能省掉后面 90% 的适配工作。

2.3 执行流程

脚本全程 17 步、每步带断言uid=0(root) / KEY_OK / SHELL_OK / PERSIST_HOOK_OK),所有 curl 都带 --connect-timeout/--max-time,文件写入是原子写$HOOK.tmp.$$mv -f)。

跑完我不看脚本自己的输出,另开一条会话独立复核:

1
2
3
4
5
6
7
8
9
# 端口开了吗
nc -zv <NAS-IP> 22
# 真的是 root 吗
ssh root@<NAS-IP> 'id'
# authorized_keys 与本地公钥逐字节一致吗
ssh root@<NAS-IP> 'md5sum /etc/dropbear/authorized_keys'
md5 ~/.ssh/id_ed25519_xiaomi_nas.pub
# /etc/passwd 只动了 root 的 shell 一行吗
ssh root@<NAS-IP> 'diff /runtime/passwd.bak-pre-ssh /etc/passwd'

实际改动面:只改了 /etc/overlay 里的文件(设备根文件系统是 overlay,upperdir 在 /data/etc/upper)+ 往 pool 里写了文件。没碰 U-Boot 环境变量、RPMB、分区表、内核 —— 这是判断「会不会变砖」的关键:变砖只可能发生在引导链路,而引导链路我们完全没动。

2.4 固化:为什么要三层

单靠一个钩子是不够的——依赖单点,一旦它没触发就彻底失联(只能拔盘重新走一遍 WebDAV)。所以做了三层:

第一层(主):pool 挂载事件钩子

设备的 syshotplug 会在 pool 挂载后,按 LC_ALL=C ls 的字典序执行 /etc/syshotplug/pool/ 下的脚本:

1
2
3
4
5
# /etc/syshotplug/pool/98.ssh-persistence
#!/bin/sh
[ "mounted" = "$ACTION" ] || exit 0
systemctl start dropbear.socket
logger -t ssh.persistence "dropbear.socket started after pool mount"

编号 98 是刻意的:排在厂商自己脚本后面,避免抢跑。

第二层(兜底):cron 时间轮

钩子依赖 pool 挂载事件;万一事件丢了(或挂载顺序变了),cron 是纯时间轮,不依赖任何事件:

1
2
3
4
5
6
7
8
9
# /etc/cron.d/ssh-watchdog        (644)
*/10 * * * * root /etc/ssh-watchdog.sh

# /etc/ssh-watchdog.sh            (755,幂等 + 开关文件)
if [ -f /etc/ssh-watchdog.off ]; then exit 0; fi
systemctl is-active --quiet dropbear.socket || {
  systemctl start dropbear.socket
  logger -t ssh.watchdog "dropbear.socket restarted"
}

实测:systemctl stop dropbear.socket15 秒自愈。

第三层(终极):WebDAV 注入救援

如果 SSH 彻底上不来,回到最初的 WebDAV 通道,往里丢一条命令再触发执行。

这里踩了一个非常阴的坑:注入通道会对命令里的裸 / 敏感,会被 WebDAV 的路径解析吃掉——表现为「命令秒返回、但什么都没发生」。定位到根因后:

1
2
# 把 / 换成命令替换形式,绕开路径解析
cmd.replace("/", "$(printf '\\057')")

改完端到端复测:0 秒恢复。

2.5 变砖风险评估(动手前必须回答的问题)

关注点 结论
/etc 会不会重启丢失? 不会。/etc 是 overlay,upperdir 在 /data/etc/upper(持久分区)
会不会影响厂商自己的容器? 不会。没动 vendor 文件、没加 systemd 单元、没抢 dropbear 的 socket 激活
和原厂开关冲突吗? 有一处语义冲突:原厂 mitee_tool rpmb get ssh_en 开关会被覆盖。留了 /etc/ssh-watchdog.off 可一键停
最坏情况? 重启后 SSH 不上来 → 回到 WebDAV 通道重新注入(不是变砖)

2.6 重启实测(最硬的证据)

我重启了设备,然后在外面盯着它自己恢复:

1
2
3
4
5
15:02:38  SSH 下线
15:03:08  SYSTEM.BOOT_CHECK: ssh server stop      ← 厂商的开机检查主动停掉 ssh
15:03:38  SYSHOTPLUG.MGR: script=/etc/syshotplug/pool/98.ssh-persistence, cost 72 ms
15:03:44  端口 22 恢复(旧私钥可登)
15:10:00  cron tick 静默 no-op(幂等,什么都没做)

boot_id 从 ae68ce28-… 变成 72d6a13f-…,确认真的重启过。

注意这个顺序:厂商的 boot_check主动 stop ssh(因为 RPMB 里的开关是关的),所以我那个钩子必须排在它后面才有效——这也是为什么钩子放在 syshotplug/pool/(pool 挂载后触发)而不是开机 early 阶段。


三、容器化 iCloud 照片同步

3.1 摸清 NAS 上的 Docker

1
2
3
/data/docker/docker version     # 20.10.17, aarch64, overlay2
/data/docker/docker ps -a       # vendor 自带容器 miot_central(别动)
df -h /data                     # 9.7G ← 镜像/容器层都吃这里

动手前先摸清三个既成事实,后面我全部踩在上面:

  1. docker 二进制不在 PATH/data/docker/docker);
  2. Docker Root Dir = /data/docker_data,只有 9.7G(照片本体在 pool 7.3T,容器里 bind mount 进去);
  3. 没有 docker-compose(连 CLI 插件目录都不存在)→ 用 docker run,或者自己装 compose。

3.2 最大的坑:容器完全没有网络

第一次构建就炸:

1
Temporary failure in name resolution

排查链路:容器内 ping 8.8.8.8 也超时 → 说明不是 DNS 是根本没网。查宿主机:

1
2
3
4
iptables -t nat -S POSTROUTING
# -P POSTROUTING ACCEPT      ← 只有这条,没有任何 MASQUERADE 规则
/data/docker/docker network inspect bridge | grep masquerade
# "com.docker.network.bridge.enable_ip_masquerade": "false"

厂商把默认 bridge 配成了不做 NAT(大概率是为了让容器只能走某些内部通道)。

解法:构建和运行都加 --network host

1
2
/data/docker/docker build --network host -t myimg .
/data/docker/docker run -d --network host ... myimg

对 icloudpd 这种不需要入站端口的服务,host 网络没有任何副作用。

这个坑的代价:pip/apt 在容器里全部解析不了域名,不加 --network host 构建永远失败,而报错信息(name resolution)会把我往 DNS 配置上带偏,白查半天 DNS。

3.3 镜像怎么建

目标是一个 arm64 原生镜像,构建完全自包含(不依赖构建时能访问 GitHub):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
FROM docker.m.daocloud.io/library/python:3.12-slim   # 国内可拉的 Docker Hub 镜像源

ENV TZ=Asia/Shanghai \
    PIP_INDEX_URL=https://mirrors.cloud.tencent.com/pypi/simple

# slim 镜像没有 tzdata,而 icloudpd 用 TZ 决定 YYYY/MM/DD 分目录 → 必须装
RUN sed -i 's|deb.debian.org|mirrors.cloud.tencent.com|g' /etc/apt/sources.list* \
 && apt-get update && apt-get install -y --no-install-recommends tzdata \
 && ln -snf /usr/share/zoneinfo/$TZ /etc/localtime

# 源码来自 fork(含自定义 --bark-url 通知补丁),以 vendored 方式放在仓库里
COPY vendor/icloudpd/ /app/
RUN pip install --no-cache-dir /app \
 && icloudpd --version \
 && icloudpd --help | grep -q -- '--bark-url' \   # 构建期断言:补丁丢了就构建失败
 && echo "BUILD_OK"

有三个设计点值得写下来:

  1. PIP_INDEX_URL 用 ENV 而不是 pip -i:pip 的构建隔离环境(装 setuptools 的子进程)不继承命令行参数,只有 ENV 才能覆盖;
  2. 构建期断言 --bark-url 存在:fork 的补丁一旦在同步上游时丢了,镜像会构建失败,而不是安静地产出一个「没有通知功能」的镜像;
  3. 基础镜像走国内镜像源:NAS 上直连 registry-1.docker.io 是不通的。

iCloud 登录会要 2FA 验证码,而容器里没有交互终端。做法是复用已经登录过的会话

1
2
3
# 把 Mac 上的 pyicloud 会话文件拷到容器挂载的 config 目录
scp ~/.pyicloud/<account> <NAS>:"/nas/pool0/<uid>/data/我的照片/icloudpd/.config/.pyicloud/"
ssh <NAS> 'chown -R <uid>:<gid> "/nas/pool0/<uid>/data/我的照片/icloudpd/.config"'

容器起来直接认证成功、无需验证码。会话失效时再进交互式容器重登一次:

1
2
docker run -it --rm --user <uid>:<gid> -e HOME=/config \
  -v "<config>:/config" <image> --username <apple-id> --domain cn --auth-only

3.5 权限:容器必须以 NAS 账号的 uid 运行

照片最终要让 App / SMB 看到,容器写进去的文件属主必须是 NAS 上的账号(uid:gid),否则:

1
docker run ... --user <uid>:<gid> ...

验证方式(写一个文件看属主):

1
2
docker exec --user <uid>:<gid> icloudpd sh -c 'touch /Photos/.w && ls -l /Photos/.w && rm /Photos/.w'
# 期望:<uid>:<gid>

3.6 CI:把构建搬到 CNB 云原生构建

NAS 上构建有两个问题:/data 只有 9.7G、构建机是 arm64 小机器。所以把构建交给 CNB(cnb.cool),NAS 只负责拉镜像。

.cnb.yml(关键片段):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
main:
  push:
    - name: 构建并推送镜像
      services:
        - docker                    # CNB 会自动 docker login 到制品库
      env:
        IMAGE: ${CNB_DOCKER_REGISTRY}/${CNB_REPO_SLUG_LOWERCASE}
        TAG: ${CNB_COMMIT_SHORT}
      stages:
        - name: 构建多架构镜像并推送
          script: |
            docker buildx build \
              --platform linux/amd64,linux/arm64 \
              -t ${IMAGE}:latest -t ${IMAGE}:${TAG} \
              --push .

几个坑:

  • 必须构 arm64:CNB 构建机是 amd64,而 NAS 是 aarch64。只构单架构的话镜像在 NAS 上起不来。文档说 buildx 多架构开箱即用,实测确实;
  • CNB_COMMIT_SHORT 是 8 位,不是常见的 7 位(我按 7 位去找 tag,报了 not found,试了 8 位才命中);
  • 制品库的可见性跟仓库走:私有时必须先 docker login;改成公开后走匿名 token,就不用登录了(详见 3.7);
  • 制品地址规则是 docker.cnb.cool/<owner>/<repo>

3.7 拉镜像的权限问题(公开之后就不是问题了)

一开始我把仓库和制品都设成私有,NAS 上拉镜像必须先登录,于是撞上:

1
2
docker login docker.cnb.cool -u cnb -p <token>
# Error saving credentials: mkdir /root/.docker: read-only file system

设备的 / 是只读的(squashfs),/root 也写不了,docker CLI 的默认配置目录不可用。

解法:把配置目录指到可写分区:

1
2
export DOCKER_CONFIG=/data/.docker
docker login docker.cnb.cool -u cnb -p <token>   # OK

顺手做了个包装脚本 /data/docker/dk,把两个坑(二进制不在 PATH、DOCKER_CONFIG)一次性收进:

1
2
3
#!/bin/sh
export DOCKER_CONFIG="${DOCKER_CONFIG:-/data/.docker}"
exec /data/docker/docker "$@"

再把 /data/docker 加进 /etc/profile 的 PATH——之后 ssh <NAS> 进去直接敲 docker ps / dk ps 就能用。

后来发现这个坑会自己消失:把仓库和制品都改成公开之后,拉镜像就不再需要任何凭据了——CNB 的制品库走的是匿名 token 流程

1
2
3
4
5
# 空配置目录(= 完全没有凭据)也能拉
env DOCKER_CONFIG=/tmp/empty docker pull docker.cnb.cool/<owner>/<repo>:latest   # 成功

# token 端点无需凭据即返回 200 + 匿名 token
curl "https://docker.cnb.cool/service/token?service=cnb-registry&scope=repository:<owner>/<repo>:pull"

⚠️ 这里有个非常容易误判的细节:直接请求 /v2/<owner>/<repo>/tags/list 仍然返回 401。那不是「需要登录」,而是「没走 token 流程」——docker 客户端会自动完成 token 交换,所以命令行拉取完全正常。

意识到这点之后,NAS 上那份为登录而存的凭据(一个长期有效的 token)就从「必需」变成了多余的机密,我把它清掉了:

1
2
3
4
5
6
7
python3 - <<'EOF'
import json, pathlib
p = pathlib.Path("/data/.docker/config.json")
d = json.loads(p.read_text())
d["auths"] = {}
p.write_text(json.dumps(d))
EOF

DOCKER_CONFIG 的导出仍然留在脚本里——万一哪天仓库改回私有,登录凭据还是得落在这个可写分区上。

3.8 「运行脚本放哪」的演进(这段最有价值)

部署脚本里不可避免要有账号和通知地址(Apple ID、Bark 推送 key)。我试了三代方案:

方案 问题
① 值直接写在仓库的 run.sh 仓库不能公开;但最省事
② 抽到 .env.local 让脚本 source 多一层间接;且 .env.local/data 的话,/data 会被固件升级重置
脚本本身只放 NAS 上(pool 目录),含真实值,不进 git ✅ 最终方案

最终形态:

1
2
3
4
5
NAS(pool 数据盘,跟照片一起备份、升级固件不受影响)
└── /nas/pool0/<uid>/data/我的照片/run.sh      ← 唯一权威副本,含真实值,不进 git

仓库
└── run.sh.example                             ← 只有占位符的模板

deploy.sh(在 Mac 上跑)自动发现并执行 NAS 上那份:

1
2
POOL_SCRIPT=$(ssh "$NAS" 'ls -1 /nas/pool0/*/data/我的照片/run.sh | head -1')
ssh "$NAS" "IMAGE=$IMAGE_BASE:$TAG sh '$POOL_SCRIPT'"

经验:敏感值和「怎么部署」这两件事要分开存——敏感值放数据盘上的运行脚本(跟随数据备份、不随固件升级消失、不进代码仓库),流程和模板放 git。中间态的 .env 文件看起来优雅,实际是把敏感值放进了一个可能被重置的系统分区

3.9 运行脚本最终长什么样

放在 我的照片/ 一级目录(SMB 里一眼能看到),带子命令:

1
2
3
4
sh "/nas/pool0/<uid>/data/我的照片/run.sh"            # 更新并启动(拉最新镜像 → 重建容器)
sh "/nas/pool0/<uid>/data/我的照片/run.sh" status     # 容器状态
sh "/nas/pool0/<uid>/data/我的照片/run.sh" logs 100   # 同步日志
sh "/nas/pool0/<uid>/data/我的照片/run.sh" stop       # 暂停同步

核心逻辑是幂等重建(先删同名容器再起),所以「更新」和「启动」是同一个动作:

1
2
3
4
5
6
7
8
"$DOCKER" pull "$IMAGE" || true
"$DOCKER" image inspect "$IMAGE" >/dev/null || { echo "镜像不在本地"; exit 1; }
"$DOCKER" rm -f "$NAME"
"$DOCKER" run -d --name "$NAME" --restart always --network host \
  --user "$UID_GID" -v "$PHOTOS:/Photos" -v "$CONFIG:/config" "$IMAGE" \
    --directory /Photos --username "$APPLE_ID" --domain cn \
    --auto-delete --watch-with-interval 600 --skip-added-before 90d \
    --bark-url "$BARK_URL"

--restart always + vendor 的 docker.service(enabled)保证了重启后容器自己回来


四、踩坑清单(浓缩版)

# 现象 解法
1 macOS 自带 curl 读不了 EC 客户端密钥 unsupported algorithm 用 Homebrew python3.12(OpenSSL 3)写 curl 替身
2 WebDAV 注入通道吃 / 命令秒返回、无任何效果 / 写成 $(printf '\057')
3 厂商 bridge 不做 NAT 容器内 DNS/外网全断 构建 + 运行都加 --network host
4 /root 只读(私有制品库要登录时才会遇到) docker loginmkdir /root/.docker: read-only file system export DOCKER_CONFIG=/data/.docker;制品公开后拉取走匿名 token,这个坑自动消失
5 dash 吞多字节字符 echo "…$TAG)"TAG<乱码>: unbound variable 写成 "${TAG}" 或加空格
6 CFS(fuse)上 find -delete 静默失效 返回 0,文件一个没删 改用 find … -exec rm -f {} +
7 想公开仓库时才发现 git 历史洗不干净 filter-branch + force push 后,旧 commit 仍能按 SHA 从服务端取回 最彻底:删掉仓库重建,推干净历史
8 镜像 tag 用 7 位短 sha 找不到 artifact … not found CNB 的 CNB_COMMIT_SHORT8 位

第 7 条值得单独强调:git filter-branch + git push -f 只清理了本地历史。服务端的不可达对象仍然可以按 SHA 取回(git fetch origin <old-sha> 依然成功),而旧 SHA 很可能就暴露在制品库 tag 名里。要真正干净,只有「删库重建」或「轮换所有泄露的密钥」。


五、最终形态

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
                         Mac                              CNB (cnb.cool)
                  ┌──────────────┐                  ┌────────────────────────┐
                  │ git push     │ ───────────────► │ .cnb.yml 流水线         │
                  │ sh deploy.sh │                  │  buildx amd64+arm64    │
                  └──────┬───────┘                  │  → 推 Docker 制品库      │
                         │ ssh                      └───────────┬────────────┘
                         ▼                                      │
       ┌────────────── 小米 NAS (Yocto, aarch64) ──────────────┼──────────────┐
       │  dropbear(22)  ← 三层固化(钩子 / cron / WebDAV 救援)  │              │
       │  dockerd 20.10.17                                     ▼              │
       │    ├── miot_central                      docker.cnb.cool/...:latest  │
       │    └── icloudpd  (--restart always, host 网络, uid=<uid>)             │
       │          └─ /Photos ← pool 里的 149G 归档(原地复用)                   │
       └───────────────────────────────────────────────────────────────────────┘
                         │ iCloud HTTPS(NAS 直连)
                         ▼
                    iCloud(cn 区)        → Bark 推送(成功/失败/需要 2FA)

日常运维就三条命令:

1
2
3
4
5
6
# 改代码
git add -A && git commit -m "..." && git push     # → CNB 自动构建镜像
# 更新 NAS 上的容器
sh deploy.sh                                      # 或 ssh <NAS> 'sh .../我的照片/run.sh'
# 看状态/日志
ssh <NAS> 'sh "/nas/pool0/<uid>/data/我的照片/run.sh" status'

六、复盘

做对的三件事

  1. 每步都要有可复核的证据:脚本自己报的 KEY_OK 我不认,另外跑了 id、逐字节对比 md5、看 boot_id 有没有变。重启实测那条时间线(stop → 钩子 → 44 秒恢复)才是最有说服力的材料。
  2. 加固做成多层,且互不依赖:pool 挂载钩子(事件驱动)+ cron(时间驱动)+ WebDAV 注入(带外通道)。任何一层挂掉都不至于失联。
  3. 敏感值与流程分离,且敏感值放数据盘:运行脚本放在 pool 上(跟照片一起备份、固件升级不重置、不进 git),仓库里只有模板。

一句话总结:封闭设备上做自建服务,难点从来不在服务本身,而在「怎么稳定地拿到一个能执行命令的入口」以及「怎么让这个入口在重启、升级、断网之后还在」——也就是服务自治