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.0 info.hilden.sessionguard sessionguard-guacamole - 0.5.0 + 0.5.2 jar 11 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.
-
` +
` const agentJS = ` let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false; diff --git a/internal/edgeguard/config.go b/internal/edgeguard/config.go new file mode 100644 index 0000000..0058f87 --- /dev/null +++ b/internal/edgeguard/config.go @@ -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 } diff --git a/internal/edgeguard/guard.go b/internal/edgeguard/guard.go new file mode 100644 index 0000000..308c068 --- /dev/null +++ b/internal/edgeguard/guard.go @@ -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()), + ) +} diff --git a/internal/edgeguard/guard_test.go b/internal/edgeguard/guard_test.go new file mode 100644 index 0000000..980584a --- /dev/null +++ b/internal/edgeguard/guard_test.go @@ -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) + } +} diff --git a/internal/edgeguard/http.go b/internal/edgeguard/http.go new file mode 100644 index 0000000..5be131b --- /dev/null +++ b/internal/edgeguard/http.go @@ -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, + }) +} diff --git a/internal/master/master.go b/internal/master/master.go index 49bceb7..1537365 100644 --- a/internal/master/master.go +++ b/internal/master/master.go @@ -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 diff --git a/internal/master/ui.go b/internal/master/ui.go index 81852dd..d910897 100644 --- a/internal/master/ui.go +++ b/internal/master/ui.go @@ -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}
-