mirror of
https://github.com/hectorqin/reader.git
synced 2026-11-11 13:31:09 +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 + 降权流程 手动模拟通过。
133 lines
4.6 KiB
TypeScript
133 lines
4.6 KiB
TypeScript
/**
|
|
* 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 });
|
|
}
|
|
});
|