Files
serverAppConfigs/tests/marreta_quadlet.bats
T
Frank 73e46a1d85 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.
2026-09-12 10:09:12 -07:00

139 lines
5.8 KiB
Bash
Executable File

#!/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"
}