Files
petal/docker-compose.yml
T
prosolis 69bf3ffde1 Close the door the edge gate used to hold
A security review of the whole repo. The queries were already scoped, the
OIDC flow already did state and nonce and PKCE, the session tokens were
already stored as hashes. What it found was mostly the seam between the
code and the deployment — and one place where the deployment quietly
undid the code.

The one that matters: with any AUTHENTIK_* variable missing, Petal fell
back to resolving every request to the single `local` user. That is right
on a laptop and a catastrophe on a public host, and Phase 16 removed the
Traefik basic-auth gate that used to stand behind the mistake. A typo in
the client secret would have served her journals to the open internet and
said so only in a log line nobody reads. It now refuses to start, guarded
by default for any BASE_URL that isn't loopback.

Then the one that would have been fixed and wasn't: stored images now
serve under `default-src 'none'; sandbox`, so an SVG pasted into a
document can't run as a page on Petal's own origin. Traefik's
customresponseheaders *overwrites*, so the CSP declared in the compose
labels would have silently replaced that per-route policy in production.
The whole header block moved into the binary, where a route can tighten
its own and a test can prove it; only HSTS stays at the edge, where TLS
actually terminates.

The rest, smaller:

  - PETAL_ALLOWED_SUBS empty means everyone authentik authenticates, and
    authentik here fronts half a dozen applications. Still legal, now
    said out loud every boot, and set in both env examples.
  - LLM failures relayed err.Error() to the browser, which carries the
    address of the inference box on the far side of the VPN. Logged
    instead; the client only ever rendered "the helper is resting".
  - Exports scheme-check their links. Escaping makes a URL safe to sit
    in an attribute and says nothing about following it, and an export
    is the one artifact here meant to leave. Writing the test found the
    markdown image src, which I'd missed reading it.
  - The draft rescue is namespaced per account and cleared on sign-out.
    Everything else in localStorage is a preference; this is her unsaved
    writing, sitting in a profile two people share.
  - /auth/logout is POST-only. With SameSite=Lax a GET route lets any
    page on the internet sign her out mid-draft.
  - Image uploads get a per-account allowance and the TTS cache a size
    cap. Both share the encrypted volume the database is on, and a full
    disk is SQLite failing to write, not a feature degrading.
  - The session cookie takes the __Host- prefix over https, so nothing
    else under parodia.dev can plant one. Old cookies still resolve;
    nobody is signed out to get there.
  - npm audit: linkify-it and postcss.

Verified: go build, go vet, the full Go suite, tsc, 195 frontend tests,
npm audit clean. The startup guard and both CSPs checked against a
running server rather than only asserted.

Claude-Session: https://claude.ai/code/session_016y6gyuHkQXPiEuW8RGQyua
2026-07-27 18:24:47 -07:00

188 lines
7.7 KiB
YAML

# Petal on the parodia.dev VPS.
#
# docker compose up -d --build
#
# Fronted by the host's existing Traefik (external `traefik` network, the
# `web-secure` entrypoint and the `default` cert resolver — same convention the
# other services on this box use). Petal itself never binds a host port; the
# only way in is through Traefik over HTTPS.
#
# Read-aloud runs as two sibling containers rather than host systemd services:
# each Piper HTTP server loads exactly one voice, the host has no lingering
# user session to keep systemd units alive, and keeping them on the internal
# network means the TTS ports are unreachable from anywhere but Petal.
#
# Copy deploy/petal.env.example to .env before the first `up`.
name: petal
services:
petal:
build:
context: .
dockerfile: Dockerfile
image: petal:local
container_name: petal
restart: unless-stopped
# ./data is a bind mount, so the image's own `petal` user (uid 10001) has no
# claim on it — the host's ownership wins and the container can't open
# petal.db. Run as whoever owns the stack directory instead. Keeping it the
# host user (rather than chowning ./data to 10001) is deliberate: the backup
# script gzips snapshots in place from the host, so the host account needs
# write access to the same directory. Still never root.
user: "${PETAL_UID:-1001}:${PETAL_GID:-1001}"
env_file: .env
environment:
# Fixed by the image layout; kept here so they're visible at a glance.
PORT: "8080"
DATABASE_PATH: /data/petal.db
IMAGE_DIR: /data/images
TTS_CACHE_DIR: /data/tts
# DreamDict's built dictionary, read-only, deployed into the data volume
# (see deploy/README.md). Absent it, word lookups fall back to the
# embedded English/Chinese datasets rather than failing.
DICT_PATH: /data/dict.db
# Piper sidecars. Each server loads one voice, so English and Chinese are
# separate containers; the handler maps language → instance from config.
TTS_ENDPOINT: http://piper-en:5000
TTS_ENDPOINT_ZH: http://piper-zh:5000
# A language is discovered from the TTS_ENDPOINT_<LANG>/TTS_VOICE_<LANG>
# pair, so fr and es cost a service and two lines rather than a code
# change. <LANG> is the base tag — an env var name can't hold pt-PT's
# hyphen, and there is one Portuguese voice loaded either way.
TTS_ENDPOINT_PT: http://piper-pt:5000
TTS_ENDPOINT_FR: http://piper-fr:5000
# The sidecars run piper-tts 1.6.0, which serves synthesis on
# /synthesize; millenia's older server keeps the default "/".
TTS_PATH: /synthesize
# The companion's bedtime nag and night mode read the local clock.
TZ: ${TZ:-Europe/Lisbon}
volumes:
# A bind mount, not a named volume: petal.db must be trivially reachable
# from the host for the nightly backup and for a restore.
- ./data:/data
# Mount-liveness guard. On the VPS ./data is an encrypted LUKS volume, and
# the mountpoint directory still exists when that volume is NOT mounted —
# so without this, a boot where the unlock failed would start Petal
# against an empty unencrypted directory and quietly serve a blank
# database. .volume-ok lives on the encrypted filesystem, and
# create_host_path: false turns its absence into a container start
# failure instead. Harmless elsewhere: create the file once and it is a
# no-op. See deploy/README.md §6.
- type: bind
source: ./data/.volume-ok
target: /data/.volume-ok
read_only: true
bind:
create_host_path: false
networks:
- traefik
- internal
depends_on:
- piper-en
- piper-zh
- piper-pt
labels:
traefik.enable: "true"
traefik.docker.network: traefik
traefik.http.routers.petal.rule: Host(`${PETAL_HOST:-petal.parodia.dev}`)
traefik.http.routers.petal.entrypoints: web-secure
traefik.http.routers.petal.tls: "true"
traefik.http.routers.petal.tls.certResolver: default
traefik.http.routers.petal.service: petal
# No edge gate: Petal authenticates for itself now (Authentik OIDC), so
# every /api route answers 401 without a session and the only thing served
# to an anonymous visitor is the app shell and its sign-in redirect. The
# basic-auth middleware that stood here until Phase 16 — plus the separate
# unauthenticated router /api/health needed to escape it — is gone; a
# second password in front of a real login is just one more thing to lose.
traefik.http.routers.petal.middlewares: compression@file,petal-headers
traefik.http.services.petal.loadbalancer.server.port: "8080"
# HSTS is the edge's business — it is a statement about the TLS
# termination, which happens here and not in the container.
#
# The Content-Security-Policy that used to sit alongside it has moved into
# the app (see securityHeaders in cmd/server/main.go). customresponseheaders
# *overwrites*, so a policy set here would silently replace the stricter
# one an individual route chooses for itself — which is exactly what the
# image store does to keep an uploaded SVG from running as a page. A rule
# the edge can quietly undo is not a rule. X-Content-Type-Options and
# Referrer-Policy moved with it for the same reason: one place to read,
# and no dependence on this file being deployed alongside the binary.
traefik.http.middlewares.petal-headers.headers.customresponseheaders.Strict-Transport-Security: max-age=31536000; includeSubDomains
piper-en:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-en
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_EN:-en_US-amy-medium}
volumes:
- piper-voices:/voices
networks:
- internal
piper-zh:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-zh
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_ZH:-zh_CN-huayan-medium}
volumes:
- piper-voices:/voices
networks:
- internal
# European Portuguese, for the pt-PT pair. pt_PT-tugão-medium is the *only*
# European voice in Piper's catalogue — the other five Portuguese models are
# all pt_BR — so the default anyone reaches for is the Brazilian one, exactly
# as it was with the Hunspell dictionary in Phase 21. Named here rather than
# left to the image default for that reason.
piper-pt:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-pt
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_PT:-pt_PT-tugão-medium}
volumes:
- piper-voices:/voices
networks:
- internal
# French, for the fr pair. The opposite situation to Portuguese: every French
# voice Piper ships is fr_FR, so there is no wrong country to land on by
# default, and the name is plain ASCII so the entrypoint's percent-encoded
# fallback (added for tugão) never has to fire. siwis-medium to match the
# register of the other three.
piper-fr:
build:
context: deploy/piper
image: petal-piper:local
container_name: petal-piper-fr
restart: unless-stopped
environment:
PIPER_VOICE: ${TTS_VOICE_FR:-fr_FR-siwis-medium}
volumes:
- piper-voices:/voices
networks:
- internal
networks:
# Created and owned by the host's Traefik stack.
traefik:
external: true
# Petal ↔ Piper only. Not reachable from the internet or the other stacks.
internal:
driver: bridge
volumes:
# Downloaded voice models, shared read-mostly by both Piper instances so the
# same model is never fetched twice.
piper-voices: