diff --git a/.env.example b/.env.example index 0bd3ab6..5e23a0b 100644 --- a/.env.example +++ b/.env.example @@ -72,7 +72,9 @@ VIRTUAL_PORT=8443 DEFAULT_EMAIL= # UID the container runs as; it must be able to write ./data on the host. # The entrypoint's default (and the README's) is 9001 — keep them equal so -# the file owner on the host matches what you expect. +# the file owner on the host matches what you expect. Must be a non-zero +# numeric uid: the entrypoint refuses 0 (the bot would keep root through the +# privilege drop) and anything non-numeric. LOCAL_USER_ID=9001 # Uncomment (here and the matching line in docker-compose.yml) to have # acme-companion issue the certificate for VIRTUAL_HOST. diff --git a/AGENTS.md b/AGENTS.md index 5041b40..751c8c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -87,7 +87,7 @@ Docker: `docker build -t tgxmb .` then `docker run --rm -d --name tgxmb --env-fi | `crates/x-media/src/site/{mod,download}.rs` | `mod.rs`: dispatcher, `Fetched`/`FetchError`, `needs_media_headers` (the per-site rule, asked by the inline path to skip what Telegram cannot fetch). `download.rs`: the media-download stack — the metadata vs. media HTTP clients, the host-network guard (applied to the start URL and every redirect hop) and `download_media_limited`/`download_media_to_file` (which add the site's headers, e.g. `Referer: https://www.pixiv.net/` for `pximg.net`) | | `crates/x-media/src/site/pixiv/api.rs` | OAuth token exchange (hardcoded app client id/secret), access-token cache, ugoira zip→MP4 via ffmpeg in `spawn_blocking` | | `Dockerfile` | Multi-stage: cached dep layer via stub sources + `touch *.rs` mtime bump (cargo's freshness is mtime-based and `cargo clean -p` removes 0 files — the touch is what forces the real sources to rebuild while deps stay cached), static ffmpeg from ffmpeg.martin-riedl.de (`FFMPEG_URL` arg, optional `FFMPEG_SHA256` checksum, `unzip -t` integrity check), `debian:bookworm-slim` runtime, entrypoint. Runtime ships **no libssl/libcrypto/CA bundle** — rustls webpki-roots handles all TLS, and the static ffmpeg only processes local files (downloads go through reqwest) | -| `docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim) | +|`docker-entrypoint.sh` | Privilege drop: `useradd` with `LOCAL_USER_ID` (default 9001) + `setpriv` (no gosu on bookworm-slim); rejects a non-numeric or `0` `LOCAL_USER_ID`, which would otherwise survive the drop and run the bot root | | `docker-compose.yml` | The deployment composition, committed as-is: every instance value (token, admins, site credentials, domain) is a `${VAR}` substitution read from the gitignored `.env` beside it, so the file needs no per-deployment edit — and a variable not listed in a service's `environment:` never reaches that container. Ships nginx-proxy + acme-companion: webhook mode needs TLS termination in front (teloxide's axum listener is HTTP-only; `WEBHOOK_CERT` only feeds `set_webhook`), bot exposes `VIRTUAL_HOST`/`VIRTUAL_PORT` on the shared `proxy` network, no host port; container names `nginx-proxy`/`acme-companion`/`tgxmb`, start order via `depends_on` (proxy → acme → bot) | | `.github/workflows/docker.yml` | CI: build+push to Docker Hub on tag `v*`/master, plus a build-only check on PRs touching the build inputs; **no test step**; verifies a release tag matches both crate versions; buildx gha cache (`cache-from` always, `cache-to` except on PRs, scope `tgxmb-build`, `mode=max`) so cargo deps + ffmpeg layers are restored across runs; `FFMPEG_URL`/`FFMPEG_SHA256` come from repo variables when set | | `README.md` | Feature docs + command table (Chinese) | diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh index 8ec5576..f475a1d 100644 --- a/docker-entrypoint.sh +++ b/docker-entrypoint.sh @@ -5,6 +5,22 @@ if [ "$(id -u)" -eq '0' ] then USER_ID=${LOCAL_USER_ID:-9001} + # A non-numeric id breaks useradd/usermod in confusing ways, and uid 0 + # would sail straight through the privilege drop below (`setpriv + # --reuid=0` keeps the bot root while looking configured) — refuse both + # up front. + case $USER_ID in + ''|*[!0-9]*) + echo "docker-entrypoint: LOCAL_USER_ID must be a numeric uid, got '$USER_ID'" >&2 + exit 1 + ;; + esac + if [ "$USER_ID" -eq 0 ] + then + echo "docker-entrypoint: LOCAL_USER_ID=0 would keep the bot root; refusing" >&2 + exit 1 + fi + # `docker compose restart` / `docker restart` reuse the same container, so # the overlay fs keeps the user created on first boot. A second `useradd` # then fails with exit code 9, which would trip `set -e` and kill the @@ -12,18 +28,18 @@ then # otherwise so LOCAL_USER_ID changes still apply. if ! id user > /dev/null 2>&1 then - useradd --shell /bin/bash -u ${USER_ID} -o -c "" -m user > /dev/null 2>&1 || true + useradd --shell /bin/bash -u "${USER_ID}" -o -c "" -m user > /dev/null 2>&1 || true else - usermod -u ${USER_ID} -o user > /dev/null 2>&1 || true + usermod -u "${USER_ID}" -o user > /dev/null 2>&1 || true fi # Bind-mounted volumes may not support chown; a failure here must not kill # the container either. - chown -R `id -u user`:`id -u user` /app > /dev/null 2>&1 || true + chown -R "$(id -u user):$(id -g user)" /app > /dev/null 2>&1 || true export HOME=/home/user # setpriv (util-linux, present in bookworm-slim) replaces gosu: drop to the # target user and exec, keeping the process as PID 1. - exec setpriv --reuid=`id -u user` --regid=`id -g user` --init-groups "$@" + exec setpriv --reuid="$(id -u user)" --regid="$(id -g user)" --init-groups "$@" fi exec "$@"