diff --git a/.gitignore b/.gitignore index b569db7..5f3751b 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,11 @@ .env **/.env copyparty/cfg/accounts.conf +# The scoped Super Productivity sync account, rendered from its own Pass +# item ("Copyparty SP Sync") at deploy time — same treatment as +# accounts.conf above, kept in a separate file because the agent Pass token +# can create items but not update them. +copyparty/cfg/accounts-spsync.conf # local-only volume definitions and compose overrides — extra copyparty # areas that deliberately stay out of the public repo entirely copyparty/cfg/local-*.conf @@ -28,6 +33,11 @@ kopia-mac/.mirror-state copyparty/data/ copyparty/public/ copyparty/inbox/ +# Super Productivity's synced task data — the ONLY copy besides whatever is +# in each browser's IndexedDB, so it is a Kopia source (see kopia-mac/backup.sh) +copyparty/sp-sync/ +# Fizzy's SQLite databases and Active Storage uploads — its entire state +fizzy/storage/ # copyparty's own internal state: salts, sessions.db, shares.db, cert.pem, iphash copyparty/cfg/copyparty/ memos-prospect-ukri-tus/data/ diff --git a/README.md b/README.md index 7af327d..0a3346d 100644 --- a/README.md +++ b/README.md @@ -18,6 +18,8 @@ in front of the lot. | [Ghost](https://ghost.org) | `blog.mathewcsims.uk` | Mac (replaces paid Ghost(Pro) hosting) | | [LittleLink](https://github.com/sethcottle/littlelink) | `mathewcsims.uk` | Mac (bare apex domain — static, no backend) | | [Karakeep](https://github.com/karakeep-app/karakeep) | `karakeep.mathewcsims.uk` | Mac (migrated from a separate Tailscale-only deployment) | +| [Fizzy](https://www.fizzy.do) | `fizzy.mathewcsims.uk` | Mac (LAN-only — backlog / triage board) | +| [Super Productivity](https://super-productivity.com) | `sp.mathewcsims.uk` | Mac (LAN-only — day-to-day tasks; syncs to copyparty at `/sp-sync`) | | [Apprise API](https://github.com/caronc/apprise-api) | `apprise.mathewcsims.uk` | Pi (LAN-only — generic notification relay to Discord) | | [Uptime Kuma](https://github.com/louislam/uptime-kuma) | `status.mathewcsims.uk` | Pi (deliberately — stays up if the Mac doesn't) | | Tailscale webhook relay (this repo) | `tailscale-relay.mathewcsims.uk` | Pi (public — bridges Tailscale's webhook events to Apprise; HMAC-verified) | @@ -78,6 +80,8 @@ internet → DrayTek router → Pi (Caddy, terminates HTTPS, routes by hostname) ├─ prospect-ukri-tus.mathewcsims.uk → Mac ├─ blog.mathewcsims.uk → Mac ├─ karakeep.mathewcsims.uk → Mac + ├─ fizzy.mathewcsims.uk → Mac (LAN clients only) + ├─ sp.mathewcsims.uk → Mac (LAN clients only) ├─ apprise.mathewcsims.uk → itself (Pi, LAN clients only) ├─ status.mathewcsims.uk → itself (Pi) ├─ backup.mathewcsims.uk → itself (Pi, LAN clients only) @@ -200,6 +204,9 @@ memos-prospect-ukri-tus/ compose.yaml and data (Mac) blog/ compose.yaml, MySQL, and Ghost content (Mac) landing-page/ compose.yaml, static site content (Mac, no secrets) karakeep/ compose.yaml, bookmark/asset data, search index (Mac) +fizzy/ compose.yaml and data (Mac — Kanban backlog, LAN-only) +super-productivity/ compose.yaml only (Mac — static app, holds NO data; + tasks live in the browser + copyparty's /sp-sync) apprise/ compose.yaml (Pi — deployed via scp + docker compose, LAN-only) uptime-kuma/ compose.yaml (Pi — deployed via scp + docker compose) kopia-server/ compose.yaml + Dockerfile + entrypoint.sh (Pi — deployed via scp + docker compose, LAN-only) diff --git a/SETUP.md b/SETUP.md index cd0e7e9..61cf441 100644 --- a/SETUP.md +++ b/SETUP.md @@ -10,6 +10,8 @@ follow the same recipe — see "Adding another app" near the end. | copyparty | `https://cp.mathewcsims.uk` | Mac `:3923` | `admin` + others, see its section below | | Memos | `https://prospect-ukri-tus.mathewcsims.uk` | Mac `:5230` | set up on first visit; OAuth planned | | Ghost blog | `https://blog.mathewcsims.uk` | Mac `:2368` | set up on first visit | +| Fizzy | `https://fizzy.mathewcsims.uk` | Mac `:3600`, LAN-only | magic link / code to `mat@mathewcsims.uk`; signups close after the first account | +| Super Productivity | `https://sp.mathewcsims.uk` | Mac `:3601`, LAN-only | **no login — none exists**; the LAN gate is the whole access control | | Landing page | `https://mathewcsims.uk` | Mac `:3080` | static site, no login | | Karakeep | `https://karakeep.mathewcsims.uk` | Mac `:3000` | set up on first visit; signups disabled | | Apprise API | `https://apprise.mathewcsims.uk` | **Pi**, LAN-only | no login — LAN-gate is the only access control | @@ -34,6 +36,7 @@ follow the same recipe — see "Adding another app" near the end. │ port published): │ │ Mac: mathewcsims.uk, cp, prospect-ukri- │ │ tus, blog, karakeep, owl, │ + │ fizzy*, sp*, │ │ author*, fj* │ │ (author/fj are BookStack/Forgejo, │ │ LAN/tailnet-gated) │ @@ -53,6 +56,9 @@ follow the same recipe — see "Adding another app" near the end. blog (Ghost) :2368 (MySQL) landing-page :3080 (static) karakeep :3000 (sqlite + Meilisearch) + fizzy :3600 (sqlite) + super-productivity :3601 (static, + no data — syncs to copyparty) slartibartfast 10.0.1.11 — Ubuntu box, docker-compose (NOT podman): @@ -102,6 +108,10 @@ section below says which. | `owl/owl-logo.svg` | **Mac** | source asset for the instance logo (tracked; the deployed logo itself is a data URI in Memos' own DB) | | `owl/owl-theme.css` | **Mac** | source stylesheet for the instance accent theme (tracked; deployed via `additionalStyle` in Memos' own DB) | | `owl/document-viewer/` | **Mac** | client-side attachment preview feature — source (`src/`), esbuild config, and a separate Caddy sidecar (`compose.yaml`, own `Caddyfile`) serving the built bundle; deployed via `scripts/pass-deploy-owl-document-viewer.sh`, see the Owl section below | +| `fizzy/compose.yaml` | **Mac** | Fizzy Kanban backlog; reads secrets from the "Fizzy" **and** "Proton SMTP" Pass items | +| `fizzy/storage/` | **Mac** | **your backlog lives here** — SQLite + uploads | +| `super-productivity/compose.yaml` | **Mac** | static Super Productivity bundle. **No volume, no data** — tasks live in the browser and in copyparty's `/sp-sync` | +| `copyparty/sp-sync/` | **Mac** | **Super Productivity's synced tasks** — the only durable copy | | `blog/compose.yaml` | **Mac** | Ghost + MySQL + traffic-analytics; reads secrets from Proton Pass | | `blog/db/` | **Mac** | **Ghost's MySQL datadir** | | `blog/content/` | **Mac** | **your posts, images, themes live here** | @@ -1338,9 +1348,35 @@ boot (Pi default) is what brings it back after a Pi reboot, same as Caddy. > below for what was checked in source and what the two structural results > are. > -> The agreed direction is to **build**, smallest-piece-first, with the -> interface built before any backend — because interface, not features, is -> what has killed almost every candidate. Nothing has been built yet. +> **SUPERSEDED 2026-08-08 (later the same day) — a three-tool answer was +> adopted and deployed.** Not a single product, and not the build below: +> +> | Tier | Tool | Where | +> |---|---|---| +> | Backlog — someday/now/done/never | **Fizzy** | `fizzy.mathewcsims.uk`, LAN-only | +> | Day-to-day detailed to-dos + reminders | **Super Productivity** | `sp.mathewcsims.uk`, LAN-only, syncs to copyparty | +> | Recurring chores | **Tasks.org** | phone **and watch**, local, no sync | +> +> **Why this works where five sweeps failed: each tool's fatal flaw is +> neutralised by the role it is given.** Tasks.org's door-closer was that +> `repeat_from` does not sync — irrelevant when it never leaves one device. +> Super Productivity's was having no server, so reminders are per-device +> with no backstop — acceptable for work you are actively engaged with, once +> the must-not-miss chores live elsewhere. Fizzy's flatness does not matter +> because a backlog is a list. The `card_not_nows` table means "not now" is +> a first-class concept in Fizzy, not a column convention. +> +> Tasks.org earned its place on something no other candidate had: real +> `:wear` and `:wear-datalayer` Gradle modules — a genuine watch app with a +> data layer, not mirrored notifications. +> +> **What this does NOT cover: deep decomposition.** Fizzy's `steps` are a +> flat checklist (no nesting, no ordering, no promote-to-card) and Super +> Productivity is one level in practice. The original "closer to a genuine +> project planning system" requirement is unmet, knowingly. +> +> The build below is shelved, not cancelled. Everything above it stands as +> the record of why nothing off-the-shelf fits as a single product. ### The requirements, restated 2026-08-08 @@ -2251,6 +2287,230 @@ create it as root first and fixing ownership after is the harder path. --- +## Fizzy (https://fizzy.mathewcsims.uk) — LAN-only + +[Fizzy](https://www.fizzy.do), 37signals' open-source Kanban board, used as +the **backlog**: someday / now / done / never triage. The other two tiers of +the task setup are Super Productivity (below) and Tasks.org on the phone and +watch — see "Task management" for why it is three tools. + +Single container, SQLite under `/rails/storage`, no DB sidecar — its own +deployment guide ships no database service. Image pinned by digest because +upstream publishes a rolling `:main` tag rather than versioned releases. +Verified arm64-native, so no emulation on the M4. + +**LAN/tailnet-gated at Caddy**, matching docs, paperless, author and fj. It +holds a private backlog and Tailscale already covers access from away. + +**Registration hardening comes free, and must not be undone.** Fizzy runs in +single-account mode by default and closes signups permanently as soon as the +first account exists — the same posture Vikunja needed +`ENABLEREGISTRATION=false` for. **Never set `MULTI_TENANT=true`.** Until the +first account is created, signups are OPEN to anyone who can reach the +hostname; the LAN gate is the only thing standing in front of that window. + +**It runs as uid 1000, so it cannot bind port 80.** Thruster (the front-end +in `./bin/thrust ./bin/rails server`) defaults to port 80 and crash-loops +with `listen tcp :80: bind: permission denied`. Upstream's example compose +publishes 80/443 and works only because plain Docker there grants the +capability. Fixed with `HTTP_PORT=8080` and a `3600:8080` mapping, rather +than running the container as root and undoing the image's own privilege +separation. + +**TLS_DOMAIN and DISABLE_SSL are both deliberately unset.** `DISABLE_SSL` +switches off `config.assume_ssl` AND `config.force_ssl` together +(`config/environments/production.rb:76-83` derives both from it), losing +HSTS and secure cookies. Leaving both unset is Rails' documented pattern +behind a terminating proxy: `assume_ssl` stops `force_ssl` bouncing the +forwarded request into a redirect loop, while `force_ssl` keeps the security +headers. + +**Mail — the first working SMTP in this repo.** Every other app here leaves +it commented out; Fizzy needs it, because sign-in is a magic link or +6-character code and the alternative is reading codes out of the container +log forever. Credentials live in the shared **"Proton SMTP"** Pass item +(`self-hosted@mathewcsims.uk`, `smtp.protonmail.ch:587`, STARTTLS), +deliberately general-purpose rather than per-app. **The field names do not +line up** — Pass uses `SMTP_SERVER`/`SMTP_TOKEN`, Rails wants +`SMTP_ADDRESS`/`SMTP_PASSWORD` — so `compose.yaml` maps them, keeping the +Pass item reusable. Proton requires the From address to match the address +the token was issued against, so `MAILER_FROM_ADDRESS` is not independently +configurable. + +**Deploy with BOTH Pass items**, or every send fails authentication: + +``` +./scripts/pass-deploy.sh fizzy Fizzy "Proton SMTP" +``` + +**Web Push is unavailable** — no VAPID keys, which cannot be generated +before first boot. Not needed: Fizzy's notifications are activity-based +(comments, assignment), not the due-date reminders this stack relies on. To +add later: + +``` +podman exec -it fizzy bin/rails runner 'k=WebPush.generate_key; puts k.private_key, k.public_key' +``` + +**Backups.** `fizzy/storage` is a Kopia source, and +`scripts/dump-databases.sh` dumps `production.sqlite3` nightly. The other +three SQLite files it writes (`production_cable`, `production_cache`, +`production_queue`) are Solid Cable/Cache/Queue infrastructure — regenerated +on boot, worthless in a restore, deliberately not dumped. + +--- + +## Super Productivity (https://sp.mathewcsims.uk) — LAN-only + +[Super Productivity](https://super-productivity.com), the middle tier: +detailed day-to-day tasks with reminders, too heavy for the Fizzy backlog +and not recurring chores. + +**THIS CONTAINER HOLDS NO DATA.** It is nginx serving a static Angular +bundle. Super Productivity is local-first: tasks live in each browser's +IndexedDB and are synced by the app itself over WebDAV. There is no +server-side database, no accounts, and nothing here to back up — losing this +container loses nothing. There is deliberately no volume, because adding one +would imply otherwise. + +**There is also no login, at all.** Unlike every other app here, there is no +second line of defence behind the proxy: Caddy's LAN gate and the +`pf-lockdown` rule on port 3601 are the entire access control. + +### The sync target, and why it shares a hostname + +Sync goes to copyparty's `/sp-sync` volume at +`https://sp.mathewcsims.uk/sp-sync/` — the **same origin** as the app, via a +Caddy route to copyparty on 3923. That is the load-bearing design decision: + +- Super Productivity syncs **from the browser**, so a target on another + hostname makes every request cross-origin. +- copyparty exposes only `--acao`/`--acam` and has **no control over + `Access-Control-Allow-Headers`**, while SP sends `Depth` and `If-Match`, + which are not CORS-safelisted. Preflight would fail. SP even has a + dedicated `PotentialCorsError` for exactly this. +- Same-origin removes the problem instead of configuring around it. + +**The path must never be rewritten** — `handle`, never `handle_path`. +copyparty puts its own absolute paths in PROPFIND `` (verified: +`/sp-sync/`) and the client resolves against them, so +stripping the prefix would point every subsequent request at nothing. + +### Things checked in source rather than assumed + +- **`--daw` is set as a VOLFLAG, not globally.** Without it copyparty + answers a PUT over an existing file by inventing a new filename instead of + overwriting, so every save becomes a new file and sync never converges. + Set globally it would change overwrite semantics for the **entire** file + server including your documents; copyparty's own help says to prefer the + volflag. Needed because SP does not send `x-oc-mtime`. +- **`vague-403` was KEPT.** The old note here said to remove it the moment a + WebDAV volume returned, on the assumption that WebDAV clients wait to be + challenged. SP's client sets `Authorization: Basic` on every request, and + an unauthenticated PROPFIND against this config returns **401, not 404** — + both measured, not inferred. +- **No `dav-port` needed.** `--ua-nodav` defaults to `^(Mozilla/|...)`, so a + browser looks like a non-WebDAV client — but that governs GET-versus-HTML + behaviour, not method dispatch. PROPFIND, MKCOL, PUT, overwrite, GET and + DELETE all verified working with a browser user-agent on the normal port. +- **copyparty returns no `ETag`, and that is fine.** SP's own XML parser + does `etag: lastModified` — it deliberately uses `getlastmodified`, which + copyparty does return on every entry. +- **The bind mount is load-bearing.** A `[/sp-sync]` volume with no matching + mount in `copyparty/compose.yaml` still "works": copyparty logs + `type=overlay` and writes into the container's ephemeral layer, losing + every task on the next recreation, silently. Caught during setup by + reading that log line. + +**The account** is `spsync`, scoped `rwmd` to `/sp-sync` only, defined in +`copyparty/cfg/accounts-spsync.conf` — rendered from its **own** Pass item +because the agent token can create Pass items but not update the existing +"Copyparty" one. copyparty merges `[accounts]` across auto-loaded `*.conf` +files; verified in a throwaway container before relying on it. + +### Configuring the sync, field by field + +Do this **once per browser/device** — the settings live in that browser's +own storage, not on the server, so a new laptop or a cleared profile needs +it again. + +1. Open `https://sp.mathewcsims.uk` and go to **Settings** (cog, bottom-left) + → **Sync & Export** → **Sync**. +2. **Sync provider** → `WebDAV`. A warning appears calling generic WebDAV + "experimental and provided as-is" — expected; that is upstream's blanket + caveat about WebDAV servers differing, not a problem with this one. +3. **Server URL** — `https://sp.mathewcsims.uk` + The field is labelled *Nextcloud Server URL* even for plain WebDAV; the + form is shared with the Nextcloud provider. Enter the origin only, with + no trailing path. +4. **Username** — `spsync` +5. **App Password** — the `PASSWORD` field of the **"Copyparty SP Sync"** + Proton Pass item. (Also labelled for Nextcloud; it is just the password.) +6. **Sync Folder Path** — `/sp-sync` +7. **Encryption** — a deliberate choice, see below. Default is off. +8. Leave everything under **Advanced** (Sync interval, Manual sync only) at + its defaults unless you want otherwise. +9. Press **Sync now**. + +**Verify it actually worked** — from the Mac: + +``` +ls -la ~/self-hosted/copyparty/sp-sync/ +``` + +You want `sync-data.json` with a current timestamp and a non-trivial size +(~30 KB with a real task list). `.hist/` is copyparty's own index and is +expected. An empty directory means the sync did not run, whatever the app +said. + +### Encryption is off by default — decide, don't drift + +The synced blob is **plaintext**: a `pf_2__` prefix followed by readable +JSON containing every task. Turning on SP's encryption passphrase makes it +opaque to anyone who obtains the `spsync` credentials. + +Not enabled here, on the grounds that the file already sits behind a LAN +gate, a volume-scoped account, and full-disk encryption, and Kopia encrypts +it again before it reaches Backblaze — while a lost passphrase means losing +every task with no server-side recovery, because there is no server that +understands the data. That reasoning is worth re-checking if the blob ever +leaves this setup. + +### The `/sp-sync/` browsing trap + +**Do not browse to `https://sp.mathewcsims.uk/sp-sync/` in a browser to +check on the files.** You will get a broken half-loaded Super Productivity +app, not copyparty's file listing, and it looks exactly like the sync being +broken when it is fine. + +Cause: Super Productivity is a PWA whose service worker is registered at +scope `/`, and its `ngsw.json` navigation rules treat any path *without a +dot in the last segment* as a navigation to serve the app shell for. +`/sp-sync/` qualifies; the shell then requests `chunk-*.js`, `main-*.js`, +`manifest.json` and fonts relative to `/sp-sync/`, and copyparty 404s all of +them. Confirmed in Caddy's access log. + +**The sync itself is unaffected, and that was checked rather than assumed:** +`sync-data.json` contains a dot, so it is excluded from navigation handling, +and the only `dataGroup` in `ngsw.json` (`api-freshness`) matches +`api.github.com`, `gitlab.com/api` and `*.atlassian.net` — nothing under +`/sp-sync/` is ever cached or intercepted. + +To browse the synced files, use the **copyparty hostname**, which carries no +service worker: + +``` +https://cp.mathewcsims.uk/sp-sync/ +``` + +Note that `curl` will happily return 200 for both — curl has no service +worker. Only a real browser hits this, which is why it is written down. + +**Backups.** `copyparty/sp-sync` is a Kopia source — the only durable copy, +since everything else is browser IndexedDB that a cleared cache wipes. + +--- + ## Ghost blog (https://blog.mathewcsims.uk) [Ghost](https://ghost.org) — replaces paid Ghost(Pro) hosting. Two @@ -3573,6 +3833,18 @@ its `settings.json` has no machine-autostart preference and nothing shows it starting or stopping machines. Co-timing was the entire case, which is not one. +**`reload.sh` now installs the anchor from the repo (2026-08-08).** It used +to only reload `/etc/pf.conf`, so editing `pf-lockdown/` in the repo +and running the script applied **nothing** — the file had to be copied to +`/etc/pf.anchors/` by hand as a separate step, which is easy to forget and +impossible to notice, because the reload succeeds and reports success either +way. That is exactly what happened when ports 3600 and 3601 were added for +Fizzy and Super Productivity: both stayed reachable from every device on the +LAN while the repo, the commit and this document all said otherwise. The repo +is now the source of truth, as it already is for the Pi's Caddyfile — so a +hand-edit of the live file will be overwritten on the next run, which is the +intended direction. + **BOTH podman plists need this key, not just the autostart one.** The watchdog now runs `podman machine start` in its remediation path, so it has exactly the same exposure — and there it is worse than a plain omission: @@ -7556,12 +7828,27 @@ ssh mathewcsims@100.68.10.65 'timeout 5 bash -c " 000 (in the pf anchor) + 10.0.1.14:2368 -> 301 (published, NOT in the anchor) + 10.0.1.14:5230 -> 200 (published, NOT in the anchor) + 10.0.1.14:3000 -> 307 (published, NOT in the anchor) +``` + +Only the port pf covers is blocked, which isolates pf as the cause rather +than any podman-machine loopback quirk. So the Mac is a perfectly good +second source for the negative test, and needs no SSH. + +The two-host test above is still the better one, because it also proves the +**pass** path (the Pi must keep working — breaking that is an outage), which +a single-source test cannot. slartibartfast is reached over its **tailnet** +address there because its `sshd` does not listen on the LAN interface. ### Hardening pass (copyparty, Nimbus, Caddy, Memos) diff --git a/copyparty/cfg/copyparty.conf b/copyparty/cfg/copyparty.conf index 7a89bf4..4ee1942 100644 --- a/copyparty/cfg/copyparty.conf +++ b/copyparty/cfg/copyparty.conf @@ -41,10 +41,22 @@ # /orgtasks volume decommissioned, nothing here speaks WebDAV again, so the # incompatibility is moot and the ambiguity is free. # - # If a WebDAV volume is ever added back, THIS IS THE FIRST THING TO REMOVE, - # and test an UNAUTHENTICATED GET returns 401 — not merely that an - # authenticated one returns 200. `curl -u` and most JS clients send Basic - # auth preemptively and will pass while a challenge-based client is broken. + # A WebDAV volume WAS added back on 2026-08-08 (/sp-sync, below) and this + # was deliberately KEPT. The instruction that used to sit here — "remove + # this first" — assumed any WebDAV client waits to be challenged, which is + # what broke Orgzly. Both halves were checked this time rather than + # assumed: + # + # * Super Productivity's WebDAV client sets `Authorization: Basic` on + # EVERY request (read in its source, not inferred), so it never depends + # on being challenged; + # * and an UNAUTHENTICATED PROPFIND against a vague-403 instance returns + # **401**, not 404 — measured in a throwaway container with this exact + # config, which is the assertion the old note demanded. + # + # If a CHALLENGE-BASED client is ever added, re-run that second test first: + # an authenticated 200 proves nothing, because `curl -u` and every JS + # client send Basic auth preemptively and pass regardless. vague-403 # Brute-force login lockout: more than 9 wrong passwords in 60min bans that @@ -85,3 +97,46 @@ A: admin # only admin (you) can see and retrieve what's been dropped. # No "r" for anyone but admin, and no anonymous access, so a visitor must # know the inbox password to upload, and can never browse the contents. + +# ── Super Productivity's WebDAV sync target ──────────────────────────────── +# Holds nothing but Super Productivity's sync blob. The app is at +# https://sp.mathewcsims.uk and reaches this volume at +# https://sp.mathewcsims.uk/sp-sync/ — the SAME ORIGIN, via a Caddy route +# that proxies that path here without rewriting it. That is deliberate: +# Super Productivity syncs from the BROWSER, so a cross-origin target would +# need CORS, and copyparty only exposes --acao/--acam with no control over +# Access-Control-Allow-Headers. SP sends Depth and If-Match, which are not +# CORS-safelisted, so preflight would fail. Same-origin removes the problem +# rather than configuring around it. The path must NOT be rewritten, because +# copyparty puts its own paths in PROPFIND and the client resolves +# against them. +# +# The "spsync" account is defined in accounts-spsync.conf, rendered from its +# own Proton Pass item ("Copyparty SP Sync"). It is separate from +# accounts.conf because the agent's Pass token cannot UPDATE items, only +# create them — copyparty merges [accounts] across auto-loaded *.conf files, +# verified in a throwaway container before relying on it. +[/sp-sync] + /sp-sync + accs: + rwmd: spsync + # Read, write, move, delete — all four are required. Delete is not + # optional decoration: copyparty mandates the delete-permission whenever + # "daw" is set, and Super Productivity issues real DELETEs. + A: admin + # Admin keeps full access so the sync blob is reachable and restorable + # from the normal copyparty UI without the scoped account. + flags: + daw + # VOLFLAG, NOT GLOBAL — this is the important part. Without "daw", + # copyparty answers a PUT over an existing file by inventing a new + # filename rather than overwriting, which for a sync client means every + # save silently becomes a new file and sync never converges. Set + # globally it would change overwrite semantics for the ENTIRE file + # server, including your actual documents; copyparty's own help says to + # prefer the volflag, and it is scoped to this volume alone. + # + # Needed here specifically because Super Productivity does not send the + # 'x-oc-mtime' header that would otherwise make it unnecessary — + # confirmed by reading its WebDAV client, which sends only GET, PUT, + # DELETE, PROPFIND and MKCOL. diff --git a/copyparty/compose.yaml b/copyparty/compose.yaml index f21fdaa..091c4c0 100644 --- a/copyparty/compose.yaml +++ b/copyparty/compose.yaml @@ -23,6 +23,14 @@ services: - ./data:/w # PRIVATE storage (admin-only, served at /) - ./public:/pub # PUBLIC storage (anonymous read, served at /pub) - ./inbox:/inbox # DROP BOX storage (password write-only, served at /inbox) + # Super Productivity's WebDAV sync target (served at /sp-sync). THIS + # BIND IS LOAD-BEARING: a [/sp-sync] volume in copyparty.conf with no + # matching mount here still "works" — copyparty logs + # `type=overlay` and writes into the container's ephemeral layer, so + # every task would be lost the next time the container is recreated, + # silently and with no error. Caught during setup by reading that log + # line rather than trusting that the volume block was enough. + - ./sp-sync:/sp-sync ports: # IMPORTANT: podman-machine on macOS will only expose a published port on # the *specific* host IP you name here — it cannot bind 0.0.0.0 to the real diff --git a/fizzy/.env.example b/fizzy/.env.example new file mode 100644 index 0000000..f4def87 --- /dev/null +++ b/fizzy/.env.example @@ -0,0 +1,8 @@ +# Template only — the real .env is fetched live from Proton Pass at deploy +# time via ../scripts/pass-deploy.sh, never committed here. Create the item +# once with ../scripts/pass-create-fizzy-secrets.sh. +# +# Rails derives every other secret from this one (signed/encrypted cookies, +# Active Record encryption, secure link tokens), so rotating it invalidates +# all sessions. Treat it as permanent. +SECRET_KEY_BASE=changeme diff --git a/fizzy/compose.yaml b/fizzy/compose.yaml new file mode 100644 index 0000000..597f3c5 --- /dev/null +++ b/fizzy/compose.yaml @@ -0,0 +1,146 @@ +# Fizzy (https://www.fizzy.do) — 37signals' open-source Kanban board, used +# here as the BACKLOG: someday / now / done / never triage, not detailed task +# work. Detailed day-to-day tasks live in ../super-productivity/, recurring +# chores live in Tasks.org on the phone. See SETUP.md's "Task management" +# section for why the split is three tools and not one. +# +# Same architecture as ../copyparty: TLS and the public hostname +# (fizzy.mathewcsims.uk) are handled by Caddy on the Raspberry Pi (see +# ../pi-reverse-proxy/). This Mac only serves plain HTTP on the LAN, and only +# to the Pi — see ../pf-lockdown/, which restricts port 3600 the same way it +# restricts copyparty's 3923. +# +# LAN/tailnet-gated at Caddy, not public. It holds a private backlog, and +# Tailscale already covers access from away — the same call as docs, +# paperless, author and fj. +# +# SQLite, no DB sidecar: Fizzy's own Docker deployment guide puts everything +# under /rails/storage and ships no database service. Same discipline as +# Forgejo and Memos. +# +# All secrets live in ./.env (gitignored — see .env.example), rendered from +# Proton Pass at deploy time by ../scripts/pass-deploy.sh. NEVER run a bare +# `podman compose up -d` here: SECRET_KEY_BASE would silently become blank, +# and Rails would generate a new one, invalidating every session and any +# encrypted column. +name: fizzy + +services: + fizzy: + # Pinned by digest. Upstream publishes a rolling `:main` tag rather than + # versioned releases, so the tag alone would move under us silently — + # digest-pinning makes every upgrade a reviewed decision, same as every + # other image in this repo. Verified arm64-native, so no emulation on the + # M4 (unlike the organice attempt, which needed linux/amd64). + image: ghcr.io/basecamp/fizzy@sha256:c0aadd02a8249752998d3fe930ce99e24de6997522511ee052d1bb44616ac521 + container_name: fizzy + restart: unless-stopped + # Do NOT set `user:` — on podman-machine (macOS) the container's root maps + # to the host user, giving sane ownership on the bind mount below. Same + # reasoning as copyparty's and Ghost's compose.yaml. + environment: + SECRET_KEY_BASE: ${SECRET_KEY_BASE} + + # Used to build absolute links (emails, push payloads, secure links). + BASE_URL: https://fizzy.mathewcsims.uk + + # The image runs as uid 1000 (verified: `user=1000:1000`, entrypoint + # `./bin/thrust ./bin/rails server`), so the Thruster front-end CANNOT + # bind its default port 80 — it crash-loops with + # "listen tcp :80: bind: permission denied". Upstream's own example + # compose publishes 80/443 and works only because plain Docker there + # grants the capability; it does not here. + # + # Moving Thruster to an unprivileged port is the right fix. The + # alternative — running the container as root to reclaim port 80 — + # would undo the image's own privilege separation for no benefit, + # since Caddy is what the outside world talks to anyway. + HTTP_PORT: "8080" + + # DELIBERATELY NOT SET: TLS_DOMAIN and DISABLE_SSL. + # + # TLS_DOMAIN would make Fizzy terminate its own TLS, which is Caddy's + # job here. DISABLE_SSL would switch off `config.assume_ssl` AND + # `config.force_ssl` together (config/environments/production.rb:76-83 + # derives both from it), losing HSTS and secure cookies. + # + # Leaving both unset is exactly right behind a terminating proxy: + # assume_ssl=true makes Rails treat the forwarded request as HTTPS — + # so force_ssl does not bounce it into a redirect loop — while + # force_ssl=true keeps secure cookies and HSTS. This is Rails' own + # documented pattern for this topology, not a workaround. + + # Single-account mode is the DEFAULT and is left alone: Fizzy closes + # signups automatically as soon as the first account exists. That is + # the registration hardening every other app in this repo does + # explicitly (Vikunja's ENABLEREGISTRATION=false, Forgejo's + # DISABLE_REGISTRATION, Karakeep's DISABLE_SIGNUPS) — here it is + # upstream's default, so MULTI_TENANT must simply never be set to true. + + # SMTP — Proton submission. This is the FIRST app in this repo with + # working outbound mail; every other one leaves it commented out. + # + # Fizzy needs it for real: sign-in is a magic link / 6-character code, + # so without mail the only way in is reading the code out of this + # container's log. That is fine for a one-off and unacceptable as the + # standing arrangement. + # + # Credentials come from the SHARED "Proton SMTP" Pass item — created + # deliberately as a general-purpose sender for this and future apps, + # not per-app — so Fizzy must be deployed with BOTH items: + # ./scripts/pass-deploy.sh fizzy Fizzy "Proton SMTP" + # Deploying with only "Fizzy" leaves the credentials blank and every + # send fails authentication. + # + # THE FIELD NAMES DO NOT LINE UP, hence the mapping below: the Pass + # item uses SMTP_SERVER/SMTP_TOKEN (generic, provider-shaped) while + # Fizzy wants SMTP_ADDRESS/SMTP_PASSWORD (Rails ActionMailer-shaped). + # Mapping here rather than renaming the Pass fields keeps that item + # reusable by apps with their own naming. + # + # SMTP_TLS is deliberately unset: Proton's submission endpoint is + # STARTTLS on 587, and Fizzy's own docs say STARTTLS is used + # automatically — SMTP_TLS is only for implicit-TLS servers on 465. + # + # MAILER_FROM_ADDRESS must MATCH the address the Proton token was + # issued against (self-hosted@mathewcsims.uk). Proton rejects a From + # that differs from the token's own address, so this is not + # independently configurable — note it is the SENDING address, and is + # deliberately not the same as the account owner's address. + SMTP_ADDRESS: ${SMTP_SERVER} + SMTP_PORT: ${SMTP_PORT} + SMTP_USERNAME: ${SMTP_USERNAME} + SMTP_PASSWORD: ${SMTP_TOKEN} + MAILER_FROM_ADDRESS: ${SMTP_USERNAME} + + # Web Push is unavailable without VAPID keys, which cannot be generated + # before first boot (they need a Rails console in the running + # container). Not needed: Fizzy's notifications are activity-based + # (comments, assignment), not the due-date reminders this stack relies + # on — those come from Tasks.org and Super Productivity. To add later: + # podman exec -it fizzy bin/rails runner 'k=WebPush.generate_key; puts k.private_key, k.public_key' + # then set VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY here and in Pass. + + TZ: Europe/London + volumes: + # Everything Fizzy persists — the SQLite databases and Active Storage + # uploads — lives under this one path, per its deployment guide. + - ./storage:/rails/storage + ports: + # IMPORTANT (same podman-machine quirk as every other Mac app): a + # published port must be bound to the Mac's *actual* LAN IP — + # podman-machine cannot bind 0.0.0.0 to the real interface. The Pi's + # Caddy connects here. The router does NOT forward 3600, and + # ../pf-lockdown/ additionally blocks every LAN source except the Pi. + - "10.0.1.14:3600:8080" + healthcheck: + # Rails' built-in health endpoint (config/routes.rb: get "up", to: + # "rails/health#show"). `curl` is present in this image and `wget` is + # NOT — checked inside the image rather than assumed, because that + # exact assumption broke a previous deployment here. + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost:8080/up"] + interval: 30s + timeout: 5s + retries: 3 + # Rails boots slower than the static apps here; migrations run first. + start_period: 60s diff --git a/kopia-mac/backup.sh b/kopia-mac/backup.sh index 7b0d337..a9ce3ec 100755 --- a/kopia-mac/backup.sh +++ b/kopia-mac/backup.sh @@ -227,6 +227,13 @@ $REPO_ROOT/copyparty/data $REPO_ROOT/copyparty/public $REPO_ROOT/copyparty/inbox $REPO_ROOT/copyparty/cfg/accounts.conf +# Super Productivity's synced tasks. This is the ONLY durable copy — the app +# is local-first, so everything else lives in each browser's IndexedDB, which +# a cleared cache or a reinstalled laptop wipes without warning. The +# super-productivity container itself holds nothing and is not backed up. +$REPO_ROOT/copyparty/sp-sync +# Fizzy's SQLite databases and Active Storage uploads — its entire state. +$REPO_ROOT/fizzy/storage $REPO_ROOT/owl/data $REPO_ROOT/docs/pgdata $REPO_ROOT/docs/uploads diff --git a/pf-lockdown/com.mathewcsims.lan-lockdown b/pf-lockdown/com.mathewcsims.lan-lockdown index 0efdc23..1819ee4 100644 --- a/pf-lockdown/com.mathewcsims.lan-lockdown +++ b/pf-lockdown/com.mathewcsims.lan-lockdown @@ -1,11 +1,20 @@ -# pf anchor: restrict copyparty (3923) on this Mac to LAN-published-port -# access from the Pi (10.0.1.19) only. Caddy on the Pi is the only intended -# path in for every app in this repo — this closes the gap where any other -# device on the LAN could otherwise reach the port directly, bypassing Caddy -# entirely (still gated by the app's own login, but an unnecessary extra -# path in). +# pf anchor: restrict copyparty (3923), Fizzy (3600) and Super Productivity +# (3601) on this Mac to LAN-published-port access from the Pi (10.0.1.19) +# only. Caddy on the Pi is the only intended path in for every app in this +# repo — this closes the gap where any other device on the LAN could +# otherwise reach the port directly, bypassing Caddy entirely. # -# Scoped to exactly this one port/this one destination IP — nothing else +# Both new ports were added on 2026-08-08 when those apps were deployed. +# Super Productivity's 3601 matters more than it looks: that app has NO +# LOGIN AT ALL — it is a static bundle whose state lives in the browser — so +# unlike copyparty and Fizzy there is no second line of defence behind the +# proxy. Caddy's LAN gate and this rule are the whole of its access control. +# +# NOTE that Super Productivity's WebDAV sync target is copyparty, reached +# through Caddy at sp.mathewcsims.uk/sp-sync/ and therefore over 3923 — it +# needs no port of its own, and is covered by the copyparty rule below. +# +# Scoped to exactly these three ports/this one destination IP — nothing else # on this Mac is affected. Tried an app-level fix first (copyparty's --ipa # option) but it doesn't work behind podman's published-port NAT, which # only ever exposes its own internal gateway IP as the connection source, @@ -14,14 +23,14 @@ # is the only lever. # # Port 3456 (Vikunja) was covered here too until Vikunja was decommissioned -# on 2026-08-08; nothing listens on it now, so the rules were narrowed -# rather than left matching a dead port. +# on 2026-08-08; nothing listens on it now, so it was dropped rather than +# left matching a dead port. # # "quick" + top-to-bottom order matters: the allow rule for the Pi must # come before the catch-all block, since "quick" stops evaluation at the # first match. -pass in quick log proto tcp from 10.0.1.19 to 10.0.1.14 port 3923 -block in quick log proto tcp from any to 10.0.1.14 port 3923 +pass in quick log proto tcp from 10.0.1.19 to 10.0.1.14 port { 3923, 3600, 3601 } +block in quick log proto tcp from any to 10.0.1.14 port { 3923, 3600, 3601 } # ── SSH (port 22): macOS Remote Login, LAN + Tailscale only ────────────── # macOS's own Application Firewall is OFF (confirmed via diff --git a/pf-lockdown/reload.sh b/pf-lockdown/reload.sh index 8e02c94..4d66235 100755 --- a/pf-lockdown/reload.sh +++ b/pf-lockdown/reload.sh @@ -41,6 +41,9 @@ ANCHOR_FILE="/etc/pf.anchors/$ANCHOR" PF_CONF="/etc/pf.conf" APPRISE_URL="https://apprise.mathewcsims.uk/notify/self-hosted" +# The repo copy, alongside this script — the source of truth for the rules. +REPO_ANCHOR="$(cd "$(dirname "$0")" && pwd)/$ANCHOR" + log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $*"; } # Never fatal — the lockdown's correctness must not depend on the Pi being @@ -95,6 +98,40 @@ Host: mathews-mac" \ log "=== pf lockdown reload ===" +# 0. Install the repo's anchor if the live one differs. +# +# WHY THIS EXISTS. Editing pf-lockdown/ in the repo and running +# this script did NOT previously apply the change: reload.sh only ever +# reloaded /etc/pf.conf, and the anchor itself had to be copied to +# /etc/pf.anchors/ by hand as a separate step. That is a step easy to +# forget and impossible to notice, because the reload succeeds and +# reports success either way. +# +# It was forgotten on 2026-08-08, adding ports 3600 and 3601 for Fizzy and +# Super Productivity. Both stayed reachable from every device on the LAN +# while the repo, the commit and the docs all said otherwise — and Super +# Productivity has no login at all, so for that app the rule was the only +# access control there was. +# +# The repo is the source of truth, exactly as it is for the Pi's Caddyfile. +# Anything that edits the live file by hand will now be overwritten on the +# next run, which is the intended direction. +if [ ! -f "$REPO_ANCHOR" ]; then + log "FATAL: $REPO_ANCHOR is missing — cannot verify or install the ruleset" + notify "🔴 pf lockdown BROKEN — repo anchor missing" "failure" \ +"\`$REPO_ANCHOR\` does not exist, so this script cannot tell whether the live +ruleset is current. Check the repo is present and intact at that path." + exit 1 +fi + +if ! cmp -s "$REPO_ANCHOR" "$ANCHOR_FILE" 2>/dev/null; then + log "live anchor differs from the repo — installing $REPO_ANCHOR" + install -o root -g wheel -m 644 "$REPO_ANCHOR" "$ANCHOR_FILE" + ANCHOR_UPDATED=1 +else + ANCHOR_UPDATED=0 +fi + if [ ! -f "$ANCHOR_FILE" ]; then log "FATAL: $ANCHOR_FILE is missing" notify "🔴 pf lockdown BROKEN — anchor file missing" "failure" \ @@ -190,4 +227,8 @@ No action needed, but worth knowing an OS update did this — it will do it again." fi +if [ "$ANCHOR_UPDATED" -eq 1 ]; then + log "NOTE: the live anchor was out of date and has been updated from the repo" +fi + log "=== done ===" diff --git a/pi-reverse-proxy/Caddyfile b/pi-reverse-proxy/Caddyfile index 16c1cc4..2efb345 100644 --- a/pi-reverse-proxy/Caddyfile +++ b/pi-reverse-proxy/Caddyfile @@ -291,6 +291,95 @@ owl.mathewcsims.uk { } } +# ── Fizzy — backlog / triage board (running on the Mac, LAN-ONLY) ───────── +# Clean public URL https://fizzy.mathewcsims.uk, LAN + tailnet clients only. +# Holds a private backlog; Tailscale covers access from away, same call as +# docs, paperless, author and fj. Fizzy has its own account and closes +# signups once the first one exists, so this gate is defence in depth rather +# than the only control. +fizzy.mathewcsims.uk { + import access_log + import security_headers + + @lan remote_ip private_ranges 100.64.0.0/10 # + Tailscale CGNAT range + handle @lan { + # Hand-written rather than `import general_ratelimit fizzy` because + # the snippet applies at block level, outside this handle — same as + # BookStack, Paperless, Forgejo, Immich and docs. + rate_limit { + zone rl_fizzy { + key {remote_host} + events 300 + window 1m + } + } + reverse_proxy http://{$MAC_IP}:3600 + } + # non-LAN (internet) clients: closed connection, nothing revealed + handle { + abort + } +} + +# ── Super Productivity + its WebDAV sync target (LAN-ONLY) ──────────────── +# Clean public URL https://sp.mathewcsims.uk, LAN + tailnet clients only. +# +# TWO BACKENDS ON ONE HOSTNAME, AND THAT IS THE POINT. `/sp-sync/*` goes to +# copyparty; everything else goes to the Super Productivity static app. +# Super Productivity is local-first and syncs FROM THE BROWSER, so if its +# WebDAV target were on another hostname every request would be +# cross-origin. copyparty exposes only --acao/--acam and has no control over +# Access-Control-Allow-Headers, while SP sends `Depth` and `If-Match`, which +# are not CORS-safelisted — preflight would fail. Serving both from one +# origin removes the problem instead of configuring around it, and SP has a +# dedicated PotentialCorsError for exactly the failure this avoids. +# +# THE PATH MUST NOT BE REWRITTEN. copyparty puts its own absolute paths in +# PROPFIND (verified: `/sp-sync/`), and the client +# resolves subsequent requests against them — so `handle` with a plain +# reverse_proxy, never `handle_path`, which would strip the prefix and make +# every href point somewhere that does not exist. +# +# The app itself has NO login — it is a static bundle with all state in the +# browser — so for that half this gate IS the only access control. The +# copyparty half is separately protected by the scoped `spsync` account. +sp.mathewcsims.uk { + import access_log + import security_headers + + @lan remote_ip private_ranges 100.64.0.0/10 # + Tailscale CGNAT range + handle @lan { + route { + # `route` pins source order; without it the adapter reorders by + # its own directive precedence and the /sp-sync handle could run + # after the catch-all had already answered. Same lesson as the + # Owl and docs blocks above. + rate_limit { + zone rl_sp { + key {remote_host} + events 300 + window 1m + } + } + + # WebDAV sync target — copyparty's /sp-sync volume, scoped to the + # `spsync` account. Same path on both sides, deliberately. + handle /sp-sync/* { + reverse_proxy http://{$MAC_IP}:3923 + } + + # Everything else: the Super Productivity static app. + handle { + reverse_proxy http://{$MAC_IP}:3601 + } + } + } + # non-LAN (internet) clients: closed connection, nothing revealed + handle { + abort + } +} + # ── Ghost blog (running on the Mac) ─────────────────────────────────────── # Clean public URL https://blog.mathewcsims.uk — replaces paid Ghost(Pro) # hosting. Same pattern as copyparty above. Ghost's own docs warn that without diff --git a/scripts/dump-databases.sh b/scripts/dump-databases.sh index e89fe95..07d5eea 100755 --- a/scripts/dump-databases.sh +++ b/scripts/dump-databases.sh @@ -241,6 +241,11 @@ dump_mysql bookstack-db mariadb-dump bookstack dump_sqlite "$REPO_ROOT/owl/data/memos_prod.db" owl dump_sqlite "$REPO_ROOT/memos-prospect-ukri-tus/data/memos_prod.db" memos-prospect dump_sqlite "$REPO_ROOT/forgejo/data/gitea/gitea.db" forgejo +# Fizzy writes four SQLite files; only this one holds real data. The other +# three (production_cable, production_cache, production_queue) are Rails' +# Solid Cable/Cache/Queue infrastructure — regenerated on boot, worth nothing +# in a restore, and dumping them would just add noise to the nightly report. +dump_sqlite "$REPO_ROOT/fizzy/storage/production.sqlite3" fizzy dump_sqlite "$REPO_ROOT/karakeep/data/db.db" karakeep dump_sqlite "$REPO_ROOT/wanderer/data/pb_data/data.db" wanderer # Paperless runs SQLite in WAL mode (db.sqlite3 + -wal + -shm all present on @@ -282,7 +287,7 @@ done # at all — counts as broken, which is exactly how that incident presented. UNHEALTHY="" echo "=== post-dump health check ===" -for _host in owl prospect-ukri-tus karakeep wanderer fj blog author docs; do +for _host in owl prospect-ukri-tus karakeep wanderer fj blog author docs fizzy sp; do _code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "https://$_host.mathewcsims.uk/" 2>/dev/null || true) [ -z "$_code" ] && _code=000 case "$_code" in diff --git a/scripts/pass-create-copyparty-spsync-secret.sh b/scripts/pass-create-copyparty-spsync-secret.sh new file mode 100755 index 0000000..26fda73 --- /dev/null +++ b/scripts/pass-create-copyparty-spsync-secret.sh @@ -0,0 +1,70 @@ +#!/bin/sh +# One-time setup: creates the "Copyparty SP Sync" Proton Pass item — the +# scoped copyparty account Super Productivity syncs to over WebDAV. +# +# ./scripts/pass-create-copyparty-spsync-secret.sh +# +# WHY A SEPARATE ITEM RATHER THAN A NEW LINE IN THE EXISTING "Copyparty" +# ONE. The agent's Pass token is create-and-read only by design (see +# SETUP.md's agent access model) — it cannot UPDATE an item, and +# scripts/pass-import-file.sh CREATES rather than updates, so re-importing +# the existing Copyparty item to add a line would produce a second active +# item with the same title and make scripts/pass-render-file.sh fail closed. +# +# copyparty auto-loads every *.conf in /cfg, and a second [accounts] block in +# a second file MERGES with the first — verified empirically before relying +# on it (throwaway container, two config files, two accounts, each usable +# only on its own volume and 403 on the other). So this account lives in its +# own file, rendered from its own item, and the existing Copyparty item is +# never touched. +# +# The account is scoped to the /sp-sync volume only (see +# copyparty/cfg/copyparty.conf) and holds nothing but Super Productivity's +# sync blob. It is NOT the admin account and must never be given more. +set -eu + +# Alphanumeric only: copyparty's config is whitespace-and-colon delimited, so +# a password containing ':' or a leading/trailing space would be misparsed +# into a broken account rather than rejected loudly. +SPSYNC_PASSWORD=$(openssl rand -base64 48 | tr -dc 'A-Za-z0-9' | head -c 40) +export SPSYNC_PASSWORD + +python3 -c ' +import json, os, sys + +pw = os.environ["SPSYNC_PASSWORD"] + +# The whole-file secret: rendered to copyparty/cfg/accounts-spsync.conf at +# deploy time. Only the account lives here — the volume definition itself is +# not secret and stays in the tracked copyparty.conf where it can be reviewed. +accounts_conf = "\n".join([ + "# RENDERED FROM PROTON PASS — do not edit, do not commit.", + "# Item \"Copyparty SP Sync\", field ACCOUNTS_CONF.", + "# Regenerate with scripts/pass-render-file.sh; see copyparty/cfg/copyparty.conf", + "# for the /sp-sync volume this account is scoped to.", + "", + "[accounts]", + " spsync: " + pw, + "", +]) + +template = { + "title": "Copyparty SP Sync", + "note": "scoped copyparty account for Super Productivity WebDAV sync — see ~/self-hosted/copyparty/. Access: /sp-sync volume ONLY. Username spsync. Enter USERNAME/PASSWORD into Super Productivity > Sync > WebDAV at https://sp.mathewcsims.uk/sp-sync/", + "sections": [{ + "section_name": "Secrets", + "fields": [ + {"field_name": "USERNAME", "field_type": "text", "value": "spsync"}, + {"field_name": "PASSWORD", "field_type": "hidden", "value": pw}, + {"field_name": "ACCOUNTS_CONF", "field_type": "hidden", "value": accounts_conf}, + ], + }], +} +json.dump(template, sys.stdout) +' | pass-cli item create custom --vault-name "Self-Hosted Secrets" --from-template - >/dev/null +# Output suppressed: `item create` echoes the created item back, secrets included. + +unset SPSYNC_PASSWORD + +echo "Done. Verify with:" +echo " pass-cli item view --vault-name \"Self-Hosted Secrets\" --item-title \"Copyparty SP Sync\"" diff --git a/scripts/pass-create-fizzy-secrets.sh b/scripts/pass-create-fizzy-secrets.sh new file mode 100755 index 0000000..2d04389 --- /dev/null +++ b/scripts/pass-create-fizzy-secrets.sh @@ -0,0 +1,43 @@ +#!/bin/sh +# One-time setup: creates the "Fizzy" Proton Pass item. +# +# ./scripts/pass-create-fizzy-secrets.sh +# +# Unlike pass-create-ntfy-secrets.sh, the value is GENERATED here rather than +# taken from the environment — SECRET_KEY_BASE isn't chosen inside the app, +# it's just a long unguessable string the app is handed. Rails derives every +# other secret from it (signed/encrypted cookies, Active Record encryption, +# secure link tokens), so rotating it invalidates all sessions and any +# encrypted-at-rest column. Treat it as permanent. +# +# NOT SET HERE: VAPID_PRIVATE_KEY / VAPID_PUBLIC_KEY, which Fizzy uses for +# Web Push. Generating them needs a Rails console inside the running +# container (`bin/rails c` then `WebPush.generate_key`), so it can't be done +# before first boot. Fizzy runs fine without them — push notifications are +# simply unavailable. Add them later if wanted; see fizzy/compose.yaml. +set -eu + +SECRET_KEY_BASE=$(openssl rand -hex 64) +export SECRET_KEY_BASE + +python3 -c ' +import json, os, sys + +template = { + "title": "Fizzy", + "note": "self-hosted Kanban backlog (37signals Fizzy) — see ~/self-hosted/fizzy/. https://fizzy.mathewcsims.uk (LAN/tailnet only). Single-account mode: signups close automatically once the first account exists. No SMTP configured, so sign-in codes appear in the container log: podman logs fizzy | grep -i code", + "sections": [{ + "section_name": "Secrets", + "fields": [ + {"field_name": "SECRET_KEY_BASE", "field_type": "hidden", "value": os.environ["SECRET_KEY_BASE"]}, + ], + }], +} +json.dump(template, sys.stdout) +' | pass-cli item create custom --vault-name "Self-Hosted Secrets" --from-template - >/dev/null +# Output suppressed: `item create` echoes the created item back, secrets included. + +unset SECRET_KEY_BASE + +echo "Done. Verify with:" +echo " pass-cli item view --vault-name \"Self-Hosted Secrets\" --item-title \"Fizzy\"" diff --git a/scripts/pass-deploy.sh b/scripts/pass-deploy.sh index 25242eb..699a080 100755 --- a/scripts/pass-deploy.sh +++ b/scripts/pass-deploy.sh @@ -13,15 +13,34 @@ # session is already active — see SETUP.md. # # Usage: -# ./scripts/pass-deploy.sh [item-title] +# ./scripts/pass-deploy.sh [item-title ...] # # Example: # ./scripts/pass-deploy.sh karakeep +# ./scripts/pass-deploy.sh fizzy Fizzy "Fizzy SMTP" +# +# MORE THAN ONE ITEM. Extra item titles are merged into one environment, in +# the order given, so an app whose secrets are split across Pass items can +# still be deployed with the standard tooling. That split is not a style +# choice: the agent's Pass token is create-and-read only and cannot UPDATE an +# item (see SETUP.md's agent access model), so a secret added after an item +# was first created has to live in its own item. Fizzy's SMTP token is the +# first case; scripts/pass-deploy-kopia-server.sh already did the same thing +# by hand for "Kopia" + "Backblaze B2", remotely. +# +# Later items win on a name clash, which is only reachable if two items +# define the same field — worth knowing rather than discovering. set -eu -APP_DIR="${1:?Usage: $0 [item-title]}" -ITEM_TITLE="${2:-$(echo "$APP_DIR" | python3 -c 'import sys; print("".join(w.capitalize() for w in sys.stdin.read().strip().split("-")))')}" +APP_DIR="${1:?Usage: $0 [item-title ...]}" +shift + +# Default when no titles are given: derive one from the directory name +# (karakeep -> Karakeep, super-productivity -> SuperProductivity). +if [ "$#" -eq 0 ]; then + set -- "$(echo "$APP_DIR" | python3 -c 'import sys; print("".join(w.capitalize() for w in sys.stdin.read().strip().split("-")))')" +fi SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" REPO_ROOT="$(dirname "$SCRIPT_DIR")" @@ -48,10 +67,9 @@ if ! pass-cli info >/dev/null 2>&1; then fi fi -echo "Fetching secrets for \"$ITEM_TITLE\" from Proton Pass..." - -EXPORTS=$(PROTON_PASS_AGENT_REASON="Fetching secrets to deploy $APP_DIR" \ - pass-cli item view --vault-name "Self-Hosted Secrets" --item-title "$ITEM_TITLE" --output json \ +fetch_exports() { + PROTON_PASS_AGENT_REASON="Fetching secrets to deploy $APP_DIR" \ + pass-cli item view --vault-name "Self-Hosted Secrets" --item-title "$1" --output json \ | python3 -c ' import json, sys, shlex @@ -70,7 +88,19 @@ for f in fields: name = f["name"] value = list(f["content"].values())[0] print(f"export {name}={shlex.quote(value)}") -') +' +} + +EXPORTS="" +for _item in "$@"; do + echo "Fetching secrets for \"$_item\" from Proton Pass..." + # Captured then eval'd all at once, deliberately: eval-ing inside the + # loop would leave a partially-applied environment behind if a later + # item failed to resolve, and `podman compose up` would then run with + # some variables silently blank. set -eu aborts before the eval instead. + EXPORTS="$EXPORTS +$(fetch_exports "$_item")" +done eval "$EXPORTS" diff --git a/super-productivity/compose.yaml b/super-productivity/compose.yaml new file mode 100644 index 0000000..d33b60a --- /dev/null +++ b/super-productivity/compose.yaml @@ -0,0 +1,54 @@ +# Super Productivity (https://super-productivity.com) — the middle tier of +# the three-tool task setup: detailed day-to-day to-dos with reminders, that +# are too heavy for the Fizzy backlog and not recurring chores. Backlog is +# ../fizzy/, recurring chores are Tasks.org on the phone and watch. See +# SETUP.md's "Task management" section. +# +# THIS CONTAINER HOLDS NO DATA. It is nginx serving a static Angular bundle +# (upstream Dockerfile's final stage is `FROM nginx:1` with the built app +# copied to /usr/share/nginx/html). Super Productivity is local-first: your +# tasks live in the BROWSER's IndexedDB on each device, and are synced by the +# app itself to WebDAV. There is no server-side database, no accounts, and +# nothing here to back up — losing this container loses nothing. +# +# That also means the sync target is what actually matters. It is copyparty's +# /sp-sync volume, reached at https://sp.mathewcsims.uk/sp-sync/ — the SAME +# ORIGIN as this app, deliberately. See ../pi-reverse-proxy/Caddyfile for why +# that matters (CORS) and ../copyparty/cfg/copyparty.conf for the volume and +# the dav-port it is served from. +# +# LAN/tailnet-gated at Caddy. There is no login on this app — there is +# nothing to log into — so the gate is the only access control, and without +# it anyone could load the client. They would see an empty instance, but +# there is no reason to publish it. +name: super-productivity + +services: + super-productivity: + # Pinned by digest; upstream's `:latest` would move silently. Verified + # arm64-native, so no emulation on the M4. + image: docker.io/johannesjo/super-productivity@sha256:ae91fe9ac19561e0f3669d15a2c4c71d7a75c43a29eb44ddc010ae50d1f63c82 + container_name: super-productivity + restart: unless-stopped + environment: + # The image's entrypoint templates nginx's config from this; 80 is its + # own default, stated explicitly so the port mapping below is readable + # rather than depending on an unwritten default. + APP_PORT: "80" + TZ: Europe/London + # No volumes, deliberately — see the header. Adding one would imply this + # container holds state that needs backing up, and it does not. + ports: + # Bound to the Mac's real LAN IP (podman-machine cannot bind 0.0.0.0 to + # the real interface). Reached only by the Pi's Caddy; the router does + # not forward 3601, and ../pf-lockdown/ blocks every LAN source but the + # Pi. + - "10.0.1.14:3601:80" + healthcheck: + # `curl` is present in this image and `wget` is NOT — checked inside + # the image rather than assumed. + test: ["CMD", "curl", "-fsS", "-o", "/dev/null", "http://localhost/"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 10s