diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index eb9078d4..81878753 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -5,6 +5,19 @@ set -e PUID=${USER_UID:-1000} PGID=${USER_GID:-1000} +# Without root we can neither remap the node user (usermod/groupmod/chown) +# nor switch users (gosu needs CAP_SETUID/CAP_SETGID), so exec directly. +# This covers Kubernetes restricted PodSecurity (runAsNonRoot + runAsUser) +# as well as platforms that assign arbitrary UIDs (e.g. OpenShift); for the +# latter a UID/GID mismatch is unfixable here, so warn instead of letting +# usermod fail cryptically and keep volume-permission issues diagnosable. +if [ "$(id -u)" -ne 0 ]; then + if [ "$(id -u)" -ne "$PUID" ] || [ "$(id -g)" -ne "$PGID" ]; then + echo "docker-entrypoint.sh: running unprivileged as $(id -u):$(id -g); cannot remap to requested ${PUID}:${PGID}" >&2 + fi + exec "$@" +fi + # Adjust the node user's UID/GID if they differ from the runtime request # and fix volume ownership only when a remap is needed changed=0 diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts new file mode 100644 index 00000000..3099f018 --- /dev/null +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -0,0 +1,122 @@ +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { execFile } from "node:child_process"; +import { mkdtempSync, readFileSync, rmSync, writeFileSync, existsSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; + +const execFileAsync = promisify(execFile); + +/** + * Behavioral tests for scripts/docker-entrypoint.sh privilege handling. + * + * The entrypoint must support two deployment shapes with one image: + * - Docker Compose: container starts as root, remaps the node user to + * USER_UID/USER_GID and drops privileges via gosu. + * - Kubernetes restricted PodSecurity / OpenShift arbitrary UIDs: the + * container starts non-root, where neither the remap nor gosu can work, + * so the command must be exec'd directly (with a warning on mismatch). + * + * The system commands (id, usermod, groupmod, chown, gosu) are stubbed via + * PATH so the branching logic runs unmodified on any host. + */ + +const ENTRYPOINT = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "..", "scripts", "docker-entrypoint.sh"); + +let stubDir: string; +let logFile: string; + +function writeStub(name: string, body: string) { + const path = join(stubDir, name); + writeFileSync(path, `#!/bin/sh\n${body}\n`, { mode: 0o755 }); +} + +function installStubs(ids: { uid: number; gid: number; nodeUid?: number; nodeGid?: number }) { + writeStub( + "id", + [ + `if [ "$1" = "-u" ] && [ "$2" = "node" ]; then echo ${ids.nodeUid ?? 1000};`, + `elif [ "$1" = "-g" ] && [ "$2" = "node" ]; then echo ${ids.nodeGid ?? 1000};`, + `elif [ "$1" = "-u" ]; then echo ${ids.uid};`, + `elif [ "$1" = "-g" ]; then echo ${ids.gid};`, + `else echo 0; fi`, + ].join("\n"), + ); + for (const cmd of ["usermod", "groupmod", "chown"]) { + writeStub(cmd, `echo "${cmd} $*" >> "${logFile}"`); + } + writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`); +} + +async function runEntrypoint(env: Record = {}) { + const result = await execFileAsync("sh", [ENTRYPOINT, "echo", "ENTRYPOINT-CMD-RAN"], { + env: { PATH: `${stubDir}:${process.env.PATH}`, ...env }, + }); + const calls = existsSync(logFile) ? readFileSync(logFile, "utf8") : ""; + return { stdout: result.stdout, stderr: result.stderr, calls }; +} + +beforeEach(() => { + stubDir = mkdtempSync(join(tmpdir(), "entrypoint-stubs-")); + logFile = join(stubDir, "calls.log"); +}); + +afterEach(() => { + rmSync(stubDir, { recursive: true, force: true }); +}); + +describe("docker-entrypoint.sh", () => { + it("keeps the root-start gosu flow with default UID/GID (Docker Compose)", async () => { + installStubs({ uid: 0, gid: 0 }); + + const { stdout, calls } = await runEntrypoint(); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); + expect(calls).not.toContain("usermod"); + expect(calls).not.toContain("chown"); + }); + + it("remaps the node user and chowns /paperclip before gosu when root requests a different UID/GID", async () => { + installStubs({ uid: 0, gid: 0 }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001" }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).toContain("usermod -o -u 1001 node"); + expect(calls).toContain("groupmod -o -g 1001 node"); + expect(calls).toContain("chown -R node:node /paperclip"); + expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); + }); + + it("execs directly and silently when already running as the requested user (restricted PodSecurity)", async () => { + installStubs({ uid: 1000, gid: 1000 }); + + const { stdout, stderr, calls } = await runEntrypoint(); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(stderr).toBe(""); + expect(calls).toBe(""); + }); + + it("execs directly with a warning for an arbitrary non-root UID (OpenShift-style)", async () => { + installStubs({ uid: 1234, gid: 1234 }); + + const { stdout, stderr, calls } = await runEntrypoint(); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(stderr).toContain("running unprivileged as 1234:1234; cannot remap to requested 1000:1000"); + expect(calls).toBe(""); + }); + + it("execs directly with a warning on a non-root GID mismatch", async () => { + installStubs({ uid: 1000, gid: 1001 }); + + const { stdout, stderr, calls } = await runEntrypoint(); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(stderr).toContain("running unprivileged as 1000:1001; cannot remap to requested 1000:1000"); + expect(calls).toBe(""); + }); +});