anubis: bot-challenge proxy in front of the guarded vhosts (two-tier chain)
Bots and AI scrapers hit the SSO-fronted vhosts and eat Authentik auth work for 403s. Anubis verifies clients first: DENY for known AI scraper UAs, JS challenge for everyone else; verified traffic resumes the normal Authentik forward_auth chain on Caddy's internal listener (:8016). Chain: client -> Caddy (TLS) -> Anubis :8923 (verify) -> Caddy :8016 (auth + routing resumes; Host survives both hops) -> app. Reverse-proxy through Anubis, not forward_auth: at the pinned tag a CHALLENGE answers HTTP 200 + HTML, and forward_auth treats 200 as "authenticated" — the page would pass through unchallenged. Exempt by construction (never routed through Anubis): plex (plain HTTP legacy), auth.* (IdP login must be reachable ungated), gitea (git smart-HTTP clients cannot solve JS challenges), and every app with its own login. Pinned ghcr.io/TecharoHQ/anubis:v1.13.0 — the tag was source-verified before pinning: at this tag the policy parser is JSON-only with a minimal bots[] schema (no imports, no CEL expressions), flags are the config surface via flagenv (TARGET/POLICY_FNAME map to --target/ --policy-fname), and the image's exec-form HEALTHCHECK is inherited by the quadlet unit (dodges the v1 generator's HealthCmd space-split quirk). Rollback nuance: stopping anubis 502s everything behind the chain (the public listeners proxy through it); a true rollback is reverting the Caddyfile and redeploying, not just systemctl --user stop anubis.
This commit is contained in:
1 parent
7b38ea63ef
commit
0ae3650414
10 files changed
+328
-94
No files matched your search
+134
-83
@@ -11,13 +11,19 @@
|
|||||||
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
|
# (dankclaw/pihole/addn-hosts — every name resolves to 192.168.0.143)
|
||||||
# and handed to LAN clients by the SURFboard G36 router.
|
# and handed to LAN clients by the SURFboard G36 router.
|
||||||
#
|
#
|
||||||
# Authentication: Caddy forward_auth (Authentik SSO) protects the vhosts that
|
# Authentication chain (Anubis rollout, 2026-08-28): the guarded vhosts
|
||||||
# do not have their own application-level auth (homepage, SearXNG, Hermes,
|
# (homepage, SearXNG, Hermes, Prometheus, RomM, Copyparty) proxy EVERYTHING
|
||||||
# Prometheus, RomM, Copyparty). The basicauth hash is a placeholder; deploy.sh
|
# through the Anubis bot-challenge proxy first. Chain: client → Caddy (TLS,
|
||||||
# renders the real hash (CADDY_BASICAUTH_HASH from the host .env, git-ignored)
|
# public listener) → Anubis :8923 (verify: DENY known AI scrapers, CHALLENGE
|
||||||
# into /home/dank/Containers/caddy/Caddyfile, which the container mounts.
|
# everyone else) → Caddy's internal listener http://:8016 (Authentik
|
||||||
# Apps with their own auth (Grafana, Gitea, OpenWebUI, Jellyfin, *arr, Pi-hole)
|
# forward_auth + app routing resumes; Host headers survive both hops) → app.
|
||||||
# are not wrapped in forward_auth — they handle their own login.
|
# Apps with their own auth (Grafana, Gitea, OpenWebUI, ComfyUI, Jellyfin,
|
||||||
|
# *arr, Pi-hole) and the Authentik IdP itself (auth.franksdank.cloud — its
|
||||||
|
# login flow must never sit behind a challenge) are exempt by construction:
|
||||||
|
# their vhosts never route through Anubis. The basicauth hash is a
|
||||||
|
# placeholder; deploy.sh renders the real hash (CADDY_BASICAUTH_HASH from the
|
||||||
|
# host .env, git-ignored) into /home/dank/Containers/caddy/Caddyfile, which
|
||||||
|
# the container mounts.
|
||||||
#
|
#
|
||||||
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
|
# TLS: real Let's Encrypt certs. Site addresses carry no http:// prefix,
|
||||||
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via
|
# so Caddy serves HTTPS (and auto-redirects HTTP). Certs are issued via
|
||||||
@@ -28,7 +34,7 @@
|
|||||||
{
|
{
|
||||||
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
|
# Admin stays ON: it binds to 127.0.0.1:2019 inside the container and the
|
||||||
# port is not published, so nothing outside the container can reach it.
|
# port is not published, so nothing outside the container can reach it.
|
||||||
# It powers the config file watcher, so a rendered Caddyfile change
|
# It powers the config file watcher, so a rendered-config change
|
||||||
# hot-reloads without a container restart. 'admin off' once left a broken
|
# hot-reloads without a container restart. 'admin off' once left a broken
|
||||||
# basicauth hash sitting on disk unapplied for hours (2026-08-22).
|
# basicauth hash sitting on disk unapplied for hours (2026-08-22).
|
||||||
email admin@franksdank.cloud
|
email admin@franksdank.cloud
|
||||||
@@ -47,42 +53,10 @@
|
|||||||
redir https://franksdank.cloud/ 302
|
redir https://franksdank.cloud/ 302
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Homepage (Authentik SSO) ---
|
# --- Homepage (Anubis → Authentik SSO) ---
|
||||||
franksdank.cloud www.franksdank.cloud {
|
franksdank.cloud www.franksdank.cloud {
|
||||||
root * /srv/homepage
|
reverse_proxy anubis:8923 {
|
||||||
|
header_up X-Real-Ip {remote_host}
|
||||||
route {
|
|
||||||
# Authentik outpost (login UI + session management).
|
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
|
||||||
|
|
||||||
# Forward auth: check the Authentik session before serving the page.
|
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
|
|
||||||
# Operator-only admin page. Auth is checked on the original path,
|
|
||||||
# before any rewrite, so the path matcher is reliable. 'handle'
|
|
||||||
# preserves order.
|
|
||||||
handle /admin* {
|
|
||||||
try_files /admin.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
# Shared data + assets: fetched by both the user page and the admin
|
|
||||||
# page. With forward_auth, any authenticated user can access them
|
|
||||||
# (the browser's Authentik session covers these fetches).
|
|
||||||
@data path /services.json /status.json /app.js /style.css /deployed.js
|
|
||||||
handle @data {
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
|
|
||||||
# Everything else (the user page).
|
|
||||||
handle {
|
|
||||||
try_files /index.html
|
|
||||||
file_server
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +71,8 @@ dankserver.franksdank.cloud {
|
|||||||
}
|
}
|
||||||
|
|
||||||
# --- Authentik (SSO / OIDC provider) ---
|
# --- Authentik (SSO / OIDC provider) ---
|
||||||
|
# Exempt by construction: the IdP login flow must be reachable without a
|
||||||
|
# challenge, so this vhost never routes through Anubis.
|
||||||
auth.franksdank.cloud {
|
auth.franksdank.cloud {
|
||||||
reverse_proxy authentik-server:9000
|
reverse_proxy authentik-server:9000
|
||||||
}
|
}
|
||||||
@@ -107,14 +83,8 @@ auth.franksdank.cloud {
|
|||||||
|
|
||||||
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
|
# --- SearXNG (quadlet; behind the Gluetun tunnel, published 8081) ---
|
||||||
search.franksdank.cloud {
|
search.franksdank.cloud {
|
||||||
route {
|
reverse_proxy anubis:8923 {
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
header_up X-Real-Ip {remote_host}
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
reverse_proxy 192.168.0.143:8081
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -130,18 +100,16 @@ chat.franksdank.cloud {
|
|||||||
|
|
||||||
# --- Hermes ---
|
# --- Hermes ---
|
||||||
hermes.franksdank.cloud {
|
hermes.franksdank.cloud {
|
||||||
route {
|
reverse_proxy anubis:8923 {
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
header_up X-Real-Ip {remote_host}
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
reverse_proxy hermes:8090
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Gitea (quadlet; published 3000) ---
|
# --- Gitea (quadlet; published 3000) ---
|
||||||
|
# Exempt by construction: Gitea has its own login, and git smart-HTTP
|
||||||
|
# clients (git push/fetch over HTTPS) cannot solve JS challenges. The
|
||||||
|
# policy would need a bypass for /info/refs + /git-*-pack paths if this
|
||||||
|
# ever moves behind Anubis.
|
||||||
gitea.franksdank.cloud {
|
gitea.franksdank.cloud {
|
||||||
reverse_proxy 192.168.0.143:3000
|
reverse_proxy 192.168.0.143:3000
|
||||||
}
|
}
|
||||||
@@ -156,14 +124,8 @@ grafana.franksdank.cloud {
|
|||||||
|
|
||||||
# --- Prometheus ---
|
# --- Prometheus ---
|
||||||
prometheus.franksdank.cloud {
|
prometheus.franksdank.cloud {
|
||||||
route {
|
reverse_proxy anubis:8923 {
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
header_up X-Real-Ip {remote_host}
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
reverse_proxy prometheus:9090
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -217,27 +179,15 @@ books.franksdank.cloud {
|
|||||||
|
|
||||||
# --- RomM ---
|
# --- RomM ---
|
||||||
romm.franksdank.cloud {
|
romm.franksdank.cloud {
|
||||||
route {
|
reverse_proxy anubis:8923 {
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
header_up X-Real-Ip {remote_host}
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
reverse_proxy 192.168.0.235:8085
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
# --- Copyparty ---
|
# --- Copyparty ---
|
||||||
files.franksdank.cloud {
|
files.franksdank.cloud {
|
||||||
route {
|
reverse_proxy anubis:8923 {
|
||||||
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
header_up X-Real-Ip {remote_host}
|
||||||
forward_auth authentik-server:9000 {
|
|
||||||
uri /outpost.goauthentik.io/auth/caddy
|
|
||||||
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
|
||||||
trusted_proxies private_ranges
|
|
||||||
}
|
|
||||||
reverse_proxy 192.168.0.235:3923
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -275,6 +225,107 @@ qbittorrent.franksdank.cloud {
|
|||||||
reverse_proxy 192.168.0.235:8080
|
reverse_proxy 192.168.0.235:8080
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# --- Internal listener: where Anubis forwards accepted traffic ---
|
||||||
|
# Anubis dials http://caddy:8016 after verifying a client (TARGET in
|
||||||
|
# dankclaw/anubis/quadlet/anubis.container). Host headers survive the hop,
|
||||||
|
# so per-vhost routing resumes here: outpost paths go straight to the
|
||||||
|
# Authentik outpost, everything else runs the forward_auth check and then
|
||||||
|
# dials the app. This block is the second half of every guarded vhost.
|
||||||
|
http://:8016 {
|
||||||
|
@home host franksdank.cloud www.franksdank.cloud
|
||||||
|
handle @home {
|
||||||
|
root * /srv/homepage
|
||||||
|
route {
|
||||||
|
# Authentik outpost (login UI + session management).
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
|
||||||
|
# Forward auth: check the Authentik session before serving the page.
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
# Operator-only admin page. Auth is checked on the original path,
|
||||||
|
# before any rewrite, so the path matcher is reliable. 'handle'
|
||||||
|
# preserves order.
|
||||||
|
handle /admin* {
|
||||||
|
try_files /admin.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Shared data + assets: fetched by both the user page and the admin
|
||||||
|
# page. With forward_auth, any authenticated user can access them
|
||||||
|
# (the browser's Authentik session covers these fetches).
|
||||||
|
@data path /services.json /status.json /app.js /style.css /deployed.js
|
||||||
|
handle @data {
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
|
||||||
|
# Everything else (the user page).
|
||||||
|
handle {
|
||||||
|
try_files /index.html
|
||||||
|
file_server
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@search host search.franksdank.cloud
|
||||||
|
handle @search {
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.143:8081
|
||||||
|
}
|
||||||
|
|
||||||
|
@hermes host hermes.franksdank.cloud
|
||||||
|
handle @hermes {
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy hermes:8090
|
||||||
|
}
|
||||||
|
|
||||||
|
@prometheus host prometheus.franksdank.cloud
|
||||||
|
handle @prometheus {
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy prometheus:9090
|
||||||
|
}
|
||||||
|
|
||||||
|
@romm host romm.franksdank.cloud
|
||||||
|
handle @romm {
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.235:8085
|
||||||
|
}
|
||||||
|
|
||||||
|
@files host files.franksdank.cloud
|
||||||
|
handle @files {
|
||||||
|
reverse_proxy /outpost.goauthentik.io/* authentik-server:9000
|
||||||
|
forward_auth authentik-server:9000 {
|
||||||
|
uri /outpost.goauthentik.io/auth/caddy
|
||||||
|
copy_headers X-Authentik-Username X-Authentik-Groups X-Authentik-Email X-Authentik-Name
|
||||||
|
trusted_proxies private_ranges
|
||||||
|
}
|
||||||
|
reverse_proxy 192.168.0.235:3923
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
# --- Optional: Jackett ---
|
# --- Optional: Jackett ---
|
||||||
# jackett.franksdank.cloud {
|
# jackett.franksdank.cloud {
|
||||||
# reverse_proxy 192.168.0.235:9117
|
# reverse_proxy 192.168.0.235:9117
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"bots": [
|
||||||
|
{
|
||||||
|
"name": "deny AI scrapers",
|
||||||
|
"user_agent_regex": "(?i)^(GPTBot|ChatGPT-User|OAI-SearchBot|CCBot|anthropic-ai|ClaudeBot|Claude-Web|PerplexityBot|PerplexityUser|Google-Extended|Applebot-Extended|Bytespider|Amazonbot|meta-externalagent|DuckAssistBot|cohere-ai|YouBot|KimiBot)",
|
||||||
|
"action": "DENY"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "challenge everything else",
|
||||||
|
"path_regex": ".",
|
||||||
|
"action": "CHALLENGE"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"dnsbl": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
# Quadlet unit: Anubis bot-challenge proxy (rootless, boot-persistent).
|
||||||
|
#
|
||||||
|
# Install path on dankclaw: ~/.config/containers/systemd/anubis.container
|
||||||
|
# Unit name: anubis.service (container name: anubis)
|
||||||
|
#
|
||||||
|
# Two-tier chain: the public Caddy listeners for the guarded vhosts proxy
|
||||||
|
# everything to this container (port 8923, internal only — no published port,
|
||||||
|
# so metrics on :9090 stay reachable only inside dankclaw_default); Anubis
|
||||||
|
# verifies the client (challenge/DENY per policy) and forwards accepted
|
||||||
|
# traffic back to Caddy's internal listener (TARGET below), where the normal
|
||||||
|
# per-vhost routing — including Authentik forward_auth — resumes. Host headers
|
||||||
|
# survive both hops, so vhost matching on the internal listener still works.
|
||||||
|
#
|
||||||
|
# Version pin: ghcr.io/TecharoHQ/anubis:v1.13.0 was source-verified before
|
||||||
|
# pinning (policy parser is JSON-only at this tag, flags are the config
|
||||||
|
# surface via flagenv: an env var named TARGET sets --target, POLICY_FNAME
|
||||||
|
# sets --policy-fname). Upgrading = stop unit, change Image=, deploy; policy
|
||||||
|
# schema must be re-verified against the new tag first.
|
||||||
|
#
|
||||||
|
# No secrets here: Anubis holds no credentials (memory store; challenge
|
||||||
|
# cookies are client-side JWTs). A restart invalidates issued challenges —
|
||||||
|
# users just re-challenge once. See serverAppConfigs-docs/Infrastructure/Anubis.md.
|
||||||
|
|
||||||
|
[Unit]
|
||||||
|
Description=Anubis bot-challenge proxy container
|
||||||
|
After=network-online.target
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Container]
|
||||||
|
ContainerName=anubis
|
||||||
|
Image=ghcr.io/TecharoHQ/anubis:v1.13.0
|
||||||
|
Pull=never
|
||||||
|
Network=dankclaw_default
|
||||||
|
# flagenv maps env names to flag names (TARGET -> --target, POLICY_FNAME ->
|
||||||
|
# --policy-fname). TARGET points back at Caddy's internal listener; the
|
||||||
|
# container DNS name resolves on dankclaw_default.
|
||||||
|
Environment=TARGET=http://caddy:8016
|
||||||
|
Environment=POLICY_FNAME=/policy/policy.json
|
||||||
|
Volume=/home/dank/serverAppConfigs/dankclaw/anubis/policy.json:/policy/policy.json:ro
|
||||||
|
# No HealthCmd: the image ships an exec-form HEALTHCHECK
|
||||||
|
# (["/app/bin/anubis","--healthcheck"]) and quadlet inherits it when no
|
||||||
|
# HealthCmd is set — this dodges the v1 generator's space-splitting quirk
|
||||||
|
# that mangled quoted probes for the authentik units.
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Restart=always
|
||||||
|
RestartSec=5
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=default.target
|
||||||
@@ -246,7 +246,7 @@ def services_for_path(path: str, target: str) -> set[str]:
|
|||||||
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
|
if len(parts) >= 2 and parts[1] == "Containerfile.comfyui":
|
||||||
_add(services, "comfyui")
|
_add(services, "comfyui")
|
||||||
return services
|
return services
|
||||||
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes"}:
|
if len(parts) >= 2 and parts[1] in {"caddy", "comfyui", "loki", "alloy", "hermes", "anubis"}:
|
||||||
_add(services, parts[1])
|
_add(services, parts[1])
|
||||||
return services
|
return services
|
||||||
if len(parts) >= 2 and parts[1] == "searxng":
|
if len(parts) >= 2 and parts[1] == "searxng":
|
||||||
|
|||||||
@@ -550,6 +550,22 @@ deploy_authentik() {
|
|||||||
systemctl --user start authentik-worker.service
|
systemctl --user start authentik-worker.service
|
||||||
}
|
}
|
||||||
|
|
||||||
|
deploy_anubis() {
|
||||||
|
# Anubis is the bot-challenge proxy in front of the guarded vhosts.
|
||||||
|
# The image lives on ghcr.io; pull once, then the unit runs it from
|
||||||
|
# local storage (Pull=never keeps boots off the registry).
|
||||||
|
if ! podman image inspect ghcr.io/TecharoHQ/anubis:v1.13.0 >/dev/null 2>&1; then
|
||||||
|
podman pull ghcr.io/TecharoHQ/anubis:v1.13.0
|
||||||
|
fi
|
||||||
|
cp "dankclaw/anubis/quadlet/anubis.container" "$quadlet_unit_dir/"
|
||||||
|
systemctl --user daemon-reload
|
||||||
|
# No enable: generated units cannot be enabled on this host (v1 quadlet
|
||||||
|
# generator); boot persistence comes from [Install] in the unit file.
|
||||||
|
# restart, not start: policy.json is parsed at process start only (no
|
||||||
|
# reload signal exists), so every redeploy must cycle the process.
|
||||||
|
systemctl --user restart anubis.service
|
||||||
|
wait_container_healthy anubis 60 || return 1
|
||||||
|
}
|
||||||
|
|
||||||
deploy_llm_large() {
|
deploy_llm_large() {
|
||||||
relink_unit frank-default-large
|
relink_unit frank-default-large
|
||||||
@@ -604,6 +620,7 @@ deploy_service() {
|
|||||||
authentik-postgres|authentik-server|authentik-worker)
|
authentik-postgres|authentik-server|authentik-worker)
|
||||||
deploy_authentik
|
deploy_authentik
|
||||||
;;
|
;;
|
||||||
|
anubis) deploy_anubis ;;
|
||||||
frank-default-large) deploy_llm_large ;;
|
frank-default-large) deploy_llm_large ;;
|
||||||
frank-default-small) deploy_llm_small ;;
|
frank-default-small) deploy_llm_small ;;
|
||||||
frank-default-npu) deploy_llm_npu ;;
|
frank-default-npu) deploy_llm_npu ;;
|
||||||
|
|||||||
+1
-1
@@ -118,7 +118,7 @@ valid_service() {
|
|||||||
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
|
dankclaw:comfyui|dankclaw:hermes|dankclaw:hermes-dashboard|\
|
||||||
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
|
dankclaw:hermes-webui|dankclaw:prometheus|dankclaw:grafana|dankclaw:loki|dankclaw:alloy|\
|
||||||
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
dankclaw:node-exporter|dankclaw:authentik-postgres|dankclaw:authentik-server|\
|
||||||
dankclaw:authentik-worker|dankclaw:caddy|dankclaw:homepage|\
|
dankclaw:authentik-worker|dankclaw:anubis|dankclaw:caddy|dankclaw:homepage|\
|
||||||
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
|
dankclaw:frank-default-large|dankclaw:frank-default-npu|\
|
||||||
dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\
|
dankserver:mariadb|dankserver:redis|dankserver:romm|dankserver:copyparty|\
|
||||||
dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\
|
dankserver:jellyfin|dankserver:calibre-web|dankserver:prowlarr|\
|
||||||
|
|||||||
@@ -120,6 +120,17 @@ for path in "${CHANGED[@]}"; do
|
|||||||
add_test tests/searxng_quadlet.bats
|
add_test tests/searxng_quadlet.bats
|
||||||
add_test tests/deployment_scoping.bats
|
add_test tests/deployment_scoping.bats
|
||||||
;;
|
;;
|
||||||
|
# Anubis is a quadlet + Caddyfile pair: the chain (public listener ->
|
||||||
|
# anubis:8923 -> internal :8016 listener) spans both, so any change
|
||||||
|
# under the service dir must re-run the caddyfile seam too. This case
|
||||||
|
# precedes the dankclaw/*/quadlet/*.container fallback below, which
|
||||||
|
# would otherwise swallow it into unrelated seams.
|
||||||
|
dankclaw/anubis/*)
|
||||||
|
add_test tests/anubis.bats
|
||||||
|
add_test tests/caddyfile.bats
|
||||||
|
add_test tests/homepage_caddy.bats
|
||||||
|
add_test tests/deployment_scoping.bats
|
||||||
|
;;
|
||||||
# Quadlets live under dankclaw/<svc>/quadlet/ (reorganized out of the
|
# Quadlets live under dankclaw/<svc>/quadlet/ (reorganized out of the
|
||||||
# top-level dankclaw/*.container globs above). Map the whole subtree so
|
# top-level dankclaw/*.container globs above). Map the whole subtree so
|
||||||
# changes to the pihole, open-webui, searxng, and gitea quadlets hit
|
# changes to the pihole, open-webui, searxng, and gitea quadlets hit
|
||||||
|
|||||||
Executable
+81
@@ -0,0 +1,81 @@
|
|||||||
|
#!/usr/bin/env bats
|
||||||
|
# Seam: the Anubis bot-challenge proxy (dankclaw/anubis). Anubis sits between
|
||||||
|
# Caddy's public listeners and the guarded vhosts: clients hit Caddy (TLS),
|
||||||
|
# Caddy proxies everything through Anubis, Anubis verifies (DENY known AI
|
||||||
|
# scrapers, CHALLENGE everyone else per policy.json) and forwards accepted
|
||||||
|
# traffic back to Caddy's internal listener at :8016, where per-vhost routing
|
||||||
|
# resumes. Static assertions only — the container runs on dankclaw.
|
||||||
|
|
||||||
|
load helpers
|
||||||
|
|
||||||
|
setup() {
|
||||||
|
UNIT="$REPO_ROOT/dankclaw/anubis/quadlet/anubis.container"
|
||||||
|
POLICY="$REPO_ROOT/dankclaw/anubis/policy.json"
|
||||||
|
CADDY="$REPO_ROOT/dankclaw/Caddyfile"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the anubis quadlet unit exists and is boot-persistent by [Install]" {
|
||||||
|
test -f "$UNIT"
|
||||||
|
grep -q 'WantedBy=default.target' "$UNIT"
|
||||||
|
# Generated units cannot be enabled on this host (v1 quadlet generator);
|
||||||
|
# boot persistence comes from [Install], not from systemctl enable.
|
||||||
|
! grep -q '^Enable=true' "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the image is pinned to an exact upstream tag and never pulled at boot" {
|
||||||
|
grep -qE '^Image=ghcr\.io/TecharoHQ/anubis:v[0-9]+\.[0-9]+\.[0-9]+$' "$UNIT"
|
||||||
|
grep -q '^Pull=never$' "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "anubis publishes no host port — it is internal-network-only" {
|
||||||
|
# Metrics (:9090) are unauthenticated; the protection strategy is that
|
||||||
|
# only dankclaw_default members can reach the container at all. Caddy
|
||||||
|
# reaches it by container DNS name, so no published port may exist.
|
||||||
|
! grep -q '^PublishPort=' "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the unit points TARGET back at caddy's internal listener" {
|
||||||
|
grep -q '^Environment=TARGET=http://caddy:8016$' "$UNIT"
|
||||||
|
grep -q '^Environment=POLICY_FNAME=/policy/policy.json$' "$UNIT"
|
||||||
|
# The policy file is mounted read-only from the repo checkout on the host.
|
||||||
|
grep -q 'policy.json:/policy/policy.json:ro' "$UNIT"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "the policy parses as JSON and matches v1.13.0 schema (bots array, action rules)" {
|
||||||
|
# At the pinned tag the parser is JSON-only with a minimal schema:
|
||||||
|
# {"bots":[{"name","user_agent_regex"|"path_regex","action"}],"dnsbl":bool}
|
||||||
|
# — imports/CEL/expression keys do not exist at this tag and would crash
|
||||||
|
# the container at parse time. Assert the shape statically via python.
|
||||||
|
python3 - "$POLICY" <<'PY'
|
||||||
|
import json, sys
|
||||||
|
with open(sys.argv[1]) as f:
|
||||||
|
p = json.load(f)
|
||||||
|
assert set(p.keys()) <= {"bots", "dnsbl"}, p.keys()
|
||||||
|
actions = [b["action"] for b in p["bots"]]
|
||||||
|
assert any(a == "DENY" for a in actions), actions
|
||||||
|
assert actions[-1] == "CHALLENGE", actions
|
||||||
|
for b in p["bots"]:
|
||||||
|
keys = set(b) ^ {"name", "action"}
|
||||||
|
assert len(keys) == 1, f"{b.get('name')}: exactly one matcher required, got {keys}"
|
||||||
|
assert (keys ^ {"user_agent_regex"}) != (keys ^ {"path_regex"}), b
|
||||||
|
PY
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "guarded vhosts proxy through anubis with the real client IP forwarded" {
|
||||||
|
# Anubis reads X-Real-Ip for its decisions; without the header every
|
||||||
|
# request looks like it comes from the Caddy container. Every guarded
|
||||||
|
# public listener must set it.
|
||||||
|
grep -q 'header_up X-Real-Ip {remote_host}' "$CADDY"
|
||||||
|
}
|
||||||
|
|
||||||
|
@test "exempt vhosts never route through anubis (auth, plex, own-auth apps)" {
|
||||||
|
# The IdP login flow and git smart-HTTP clients cannot solve challenges;
|
||||||
|
# these vhosts bypass the chain by construction.
|
||||||
|
local block
|
||||||
|
block=$(awk '/^auth\.franksdank\.cloud/,/^}/' "$CADDY")
|
||||||
|
echo "$block" | grep -q 'reverse_proxy authentik-server:9000'
|
||||||
|
! echo "$block" | grep -q anubis
|
||||||
|
block=$(awk '/^plex\.franksdank\.cloud/,/^}/' "$CADDY")
|
||||||
|
echo "$block" | grep -q '192\.168\.0\.235:32400'
|
||||||
|
! echo "$block" | grep -q anubis
|
||||||
|
}
|
||||||
@@ -124,9 +124,11 @@ CADDYFILE="$REPO_ROOT/dankclaw/Caddyfile"
|
|||||||
@test "all vhosts use TLS except the legacy .casa name" {
|
@test "all vhosts use TLS except the legacy .casa name" {
|
||||||
# franksdank.casa is not in the same Porkbun account, so it cannot get
|
# franksdank.casa is not in the same Porkbun account, so it cannot get
|
||||||
# a DNS-01 Let's Encrypt cert; it stays explicitly http. Every other
|
# a DNS-01 Let's Encrypt cert; it stays explicitly http. Every other
|
||||||
# site address must carry no http:// prefix.
|
# client-facing site address must carry no http:// prefix. The internal
|
||||||
|
# listener (http://:8016) is not client-facing — Anubis dials it over
|
||||||
|
# container DNS after verifying a client, so plain HTTP is intended.
|
||||||
local offenders
|
local offenders
|
||||||
offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' || true)
|
offenders=$(grep -E '^http://' "$CADDYFILE" | grep -v 'plex\.franksdank\.casa' | grep -v '^http://:8016' || true)
|
||||||
[ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; }
|
[ -z "$offenders" ] || { echo "non-.casa http:// vhosts: $offenders"; return 1; }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
#!/usr/bin/env bats
|
#!/usr/bin/env bats
|
||||||
# Seam: the homepage site block in dankclaw/Caddyfile. It serves /srv/homepage
|
# Seam: the homepage site block in dankclaw/Caddyfile. Since the Anubis
|
||||||
# from the naked domain (franksdank.cloud) and www. Authentication is via
|
# rollout the naked domain + www are guarded vhosts: the public listener
|
||||||
# Caddy forward_auth (Authentik SSO) — the basicauth hashes were retired in
|
# proxies everything through Anubis (bot challenge), and the Authentik
|
||||||
# favor of the unified Authentik login. caddy is not on the dev box, so the
|
# forward_auth for SSO runs on Caddy's internal listener (http://:8016)
|
||||||
|
# after Anubis has verified the client. caddy is not on the dev box, so the
|
||||||
# tests assert the structure statically.
|
# tests assert the structure statically.
|
||||||
|
|
||||||
load helpers
|
load helpers
|
||||||
@@ -26,13 +27,19 @@ setup() {
|
|||||||
@test "the shared data + assets are served to any authenticated user" {
|
@test "the shared data + assets are served to any authenticated user" {
|
||||||
# The data (services.json, status.json) and assets are fetched by both the
|
# The data (services.json, status.json) and assets are fetched by both the
|
||||||
# user page and the admin page. With forward_auth, any authenticated user
|
# user page and the admin page. With forward_auth, any authenticated user
|
||||||
# can access them (the browser's Authentik session covers the fetch).
|
# can access them (the browser's Authentik session covers these fetches).
|
||||||
grep -qE "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY"
|
grep -qE "@data[[:space:]]+path[[:space:]]+/services\.json[[:space:]]+/status\.json" "$CADDY"
|
||||||
grep -qE "handle[[:space:]]+@data" "$CADDY"
|
grep -qE "handle[[:space:]]+@data" "$CADDY"
|
||||||
}
|
}
|
||||||
|
|
||||||
@test "the homepage vhost uses forward_auth (Authentik SSO)" {
|
@test "the homepage vhost chains Anubis (public) before Authentik SSO (internal listener)" {
|
||||||
|
# Public listener: the naked domain proxies everything through Anubis.
|
||||||
local block
|
local block
|
||||||
block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY")
|
block=$(awk '/^franksdank\.cloud[[:space:]]/,/^}/' "$CADDY")
|
||||||
echo "$block" | grep -q 'forward_auth'
|
echo "$block" | grep -q 'reverse_proxy anubis:8923'
|
||||||
|
# The SSO check itself now runs on the internal listener that Anubis
|
||||||
|
# forwards accepted traffic to; Host survives the hop, so per-vhost
|
||||||
|
# routing (and with it forward_auth) resumes there.
|
||||||
|
grep -q 'http://:8016' "$CADDY"
|
||||||
|
awk '/^http:\/\/:8016/,/^\}/' "$CADDY" | grep -q 'forward_auth authentik-server:9000'
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user