Compare commits

...

2 Commits

Author SHA1 Message Date
JP
1710e6147b Document the off-site backup leg now that it exists
Nightly backups now copy to TrueNAS over SSH rather than living only on the
LXC they are protecting. Chose scp over the existing NFS share deliberately:
a mount that goes stale writes into the local mountpoint instead, silently
filling the LXC disk while every run reports success. No mount, no such class
of failure - and the backup script already spoke scp, so nothing changed but
two Environment lines.

Set as a systemd drop-in so re-installing the service cannot quietly return to
local-only backups.

Recorded the failure mode that cost time here: sshd refuses authorized_keys
when the user's home directory is group- or world-writable, which TrueNAS
datasets often are. The key is offered and silently rejected, which looks
exactly like a key that was never added.

Verified end to end rather than assumed: the copy lands, the dump passes
gzip -t, contains 25 CREATE TABLE statements including the new MCP and OAuth
tables, and the MinIO tarball reads back with 194 entries.

Still open: the dumps are unencrypted, and TrueNAS is on the same site.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 19:20:16 +00:00
JP
82680d9430 Correct stale deploy docs and document the new env knobs
deploy/README.md described a rewrites() block that no longer exists, and told
the reader to verify the image bucket's anonymous-download policy was set -
which is precisely the exposure that was closed. Following it would have
re-opened every stored image to the public internet.

Replaced with what is actually true: /minio-images is a session-gated route
handler with per-user key-prefix ownership, the bucket must have no anonymous
policy, and MinIO must stay bound to loopback. Also noted that the suggested
`mc anonymous get` check does not work on this host - the alias has no
credentials and returns Access Denied either way - and gave an external curl
that actually tells you something.

The same stale rationale sat in deploy.sh's build comment. Building without the
env file is still right, just for a different reason: nothing needs baking in.

Added the MCP and OAuth journal greps, an unauthenticated discovery check, and
a note that a 307 there means /.well-known fell out of PUBLIC_ROUTES. Documented
in .env.example that NEXTAUTH_URL must be the exact public origin with no
trailing slash, since it is now the OAuth issuer and MCP resource identifier,
plus the MCP_CHATGPT_TOOLS opt-out.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
2026-08-09 19:04:42 +00:00
3 changed files with 64 additions and 7 deletions

View File

@@ -5,6 +5,9 @@ POSTGRES_PASSWORD="YOUR_PASSWORD"
POSTGRES_DB="drinktracker" POSTGRES_DB="drinktracker"
# NextAuth # NextAuth
# Must be the exact public origin, with NO trailing slash. Beyond login redirects
# it is the OAuth `issuer` and the MCP `resource` identifier, and connectors
# compare both byte-for-byte - a mismatch fails discovery with no useful error.
NEXTAUTH_URL="http://localhost:3000" NEXTAUTH_URL="http://localhost:3000"
NEXTAUTH_SECRET="generate-with: openssl rand -base64 32" NEXTAUTH_SECRET="generate-with: openssl rand -base64 32"
AUTH_TRUST_HOST="true" # Set to true when behind a reverse proxy AUTH_TRUST_HOST="true" # Set to true when behind a reverse proxy
@@ -25,3 +28,9 @@ ENCRYPTION_KEY="generate-with: openssl rand -hex 32"
# OpenAI-compatible router that picks the best model per request. LAN-only, plain HTTP. # OpenAI-compatible router that picks the best model per request. LAN-only, plain HTTP.
# Each user adds their own gateway API key in Settings; this is only the endpoint. # Each user adds their own gateway API key in Settings; this is only the endpoint.
SWITCHBOARD_BASE_URL="http://192.168.2.11:8787/v1" SWITCHBOARD_BASE_URL="http://192.168.2.11:8787/v1"
# MCP server (optional)
# The `search` and `fetch` tools exist only for ChatGPT's deep-research mode.
# Their generic names can muddy tool selection when several connectors are
# attached at once, so set this to 0 on a Claude-only deployment to drop them.
# MCP_CHATGPT_TOOLS="0"

View File

@@ -26,9 +26,13 @@ The app runs **natively under systemd — there is no Docker on this host.**
**`drinktracker.tenseconddelay.net` resolves to the reverse proxy (192.168.2.172), not the container.** `NEXTAUTH_URL` points at the public name, so resolving it and SSHing there fails in a way that looks like a rejected key but is simply a different machine. **`drinktracker.tenseconddelay.net` resolves to the reverse proxy (192.168.2.172), not the container.** `NEXTAUTH_URL` points at the public name, so resolving it and SSHing there fails in a way that looks like a rejected key but is simply a different machine.
**`next.config.mjs` evaluates `rewrites()` at BUILD time.** The `/minio-images/:path*` destination is baked into `routes-manifest.json` as `http://localhost:9000/drink-images/:path*`. Changing `MINIO_ENDPOINT` in the env file appears to work and does nothing — images break only after the *next* rebuild, so the change and the breakage are separated in time. MinIO must stay on `localhost:9000` serving `drink-images`. **Images are served by an authenticated route, not a rewrite.** `next.config.mjs` has no `rewrites()` block at all. `/minio-images/:path*` is a route handler (`src/app/minio-images/[...key]/route.ts`) that requires a session, rejects `..` in the key, and enforces per-user ownership by key prefix (`<userId>/` or `scans/<userId>/`). It reads `MINIO_ENDPOINT` at request time, so changing it takes effect on restart with no rebuild.
**The image proxy is an unauthenticated GET.** Next rewrites don't sign requests, so the bucket's anonymous-download policy is load-bearing. Verify with `mc anonymous get local/drink-images` → must say `download`. > This replaced an unauthenticated rewrite that, combined with an anonymous-download bucket policy, exposed every stored image to the public internet. **The bucket must not have an anonymous policy, and MinIO must stay bound to loopback** (`127.0.0.1:9000`, verify with `ss -lntp | grep 9000`). `mc anonymous get local/drink-images` is not a usable check here — the `local` alias on this host has no working credentials and returns `Access Denied` regardless. Check from outside instead: `curl -si https://drinktracker.tenseconddelay.net/minio-images?list-type=2` must **not** return a `200` with an XML key listing.
**Never use `next/image` for stored images.** The optimizer fetches them server-side without the session cookie, so every image would 401. All renders are plain `<img>`, and there is deliberately no `images.remotePatterns` config.
**`NEXTAUTH_URL` must exactly equal the public origin, with no trailing slash.** Beyond login redirects it now drives the OAuth `issuer` and the MCP `resource` identifier, both of which clients compare byte-for-byte. A mismatch does not error — connectors just fail to complete with an unhelpful "couldn't reach the server".
**`prisma db push`, not `migrate deploy`.** Production has no `_prisma_migrations` table, so `migrate deploy` would try to apply the init migration against populated tables and fail. `--accept-data-loss` means removing a field from `schema.prisma` **drops the column and its data** — the deploy script dumps first for this reason. **`prisma db push`, not `migrate deploy`.** Production has no `_prisma_migrations` table, so `migrate deploy` would try to apply the init migration against populated tables and fail. `--accept-data-loss` means removing a field from `schema.prisma` **drops the column and its data** — the deploy script dumps first for this reason.
@@ -36,7 +40,7 @@ The app runs **natively under systemd — there is no Docker on this host.**
Build happens in `/opt/drinktracker/repo` and is assembled **out-of-place** into `releases/<sha>`, so the ~3 minute build runs while the current release keeps serving. Only the symlink swap and `systemctl restart` cause downtime, about 3 seconds. Rollback is the same swap in reverse and needs no rebuild. Build happens in `/opt/drinktracker/repo` and is assembled **out-of-place** into `releases/<sha>`, so the ~3 minute build runs while the current release keeps serving. Only the symlink swap and `systemctl restart` cause downtime, about 3 seconds. Rollback is the same swap in reverse and needs no rebuild.
The build deliberately does **not** source the env file same as the Dockerfile — because of the build-time rewrite baking described above. The build deliberately does **not** source the env file, same as the Dockerfile. Nothing in this app needs configuration baked into the bundle: `NEXTAUTH_URL`, `MINIO_ENDPOINT` and the rest are read at request time by dynamic routes, so the build stays identical regardless of which host it runs on. (This originally guarded against a `rewrites()` block that baked a MinIO URL into `routes-manifest.json`; that block is gone, but building without secrets is still the right default.)
## Docker still works, just not here ## Docker still works, just not here
@@ -56,16 +60,59 @@ ssh -i ~/.ssh/drinktracker_ed25519 drinkadmin@192.168.2.169
systemctl is-active postgresql@16-main minio drinktracker systemctl is-active postgresql@16-main minio drinktracker
sudo journalctl -u drinktracker -n 50 sudo journalctl -u drinktracker -n 50
sudo journalctl -u drinktracker | grep '\[switchboard\]' # AI routing + cost per call sudo journalctl -u drinktracker | grep '\[switchboard\]' # AI routing + cost per call
sudo journalctl -u drinktracker | grep '\[mcp\]' # one line per MCP tool call
sudo journalctl -u drinktracker | grep '\[oauth\]' # connector grants and token issuance
readlink /opt/drinktracker/current # which release is live readlink /opt/drinktracker/current # which release is live
``` ```
Each AI call logs one `[switchboard] feature=… model=… cost=… latency_ms=…` line. `FAILOVER` warnings are expected — the gateway's `:batch` model variants currently fail on every request and fall back. `CONTEXT OVERFLOW` is worth investigating. Each AI call logs one `[switchboard] feature=… model=… cost=… latency_ms=…` line. `FAILOVER` warnings are expected — the gateway's `:batch` model variants currently fail on every request and fall back. `CONTEXT OVERFLOW` is worth investigating.
MCP tool calls log `[mcp] user=… tool=… ok=… ms=…`, and the same events are rows in `McpAuditLog` (arguments are deliberately never recorded). `[oauth]` lines cover consent grants and token issuance; a `PKCE mismatch` or `refresh token replay` warning there means a grant was revoked defensively and is worth looking at.
Discovery can be checked without credentials:
```bash
curl -s https://drinktracker.tenseconddelay.net/.well-known/oauth-protected-resource | jq
curl -si https://drinktracker.tenseconddelay.net/api/mcp -X POST \
-H 'content-type: application/json' -H 'accept: application/json, text/event-stream' \
-H 'MCP-Protocol-Version: 2026-07-28' -H 'Mcp-Method: tools/list' \
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{"_meta":{"io.modelcontextprotocol/protocolVersion":"2026-07-28","io.modelcontextprotocol/clientCapabilities":{}}}}'
```
The second must return **401** with a `WWW-Authenticate: Bearer … resource_metadata="…"` header. A `307` there means `/.well-known` fell out of `PUBLIC_ROUTES` in `src/lib/auth.ts` and discovery is broken.
## Backups ## Backups
`drinktracker-backup.timer` runs nightly at 03:25: `pg_dump` + a tar of `/var/lib/minio/data` + a copy of the env file, into `/var/backups/drinktracker`, retained 14 days (~1 GB). `drinktracker-backup.timer` runs nightly at 03:25: `pg_dump` + a tar of `/var/lib/minio/data` + a copy of the env file, into `/var/backups/drinktracker`, retained 14 days (~1 GB).
**TODO — the chain has no off-site leg.** `/usr/local/bin/drinktracker-backup` supports `OFFSITE_DEST` (an scp target) and `OFFSITE_KEY`; set them via `Environment=` in `drinktracker-backup.service`. Until then everything lives on one LXC on one Proxmox host, and the nightly job prints a warning saying so. Each run also copies the dump and the MinIO tarball off-site to TrueNAS:
| | |
|---|---|
| Destination | `jpscott84@192.168.2.196:/mnt/Beefcake/backup/drinktracker` (dataset `Beefcake/backup`) |
| Transport | `scp` over SSH — **not** NFS, so there is no mount to go stale and silently swallow a night's backup into the LXC's own disk |
| Key | `/root/.ssh/drinktracker_backup_ed25519`, a dedicated backup-only keypair |
| Config | `Environment=` lines in `/etc/systemd/system/drinktracker-backup.service.d/offsite.conf` |
Set as a **drop-in** rather than an edit to the unit, so re-installing the service cannot silently drop the off-site leg and return to local-only backups.
A successful run logs `offsite copy ok -> …`. If it ever logs `NOTE: OFFSITE_DEST unset` again, the drop-in has gone missing. A failed copy logs `WARNING: offsite copy FAILED` and **keeps the local copy** — the job still exits 0, so watch the log rather than the exit status.
```bash
sudo systemctl start drinktracker-backup.service # run it now
sudo journalctl -u drinktracker-backup -n 20 # look for "offsite copy ok"
```
Verify what landed is actually restorable, not merely present:
```bash
sudo ssh -i /root/.ssh/drinktracker_backup_ed25519 jpscott84@192.168.2.196 \
'ls -lh /mnt/Beefcake/backup/drinktracker && gzip -t /mnt/Beefcake/backup/drinktracker/pg-*.sql.gz && echo OK'
```
**Two gaps that remain.** The dumps are **unencrypted** and contain every user's data plus bcrypt password hashes — acceptable writing to a NAS on the same LAN, but encrypt before this ever leaves the network. And TrueNAS is in the same building, so this survives losing the LXC or the Proxmox host, not fire or theft.
> If SSH key auth to TrueNAS ever breaks: sshd refuses `authorized_keys` when the user's **home directory** is group- or world-writable, which TrueNAS datasets often are by default. `/mnt/local/jpscott84` must not be `777`. The symptom is indistinguishable from a missing key — the key is offered and silently rejected — so check `journalctl -u ssh | grep 'bad ownership'` on the NAS first.
Restore: Restore:

View File

@@ -109,9 +109,10 @@ git -C "$APP_DIR/repo" log --oneline -1
cd "$APP_DIR/repo" cd "$APP_DIR/repo"
export npm_config_cache="$APP_DIR/.npm" NODE_OPTIONS=--max-old-space-size=3072 export npm_config_cache="$APP_DIR/.npm" NODE_OPTIONS=--max-old-space-size=3072
# Built WITHOUT the env file, deliberately - same as the Dockerfile. next.config.mjs # Built WITHOUT the env file, deliberately - same as the Dockerfile. Every setting
# evaluates rewrites() at build time, so sourcing the env here would bake a different # this app reads (NEXTAUTH_URL, MINIO_ENDPOINT, ...) is read at request time by a
# /minio-images destination into routes-manifest.json. # dynamic route, so nothing needs baking in and the bundle stays host-independent.
# Keep it that way: a build that depends on secrets stops being reproducible.
npm ci --prefer-offline --no-audit --fund=false npm ci --prefer-offline --no-audit --fund=false
npx prisma generate npx prisma generate
npm run build npm run build