marreta: content-cleaning proxy on Dankclaw, gluetun netns, guard-chained

Deploy 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.

- marreta relocated to NGINX_HTTP_PORT=8085 (netns already has searxng on
  8080); gluetun publishes 8082:8085; Caddy dials 192.168.0.143:8082.
- Guard chain: marreta.franksdank.cloud -> Anubis :8923 -> Authentik ->
  post-auth :8017 @marreta -> 8082. Access = any authenticated user.
- Secrets seeded to /home/dank/Containers/marreta/env (0600) from
  MARRETA_APP_KEY / MARRETA_ADMIN_EMAIL / MARRETA_ADMIN_PASSWORD.
- deploy-plan.py: marreta joins the gluetun netns family.
- New seam tests/marreta_quadlet.bats (17 contracts); scoping test updated
  for the widened netns family. Doc: Dankclaw/Marreta.md + index + log.
This commit is contained in:
Frank
2026-09-12 10:09:12 -07:00
parent 9530bffad6
commit 73e46a1d85
13 changed files with 547 additions and 6 deletions
+13
View File
@@ -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
+15
View File
@@ -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 ---
@@ -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
@@ -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
+1
View File
@@ -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
@@ -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
+13 -4
View File
@@ -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")
+34 -1
View File
@@ -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 ;;
+205
View File
@@ -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:** <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.
- 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: <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]].
+1
View File
@@ -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
+25
View File
@@ -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;
+1 -1
View File
@@ -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" {
+138
View File
@@ -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"
}