Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 28 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
35 changes: 30 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<birdnet-go-host>:8080 \
-v saezuri-illustrations:/data/illustrations \
-v saezuri-calls:/data/calls \
ghcr.io/vrwrts/saezuri:latest
```

Then open <http://localhost:8090>. The host port is 8090 rather than 8080 on purpose: 8080 is
Then open <http://localhost:8090>. 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
Expand All @@ -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://<birdnet-go-host>:8080 \
-e GEMINI_API_KEY=<your-google-ai-key> \
-v saezuri-illustrations:/data/illustrations \
Expand Down Expand Up @@ -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://<birdnet-go-host>:8080 \
-v saezuri-illustrations:/data/illustrations \
ghcr.io/vrwrts/saezuri:latest
Expand Down Expand Up @@ -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://<birdnet-go-host>:8080 \
-v saezuri-calls:/data/calls \
ghcr.io/vrwrts/saezuri:latest
Expand Down
6 changes: 5 additions & 1 deletion addon/DOCS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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://<home-assistant-host>: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
Expand Down
5 changes: 5 additions & 0 deletions addon/Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions addon/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion addon/nginx/ingress.conf
Original file line number Diff line number Diff line change
Expand Up @@ -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 '<head>'
'<head><script>window.__SAEZURI_BASE__="$http_x_ingress_path";</script>';
Expand Down
2 changes: 1 addition & 1 deletion addon/translations/en.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 7 additions & 3 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion nginx/default.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
25 changes: 24 additions & 1 deletion nginx/entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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)"
4 changes: 2 additions & 2 deletions site/src/components/Install.astro
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,7 @@
<p class="step">Run the container (use your instance&rsquo;s address):</p>
<div class="code">
<button class="copy-btn" type="button" aria-label="Copy command">copy</button>
<pre>docker run -d -p 8090:80 \
<pre>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 \
Expand All @@ -57,7 +57,7 @@
<pre>services:
saezuri:
image: ghcr.io/vrwrts/saezuri:latest
ports: ["8090:80"]
ports: ["8090:8080"]
environment:
BIRDNETGO_URL: http://192.168.1.10:8080
volumes:
Expand Down
Loading