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 "$@"