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