- service-map render node-exporter-unit --host H: the MIRROR pair comment
('keep this file in step') becomes a template. Per-host title, LAN
address from the hosts map, port from the node-<host> scrape job. Both
tracked quadlets are now rendered artifacts; check_drift gates them.
- dankclaw/Caddyfile: the nine verbatim Anubis pre-auth chains
(reverse_proxy anubis:8923 + X-Real-Ip) collapse into the (guarded)
named snippet; guarded vhosts are 'import guarded'. Validated with real
caddy 2.11.7 (import resolution clean; the stock image stops at the
porkbun module and the git basicauth placeholder, as designed).
- vhost-scoped guard greps (authentik, homepage_caddy, marreta_quadlet)
now pin 'import guarded' in the vhost block.
- Service Inventory + Authentik docs carry the new laws.
139 lines
5.8 KiB
Bash
Executable File
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 && /import guarded/ { challenge=1 }
|
|
inblk && /^\}/ { inblk=0 }
|
|
END { exit challenge ? 0 : 1 }
|
|
' "$REPO_ROOT/dankclaw/Caddyfile" \
|
|
|| { echo "marreta vhost must import the guarded chain (the one named Anubis snippet)"; 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"
|
|
}
|