Files
Frank e7f0d93c86 docs(vault): fleet-wide fact re-verification pass (67 pages)
The docs-fact census wave re-read the live hosts and re-keyed every page
it could: runtime/payload identities, ports, sizes, ledger figures, unit
state, and the retirement banners that drifted as the fleet moved. The
wave was interrupted mid-run, so this commit carries only what passed the
integrity gates: every touched page parses (front matter or H1+status
convention intact, balanced fences, no truncated tables, trailing
newline), the docs_completeness and link-integrity seams are green, and
bats is 384/384.

Numbers that this session proved on live hosts are keyed to the measured
value, not the page's earlier claim.
2026-10-18 13:15:43 -07:00

266 lines
14 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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 (`composer.json` requires
`php: ^8.4`, `laravel/framework: ^13.0`, `filament/filament: ^5.7` —
<https://raw.githubusercontent.com/manualdousuario/marreta/main/composer.json>),
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:** <https://github.com/manualdousuario/marreta>
## 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 — the three `app/Services/Fetch/*FetchStrategy.php`
files upstream (`BrowserFetchStrategy`, `CurlFetchStrategy`,
`WaybackFetchStrategy`, dispatched by `app/Services/FetchManager.php`).
- 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 — live 2026-10-18
`curl http://127.0.0.1:8082/admin/login` → **200**.
- Health endpoint at `/up` (Laravel's built-in health route —
`bootstrap/app.php` passes `health: '/up'`). Live: `curl
http://127.0.0.1:8082/up` → **200** (2026-10-18).
## 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` (`:35`) | `gluetun.container` (`:34`) | `8085` (nginx, `NGINX_HTTP_PORT=8085`, `:37`) |
| `marreta-browser.service` | `dankclaw/marreta/quadlet/marreta-browser.container` | `docker.io/lightpanda/browser:nightly` (`:26`) | `gluetun.container` (`:25`) | `9222` (CDP) |
Both units are boot-persistent (`WantedBy=default.target`) and self-healing
(`Restart=always`, `RestartSec=10` — `marreta.container:62-69`,
`marreta-browser.container:30-34`), per the repo's Boot Persistence rule. Live
2026-10-18 both report `systemctl --user is-enabled` → `generated` and
`is-active` → `active`, and `podman inspect` reports marreta **healthy**
(`HealthCmd` GETs `http://127.0.0.1:8085/up`, `marreta.container:53`) and
marreta_browser `running`. Both images are `:latest`, so a redeploy can move
them.
## 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 (`dankclaw/searxng/quadlet/gluetun.container:32`),
exactly as it publishes `8081:8080` for SearXNG. Caddy dials
`192.168.0.143:8082` (`dankclaw/Caddyfile:419-422`). Live env confirms the
relocation: `podman inspect marreta` carries `NGINX_HTTP_PORT=8085`
(2026-10-18).
## 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 — upstream's own compose uses
`ws://marreta_browser:9222`, which needs no service name here because both
containers share one namespace. Live env: `BROWSER_WS_ENDPOINT=ws://127.0.0.1:9222`
(`podman inspect marreta` 2026-10-18; `marreta.container:44`). Upstream agrees
on the mechanism: `BrowserFetchStrategy.php` only calls
`BrowserFactory::connectToBrowser($wsEndpoint)` and never passes a proxy, while
`CurlFetchStrategy.php:24-25` is the only place `marreta.proxy_url` is read.
## 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}`
(`dankclaw/Caddyfile:159-163`).
2. **Auth decision** — Anubis dials Caddy's `http://:8016`, which proxies
whole-request to `authentik-server:9000` (`dankclaw/Caddyfile:22-24,94`).
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` (`dankclaw/Caddyfile:419-422`).
**Access model: any authenticated user.** The post-auth handler carries no
group gate (unlike the homepage `/admin`, which requires the
`homepage-admins` group) — the Caddyfile says so in the block header
("Any authentik-authenticated user may use it (no group gate)",
`dankclaw/Caddyfile:156-157`). 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')`
— that is the `TRUSTED_PROXIES` fallback in upstream `bootstrap/app.php`, and
this container sets no `TRUSTED_PROXIES`, so the default is what runs
(`podman inspect marreta` 2026-10-18) — and forces `https` when `APP_URL`
starts with `https://` (`app/Providers/AppServiceProvider.php:23-24`,
`URL::forceScheme('https')`). With
`APP_URL=https://marreta.franksdank.cloud` (`dankclaw/marreta/quadlet/marreta.container:41`) 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`; live 2026-10-18 `-rw------- dank dank` 155 B). The seeder writes it
only when the file does not exist and `chmod 600`s it
(`seed_marreta_env`, `scripts/deploy-remote.sh:615-622`).
| Repo `.env` key | Mapped to (app) | Notes |
|-----------------|-----------------|-------|
| `MARRETA_APP_KEY` | `APP_KEY` | Laravel encryption key. **Must be the `base64:`-prefixed form**: `base64:$(openssl rand -base64 32)`. A bare base64 string (no prefix) is read as a raw key of the wrong length and every app route 500s with `Unsupported cipher or incorrect key length` at `Encrypter.php:61` (laravel/framework 13.x, `src/Illuminate/Encryption/Encrypter.php:61`) while `/up`, which skips the encrypter, still passes. Today's key is well-formed: `/`, `/admin/login` and `/up` all answer 200 (`curl http://127.0.0.1:8082/…` 2026-10-18). 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.
(`dankclaw/marreta/quadlet/marreta.container:47-49`), loaded from the env file
via `[Service] EnvironmentFile=` (`:60`). 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` (`marreta.container:52`; podman auto-creates it and
copyups the image's `www-data` ownership so the app can write). Live 2026-10-18
the volume exists at `/home/dank/.local/share/containers/storage/volumes/marreta_storage/_data`.
## Deploy
```
./scripts/deploy.sh dankclaw --service marreta
```
`deploy_marreta` (`scripts/deploy-remote.sh:630-640`) 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` (`scripts/deploy-plan.py:295-309` — 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. Re-checked 2026-10-18: **200** with `challenge`
×5 and `not a bot` ×2 in the body — the guard chain is live.
- **Post-auth routing:** `podman exec caddy sh -c "grep -n -A3 '@marreta host' /etc/caddy/Caddyfile"`
after the deploy's reload line (repo copy: `dankclaw/Caddyfile:419-422`).
- **Egress:** from inside the netns, `podman exec marreta curl -s https://ifconfig.co`
must return the VPN exit IP, not the home IP. The tunnel's own egress is
readable without exec: `journalctl --user -u gluetun` logged exit
`149.22.88.19` (United States, Washington, Seattle) on 2026-10-18.
- **Port:** `podman exec marreta sh -c 'curl -fsS localhost:8085/up'` → 200;
`curl http://192.168.0.143:8082/up` → 200 (also **200** from the host
loopback, `curl http://127.0.0.1:8082/up` 2026-10-18; the container health
probe GETs `http://127.0.0.1:8085/up` and reports **healthy**).
- **Test seam:** `bats tests/marreta_quadlet.bats` (17 contracts — 17 `@test`
blocks, counted 2026-10-18).
### Live verification (deployed 2026-09-12, rev `91e9410`)
Deployed via `deploy.sh dankclaw --service gluetun --service marreta --service
caddy` (gluetun first to recreate the tunnel with the new `8082:8085` publish,
which also cycles SearXNG's netns ref). Authentik `ProxyProvider` `marreta`
registered (pk 13, `external_host=https://marreta.franksdank.cloud/`,
`internal_host=http://caddy:8017`). Results:
- Unauth edge → Anubis challenge page (not 502). TLS cert issued via DNS-01.
- Post-auth sim (`caddy :8017` with `Host: marreta.franksdank.cloud`) → **200**.
- `marreta` container health: **healthy**; `/` and `/admin/login` → 200.
- Netns sharing proven: `gluetun`, `marreta`, `marreta_browser` all on the same
`net:[…]` inode; marreta reads the CDP endpoint
(`GET 127.0.0.1:9222/json/version` → `Lightpanda/1.0`).
- SearXNG unaffected: `:8081` → 200 after the tunnel recreate.
> **Gotcha hit during deploy:** the first seed used a bare-base64 `APP_KEY`,
> which 500s every app route (see Secrets above). Fixed by re-keying with the
> `base64:` prefix before any encrypted data was persisted.
## Citations
- Marreta upstream: <https://github.com/manualdousuario/marreta> (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: <https://hub.docker.com/r/lightpanda/browser>
(`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]].