diff --git a/.gitea/workflows/release.yml b/.gitea/workflows/release.yml
index 2f23416..55a0883 100644
--- a/.gitea/workflows/release.yml
+++ b/.gitea/workflows/release.yml
@@ -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
diff --git a/.gitignore b/.gitignore
index 35bb318..7097ee0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,4 +1,5 @@
/bin/
+*.exe
*.log
state.json
master.json
diff --git a/CHANGELOG.md b/CHANGELOG.md
index dac522f..6a32ad1 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/Dockerfile.edgeguard b/Dockerfile.edgeguard
new file mode 100644
index 0000000..56d10b2
--- /dev/null
+++ b/Dockerfile.edgeguard
@@ -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"]
diff --git a/README.md b/README.md
index d93f619..8eddf27 100644
--- a/README.md
+++ b/README.md
@@ -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.
diff --git a/cmd/edgeguard/main.go b/cmd/edgeguard/main.go
new file mode 100644
index 0000000..d096cdf
--- /dev/null
+++ b/cmd/edgeguard/main.go
@@ -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()
+ }
+ }
+}
diff --git a/configs/edgeguard.example.json b/configs/edgeguard.example.json
new file mode 100644
index 0000000..0538b8b
--- /dev/null
+++ b/configs/edgeguard.example.json
@@ -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
+}
diff --git a/deploy/public-vps-netbird/.env.example b/deploy/public-vps-netbird/.env.example
new file mode 100644
index 0000000..f9b3f0b
--- /dev/null
+++ b/deploy/public-vps-netbird/.env.example
@@ -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
diff --git a/deploy/public-vps-netbird/Caddyfile b/deploy/public-vps-netbird/Caddyfile
new file mode 100644
index 0000000..3a0c442
--- /dev/null
+++ b/deploy/public-vps-netbird/Caddyfile
@@ -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
+ }
+ }
+}
diff --git a/deploy/public-vps-netbird/README.md b/deploy/public-vps-netbird/README.md
new file mode 100644
index 0000000..79bca62
--- /dev/null
+++ b/deploy/public-vps-netbird/README.md
@@ -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.
diff --git a/deploy/public-vps-netbird/blacklist.txt b/deploy/public-vps-netbird/blacklist.txt
new file mode 100644
index 0000000..55f2cbf
--- /dev/null
+++ b/deploy/public-vps-netbird/blacklist.txt
@@ -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
diff --git a/deploy/public-vps-netbird/docker-compose.yml b/deploy/public-vps-netbird/docker-compose.yml
new file mode 100644
index 0000000..b1b5f90
--- /dev/null
+++ b/deploy/public-vps-netbird/docker-compose.yml
@@ -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:
diff --git a/deploy/public-vps-netbird/edgeguard.json b/deploy/public-vps-netbird/edgeguard.json
new file mode 100644
index 0000000..0538b8b
--- /dev/null
+++ b/deploy/public-vps-netbird/edgeguard.json
@@ -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
+}
diff --git a/deploy/public-vps-netbird/rate-exempt.txt b/deploy/public-vps-netbird/rate-exempt.txt
new file mode 100644
index 0000000..a94f9a8
--- /dev/null
+++ b/deploy/public-vps-netbird/rate-exempt.txt
@@ -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
diff --git a/docs/EDGE-SECURITY.md b/docs/EDGE-SECURITY.md
new file mode 100644
index 0000000..c75f0e6
--- /dev/null
+++ b/docs/EDGE-SECURITY.md
@@ -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.
diff --git a/guacamole-extension/pom.xml b/guacamole-extension/pom.xml
index 6f72c02..4787508 100644
--- a/guacamole-extension/pom.xml
+++ b/guacamole-extension/pom.xml
@@ -5,7 +5,7 @@
4.0.0info.hilden.sessionguardsessionguard-guacamole
- 0.5.0
+ 0.5.2jar11
diff --git a/internal/agent/agent.go b/internal/agent/agent.go
index cc5e0c6..d35ec45 100644
--- a/internal/agent/agent.go
+++ b/internal/agent/agent.go
@@ -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
diff --git a/internal/agent/ui.go b/internal/agent/ui.go
index 9efe576..b43615b 100644
--- a/internal/agent/ui.go
+++ b/internal/agent/ui.go
@@ -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}}
-
+
Lokaler Agent
Lokaler Terminalserver
Live · 5s
Local Control
Terminalserver-Status
Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.
Aktiv▶
–
GetrenntⅡ
–
Sitzungen◎
–
Profil-Jobs↕
–
Cleanup⌫
–
RemoteApps◇
–
Master⇄
–
RDS
Sitzungen
Aktive und getrennte Benutzer sowie administrative Aktionen.
Operations
Betriebsdaten
RemoteApps, Profil-Pipeline und lokale Ereignisse kompakt in einer Arbeitsfläche.
Configuration
Lokale Policy
Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.
Fallback-Verhalten: Die lokale Policy bleibt als Notfallkonfiguration verfügbar. Eine aktive Master-Soll-Policy hat im zentral verwalteten Betrieb Vorrang.