diff --git a/scripts/docker-entrypoint.sh b/scripts/docker-entrypoint.sh index 1e723f849dc..2fc32ebc6b0 100644 --- a/scripts/docker-entrypoint.sh +++ b/scripts/docker-entrypoint.sh @@ -27,18 +27,57 @@ if [ "$(id -u)" -ne 0 ]; then exec "$@" fi -# Adjust the node user's UID/GID if they differ from the runtime request -if [ "$(id -u node)" -ne "$PUID" ]; then +# Adjust the node user's UID/GID if they differ from the runtime request. +# +# `usermod -u` and `usermod -g` also re-own every file under the user's home +# directory. When that home is the app home (the data volume), the walk can +# take minutes on a large home, and the app cannot start until it ends. In +# that case the remap runs while the home points at a placeholder path that +# does not exist, so usermod changes only the account, and the ownership probe +# below repairs the app home when the IDs really changed (the home directory +# itself then has the old owner, which the shallow probe detects on the same +# boot). A passwd home outside the app home is not covered by that probe, so +# it keeps the plain usermod behavior, which re-owns it. +# +# The placeholder embeds the original home, so a boot that stops between the +# two usermod -d calls is repaired on the next start of the same container. +remap_home_prefix="/nonexistent/paperclip-uid-remap" +node_home="$(getent passwd node 2>/dev/null | cut -d: -f6 || true)" +case "$node_home" in + "$remap_home_prefix"/*) + node_home="${node_home#"$remap_home_prefix"}" + echo "Restoring node home to $node_home after an interrupted remap" + usermod -d "$node_home" node + ;; +esac + +remap_uid=0 +remap_gid=0 +[ "$(id -u node)" -ne "$PUID" ] && remap_uid=1 +[ "$(id -g node)" -ne "$PGID" ] && remap_gid=1 +app_home="${PAPERCLIP_HOME:-/paperclip}" +move_home=0 +if { [ "$remap_uid" -eq 1 ] || [ "$remap_gid" -eq 1 ]; } \ + && [ -n "$node_home" ] && [ "${node_home%/}" = "${app_home%/}" ]; then + move_home=1 + usermod -d "$remap_home_prefix$node_home" node +fi + +if [ "$remap_uid" -eq 1 ]; then echo "Updating node UID to $PUID" usermod -o -u "$PUID" node fi -if [ "$(id -g node)" -ne "$PGID" ]; then +if [ "$remap_gid" -eq 1 ]; then echo "Updating node GID to $PGID" groupmod -o -g "$PGID" node usermod -g "$PGID" node fi +if [ "$move_home" -eq 1 ]; then + usermod -d "$node_home" node +fi + # Ensure the app home is owned by the runtime user BEFORE dropping # privileges -- not only after a UID/GID remap. A freshly mounted volume # (Docker named volume, Railway volume, Kubernetes PV) arrives root-owned diff --git a/server/src/__tests__/docker-entrypoint.test.ts b/server/src/__tests__/docker-entrypoint.test.ts index f71586f8094..1f3c3c8a445 100644 --- a/server/src/__tests__/docker-entrypoint.test.ts +++ b/server/src/__tests__/docker-entrypoint.test.ts @@ -43,6 +43,8 @@ type StubOptions = { deepMismatch?: boolean; /** The full-tree find exits non-zero without printing a mismatch (e.g. an I/O error). */ fullProbeFails?: boolean; + /** The node user's passwd home; null models a host without a node passwd entry. */ + nodeHome?: string | null; }; function installStubs(ids: StubOptions) { @@ -75,6 +77,13 @@ function installStubs(ids: StubOptions) { ].join("\n"), ); writeStub("gosu", `echo "gosu $*" >> "${logFile}"\nshift\nexec "$@"`); + const nodeHome = ids.nodeHome === undefined ? "/paperclip" : ids.nodeHome; + writeStub( + "getent", + nodeHome === null + ? "exit 2" + : `if [ "$1" = "passwd" ] && [ "$2" = "node" ]; then echo "node:x:${ids.nodeUid ?? 1000}:${ids.nodeGid ?? 1000}::${nodeHome}:/bin/bash"; else exit 2; fi`, + ); } async function runEntrypoint(env: Record = {}) { @@ -120,6 +129,82 @@ describe("docker-entrypoint.sh", () => { expect(calls).toContain("gosu node echo ENTRYPOINT-CMD-RAN"); }); + it("moves the app home away during the remap so usermod does not walk it", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true, nodeHome: stubDir }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + const lines = calls.trim().split("\n"); + // usermod re-owns files only under the current home, so every ID change + // runs while the home points at a placeholder that does not exist. + expect(lines.filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -o -u 1001 node", + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + // The ownership probe still repairs the app home after a real ID change. + expect(lines.indexOf(`chown -R node:node ${stubDir}`)).toBeGreaterThan(lines.indexOf(`usermod -d ${stubDir} node`)); + }); + + it("moves the app home away for a GID-only remap too", async () => { + installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1000, nodeHome: stubDir }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + }); + + it("keeps the plain remap when the passwd home is outside the app home", async () => { + // The ownership probe covers only the app home, so usermod must still + // re-own a separate passwd home. + installStubs({ uid: 0, gid: 0, nodeHome: "/paperclip" }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls).toContain("usermod -o -u 1001 node"); + expect(calls).not.toContain("usermod -d"); + }); + + it("remaps without moving the home when the node passwd entry is unavailable", async () => { + installStubs({ uid: 0, gid: 0, homeMismatch: true, nodeHome: null }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls).toContain("usermod -o -u 1001 node"); + expect(calls).not.toContain("usermod -d"); + }); + + it("restores the home left by an interrupted remap when the IDs already match", async () => { + installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, nodeHome: `/nonexistent/paperclip-uid-remap${stubDir}` }); + + const { stdout, calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(stdout).toContain("ENTRYPOINT-CMD-RAN"); + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d ${stubDir} node`, + ]); + }); + + it("restores the original home, not the placeholder, when an interrupted remap is retried", async () => { + installStubs({ uid: 0, gid: 0, nodeHome: `/nonexistent/paperclip-uid-remap${stubDir}` }); + + const { calls } = await runEntrypoint({ USER_UID: "1001", USER_GID: "1001", PAPERCLIP_HOME: stubDir }); + + expect(calls.trim().split("\n").filter((line) => line.startsWith("usermod "))).toEqual([ + `usermod -d ${stubDir} node`, + `usermod -d /nonexistent/paperclip-uid-remap${stubDir} node`, + "usermod -o -u 1001 node", + "usermod -g 1001 node", + `usermod -d ${stubDir} node`, + ]); + }); + it.each([false, true])("skips remapping a cloud identity while preserving volume repair (mismatch: %s)", async (homeMismatch) => { installStubs({ uid: 0, gid: 0, nodeUid: 1001, nodeGid: 1001, homeMismatch });