diff --git a/dankclaw/.env.example b/dankclaw/.env.example index 566e49c..d928ccd 100644 --- a/dankclaw/.env.example +++ b/dankclaw/.env.example @@ -61,3 +61,16 @@ AUTHENTIK_PG_PASS=changeme # AUTHENTIK_SECRET_KEY: the server's secret key (64+ chars). # Generate: openssl rand -base64 48 AUTHENTIK_SECRET_KEY=changeme + +# Marreta (content-cleaning proxy, behind the Authentik guard chain). +# deploy-remote.sh seeds /home/dank/Containers/marreta/env from these three +# values (only if that file does not already exist) and the marreta quadlet +# maps them to the app's APP_KEY / ADMIN_EMAIL / ADMIN_PASSWORD. +# MARRETA_APP_KEY: Laravel encryption key. Generate: +# openssl rand -base64 32 +# Changing it after first boot invalidates encrypted session/cookie data. +MARRETA_APP_KEY=changeme +# Admin panel login (marreta.franksdank.cloud/admin). The app creates this +# account on first boot from these values. +MARRETA_ADMIN_EMAIL=admin@franksdank.cloud +MARRETA_ADMIN_PASSWORD=changeme diff --git a/dankclaw/Caddyfile b/dankclaw/Caddyfile index 7e9e4a6..6673111 100644 --- a/dankclaw/Caddyfile +++ b/dankclaw/Caddyfile @@ -152,6 +152,16 @@ seerr.franksdank.cloud { } } +# --- Marreta (guarded: Anubis → Authentik; marreta provider, internal_host caddy:8017) --- +# Content-cleaning proxy. Any authentik-authenticated user may use it (no +# group gate). Egress rides the searxng gluetun tunnel; reached via the +# host-published port 8082 (gluetun netns), not by container name. +marreta.franksdank.cloud { + reverse_proxy anubis:8923 { + header_up X-Real-Ip {remote_host} + } +} + # --- Pi-hole (quadlet on slirp4netns; published 8083) --- pihole.franksdank.cloud { basicauth { @@ -379,6 +389,11 @@ http://:8017 { handle @qbittorrent { reverse_proxy 192.168.0.112:8080 } + + @marreta host marreta.franksdank.cloud + handle @marreta { + reverse_proxy 192.168.0.143:8082 + } } # --- Optional: Jackett --- diff --git a/dankclaw/marreta/quadlet/marreta-browser.container b/dankclaw/marreta/quadlet/marreta-browser.container new file mode 100644 index 0000000..9b6bd6e --- /dev/null +++ b/dankclaw/marreta/quadlet/marreta-browser.container @@ -0,0 +1,34 @@ +# Quadlet unit: Marreta browser companion — Lightpanda (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/marreta-browser.container +# Unit name: marreta-browser.service (container name: marreta_browser) +# +# Lightpanda is a headless browser that exposes the Chrome DevTools Protocol +# over a WebSocket on port 9222. Marreta drives it to render JS-heavy pages +# the plain curl fetch cannot. It shares gluetun's network namespace so the +# browser's OWN outbound fetches also exit the WireGuard tunnel — this is +# required because marreta's PROXY_URL only covers the curl strategy; the +# CDP session passes no proxy to the browser, so without netns sharing the +# browser would leak the host's real egress IP. +# +# Marreta reaches it on loopback (same netns): ws://127.0.0.1:9222. No +# published port — nothing outside the netns talks to it directly. +# See serverAppConfigs-docs/Dankclaw/Marreta.md. + +[Unit] +Description=Marreta Lightpanda browser companion container +Wants=gluetun.service +After=gluetun.service network.target + +[Container] +ContainerName=marreta_browser +Network=gluetun.container +Image=docker.io/lightpanda/browser:nightly + +[Service] +TimeoutStartSec=300 +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/dankclaw/marreta/quadlet/marreta.container b/dankclaw/marreta/quadlet/marreta.container new file mode 100644 index 0000000..b8780eb --- /dev/null +++ b/dankclaw/marreta/quadlet/marreta.container @@ -0,0 +1,66 @@ +# Quadlet unit: Marreta content-cleaning proxy (rootless, boot-persistent). +# +# Install path on dankclaw: ~/.config/containers/systemd/marreta.container +# Unit name: marreta.service (container name: marreta) +# +# Marreta is a Laravel app (serversideup/php:8.4-fpm-nginx) that strips +# paywalls/tracking from a fetched page. It reuses gluetun's network +# namespace (Network=gluetun.container) so ALL of its outbound fetches exit +# the same WireGuard tunnel as SearXNG — the curl strategy AND, because the +# browser companion shares the same netns, the Lightpanda fetches too +# (marreta's PROXY_URL does NOT cover the browser: the CDP connection passes +# no proxy, so netns sharing is the only way to keep the browser's egress on +# the tunnel). +# +# Port: the shared netns already has SearXNG on 8080, so marreta must NOT +# bind 8080. NGINX_HTTP_PORT=8085 relocates it (the fpm-nginx image does +# not bake conf.d/*.conf; the site config is envsubst'd from +# ${NGINX_HTTP_PORT} at container start). Gluetun publishes host 8082 -> +# 8085 so Caddy can reach it; see dankclaw/searxng/quadlet/gluetun.container. +# The app reaches its browser companion over loopback (same netns): +# ws://127.0.0.1:9222. +# +# Secrets (APP_KEY, admin creds) live in /home/dank/Containers/marreta/env +# (mode 0600, dank-owned so the rootless unit can read it); this file holds +# none. See serverAppConfigs-docs/Dankclaw/Marreta.md. + +[Unit] +Description=Marreta content-cleaning proxy container +Wants=gluetun.service marreta-browser.service +After=gluetun.service marreta-browser.service network.target + +[Container] +ContainerName=marreta +Network=gluetun.container +Image=ghcr.io/manualdousuario/marreta:latest +# Relocate off SearXNG's 8080 inside the shared netns. +Environment=NGINX_HTTP_PORT=8085 +Environment=APP_NAME=Marreta +Environment=APP_ENV=production +Environment=APP_DEBUG=false +Environment=APP_URL=https://marreta.franksdank.cloud +Environment=APP_LOCALE=en +Environment="APP_DESCRIPTION=Paywall-free reading" +Environment=BROWSER_WS_ENDPOINT=ws://127.0.0.1:9222 +Environment=DISABLE_CACHE=false +# Secrets expanded from the EnvironmentFile below. +Environment=APP_KEY=${MARRETA_APP_KEY} +Environment=ADMIN_EMAIL=${MARRETA_ADMIN_EMAIL} +Environment=ADMIN_PASSWORD=${MARRETA_ADMIN_PASSWORD} +# Named volume: podman auto-creates it and copyups the image's storage/app +# ownership (www-data), so the app can write its SQLite DB + cache. +Volume=marreta_storage:/var/www/html/storage/app +HealthCmd=["curl","-fsS","-o","/dev/null","http://127.0.0.1:8085/up"] +HealthInterval=30s +HealthTimeout=10s +HealthRetries=5 +HealthStartPeriod=120s + +[Service] +EnvironmentFile=/home/dank/Containers/marreta/env +TimeoutStartSec=900 +Restart=always +RestartSec=10 + +[Install] +WantedBy=default.target diff --git a/dankclaw/pihole/addn-hosts b/dankclaw/pihole/addn-hosts index d2902c7..99ae03f 100644 --- a/dankclaw/pihole/addn-hosts +++ b/dankclaw/pihole/addn-hosts @@ -44,3 +44,4 @@ 192.168.0.143 loki.franksdank.cloud 192.168.0.143 pihole.franksdank.cloud 192.168.0.143 seerr.franksdank.cloud +192.168.0.143 marreta.franksdank.cloud diff --git a/dankclaw/searxng/quadlet/gluetun.container b/dankclaw/searxng/quadlet/gluetun.container index a53a660..8fd81d7 100644 --- a/dankclaw/searxng/quadlet/gluetun.container +++ b/dankclaw/searxng/quadlet/gluetun.container @@ -29,6 +29,7 @@ Environment=WIREGUARD_ENDPOINT_PORT=${WIREGUARD_ENDPOINT_PORT} Environment=WIREGUARD_ALLOWED_IPS=${WIREGUARD_ALLOWED_IPS} Environment=WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL=${WIREGUARD_PERSISTENT_KEEPALIVE_INTERVAL} PublishPort=8081:8080 +PublishPort=8082:8085 [Service] EnvironmentFile=/opt/searxng/.env diff --git a/scripts/deploy-plan.py b/scripts/deploy-plan.py index 0febef6..237ce1c 100755 --- a/scripts/deploy-plan.py +++ b/scripts/deploy-plan.py @@ -23,6 +23,7 @@ SERVICE_ORDER = ( "gluetun", "searxng-valkey", "searxng", + "marreta", "pihole", "open-webui", "gitea-postgres", @@ -67,6 +68,7 @@ DANKCLAW_SERVICES = { "gluetun", "searxng-valkey", "searxng", + "marreta", "pihole", "open-webui", "gitea-postgres", @@ -279,15 +281,22 @@ def services_for_path(path: str, target: str) -> set[str]: if len(parts) >= 4 and parts[2] == "quadlet": name = parts[3] if name == "gluetun.container": - # SearXNG shares Gluetun's network namespace. A Gluetun - # recreation requires both units to be restarted. - _add(services, "gluetun", "searxng") + # SearXNG and Marreta (+ its browser companion) share + # Gluetun's network namespace. A Gluetun recreation + # tears the netns down, so every sharer must restart. + _add(services, "gluetun", "searxng", "marreta") elif name == "searxng.container": _add(services, "searxng") elif name == "searxng-valkey.container": _add(services, "searxng-valkey", "searxng") elif name == "searxng.network": - _add(services, "gluetun", "searxng-valkey", "searxng") + _add(services, "gluetun", "searxng-valkey", "searxng", "marreta") + return services + if len(parts) >= 2 and parts[1] == "marreta": + # One deployable owns both the app and its browser companion; + # both live in gluetun's netns. A change to either unit selects + # the marreta service, which restarts browser-then-app. + _add(services, "marreta") return services if len(parts) >= 2 and parts[1] == "pihole": _add(services, "pihole") diff --git a/scripts/deploy-remote.sh b/scripts/deploy-remote.sh index 4fff48c..aad2100 100755 --- a/scripts/deploy-remote.sh +++ b/scripts/deploy-remote.sh @@ -89,7 +89,7 @@ service_is_allowed() { dankclaw:loki|dankclaw:alloy|dankclaw:node-exporter|\ dankclaw:authentik-postgres|dankclaw:authentik-server|\ dankclaw:authentik-worker|dankclaw:authentik-outpost|dankclaw:authentik-outpost-ldap|dankclaw:anubis|\ - dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|\ + dankclaw:caddy|dankclaw:homepage|dankclaw:seerr|dankclaw:marreta|\ dankclaw:frank-large|\ dankserver:romm-mariadb|dankserver:romm-redis|dankserver:romm|\ dankserver:copyparty|dankserver:jellyfin|dankserver:calibre-web|dankserver:plex|dankserver:tautulli|\ @@ -508,6 +508,38 @@ deploy_searxng_valkey() { systemctl --user restart searxng-valkey.service searxng.service } +# Marreta's secrets are seeded into a per-service env file under the deploy +# user's home (rootless-readable), following the grafana convention rather +# than the /opt/searxng one, so the rootless unit can read it without any +# ownership gymnastics. Only the three MARRETA_* values are copied; the +# quadlet maps them to the app's APP_KEY / ADMIN_EMAIL / ADMIN_PASSWORD. +seed_marreta_env() { + local dest=/home/dank/Containers/marreta/env + mkdir -p /home/dank/Containers/marreta + if [[ ! -f "$dest" ]]; then + awk -F= '/^MARRETA_APP_KEY=/ || /^MARRETA_ADMIN_EMAIL=/ || /^MARRETA_ADMIN_PASSWORD=/ { print }' \ + dankclaw/.env > "$dest" + chmod 600 "$dest" + fi + local var + for var in MARRETA_APP_KEY MARRETA_ADMIN_EMAIL MARRETA_ADMIN_PASSWORD; do + grep -q "^${var}=." "$dest" \ + || { echo "marreta env missing ${var}; set it in dankclaw/.env" >&2; return 1; } + done +} + +deploy_marreta() { + seed_marreta_env + install_quadlet marreta dankclaw/marreta/quadlet marreta + install_quadlet marreta-browser dankclaw/marreta/quadlet marreta-browser + systemctl --user daemon-reload + # Both containers share gluetun's netns; the tunnel must be up first. + systemctl --user start gluetun.service + # Browser before app: marreta drives the CDP endpoint on loopback. + systemctl --user restart marreta-browser.service + systemctl --user restart marreta.service +} + deploy_open_webui() { install_quadlet open-webui dankclaw/open-webui/quadlet open-webui mkdir -p /home/dank/Containers/open-webui/data @@ -1153,6 +1185,7 @@ deploy_service() { ;; anubis) deploy_anubis ;; seerr) deploy_seerr ;; + marreta) deploy_marreta ;; frank-large) deploy_llm_large ;; frank-small) deploy_llm_small ;; hermes|hermes-dashboard|hermes-webui) deploy_hermes ;; diff --git a/serverAppConfigs-docs/Dankclaw/Marreta.md b/serverAppConfigs-docs/Dankclaw/Marreta.md new file mode 100644 index 0000000..0094d75 --- /dev/null +++ b/serverAppConfigs-docs/Dankclaw/Marreta.md @@ -0,0 +1,205 @@ +--- +type: reference +title: Marreta on Dankclaw +description: The content-cleaning proxy (manualdousuario/marreta, Laravel/serversideup fpm-nginx) deployed as a rootless Quadlet pair that shares SearXNG's Gluetun netns for egress and sits behind the Anubis + Authentik guard chain at marreta.franksdank.cloud. +tags: + - dankclaw + - marreta + - proxy + - gluetun + - authentik + - quadlet +timestamp: 2026-09-12 +--- + +# Marreta + +Marreta is a self-hosted **content-cleaning proxy**: you hand it a URL and it +returns a stripped, readable version of the page — no paywall chrome, no +tracking, no cookie walls. It is a Laravel 13 app on the +`serversideup/php:8.4-fpm-nginx` image, with an optional headless-browser +companion for JavaScript-heavy pages. + +**Host:** Dankclaw — **vhost:** `marreta.franksdank.cloud` (Caddy, guard-chained) +— **Upstream:** host-published port `8082` (inside the Gluetun netns) +— **Source:** + +## What it does (from the upstream README) + +- Fetches a target URL with a cascade of strategies: plain HTTP (curl), a + headless browser (Lightpanda) for JS pages, and the Internet Archive + (Wayback) as a last resort. +- Strips paywall/consent/tracking markup and re-renders clean HTML. +- Ships a Filament admin panel at `/admin` (login = `ADMIN_EMAIL` / + `ADMIN_PASSWORD`) for settings and usage. +- Health endpoint at `/up` (Laravel's built-in health route). + +## Topology + +Marreta runs as **two rootless Quadlet containers** that both join +**Gluetun's network namespace** — the same tunnel [[SearXNG]] uses — so every +outbound fetch exits over WireGuard, never the home IP. + +```mermaid +graph LR + C[Client] -->|HTTPS| TLS[Caddy TLS edge] + TLS -->|challenge| AN[Anubis :8923] + AN -->|auth decision| A16[Caddy :8016] + A16 -->|Authentik| AUTH[authentik-server] + AUTH -->|authenticated| A17[Caddy :8017] + A17 -->|host marreta| PUB[host :8082] + subgraph G[gluetun netns - WireGuard egress] + PUB -->|8085| M[marreta :8085] + M -->|ws loopback| B[marreta_browser :9222] + end + M -->|fetch| WG[WireGuard tunnel] + B -->|fetch| WG +``` + +| Unit | File | Image | Netns | Listens | +|------|------|-------|-------|---------| +| `marreta.service` | `dankclaw/marreta/quadlet/marreta.container` | `ghcr.io/manualdousuario/marreta:latest` | `gluetun.container` | `8085` (nginx) | +| `marreta-browser.service` | `dankclaw/marreta/quadlet/marreta-browser.container` | `docker.io/lightpanda/browser:nightly` | `gluetun.container` | `9222` (CDP) | + +Both units are boot-persistent (`WantedBy=default.target`) and self-healing +(`Restart=always`), per the repo's Boot Persistence rule. + +## The port-collision resolution (why 8085) + +The shared netns **already has SearXNG on 8080**. Marreta's image +(`serversideup/php:8.4-fpm-nginx`) defaults to `NGINX_HTTP_PORT=8080`, so a +naive deploy would fail the moment nginx tried to bind a port SearXNG holds. + +The fix is a single env var, verified against the serversideup source: + +- The image sets `ENV NGINX_HTTP_PORT="8080"` and the site template is + `listen ${NGINX_HTTP_PORT} default_server;`. +- The image does **not** bake `conf.d/*.conf` (it `rm -rf`s them at build); + the entrypoint `10-init-webserver-config.sh` runs `envsubst` over every + `NGINX_*` variable **at container start**. +- So `Environment=NGINX_HTTP_PORT=8085` in the quadlet relocates the listen + port off the collision with no custom build. + +Marreta then publishes **nothing** of its own — the netns owner (Gluetun) +publishes `8082:8085` on its behalf, exactly as it publishes `8081:8080` +for SearXNG. Caddy dials `192.168.0.143:8082`. + +## Why netns sharing, not `PROXY_URL` + +Marreta supports a `PROXY_URL` env var, but it only covers the **curl** +fetch strategy. The `BrowserFetchStrategy` connects to Lightpanda over CDP +and passes **no proxy** — the browser makes its own connections. If the +browser ran outside the tunnel it would leak the host's real egress IP on +every JS-rendered page. Netns sharing is the only way to put **both** the +app and the browser on the same exit, which is what "share the same Gluetun +egress as SearXNG" requires. The browser is therefore reached on loopback +(`ws://127.0.0.1:9222`) inside the shared netns. + +## Guard chain (behind Authentik) + +`marreta.franksdank.cloud` follows the standard chain documented in +[[Anubis]] and [[Authentik]]: + +1. **Public edge** — the vhost proxies everything to `anubis:8923` + (bot challenge), with `header_up X-Real-Ip {remote_host}`. +2. **Auth decision** — Anubis dials Caddy's `http://:8016`, which proxies + whole-request to `authentik-server:9000`. +3. **Post-auth routing** — authenticated traffic returns to `http://:8017`, + where `@marreta host marreta.franksdank.cloud` routes to + `reverse_proxy 192.168.0.143:8082`. + +**Access model: any authenticated user.** The post-auth handler carries no +group gate (unlike the homepage `/admin`, which requires the +`homepage-admins` group). Anyone who can log in via Authentik can use +Marreta. + +**Required provider (manual, one-time).** Before the vhost goes live, a +proxy provider must exist on the embedded outpost or authenticated traffic +hits the outpost error page instead of the app: + +```python +# podman exec -i authentik-server ak shell < provider.py +from authentik.core.models import Group +from authentik.providers.proxy.models import ProxyProvider + +p = ProxyProvider.objects.filter(name="marreta").first() or ProxyProvider() +p.name = "marreta" +p.external_host = "https://marreta.franksdank.cloud/" # exact, trailing slash +p.internal_host = "http://caddy:8017" +p.save() +``` + +This is the "provider first, then route" ordering from the guard-chain +runbook — the Anubis guard set, the Caddy TLS edge, and the provider list +must change together. + +## Scheme / proxy trust + +Marreta ships `trustProxies(at: '172.16.0.0/12,10.0.0.0/8,192.168.0.0/16,127.0.0.1')` +and forces `https` when `APP_URL` starts with `https://`. With +`APP_URL=https://marreta.franksdank.cloud` the app honors the forwarded +scheme and client IP through the whole chain, so there is no redirect loop +or mixed-content breakage. + +## Secrets + +Three values, seeded into `/home/dank/Containers/marreta/env` (mode 0600, +dank-owned so the rootless unit can read it — the grafana convention, not +`/opt`): + +| Repo `.env` key | Mapped to (app) | Notes | +|-----------------|-----------------|-------| +| `MARRETA_APP_KEY` | `APP_KEY` | Laravel encryption key (`openssl rand -base64 32`). Changing it after first boot invalidates encrypted session/cookie data. | +| `MARRETA_ADMIN_EMAIL` | `ADMIN_EMAIL` | Admin panel login (created on first boot). | +| `MARRETA_ADMIN_PASSWORD` | `ADMIN_PASSWORD` | Admin panel password. | + +The quadlet maps them with `Environment=APP_KEY=${MARRETA_APP_KEY}` etc. +The `.container` files hold **no** secrets — only `${VAR}` placeholders. +The SQLite DB + cache live in the `marreta_storage` named volume at +`/var/www/html/storage/app` (podman auto-creates it and copyups the image's +`www-data` ownership so the app can write). + +## Deploy + +``` +./scripts/deploy.sh dankclaw --service marreta +``` + +`deploy_marreta` seeds the env file (only if absent), installs both +`.container` units, ensures Gluetun is up, then restarts the browser before +the app. Because Marreta shares Gluetun's netns, the planner treats it as +part of the Gluetun netns family: a change to `gluetun.container` or +`searxng.network` also selects `marreta` (a Gluetun recreation tears the +netns down and would otherwise orphan it). + +> **First deploy note:** adding `PublishPort=8082:8085` to `gluetun.container` +> means the first deploy recreates Gluetun, which briefly cycles SearXNG and +> Marreta together. Plan for the short tunnel restart. + +## Verification + +- **Unauthenticated:** `curl -s https://marreta.franksdank.cloud/` → the + Anubis challenge page (grep body for `challenge`/`not a bot`). A 502 here + means the chain is mis-wired. +- **Post-auth routing:** `podman exec caddy sh -c "grep -n -A3 '@marreta host' /etc/caddy/Caddyfile"` + after the deploy's reload line. +- **Egress:** from inside the netns, `podman exec marreta curl -s https://ifconfig.co` + must return the VPN exit IP, not the home IP. +- **Port:** `podman exec marreta sh -c 'curl -fsS localhost:8085/up'` → 200; + `curl http://192.168.0.143:8082/up` → 200. +- **Test seam:** `bats tests/marreta_quadlet.bats` (17 contracts). + +## Citations + +- Marreta upstream: (README, + `compose.yml`, `bootstrap/app.php`, `app/Services/Fetch/BrowserFetchStrategy.php`, + `lang/` directory listing). +- serversideup/php fpm-nginx port mechanism: `serversideup/docker-php` + `src/variations/fpm-nginx/Dockerfile` (`NGINX_HTTP_PORT="8080"`) and + `src/s6/etc/entrypoint.d/10-init-webserver-config.sh` (`envsubst` over + `NGINX_*` at start; `conf.d/*.conf` removed at build). +- Lightpanda browser image: + (`nightly` tag). +- Guard-chain ordering and provider-first rule: [[Anubis]], [[Authentik]], + and the `anubis-guard-chain-flip` runbook. +- Netns egress pattern: [[SearXNG]], [[Gluetun]], [[Container Network Namespaces]]. diff --git a/serverAppConfigs-docs/index.md b/serverAppConfigs-docs/index.md index 28f287b..037119f 100644 --- a/serverAppConfigs-docs/index.md +++ b/serverAppConfigs-docs/index.md @@ -71,6 +71,7 @@ covers both workloads). - [[Gitea]] — Git hosting (migrated) - [[Postgres]] — Gitea database backend - [[Seerr]] — unified *arr request manager (guard-chained; fronts FrankBook-Pro's stack) +- [[Marreta]] — content-cleaning proxy (guard-chained; shares SearXNG's Gluetun egress) ## Infrastructure diff --git a/serverAppConfigs-docs/log.md b/serverAppConfigs-docs/log.md index 2d0d1bf..4464dbc 100644 --- a/serverAppConfigs-docs/log.md +++ b/serverAppConfigs-docs/log.md @@ -11,6 +11,31 @@ timestamp: 2026-09-14 # Bundle Change Log +## 2026-09-12 — Marreta content-cleaning proxy deployed on Dankclaw +- Added `manualdousuario/marreta` (Laravel / `serversideup/php:8.4-fpm-nginx`) + as a rootless Quadlet pair: `marreta` + `marreta-browser` (Lightpanda), + both joining **gluetun's netns** so all fetch egress exits the same + ProtonVPN tunnel as [[SearXNG]]. +- **Port-collision fix:** the shared netns already has SearXNG on 8080, so + marreta is relocated to `NGINX_HTTP_PORT=8085` (verified against the + serversideup source: the image does not bake `conf.d/*.conf`; the + entrypoint `envsubst`s `${NGINX_HTTP_PORT}` at start). Gluetun publishes + `8082:8085` on marreta's behalf; Caddy dials `192.168.0.143:8082`. +- **Egress rationale:** netns sharing, not `PROXY_URL` — the browser's CDP + fetch passes no proxy, so only netns keeps the browser off the home IP. +- **Guard chain:** `marreta.franksdank.cloud` → Anubis `:8923` → Authentik + → post-auth `:8017` `@marreta` → `192.168.0.143:8082`. Access model is + **any authenticated user** (no group gate). Requires a one-time + `ProxyProvider` (external_host `https://marreta.franksdank.cloud/`, + internal_host `http://caddy:8017`) on the embedded outpost. +- Secrets seeded to `/home/dank/Containers/marreta/env` (0600, dank-owned) + from `MARRETA_APP_KEY` / `MARRETA_ADMIN_EMAIL` / `MARRETA_ADMIN_PASSWORD`. +- New test seam `tests/marreta_quadlet.bats` (17 contracts). deploy-plan.py: + marreta joins the gluetun netns family. Doc: [[Marreta]]. +- **Not yet run live:** this box has no deploy key for Dankclaw. The deploy + and the Authentik provider registration must be run from a host with + access; see the doc's Deploy + Guard chain sections. + ## 2026-09-04 — *arr fleet consolidated on FrankBook-Pro; all egress VPN-gated - Operator decision: **FrankBook-Pro is the sole *arr deployment.** The Dankserver compose copy (booted earlier the same day) was removed; diff --git a/tests/deployment_scoping.bats b/tests/deployment_scoping.bats index c921e8a..ee5283e 100644 --- a/tests/deployment_scoping.bats +++ b/tests/deployment_scoping.bats @@ -98,7 +98,7 @@ REMOTE_DEPLOY="$REPO_ROOT/scripts/deploy-remote.sh" run python3 "$PLANNER" --target dankclaw --paths \ dankclaw/searxng/quadlet/gluetun.container [ "$status" -eq 0 ] - [ "$output" = $'gluetun\nsearxng' ] + [ "$output" = $'gluetun\nsearxng\nmarreta' ] } @test "retiring the standalone Gitea proxy deploys the canonical Caddy" { diff --git a/tests/marreta_quadlet.bats b/tests/marreta_quadlet.bats new file mode 100755 index 0000000..64c35f2 --- /dev/null +++ b/tests/marreta_quadlet.bats @@ -0,0 +1,138 @@ +#!/usr/bin/env bats +# Seam: Marreta content-cleaning proxy boot-persistence + egress + guard chain. +# +# Marreta (Laravel / serversideup fpm-nginx) and its Lightpanda browser +# companion run as rootless Quadlet units that share gluetun's network +# namespace, so every outbound fetch — the app's curl strategy AND the +# browser's own requests — exits the same WireGuard tunnel as SearXNG. +# The load-bearing invariants this seam defends: +# * netns sharing (the egress contract) +# * marreta listens on 8085, NOT 8080 (SearXNG already owns 8080 in the +# shared netns; a collision would fail the container at bind time) +# * gluetun publishes host 8082 -> 8085 (the ingress contract Caddy dials) +# * the browser is reached on loopback (same netns), never published +# * the guard chain: public vhost -> Anubis, post-auth listener -> 8082 +# * boot persistence + self-heal + no leaked secrets + +load helpers + +QDIR="$REPO_ROOT/dankclaw/marreta/quadlet" +MARRETA="$QDIR/marreta.container" +BROWSER="$QDIR/marreta-browser.container" +GLUETUN="$REPO_ROOT/dankclaw/searxng/quadlet/gluetun.container" + +@test "both marreta quadlet files exist" { + [ -f "$MARRETA" ] + [ -f "$BROWSER" ] +} + +@test "marreta shares gluetun's network namespace (egress exits the tunnel)" { + grep -qE '^Network=gluetun\.container' "$MARRETA" \ + || { echo "marreta.container must set Network=gluetun.container"; return 1; } +} + +@test "the browser companion shares gluetun's network namespace too" { + grep -qE '^Network=gluetun\.container' "$BROWSER" \ + || { echo "marreta-browser.container must set Network=gluetun.container"; return 1; } +} + +@test "marreta relocates off SearXNG's 8080 to NGINX_HTTP_PORT=8085" { + # The shared netns already has SearXNG on 8080. serversideup fpm-nginx + # envsubst's the site config from ${NGINX_HTTP_PORT} at container start, + # so this is what actually moves the listen port off the collision. + grep -qE '^Environment=NGINX_HTTP_PORT=8085$' "$MARRETA" \ + || { echo "marreta.container must set Environment=NGINX_HTTP_PORT=8085"; return 1; } +} + +@test "marreta publishes no port of its own (the port lives on gluetun)" { + if grep -qE '^PublishPort=' "$MARRETA"; then + echo "marreta.container must not PublishPort; the netns owner (gluetun) publishes it" + return 1 + fi + return 0 +} + +@test "gluetun publishes marreta on 8082:8085" { + grep -qE '^PublishPort=8082:8085' "$GLUETUN" \ + || { echo "gluetun.container must publish 8082:8085 for marreta"; return 1; } +} + +@test "marreta reaches the browser on loopback (same netns), not by service name" { + grep -qE '^Environment=BROWSER_WS_ENDPOINT=ws://127\.0\.0\.1:9222$' "$MARRETA" \ + || { echo "marreta.container must point BROWSER_WS_ENDPOINT at ws://127.0.0.1:9222"; return 1; } +} + +@test "the browser companion publishes no port (loopback-only CDP)" { + if grep -qE '^PublishPort=' "$BROWSER"; then + echo "marreta-browser.container must not PublishPort; CDP is loopback-only" + return 1 + fi + return 0 +} + +@test "marreta feeds \${VAR} expansion via [Service] EnvironmentFile" { + # An EnvironmentFile in [Container] lands in the ignored [X-Container] + # section and does NOT feed ${VAR} expansion. It must be in [Service]. + awk ' + /^\[Service\]/ { svc=1 } + /^\[Container\]/ { svc=0 } + /^EnvironmentFile=/ { if (svc) found=1 } + END { exit found ? 0 : 1 } + ' "$MARRETA" || { echo "marreta.container needs EnvironmentFile in [Service]"; return 1; } +} + +@test "marreta maps the seeded secrets to the app's own env names" { + grep -qE '^Environment=APP_KEY=\$\{MARRETA_APP_KEY\}$' "$MARRETA" + grep -qE '^Environment=ADMIN_EMAIL=\$\{MARRETA_ADMIN_EMAIL\}$' "$MARRETA" + grep -qE '^Environment=ADMIN_PASSWORD=\$\{MARRETA_ADMIN_PASSWORD\}$' "$MARRETA" +} + +@test "both units are installed for boot (WantedBy=default.target)" { + grep -q 'WantedBy=default.target' "$MARRETA" + grep -q 'WantedBy=default.target' "$BROWSER" +} + +@test "both units self-heal (Restart=always)" { + grep -q '^Restart=always' "$MARRETA" + grep -q '^Restart=always' "$BROWSER" +} + +@test "marreta orders after gluetun and the browser it depends on" { + grep -qE '^After=.*gluetun\.service' "$MARRETA" + grep -qE '^After=.*marreta-browser\.service' "$MARRETA" +} + +@test "no secret material in the marreta quadlet files" { + # The units reference \${VAR} placeholders, never literal values. + has_no_leaked_secret "$MARRETA" + has_no_leaked_secret "$BROWSER" +} + +@test "the Caddyfile guards the marreta vhost through Anubis" { + # Public edge: the vhost must proxy to the challenge proxy, not straight to the app. + awk ' + /^marreta\.franksdank\.cloud \{/ { inblk=1 } + inblk && /reverse_proxy anubis:8923/ { challenge=1 } + inblk && /^\}/ { inblk=0 } + END { exit challenge ? 0 : 1 } + ' "$REPO_ROOT/dankclaw/Caddyfile" \ + || { echo "marreta vhost must reverse_proxy anubis:8923"; return 1; } +} + +@test "the Caddyfile post-auth listener routes marreta to the published 8082" { + # The end of the guard chain: authenticated traffic returns to :8017 and + # the Host-matched handler must dial the gluetun-published marreta port. + awk ' + /^http:\/\/:8017 \{/ { inlis=1 } + inlis && /@marreta host marreta\.franksdank\.cloud/ { matched=1 } + inlis && matched && /reverse_proxy 192\.168\.0\.143:8082/ { routed=1 } + END { exit routed ? 0 : 1 } + ' "$REPO_ROOT/dankclaw/Caddyfile" \ + || { echo "post-auth listener must route @marreta to 192.168.0.143:8082"; return 1; } +} + +@test "the deployer has a marreta handler that seeds env and installs both units" { + grep -q 'deploy_marreta()' "$REPO_ROOT/scripts/deploy-remote.sh" + grep -q 'seed_marreta_env()' "$REPO_ROOT/scripts/deploy-remote.sh" + grep -q 'marreta) deploy_marreta ;;' "$REPO_ROOT/scripts/deploy-remote.sh" +}