小米 NAS(RP05)开 root SSH 并容器化 iCloud 照片同步实录
背景:两个刚性需求
我有一台小米 NAS,主要用途之一是归档 iCloud 照片(149G,从 2008 年到今天)。原来只有一条路:
1
Mac 挂载 NAS 的 SMB 共享 → 在 Mac 上跑 icloudpd 写进挂载目录
痛点很明显:
- Mac 必须常开,笔记本合盖/睡眠就断;
- 每次同步会触发 macOS 的 TCC 权限弹窗(照片目录、网络卷),烦且容易误点;
- 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 套路通常是三步:
- 写文件:借 App 的文件管理通道(WebDAV)往设备里塞脚本;
- 触发执行:找一个「开机/挂载时会自动执行某个目录下脚本」的厂商机制;
- 固化:把执行入口落在持久化分区上,重启不丢。
我用的载体是社区现成的开源工具(CNB 上有一份工程化不错的),但真正跑通靠的是自己补的两个东西(见 2.2、2.4)。
2.2 第一个坎:macOS 的 curl 用不了
开 root 的脚本要用 WebDAV 通道传文件,需要带客户端证书走 HTTPS + EC 客户端密钥。而 macOS 自带的 curl 是 LibreSSL 编译的,读到 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.socket 后 15 秒自愈。
第三层(终极):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 ← 镜像/容器层都吃这里
动手前先摸清三个既成事实,后面我全部踩在上面:
- docker 二进制不在 PATH(
/data/docker/docker); - Docker Root Dir =
/data/docker_data,只有 9.7G(照片本体在 pool 7.3T,容器里 bind mount 进去); - 没有 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"
有三个设计点值得写下来:
PIP_INDEX_URL用 ENV 而不是pip -i:pip 的构建隔离环境(装 setuptools 的子进程)不继承命令行参数,只有 ENV 才能覆盖;- 构建期断言
--bark-url存在:fork 的补丁一旦在同步上游时丢了,镜像会构建失败,而不是安静地产出一个「没有通知功能」的镜像; - 基础镜像走国内镜像源:NAS 上直连
registry-1.docker.io是不通的。
3.4 免 2FA:复用会话 cookie
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 login 报 mkdir /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_SHORT 是 8 位 |
第 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'
六、复盘
做对的三件事
- 每步都要有可复核的证据:脚本自己报的
KEY_OK我不认,另外跑了id、逐字节对比md5、看boot_id有没有变。重启实测那条时间线(stop → 钩子 → 44 秒恢复)才是最有说服力的材料。 - 加固做成多层,且互不依赖:pool 挂载钩子(事件驱动)+ cron(时间驱动)+ WebDAV 注入(带外通道)。任何一层挂掉都不至于失联。
- 敏感值与流程分离,且敏感值放数据盘:运行脚本放在 pool 上(跟照片一起备份、固件升级不重置、不进 git),仓库里只有模板。
一句话总结:封闭设备上做自建服务,难点从来不在服务本身,而在「怎么稳定地拿到一个能执行命令的入口」以及「怎么让这个入口在重启、升级、断网之后还在」——也就是服务自治。