From e3c4ba03add2ebf7acd963df2992648bb2cfdf60 Mon Sep 17 00:00:00 2001 From: clawbot <35+clawbot@noreply.example.org> Date: Tue, 29 Sep 2026 09:21:19 +0000 Subject: [PATCH] Container sets its data directory's owner and mode itself (closes #340) The image now starts as root through deploy/docker-entrypoint.sh, which creates DATA_DIR if it is missing, gives the directory and anything in it owned by another user to webhooker, sets the directory to 0750, and runs the command as webhooker with su-exec. An empty root-owned bind mount, or data left by another uid, now works with no step on the host. Started with --user, the script only runs the command. The README drops every instruction to create or chown the host directory; the upaas volume bullet names only the path. Model: opus-5-5 --- Dockerfile | 11 +++- README.md | 120 +++++++++++++----------------------- deploy/docker-entrypoint.sh | 22 +++++++ 3 files changed, 74 insertions(+), 79 deletions(-) create mode 100755 deploy/docker-entrypoint.sh diff --git a/Dockerfile b/Dockerfile index bb47620..907ae40 100644 --- a/Dockerfile +++ b/Dockerfile @@ -88,7 +88,9 @@ RUN CGO_ENABLED=1 make build VERSION="$VERSION" GO_LDFLAGS='-extldflags "-static # alpine:3.21, 2026-03-17 FROM alpine:3.21@sha256:c3f8e73fdb79deaebaa2037150150191b9dcbfba68b4a46d70103204c53f4709 -RUN apk --no-cache add ca-certificates +# su-exec 0.2-r3 (Alpine 3.21), 2026-09-29: the entrypoint runs the app +# as webhooker with it. +RUN apk --no-cache add ca-certificates su-exec=0.2-r3 # Create non-root user RUN addgroup -g 1000 -S webhooker && \ @@ -99,13 +101,17 @@ WORKDIR /app # Copy binary from builder COPY --from=builder /build/bin/webhooker /app/webhooker +# Not under /app, which belongs to webhooker: this script runs as root. +COPY deploy/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh + # Create data directory for all SQLite databases (main app DB + # per-webhook event DBs). DATA_DIR defaults to /var/lib/webhooker. RUN mkdir -p /var/lib/webhooker RUN chown -R webhooker:webhooker /app /var/lib/webhooker -USER webhooker +# No USER: the entrypoint starts as root to make the data directory +# webhooker's, then runs the app as webhooker. EXPOSE 8080 @@ -124,4 +130,5 @@ ENV BIND_ADDRESS=0.0.0.0 HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ CMD wget --no-verbose --tries=1 --spider http://localhost:8080/.well-known/healthcheck || exit 1 +ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] CMD ["/app/webhooker"] diff --git a/README.md b/README.md index 0536a4e..e646274 100644 --- a/README.md +++ b/README.md @@ -552,8 +552,9 @@ printf '%s' "$NEW_PASSWORD" | \ DATA_DIR=/var/lib/webhooker webhooker resetpw admin ``` -In a container it is the same binary, which the image sets as `CMD` -rather than `ENTRYPOINT`, so the whole command has to be given: +In a container it is the same binary. The image's `CMD` is +`/app/webhooker`, and a command given to `docker run` replaces all of +it, so the whole command has to be given: ```bash docker run --rm -v webhooker-data:/var/lib/webhooker \ @@ -690,38 +691,22 @@ those three values rather than trusting the figure. Measured at 65s on Docker 29.7.2.) A container `unhealthy` with `connection refused` in its health log, or a published port that resets connections, is this. -The container runs as a non-root user (`webhooker`, UID 1000), exposes -port 8080, and includes a health check against -`/.well-known/healthcheck`. The `/var/lib/webhooker` volume holds all -SQLite databases: the main application database (`webhooker.db`), the -per-webhook event databases (`events-{uuid}.db`), and any archive -databases written by `database` targets (`archive-{uuid}.db`). Mount -this as a persistent volume to preserve data across container -restarts. +The app runs as a non-root user (`webhooker`, UID 1000), exposes port +8080, and includes a health check against `/.well-known/healthcheck`. +The `/var/lib/webhooker` volume holds all SQLite databases: the main +application database (`webhooker.db`), the per-webhook event databases +(`events-{uuid}.db`), and any archive databases written by `database` +targets (`archive-{uuid}.db`). Mount this as a persistent volume to +preserve data across container restarts. -**The bind-mounted directory must be owned by UID 1000, or the -container does not start.** Docker creates a `-v` source path that -does not exist yet as `root:root`, and the process runs as UID 1000, -so it cannot take its `DATA_DIR` lock: - -``` -webhooker: locking data directory /var/lib/webhooker: open -/var/lib/webhooker/webhooker.lock: permission denied -``` - -It exits non-zero at that point, before opening any database. Create -the directory ahead of the first `docker run`: - -```bash -mkdir -p /path/to/data -chown 1000:1000 /path/to/data -chmod 750 /path/to/data -``` - -The same `chown` is what a restore needs — see step 4 of -[Restore](#restore). A **named volume** does not have this problem: -Docker copies the image's ownership onto a volume it initializes, and -the image creates `/var/lib/webhooker` owned by `webhooker`. +**The container sets its data directory's owner and mode itself +before the app starts**, so a host directory can be mounted as it is, +whoever owns it. The image's `ENTRYPOINT`, +`deploy/docker-entrypoint.sh`, starts as root, creates `DATA_DIR` if +it is missing, gives the directory and anything in it that belongs to +another user to `webhooker`, sets the directory to `0750`, and only +then runs the app as `webhooker`. Started with `--user`, it changes +nothing and runs the app as that user. **The file modes are not yours to set, and do not depend on the directory.** `webhooker.db` holds target configuration in plaintext — @@ -729,13 +714,10 @@ bearer tokens, API keys, Slack webhook URLs — along with the session encryption key, so webhooker creates every SQLite file it owns `0600`: each database and both of its `-wal` and `-shm` sidecars, across all three tiers. Files an earlier build left `0644` are tightened when -they are opened. A `DATA_DIR` webhooker creates itself is `0750`, but -a bind mount supplies its own directory and Docker's default for one -it creates is `0755`; the `0600` files hold there regardless. The -`chmod 750` above is defence in depth — it stops other local users -listing the directory and learning your webhook UUIDs from the -`events-{uuid}.db` filenames — not the barrier protecting the -credentials. +they are opened. The directory's `0750` is defence in depth — it stops +other local users listing the directory and learning your webhook +UUIDs from the `events-{uuid}.db` filenames — not the barrier +protecting the credentials. ### Running under upaas @@ -751,17 +733,6 @@ repository's `Dockerfile` and runs it. The app needs: app name, port `8080`. Leave `PORT` unset: the image's health check probes `8080`. - **Volume:** one host directory mounted at `/var/lib/webhooker`. - upaas bind-mounts the host path it is given and does not create it, - and the container does not start unless UID 1000 owns it (see - [Running with Docker](#running-with-docker)). Create it before the - first deploy: - - ```bash - mkdir -p /path/to/data - chown 1000:1000 /path/to/data - chmod 750 /path/to/data - ``` - - **Environment variables:** - `WEBHOOKER_ENVIRONMENT=prod` - `TRUSTED_PROXIES`: your reverse proxy's address on that Docker @@ -1020,12 +991,12 @@ done `.backup` reads through the WAL and writes a single consistent file with no sidecars of its own, so the destination is complete as it stands. Two caveats. First, the runtime image is `alpine:3.21` with only -`ca-certificates` added — the `sqlite3` CLI is **not** in it, so run -this on the host against the volume path, or from a throwaway container -that mounts the volume. Second, each file is captured at its own -instant, so a webhook created or an event delivered between two files -being copied lands in one and not the other. If you need the whole set -coherent as of a single moment, stop the service. +`ca-certificates` and `su-exec` added — the `sqlite3` CLI is **not** in +it, so run this on the host against the volume path, or from a +throwaway container that mounts the volume. Second, each file is +captured at its own instant, so a webhook created or an event delivered +between two files being copied lands in one and not the other. If you +need the whole set coherent as of a single moment, stop the service. Note that `sqlite3 .dump` is **not** one of these procedures: it is an export, it holds a read transaction open for as long as it runs, and @@ -1079,21 +1050,11 @@ with any `-wal`/`-shm` beside it, or wait until there are none. archive not opened since a crash. A copy salvaged from a crashed instance has them for everything, and needs all of them. -4. **Fix ownership.** The container runs as the non-root `webhooker` - user, UID 1000 / GID 1000. Restored files must be owned by (or - writable by) that UID, and so must the directory itself — SQLite - creates the `-wal` and `-shm` sidecars beside the database, so a - writable file inside a directory it cannot write is not enough: - - ```bash - chown -R 1000:1000 /path/to/data - ``` - - Restoring as `root` on the host and forgetting this step is the - usual way a restore fails. - -5. Start the service. `AutoMigrate` runs against each restored database - as it is opened. +4. Start the service. The container gives the directory and the + restored files to the `webhooker` user before the app starts, + whoever restored them (see + [Running with Docker](#running-with-docker)). `AutoMigrate` runs + against each restored database as it is opened. ### Upgrades @@ -3071,7 +3032,9 @@ check, see [The login endpoint](#the-login-endpoint). - Prometheus metrics behind basic auth - Static assets embedded in binary (no filesystem access needed at runtime) -- Container runs as non-root user (UID 1000) +- The app runs as a non-root user (UID 1000) in the container; only + the `ENTRYPOINT` script that sets the data directory's owner runs as + root, before the app starts - GORM soft deletes on every entity that carries `BaseModel`, which is all of them but `Setting` (data preserved for audit) @@ -3198,10 +3161,13 @@ version is fixed independently of the compiler's: `GO_LDFLAGS`, so neither can drop the `-X` that stamps the version. The version arrives as the `VERSION` build arg, since the context has no `.git` (see [Version stamping](#version-stamping)). -3. **Runtime stage** (`alpine:3.21`) — copies the static binary, - creates the `/var/lib/webhooker` directory for all SQLite databases, - runs as the non-root `webhooker` user (UID 1000), exposes port 8080, - and includes a health check against `/.well-known/healthcheck`. +3. **Runtime stage** (`alpine:3.21`) — copies the static binary and + `deploy/docker-entrypoint.sh`, creates the `/var/lib/webhooker` + directory for all SQLite databases, exposes port 8080, and includes + a health check against `/.well-known/healthcheck`. It sets no + `USER`: the `ENTRYPOINT` script starts as root, sets the data + directory's owner and mode, and runs the app as the non-root + `webhooker` user (UID 1000) through `su-exec`. The lint stage invokes `golangci-lint` directly rather than `make lint`: it is already the pinned linter image, and `make lint` builds diff --git a/deploy/docker-entrypoint.sh b/deploy/docker-entrypoint.sh new file mode 100755 index 0000000..b081336 --- /dev/null +++ b/deploy/docker-entrypoint.sh @@ -0,0 +1,22 @@ +#!/bin/sh +# deploy/docker-entrypoint.sh: the image's ENTRYPOINT. A bind-mounted +# data directory keeps its owner from the host, often root, and the app +# could not write to it. Started as root, this creates DATA_DIR if +# needed, gives it and everything in it to webhooker, sets its mode, and +# runs the command as webhooker, so the app never runs as root. Started +# as another user, it only runs the command. +set -eu + +main() { + if [ "$(id -u)" != 0 ]; then + exec "$@" + fi + + dir="${DATA_DIR:-/var/lib/webhooker}" + mkdir -p "$dir" + find "$dir" ! -user webhooker -exec chown -h webhooker:webhooker {} + + chmod 750 "$dir" + exec su-exec webhooker "$@" +} + +main "$@"