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
188 lines
7.7 KiB
YAML
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:
|