From a0df23bcc3eb1b4af54ac10e6e28c54110bfe7ad Mon Sep 17 00:00:00 2001 From: kh0pper <93635715+kh0pper@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:17:17 -0500 Subject: [PATCH 1/2] fix(lemmy): the bundle starts, and the generated admin can log in (lemmy 1.0.4) The Lemmy bundle never started on the pinned images: - the entrypoint ran /app/lemmy; dessalines/lemmy:0.19.17 ships /usr/local/bin/lemmy_server (the image's own entrypoint); - it wrote its config into ./config, mounted read-only, as uid 1000; - both health checks called wget, which neither the lemmy nor the lemmy-ui image has, so lemmy never turned healthy and lemmy-ui never started; - pict-rs runs as uid 991 and got a host folder the Docker daemon creates as root. Now the entrypoint writes a mode-600 config into the container's own /tmp at every start and execs lemmy_server; the hostname reaches the script through the environment and is checked (bare hostname only) before use; health checks use curl; Postgres and pict-rs data live in this compose project's own volumes, so uninstall with delete data removes both; the config holds only keys Lemmy 0.19 reads (tls_enabled true because Caddy serves https); pict-rs limits use the 0.5 key names; LEMMY_FEDERATION_ENABLED and LEMMY_OPEN_REGISTRATION are gone (site settings in 0.19). The generated LEMMY_ADMIN_PASSWORD (keychain) still reaches Lemmy's first-start setup only through the environment. Docs: lemmy-ui does not proxy the API (checked live: 404), so the notes, skill and post-install text say the public site must split /api, /pictrs, /feeds, /nodeinfo, /.well-known and ActivityPub traffic to lemmy:8536. Licensing note added (AGPL-3.0 Lemmy/lemmy-ui/pict-rs, separate containers). Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp --- bundles/lemmy/docker-compose.yml | 80 ++++++++++--------- bundles/lemmy/manifest.json | 14 ++-- bundles/lemmy/scripts/backup.sh | 7 +- bundles/lemmy/scripts/post-install.sh | 21 ++--- bundles/lemmy/skills/lemmy.md | 18 ++--- bundles/skill-history.json | 3 +- registry/add-ons.json | 22 ++---- tests/bundle-generated-secrets.test.js | 2 +- tests/lemmy-bundle.test.js | 104 +++++++++++++++++++++++++ 9 files changed, 179 insertions(+), 92 deletions(-) create mode 100644 tests/lemmy-bundle.test.js diff --git a/bundles/lemmy/docker-compose.yml b/bundles/lemmy/docker-compose.yml index f9a42b048..45edfdc81 100644 --- a/bundles/lemmy/docker-compose.yml +++ b/bundles/lemmy/docker-compose.yml @@ -4,14 +4,18 @@ # postgres + pict-rs (image hosting). lemmy + lemmy-ui on crow-federation # (Caddy reverse-proxies to lemmy-ui:1234); DB + pict-rs on default. # -# Lemmy-UI serves the SPA AND proxies /api/v3/* to lemmy:8536 internally, -# so a single Caddy federation site is enough. +# lemmy-ui serves the web app only; it does NOT proxy the API (GET /api/v3/site on +# lemmy-ui answers 404, checked live on 0.19.17). The public site must send /api, +# /pictrs, /feeds, /nodeinfo, /.well-known and ActivityPub requests to lemmy:8536 and +# everything else to lemmy-ui:1234. # # Data: -# ~/.crow/lemmy/postgres/ Postgres data dir -# ~/.crow/lemmy/pictrs/ pict-rs sled db + media files -# ~/.crow/lemmy/lemmy.hjson Generated once at install time; not volume-mounted -# because lemmy reads env-var overrides too. +# volume "postgres" Postgres data (posts, users, the instance's private keys) +# volume "pictrs" pict-rs sled db + media files +# Both are Docker-managed volumes of this compose project: the images run as their +# own uids (postgres 70, pict-rs 991), which a host folder created by the Docker +# daemon as root does not suit, and uninstall with delete data removes both. +# Lemmy's config is written inside the container at every start (see the entrypoint). # # Images: dessalines/lemmy:0.19.17 and dessalines/lemmy-ui:0.19.17 (floats # within 0.19.x — verify latest + CVE feed at implementation time per the @@ -22,6 +26,10 @@ networks: external: true default: +volumes: + postgres: + pictrs: + services: postgres: image: postgres:16-alpine @@ -33,7 +41,7 @@ services: POSTGRES_PASSWORD: ${LEMMY_DB_PASSWORD} POSTGRES_DB: lemmy volumes: - - ${LEMMY_DATA_DIR:-~/.crow/lemmy}/postgres:/var/lib/postgresql/data + - postgres:/var/lib/postgresql/data init: true mem_limit: 512m restart: unless-stopped @@ -52,15 +60,16 @@ services: - default environment: PICTRS__SERVER__API_KEY: ${LEMMY_PICTRS_API_KEY} - PICTRS__MEDIA__VIDEO_CODEC: vp9 - PICTRS__MEDIA__GIF__MAX_WIDTH: "256" - PICTRS__MEDIA__GIF__MAX_HEIGHT: "256" - PICTRS__MEDIA__GIF__MAX_AREA: "65536" - PICTRS__MEDIA__GIF__MAX_FRAME_COUNT: "400" + # pict-rs 0.5 names (0.4's MEDIA__GIF__* and MEDIA__VIDEO_CODEC are ignored by 0.5) + PICTRS__MEDIA__VIDEO__VIDEO_CODEC: vp9 + PICTRS__MEDIA__ANIMATION__MAX_WIDTH: "256" + PICTRS__MEDIA__ANIMATION__MAX_HEIGHT: "256" + PICTRS__MEDIA__ANIMATION__MAX_AREA: "65536" + PICTRS__MEDIA__ANIMATION__MAX_FRAME_COUNT: "400" RUST_LOG: warn,tracing=warn RUST_BACKTRACE: full volumes: - - ${LEMMY_DATA_DIR:-~/.crow/lemmy}/pictrs:/mnt + - pictrs:/mnt init: true mem_limit: 512m restart: unless-stopped @@ -79,60 +88,56 @@ services: RUST_LOG: "warn" RUST_BACKTRACE: "full" LEMMY_DATABASE_URL: "postgres://lemmy:${LEMMY_DB_PASSWORD}@postgres:5432/lemmy" - LEMMY_CONFIG_LOCATION: /config/config.hjson + # The image runs as uid 1000 (lemmy) with no writable config folder, so the + # entrypoint writes the config into the container's own /tmp at every start. + LEMMY_CONFIG_LOCATION: /tmp/crow-lemmy/config.hjson # Secrets reach the entrypoint through the environment and are expanded by its # shell ($${...}), so the script text that stays in PID 1's command line (init: true) # holds variable names, never values. - CROW_LEMMY_DB_PASSWORD: "${LEMMY_DB_PASSWORD}" + CROW_LEMMY_HOSTNAME: "${LEMMY_HOSTNAME:?LEMMY_HOSTNAME is required}" CROW_LEMMY_PICTRS_API_KEY: "${LEMMY_PICTRS_API_KEY}" CROW_LEMMY_ADMIN_PASSWORD: "${LEMMY_ADMIN_PASSWORD}" - volumes: - - ./config:/config:ro + # Lemmy 0.19 reads only the keys below (federation on/off, captcha and registration + # are site settings in Admin -> Settings). tls_enabled: true because Caddy serves + # https; Lemmy itself still listens on plain http. `setup` is used once, at the + # first start against an empty database, to create the admin. entrypoint: - sh - -c - | - mkdir -p /config - cat > /config/config.hjson <&2; exit 64 ;; + esac + mkdir -p /tmp/crow-lemmy + cat > /tmp/crow-lemmy/config.hjson </dev/null 2>&1 || exit 1"] + test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:8536/api/v3/site || exit 1"] interval: 30s timeout: 10s retries: 10 start_period: 60s + start_interval: 5s lemmy-ui: image: dessalines/lemmy-ui:0.19.17 @@ -151,8 +156,9 @@ services: mem_limit: 256m restart: unless-stopped healthcheck: - test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:1234/ >/dev/null 2>&1 || exit 1"] + test: ["CMD-SHELL", "curl -fsS -o /dev/null http://127.0.0.1:1234/ || exit 1"] interval: 30s timeout: 10s retries: 10 start_period: 60s + start_interval: 5s diff --git a/bundles/lemmy/manifest.json b/bundles/lemmy/manifest.json index 2093ddcbd..d15d59543 100644 --- a/bundles/lemmy/manifest.json +++ b/bundles/lemmy/manifest.json @@ -1,7 +1,7 @@ { "id": "lemmy", "name": "Lemmy", - "version": "1.0.3", + "version": "1.0.4", "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.", "type": "bundle", "author": "Crow", @@ -31,16 +31,14 @@ "recommended_disk_mb": 50000 }, "env_vars": [ - { "name": "LEMMY_HOSTNAME", "description": "Public domain (subdomain; path-mounts break ActivityPub).", "required": true }, + { "name": "LEMMY_HOSTNAME", "description": "Public hostname, bare (no https://, no path), e.g. lemmy.example.com. Lemmy stores it in every federated identity at its first start, so choose it before installing.", "required": true }, { "name": "LEMMY_DB_PASSWORD", "description": "Password for the bundled Postgres role. Generated by Crow at install; never shown.", "required": true, "secret": true, "generate": "secret" }, { "name": "LEMMY_PICTRS_API_KEY", "description": "Shared secret Lemmy uses to talk to pict-rs. Generated by Crow at install; never shown.", "required": true, "secret": true, "generate": "secret" }, - { "name": "LEMMY_ADMIN_PASSWORD", "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first successful start. (This bundle's container does not start yet — see the notes.)", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "admin password", "keychain_username": "admin_pending" }, - { "name": "LEMMY_JWT", "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", "required": false, "secret": true }, - { "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false }, - { "name": "LEMMY_OPEN_REGISTRATION", "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", "default": "false", "required": false }, - { "name": "LEMMY_FEDERATION_ENABLED", "description": "Whether to federate with other ActivityPub servers (true/false).", "default": "true", "required": false } + { "name": "LEMMY_ADMIN_PASSWORD", "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first start against an empty database; change it after your first login.", "required": true, "secret": true, "generate": "secret", "keychain": true, "keychain_label": "admin password", "keychain_username": "admin_pending" }, + { "name": "LEMMY_JWT", "description": "Admin login JWT: POST /api/v3/user/login with username admin_pending and the admin password from Settings → Passwords, then paste the returned jwt here.", "required": false, "secret": true }, + { "name": "LEMMY_URL", "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false } ], "ports": [], "webUI": null, - "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed). Known issue: the entrypoint runs a path the pinned Lemmy image does not have, so the container does not start yet; a fix needs a live test." + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs); data lives in two Docker volumes (database, images), removed by uninstall with delete data. On its first start Lemmy creates the admin admin_pending with the generated password saved in Settings → Passwords; log in with it, then change the password and rename the account or add your own admin. lemmy-ui does not proxy the API: the public site must send /api, /pictrs, /feeds, /nodeinfo, /.well-known and ActivityPub requests (Accept: application/activity+json) to lemmy:8536 and the rest to lemmy-ui:1234. caddy_add_federation_site takes one upstream, so that split is a hand-written Caddy site for now. Federation, registration and captcha are site settings in Lemmy's Admin → Settings (Lemmy 0.19 does not read them from its config file). Licensing: Lemmy and lemmy-ui are AGPL-3.0 (image labels: AGPL-3.0-or-later), pict-rs is AGPL-3.0-only, Postgres uses the PostgreSQL License. Crow pulls the unmodified upstream images and runs them as separate containers, so Crow's own code stays MIT; a hosted Crow offering that runs Lemmy for other people must offer them the source of the Lemmy version it runs, including any changes (AGPL section 13)." } diff --git a/bundles/lemmy/scripts/backup.sh b/bundles/lemmy/scripts/backup.sh index ccccc6b95..bfeaaf004 100755 --- a/bundles/lemmy/scripts/backup.sh +++ b/bundles/lemmy/scripts/backup.sh @@ -4,7 +4,6 @@ set -euo pipefail STAMP="$(date -u +%Y%m%dT%H%M%SZ)" BACKUP_ROOT="${CROW_HOME:-$HOME/.crow}/backups/lemmy" -DATA_DIR="${LEMMY_DATA_DIR:-$HOME/.crow/lemmy}" mkdir -p "$BACKUP_ROOT" WORK="$(mktemp -d)" @@ -18,8 +17,10 @@ if docker ps --format '{{.Names}}' | grep -qw crow-lemmy-postgres; then docker exec crow-lemmy-postgres rm "/tmp/lemmy-${STAMP}.pgcustom" fi -# pict-rs sled + files -tar -C "$DATA_DIR/pictrs" -cf "$WORK/pictrs.tar" . 2>/dev/null || true +# pict-rs sled + files (a Docker-managed volume, read through the running container) +if docker ps --format '{{.Names}}' | grep -qw crow-lemmy-pictrs; then + docker exec crow-lemmy-pictrs tar -C /mnt -cf - . > "$WORK/pictrs.tar" 2>/dev/null || true +fi OUT="${BACKUP_ROOT}/lemmy-${STAMP}.tar.zst" if command -v zstd >/dev/null 2>&1; then diff --git a/bundles/lemmy/scripts/post-install.sh b/bundles/lemmy/scripts/post-install.sh index 445309949..154086129 100755 --- a/bundles/lemmy/scripts/post-install.sh +++ b/bundles/lemmy/scripts/post-install.sh @@ -42,24 +42,19 @@ cat < Passwords). There is no setup wizard. - 2. Open https://${LEMMY_HOSTNAME:-}/ and complete the setup - wizard (admin username + password + site name). The compose - entrypoint creates a first admin, admin_pending, with the password - Crow generated at install (Settings -> Passwords). + 2. Expose ${LEMMY_HOSTNAME:-} with a Caddy site that sends /api, + /pictrs, /feeds, /nodeinfo, /.well-known and ActivityPub requests to + lemmy:8536 and everything else to lemmy-ui:1234 (lemmy-ui does not proxy + the API). 3. Obtain a JWT: curl -X POST https://${LEMMY_HOSTNAME:-}/api/v3/user/login \\ -H 'Content-Type: application/json' \\ - -d '{"username_or_email":"admin","password":""}' - Paste the returned jwt into .env as LEMMY_JWT, then: - crow bundle restart lemmy + -d '{"username_or_email":"admin_pending","password":" Passwords>"}' + Put the returned jwt in LEMMY_JWT, then restart the extension. 4. Verify: lemmy_status {} diff --git a/bundles/lemmy/skills/lemmy.md b/bundles/lemmy/skills/lemmy.md index 5e7198aac..577988724 100644 --- a/bundles/lemmy/skills/lemmy.md +++ b/bundles/lemmy/skills/lemmy.md @@ -36,22 +36,16 @@ Gated by F.0's hardware check. Refused below **1 GB effective RAM after committe ## First-run bootstrap -1. After install, expose via Caddy: - ``` - caddy_add_federation_site { - domain: "lemmy.example.com", - upstream: "lemmy-ui:1234", - profile: "activitypub" - } - ``` -2. Open https://lemmy.example.com/ and complete the setup wizard (admin username + password + site name). The compose entrypoint creates a first admin, `admin_pending`, with a password Crow generated at install (Settings → Passwords); log in with it if the wizard does not appear, and rename or replace the account. +1. On its first start Lemmy creates the admin `admin_pending` with the password Crow generated at install (Settings → Passwords). There is no setup wizard. +2. Expose it on its own subdomain. lemmy-ui serves the web app only and does **not** proxy the API, so the site must split traffic: `/api`, `/pictrs`, `/feeds`, `/nodeinfo`, `/.well-known` and ActivityPub requests (`Accept: application/activity+json`) go to `lemmy:8536`, everything else to `lemmy-ui:1234`. `caddy_add_federation_site` takes a single upstream, so write this Caddy site by hand for now. 3. Obtain an auth JWT: ```bash curl -X POST https://lemmy.example.com/api/v3/user/login \ -H 'Content-Type: application/json' \ - -d '{"username_or_email":"admin","password":""}' + -d '{"username_or_email":"admin_pending","password":""}' ``` - Copy `jwt` from the response into `.env` as `LEMMY_JWT`, then `crow bundle restart lemmy`. + Copy `jwt` from the response into `LEMMY_JWT` (the extension's settings), then restart the extension. +4. Federation, registration and captcha are site settings: change them in Lemmy's Admin → Settings. Rename `admin_pending` or add your own admin there too. ## Common workflows @@ -114,7 +108,7 @@ Lemmy's moderation is layered: **community mods** handle their community; **inst ## Troubleshooting - **"Cannot reach Lemmy"** — `docker ps | grep crow-lemmy`. First-boot migrations take 60+ seconds; the healthcheck has a 60s start period. -- **Federation not working** — check `LEMMY_FEDERATION_ENABLED` is true (default). Also verify `federation.enabled` is true in `/config/config.hjson` inside the container (`docker exec crow-lemmy cat /config/config.hjson`). +- **Federation not working** — check that federation is on in Lemmy's Admin → Settings, and that the public site sends `/.well-known`, `/nodeinfo` and ActivityPub requests to `lemmy:8536` (lemmy-ui answers them with 404). - **Images not loading** — pict-rs container health check. `docker logs crow-lemmy-pictrs` and verify `LEMMY_PICTRS_API_KEY` matches between the two containers. - **Disk filling fast** — federated community caching. Run `lemmy_media_prune` and reduce follow count on heavy communities. - **Admin endpoints returning 404** — your `LEMMY_JWT` may belong to a regular user rather than admin. Verify in the web UI's **Admin** dropdown — if absent, log out, promote the account via DB, and re-login. diff --git a/bundles/skill-history.json b/bundles/skill-history.json index 5a9b1d110..a86172ce0 100644 --- a/bundles/skill-history.json +++ b/bundles/skill-history.json @@ -131,7 +131,8 @@ ], "lemmy/skills/lemmy.md": [ "8159d8d03f02023153a8b78a885c92ed45d76bd163fee5f547487b400fb3529f", - "4da3ec3fd92fb5b03ddb54823c4d7147a6da63f76e30b6ab8c5334494c1f83aa" + "4da3ec3fd92fb5b03ddb54823c4d7147a6da63f76e30b6ab8c5334494c1f83aa", + "3a46fb8ae525356cd3c8f911ec23661c648963ea7bfd55c302a456553d0d6a7a" ], "linkding/skills/linkding.md": [ "72ab294aa4ba9d905e8ccf81212e14290c7c3a864c5ab3f6119b2ca6e65526c6" diff --git a/registry/add-ons.json b/registry/add-ons.json index b0de393b9..7b33aebfb 100644 --- a/registry/add-ons.json +++ b/registry/add-ons.json @@ -2518,7 +2518,7 @@ { "id": "lemmy", "name": "Lemmy", - "version": "1.0.3", + "version": "1.0.4", "description": "Federated link aggregator and discussion platform over ActivityPub — Reddit-alternative on the fediverse. Posts, comments, communities, votes; remote Lemmy/Mastodon/Kbin users can follow your communities and comment cross-server.", "type": "bundle", "author": "Crow", @@ -2573,7 +2573,7 @@ "env_vars": [ { "name": "LEMMY_HOSTNAME", - "description": "Public domain (subdomain; path-mounts break ActivityPub).", + "description": "Public hostname, bare (no https://, no path), e.g. lemmy.example.com. Lemmy stores it in every federated identity at its first start, so choose it before installing.", "required": true }, { @@ -2592,7 +2592,7 @@ }, { "name": "LEMMY_ADMIN_PASSWORD", - "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first successful start. (This bundle's container does not start yet — see the notes.)", + "description": "Password of the first Lemmy admin (username admin_pending). Crow generates it at install and saves it in Settings → Passwords (this machine only); Lemmy creates the admin with it on its first start against an empty database; change it after your first login.", "required": true, "secret": true, "generate": "secret", @@ -2602,7 +2602,7 @@ }, { "name": "LEMMY_JWT", - "description": "Admin login JWT (obtain via POST /api/v3/user/login after registering the admin via the web UI).", + "description": "Admin login JWT: POST /api/v3/user/login with username admin_pending and the admin password from Settings → Passwords, then paste the returned jwt here.", "required": false, "secret": true }, @@ -2611,23 +2611,11 @@ "description": "Internal URL the MCP server uses to reach Lemmy's API (default http://lemmy:8536 over crow-federation).", "default": "http://lemmy:8536", "required": false - }, - { - "name": "LEMMY_OPEN_REGISTRATION", - "description": "Allow new user signups (true/false). Default false — opens registration only after moderation tooling is configured.", - "default": "false", - "required": false - }, - { - "name": "LEMMY_FEDERATION_ENABLED", - "description": "Whether to federate with other ActivityPub servers (true/false).", - "default": "true", - "required": false } ], "ports": [], "webUI": null, - "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs). Expose via caddy_add_federation_site { domain: LEMMY_HOSTNAME, upstream: 'lemmy-ui:1234', profile: 'activitypub' }. The UI proxies /api/v3/ to lemmy:8536 internally. Initial admin registers via the /signup page during the first-boot setup wizard (no CLI bootstrap needed). Known issue: the entrypoint runs a path the pinned Lemmy image does not have, so the container does not start yet; a fix needs a live test.", + "notes": "Four containers (lemmy + lemmy-ui + postgres + pict-rs); data lives in two Docker volumes (database, images), removed by uninstall with delete data. On its first start Lemmy creates the admin admin_pending with the generated password saved in Settings → Passwords; log in with it, then change the password and rename the account or add your own admin. lemmy-ui does not proxy the API: the public site must send /api, /pictrs, /feeds, /nodeinfo, /.well-known and ActivityPub requests (Accept: application/activity+json) to lemmy:8536 and the rest to lemmy-ui:1234. caddy_add_federation_site takes one upstream, so that split is a hand-written Caddy site for now. Federation, registration and captcha are site settings in Lemmy's Admin → Settings (Lemmy 0.19 does not read them from its config file). Licensing: Lemmy and lemmy-ui are AGPL-3.0 (image labels: AGPL-3.0-or-later), pict-rs is AGPL-3.0-only, Postgres uses the PostgreSQL License. Crow pulls the unmodified upstream images and runs them as separate containers, so Crow's own code stays MIT; a hosted Crow offering that runs Lemmy for other people must offer them the source of the Lemmy version it runs, including any changes (AGPL section 13).", "official": true }, { diff --git a/tests/bundle-generated-secrets.test.js b/tests/bundle-generated-secrets.test.js index f19c5c97a..958677660 100644 --- a/tests/bundle-generated-secrets.test.js +++ b/tests/bundle-generated-secrets.test.js @@ -148,7 +148,7 @@ test("lemmy: no literal credential in the compose file; secrets reach the script const compose = readFileSync(join(REPO, "bundles", "lemmy", "docker-compose.yml"), "utf8"); assert.match(compose, /admin_password:\s*"\$\$\{CROW_LEMMY_ADMIN_PASSWORD\}"/); assert.match(compose, /CROW_LEMMY_ADMIN_PASSWORD:\s*"\$\{LEMMY_ADMIN_PASSWORD\}"/); - const script = compose.slice(compose.indexOf("cat > /config"), compose.indexOf("HJSON\n exec")); + const script = compose.slice(compose.indexOf("cat > /tmp/crow-lemmy"), compose.indexOf("HJSON\n exec")); assert.ok(script.length > 100, "found the entrypoint script"); assert.doesNotMatch(script, /(?= 0, `service ${name} exists`); + const rest = services.slice(start + 1); + const next = rest.search(/^ [a-z][a-z0-9-]*:\s*$/m); + return next < 0 ? services.slice(start) : services.slice(start, start + 1 + next); +} +const lemmy = service("lemmy"); + +test("the entrypoint starts the binary the pinned image ships", () => { + assert.match(lemmy, /image:\s*dessalines\/lemmy:0\.19\.\d+/); + assert.match(lemmy, /^\s*exec lemmy_server\s*$/m, "exec lemmy_server (on PATH at /usr/local/bin)"); + assert.doesNotMatch(compose, /\/app\/lemmy/, "the image has no /app"); +}); + +test("the config is written somewhere the container user can write, and read from there", () => { + const loc = lemmy.match(/LEMMY_CONFIG_LOCATION:\s*"?([^"\s]+)"?/); + assert.ok(loc, "LEMMY_CONFIG_LOCATION is set"); + assert.match(loc[1], /^\/tmp\//, "inside the container's own writable /tmp"); + assert.ok(lemmy.includes(`cat > ${loc[1]} <<`), "the entrypoint writes the file Lemmy reads"); + assert.match(lemmy, /umask 077/, "the file holding secrets is private to the container user"); + assert.doesNotMatch(lemmy, /^\s*-\s+\S+:\/config(?::ro)?\s*$/m, "no config folder mount"); +}); + +test("health checks use a tool the images have (curl), not wget", () => { + for (const name of ["lemmy", "lemmy-ui"]) { + const s = service(name); + assert.match(s, /healthcheck:[\s\S]*curl -fsS/, `${name} checks with curl`); + assert.doesNotMatch(s, /wget/, `${name} images ship no wget`); + } +}); + +test("pict-rs gets a Docker-managed volume (its uid 991 cannot write a root-created host folder)", () => { + const p = service("pictrs"); + assert.match(p, /^\s*-\s+pictrs:\/mnt\s*$/m); + assert.match(compose, /^volumes:[\s\S]*?^ pictrs:\s*$/m, "the named volume is declared"); +}); + +test("all data lives in this compose project's own Docker volumes, never a host folder", () => { + assert.doesNotMatch(compose, /~\/\.crow|LEMMY_DATA_DIR/); + assert.match(service("postgres"), /^\s*-\s+postgres:\/var\/lib\/postgresql\/data\s*$/m); + assert.match(compose, /^volumes:\s*\n\s+postgres:\s*\n\s+pictrs:/m); +}); + +test("the hostname reaches the script through the environment and is checked before use", () => { + assert.match(lemmy, /CROW_LEMMY_HOSTNAME:\s*"\$\{LEMMY_HOSTNAME:\?[^}]*\}"/); + assert.match(lemmy, /hostname:\s*"\$\$\{CROW_LEMMY_HOSTNAME\}"/); + assert.match(lemmy, /case "\$\$\{CROW_LEMMY_HOSTNAME\}" in/); + const body = lemmy.slice(lemmy.indexOf("- |")); + assert.doesNotMatch(body, /(? { + const p = service("pictrs"); + assert.match(p, /image:\s*asonix\/pictrs:0\.5\./); + assert.doesNotMatch(p, /PICTRS__MEDIA__GIF__|PICTRS__MEDIA__VIDEO_CODEC/); + assert.match(p, /PICTRS__MEDIA__ANIMATION__MAX_WIDTH/); +}); + +test("the generated admin password reaches Lemmy's first-start setup through the environment only", () => { + assert.match(lemmy, /admin_username:\s*"admin_pending"/); + assert.match(lemmy, /admin_password:\s*"\$\$\{CROW_LEMMY_ADMIN_PASSWORD\}"/); + assert.match(lemmy, /CROW_LEMMY_ADMIN_PASSWORD:\s*"\$\{LEMMY_ADMIN_PASSWORD\}"/); + const v = manifest.env_vars.find((e) => e.name === "LEMMY_ADMIN_PASSWORD"); + assert.equal(v.generate, "secret"); + assert.equal(v.keychain, true); + assert.equal(v.keychain_username, "admin_pending"); +}); + +test("only keys Lemmy 0.19 reads are written, and federation ids are https behind Caddy", () => { + const script = lemmy.slice(lemmy.indexOf("< e.name === "LEMMY_FEDERATION_ENABLED"), false, "no env var that Lemmy ignores"); +}); + +test("the manifest no longer says the bundle cannot start, and names the upstream licence", () => { + const text = JSON.stringify(manifest); + assert.doesNotMatch(text, /does not start yet/); + assert.match(manifest.notes, /AGPL-3\.0/); +}); From 8d92396ef20af0d26b51aca02b5782fb90c6adef Mon Sep 17 00:00:00 2001 From: kh0pper <93635715+kh0pper@users.noreply.github.com> Date: Fri, 16 Oct 2026 17:29:09 -0500 Subject: [PATCH 2/2] fix(lemmy): stricter hostname check (tested), backup fails loudly instead of writing an empty archive Review follow-ups: the hostname must be bare and lowercase with no leading or trailing dot, colon or dash and no empty label; a test runs the entrypoint's own case pattern against good and bad values. backup.sh refuses when pict-rs is not running or its archive is empty. Claude-Session: https://claude.ai/code/session_01N9fXzvNy8rCEArj1y5p6fp --- bundles/lemmy/docker-compose.yml | 2 +- bundles/lemmy/scripts/backup.sh | 7 ++++--- tests/lemmy-bundle.test.js | 14 +++++++++++++- 3 files changed, 18 insertions(+), 5 deletions(-) diff --git a/bundles/lemmy/docker-compose.yml b/bundles/lemmy/docker-compose.yml index 45edfdc81..3c2d9a105 100644 --- a/bundles/lemmy/docker-compose.yml +++ b/bundles/lemmy/docker-compose.yml @@ -107,7 +107,7 @@ services: - | umask 077 case "$${CROW_LEMMY_HOSTNAME}" in - ""|*[!A-Za-z0-9.:-]*|.*|-*) echo "LEMMY_HOSTNAME must be a bare hostname such as lemmy.example.com (no https://, no path)" >&2; exit 64 ;; + ""|*[!a-z0-9.:-]*|[.:-]*|*[.:-]|*..*) echo "LEMMY_HOSTNAME must be a bare lowercase hostname such as lemmy.example.com (no https://, no path)" >&2; exit 64 ;; esac mkdir -p /tmp/crow-lemmy cat > /tmp/crow-lemmy/config.hjson < "$WORK/pictrs.tar" 2>/dev/null || true -fi +# A hot copy: pict-rs keeps running, so its sled db may need pict-rs's own repair on restore. +docker ps --format '{{.Names}}' | grep -qw crow-lemmy-pictrs || { echo "crow-lemmy-pictrs is not running; no backup written" >&2; exit 1; } +docker exec crow-lemmy-pictrs tar -C /mnt -cf - . > "$WORK/pictrs.tar" +[ -s "$WORK/pictrs.tar" ] || { echo "pict-rs archive is empty; no backup written" >&2; exit 1; } OUT="${BACKUP_ROOT}/lemmy-${STAMP}.tar.zst" if command -v zstd >/dev/null 2>&1; then diff --git a/tests/lemmy-bundle.test.js b/tests/lemmy-bundle.test.js index c756d0a82..4ed7712a2 100644 --- a/tests/lemmy-bundle.test.js +++ b/tests/lemmy-bundle.test.js @@ -10,6 +10,7 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -69,7 +70,18 @@ test("the hostname reaches the script through the environment and is checked bef assert.match(lemmy, /hostname:\s*"\$\$\{CROW_LEMMY_HOSTNAME\}"/); assert.match(lemmy, /case "\$\$\{CROW_LEMMY_HOSTNAME\}" in/); const body = lemmy.slice(lemmy.indexOf("- |")); - assert.doesNotMatch(body, /(? { + const m = lemmy.match(/case "\$\$\{CROW_LEMMY_HOSTNAME\}" in\n\s*(.*?)\) echo/); + assert.ok(m, "found the case pattern"); + const script = `case "$1" in ${m[1]}) exit 64 ;; esac; exit 0`; + const ok = (h) => spawnSync("sh", ["-c", script, "sh", h]).status === 0; + for (const h of ["lemmy.example.com", "a-b.example.org", "lemmy.localhost", "host.test:8536"]) assert.ok(ok(h), `accepts ${h}`); + for (const h of ["", "https://lemmy.example.com", "lemmy.example.com/x", "LEMMY.example.com", "host.", ".host", "-host", "a..b", "host:", "a\"b", "a b", "a$(id)", "a`id`", "a\nb"]) { + assert.equal(ok(h), false, `refuses ${JSON.stringify(h)}`); + } }); test("pict-rs limits use the 0.5 key names (0.4 names are silently ignored)", () => {