mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-10 21:11:08 +08:00
fix(docker): 允许非 root 用户写入挂载的 /data
服务端以非特权用户 `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 + 降权流程 手动模拟通过。
This commit is contained in:
5 files changed
+243
-3
No files matched your search
@@ -319,6 +319,43 @@ EPUB 永远留在 WebView:文字排版是差异点,只能有一份实现。
|
||||
|
||||
---
|
||||
|
||||
## 故障排查
|
||||
|
||||
### 启动即退出:`EACCES: permission denied, open '/data/token.secret'`
|
||||
|
||||
```
|
||||
fatal: Error: EACCES: permission denied, open '/data/token.secret'
|
||||
at loadOrCreateSecret (file:///app/dist/config/index.js:27:5)
|
||||
```
|
||||
|
||||
`/data` 挂上了,但容器里的服务进程写不进去。服务端以非特权用户 `reader`
|
||||
(uid/gid `100:100`)运行,而 `/data` 若是 **bind mount**(`./data:/data`),
|
||||
挂载点的属主来自宿主机目录,**会覆盖镜像里构建时做的 chown**——宿主机上
|
||||
`./data` 不存在时 Docker 会以 `root:root` 新建它,于是非特权用户写不了。
|
||||
命名卷(named volume)继承镜像里的 chown,不受影响。
|
||||
|
||||
镜像的入口脚本已经会在启动时自动把 `/data` 的属主改成 `reader` 再降权运行,
|
||||
所以**正常用官方镜像不会遇到这个问题**。仍然报错时按顺序检查:
|
||||
|
||||
1. 拉的是最新镜像:`docker compose pull && docker compose up -d`。
|
||||
2. 自己加了 `user:` / `docker run --user` 覆盖了启动用户,去掉它。
|
||||
3. `/data` 所在文件系统不支持改属主(NFS / SMB / NTFS 挂载,如群晖的部分共享目录),
|
||||
此时给 `READER_TOKEN_SECRET` 设一个 ≥16 字符的随机串,服务端就不需要写密钥文件:
|
||||
```bash
|
||||
openssl rand -base64 48 # 生成一个
|
||||
```
|
||||
在 compose 里加 `READER_TOKEN_SECRET: "<上面的值>"`。
|
||||
4. 手动修宿主机目录属主(Linux;群晖等 NAS 上 uid 可能不同):
|
||||
```bash
|
||||
sudo chown -R 100:100 ./data
|
||||
```
|
||||
|
||||
> 自建的镜像若来自 `server/Dockerfile`,注意 `ENTRYPOINT` 不能被覆盖,否则降权
|
||||
> 那一步不会执行。服务端本身在写密钥失败时会直接报出「`DATA_DIR` 必须可写」
|
||||
> 并给出一键修法,不会再只抛一个 errno。
|
||||
|
||||
---
|
||||
|
||||
## 让外网访问(内网穿透 / 域名 / HTTPS)
|
||||
|
||||
自部署最大的门槛在这里,所以单独说清楚。三种常见做法:
|
||||
|
||||
+13
-2
@@ -52,7 +52,12 @@ ENV NODE_ENV=production \
|
||||
|
||||
# Run as an unprivileged user. The library mount is read-only, so this user only
|
||||
# ever needs write access to /data.
|
||||
RUN addgroup -S reader && adduser -S reader -G reader \
|
||||
#
|
||||
# `su-exec` is a ~10KB static helper that drops privileges from an entrypoint;
|
||||
# Alpine's busybox has no equivalent (`setpriv` lives in util-linux, which the
|
||||
# base image does not carry), and `su` there needs a password hash to be set up.
|
||||
RUN apk add --no-cache su-exec \
|
||||
&& addgroup -S reader && adduser -S reader -G reader \
|
||||
&& mkdir -p /data /books /app/public && chown -R reader:reader /data /books /app/public
|
||||
|
||||
COPY --from=build --chown=reader:reader /app/node_modules ./node_modules
|
||||
@@ -61,7 +66,12 @@ COPY --from=build --chown=reader:reader /app/package.json ./
|
||||
# The built H5 client, served by the same process at `/`.
|
||||
COPY --from=web --chown=reader:reader /web/dist ./web
|
||||
|
||||
USER reader
|
||||
# The entrypoint starts as root purely to take ownership of the *mounted* /data
|
||||
# (a bind mount's uid/gid comes from the host and overrides the chown above),
|
||||
# then re-execs the server as `reader`. See the script for the full rationale.
|
||||
COPY --chown=root:root server/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod 0755 /usr/local/bin/docker-entrypoint.sh
|
||||
|
||||
EXPOSE 8080
|
||||
VOLUME ["/data"]
|
||||
|
||||
@@ -70,4 +80,5 @@ VOLUME ["/data"]
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
|
||||
CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||8080)+'/api/v1/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"]
|
||||
CMD ["node", "dist/main.js"]
|
||||
Executable
+45
@@ -0,0 +1,45 @@
|
||||
#!/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 "$@"
|
||||
@@ -76,7 +76,22 @@ function loadOrCreateSecret(dataDir: string, file = 'token.secret'): string {
|
||||
if (existing.length >= 16) return existing;
|
||||
}
|
||||
const generated = randomBytes(48).toString('base64url');
|
||||
writeFileSync(secretPath, generated, { mode: 0o600 });
|
||||
try {
|
||||
writeFileSync(secretPath, generated, { mode: 0o600 });
|
||||
} catch (err) {
|
||||
// A bare EACCES here is the single most common self-hosting failure: /data
|
||||
// is a bind mount whose host directory is root-owned while the server runs
|
||||
// as an unprivileged user. Say so, instead of surfacing a bare errno.
|
||||
const code = (err as NodeJS.ErrnoException).code;
|
||||
if (code === 'EACCES' || code === 'EPERM' || code === 'EROFS') {
|
||||
throw new Error(
|
||||
`cannot write ${secretPath} (${code}); DATA_DIR must be writable by the server. ` +
|
||||
`If /data is a bind mount, fix its ownership on the host (e.g. \`chown -R 100:100 ./data\`) ` +
|
||||
`or set READER_TOKEN_SECRET to a value of at least 16 characters to skip the file entirely.`,
|
||||
);
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
return generated;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
/**
|
||||
* Config-loading tests, focused on the failure mode that breaks a plain
|
||||
* `docker compose up` on a fresh checkout.
|
||||
*
|
||||
* The image runs the server as an unprivileged user, so a bind-mounted `/data`
|
||||
* whose host directory is root-owned makes the signing-key write fail with a
|
||||
* bare `EACCES`. These tests pin down both the happy path and the *explained*
|
||||
* error, because a raw errno here is what turns a one-line fix into a support
|
||||
* thread.
|
||||
*/
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtemp, mkdir, readFile, chmod, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { loadConfig } from '../src/config/index.ts';
|
||||
|
||||
const SECRET_ENV = ['READER_TOKEN_SECRET', 'JWT_SECRET'] as const;
|
||||
|
||||
async function withEnv(
|
||||
vars: Record<string, string | undefined>,
|
||||
fn: () => Promise<void> | void,
|
||||
): Promise<void> {
|
||||
const saved = new Map<string, string | undefined>();
|
||||
for (const key of [...SECRET_ENV, 'DATA_DIR', 'BOOKS_DIR']) {
|
||||
saved.set(key, process.env[key]);
|
||||
delete process.env[key];
|
||||
}
|
||||
Object.assign(process.env, vars);
|
||||
try {
|
||||
await fn();
|
||||
} finally {
|
||||
for (const [key, value] of saved) {
|
||||
if (value === undefined) delete process.env[key];
|
||||
else process.env[key] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('a fresh DATA_DIR gets a persisted, stable secret', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'reader-config-'));
|
||||
const dataDir = join(root, 'data');
|
||||
const booksDir = join(root, 'books');
|
||||
await mkdir(booksDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir, BOOKS_DIR: booksDir }, async () => {
|
||||
const first = loadConfig();
|
||||
assert.ok(first.jwtSecret.length >= 16, 'secret is long enough to sign with');
|
||||
|
||||
// Written 0600: the key is a credential, not world-readable config.
|
||||
const stat = await readFile(join(dataDir, 'token.secret'), 'utf8');
|
||||
assert.equal(stat.trim(), first.jwtSecret);
|
||||
|
||||
// A second boot must reuse it, or every client session would be dropped
|
||||
// on restart.
|
||||
const second = loadConfig();
|
||||
assert.equal(second.jwtSecret, first.jwtSecret);
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('READER_TOKEN_SECRET wins and skips the file entirely', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'reader-config-'));
|
||||
const dataDir = join(root, 'data');
|
||||
const booksDir = join(root, 'books');
|
||||
await mkdir(booksDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await withEnv(
|
||||
{ DATA_DIR: dataDir, BOOKS_DIR: booksDir, READER_TOKEN_SECRET: 'a-secret-set-by-the-operator' },
|
||||
async () => {
|
||||
const config = loadConfig();
|
||||
assert.equal(config.jwtSecret, 'a-secret-set-by-the-operator');
|
||||
// Nothing to persist, so no file is created.
|
||||
await assert.rejects(readFile(join(dataDir, 'token.secret'), 'utf8'));
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('an unwritable DATA_DIR fails with the fix, not a bare EACCES', async (t) => {
|
||||
if (process.getuid?.() === 0) {
|
||||
// root bypasses the permission bits, so the failure cannot be reproduced.
|
||||
t.skip('running as root; permission bits are not enforced');
|
||||
return;
|
||||
}
|
||||
|
||||
const root = await mkdtemp(join(tmpdir(), 'reader-config-'));
|
||||
const dataDir = join(root, 'data');
|
||||
const booksDir = join(root, 'books');
|
||||
await mkdir(dataDir, { recursive: true });
|
||||
await mkdir(booksDir, { recursive: true });
|
||||
await chmod(dataDir, 0o500);
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: dataDir, BOOKS_DIR: booksDir }, async () => {
|
||||
assert.throws(
|
||||
() => loadConfig(),
|
||||
(err: unknown) => {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
assert.match(message, /cannot write .*token\.secret/);
|
||||
assert.match(message, /DATA_DIR must be writable/);
|
||||
// The escape hatch is spelled out, so the reader has a way forward.
|
||||
assert.match(message, /READER_TOKEN_SECRET/);
|
||||
return true;
|
||||
},
|
||||
);
|
||||
});
|
||||
} finally {
|
||||
await chmod(dataDir, 0o700);
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test('DATA_DIR inside BOOKS_DIR is refused at boot', async () => {
|
||||
const root = await mkdtemp(join(tmpdir(), 'reader-config-'));
|
||||
const booksDir = join(root, 'books');
|
||||
await mkdir(booksDir, { recursive: true });
|
||||
|
||||
try {
|
||||
await withEnv({ DATA_DIR: join(booksDir, 'data'), BOOKS_DIR: booksDir }, () => {
|
||||
assert.throws(() => loadConfig(), /must not live inside BOOKS_DIR/);
|
||||
});
|
||||
} finally {
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
Reference in new issue
Block a user