mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 21:41:02 +08:00
服务端以非特权用户 `reader` 运行,镜像里构建时做的 `chown /data` 在 `/data` 被 **挂载** 时完全失效:bind mount 的属主来自宿主机目录,宿主机上 `./data` 不存在时 Docker 会以 root:root 新建它,于是服务端写签名密钥时 直接以 `EACCES: permission denied, open '/data/token.secret'` 崩溃退出。 命名卷继承镜像的 chown,所以只有 bind mount 受影响,而 compose 与 README 推荐的正是 bind mount,等于默认路径就是坏的。 改动: - 新增 `server/docker-entrypoint.sh`:以 root 启动,只在启动时把 /data 这一层的属主改成 reader(不递归整棵树,避免大缓存目录每次重启都被 chown),随后用 su-exec 降权到 reader 再 exec 服务端。服务端仍是 pid 1, main.ts 的 SIGTERM/SIGINT 优雅退出照常工作。若容器本来就不是 root 启动(`--user`),脚本直接 exec 命令,不尝试改属主。 - `server/Dockerfile`:装 su-exec(约 10KB;alpine 的 busybox 没有等价的 setpriv,util-linux 又不在基础镜像里),加 ENTRYPOINT。去掉 `USER reader`, 因为降权改由入口脚本完成。 - `server/src/config/index.ts`:写密钥失败时,EACCES/EPERM/EROFS 不再只抛 errno,而是报出「DATA_DIR 必须可写」并给出两种修法(改宿主机属主, 或设 READER_TOKEN_SECRET 跳过写文件)。这是自部署最高频的坑, 直接给出下一步能省掉一整轮排查。 - 新增 `server/test/config.test.ts`:覆盖密钥持久化与复用的稳定性、 READER_TOKEN_SECRET 优先时不落盘、目录不可写时错误信息含修法、 DATA_DIR 落在 BOOKS_DIR 内被拒。权限用例在非 root 下运行, 以 root 跑测试时跳过。 - README 增加「故障排查」小节,写明成因与 4 步自查。 验证:`npm run typecheck` 通过;`npm test` 149 项 148 通过 1 跳过 (跳过项即需非 root 的权限用例,已用非 root 单独跑通);以非 root 手动 复现 EACCES 路径,确认错误信息与修法正确;入口脚本的 chown + 降权流程 手动模拟通过。
46 lines
2.0 KiB
Bash
Executable File
46 lines
2.0 KiB
Bash
Executable File
#!/bin/sh
|
|
# Fix the ownership of the writable data directory before dropping privileges.
|
|
#
|
|
# Why this exists: the image runs as the unprivileged `reader` user, and the
|
|
# build-time `chown /data` is useless the moment `/data` is *mounted*. A bind
|
|
# mount (`./data:/data`) exposes the host directory's own uid/gid, which on Linux
|
|
# is almost always root:root — the chown baked into the image never applies to a
|
|
# mount, only to the image layer underneath it. Docker creates a *missing* bind
|
|
# source as root:root too, so the very common
|
|
#
|
|
# docker compose up -d # ./data does not exist yet
|
|
#
|
|
# lands on a root-owned `/data`, and the server then dies at boot with
|
|
#
|
|
# EACCES: permission denied, open '/data/token.secret'
|
|
#
|
|
# because `loadOrCreateSecret` cannot write the signing key.
|
|
#
|
|
# Named volumes inherit the image's chown, so they were never affected — this
|
|
# only repairs bind mounts, and only the directory itself (not the whole tree),
|
|
# so a big library cache is not re-chowned on every restart.
|
|
#
|
|
# The entrypoint therefore stays root just long enough to take ownership of the
|
|
# single directory the server writes to, then `exec`s the server as `reader` so
|
|
# the long-running process keeps the unprivileged uid.
|
|
set -e
|
|
|
|
DATA_DIR="${DATA_DIR:-/data}"
|
|
|
|
if [ "$(id -u)" = "0" ]; then
|
|
mkdir -p "$DATA_DIR"
|
|
# `chown` on the directory alone is enough: SQLite, the cover cache and the
|
|
# secret are all created *inside* it by the server, which then owns them.
|
|
# Failures are tolerated (e.g. a read-only data mount) so the server can still
|
|
# start and report a clearer error of its own.
|
|
chown reader:reader "$DATA_DIR" 2>/dev/null || true
|
|
|
|
# Drop to the unprivileged user for the long-running process. `su-exec` is the
|
|
# alpine-native equivalent of gosu; it sets uid/gid and exec's, so the server
|
|
# keeps pid 1 and still receives SIGTERM (see main.ts shutdown handling).
|
|
exec su-exec reader:reader "$@"
|
|
fi
|
|
|
|
# Already non-root (e.g. `docker run --user`): just run the command.
|
|
exec "$@"
|