Files
news/deploy/README.md
T
connor 5f4bc8d890 Browser feedback uses the signed public endpoint; add the config page
Connor pointed out he may use the site from a laptop away from home. The gated
/ui/feedback/ path was worse than just inconvenient: fetch() follows redirects,
so an expired Authelia session would go 302 -> login page -> HTTP 200, and the
UI would mark the card labelled having recorded nothing. Silent data loss on the
one signal the system learns from.

Both surfaces now post to the same HMAC-signed public endpoint, with
`redirect: 'error'` so a redirect fails loudly instead of looking like success.
The gated route is gone -- one mechanism, not two.

/config shows what every block actually matches right now, and a box to try a
filter before saving it: describe what you want caught, see what it catches and
what sits just below the line, move the threshold, then save it as a veto or a
demote. That boundary view is the point -- a crypto filter at 0.55 catches 131
clusters including "Security updates for Monday", and at 0.62 catches 4, which
you can only know by looking.

Saves use tomlkit rather than tomllib plus a dump, because profile.toml carries
the reasoning behind every constant and a comment-stripping round-trip would
quietly destroy the record of why each number is what it is. Verified: 97
top-level comment lines in, 97 out.

The config mount had to stop being :ro, and ~/config/news2 is now owned
10001:1000 group-writable so the container can save while hand-editing still
works. `:U` would have fixed the container and locked the user out.
2026-09-08 17:26:21 -04:00

154 lines
6.2 KiB
Markdown

# Deploying
This replaces the `news-backend` service (from `../news_triage`) in place: same
host, same vhost, same ntfy topic.
## 1. Compose — APPLIED 2026-09-08
The `news-backend` block in `~/compose.yml` now reads (snapshot of the previous
file: `~/compose.yml.pre-news-v2`):
```yaml
news-backend:
image: git.rcjohnstone.com/connor/news:latest
restart: unless-stopped
networks:
- internal
environment:
- TZ=America/Louisville
- NEWS_DB=/data/news.db
- NEWS_CONFIG_DIR=/config
- NTFY_URL=${NTFY_URL}
# The old service defaulted to this topic (bin_support.rs:121) rather than
# setting it, so reusing it keeps the existing phone subscription working.
- NTFY_TOPIC=news-triage
- NTFY_USER=${NTFY_USER}
- NTFY_PASS=${NTFY_PASS}
# REQUIRED for feedback buttons. ntfy runs http actions FROM THE PHONE, so
# this must be the public URL, not a container address. Its absence is
# exactly why the previous version's buttons never fired.
- NEWS_API_BASE_URL=https://news.rcjohnstone.com
- NEWS_FEEDBACK_SECRET=${NEWS_FEEDBACK_SECRET}
# One LLM call per day, for the digest prose only.
- NEWS_LITELLM_KEY=${NEWS_LITELLM_KEY}
volumes:
# `U` makes podman chown the volume to the container's uid. The image runs
# as uid 10001, not root, so without it SQLite fails with
# "attempt to write a readonly database".
- ./data/news2:/data:U
# NOT :ro -- the /config page writes back to profile.toml.
- ./config/news2:/config
healthcheck:
# NOT curl: the v0.2 image shipped none, so its healthcheck failed forever
# while the service was fine and `podman ps` reported it unhealthy for days.
test: ["CMD", "python", "-c",
"import urllib.request,sys; sys.exit(0 if urllib.request.urlopen('http://localhost:3000/healthz',timeout=4).status==200 else 1)"]
interval: 60s
timeout: 5s
retries: 3
start_period: 20s
```
The image is tagged `localhost/news-py:latest` rather than reusing
`git.rcjohnstone.com/connor/news:latest`, so the Rust image stays on disk as the
rollback path; the old database at `./data/news/db` is likewise untouched.
Rolling back is a two-line edit plus `podman-compose up -d news-backend`.
Config lives in `~/config/news2/`, owned `10001:1000` and group-writable so the
container (uid 10001) can save from the `/config` page while you can still edit
the files by hand. `:U` would have worked for the container and locked you out.
The `caddy` service still bind-mounts `./data/news/web:/srv/news:ro`, which is
now unused since the Caddyfile no longer serves a static SPA. Harmless; remove
it whenever the compose file is next touched. A bind-mounted config edit needs
`sudo podman restart news-backend`; `podman-compose up -d` will not restart a
container when only a mounted file changed. An **environment** change does need
the full `up -d`.
## 2. Caddy — APPLIED 2026-09-08
Done. The block was moved out of the "Internal only" section into the Public
section (the section headers are load-bearing documentation, and leaving a
public block under an internal header would make the file lie), given a
`# ddns: public` marker, and reloaded. `ddns_update` published the A record and
Let's Encrypt issued a cert for the name.
Snapshot of the previous file: `~/Caddyfile.pre-news-public`.
Verified after reload: `/` and `/healthz` return 403 (Authelia gating), while
`/api/feedback/...` returns 404 -- a 404 from the OLD backend, which proves
Caddy routed it through without authentication.
```
# ddns: public
news.rcjohnstone.com {
import secure_headers
import accesslog
# ntfy executes a notification's action buttons FROM THE PHONE, and a native
# app cannot complete an interactive login -- the same reason ntfy, Immich
# and Jellyfin are exempted from the identity gateway on this box. So this
# one path bypasses Authelia. It is safe to: every request carries an HMAC
# signed over (cluster_id, kind), so a leaked URL casts one vote on one
# story rather than acting as a bearer token for the model.
@feedback path /api/feedback/*
handle @feedback {
reverse_proxy news-backend:3000
}
# Everything else is browser-facing, so it goes behind Authelia. Tapping the
# digest notification opens a browser, where an interactive login works.
handle {
import authelia
reverse_proxy news-backend:3000
}
}
```
The change from the current block is deliberate and worth stating plainly: the
vhost moves from **LAN-only** (`tls internal` + `import internal`, which aborts
anything outside 10.0.0.0/24, 10.0.1.0/24, 10.89.0.0/16) to **public**. Without
that, feedback buttons fail on cellular and the digest link is unopenable away
from home -- and silent failure there is precisely how the previous version
ended up with 32 labels in a week.
`/srv/news/dist` is now unused and the root-owned `news-update` systemd timer
that populated it should be disabled:
```sh
sudo systemctl disable --now news-update.timer
```
**Verified after the swap**, from the public internet rather than the LAN:
| request | result | meaning |
|---|---|---|
| `GET /` | 403 (Authelia) | browser paths are gated |
| `POST /api/feedback/.../bogus` | `{"error":"bad signature"}` | reaches the app, unauthenticated, and rejects a forgery |
| `POST /api/feedback/.../<valid HMAC>` | `{"ok":true}` and a row in `feedback` | **the loop that never once worked in v0.2** |
## 3. Scheduling
```
*/15 * * * * podman exec news-backend python -m news.cli poll
*/20 * * * * podman exec news-backend python -m news.cli notify
45 8 * * * podman exec news-backend python -m news.cli digest
```
## 4. Verifying it actually works
```sh
podman exec news-backend python -m news.cli poll # fetch, embed, cluster
podman exec news-backend python -m news.cli simulate # what WOULD be sent
podman exec news-backend python -m news.cli notify --dry-run
```
Then the one test that matters, because it is the loop that was broken before:
send a real notification, **tap a feedback button on the phone**, and confirm a
row lands in `feedback`:
```sh
podman exec news-backend python -c \
"import sqlite3;print(sqlite3.connect('/data/news.db').execute('select * from feedback').fetchall())"
```