Grant the app user Docker socket access and a writable state dir, so downstreams stop wrapping the entrypoint #36

Open
opened 2026-08-06 00:52:54 +00:00 by geusebio · 0 comments
Owner

The base image drops privileges to app but ships nothing that makes app able to do
the two things every downstream then needs, so each project reimplements the same root-time
entrypoint wrapper. Both belong here, behind opt-in env vars.

#22, #23 and #24 fixed the three concrete instances of this — the Mercure bolt store, the
session directory, the privilege drop that has to reach both. This is the same family one
level up: those were specific paths the base image already owned; these are the paths a
downstream owns and currently has to fix itself.

# What downstreams reimplement Proposed knob Default
1 Add app to the Docker socket's group before the su-exec drop FRANKENPHP_DOCKER_SOCKET=On (+ DOCKER_SOCKET path override) off
2 chown app a bind-mounted writable state directory at boot APP_VAR_DIR=/app/var unset

Both must run as root, before /usr/local/bin/entrypoint — which is exactly the slot
the base image's own ENTRYPOINT occupies, so downstreams can only get there by wrapping it:

COPY --chmod=0755 docker/socket-entrypoint.sh /usr/local/bin/socket-entrypoint
ENTRYPOINT ["/usr/local/bin/socket-entrypoint"]
CMD ["/usr/local/bin/entrypoint"]

1. Docker socket group membership

Any image that mounts /var/run/docker.sock and talks to the Engine API from PHP needs
app in the socket's owning group. That group is not knowable at build time: the socket
is root:root (gid 0) on Docker Desktop and root:<docker> on a Linux host, so it has to be
resolved at runtime. someones.computer's wrapper, in full:

SOCK="${DOCKER_SOCKET:-/var/run/docker.sock}"
APP_USER=app

if [ -S "$SOCK" ]; then
    GID=$(stat -c '%g' "$SOCK")

    # Find (or create) a group with the socket's gid.
    GRP=$(awk -F: -v g="$GID" '$3==g {print $1; exit}' /etc/group)
    if [ -z "$GRP" ]; then
        GRP=docker
        addgroup -g "$GID" "$GRP" 2>/dev/null || true
    fi

    # Add the app user to it (no-op if already a member).
    addgroup "$APP_USER" "$GRP" 2>/dev/null || true
else
    echo "[socket-entrypoint] warning: $SOCK not present; orchestration disabled" 1>&2
fi

exec "$@"

Pure boilerplate, with three ways to get it wrong that are only discoverable by hitting them:

Trap Consequence
Hardcoding docker or a gid at build time Works on one host, silently fails on the other
A gid already claimed by another group name addgroup -g fails; membership never granted
Dropping privileges with anything but initgroups() Supplementary group discarded — the whole script becomes a no-op

The third is #22 verbatim, and it's the one that argues hardest for upstreaming: the base
image's choice of su-exec over chpst is load-bearing for this script, and nothing about
the script says so unless the author already knows. Downstream services that also drop
privileges have to make the same choice by hand — docker/services.d/worker/run here carries
a paragraph of comment explaining why it is su-exec app php bin/console messenger:consume …
and not chpst -u app.

Defaulting the knob off keeps images with no socket mounted byte-identical in behaviour; the
[ -S "$SOCK" ] guard already makes the script a no-op there, but an explicit opt-in also
means no surprise /etc/group mutation in images that never asked.

2. Ownership of the writable state directory

Anything the app writes must be owned by app. Nothing keeps it that way, because
docker compose exec <svc> … runs as root — the image's USER is root and runit drops
privileges only for supervised services. So every ordinary operational command leaves
root-owned files behind in a bind-mounted state directory:

Writer Runs as Writes
Supervised services (frankenphp, ours: worker) app the app's own runtime state
docker compose exec app … (migrations, seeds, asset builds) root whatever that command touches
Host-side composer install / composer test:coverage host user var/cache/, var/coverage/, var/test_*.db

Our concrete failure (Grey.ooo/someones.computer#197): a root-owned SQLite session file.

-rw-r--r--  1 root  root  32768  /app/var/sessions.db

Still world-readable, so sign-in succeeded — and was then instantly forgotten when the
write at session close failed. A 302 straight back to /login with no flash, no error
page, and no log line
. It reads exactly like wrong credentials, so the search goes to the
user table, the firewall config and CSRF before it goes to ls -la var/. chown app:app
fixed it on the next attempt.

Suggested behaviour: if APP_VAR_DIR is set, chown app it at boot, non-recursively
by default. Recursion is the wrong default here, not just a slow one — these directories hold
caches and coverage data, and recursing over tens of thousands of small files on a bind mount
(especially a Docker Desktop virtiofs/gRPC-FUSE mount) adds seconds to every container start
for no benefit. A APP_VAR_DIR_RECURSIVE=On escape hatch covers the cases that genuinely
need it.

Why this one generalises

Every de-privileged image with a bind-mounted writable directory has this bug latent in it.
The ingredients are all supplied by the base image and the standard workflow, not by anything
the downstream chose:

  • the base image picks the runtime user and drops to it;
  • USER stays root, so exec is root — that is Docker's behaviour, not ours;
  • bind-mounting a project directory in dev is the normal case.

And the failure mode is silence, not an error. #23 at least emitted
SessionHandler::write(): … Permission denied warnings; this emits nothing, because from
PHP's point of view SQLite simply reported a failed write on a file it could open. A class of
bug where the only signal is "the thing you did didn't happen" is worth fixing centrally,
because the per-project cost is not the one-line chown — it's the hour before you think to
look at ownership.

Done when

  • FRANKENPHP_DOCKER_SOCKET=On resolves ${DOCKER_SOCKET:-/var/run/docker.sock}'s gid at
    boot, finds-or-creates a matching group, and adds app to it — before the privilege drop
  • Unset/Off leaves /etc/group untouched; set-but-socket-absent warns on stderr and
    continues rather than failing the boot
  • Verified on both gid layouts: root:root (gid 0, Docker Desktop) and root:docker
    (Linux host), including the case where the socket's gid already belongs to a
    differently-named group
  • docker run … sh -c 'su-exec app curl -s --unix-socket /var/run/docker.sock http://localhost/version'
    returns 200 with the flag on
  • APP_VAR_DIR=<path> chowns that path to app at boot, non-recursively
  • APP_VAR_DIR_RECURSIVE=On (or equivalent) opts into recursion; unset APP_VAR_DIR is a
    no-op
  • Both run as root before /usr/local/bin/entrypoint, so no downstream needs to wrap the
    ENTRYPOINT to reach that slot
  • Documented alongside the existing PHP_* / FRANKENPHP_* toggles, with the su-exec
    vs chpst note (#22) stated where the socket flag is described — a downstream service
    script that drops privileges itself has to make the same choice
  • someones.computer can delete docker/socket-entrypoint.sh and its ENTRYPOINT/CMD
    wrapping, keeping only the two env vars
Issue Covered
#22 chpst -u app drops supplementary groups — the mechanism both items here depend on
#23 /var/lib/php-zts/session unwritable by the server user
#24 HOME=/root survives the drop, blocking the Mercure bolt store
Grey.ooo/someones.computer#197 The silent-login write-up behind item 2

#22/#23/#24 fixed base-image-owned paths. Item 2 is the same failure on a
downstream-owned path, which is why it needs a knob rather than a fix.

The base image drops privileges to `app` but ships nothing that makes `app` *able* to do the two things every downstream then needs, so each project reimplements the same root-time entrypoint wrapper. Both belong here, behind opt-in env vars. #22, #23 and #24 fixed the three concrete instances of this — the Mercure bolt store, the session directory, the privilege drop that has to reach both. This is the same family one level up: those were *specific paths* the base image already owned; these are the paths a downstream owns and currently has to fix itself. | # | What downstreams reimplement | Proposed knob | Default | |---|---|---|---| | 1 | Add `app` to the Docker socket's group before the `su-exec` drop | `FRANKENPHP_DOCKER_SOCKET=On` (+ `DOCKER_SOCKET` path override) | off | | 2 | `chown app` a bind-mounted writable state directory at boot | `APP_VAR_DIR=/app/var` | unset | Both must run **as root, before** `/usr/local/bin/entrypoint` — which is exactly the slot the base image's own ENTRYPOINT occupies, so downstreams can only get there by wrapping it: ```dockerfile COPY --chmod=0755 docker/socket-entrypoint.sh /usr/local/bin/socket-entrypoint ENTRYPOINT ["/usr/local/bin/socket-entrypoint"] CMD ["/usr/local/bin/entrypoint"] ``` --- ## 1. Docker socket group membership Any image that mounts `/var/run/docker.sock` and talks to the Engine API from PHP needs `app` in the socket's owning group. That group is **not** knowable at build time: the socket is `root:root` (gid 0) on Docker Desktop and `root:<docker>` on a Linux host, so it has to be resolved at runtime. `someones.computer`'s wrapper, in full: ```sh SOCK="${DOCKER_SOCKET:-/var/run/docker.sock}" APP_USER=app if [ -S "$SOCK" ]; then GID=$(stat -c '%g' "$SOCK") # Find (or create) a group with the socket's gid. GRP=$(awk -F: -v g="$GID" '$3==g {print $1; exit}' /etc/group) if [ -z "$GRP" ]; then GRP=docker addgroup -g "$GID" "$GRP" 2>/dev/null || true fi # Add the app user to it (no-op if already a member). addgroup "$APP_USER" "$GRP" 2>/dev/null || true else echo "[socket-entrypoint] warning: $SOCK not present; orchestration disabled" 1>&2 fi exec "$@" ``` Pure boilerplate, with three ways to get it wrong that are only discoverable by hitting them: | Trap | Consequence | |---|---| | Hardcoding `docker` or a gid at build time | Works on one host, silently fails on the other | | A gid already claimed by another group name | `addgroup -g` fails; membership never granted | | Dropping privileges with anything but `initgroups()` | Supplementary group discarded — the whole script becomes a no-op | The third is #22 verbatim, and it's the one that argues hardest for upstreaming: the base image's choice of `su-exec` over `chpst` is *load-bearing* for this script, and nothing about the script says so unless the author already knows. Downstream services that also drop privileges have to make the same choice by hand — `docker/services.d/worker/run` here carries a paragraph of comment explaining why it is `su-exec app php bin/console messenger:consume …` and not `chpst -u app`. Defaulting the knob off keeps images with no socket mounted byte-identical in behaviour; the `[ -S "$SOCK" ]` guard already makes the script a no-op there, but an explicit opt-in also means no surprise `/etc/group` mutation in images that never asked. ## 2. Ownership of the writable state directory Anything the app writes must be owned by `app`. Nothing keeps it that way, because `docker compose exec <svc> …` runs as **root** — the image's `USER` is root and runit drops privileges only for supervised services. So every ordinary operational command leaves root-owned files behind in a bind-mounted state directory: | Writer | Runs as | Writes | |---|---|---| | Supervised services (`frankenphp`, ours: `worker`) | `app` | the app's own runtime state | | `docker compose exec app …` (migrations, seeds, asset builds) | **root** | whatever that command touches | | Host-side `composer install` / `composer test:coverage` | host user | `var/cache/`, `var/coverage/`, `var/test_*.db` | Our concrete failure (Grey.ooo/someones.computer#197): a root-owned SQLite session file. ``` -rw-r--r-- 1 root root 32768 /app/var/sessions.db ``` Still world-**readable**, so sign-in succeeded — and was then instantly forgotten when the write at session close failed. A 302 straight back to `/login` with **no flash, no error page, and no log line**. It reads exactly like wrong credentials, so the search goes to the user table, the firewall config and CSRF before it goes to `ls -la var/`. `chown app:app` fixed it on the next attempt. **Suggested behaviour:** if `APP_VAR_DIR` is set, `chown app` it at boot, **non-recursively** by default. Recursion is the wrong default here, not just a slow one — these directories hold caches and coverage data, and recursing over tens of thousands of small files on a bind mount (especially a Docker Desktop virtiofs/gRPC-FUSE mount) adds seconds to every container start for no benefit. A `APP_VAR_DIR_RECURSIVE=On` escape hatch covers the cases that genuinely need it. ### Why this one generalises Every de-privileged image with a bind-mounted writable directory has this bug latent in it. The ingredients are all supplied by the base image and the standard workflow, not by anything the downstream chose: - the base image picks the runtime user and drops to it; - `USER` stays root, so `exec` is root — that is Docker's behaviour, not ours; - bind-mounting a project directory in dev is the normal case. And the failure mode is **silence**, not an error. #23 at least emitted `SessionHandler::write(): … Permission denied` warnings; this emits nothing, because from PHP's point of view SQLite simply reported a failed write on a file it could open. A class of bug where the only signal is "the thing you did didn't happen" is worth fixing centrally, because the per-project cost is not the one-line `chown` — it's the hour before you think to look at ownership. ## Done when - [ ] `FRANKENPHP_DOCKER_SOCKET=On` resolves `${DOCKER_SOCKET:-/var/run/docker.sock}`'s gid at boot, finds-or-creates a matching group, and adds `app` to it — before the privilege drop - [ ] Unset/`Off` leaves `/etc/group` untouched; set-but-socket-absent warns on stderr and continues rather than failing the boot - [ ] Verified on both gid layouts: `root:root` (gid 0, Docker Desktop) and `root:docker` (Linux host), including the case where the socket's gid already belongs to a differently-named group - [ ] `docker run … sh -c 'su-exec app curl -s --unix-socket /var/run/docker.sock http://localhost/version'` returns 200 with the flag on - [ ] `APP_VAR_DIR=<path>` chowns that path to `app` at boot, non-recursively - [ ] `APP_VAR_DIR_RECURSIVE=On` (or equivalent) opts into recursion; unset `APP_VAR_DIR` is a no-op - [ ] Both run as root before `/usr/local/bin/entrypoint`, so no downstream needs to wrap the ENTRYPOINT to reach that slot - [ ] Documented alongside the existing `PHP_*` / `FRANKENPHP_*` toggles, with the `su-exec` vs `chpst` note (#22) stated where the socket flag is described — a downstream service script that drops privileges itself has to make the same choice - [ ] `someones.computer` can delete `docker/socket-entrypoint.sh` and its `ENTRYPOINT`/`CMD` wrapping, keeping only the two env vars ## Related | Issue | Covered | |---|---| | #22 | `chpst -u app` drops supplementary groups — the mechanism both items here depend on | | #23 | `/var/lib/php-zts/session` unwritable by the server user | | #24 | `HOME=/root` survives the drop, blocking the Mercure bolt store | | Grey.ooo/someones.computer#197 | The silent-login write-up behind item 2 | #22/#23/#24 fixed base-image-owned paths. Item 2 is the same failure on a **downstream-owned** path, which is why it needs a knob rather than a fix.
Sign in to join this conversation.
No labels
No milestone
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
Grey.ooo/docker#36
No description provided.