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:
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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 ;;
|
||||
|
||||
@@ -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]].
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" {
|
||||
|
||||
Executable
+138
@@ -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"
|
||||
}
|
||||
Reference in New Issue
Block a user