diff --git a/Dockerfile b/Dockerfile index 616c17c..69a705f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -49,6 +49,9 @@ RUN apk add --no-cache python3 py3-pip nodejs COPY --from=build /build/pipeline /opt/saezuri/pipeline RUN pip3 install --no-cache-dir --break-system-packages \ -r /opt/saezuri/pipeline/requirements.txt +# The runtime user cannot write __pycache__ under /opt, and CPython swallows that +# failure silently — without this, every generation batch re-compiles the pipeline. +RUN python3 -m compileall -q /opt/saezuri/pipeline # Stream Python stdout straight to `docker logs` rather than block-buffering it # during long generation runs. @@ -101,5 +104,29 @@ COPY nginx/generator.sh /docker-entrypoint.d/50-generator.sh RUN chmod +x /docker-entrypoint.d/40-saezuri.sh \ /docker-entrypoint.d/50-generator.sh -EXPOSE 80 +# --- Rootless --- +# Permission is granted by directory mode, not ownership, so that any `docker run +# --user` works and not just the default one — ownership would also leak into a +# fresh named volume, which inherits the image directory's uid and mode. The mode +# is enough because every runtime writer here is tmp-file + rename(), which needs +# the directory bit rather than the file. uid 1000 over nginx's own 101, a system +# uid on Debian hosts. The stock conf.d/default.conf goes because entrypoint.sh +# copies over that path and O_TRUNC on a root-owned file is not granted by a +# writable parent; the pid file leaves root-owned /run for the same reason. +RUN addgroup -g 1000 saezuri \ + && adduser -D -u 1000 -G saezuri saezuri \ + && rm -f /etc/nginx/conf.d/default.conf \ + && sed -i 's,^pid .*,pid /tmp/nginx.pid;,' /etc/nginx/nginx.conf \ + && mkdir -p /var/cache/saezuri/references \ + && chmod 0777 /etc/nginx/conf.d /usr/share/nginx/html \ + /data /data/illustrations /data/calls \ + /var/cache/saezuri /var/cache/saezuri/references \ + && chmod -R 0777 /var/cache/nginx + +# Docker sets HOME=/ for a --user with no passwd entry, and / is not writable. +ENV HOME=/tmp + +USER saezuri + +EXPOSE 8080 # nginx:alpine's own entrypoint runs /docker-entrypoint.d/* then starts nginx. diff --git a/README.md b/README.md index c310ac2..0d4ef71 100644 --- a/README.md +++ b/README.md @@ -125,14 +125,15 @@ on its Docker network and point `BIRDNETGO_URL` at the service name + internal p ## Run the published image ```bash -docker run -d -p 8090:80 \ +docker run -d -p 8090:8080 \ -e BIRDNETGO_URL=http://:8080 \ -v saezuri-illustrations:/data/illustrations \ -v saezuri-calls:/data/calls \ ghcr.io/vrwrts/saezuri:latest ``` -Then open . The host port is 8090 rather than 8080 on purpose: 8080 is +Then open . The container listens on 8080, which is unprivileged so it +never needs root to bind. The *host* port is 8090 rather than 8080 on purpose: 8080 is BirdNET-Go's own default, so the two would collide whenever they share a host, which is the common case. Images are published multi-arch (amd64 + arm64), so they run on a Raspberry Pi as well as an x86 host. The two volumes keep the illustrations @@ -144,13 +145,37 @@ both sections below explain what lands in them. resolves a symlinked mount destination, so both spellings mount the same directory: the short one is just less to type, and an existing deployment mounted on the long path keeps working unchanged. +### Running as another user + +The image runs as uid/gid **1000**, not root, so files it writes into a mounted volume belong to +a real account rather than to root. Override it the ordinary Docker way: + +```bash +docker run -d -p 8090:8080 --user 1000:1000 ... +``` + +Any uid works — the directories the container writes to are mode-granted rather than +owner-granted, so there is no `PUID`/`PGID` to set. Two things follow from it: + +- A **bind-mounted** host directory has to be owned by the uid you pass; Docker never changes + ownership on a mount. Named volumes are seeded from the image and need nothing. +- **Upgrading from a release before this one**, your existing volumes are still root-owned, so + the container refuses to start and tells you to take ownership once, per volume: + + ```bash + docker run --rm -v saezuri-illustrations:/d alpine chown -R 1000:1000 /d + ``` + +One stock nginx knob is unsupported as a result: `NGINX_ENTRYPOINT_WORKER_PROCESSES_AUTOTUNE` +rewrites `/etc/nginx/nginx.conf`, which an unprivileged container cannot do. + ## On-demand generation (optional) The free downloads above only cover species someone has contributed art for. To *also* fill in anything the repo doesn't have — generated fresh in the same kachō-e style — set `GEMINI_API_KEY`: ```bash -docker run -d -p 8090:80 \ +docker run -d -p 8090:8080 \ -e BIRDNETGO_URL=http://:8080 \ -e GEMINI_API_KEY= \ -v saezuri-illustrations:/data/illustrations \ @@ -186,7 +211,7 @@ reports a species the refresh service downloads its ready-made cutout from the CDN) — no API key needed. Just mount the volume so it persists: ```bash -docker run -d -p 8090:80 \ +docker run -d -p 8090:8080 \ -e BIRDNETGO_URL=http://:8080 \ -v saezuri-illustrations:/data/illustrations \ ghcr.io/vrwrts/saezuri:latest @@ -227,7 +252,7 @@ recording of its call, caches it in a volume, and publishes it — so selecting collage offers a play button for what it sounds like. Mount the volume so it persists: ```bash -docker run -d -p 8090:80 \ +docker run -d -p 8090:8080 \ -e BIRDNETGO_URL=http://:8080 \ -v saezuri-calls:/data/calls \ ghcr.io/vrwrts/saezuri:latest diff --git a/addon/DOCS.md b/addon/DOCS.md index de609c6..f2952b6 100644 --- a/addon/DOCS.md +++ b/addon/DOCS.md @@ -127,12 +127,16 @@ serve it, because ingress requires Home Assistant authentication and a panel has way to log in. So open the direct port instead: 1. Open the app's **Configuration** tab and switch to **Network**. -2. Give **80/tcp** a host port, for example `8090`. +2. Give **8080/tcp** a host port, for example `8090`. 3. Restart the app. Your panel then fetches `http://:8090/24h.png`. That port serves the whole collage with no authentication, so only open it on a network you trust. +The direct port used to be **80/tcp** and is now **8080/tcp**. The Supervisor stores the +mapping against the old number, so updating from 0.14.1 or earlier closes it: if you run a +panel, re-open it with the steps above once after updating. + ## Storage Downloaded illustrations, cached recordings and the working cache live in the app's diff --git a/addon/Dockerfile b/addon/Dockerfile index 740683f..073a88e 100644 --- a/addon/Dockerfile +++ b/addon/Dockerfile @@ -4,6 +4,11 @@ ARG BUILD_FROM=ghcr.io/vrwrts/saezuri:latest FROM ${BUILD_FROM} +# The application image is rootless, which the Supervisor's root-owned /data bind +# mount and run.sh's mkdir into it are not. Before the RUN below, which also needs +# root, so one instruction covers build and runtime alike. +USER root + # jq reads options.json. curl (not busybox wget) because the BirdNET-Go probe has # to tell an authentication failure apart from an unreachable host, and busybox # wget cannot report an HTTP status code. diff --git a/addon/config.yaml b/addon/config.yaml index 1ba3719..e0b43cd 100644 --- a/addon/config.yaml +++ b/addon/config.yaml @@ -17,9 +17,9 @@ panel_title: Saezuri # /24h.png cannot go through ingress, which requires Home Assistant # authentication, so that one case needs the port opened by hand. ports: - "80/tcp": null + "8080/tcp": null ports_description: - "80/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png + "8080/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png options: generate_max_per_cycle: 4 calls_max_per_cycle: 4 diff --git a/addon/nginx/ingress.conf b/addon/nginx/ingress.conf index 5e89725..3ff0692 100644 --- a/addon/nginx/ingress.conf +++ b/addon/nginx/ingress.conf @@ -17,7 +17,7 @@ server { # X-Ingress-Path is set by Home Assistant Core (the Supervisor forwards it # through) and carries the per-session prefix with no trailing slash. Empty # for a request that did not come through ingress, which leaves the app - # root-served — the same behaviour as port 80. + # root-served — the same behaviour as the standalone listener. sub_filter_once on; sub_filter '' ''; diff --git a/addon/translations/en.yaml b/addon/translations/en.yaml index 3658a75..bab9d52 100644 --- a/addon/translations/en.yaml +++ b/addon/translations/en.yaml @@ -81,4 +81,4 @@ configuration: name: Summary interval description: Milliseconds between full recounts from BirdNET-Go. network: - "80/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png + "8080/tcp": Direct web access, only needed for an e-ink panel fetching /24h.png diff --git a/docker-compose.yml b/docker-compose.yml index b174823..6a29f60 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,10 +2,14 @@ services: saezuri: build: . image: saezuri:local - # 8090, not 8080: BirdNET-Go's own default is 8080, and the two commonly - # share a host. + # The container listens on 8080 (unprivileged, so it needs no root). The host + # port is 8090 rather than 8080 because BirdNET-Go's own default is 8080 and + # the two commonly share a host. ports: - - "8090:80" + - "8090:8080" + # Runs as uid/gid 1000 by default; override to own the volume contents with + # another account. A bind mount must already be owned by whatever you set here. + # user: "1000:1000" environment: # Required: base URL of your BirdNET-Go instance, reachable from inside this # container. A LAN IP is simplest; a hostname (incl. one behind a reverse diff --git a/nginx/default.conf.template b/nginx/default.conf.template index 1c76b4a..faf9aef 100644 --- a/nginx/default.conf.template +++ b/nginx/default.conf.template @@ -3,7 +3,8 @@ # only ever talks to this origin, and this origin serves ONLY static files the # refresh service publishes — nginx does not proxy BirdNET-Go at all. server { - listen 80; + # Unprivileged, so the container never needs root to bind it. + listen 8080; server_name _; root /usr/share/nginx/html; index index.html; diff --git a/nginx/entrypoint.sh b/nginx/entrypoint.sh index d7ae7e8..cd8b004 100644 --- a/nginx/entrypoint.sh +++ b/nginx/entrypoint.sh @@ -10,7 +10,10 @@ set -eu : "${BIRDNETGO_URL:?BIRDNETGO_URL is required, e.g. http://192.168.1.10:8080}" # The template has no ${...} placeholders left, so copy it verbatim — nginx's own -# $host/$uri/... must survive, which a plain copy trivially guarantees. +# $host/$uri/... must survive, which a plain copy trivially guarantees. Unlink +# rather than truncate in place: a previous run may have been a different uid, and +# unlink needs write permission on the directory only. +rm -f /etc/nginx/conf.d/default.conf cp /etc/nginx/saezuri.conf.template /etc/nginx/conf.d/default.conf # The illustrations and calls directories live in /data now (see the Dockerfile), @@ -19,6 +22,26 @@ cp /etc/nginx/saezuri.conf.template /etc/nginx/conf.d/default.conf # So put the bundled fallback silhouette back. `-n` so art the refresh service has # already downloaded is never overwritten. mkdir -p /usr/share/nginx/html/assets/illustrations /usr/share/nginx/html/assets/calls + +# Docker never re-chowns an existing volume, so one created by an older, +# root-running Saezuri is unwritable to this unprivileged container. Refuse to +# start: otherwise it serves the art already there forever while every publish, +# download and recording fails silently. +for dir in /usr/share/nginx/html /usr/share/nginx/html/assets/illustrations \ + /usr/share/nginx/html/assets/calls "${CACHE_DIR:-/var/cache/saezuri}"; do + mkdir -p "$dir" 2>/dev/null || true + if [ ! -w "$dir" ]; then + echo "saezuri: $dir is not writable by uid $(id -u):$(id -g)." >&2 + echo "saezuri: upgrading from a root-running release? take ownership once, e.g." >&2 + echo "saezuri: docker run --rm -v saezuri-illustrations:/d alpine chown -R $(id -u):$(id -g) /d" >&2 + exit 1 + fi +done + +# Tmp names are fixed, so one left behind by a crash under a different uid would +# block that file's publish forever. +rm -f /usr/share/nginx/html/*.tmp + cp -rn /opt/saezuri/bundled/illustrations/. /usr/share/nginx/html/assets/illustrations/ echo "saezuri: serving static bundle; BirdNET-Go is backend-only (no /api proxy)" diff --git a/site/src/components/Install.astro b/site/src/components/Install.astro index 8b5e98f..ba6af65 100644 --- a/site/src/components/Install.astro +++ b/site/src/components/Install.astro @@ -44,7 +44,7 @@

Run the container (use your instance’s address):

-
docker run -d -p 8090:80 \
+    
docker run -d -p 8090:8080 \
   -e BIRDNETGO_URL=http://192.168.1.10:8080 \
   -v saezuri-illustrations:/data/illustrations \
   -v saezuri-calls:/data/calls \
@@ -57,7 +57,7 @@
     
services:
   saezuri:
     image: ghcr.io/vrwrts/saezuri:latest
-    ports: ["8090:80"]
+    ports: ["8090:8080"]
     environment:
       BIRDNETGO_URL: http://192.168.1.10:8080
     volumes: