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:
@@ -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