RC-2 0.5.2
Some checks failed
release-tag / release-image (push) Successful in 2m3s
release-main / release-images (push) Failing after 3m9s

This commit is contained in:
2026-08-24 05:46:42 +02:00
parent 934b013d18
commit 7c69432097
34 changed files with 2393 additions and 52 deletions

View File

@@ -69,6 +69,19 @@ jobs:
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
- name: Build and push SessionGuard EdgeGuard
uses: docker/build-push-action@v4
with:
context: .
file: ./Dockerfile.edgeguard
platforms: linux/amd64
push: true
build-args: |
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
tags: |
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ steps.meta.outputs.REPO_VERSION }}
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ env.DOCKER_LATEST }}
# Export exactly the same extension that is embedded into the Guacamole image.
# The dedicated target avoids depending on the Maven project version in CI.
- name: Build Guacamole Extension JAR
@@ -166,6 +179,9 @@ jobs:
Guacamole image:
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.GUAC_IMAGE }}:${{ steps.meta.outputs.REPO_VERSION }}
EdgeGuard image:
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ steps.meta.outputs.REPO_VERSION }}
Extension package:
${GITEA_API_BASE}/api/packages/${DOCKER_ORG}/generic/${{ steps.meta.outputs.EXT_PACKAGE }}/${{ steps.meta.outputs.REPO_VERSION }}/sessionguard-guacamole.jar

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
/bin/
*.exe
*.log
state.json
master.json

View File

@@ -1,5 +1,22 @@
# Changelog
## 0.5.2 — Public EdgeGuard security layer
- Added `sessionguard-edgeguard`, a dependency-free Go edge pre-check for the public Caddy host.
- Static IPv4/IPv6/CIDR blacklist with automatic reload.
- Global and per-IP token-bucket rate limiting plus endpoint-specific OIDC/login limits.
- NAT-safe defaults: ordinary rate-limit hits do not automatically ban a shared public address.
- Scanner/exploit path detection and persistent temporary auto-bans for clearly hostile behavior.
- Bounded per-IP state table prevents rotating-source floods from causing unbounded memory growth; ban persistence is write-debounced to avoid I/O amplification.
- Blocks CONNECT/TRACE/TRACK, validates allowed public hosts and rejects malformed/oversized URIs.
- Local `/healthz` and Prometheus-style `/metrics` endpoints for EdgeGuard.
- Added hardened public Caddy deployment: strict SNI/Host matching, 10s header timeout, 64 KiB header ceiling, HTTP/1.1+HTTP/2 only and conservative response security headers.
- Caddy admin API is disabled on the dedicated edge; access logs use bounded file rotation and high-load sampling to reduce log-amplification risk.
- Public-VPS example pins Caddy 2.11.4 instead of an unqualified major tag.
- Public Caddy now hides SessionGuard `/metrics` and broker endpoints; Guacamole workers continue to use broker APIs directly over NetBird.
- Added a dedicated `Dockerfile.edgeguard`, public-VPS Compose stack and CI image publication.
- No Master/Agent protocol or database migration; Agent protocol remains version 4.
## 0.5.1 — Modal-first responsive Web UI
- Master-WebUI neu strukturiert: Terminalserver öffnen in einem großen responsiven Arbeitsdialog statt in einer langen Inline-Detailspalte.

21
Dockerfile.edgeguard Normal file
View File

@@ -0,0 +1,21 @@
FROM golang:1.23-bookworm AS build
WORKDIR /src
COPY go.mod ./
RUN go mod download
COPY . .
ARG VERSION=dev
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/sessionguard-edgeguard ./cmd/edgeguard
FROM alpine:3.20
RUN apk add --no-cache ca-certificates \
&& addgroup -S edgeguard \
&& adduser -S -G edgeguard edgeguard \
&& mkdir -p /etc/sessionguard-edgeguard /var/lib/sessionguard-edgeguard \
&& chown -R edgeguard:edgeguard /etc/sessionguard-edgeguard /var/lib/sessionguard-edgeguard
COPY --from=build /out/sessionguard-edgeguard /usr/local/bin/sessionguard-edgeguard
USER edgeguard
VOLUME ["/var/lib/sessionguard-edgeguard"]
EXPOSE 9081
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:9081/healthz || exit 1
ENTRYPOINT ["/usr/local/bin/sessionguard-edgeguard"]
CMD ["-config", "/etc/sessionguard-edgeguard/edgeguard.json"]

View File

@@ -2,10 +2,14 @@
SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions.
**Current development version: 0.5.1 (modal-first responsive Web UI + integrated Guacamole Access Auth)**
**Current development version: 0.5.2 (Public EdgeGuard + modal-first responsive Web UI + integrated Guacamole Access Auth)**
> SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC.
## 0.5.2 Public EdgeGuard
The optional public-VPS deployment now includes `sessionguard-edgeguard`, a small Go service called by Caddy before requests reach SessionGuard or Guacamole. It provides static CIDR blacklisting, global/per-IP/endpoint token-bucket limits, scanner-path detection, persistent temporary bans, method/host/URI guards and local metrics. Caddy is additionally hardened with strict SNI/Host matching, a request-header timeout and smaller header ceiling. This protects application backends against common Internet abuse; provider-side DDoS filtering is still required for attacks that saturate the network link. See `docs/EDGE-SECURITY.md` and `deploy/public-vps-netbird/`.
## 0.5.1 Web UI refresh
The Master and local Agent consoles now use a modal-first, responsive Vanilla-JavaScript interface. Server drill-down, Farm creation, Resource editing and local Agent policy editing no longer occupy permanent large inline forms. The Master server workspace uses tabs, Farms/Resources use compact cards, and the Agent groups operational data into a tabbed workspace. No Node.js/npm/frontend build step is required.

121
cmd/edgeguard/main.go Normal file
View File

@@ -0,0 +1,121 @@
package main
import (
"context"
"flag"
"fmt"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/example/sessionguard/internal/edgeguard"
)
var version = "dev"
func main() {
configPath := flag.String("config", "/etc/sessionguard-edgeguard/edgeguard.json", "path to EdgeGuard JSON configuration")
checkConfig := flag.Bool("check-config", false, "validate configuration and exit")
showVersion := flag.Bool("version", false, "print version and exit")
flag.Parse()
if *showVersion {
fmt.Printf("sessionguard-edgeguard %s\n", version)
return
}
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
cfg, err := edgeguard.LoadRuntimeConfig(*configPath)
if err != nil {
logger.Error("load configuration", "error", err)
os.Exit(2)
}
if *checkConfig {
fmt.Printf("configuration OK (%s)\n", cfg.Fingerprint()[:12])
return
}
guard := edgeguard.NewGuard(cfg, logger)
handler := edgeguard.NewHTTPServer(guard, logger).Handler()
server := &http.Server{
Addr: cfg.Listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
IdleTimeout: 30 * time.Second,
MaxHeaderBytes: 16 << 10,
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
go reloadLoop(ctx, *configPath, guard, logger)
go cleanupLoop(ctx, guard)
go persistenceLoop(ctx, guard)
go func() {
<-ctx.Done()
guard.FlushState()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = server.Shutdown(shutdownCtx)
}()
logger.Info("SessionGuard EdgeGuard started", "version", version, "listen", cfg.Listen)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("edgeguard server stopped", "error", err)
os.Exit(1)
}
}
func reloadLoop(ctx context.Context, path string, guard *edgeguard.Guard, logger *slog.Logger) {
interval := guard.Config().ReloadInterval()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
cfg, err := edgeguard.LoadRuntimeConfig(path)
if err != nil {
logger.Error("reload edgeguard configuration", "error", err)
continue
}
guard.ReplaceConfig(cfg)
newInterval := cfg.ReloadInterval()
if newInterval != interval {
interval = newInterval
t.Reset(interval)
}
}
}
}
func cleanupLoop(ctx context.Context, guard *edgeguard.Guard) {
t := time.NewTicker(time.Minute)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case now := <-t.C:
guard.Cleanup(now)
}
}
}
func persistenceLoop(ctx context.Context, guard *edgeguard.Guard) {
t := time.NewTicker(5 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
guard.FlushState()
}
}
}

View File

@@ -0,0 +1,77 @@
{
"listen": "127.0.0.1:9081",
"allowed_hosts": [
"ts.hilden.info",
"sessionguard.hilden.info"
],
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
"reload_seconds": 15,
"max_uri_length": 8192,
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
},
"per_ip_limit": {
"rate_per_second": 200,
"burst": 500
},
"rules": [
{
"name": "guac-access-login",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/login",
"rate_per_second": 5,
"burst": 100
},
{
"name": "guac-access-callback",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/oidc/callback",
"rate_per_second": 10,
"burst": 100
},
{
"name": "sessionguard-oidc",
"host": "sessionguard.hilden.info",
"path_prefix": "/oidc/",
"rate_per_second": 5,
"burst": 50
}
],
"blocked_methods": [
"CONNECT",
"TRACE",
"TRACK"
],
"scanner_path_prefixes": [
"/.env",
"/.git",
"/.svn",
"/.hg",
"/wp-admin",
"/wp-login.php",
"/phpmyadmin",
"/pma",
"/cgi-bin",
"/server-status",
"/actuator",
"/vendor/phpunit",
"/boaform",
"/HNAP1",
"/solr/",
"/jenkins/"
],
"auto_ban": {
"enabled": true,
"threshold": 10,
"window_seconds": 120,
"ban_seconds": 900,
"scanner_weight": 5,
"method_weight": 3,
"rate_limit_weight": 0,
"invalid_uri_weight": 3
},
"max_tracked_ips": 100000
}

View File

@@ -0,0 +1,26 @@
# Public VPS / edge
NETBIRD_VERSION=latest
CADDY_VERSION=2.11.4
EDGEGUARD_VERSION=0.5.2
NETBIRD_PEER_NAME=public-proxy
NETBIRD_MANAGEMENT_URL=https://netbird.example.org
NETBIRD_SETUP_KEY=REPLACE_ME
NETBIRD_LOG_LEVEL=info
# Public DNS names. Both A/AAAA records point to this VPS.
GUAC_HOST=ts.hilden.info
SESSIONGUARD_HOST=sessionguard.hilden.info
ACME_EMAIL=admin@example.org
# Fixed private Docker addresses advertised through NetBird Networks.
GUAC01_IP=10.201.1.10
GUAC02_IP=10.201.2.10
GUAC03_IP=10.201.3.10
SESSIONGUARD_IP=10.202.0.10
# Generate e.g. with: openssl rand -hex 32
GUAC_LB_SECRET=REPLACE_WITH_RANDOM_SECRET
# Base image name built by this repository's release workflow.
# If your repository name differs, adjust accordingly.
EDGEGUARD_IMAGE=git.send.nrw/sendnrw/sessionguard-edgeguard

View File

@@ -0,0 +1,133 @@
{
email {$ACME_EMAIL}
admin off
# Reduce protocol/parser attack surface without interfering with Guacamole
# WebSockets. HTTP/3 can be enabled later if there is a concrete need.
servers {
protocols h1 h2
strict_sni_host on
max_header_size 64KB
timeouts {
read_header 10s
}
}
}
(edge_security_headers) {
header {
-Server
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
}
}
(edgeguard_check) {
# EdgeGuard only listens on 127.0.0.1. These headers are overwritten by
# Caddy and therefore cannot be forged by an Internet client.
forward_auth 127.0.0.1:9081 {
uri /check
header_up X-Edge-Client-IP {client_ip}
header_up X-Edge-Original-Host {host}
header_up X-Edge-Original-Method {method}
header_up X-Edge-Original-URI {uri}
}
}
# Guacamole public endpoint + SessionGuard Access Auth
{$GUAC_HOST} {
encode zstd gzip
import edge_security_headers
route {
# Security pre-check occurs before OIDC/Auth and before any backend.
import edgeguard_check
# These endpoints belong to SessionGuard, but intentionally live on
# the Guacamole hostname so the Access-Auth cookie remains host-bound.
handle_path /_sessionguard/* {
reverse_proxy {$SESSIONGUARD_IP}:8080
}
handle {
route {
# Never trust identity headers supplied by an Internet client.
request_header -X-Guacamole-User
request_header -X-SessionGuard-User
request_header -X-SessionGuard-Email
request_header -X-SessionGuard-Groups
request_header -X-Forwarded-User
request_header -X-Authenticated-User
# SessionGuard is the single OIDC/ForwardAuth authority.
forward_auth {$SESSIONGUARD_IP}:8080 {
uri /auth/verify
copy_headers {
X-Guacamole-User
X-SessionGuard-User
X-SessionGuard-Email
X-SessionGuard-Groups
}
}
# Sticky sessions are important because Guacamole keeps runtime
# authentication/session state in the selected webapp process.
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
lb_policy cookie guac_node {$GUAC_LB_SECRET}
lb_try_duration 5s
lb_try_interval 250ms
health_uri /
health_interval 10s
health_timeout 3s
}
}
}
}
log {
output file /var/log/caddy/guacamole-access.log {
roll_size 100MiB
roll_keep 5
roll_keep_for 168h
}
format json
sampling {
interval 1s
first 200
thereafter 20
}
}
}
# SessionGuard administration UI, Agent endpoint and APIs.
{$SESSIONGUARD_HOST} {
encode zstd gzip
import edge_security_headers
route {
import edgeguard_check
# These endpoints are required internally only. Broker requests from
# Guacamole workers go directly over NetBird, never via public Caddy.
respond /metrics 404
respond /api/v1/broker/* 404
reverse_proxy {$SESSIONGUARD_IP}:8080
}
log {
output file /var/log/caddy/sessionguard-access.log {
roll_size 100MiB
roll_keep 5
roll_keep_for 168h
}
format json
sampling {
interval 1s
first 200
thereafter 20
}
}
}

View File

@@ -0,0 +1,74 @@
# SessionGuard Public VPS: Caddy + NetBird + EdgeGuard
This stack keeps public TLS termination and Guacamole load balancing on Caddy,
uses NetBird only as the encrypted backend transport, and inserts SessionGuard
EdgeGuard as a localhost-only request pre-check.
## Data path
```text
Internet
-> Caddy :443 (TLS, SNI, security headers, sticky load balancing)
-> EdgeGuard 127.0.0.1:9081 /check
-> SessionGuard Access Auth over NetBird
-> Guacamole worker over NetBird
```
EdgeGuard is not in the Guacamole tunnel after the WebSocket connection has
been established. It evaluates normal HTTP requests and the WebSocket handshake.
## What EdgeGuard enforces
- static IPv4/IPv6 IP/CIDR blacklist;
- global request rate limit to protect backends during distributed HTTP floods;
- high per-IP request rate limit (NAT-friendly defaults);
- tighter, configurable limits for OIDC/login/callback paths;
- scanner-path blocking (`/.env`, `/.git`, WordPress/phpMyAdmin probes, etc.);
- blocks CONNECT/TRACE/TRACK;
- temporary persistent bans for clearly hostile scanner/method behavior;
- host allowlist and URI-length validation;
- localhost-only health and Prometheus-style metrics endpoints;
- automatic config/list reload (15 seconds by default);
- bounded per-IP state (100,000 entries by default) to avoid memory exhaustion from rotating source addresses.
The default `rate_limit_weight` for auto-ban is zero on purpose: legitimate
users behind a shared NAT should receive 429 during extreme bursts, but should
not cause the whole NAT address to be banned. Scanner probes are weighted much
more strongly and are auto-banned after repeated hits.
## Caddy hardening
The supplied Caddyfile additionally enables:
- strict SNI/Host matching;
- 10 second request-header timeout;
- 64 KiB maximum request headers;
- HTTP/1.1 + HTTP/2 only (HTTP/3 disabled to reduce exposed protocol surface);
- HSTS and conservative security headers;
- public blocking of SessionGuard `/metrics` and `/api/v1/broker/*`;
- rotated JSON access logs with sampling during request floods;
- Caddy admin API disabled (`admin off`); configuration changes use a container restart.
## Installation
1. Copy `.env.example` to `.env` and set all values.
2. Adjust `edgeguard.json` host names if needed.
3. Add permanent abusive IPs/CIDRs to `blacklist.txt`.
4. Keep `rate-exempt.txt` empty unless you have a known large NAT that really
needs exemption from per-IP limits.
5. Start with `docker compose up -d`.
6. Verify:
```bash
curl -fsS http://127.0.0.1:9081/healthz -o /dev/null
curl -fsS http://127.0.0.1:9081/metrics
curl -I https://ts.hilden.info/
```
## Important DDoS boundary
EdgeGuard protects the application/backends against HTTP request floods and
common low-cost scanners. It cannot protect a single VPS if the Internet link
or provider edge is saturated. Keep the VPS provider's network firewall and
DDoS protection enabled. For a volumetric attack, filtering must happen before
traffic reaches the VPS.

View File

@@ -0,0 +1,7 @@
# Static IP/CIDR blacklist. One IPv4/IPv6 address or CIDR per line.
# Changes are picked up automatically (default: within 15 seconds).
#
# Examples:
# 203.0.113.44
# 198.51.100.0/24
# 2001:db8:1234::/48

View File

@@ -0,0 +1,81 @@
services:
netbird:
image: netbirdio/netbird:${NETBIRD_VERSION:-latest}
container_name: netbird-public-proxy
hostname: ${NETBIRD_PEER_NAME:-public-proxy}
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
- SYS_ADMIN
- SYS_RESOURCE
devices:
- /dev/net/tun:/dev/net/tun
environment:
NB_SETUP_KEY: ${NETBIRD_SETUP_KEY:?NETBIRD_SETUP_KEY is required}
NB_MANAGEMENT_URL: ${NETBIRD_MANAGEMENT_URL:?NETBIRD_MANAGEMENT_URL is required}
NB_LOG_LEVEL: ${NETBIRD_LOG_LEVEL:-info}
volumes:
- netbird-client:/var/lib/netbird
edgeguard:
image: ${EDGEGUARD_IMAGE:?EDGEGUARD_IMAGE is required}:${EDGEGUARD_VERSION:-latest}
container_name: sessionguard-edgeguard
restart: unless-stopped
network_mode: host
depends_on:
- netbird
command:
- -config
- /etc/sessionguard-edgeguard/edgeguard.json
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
volumes:
- ./edgeguard.json:/etc/sessionguard-edgeguard/edgeguard.json:ro
- ./blacklist.txt:/etc/sessionguard-edgeguard/blacklist.txt:ro
- ./rate-exempt.txt:/etc/sessionguard-edgeguard/rate-exempt.txt:ro
- edgeguard-state:/var/lib/sessionguard-edgeguard
caddy:
image: caddy:${CADDY_VERSION:-2.11.4}
container_name: caddy-public
restart: unless-stopped
network_mode: host
depends_on:
- netbird
- edgeguard
environment:
ACME_EMAIL: ${ACME_EMAIL:?ACME_EMAIL is required}
GUAC_HOST: ${GUAC_HOST:-ts.hilden.info}
SESSIONGUARD_HOST: ${SESSIONGUARD_HOST:-sessionguard.hilden.info}
GUAC01_IP: ${GUAC01_IP:?GUAC01_IP is required}
GUAC02_IP: ${GUAC02_IP:?GUAC02_IP is required}
GUAC03_IP: ${GUAC03_IP:?GUAC03_IP is required}
SESSIONGUARD_IP: ${SESSIONGUARD_IP:?SESSIONGUARD_IP is required}
GUAC_LB_SECRET: ${GUAC_LB_SECRET:?GUAC_LB_SECRET is required}
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64m,noexec,nosuid,nodev
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
- caddy-logs:/var/log/caddy
volumes:
netbird-client:
edgeguard-state:
caddy-data:
caddy-config:
caddy-logs:

View File

@@ -0,0 +1,77 @@
{
"listen": "127.0.0.1:9081",
"allowed_hosts": [
"ts.hilden.info",
"sessionguard.hilden.info"
],
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
"reload_seconds": 15,
"max_uri_length": 8192,
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
},
"per_ip_limit": {
"rate_per_second": 200,
"burst": 500
},
"rules": [
{
"name": "guac-access-login",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/login",
"rate_per_second": 5,
"burst": 100
},
{
"name": "guac-access-callback",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/oidc/callback",
"rate_per_second": 10,
"burst": 100
},
{
"name": "sessionguard-oidc",
"host": "sessionguard.hilden.info",
"path_prefix": "/oidc/",
"rate_per_second": 5,
"burst": 50
}
],
"blocked_methods": [
"CONNECT",
"TRACE",
"TRACK"
],
"scanner_path_prefixes": [
"/.env",
"/.git",
"/.svn",
"/.hg",
"/wp-admin",
"/wp-login.php",
"/phpmyadmin",
"/pma",
"/cgi-bin",
"/server-status",
"/actuator",
"/vendor/phpunit",
"/boaform",
"/HNAP1",
"/solr/",
"/jenkins/"
],
"auto_ban": {
"enabled": true,
"threshold": 10,
"window_seconds": 120,
"ban_seconds": 900,
"scanner_weight": 5,
"method_weight": 3,
"rate_limit_weight": 0,
"invalid_uri_weight": 3
},
"max_tracked_ips": 100000
}

View File

@@ -0,0 +1,6 @@
# Optional CIDRs which bypass per-IP and endpoint rate limits.
# They are NOT exempt from the static blacklist, host/method/scanner checks,
# or the global overload limit.
#
# Use this only for known NAT gateways/monitoring systems if necessary.
# 192.0.2.10

181
docs/EDGE-SECURITY.md Normal file
View File

@@ -0,0 +1,181 @@
# 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
```text
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.
```text
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:
```json
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
}
```
### Per-IP limit
The default is deliberately NAT-friendly:
```json
"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:
```caddyfile
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:
```text
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
1. Keep provider/network DDoS protection enabled.
2. Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
3. Keep NetBird peer/backend ports private.
4. Pin Caddy to a currently patched release rather than an old major-only image
during controlled production rollouts.
5. Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
6. Do not put broad office/country CIDRs on the static blacklist without first
checking whether legitimate remote users may originate there.

View File

@@ -5,7 +5,7 @@
<modelVersion>4.0.0</modelVersion>
<groupId>info.hilden.sessionguard</groupId>
<artifactId>sessionguard-guacamole</artifactId>
<version>0.5.0</version>
<version>0.5.2</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.release>11</maven.compiler.release>

View File

@@ -25,7 +25,7 @@ import (
"github.com/example/sessionguard/internal/windowsx"
)
const Version = "0.5.0"
const Version = "0.5.2"
type App struct {
cfg config.Agent

View File

@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
/* SessionGuard UI v0.5.1 — modal-first responsive console */
/* SessionGuard UI v0.5.2 — modal-first responsive console */
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
@@ -68,13 +68,13 @@ body.modal-open{overflow:hidden}
@media(max-width:720px){dialog.sg-modal,dialog.sg-modal.wide,dialog.sg-modal.compact{width:100vw;max-width:none;height:100dvh;max-height:100dvh;border-radius:0;border:0}.modal-shell{max-height:100dvh;height:100dvh}.modal-head{padding:13px 14px}.modal-title{font-size:16px}.modal-body.pad,.detail-pane{padding:12px}.detail-tabs{padding:8px}.form{padding:12px}.form-grid,.form-grid.three,.entity-stats{grid-template-columns:1fr}.entity-grid{grid-template-columns:1fr;padding:10px}.mobile-only{display:inline-flex}.table th,.table td{white-space:nowrap}.section-heading .section-actions{width:100%}.section-heading .section-actions button{flex:1}}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.1</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.2</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount"></div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master"></div></div></div></section>
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
<section class="page-section" id="operations-section"><div class="section-heading"><div><div class="eyebrow">Operations</div><h2>Betriebsdaten</h2><div class="section-copy">RemoteApps, Profil-Pipeline und lokale Ereignisse kompakt in einer Arbeitsfläche.</div></div></div><section class="panel"><div class="detail-tabs" style="position:static"><button class="detail-tab active" type="button" data-agent-tab="remoteapps">RemoteApps</button><button class="detail-tab" type="button" data-agent-tab="profiles">Profil-Pipeline</button><button class="detail-tab" type="button" data-agent-tab="events">Aktivitätslog</button></div><div class="detail-pane active" data-agent-pane="remoteapps"><div id="remoteapps"></div></div><div class="detail-pane" data-agent-pane="profiles"><div id="profiles"></div></div><div class="detail-pane" data-agent-pane="events"><div id="events"></div></div></section></section>
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div><div class="section-actions"><button type="button" class="primary-soft" id="openPolicyModal">⚙ Policy bearbeiten</button></div></div><section class="panel"><div class="form"><div class="note"><strong>Fallback-Verhalten:</strong> Die lokale Policy bleibt als Notfallkonfiguration verfügbar. Eine aktive Master-Soll-Policy hat im zentral verwalteten Betrieb Vorrang.</div><div id="policySummary" class="entity-grid" style="padding:0"></div></div></section></section>
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.1"></script></body></html>`
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.2"></script></body></html>`
const agentJS = `
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;

View File

@@ -0,0 +1,306 @@
package edgeguard
import (
"bufio"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/netip"
"os"
"sort"
"strings"
"time"
)
type RateLimit struct {
RatePerSecond float64 `json:"rate_per_second"`
Burst int `json:"burst"`
}
type RateRule struct {
Name string `json:"name"`
Host string `json:"host,omitempty"`
PathPrefix string `json:"path_prefix"`
RatePerSecond float64 `json:"rate_per_second"`
Burst int `json:"burst"`
}
type AutoBanConfig struct {
Enabled bool `json:"enabled"`
Threshold int `json:"threshold"`
WindowSeconds int `json:"window_seconds"`
BanSeconds int `json:"ban_seconds"`
ScannerWeight int `json:"scanner_weight"`
MethodWeight int `json:"method_weight"`
RateLimitWeight int `json:"rate_limit_weight"`
InvalidURIWeight int `json:"invalid_uri_weight"`
}
type Config struct {
Listen string `json:"listen"`
AllowedHosts []string `json:"allowed_hosts"`
BlacklistFile string `json:"blacklist_file"`
RateExemptFile string `json:"rate_exempt_file"`
StateFile string `json:"state_file"`
ReloadSeconds int `json:"reload_seconds"`
MaxURILength int `json:"max_uri_length"`
MaxTrackedIPs int `json:"max_tracked_ips"`
GlobalLimit RateLimit `json:"global_limit"`
PerIPLimit RateLimit `json:"per_ip_limit"`
Rules []RateRule `json:"rules"`
BlockedMethods []string `json:"blocked_methods"`
ScannerPathPrefixes []string `json:"scanner_path_prefixes"`
AutoBan AutoBanConfig `json:"auto_ban"`
}
type RuntimeConfig struct {
Config
allowedHosts map[string]struct{}
blockedMethods map[string]struct{}
blacklist []netip.Prefix
rateExempt []netip.Prefix
fingerprint string
}
func DefaultConfig() Config {
return Config{
Listen: "127.0.0.1:9081",
ReloadSeconds: 15,
MaxURILength: 8192,
MaxTrackedIPs: 100000,
GlobalLimit: RateLimit{
RatePerSecond: 2500,
Burst: 5000,
},
PerIPLimit: RateLimit{
RatePerSecond: 200,
Burst: 500,
},
Rules: []RateRule{
{Name: "access-login", PathPrefix: "/_sessionguard/auth/login", RatePerSecond: 5, Burst: 100},
{Name: "access-callback", PathPrefix: "/_sessionguard/auth/oidc/callback", RatePerSecond: 10, Burst: 100},
{Name: "admin-oidc", PathPrefix: "/oidc/", RatePerSecond: 5, Burst: 50},
},
BlockedMethods: []string{"CONNECT", "TRACE", "TRACK"},
ScannerPathPrefixes: []string{
"/.env", "/.git", "/.svn", "/.hg", "/wp-admin", "/wp-login.php",
"/phpmyadmin", "/pma", "/cgi-bin", "/server-status", "/actuator",
"/vendor/phpunit", "/boaform", "/HNAP1", "/solr/", "/jenkins/",
},
AutoBan: AutoBanConfig{
Enabled: true,
Threshold: 10,
WindowSeconds: 120,
BanSeconds: 900,
ScannerWeight: 5,
MethodWeight: 3,
RateLimitWeight: 0,
InvalidURIWeight: 3,
},
}
}
func LoadRuntimeConfig(path string) (*RuntimeConfig, error) {
cfg := DefaultConfig()
b, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config: %w", err)
}
if err := json.Unmarshal(b, &cfg); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
if err := validateConfig(&cfg); err != nil {
return nil, err
}
rc := &RuntimeConfig{Config: cfg}
rc.allowedHosts = make(map[string]struct{}, len(cfg.AllowedHosts))
for _, h := range cfg.AllowedHosts {
h = normalizeHost(h)
if h != "" {
rc.allowedHosts[h] = struct{}{}
}
}
rc.blockedMethods = make(map[string]struct{}, len(cfg.BlockedMethods))
for _, m := range cfg.BlockedMethods {
m = strings.ToUpper(strings.TrimSpace(m))
if m != "" {
rc.blockedMethods[m] = struct{}{}
}
}
rc.blacklist, err = loadPrefixFile(cfg.BlacklistFile)
if err != nil {
return nil, fmt.Errorf("load blacklist: %w", err)
}
rc.rateExempt, err = loadPrefixFile(cfg.RateExemptFile)
if err != nil {
return nil, fmt.Errorf("load rate exempt list: %w", err)
}
h := sha256.New()
h.Write(b)
for _, p := range rc.blacklist {
h.Write([]byte("blacklist:" + p.String() + "\n"))
}
for _, p := range rc.rateExempt {
h.Write([]byte("rate-exempt:" + p.String() + "\n"))
}
rc.fingerprint = hex.EncodeToString(h.Sum(nil))
return rc, nil
}
func validateConfig(cfg *Config) error {
if strings.TrimSpace(cfg.Listen) == "" {
return fmt.Errorf("listen must not be empty")
}
if cfg.ReloadSeconds <= 0 {
cfg.ReloadSeconds = 15
}
if cfg.MaxURILength <= 0 {
cfg.MaxURILength = 8192
}
if cfg.MaxTrackedIPs <= 0 {
cfg.MaxTrackedIPs = 100000
}
if err := validateRate("global_limit", cfg.GlobalLimit); err != nil {
return err
}
if err := validateRate("per_ip_limit", cfg.PerIPLimit); err != nil {
return err
}
for i := range cfg.Rules {
r := &cfg.Rules[i]
r.Host = normalizeHost(r.Host)
r.PathPrefix = strings.ToLower(strings.TrimSpace(r.PathPrefix))
if r.PathPrefix == "" {
return fmt.Errorf("rules[%d].path_prefix must not be empty", i)
}
if r.RatePerSecond <= 0 || r.Burst <= 0 {
return fmt.Errorf("rules[%d] must have positive rate_per_second and burst", i)
}
}
if cfg.AutoBan.Enabled {
if cfg.AutoBan.Threshold <= 0 || cfg.AutoBan.WindowSeconds <= 0 || cfg.AutoBan.BanSeconds <= 0 {
return fmt.Errorf("auto_ban threshold/window_seconds/ban_seconds must be positive")
}
}
return nil
}
func validateRate(name string, r RateLimit) error {
if r.RatePerSecond <= 0 || r.Burst <= 0 {
return fmt.Errorf("%s must have positive rate_per_second and burst", name)
}
return nil
}
func loadPrefixFile(path string) ([]netip.Prefix, error) {
path = strings.TrimSpace(path)
if path == "" {
return nil, nil
}
f, err := os.Open(path)
if err != nil {
return nil, err
}
defer f.Close()
var out []netip.Prefix
s := bufio.NewScanner(f)
lineNo := 0
for s.Scan() {
lineNo++
line := strings.TrimSpace(s.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if i := strings.IndexByte(line, '#'); i >= 0 {
line = strings.TrimSpace(line[:i])
}
p, err := parsePrefix(line)
if err != nil {
return nil, fmt.Errorf("%s:%d: %w", path, lineNo, err)
}
out = append(out, p)
}
if err := s.Err(); err != nil {
return nil, err
}
sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() })
return out, nil
}
func parsePrefix(s string) (netip.Prefix, error) {
if p, err := netip.ParsePrefix(s); err == nil {
return p.Masked(), nil
}
a, err := netip.ParseAddr(s)
if err != nil {
return netip.Prefix{}, fmt.Errorf("invalid IP/CIDR %q", s)
}
bits := 128
if a.Is4() {
bits = 32
}
return netip.PrefixFrom(a, bits), nil
}
func normalizeHost(s string) string {
s = strings.TrimSpace(strings.ToLower(s))
if i := strings.IndexByte(s, ':'); i > 0 && strings.Count(s, ":") == 1 {
s = s[:i]
}
return strings.TrimSuffix(s, ".")
}
func (r *RuntimeConfig) IsBlacklisted(ip netip.Addr) bool {
return containsPrefix(r.blacklist, ip)
}
func (r *RuntimeConfig) IsRateExempt(ip netip.Addr) bool {
return containsPrefix(r.rateExempt, ip)
}
func containsPrefix(prefixes []netip.Prefix, ip netip.Addr) bool {
for _, p := range prefixes {
if p.Contains(ip) {
return true
}
}
return false
}
func (r *RuntimeConfig) HostAllowed(host string) bool {
if len(r.allowedHosts) == 0 {
return true
}
_, ok := r.allowedHosts[normalizeHost(host)]
return ok
}
func (r *RuntimeConfig) MethodBlocked(method string) bool {
_, ok := r.blockedMethods[strings.ToUpper(strings.TrimSpace(method))]
return ok
}
func (r *RuntimeConfig) MatchingRule(host, path string) *RateRule {
host = normalizeHost(host)
for i := range r.Rules {
rule := &r.Rules[i]
if rule.Host != "" && rule.Host != host {
continue
}
if strings.HasPrefix(path, rule.PathPrefix) {
return rule
}
}
return nil
}
func (r *RuntimeConfig) ReloadInterval() time.Duration {
return time.Duration(r.ReloadSeconds) * time.Second
}
func (r *RuntimeConfig) Fingerprint() string { return r.fingerprint }

440
internal/edgeguard/guard.go Normal file
View File

@@ -0,0 +1,440 @@
package edgeguard
import (
"encoding/json"
"fmt"
"log/slog"
"net/netip"
"net/url"
"os"
pathpkg "path"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
)
type bucket struct {
tokens float64
last time.Time
}
type offenseState struct {
points int
start time.Time
}
type clientState struct {
bucket bucket
rules map[string]*bucket
offense offenseState
lastSeen time.Time
}
type persistedState struct {
Bans map[string]time.Time `json:"bans"`
}
type Counters struct {
Requests atomic.Uint64
Allowed atomic.Uint64
DeniedBlacklist atomic.Uint64
DeniedBan atomic.Uint64
DeniedRate atomic.Uint64
DeniedGlobal atomic.Uint64
DeniedScanner atomic.Uint64
DeniedMethod atomic.Uint64
DeniedHost atomic.Uint64
DeniedInvalid atomic.Uint64
DeniedCapacity atomic.Uint64
AutoBans atomic.Uint64
}
type Decision struct {
Allowed bool
StatusCode int
Reason string
RetryAfter int
}
type Guard struct {
mu sync.Mutex
cfg atomic.Pointer[RuntimeConfig]
global bucket
clients map[netip.Addr]*clientState
bans map[netip.Addr]time.Time
stateDirty bool
counters Counters
logger *slog.Logger
}
func NewGuard(cfg *RuntimeConfig, logger *slog.Logger) *Guard {
if logger == nil {
logger = slog.Default()
}
g := &Guard{
clients: make(map[netip.Addr]*clientState),
bans: make(map[netip.Addr]time.Time),
logger: logger,
}
g.cfg.Store(cfg)
g.loadState(cfg.StateFile)
return g
}
func (g *Guard) Config() *RuntimeConfig { return g.cfg.Load() }
func (g *Guard) ReplaceConfig(cfg *RuntimeConfig) {
old := g.cfg.Swap(cfg)
if old == nil || old.Fingerprint() != cfg.Fingerprint() {
g.logger.Info("edgeguard configuration loaded", "fingerprint", cfg.Fingerprint()[:12])
}
}
func (g *Guard) Check(ip netip.Addr, host, method, rawURI string, now time.Time) Decision {
g.counters.Requests.Add(1)
cfg := g.cfg.Load()
if cfg == nil {
return Decision{StatusCode: 503, Reason: "configuration unavailable"}
}
if !ip.IsValid() {
g.counters.DeniedInvalid.Add(1)
return Decision{StatusCode: 400, Reason: "invalid client ip"}
}
if len(rawURI) == 0 || len(rawURI) > cfg.MaxURILength {
g.counters.DeniedInvalid.Add(1)
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
return Decision{StatusCode: 414, Reason: "invalid uri"}
}
path, ok := normalizeRequestPath(rawURI)
if !ok {
g.counters.DeniedInvalid.Add(1)
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
return Decision{StatusCode: 400, Reason: "invalid uri"}
}
if !cfg.HostAllowed(host) {
g.counters.DeniedHost.Add(1)
return Decision{StatusCode: 421, Reason: "host not allowed"}
}
if cfg.IsBlacklisted(ip) {
g.counters.DeniedBlacklist.Add(1)
return Decision{StatusCode: 403, Reason: "blacklisted"}
}
g.mu.Lock()
if until, ok := g.bans[ip]; ok {
if now.Before(until) {
g.mu.Unlock()
g.counters.DeniedBan.Add(1)
return Decision{StatusCode: 403, Reason: "temporarily banned", RetryAfter: max(1, int(until.Sub(now).Seconds()))}
}
delete(g.bans, ip)
g.stateDirty = true
}
g.mu.Unlock()
if cfg.MethodBlocked(method) {
g.counters.DeniedMethod.Add(1)
g.addOffense(ip, cfg.AutoBan.MethodWeight, now, cfg)
return Decision{StatusCode: 405, Reason: "method blocked"}
}
if scannerPath(path, cfg.ScannerPathPrefixes) {
g.counters.DeniedScanner.Add(1)
g.addOffense(ip, cfg.AutoBan.ScannerWeight, now, cfg)
return Decision{StatusCode: 404, Reason: "scanner path"}
}
g.mu.Lock()
defer g.mu.Unlock()
if !take(&g.global, cfg.GlobalLimit.RatePerSecond, cfg.GlobalLimit.Burst, now) {
g.counters.DeniedGlobal.Add(1)
return Decision{StatusCode: 429, Reason: "global rate limit", RetryAfter: 1}
}
cs := g.clients[ip]
if cs == nil {
if len(g.clients) >= cfg.MaxTrackedIPs {
g.counters.DeniedCapacity.Add(1)
return Decision{StatusCode: 429, Reason: "edge state capacity", RetryAfter: 1}
}
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
g.clients[ip] = cs
}
cs.lastSeen = now
if !cfg.IsRateExempt(ip) {
if !take(&cs.bucket, cfg.PerIPLimit.RatePerSecond, cfg.PerIPLimit.Burst, now) {
g.counters.DeniedRate.Add(1)
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
return Decision{StatusCode: 429, Reason: "per-ip rate limit", RetryAfter: 1}
}
if rule := cfg.MatchingRule(host, path); rule != nil {
rb := cs.rules[rule.Name]
if rb == nil {
rb = &bucket{}
cs.rules[rule.Name] = rb
}
if !take(rb, rule.RatePerSecond, rule.Burst, now) {
g.counters.DeniedRate.Add(1)
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
return Decision{StatusCode: 429, Reason: "endpoint rate limit", RetryAfter: 1}
}
}
}
g.counters.Allowed.Add(1)
return Decision{Allowed: true, StatusCode: 204, Reason: "allow"}
}
func take(b *bucket, rate float64, burst int, now time.Time) bool {
if b.last.IsZero() {
b.tokens = float64(burst)
b.last = now
}
elapsed := now.Sub(b.last).Seconds()
if elapsed > 0 {
b.tokens += elapsed * rate
if b.tokens > float64(burst) {
b.tokens = float64(burst)
}
b.last = now
}
if b.tokens < 1 {
return false
}
b.tokens--
return true
}
func normalizeRequestPath(rawURI string) (string, bool) {
u, err := url.ParseRequestURI(rawURI)
if err != nil {
return "", false
}
p := u.EscapedPath()
if p == "" {
p = "/"
}
decoded, err := url.PathUnescape(p)
if err != nil {
return "", false
}
decoded = strings.ReplaceAll(decoded, "\\", "/")
decoded = pathpkg.Clean(decoded)
if !strings.HasPrefix(decoded, "/") {
decoded = "/" + decoded
}
return strings.ToLower(decoded), true
}
func scannerPath(path string, prefixes []string) bool {
for _, prefix := range prefixes {
prefix = strings.ToLower(strings.TrimSpace(prefix))
if prefix != "" && strings.HasPrefix(path, prefix) {
return true
}
}
return false
}
func (g *Guard) addOffense(ip netip.Addr, weight int, now time.Time, cfg *RuntimeConfig) {
if weight <= 0 || !cfg.AutoBan.Enabled {
return
}
g.mu.Lock()
defer g.mu.Unlock()
cs := g.clients[ip]
if cs == nil {
if len(g.clients) >= cfg.MaxTrackedIPs {
return
}
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
g.clients[ip] = cs
}
g.addOffenseLocked(ip, cs, weight, now, cfg)
}
func (g *Guard) addOffenseLocked(ip netip.Addr, cs *clientState, weight int, now time.Time, cfg *RuntimeConfig) {
if weight <= 0 || !cfg.AutoBan.Enabled {
return
}
window := time.Duration(cfg.AutoBan.WindowSeconds) * time.Second
if cs.offense.start.IsZero() || now.Sub(cs.offense.start) > window {
cs.offense = offenseState{start: now}
}
cs.offense.points += weight
cs.lastSeen = now
if cs.offense.points < cfg.AutoBan.Threshold {
return
}
until := now.Add(time.Duration(cfg.AutoBan.BanSeconds) * time.Second)
g.bans[ip] = until
cs.offense = offenseState{}
g.counters.AutoBans.Add(1)
g.logger.Warn("temporary IP ban", "ip", ip.String(), "until", until.UTC().Format(time.RFC3339))
g.stateDirty = true
}
func (g *Guard) Cleanup(now time.Time) {
cfg := g.cfg.Load()
if cfg == nil {
return
}
g.mu.Lock()
defer g.mu.Unlock()
for ip, until := range g.bans {
if !now.Before(until) {
delete(g.bans, ip)
g.stateDirty = true
}
}
stale := now.Add(-30 * time.Minute)
for ip, cs := range g.clients {
if cs.lastSeen.Before(stale) {
delete(g.clients, ip)
}
}
}
func (g *Guard) ActiveBans(now time.Time) int {
g.mu.Lock()
defer g.mu.Unlock()
n := 0
for _, until := range g.bans {
if now.Before(until) {
n++
}
}
return n
}
func (g *Guard) loadState(path string) {
if strings.TrimSpace(path) == "" {
return
}
b, err := os.ReadFile(path)
if err != nil {
if !os.IsNotExist(err) {
g.logger.Warn("read edgeguard state", "error", err)
}
return
}
var state persistedState
if err := json.Unmarshal(b, &state); err != nil {
g.logger.Warn("decode edgeguard state", "error", err)
return
}
now := time.Now()
for raw, until := range state.Bans {
ip, err := netip.ParseAddr(raw)
if err == nil && now.Before(until) {
g.bans[ip] = until
}
}
}
func (g *Guard) FlushState() {
cfg := g.cfg.Load()
if cfg == nil || strings.TrimSpace(cfg.StateFile) == "" {
return
}
g.mu.Lock()
if !g.stateDirty {
g.mu.Unlock()
return
}
state := persistedState{Bans: make(map[string]time.Time)}
now := time.Now()
for ip, until := range g.bans {
if now.Before(until) {
state.Bans[ip.String()] = until
}
}
g.stateDirty = false
g.mu.Unlock()
if err := writeState(cfg.StateFile, state); err != nil {
g.logger.Error("persist edgeguard state", "error", err)
g.mu.Lock()
g.stateDirty = true
g.mu.Unlock()
}
}
func writeState(path string, state persistedState) error {
b, err := json.MarshalIndent(state, "", " ")
if err != nil {
return fmt.Errorf("encode: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("mkdir: %w", err)
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o600); err != nil {
return fmt.Errorf("write: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
return fmt.Errorf("rename: %w", err)
}
return nil
}
func (g *Guard) Metrics() string {
return fmt.Sprintf(`# HELP sessionguard_edgeguard_requests_total Requests evaluated by EdgeGuard.
# TYPE sessionguard_edgeguard_requests_total counter
sessionguard_edgeguard_requests_total %d
# HELP sessionguard_edgeguard_allowed_total Requests allowed by EdgeGuard.
# TYPE sessionguard_edgeguard_allowed_total counter
sessionguard_edgeguard_allowed_total %d
# HELP sessionguard_edgeguard_denied_blacklist_total Requests denied by static blacklist.
# TYPE sessionguard_edgeguard_denied_blacklist_total counter
sessionguard_edgeguard_denied_blacklist_total %d
# HELP sessionguard_edgeguard_denied_ban_total Requests denied by temporary auto-ban.
# TYPE sessionguard_edgeguard_denied_ban_total counter
sessionguard_edgeguard_denied_ban_total %d
# HELP sessionguard_edgeguard_denied_rate_total Requests denied by per-IP or endpoint rate limits.
# TYPE sessionguard_edgeguard_denied_rate_total counter
sessionguard_edgeguard_denied_rate_total %d
# HELP sessionguard_edgeguard_denied_global_total Requests denied by the global rate limit.
# TYPE sessionguard_edgeguard_denied_global_total counter
sessionguard_edgeguard_denied_global_total %d
# HELP sessionguard_edgeguard_denied_scanner_total Requests denied as scanner paths.
# TYPE sessionguard_edgeguard_denied_scanner_total counter
sessionguard_edgeguard_denied_scanner_total %d
# HELP sessionguard_edgeguard_denied_method_total Requests denied by HTTP method policy.
# TYPE sessionguard_edgeguard_denied_method_total counter
sessionguard_edgeguard_denied_method_total %d
# HELP sessionguard_edgeguard_denied_host_total Requests denied because the public host is not allowed.
# TYPE sessionguard_edgeguard_denied_host_total counter
sessionguard_edgeguard_denied_host_total %d
# HELP sessionguard_edgeguard_denied_invalid_total Requests denied because the client IP or URI was invalid.
# TYPE sessionguard_edgeguard_denied_invalid_total counter
sessionguard_edgeguard_denied_invalid_total %d
# HELP sessionguard_edgeguard_denied_capacity_total Requests denied because the bounded per-IP state table is full.
# TYPE sessionguard_edgeguard_denied_capacity_total counter
sessionguard_edgeguard_denied_capacity_total %d
# HELP sessionguard_edgeguard_autobans_total Temporary bans created.
# TYPE sessionguard_edgeguard_autobans_total counter
sessionguard_edgeguard_autobans_total %d
# HELP sessionguard_edgeguard_active_bans Current temporary bans.
# TYPE sessionguard_edgeguard_active_bans gauge
sessionguard_edgeguard_active_bans %d
`,
g.counters.Requests.Load(),
g.counters.Allowed.Load(),
g.counters.DeniedBlacklist.Load(),
g.counters.DeniedBan.Load(),
g.counters.DeniedRate.Load(),
g.counters.DeniedGlobal.Load(),
g.counters.DeniedScanner.Load(),
g.counters.DeniedMethod.Load(),
g.counters.DeniedHost.Load(),
g.counters.DeniedInvalid.Load(),
g.counters.DeniedCapacity.Load(),
g.counters.AutoBans.Load(),
g.ActiveBans(time.Now()),
)
}

View File

@@ -0,0 +1,217 @@
package edgeguard
import (
"encoding/json"
"log/slog"
"net/netip"
"os"
"path/filepath"
"testing"
"time"
)
func testConfig(t *testing.T, mutate func(*Config)) *RuntimeConfig {
t.Helper()
dir := t.TempDir()
cfg := DefaultConfig()
cfg.Listen = "127.0.0.1:0"
cfg.BlacklistFile = filepath.Join(dir, "blacklist.txt")
cfg.RateExemptFile = filepath.Join(dir, "rate-exempt.txt")
if err := os.WriteFile(cfg.BlacklistFile, nil, 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(cfg.RateExemptFile, nil, 0o600); err != nil {
t.Fatal(err)
}
cfg.StateFile = filepath.Join(dir, "state.json")
cfg.AllowedHosts = []string{"ts.example.test", "sessionguard.example.test"}
if mutate != nil {
mutate(&cfg)
}
b, err := jsonMarshal(cfg)
if err != nil {
t.Fatal(err)
}
path := filepath.Join(dir, "edgeguard.json")
if err := os.WriteFile(path, b, 0o600); err != nil {
t.Fatal(err)
}
rc, err := LoadRuntimeConfig(path)
if err != nil {
t.Fatal(err)
}
return rc
}
func jsonMarshal(v any) ([]byte, error) {
return json.MarshalIndent(v, "", " ")
}
func newTestGuard(cfg *RuntimeConfig) *Guard {
return NewGuard(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
}
func TestAllowsNormalRequest(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
d := g.Check(netip.MustParseAddr("203.0.113.10"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
if !d.Allowed || d.StatusCode != 204 {
t.Fatalf("unexpected decision: %+v", d)
}
}
func TestBlacklistCIDR(t *testing.T) {
dir := t.TempDir()
blacklist := filepath.Join(dir, "blacklist.txt")
if err := os.WriteFile(blacklist, []byte("203.0.113.0/24\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.AllowedHosts = []string{"ts.example.test"}
cfg.BlacklistFile = blacklist
cfg.RateExemptFile = ""
cfg.StateFile = filepath.Join(dir, "state.json")
b, _ := json.Marshal(cfg)
configPath := filepath.Join(dir, "edgeguard.json")
_ = os.WriteFile(configPath, b, 0o600)
rc, err := LoadRuntimeConfig(configPath)
if err != nil {
t.Fatal(err)
}
g := newTestGuard(rc)
d := g.Check(netip.MustParseAddr("203.0.113.99"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
if d.StatusCode != 403 || d.Reason != "blacklisted" {
t.Fatalf("unexpected decision: %+v", d)
}
}
func TestScannerTriggersAutoBan(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.AutoBan.Threshold = 10
c.AutoBan.ScannerWeight = 5
})
g := newTestGuard(cfg)
ip := netip.MustParseAddr("198.51.100.7")
now := time.Unix(1000, 0)
for i := 0; i < 2; i++ {
d := g.Check(ip, "ts.example.test", "GET", "/.env", now.Add(time.Duration(i)*time.Second))
if d.StatusCode != 404 {
t.Fatalf("scanner request %d: %+v", i, d)
}
}
d := g.Check(ip, "ts.example.test", "GET", "/", now.Add(3*time.Second))
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
t.Fatalf("expected temp ban, got %+v", d)
}
}
func TestPerIPRateLimit(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 2}
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.AutoBan.Enabled = false
c.Rules = nil
})
g := newTestGuard(cfg)
ip := netip.MustParseAddr("198.51.100.8")
now := time.Unix(1000, 0)
for i := 0; i < 2; i++ {
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("request %d should be allowed: %+v", i, d)
}
}
d := g.Check(ip, "ts.example.test", "GET", "/", now)
if d.StatusCode != 429 {
t.Fatalf("expected 429, got %+v", d)
}
}
func TestRateExemptCIDR(t *testing.T) {
dir := t.TempDir()
exempt := filepath.Join(dir, "exempt.txt")
if err := os.WriteFile(exempt, []byte("198.51.100.0/24\n"), 0o600); err != nil {
t.Fatal(err)
}
cfg := DefaultConfig()
cfg.AllowedHosts = []string{"ts.example.test"}
cfg.BlacklistFile = ""
cfg.RateExemptFile = exempt
cfg.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 1}
cfg.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
cfg.Rules = nil
cfg.StateFile = filepath.Join(dir, "state.json")
b, _ := json.Marshal(cfg)
configPath := filepath.Join(dir, "edgeguard.json")
_ = os.WriteFile(configPath, b, 0o600)
rc, err := LoadRuntimeConfig(configPath)
if err != nil {
t.Fatal(err)
}
g := newTestGuard(rc)
ip := netip.MustParseAddr("198.51.100.42")
now := time.Unix(1000, 0)
for i := 0; i < 20; i++ {
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("request %d unexpectedly denied: %+v", i, d)
}
}
}
func TestBlockedMethodAndHost(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
ip := netip.MustParseAddr("192.0.2.10")
now := time.Unix(1000, 0)
if d := g.Check(ip, "unknown.example.test", "GET", "/", now); d.StatusCode != 421 {
t.Fatalf("expected 421, got %+v", d)
}
if d := g.Check(ip, "ts.example.test", "TRACE", "/", now); d.StatusCode != 405 {
t.Fatalf("expected 405, got %+v", d)
}
}
func TestEncodedScannerPath(t *testing.T) {
g := newTestGuard(testConfig(t, nil))
d := g.Check(netip.MustParseAddr("192.0.2.20"), "ts.example.test", "GET", "/.%65nv", time.Unix(1000, 0))
if d.StatusCode != 404 {
t.Fatalf("expected encoded /.env to be denied, got %+v", d)
}
}
func TestTemporaryBanPersistsAcrossRestart(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.AutoBan.Threshold = 5
c.AutoBan.ScannerWeight = 5
})
ip := netip.MustParseAddr("203.0.113.77")
now := time.Now()
g1 := newTestGuard(cfg)
if d := g1.Check(ip, "ts.example.test", "GET", "/.env", now); d.StatusCode != 404 {
t.Fatalf("expected scanner deny, got %+v", d)
}
g1.FlushState()
g2 := newTestGuard(cfg)
d := g2.Check(ip, "ts.example.test", "GET", "/", now.Add(time.Second))
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
t.Fatalf("expected persisted temporary ban, got %+v", d)
}
}
func TestTrackedIPCapacityIsBounded(t *testing.T) {
cfg := testConfig(t, func(c *Config) {
c.MaxTrackedIPs = 2
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.PerIPLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
c.Rules = nil
c.AutoBan.Enabled = false
})
g := newTestGuard(cfg)
now := time.Unix(1000, 0)
for _, raw := range []string{"192.0.2.1", "192.0.2.2"} {
if d := g.Check(netip.MustParseAddr(raw), "ts.example.test", "GET", "/", now); !d.Allowed {
t.Fatalf("first two IPs should be tracked: %s %+v", raw, d)
}
}
d := g.Check(netip.MustParseAddr("192.0.2.3"), "ts.example.test", "GET", "/", now)
if d.StatusCode != 429 || d.Reason != "edge state capacity" {
t.Fatalf("expected bounded-state 429, got %+v", d)
}
}

View File

@@ -0,0 +1,80 @@
package edgeguard
import (
"encoding/json"
"log/slog"
"net/http"
"net/netip"
"strconv"
"strings"
"time"
)
type HTTPServer struct {
guard *Guard
logger *slog.Logger
}
func NewHTTPServer(g *Guard, logger *slog.Logger) *HTTPServer {
if logger == nil {
logger = slog.Default()
}
return &HTTPServer{guard: g, logger: logger}
}
func (s *HTTPServer) Handler() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", s.healthz)
mux.HandleFunc("GET /metrics", s.metrics)
mux.HandleFunc("GET /check", s.check)
return mux
}
func (s *HTTPServer) healthz(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
}
func (s *HTTPServer) metrics(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
_, _ = w.Write([]byte(s.guard.Metrics()))
}
func (s *HTTPServer) check(w http.ResponseWriter, r *http.Request) {
rawIP := strings.TrimSpace(r.Header.Get("X-Edge-Client-IP"))
ip, err := netip.ParseAddr(rawIP)
if err != nil {
s.writeDeny(w, http.StatusBadRequest, "invalid client ip", 0)
return
}
host := r.Header.Get("X-Edge-Original-Host")
method := r.Header.Get("X-Edge-Original-Method")
uri := r.Header.Get("X-Edge-Original-URI")
decision := s.guard.Check(ip.Unmap(), host, method, uri, time.Now())
if decision.Allowed {
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusNoContent)
return
}
// Do not log every denial here: during an attack that would turn logging
// itself into a resource-exhaustion vector. Caddy access logs are sampled
// and EdgeGuard exposes counters; only state changes such as auto-bans are
// emitted as runtime log events.
s.writeDeny(w, decision.StatusCode, decision.Reason, decision.RetryAfter)
}
func (s *HTTPServer) writeDeny(w http.ResponseWriter, code int, reason string, retryAfter int) {
w.Header().Set("Cache-Control", "no-store")
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("X-Content-Type-Options", "nosniff")
if retryAfter > 0 {
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
}
w.WriteHeader(code)
_ = json.NewEncoder(w).Encode(map[string]any{
"error": http.StatusText(code),
"reason": reason,
})
}

View File

@@ -23,7 +23,7 @@ import (
"github.com/example/sessionguard/internal/model"
)
const Version = "0.5.0"
const Version = "0.5.2"
type App struct {
cfg config.Master

View File

@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
@media(max-width:720px){.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
/* SessionGuard UI v0.5.1 — modal-first responsive console */
/* SessionGuard UI v0.5.2 — modal-first responsive console */
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
@@ -69,7 +69,7 @@ body.modal-open{overflow:hidden}
</style></head><body>
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell">
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.1</div></div></div>
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.2</div></div></div>
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a><a class="nav-link" href="#access-section"><span class="nav-icon">◎</span>Access-Sessions</a></nav>
<nav class="nav-group"><div class="nav-label">Betrieb</div><a class="nav-link" href="#history-section"><span class="nav-icon">↻</span>Session-Historie</a><a class="nav-link" href="#policies-section"><span class="nav-icon">≋</span>Policy-Historie</a><a class="nav-link" href="#alerts-section"><span class="nav-icon">!</span>Alerts</a><a class="nav-link" href="#audit-section"><span class="nav-icon">✓</span>Audit-Log</a></nav>
<div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
@@ -88,7 +88,7 @@ body.modal-open{overflow:hidden}
<dialog class="sg-modal wide" id="agentModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="detailTitle">Terminalserver</div><div class="modal-sub">Live-Daten, Sitzungen und Richtlinien</div></div><button type="button" class="modal-close icon-btn" data-close-modal="agentModal" aria-label="Schließen">×</button></div><div class="modal-body"><div id="detail" class="empty">Server wird geladen…</div></div></div></dialog>
<dialog class="sg-modal compact" id="farmModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Neue Farm</div><div class="modal-sub">Broker-Ziel und optionale Tag-Selektion definieren</div></div><button type="button" class="modal-close icon-btn" data-close-modal="farmModal">×</button></div><div class="modal-body pad"><div id="farmEditor" class="form"><div class="form-grid"><label>Name<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc" placeholder="Standard Desktop-Farm"></label></div><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label><div class="actions end"><button type="button" class="secondary" data-close-modal="farmModal">Abbrechen</button><button type="button" data-global-action="create-farm">Farm anlegen</button></div></div></div></div></dialog>
<dialog class="sg-modal" id="resourceModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="resourceModalTitle">Resource anlegen</div><div class="modal-sub">Desktop oder RemoteApp für eine Farm veröffentlichen</div></div><button type="button" class="modal-close icon-btn" data-close-modal="resourceModal">×</button></div><div class="modal-body"><div id="resourceEditor" class="form"><div class="form-grid three"><label>Name<input id="resName" placeholder="Sage 100"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName" placeholder="Sage"></label><label>RemoteApp Alias<input id="resRemoteApp" placeholder="||Sage"></label><label>Guacamole Working Dir<input id="resRemoteDir"></label><label>Guacamole Argumente<input id="resRemoteArgs"></label></div><div class="note" id="remoteAppManageNote"><strong>Agent-gesteuerte RemoteApp-Veröffentlichung</strong><br>SessionGuard hält den Alias auf allen geeigneten Farm-Agents im Sollzustand; fehlerhafte Hosts werden für diese Resource nicht gebrokert.</div><div class="check"><input id="resManage" type="checkbox"><label>RemoteApp auf Farm-Agents automatisch veröffentlichen und überwachen</label></div><div class="form-grid three"><label>Executable auf dem Terminalserver<input id="resRemotePath" placeholder="C:\Program Files\Hersteller\app.exe"></label><label>Icon-Pfad (optional)<input id="resRemoteIcon" placeholder="leer = Executable"></label><label>Icon-Index<input id="resIconIndex" type="number" value="0"></label><label>Command-Line-Policy<select id="resCmd"><option value="0">Keine Argumente erlauben</option><option value="1">Argumente aus RDP/Guacamole erlauben</option><option value="2">Vorgegebene Argumente erzwingen</option></select></label><label>Vorgegebene Argumente<input id="resRemoteRequired"></label><div class="check"><input id="resPortal" type="checkbox"><label>Auch in RD Web Access anzeigen</label></div></div><div class="actions end"><button type="button" class="secondary" data-close-modal="resourceModal">Abbrechen</button><button id="resSave" type="button" data-global-action="save-resource">Resource anlegen</button></div></div></div></div></dialog>
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.1"></script></body></html>`
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.2"></script></body></html>`
const masterJS = `
let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));function when(v){if(!v)return'';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function openModal(id){let d=$(id);if(!d)return;document.body.classList.add('modal-open');if(!d.open)d.showModal()}function closeModal(id){let d=$(id);if(!d)return;if(d.open)d.close();if(!document.querySelector('dialog[open]'))document.body.classList.remove('modal-open')}function activateDetailTab(name){document.querySelectorAll('#detail .detail-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===name));document.querySelectorAll('#detail .detail-pane').forEach(p=>p.classList.toggle('active',p.dataset.pane===name))}

181
production/EDGE-SECURITY.md Normal file
View File

@@ -0,0 +1,181 @@
# 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
```text
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.
```text
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:
```json
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
}
```
### Per-IP limit
The default is deliberately NAT-friendly:
```json
"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:
```caddyfile
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:
```text
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
1. Keep provider/network DDoS protection enabled.
2. Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
3. Keep NetBird peer/backend ports private.
4. Pin Caddy to a currently patched release rather than an old major-only image
during controlled production rollouts.
5. Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
6. Do not put broad office/country CIDRs on the static blacklist without first
checking whether legitimate remote users may originate there.

View File

@@ -135,3 +135,7 @@ nameserver/match-domain for your AD/internal DNS zone.
Because the three workers are stateless apart from the shared `drive` volume and
shared PostgreSQL state, drain one worker at the Caddy/NetBird level, update it,
then return it to service. Never create three independent Guacamole databases.
## Public edge security (v0.5.2)
The `public-vps/` stack now contains SessionGuard EdgeGuard in front of both public hosts. EdgeGuard provides CIDR blacklisting, NAT-friendly rate limiting, scanner detection, bounded per-IP state, persistent temporary bans and localhost-only metrics. The Caddy example disables its admin API, uses bounded/sampled access logs, pins Caddy 2.11.4, and exposes only the proxy service publicly. See `EDGE-SECURITY.md`.

View File

@@ -1,6 +1,7 @@
# Public VPS / edge
NETBIRD_VERSION=latest
CADDY_VERSION=2
CADDY_VERSION=2.11.4
EDGEGUARD_VERSION=0.5.2
NETBIRD_PEER_NAME=public-proxy
NETBIRD_MANAGEMENT_URL=https://netbird.example.org
NETBIRD_SETUP_KEY=REPLACE_ME
@@ -19,3 +20,7 @@ SESSIONGUARD_IP=10.202.0.10
# Generate e.g. with: openssl rand -hex 32
GUAC_LB_SECRET=REPLACE_WITH_RANDOM_SECRET
# Base image name built by this repository's release workflow.
# If your repository name differs, adjust accordingly.
EDGEGUARD_IMAGE=git.send.nrw/sendnrw/sessionguard-edgeguard

View File

@@ -1,67 +1,133 @@
{
email {$ACME_EMAIL}
admin off
# Reduce protocol/parser attack surface without interfering with Guacamole
# WebSockets. HTTP/3 can be enabled later if there is a concrete need.
servers {
protocols h1 h2
strict_sni_host on
max_header_size 64KB
timeouts {
read_header 10s
}
}
}
(edge_security_headers) {
header {
-Server
Strict-Transport-Security "max-age=31536000"
X-Content-Type-Options "nosniff"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
}
}
(edgeguard_check) {
# EdgeGuard only listens on 127.0.0.1. These headers are overwritten by
# Caddy and therefore cannot be forged by an Internet client.
forward_auth 127.0.0.1:9081 {
uri /check
header_up X-Edge-Client-IP {client_ip}
header_up X-Edge-Original-Host {host}
header_up X-Edge-Original-Method {method}
header_up X-Edge-Original-URI {uri}
}
}
# Guacamole public endpoint + SessionGuard Access Auth
{$GUAC_HOST} {
encode zstd gzip
import edge_security_headers
# These endpoints belong to SessionGuard, but intentionally live on the
# Guacamole hostname so the Access-Auth cookie remains host-bound.
# handle_path removes /_sessionguard before proxying.
handle_path /_sessionguard/* {
reverse_proxy {$SESSIONGUARD_IP}:8080
}
route {
# Security pre-check occurs before OIDC/Auth and before any backend.
import edgeguard_check
handle {
route {
# Never trust identity headers supplied by an Internet client.
request_header -X-Guacamole-User
request_header -X-SessionGuard-User
request_header -X-SessionGuard-Email
request_header -X-SessionGuard-Groups
request_header -X-Forwarded-User
request_header -X-Authenticated-User
# These endpoints belong to SessionGuard, but intentionally live on
# the Guacamole hostname so the Access-Auth cookie remains host-bound.
handle_path /_sessionguard/* {
reverse_proxy {$SESSIONGUARD_IP}:8080
}
# SessionGuard is the single OIDC/ForwardAuth authority.
forward_auth {$SESSIONGUARD_IP}:8080 {
uri /auth/verify
copy_headers {
X-Guacamole-User
X-SessionGuard-User
X-SessionGuard-Email
X-SessionGuard-Groups
handle {
route {
# Never trust identity headers supplied by an Internet client.
request_header -X-Guacamole-User
request_header -X-SessionGuard-User
request_header -X-SessionGuard-Email
request_header -X-SessionGuard-Groups
request_header -X-Forwarded-User
request_header -X-Authenticated-User
# SessionGuard is the single OIDC/ForwardAuth authority.
forward_auth {$SESSIONGUARD_IP}:8080 {
uri /auth/verify
copy_headers {
X-Guacamole-User
X-SessionGuard-User
X-SessionGuard-Email
X-SessionGuard-Groups
}
}
}
# Sticky sessions are important because Guacamole keeps runtime
# authentication/session state in the selected webapp process.
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
lb_policy cookie guac_node {$GUAC_LB_SECRET}
lb_try_duration 5s
lb_try_interval 250ms
# Guacamole with WEBAPP_CONTEXT=ROOT serves / as its frontend.
health_uri /
health_interval 10s
health_timeout 3s
# Sticky sessions are important because Guacamole keeps runtime
# authentication/session state in the selected webapp process.
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
lb_policy cookie guac_node {$GUAC_LB_SECRET}
lb_try_duration 5s
lb_try_interval 250ms
health_uri /
health_interval 10s
health_timeout 3s
}
}
}
}
log {
output stdout
format console
output file /var/log/caddy/guacamole-access.log {
roll_size 100MiB
roll_keep 5
roll_keep_for 168h
}
format json
sampling {
interval 1s
first 200
thereafter 20
}
}
}
# SessionGuard administration UI, Agent endpoint and APIs.
{$SESSIONGUARD_HOST} {
encode zstd gzip
reverse_proxy {$SESSIONGUARD_IP}:8080
import edge_security_headers
route {
import edgeguard_check
# These endpoints are required internally only. Broker requests from
# Guacamole workers go directly over NetBird, never via public Caddy.
respond /metrics 404
respond /api/v1/broker/* 404
reverse_proxy {$SESSIONGUARD_IP}:8080
}
log {
output stdout
format console
output file /var/log/caddy/sessionguard-access.log {
roll_size 100MiB
roll_keep 5
roll_keep_for 168h
}
format json
sampling {
interval 1s
first 200
thereafter 20
}
}
}

View File

@@ -0,0 +1,74 @@
# SessionGuard Public VPS: Caddy + NetBird + EdgeGuard
This stack keeps public TLS termination and Guacamole load balancing on Caddy,
uses NetBird only as the encrypted backend transport, and inserts SessionGuard
EdgeGuard as a localhost-only request pre-check.
## Data path
```text
Internet
-> Caddy :443 (TLS, SNI, security headers, sticky load balancing)
-> EdgeGuard 127.0.0.1:9081 /check
-> SessionGuard Access Auth over NetBird
-> Guacamole worker over NetBird
```
EdgeGuard is not in the Guacamole tunnel after the WebSocket connection has
been established. It evaluates normal HTTP requests and the WebSocket handshake.
## What EdgeGuard enforces
- static IPv4/IPv6 IP/CIDR blacklist;
- global request rate limit to protect backends during distributed HTTP floods;
- high per-IP request rate limit (NAT-friendly defaults);
- tighter, configurable limits for OIDC/login/callback paths;
- scanner-path blocking (`/.env`, `/.git`, WordPress/phpMyAdmin probes, etc.);
- blocks CONNECT/TRACE/TRACK;
- temporary persistent bans for clearly hostile scanner/method behavior;
- host allowlist and URI-length validation;
- localhost-only health and Prometheus-style metrics endpoints;
- automatic config/list reload (15 seconds by default);
- bounded per-IP state (100,000 entries by default) to avoid memory exhaustion from rotating source addresses.
The default `rate_limit_weight` for auto-ban is zero on purpose: legitimate
users behind a shared NAT should receive 429 during extreme bursts, but should
not cause the whole NAT address to be banned. Scanner probes are weighted much
more strongly and are auto-banned after repeated hits.
## Caddy hardening
The supplied Caddyfile additionally enables:
- strict SNI/Host matching;
- 10 second request-header timeout;
- 64 KiB maximum request headers;
- HTTP/1.1 + HTTP/2 only (HTTP/3 disabled to reduce exposed protocol surface);
- HSTS and conservative security headers;
- public blocking of SessionGuard `/metrics` and `/api/v1/broker/*`;
- rotated JSON access logs with sampling during request floods;
- Caddy admin API disabled (`admin off`); configuration changes use a container restart.
## Installation
1. Copy `.env.example` to `.env` and set all values.
2. Adjust `edgeguard.json` host names if needed.
3. Add permanent abusive IPs/CIDRs to `blacklist.txt`.
4. Keep `rate-exempt.txt` empty unless you have a known large NAT that really
needs exemption from per-IP limits.
5. Start with `docker compose up -d`.
6. Verify:
```bash
curl -fsS http://127.0.0.1:9081/healthz -o /dev/null
curl -fsS http://127.0.0.1:9081/metrics
curl -I https://ts.hilden.info/
```
## Important DDoS boundary
EdgeGuard protects the application/backends against HTTP request floods and
common low-cost scanners. It cannot protect a single VPS if the Internet link
or provider edge is saturated. Keep the VPS provider's network firewall and
DDoS protection enabled. For a volumetric attack, filtering must happen before
traffic reaches the VPS.

View File

@@ -0,0 +1,7 @@
# Static IP/CIDR blacklist. One IPv4/IPv6 address or CIDR per line.
# Changes are picked up automatically (default: within 15 seconds).
#
# Examples:
# 203.0.113.44
# 198.51.100.0/24
# 2001:db8:1234::/48

View File

@@ -18,13 +18,37 @@ services:
volumes:
- netbird-client:/var/lib/netbird
edgeguard:
image: ${EDGEGUARD_IMAGE:?EDGEGUARD_IMAGE is required}:${EDGEGUARD_VERSION:-latest}
container_name: sessionguard-edgeguard
restart: unless-stopped
network_mode: host
depends_on:
- netbird
command:
- -config
- /etc/sessionguard-edgeguard/edgeguard.json
read_only: true
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=16m,noexec,nosuid,nodev
volumes:
- ./edgeguard.json:/etc/sessionguard-edgeguard/edgeguard.json:ro
- ./blacklist.txt:/etc/sessionguard-edgeguard/blacklist.txt:ro
- ./rate-exempt.txt:/etc/sessionguard-edgeguard/rate-exempt.txt:ro
- edgeguard-state:/var/lib/sessionguard-edgeguard
caddy:
image: caddy:${CADDY_VERSION:-2}
image: caddy:${CADDY_VERSION:-2.11.4}
container_name: caddy-public
restart: unless-stopped
network_mode: host
depends_on:
- netbird
- edgeguard
environment:
ACME_EMAIL: ${ACME_EMAIL:?ACME_EMAIL is required}
GUAC_HOST: ${GUAC_HOST:-ts.hilden.info}
@@ -34,12 +58,24 @@ services:
GUAC03_IP: ${GUAC03_IP:?GUAC03_IP is required}
SESSIONGUARD_IP: ${SESSIONGUARD_IP:?SESSIONGUARD_IP is required}
GUAC_LB_SECRET: ${GUAC_LB_SECRET:?GUAC_LB_SECRET is required}
read_only: true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
security_opt:
- no-new-privileges:true
tmpfs:
- /tmp:size=64m,noexec,nosuid,nodev
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile:ro
- caddy-data:/data
- caddy-config:/config
- caddy-logs:/var/log/caddy
volumes:
netbird-client:
edgeguard-state:
caddy-data:
caddy-config:
caddy-logs:

View File

@@ -0,0 +1,77 @@
{
"listen": "127.0.0.1:9081",
"allowed_hosts": [
"ts.hilden.info",
"sessionguard.hilden.info"
],
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
"reload_seconds": 15,
"max_uri_length": 8192,
"global_limit": {
"rate_per_second": 2500,
"burst": 5000
},
"per_ip_limit": {
"rate_per_second": 200,
"burst": 500
},
"rules": [
{
"name": "guac-access-login",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/login",
"rate_per_second": 5,
"burst": 100
},
{
"name": "guac-access-callback",
"host": "ts.hilden.info",
"path_prefix": "/_sessionguard/auth/oidc/callback",
"rate_per_second": 10,
"burst": 100
},
{
"name": "sessionguard-oidc",
"host": "sessionguard.hilden.info",
"path_prefix": "/oidc/",
"rate_per_second": 5,
"burst": 50
}
],
"blocked_methods": [
"CONNECT",
"TRACE",
"TRACK"
],
"scanner_path_prefixes": [
"/.env",
"/.git",
"/.svn",
"/.hg",
"/wp-admin",
"/wp-login.php",
"/phpmyadmin",
"/pma",
"/cgi-bin",
"/server-status",
"/actuator",
"/vendor/phpunit",
"/boaform",
"/HNAP1",
"/solr/",
"/jenkins/"
],
"auto_ban": {
"enabled": true,
"threshold": 10,
"window_seconds": 120,
"ban_seconds": 900,
"scanner_weight": 5,
"method_weight": 3,
"rate_limit_weight": 0,
"invalid_uri_weight": 3
},
"max_tracked_ips": 100000
}

View File

@@ -0,0 +1,6 @@
# Optional CIDRs which bypass per-IP and endpoint rate limits.
# They are NOT exempt from the static blacklist, host/method/scanner checks,
# or the global overload limit.
#
# Use this only for known NAT gateways/monitoring systems if necessary.
# 192.0.2.10

Binary file not shown.