diff --git a/.gitignore b/.gitignore index 72f8e19..5d24f20 100644 --- a/.gitignore +++ b/.gitignore @@ -56,6 +56,13 @@ bookstack/db/ bookstack/config/ forgejo/data/ wanderer/data/ +# HedgeDoc (docs.mathewcsims.uk): its Postgres datadir, and the uploads +# directory holding every image pasted into a note. uploads/ is user data with +# no copy anywhere in git, so kopia-mac/backup.sh is its only backup — see +# docs/compose.yaml. +docs/pgdata/ +docs/uploads/ + # Paperless: the SQLite database, Tantivy full-text index and trained # classifier. Deliberately local disk, never the NAS — see # paperless/compose.yaml for why that split is not negotiable. The document diff --git a/README.md b/README.md index a9b5109..67e2fb5 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,7 @@ in front of the lot. | [Wanderer](https://github.com/open-wanderer/wanderer) | `wanderer.mathewcsims.uk` | Mac (self-hosted GPS trail/cycle-ride log — GPX/FIT/TCX/KML import; Meilisearch + PocketBase sidecars; posts a Memo to Owl on every new ride via a PocketBase-realtime relay) | | [Immich](https://immich.app) | `immich.mathewcsims.uk` | **slartibartfast** (self-hosted photo/video library with local CLIP semantic search + face recognition — first app on the third host; LAN/tailnet-only, local accounts, no public sharing) | | [Paperless-ngx](https://github.com/paperless-ngx/paperless-ngx) | `paperless.mathewcsims.uk` | Mac (personal document store — letters, medical, certificates; OCR on ingest, LAN-only. Document blobs live on the NAS over CIFS; the SQLite database and search index stay on local disk. **Deliberately not wired to LiteLLM** — personal medical/legal documents have no business on an employer's GCP project) | +| [HedgeDoc](https://hedgedoc.org) | `docs.mathewcsims.uk` | Mac (personal Markdown authoring — real-time notes, per-note permissions, document list; LAN/tailnet-only **and** account-based, since the LAN gate admits any tailnet device but isn't an identity. Notes default to owner-only; accounts created deliberately via `scripts/docs-add-user.sh`, no self-registration, no guest access) | | [LiteLLM](https://github.com/BerriAI/litellm) | `litellm.possum-prometheus.ts.net` | **slartibartfast** (OpenAI-compatible proxy in front of employer-funded Gemini Enterprise Agent Platform (formerly Vertex AI) — **tailnet-only** via a Tailscale sidecar tagged `personal` — no public hostname, no DNS record, not behind Caddy; ADC auth, no service-account key) | ### Decommissioned @@ -79,6 +80,7 @@ internet → DrayTek router → Pi (Caddy, terminates HTTPS, routes by hostname) ├─ owl.mathewcsims.uk → Mac ├─ author.mathewcsims.uk → Mac (LAN clients only) ├─ paperless.mathewcsims.uk → Mac (LAN clients only) + ├─ docs.mathewcsims.uk → Mac (LAN clients only) └─ fj.mathewcsims.uk → Mac (LAN clients only; git-over-SSH bypasses Caddy entirely, port 2222) @@ -208,6 +210,9 @@ forgejo/ compose.yaml and data (Mac — self-hosted git remote, LA contact-sync/ cross-provider contact sync engine + launchd job (Mac — data lives at ~/contact-sync, store pushed to Forgejo) wanderer/ compose.yaml and data (Mac — GPS trail/cycle-ride log) +docs/ compose.yaml, Postgres and uploads (Mac — HedgeDoc, + personal Markdown authoring, LAN-only; uploads/ holds + every pasted image and exists only here + Kopia) pi-reverse-proxy/ Caddy reverse proxy (Pi — deployed via scp + docker compose) autostart/ launchd auto-start for podman on the Mac scripts/ deploy tooling that fetches secrets from Proton Pass diff --git a/SETUP.md b/SETUP.md index 37afc71..1b86ab8 100644 --- a/SETUP.md +++ b/SETUP.md @@ -125,6 +125,9 @@ section below says which. | `bookstack/db/` | **Mac** | **BookStack's MariaDB datadir** | | `forgejo/compose.yaml` | **Mac** | Forgejo (git + issues), SQLite; LAN-only (`fj.mathewcsims.uk`) plus direct git-over-SSH on port 2222; reads secrets from Proton Pass | | `forgejo/data/` | **Mac** | **your repos, SQLite DB, SSH host keys live here** | +| `docs/compose.yaml` | **Mac** | HedgeDoc — personal Markdown authoring (`docs.mathewcsims.uk`); LAN/tailnet-only *and* per-user login; Postgres sidecar; reads `POSTGRES_PASSWORD` from Proton Pass | +| `docs/pgdata/` | **Mac** | **your notes live here** (HedgeDoc's Postgres datadir) | +| `docs/uploads/` | **Mac** | **every image pasted into a note lives here** — gitignored, so Kopia is the only copy | | `contact-sync/` | **Mac** | hub-and-spoke contact sync engine (Proton/Google/2× Microsoft) — `sync.py` + per-provider spoke modules; canonical vCard store lives outside the repo at `~/contact-sync/store/` (its own private Forgejo repo); daily launchd job | | `ntfy/compose.yaml` | **Pi** | self-hosted push notifications, on trial alongside Discord; auth default-deny, fed by Apprise | | `ntfy/data/` | **Pi** | **auth DB, message cache, attachments live here** | @@ -6343,6 +6346,120 @@ PDFs) ever outgrows the cost of a second copy. --- +## HedgeDoc / Docs (https://docs.mathewcsims.uk) — LAN-only, runs on the Mac + +Personal Markdown authoring space. Real-time notes, a document list, per-note +permissions, and proper accounts. Friends and family on the tailnet can be given +accounts. + +### Why it has BOTH a LAN gate and its own login + +The Caddy gate admits any device on the LAN or the tailnet, which is the +availability wanted — but it is not an *identity*. It cannot distinguish one +tailnet device from another, and these are personal drafts. HedgeDoc's own +accounts supply the identity, and notes default to owner-only. + +### Why HedgeDoc and not Etherpad + +An Etherpad instance was built for this and torn down the same day. Its +`requireAuthentication` is HTTP Basic only — a native browser prompt, credentials +cached by the browser, and no usable logout — it has **no per-note permissions +at all**, and its own OIDC provider cannot serve pad login (core accepts only +Basic, and the provider's endpoints sit behind that same gate). None of that was +fixable by configuration. Two bugs found along the way were filed upstream +([ether/etherpad#8109](https://github.com/ether/etherpad/issues/8109), +[ether/ep_comments_page#454](https://github.com/ether/ep_comments_page/issues/454)). + +### The trap: HedgeDoc builds ABSOLUTE URLs + +Every asset URL comes from `CMD_DOMAIN` + `CMD_PROTOCOL_USESSL` + +`CMD_URL_ADDPORT`, **not** from the incoming request. Set them to the +container's values instead of the public ones and you get a completely unstyled +page with every asset 404ing. They must describe how a *browser* reaches this: +`docs.mathewcsims.uk`, SSL true, no port. This fails loudly and immediately, so +an unstyled page after a config change means look here first. + +### Accounts + +Self-registration is off, so accounts only exist because you made them: + +``` +./scripts/docs-add-user.sh someone@example.com +``` + +That prints a generated password once and stores nothing — put it in Proton +Pass or hand it over. Remove one with `./scripts/docs-add-user.sh --delete +someone@example.com` (which does **not** delete their notes). + +**The permission model, and its limit.** Each note is owner-controlled with six +levels: Freely, Editable, Limited, Locked, Protected, and Private. New notes are +**Private** (`CMD_DEFAULT_PERMISSION=private`) — verified that a private note +returns **403** to a different signed-in account and does not appear in that +account's History. But permissions are owner / all-signed-in / guests: there is +**no per-person sharing**. Sharing a note shares it with everyone who has an +account. + +### /status and /metrics are blocked at the proxy + +HedgeDoc serves both without authentication, exposing note counts, +registered-user counts and who is online. Caddy `abort`s them (verified: 200 for +`/`, connection closed for `/status` and `/metrics`). Two consequences: + +- The container healthcheck reaches `/status` on `127.0.0.1` **inside** the + container, so it is unaffected. +- Uptime Kuma therefore monitors `/` instead — see below. + +### Monitoring + +| Field | Value | +|---|---| +| Monitor type | `HTTP(s) - Keyword` | +| Friendly name | `Docs` | +| URL | `https://docs.mathewcsims.uk/` | +| Keyword | `HedgeDoc` | + +`/` returns 200 without authentication and contains that string (verified), so a +keyword monitor works even though the app is behind a login. Kuma runs on the +Pi, which is in `private_ranges`, so the LAN gate is not an obstacle. + +The `dump-databases.sh` post-dump health-check loop needs no special handling — +it already treats 4xx as healthy and only alerts on 5xx or no response. + +### The healthcheck uses node, not wget + +The HedgeDoc image ships **no wget, curl, nc or python3** — only node. A +wget-based healthcheck would report the container permanently unhealthy while +the service was fine (a mistake already made once on the Etherpad instance this +replaces). It also uses `127.0.0.1`, not `localhost`, because localhost can +resolve to `::1` first while the app listens on IPv4. + +### To bring it up on a fresh machine + +1. Create the Pass item once, under your own pass-cli session (agent tokens are + read-only for item creation): + ``` + ./scripts/pass-create-docs-secrets.sh + ``` +2. Deploy: + ``` + ./scripts/pass-deploy.sh docs + ``` + Never a bare `podman compose up -d` — `POSTGRES_PASSWORD` would be empty and + Postgres would initialise with trust authentication. +3. Create your account with `./scripts/docs-add-user.sh`. +4. Add the DNS records and the Caddy block (see the general recipe below), copy + the Caddyfile to the Pi and `docker compose restart caddy`. + +### Backups + +`docs/pgdata` is dumped nightly by `scripts/dump-databases.sh` (`dump_postgres +docs-postgres hedgedoc hedgedoc docs`). **`docs/uploads/` is a separate Kopia +source and matters just as much** — it holds every image pasted into a note, it +is gitignored, and it exists nowhere else. A restore without it gives you notes +full of broken images. + +--- + ## Adding another app (the general recipe) Three patterns, depending on where the app runs: diff --git a/docs/.env.example b/docs/.env.example new file mode 100644 index 0000000..05cb627 --- /dev/null +++ b/docs/.env.example @@ -0,0 +1,25 @@ +# Template only — there is NO real .env for this app. +# +# This value lives as a "Docs" item in the "Self-Hosted Secrets" Proton Pass +# vault, and scripts/pass-deploy.sh exports it into the deploy process's +# environment at `podman compose up` time. Nothing is written to disk. This +# file exists purely to record which field the Pass item must contain. +# +# Create the item once with: +# ./scripts/pass-create-docs-secrets.sh +# +# Deploy with: +# ./scripts/pass-deploy.sh docs +# +# Never `podman compose up -d` directly. POSTGRES_PASSWORD would resolve to an +# empty string, which for the postgres image means it initialises the cluster +# with trust authentication — every container on the compose network could then +# read the database without a password, and you would not be told. + +# Postgres password for the hedgedoc role. Consumed twice: by the postgres +# service (POSTGRES_PASSWORD) and by HedgeDoc's CMD_DB_URL connection string. +POSTGRES_PASSWORD= + +# NOTE: user account passwords are NOT here. HedgeDoc accounts are created with +# ./scripts/docs-add-user.sh, which prints a generated password once and stores +# nothing — put it straight into Proton Pass yourself. diff --git a/docs/compose.yaml b/docs/compose.yaml new file mode 100644 index 0000000..1bb912d --- /dev/null +++ b/docs/compose.yaml @@ -0,0 +1,167 @@ +# HedgeDoc (https://hedgedoc.org) — personal Markdown authoring space at +# docs.mathewcsims.uk. Real-time collaborative notes with per-note permissions, +# a document list, and proper accounts. +# +# Same architecture as every other Mac app here: this container serves plain +# HTTP on the LAN only, and the Caddy reverse proxy on the Pi terminates HTTPS +# and forwards to MAC_IP:3500 (see ../pi-reverse-proxy/). +# +# ── Why BOTH a LAN gate and its own login ──────────────────────────────── +# The Caddy gate admits anything on the LAN or the tailnet, which is the right +# availability — family on the tailnet may be given accounts later — but it is +# not an identity: it cannot tell one tailnet device from another, and these +# are personal drafts. HedgeDoc's own accounts supply the identity, and notes +# default to PRIVATE (owner-only). +# +# ── Why HedgeDoc and not Etherpad ──────────────────────────────────────── +# An Etherpad instance was built for this and torn down the same day. Its +# `requireAuthentication` is HTTP Basic only — a native browser prompt with +# browser-cached credentials and no usable logout — it has no per-note +# permissions at all, and its built-in OIDC provider cannot serve pad login +# (core only accepts Basic, and the provider's own endpoints sit behind that +# same gate). None of it was fixable by configuration. +# +# Everything claimed below was verified by running it behind a Caddy carrying +# this repo's real security_headers, not inferred: +# * renders correctly under X-Frame-Options: DENY (no iframe editor, unlike +# Etherpad, so no per-site header override is needed) +# * real login form; with CMD_ALLOW_EMAIL_REGISTER=false the register option +# disappears entirely +# * real logout that actually ends the session +# * a note left PRIVATE returns 403 to a *different signed-in account* and +# does not appear in that account's History +# * the /socket.io/ upgrade works through Caddy untouched — typed content was +# confirmed present in Postgres, not just in the browser +# +# ── The trap: HedgeDoc builds ABSOLUTE URLs ────────────────────────────── +# Every asset URL is constructed from CMD_DOMAIN + CMD_PROTOCOL_USESSL + +# CMD_URL_ADDPORT, NOT from the incoming request. Set them to the container's +# own values rather than the public ones and you get a completely unstyled page +# with every asset 404ing. Hit exactly that during testing. They must describe +# how a *browser* reaches this, i.e. https://docs.mathewcsims.uk with no port. +# +# Secrets live in Proton Pass, not a .env file (see ./.env.example). Deploy +# with ./scripts/pass-deploy.sh docs — never a bare `podman compose up -d`, or +# POSTGRES_PASSWORD resolves to empty and Postgres silently initialises with +# trust auth. +name: docs + +services: + postgres: + # 17-alpine. Pinned by digest, not the tag alone — tags aren't immutable, + # so a bare version tag can still silently move. Same pattern as + # ../healthlog/compose.yaml, this repo's other Postgres app. + image: postgres:17-alpine@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193 + container_name: docs-postgres + restart: unless-stopped + security_opt: + - no-new-privileges:true + environment: + POSTGRES_USER: hedgedoc + POSTGRES_DB: hedgedoc + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - ./pgdata:/var/lib/postgresql/data + # No `ports:` — reached only by the hedgedoc service, by container name, + # over this project's own default network. Never exposed to the LAN. + healthcheck: + test: ["CMD-SHELL", "pg_isready -U hedgedoc"] + interval: 5s + timeout: 5s + retries: 5 + mem_limit: 512m + pids_limit: 256 + + hedgedoc: + image: quay.io/hedgedoc/hedgedoc@sha256:14f583bcf248e407a8b95e1c4bbf612543d13d3ad6b103aab8f007a99db5dd17 # 1.11.1 + container_name: docs + restart: unless-stopped + # Do NOT set `user:` — on podman-machine (macOS) the container's root maps + # to your host user, giving sane ownership on the bind mounts below. Same + # reasoning as vikunja and copyparty. + # + # Hardening. The capability set is dictated by the image, not chosen: the + # entrypoint starts as root and drops to the `hedgedoc` user, so a bare + # `cap_drop: ALL` crash-loops it with + # error: failed switching to "hedgedoc": operation not permitted + # — the same class of trap the BookStack block records for s6 images. + # + # SETUID and SETGID are the MINIMAL set, established by probing rather + # than copied from BookStack: with cap_drop ALL it fails; +SETUID,SETGID + # it starts; adding CHOWN, DAC_OVERRIDE and FOWNER changes nothing. Also + # verified with the uploads bind mount attached (the one place a chown + # might have been needed) — it starts and the directory is writable. + # NET_BIND_SERVICE is not needed either; it binds 3000, not a low port. + # + # Limits are ~4x observed steady state — measured 120 MiB for HedgeDoc and + # 30 MiB for Postgres at idle. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - SETUID + - SETGID + mem_limit: 512m + pids_limit: 256 + depends_on: + postgres: + condition: service_healthy + environment: + TZ: Europe/London + CMD_DB_URL: postgres://hedgedoc:${POSTGRES_PASSWORD}@postgres:5432/hedgedoc + + # See "The trap" in the header. These describe how a BROWSER reaches + # this instance, not how the container is addressed internally. + CMD_DOMAIN: docs.mathewcsims.uk + CMD_PROTOCOL_USESSL: "true" + CMD_URL_ADDPORT: "false" + + # Accounts only. No guests at all: the LAN gate already limits *who can + # reach* this, and identity is what decides what they can see. Combined + # with CMD_ALLOW_FREEURL=false, an unauthenticated visitor cannot read + # or create anything. + CMD_ALLOW_ANONYMOUS: "false" + CMD_ALLOW_FREEURL: "false" + + # Email/password login is on, but self-registration is off — accounts + # are created deliberately with ./scripts/docs-add-user.sh, which wraps + # HedgeDoc's own `bin/manage_users`. This is what stops the instance + # growing accounts you didn't intend. + CMD_EMAIL: "true" + CMD_ALLOW_EMAIL_REGISTER: "false" + + # New notes are owner-only until deliberately shared. The safe default + # for personal drafts, and verified: a private note 403s for another + # signed-in account and stays out of its History. + CMD_DEFAULT_PERMISSION: private + volumes: + # Images pasted into notes land here. This is user data with no copy in + # git, so Kopia is its only backup — see kopia-mac/backup.sh. + - ./uploads:/hedgedoc/public/uploads + ports: + # IMPORTANT (same podman-machine quirk as every other Mac app here): 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 + # reverse proxy connects to this address. 3000 is HedgeDoc's own default + # but karakeep already holds it (the same reason healthlog took 3200), + # so 3500. The router does NOT forward 3500, so this is never directly + # internet-facing even before the Caddy LAN-gate is considered. + - "10.0.1.14:3500:3000" + healthcheck: + # node, NOT wget: this image ships no wget, curl, nc or python3 — + # confirmed by checking inside the running container. A wget healthcheck + # would report the container permanently unhealthy while the service was + # perfectly fine, which is a mistake already made once on the Etherpad + # instance this replaces. + # + # 127.0.0.1, NOT localhost — localhost can resolve to ::1 first while the + # app listens on IPv4, the other half of that same Etherpad mistake. + # + # /status is served without authentication (verified). Caddy blocks it + # from outside; this check never goes through Caddy. + test: ["CMD", "node", "-e", "fetch('http://127.0.0.1:3000/status').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"] + interval: 30s + timeout: 5s + retries: 3 + start_period: 30s diff --git a/kopia-mac/backup.sh b/kopia-mac/backup.sh index 819e417..0be33ce 100755 --- a/kopia-mac/backup.sh +++ b/kopia-mac/backup.sh @@ -231,6 +231,8 @@ $REPO_ROOT/copyparty/public $REPO_ROOT/copyparty/inbox $REPO_ROOT/copyparty/cfg/accounts.conf $REPO_ROOT/owl/data +$REPO_ROOT/docs/pgdata +$REPO_ROOT/docs/uploads $REPO_ROOT/bookstack/config $REPO_ROOT/bookstack/db $REPO_ROOT/forgejo/data diff --git a/pi-reverse-proxy/Caddyfile b/pi-reverse-proxy/Caddyfile index 1828b9d..280e494 100644 --- a/pi-reverse-proxy/Caddyfile +++ b/pi-reverse-proxy/Caddyfile @@ -833,6 +833,65 @@ msims.link { } } +# ── HedgeDoc / Docs (running on the Mac, LAN-ONLY) ─────────────────────── +# Clean public URL https://docs.mathewcsims.uk, but only for LAN and tailnet +# clients — the LAN-gate idiom explained at the top of this file. Personal +# Markdown authoring space; see ../docs/. +# +# Like BookStack, this has BOTH the gate and its own login, and that is the +# point rather than belt-and-braces: the gate admits any device on the LAN or +# the tailnet (right availability — family on the tailnet may get accounts) +# but it is not an identity. HedgeDoc's own accounts supply that, and notes +# default to owner-only. +# +# No X-Frame-Options override needed here, unlike the Ghost and Owl blocks +# above: HedgeDoc's editor is not built from iframes, and it was verified +# rendering correctly under security_headers' blanket DENY before deploying. +# (The Etherpad instance this replaces needed SAMEORIGIN for exactly that +# reason — a useful contrast, not a precedent to copy.) +docs.mathewcsims.uk { + import security_headers + + @lan remote_ip private_ranges 100.64.0.0/10 # + Tailscale CGNAT range + handle @lan { + # `route` to pin the order: without it Caddyfile's adapter reorders + # top-level directives by its own precedence rather than source + # order, so the /status block below could end up running AFTER + # reverse_proxy had already answered — same reasoning as the Owl + # block above, which learned this the hard way. + route { + # Hand-written rather than `import general_ratelimit docs` + # because the snippet applies at block level, outside this + # handle — same as BookStack, Paperless, Forgejo and Immich. + rate_limit { + zone rl_docs { + key {remote_host} + events 300 + window 1m + } + } + + # HedgeDoc serves /status and /metrics WITHOUT authentication, + # exposing note counts, registered-user counts and who is + # currently online. Harmless to the app but not something every + # device on the tailnet needs to see, and HedgeDoc's own docs + # suggest restricting them at the proxy. Blocked outright: the + # container's healthcheck reaches /status on 127.0.0.1 inside + # the container, never through here, and Uptime Kuma monitors + # `/` with the keyword "HedgeDoc" instead (verified: `/` + # returns 200 unauthenticated and contains that string). + @stats path /status /metrics + abort @stats + + reverse_proxy http://{$MAC_IP}:3500 + } + } + # non-LAN (internet) clients: closed connection, nothing revealed + handle { + abort + } +} + # ── Refuse every other hostname / bare-IP access ───────────────────────── # Only the hostnames explicitly configured above are served. Any other # SNI/Host — including a bare-IP scan of the public address — gets a closed diff --git a/scripts/docs-add-user.sh b/scripts/docs-add-user.sh new file mode 100755 index 0000000..b4b9840 --- /dev/null +++ b/scripts/docs-add-user.sh @@ -0,0 +1,65 @@ +#!/bin/sh +# Creates an account on the HedgeDoc instance at docs.mathewcsims.uk. +# +# Self-registration is deliberately disabled (CMD_ALLOW_EMAIL_REGISTER=false in +# docs/compose.yaml), so this is the only way accounts come into existence — +# which is what keeps the instance from growing accounts you didn't intend. +# It wraps HedgeDoc's own `bin/manage_users`; nothing here is bespoke. +# +# ── What the new account can and cannot see ────────────────────────────── +# Notes default to PRIVATE (CMD_DEFAULT_PERMISSION=private), so a new account +# sees nothing of yours: verified that a private note returns 403 to a +# different signed-in account and does not appear in its History. A note only +# becomes visible to other accounts when you deliberately change its +# permission to Editable, Limited, Locked or Protected from the note itself. +# +# Note the limit of HedgeDoc's model: permissions are owner / all-signed-in / +# guests. There is no per-person sharing — you cannot share one note with just +# one family member. Sharing a note shares it with everyone who has an account. +# +# Usage: +# ./scripts/docs-add-user.sh +# ./scripts/docs-add-user.sh --delete +# +# Prints the generated password ONCE. It is not stored anywhere by this +# script — put it into Proton Pass or hand it over directly. +set -eu + +CONTAINER=docs + +usage() { echo "usage: $0 [--delete] " >&2; exit 1; } + +DELETE=0 +if [ "${1:-}" = "--delete" ]; then DELETE=1; shift; fi +EMAIL=${1:-} +[ -n "$EMAIL" ] || usage +# Keep it to something that is plausibly an email and safe to pass onward. +echo "$EMAIL" | grep -qE '^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$' || { + echo "'$EMAIL' does not look like an email address" >&2; exit 1; } + +podman ps --format '{{.Names}}' | grep -qx "$CONTAINER" || { + echo "container '$CONTAINER' is not running — deploy first with ./scripts/pass-deploy.sh docs" >&2 + exit 1; } + +if [ "$DELETE" = 1 ]; then + podman exec "$CONTAINER" bin/manage_users --del "$EMAIL" + echo "Deleted $EMAIL." + echo "Their notes are NOT deleted with the account — reassign or remove them separately." + exit 0 +fi + +PASSWORD=$(python3 -c 'import secrets,string; print("".join(secrets.choice(string.ascii_letters+string.digits) for _ in range(24)))') + +# --pass keeps manage_users from prompting interactively. The password does +# appear in this container exec's argv, which is acceptable here because the +# alternative (an interactive prompt) can't be scripted, and it is a value you +# are about to read on screen anyway. It is not written to any file. +podman exec "$CONTAINER" bin/manage_users --pass "$PASSWORD" --add "$EMAIL" >/dev/null + +echo "Created HedgeDoc account:" +echo +echo " email: $EMAIL" +echo " password: $PASSWORD" +echo +echo "Shown once and not stored — record it now." +echo "They sign in at https://docs.mathewcsims.uk (LAN or tailnet only)." diff --git a/scripts/dump-databases.sh b/scripts/dump-databases.sh index 6f3e43a..047c8e9 100755 --- a/scripts/dump-databases.sh +++ b/scripts/dump-databases.sh @@ -229,6 +229,9 @@ dump_sqlite() { echo "=== dumping databases -> $OUT ===" dump_postgres healthlog-postgres healthlog healthlog healthlog +# HedgeDoc. Note this dump is the notes themselves; images pasted into notes +# live in docs/uploads/ and are covered by the Kopia source instead, not here. +dump_postgres docs-postgres hedgedoc hedgedoc docs dump_mysql blog-db mysqldump ghost dump_mysql bookstack-db mariadb-dump bookstack @@ -278,7 +281,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 vikunja karakeep wanderer fj healthlog blog author; do +for _host in owl prospect-ukri-tus vikunja karakeep wanderer fj healthlog blog author docs; 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-docs-secrets.sh b/scripts/pass-create-docs-secrets.sh new file mode 100755 index 0000000..0d83e05 --- /dev/null +++ b/scripts/pass-create-docs-secrets.sh @@ -0,0 +1,38 @@ +#!/bin/sh +# One-time setup: generates the Postgres password for the HedgeDoc instance at +# docs.mathewcsims.uk (see ../docs/) and stores it as a new Proton Pass item, +# "Docs", for docs/ to read at deploy time via scripts/pass-deploy.sh — same +# pattern as every other app in this repo. +# +# Deliberately NOT run by the agent — pass-cli agent PATs are read-only by +# design, so item creation has to happen under your own personal pass-cli +# session, not the agent one used elsewhere in this repo's tooling. +# +# The secret is generated INSIDE this script and goes straight into Pass — +# never typed, never printed, never touches argv or a file on disk. +# +# Hex rather than mixed alphanumeric: it travels through compose's ${VAR} +# interpolation into both POSTGRES_PASSWORD and a postgres:// connection +# string, and hex avoids both the shell-quoting and the URL-encoding classes +# of bug in one go. +# +# Usage: +# ./scripts/pass-create-docs-secrets.sh +set -eu + +POSTGRES_PASSWORD=$(openssl rand -hex 32) + +printf '{"title":"Docs","note":"HedgeDoc — personal Markdown authoring, see ~/self-hosted/docs/. LAN/tailnet-only at https://docs.mathewcsims.uk. User accounts are created with scripts/docs-add-user.sh and are NOT stored here.","sections":[{"section_name":"Secrets","fields":[{"field_name":"POSTGRES_PASSWORD","field_type":"hidden","value":"%s"}]}]}' \ + "$POSTGRES_PASSWORD" \ + | pass-cli item create custom --vault-name "Self-Hosted Secrets" --from-template - >/dev/null +# Output suppressed: `item create` echoes the created item back, including the +# secret just generated above — the same trap the BookStack script documents. +# Never let this command's stdout reach a terminal or log. + +echo "Done. \"Docs\" item created in Proton Pass with POSTGRES_PASSWORD —" +echo "nothing printed here, the value went straight from openssl into Pass." +echo +echo "Verify with:" +echo " pass-cli item view --vault-name \"Self-Hosted Secrets\" --item-title \"Docs\"" +echo +echo "Then deploy: ./scripts/pass-deploy.sh docs"