frankenphp: keep groups, HOME and the session dir across the privilege drop #31

Merged
geusebio merged 3 commits from fix/frankenphp-privilege-drop into main 2026-08-04 12:32:13 +00:00
Owner

Three bugs with one root cause area: the frankenphp service script dropped
privileges with chpst -u app, and everything root was carrying — groups,
HOME, directory ownership — either got thrown away or came along unchanged
when it should not have.

All three failed quietly. Nothing crashed; the image just did less than it
claimed. That is the main reason each fix here comes with an assertion.

#22 — supplementary groups

chpst sets only uid and gid, so addgroup app <group> before the service
starts was a silent no-op. Replaced with su-exec, which calls initgroups()
(new apk package, ~10KB). Verified against a real bind-mounted socket:

socket gid=0  perms=srw-rw----
chpst:   000
su-exec: 200

That is the Docker Engine API /version endpoint, unreachable before and
answering now.

#23 — session directory

The php-zts package points session.save_path at /var/lib/php-zts/session,
owned root:frankenphp 0770 — unwritable by app, so every session read and
write failed with a warning and nobody stayed logged in. The directory now
belongs to app, with the group and mode kept so membership of the
frankenphp group still grants access (which now actually works, given #22).
The path is read back from ini_get() at build time rather than hardcoded.

Before / after, same probe page, two requests with a cookie jar:

before:  hits=1 home=/root      after:  hits=1 home=/home
         hits=1 home=/root              hits=2 home=/home

#24 — HOME

Half of this was already fixed on main but unreleased: 25aba96 added
XDG_DATA_HOME=/var/cache/frankenphp, and I confirmed by building main that
FRANKENPHP_MERCURE=On does boot and serve the hub — the blocking error in
the issue is gone. It is not fully fixed, though. HOME was still /root, so
main logs on every boot:

"unable to create folder for config autosave","dir":"/root/.config/caddy",
"error":"mkdir /root/.config: permission denied"

and ~ resolved somewhere the server cannot read, hiding the known_hosts the
image installs in the app user's home. HOME now follows the drop; that error
is gone and the container logs zero Caddy errors.

Test coverage

  • Image build asserts a group added to app survives the drop. This has to
    be root-side, so it is a RUN in the Dockerfile rather than a
    /etc/validate.d script — validate runs each script as app via su.
  • /etc/validate.d/frankenphp.sh asserts session.save_path is writable.
    Running as app is exactly right here: it is the server's own view.
  • frankenphp-85-session-healthchecks (new) is healthy only when a session
    survives between two requests and the server reports the app user's HOME.
    Confirmed it goes unhealthy against a pre-fix image.
  • frankenphp-85-mercure-healthchecks now asks the hub for a subscription
    instead of settling for the site root, so it exercises the bolt store under
    XDG_DATA_HOME — the thing #24 was about.

Verification

./test green locally, and ./test test-frankenphp green across 8.2/8.3/8.4/
8.5 on native aarch64.

Worth flagging about CI: the test job does not build the FrankenPHP images
(they are behind the separate test-frankenphp group, per the x86-64-v3/v4
note in the README), so a green run here does not exercise the new session
healthcheck. The build job does bake the images on both arches, so the two
build-time assertions are covered by CI.

Closes #22
Closes #23
Closes #24

Three bugs with one root cause area: the frankenphp service script dropped privileges with `chpst -u app`, and everything root was carrying — groups, `HOME`, directory ownership — either got thrown away or came along unchanged when it should not have. All three failed *quietly*. Nothing crashed; the image just did less than it claimed. That is the main reason each fix here comes with an assertion. ## #22 — supplementary groups `chpst` sets only uid and gid, so `addgroup app <group>` before the service starts was a silent no-op. Replaced with `su-exec`, which calls `initgroups()` (new apk package, ~10KB). Verified against a real bind-mounted socket: ``` socket gid=0 perms=srw-rw---- chpst: 000 su-exec: 200 ``` That is the Docker Engine API `/version` endpoint, unreachable before and answering now. ## #23 — session directory The `php-zts` package points `session.save_path` at `/var/lib/php-zts/session`, owned `root:frankenphp 0770` — unwritable by `app`, so every session read and write failed with a warning and nobody stayed logged in. The directory now belongs to `app`, with the group and mode kept so membership of the `frankenphp` group still grants access (which now actually works, given #22). The path is read back from `ini_get()` at build time rather than hardcoded. Before / after, same probe page, two requests with a cookie jar: ``` before: hits=1 home=/root after: hits=1 home=/home hits=1 home=/root hits=2 home=/home ``` ## #24 — HOME Half of this was already fixed on main but unreleased: 25aba96 added `XDG_DATA_HOME=/var/cache/frankenphp`, and I confirmed by building main that `FRANKENPHP_MERCURE=On` **does** boot and serve the hub — the blocking error in the issue is gone. It is not fully fixed, though. `HOME` was still `/root`, so main logs on every boot: ``` "unable to create folder for config autosave","dir":"/root/.config/caddy", "error":"mkdir /root/.config: permission denied" ``` and `~` resolved somewhere the server cannot read, hiding the `known_hosts` the image installs in the app user's home. `HOME` now follows the drop; that error is gone and the container logs zero Caddy errors. ## Test coverage - **Image build** asserts a group added to `app` survives the drop. This has to be root-side, so it is a `RUN` in the Dockerfile rather than a `/etc/validate.d` script — `validate` runs each script as `app` via `su`. - **`/etc/validate.d/frankenphp.sh`** asserts `session.save_path` is writable. Running as `app` is exactly right here: it is the server's own view. - **`frankenphp-85-session-healthchecks`** (new) is healthy only when a session survives between two requests *and* the server reports the app user's `HOME`. Confirmed it goes unhealthy against a pre-fix image. - **`frankenphp-85-mercure-healthchecks`** now asks the hub for a subscription instead of settling for the site root, so it exercises the bolt store under `XDG_DATA_HOME` — the thing #24 was about. ## Verification `./test` green locally, and `./test test-frankenphp` green across 8.2/8.3/8.4/ 8.5 on native aarch64. Worth flagging about CI: the `test` job does not build the FrankenPHP images (they are behind the separate `test-frankenphp` group, per the x86-64-v3/v4 note in the README), so a green run here does **not** exercise the new session healthcheck. The `build` job does bake the images on both arches, so the two build-time assertions are covered by CI. Closes #22 Closes #23 Closes #24
The service script handed the server to the app user with `chpst -u app`,
which sets only uid and gid. Supplementary groups were discarded, so
`addgroup app <group>` before the service starts -- the portable way to
reach a bind-mounted /var/run/docker.sock, whose gid differs between
Docker Desktop and a Linux host -- was a silent no-op, and group-based
access to anything else was equally out of reach. su-exec calls
initgroups(), so the drop now keeps them; it is a new apk package, but a
small one.

That leaves two things the drop was carrying over from root. The php-zts
package points session.save_path at a directory it owns root:frankenphp
0770, which the app user cannot write: every session read and write
failed with a warning, so an app looked fine but never kept anyone
logged in. The directory now belongs to app, keeping the group and mode
so membership of the frankenphp group still grants access. And HOME
stayed at root's /root, where Caddy could not create its config autosave
directory and where the known_hosts the image installs in the app user's
home was invisible; it now follows the user the server runs as.

Regression coverage, since all three failed quietly rather than loudly:
the image build asserts that a group added to app survives the drop
(root-only, so it cannot live in /etc/validate.d, which runs as app),
/etc/validate.d/frankenphp.sh asserts session.save_path is writable from
the server's own uid, and a new frankenphp-85-session-healthchecks
service is healthy only when a session survives between two requests and
the server reports the app user's HOME. The Mercure healthcheck now asks
the hub for a subscription rather than settling for the site root, which
is what exercises its bolt store under XDG_DATA_HOME.
Merge remote-tracking branch 'origin/main' into fix/frankenphp-privilege-drop
Some checks failed
Build / Run container tests (push) Failing after 2m54s
Build / Build (push) Has been skipped
89a47f8795
ci: re-trigger after a BuildKit frontend crash on base-alpine-21-assertions
Some checks failed
Build / Run container tests (push) Successful in 27m28s
Build / Build (push) Has been cancelled
0de8a15bf4
The run for 89a47f8 died in 92s with 'failed to run Build function: frontend
grpc server closed unexpectedly' against Alpine.Dockerfile, a file this branch
does not touch. Runs 263 and 264 failed the same way on other branches.
Sign in to join this conversation.
No reviewers
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!31
No description provided.