5.9 KiB
Public Edge Security (Caddy + SessionGuard EdgeGuard)
SessionGuard EdgeGuard is a small Go service for the public reverse-proxy host.
It is not another login and it is not a WAF replacement. It runs locally on
127.0.0.1:9081 and is called by Caddy using forward_auth before traffic is
sent to SessionGuard or a Guacamole worker.
Threat model
EdgeGuard is intended to reduce the cost of common Internet abuse:
- repeated scanners and exploit probes;
- excessive requests from one source;
- request floods intended to exhaust SessionGuard/Guacamole rather than the physical Internet link;
- malformed/oversized URIs and unexpected HTTP methods;
- accidental exposure of backend-only SessionGuard endpoints.
It cannot stop a volumetric attack that saturates the VPS uplink. Provider-side DDoS filtering/firewalling remains necessary for that class of attack.
Request flow
Browser
-> Caddy TLS
-> EdgeGuard /check (localhost only)
-> 204: continue
-> 403/404/405/421/429: stop at edge
-> SessionGuard Access Auth
-> Guacamole worker
Caddy's forward_auth sends a lightweight GET subrequest. EdgeGuard therefore
checks the initial WebSocket handshake, but it is not in the byte path of the
established Guacamole WebSocket/RDP stream.
Controls
Static blacklist
blacklist.txt accepts one IPv4/IPv6 address or CIDR per line. Comments start
with #. Changes are automatically picked up.
203.0.113.44
198.51.100.0/24
2001:db8:1234::/48
Global overload limit
The global token bucket is intentionally high and protects the application backends when a distributed HTTP flood reaches the VPS. It does not prevent network saturation because TLS and the incoming packets already reached Caddy.
Default production example:
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
}
Per-IP limit
The default is deliberately NAT-friendly:
"per_ip_limit": {
"rate_per_second": 200,
"burst": 500
}
For environments where many staff share one public NAT IP, do not aggressively
lower this value. If a known source really needs exemption, put its address in
rate-exempt.txt. Exempt sources still pass blacklist, scanner, method, host
and global-overload checks.
Endpoint-specific limits
The example uses tighter limits for OIDC login/callback paths. These endpoints do not contain the PocketID password check itself; the limits are intended to protect state/session allocation and redirect processing from floods.
Bounded per-IP memory
max_tracked_ips bounds the in-memory table used for per-IP token buckets and
offense state. The production example allows 100,000 active source addresses;
when the table is full, previously unseen sources receive HTTP 429 instead of
causing unbounded memory growth. Stale entries are cleaned up automatically.
Scanner detection and temporary bans
Known irrelevant exploit/scanner paths are denied before they reach the
backends. The defaults include /.env, /.git, WordPress, phpMyAdmin, CGI,
Spring Actuator and several common automated exploit probes.
The example auto-ban weights are:
- scanner path: 5 points;
- blocked method: 3 points;
- malformed URI: 3 points;
- ordinary rate-limit violation: 0 points.
At 10 points within 120 seconds, the IP is banned for 900 seconds. Bans are
persisted in /var/lib/sessionguard-edgeguard/state.json with a short write
debounce, so a container restart does not normally remove them while scanner
floods cannot force one synchronous disk write per request.
Rate-limit violations deliberately have weight 0 by default to avoid banning a whole corporate NAT during a legitimate burst.
Blocked HTTP methods
CONNECT, TRACE and TRACK are rejected. SessionGuard/Guacamole continue to
use their normal GET/POST/PUT/PATCH/DELETE/OPTIONS behavior.
Host allowlist
Only configured public host names are accepted by EdgeGuard. Caddy additionally
uses strict_sni_host on, requiring the TLS SNI host and HTTP Host header to
match.
Caddy hardening
The supplied public Caddyfile also configures:
servers {
protocols h1 h2
strict_sni_host on
max_header_size 64KB
timeouts {
read_header 10s
}
}
The short header timeout and smaller header ceiling reduce slow-header/resource exhaustion risk. HTTP/3 is intentionally disabled in the example to reduce the public protocol surface; Guacamole works with HTTP/1.1/WebSocket and HTTP/2.
Access logs are written to size-limited rotating files. Sampling keeps all normal traffic but reduces log amplification once a single logger exceeds 200 entries per second.
The public SessionGuard host denies /metrics and /api/v1/broker/* at Caddy.
The Caddy admin API is disabled (admin off) on this dedicated public edge;
configuration changes are applied by restarting the Caddy container.
Guacamole workers call broker APIs directly over NetBird instead.
Metrics
EdgeGuard exposes Prometheus text metrics only on localhost:
http://127.0.0.1:9081/metrics
Counters include total checks, allows, static-blacklist denies, temporary-ban denies, rate-limit denies, scanner denies and auto-bans.
Configuration reload
edgeguard.json, blacklist.txt and rate-exempt.txt are re-read on the
configured interval (15 seconds in the example). An invalid replacement config
is logged and the previous working configuration remains active.
Operational recommendations
- Keep provider/network DDoS protection enabled.
- Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
- Keep NetBird peer/backend ports private.
- Pin Caddy to a currently patched release rather than an old major-only image during controlled production rollouts.
- Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
- Do not put broad office/country CIDRs on the static blacklist without first checking whether legitimate remote users may originate there.