RC-2 0.5.2
This commit is contained in:
@@ -69,6 +69,19 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}:${{ env.DOCKER_LATEST }}
|
||||
|
||||
- name: Build and push SessionGuard EdgeGuard
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
context: .
|
||||
file: ./Dockerfile.edgeguard
|
||||
platforms: linux/amd64
|
||||
push: true
|
||||
build-args: |
|
||||
VERSION=${{ steps.meta.outputs.REPO_VERSION }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
${{ env.REGISTRY }}/${{ env.DOCKER_ORG }}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ env.DOCKER_LATEST }}
|
||||
|
||||
# Export exactly the same extension that is embedded into the Guacamole image.
|
||||
# The dedicated target avoids depending on the Maven project version in CI.
|
||||
- name: Build Guacamole Extension JAR
|
||||
@@ -166,6 +179,9 @@ jobs:
|
||||
Guacamole image:
|
||||
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.GUAC_IMAGE }}:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
|
||||
EdgeGuard image:
|
||||
${REGISTRY}/${DOCKER_ORG}/${{ steps.meta.outputs.REPO_NAME }}-edgeguard:${{ steps.meta.outputs.REPO_VERSION }}
|
||||
|
||||
Extension package:
|
||||
${GITEA_API_BASE}/api/packages/${DOCKER_ORG}/generic/${{ steps.meta.outputs.EXT_PACKAGE }}/${{ steps.meta.outputs.REPO_VERSION }}/sessionguard-guacamole.jar
|
||||
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
||||
/bin/
|
||||
*.exe
|
||||
*.log
|
||||
state.json
|
||||
master.json
|
||||
|
||||
17
CHANGELOG.md
17
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.
|
||||
|
||||
21
Dockerfile.edgeguard
Normal file
21
Dockerfile.edgeguard
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM golang:1.23-bookworm AS build
|
||||
WORKDIR /src
|
||||
COPY go.mod ./
|
||||
RUN go mod download
|
||||
COPY . .
|
||||
ARG VERSION=dev
|
||||
RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags="-s -w -X main.version=${VERSION}" -o /out/sessionguard-edgeguard ./cmd/edgeguard
|
||||
|
||||
FROM alpine:3.20
|
||||
RUN apk add --no-cache ca-certificates \
|
||||
&& addgroup -S edgeguard \
|
||||
&& adduser -S -G edgeguard edgeguard \
|
||||
&& mkdir -p /etc/sessionguard-edgeguard /var/lib/sessionguard-edgeguard \
|
||||
&& chown -R edgeguard:edgeguard /etc/sessionguard-edgeguard /var/lib/sessionguard-edgeguard
|
||||
COPY --from=build /out/sessionguard-edgeguard /usr/local/bin/sessionguard-edgeguard
|
||||
USER edgeguard
|
||||
VOLUME ["/var/lib/sessionguard-edgeguard"]
|
||||
EXPOSE 9081
|
||||
HEALTHCHECK --interval=30s --timeout=3s --retries=3 CMD wget -q -O /dev/null http://127.0.0.1:9081/healthz || exit 1
|
||||
ENTRYPOINT ["/usr/local/bin/sessionguard-edgeguard"]
|
||||
CMD ["-config", "/etc/sessionguard-edgeguard/edgeguard.json"]
|
||||
@@ -2,10 +2,14 @@
|
||||
|
||||
SessionGuard is a Go-based **RDS control plane** for Windows Remote Desktop Session Hosts. It is designed to complement Apache Guacamole: Guacamole remains the HTML5/RDP gateway, while SessionGuard provides Citrix-like broker, Director, policy, profile-lifecycle and operations functions.
|
||||
|
||||
**Current development version: 0.5.1 (modal-first responsive Web UI + integrated Guacamole Access Auth)**
|
||||
**Current development version: 0.5.2 (Public EdgeGuard + modal-first responsive Web UI + integrated Guacamole Access Auth)**
|
||||
|
||||
> SessionGuard is not an ICA/HDX implementation and does not replace the Windows RDS runtime. It deliberately reuses standard RDP/WTS, Guacamole and PocketID/OIDC.
|
||||
|
||||
## 0.5.2 Public EdgeGuard
|
||||
|
||||
The optional public-VPS deployment now includes `sessionguard-edgeguard`, a small Go service called by Caddy before requests reach SessionGuard or Guacamole. It provides static CIDR blacklisting, global/per-IP/endpoint token-bucket limits, scanner-path detection, persistent temporary bans, method/host/URI guards and local metrics. Caddy is additionally hardened with strict SNI/Host matching, a request-header timeout and smaller header ceiling. This protects application backends against common Internet abuse; provider-side DDoS filtering is still required for attacks that saturate the network link. See `docs/EDGE-SECURITY.md` and `deploy/public-vps-netbird/`.
|
||||
|
||||
## 0.5.1 Web UI refresh
|
||||
|
||||
The Master and local Agent consoles now use a modal-first, responsive Vanilla-JavaScript interface. Server drill-down, Farm creation, Resource editing and local Agent policy editing no longer occupy permanent large inline forms. The Master server workspace uses tabs, Farms/Resources use compact cards, and the Agent groups operational data into a tabbed workspace. No Node.js/npm/frontend build step is required.
|
||||
|
||||
121
cmd/edgeguard/main.go
Normal file
121
cmd/edgeguard/main.go
Normal file
@@ -0,0 +1,121 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/example/sessionguard/internal/edgeguard"
|
||||
)
|
||||
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
configPath := flag.String("config", "/etc/sessionguard-edgeguard/edgeguard.json", "path to EdgeGuard JSON configuration")
|
||||
checkConfig := flag.Bool("check-config", false, "validate configuration and exit")
|
||||
showVersion := flag.Bool("version", false, "print version and exit")
|
||||
flag.Parse()
|
||||
|
||||
if *showVersion {
|
||||
fmt.Printf("sessionguard-edgeguard %s\n", version)
|
||||
return
|
||||
}
|
||||
|
||||
logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
||||
cfg, err := edgeguard.LoadRuntimeConfig(*configPath)
|
||||
if err != nil {
|
||||
logger.Error("load configuration", "error", err)
|
||||
os.Exit(2)
|
||||
}
|
||||
if *checkConfig {
|
||||
fmt.Printf("configuration OK (%s)\n", cfg.Fingerprint()[:12])
|
||||
return
|
||||
}
|
||||
|
||||
guard := edgeguard.NewGuard(cfg, logger)
|
||||
handler := edgeguard.NewHTTPServer(guard, logger).Handler()
|
||||
server := &http.Server{
|
||||
Addr: cfg.Listen,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
IdleTimeout: 30 * time.Second,
|
||||
MaxHeaderBytes: 16 << 10,
|
||||
}
|
||||
|
||||
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
|
||||
defer stop()
|
||||
|
||||
go reloadLoop(ctx, *configPath, guard, logger)
|
||||
go cleanupLoop(ctx, guard)
|
||||
go persistenceLoop(ctx, guard)
|
||||
|
||||
go func() {
|
||||
<-ctx.Done()
|
||||
guard.FlushState()
|
||||
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
||||
defer cancel()
|
||||
_ = server.Shutdown(shutdownCtx)
|
||||
}()
|
||||
|
||||
logger.Info("SessionGuard EdgeGuard started", "version", version, "listen", cfg.Listen)
|
||||
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
||||
logger.Error("edgeguard server stopped", "error", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
func reloadLoop(ctx context.Context, path string, guard *edgeguard.Guard, logger *slog.Logger) {
|
||||
interval := guard.Config().ReloadInterval()
|
||||
t := time.NewTicker(interval)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
cfg, err := edgeguard.LoadRuntimeConfig(path)
|
||||
if err != nil {
|
||||
logger.Error("reload edgeguard configuration", "error", err)
|
||||
continue
|
||||
}
|
||||
guard.ReplaceConfig(cfg)
|
||||
newInterval := cfg.ReloadInterval()
|
||||
if newInterval != interval {
|
||||
interval = newInterval
|
||||
t.Reset(interval)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cleanupLoop(ctx context.Context, guard *edgeguard.Guard) {
|
||||
t := time.NewTicker(time.Minute)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case now := <-t.C:
|
||||
guard.Cleanup(now)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func persistenceLoop(ctx context.Context, guard *edgeguard.Guard) {
|
||||
t := time.NewTicker(5 * time.Second)
|
||||
defer t.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-t.C:
|
||||
guard.FlushState()
|
||||
}
|
||||
}
|
||||
}
|
||||
77
configs/edgeguard.example.json
Normal file
77
configs/edgeguard.example.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"listen": "127.0.0.1:9081",
|
||||
"allowed_hosts": [
|
||||
"ts.hilden.info",
|
||||
"sessionguard.hilden.info"
|
||||
],
|
||||
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
|
||||
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
|
||||
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
|
||||
"reload_seconds": 15,
|
||||
"max_uri_length": 8192,
|
||||
"global_limit": {
|
||||
"rate_per_second": 2500,
|
||||
"burst": 5000
|
||||
},
|
||||
"per_ip_limit": {
|
||||
"rate_per_second": 200,
|
||||
"burst": 500
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "guac-access-login",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/login",
|
||||
"rate_per_second": 5,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "guac-access-callback",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/oidc/callback",
|
||||
"rate_per_second": 10,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "sessionguard-oidc",
|
||||
"host": "sessionguard.hilden.info",
|
||||
"path_prefix": "/oidc/",
|
||||
"rate_per_second": 5,
|
||||
"burst": 50
|
||||
}
|
||||
],
|
||||
"blocked_methods": [
|
||||
"CONNECT",
|
||||
"TRACE",
|
||||
"TRACK"
|
||||
],
|
||||
"scanner_path_prefixes": [
|
||||
"/.env",
|
||||
"/.git",
|
||||
"/.svn",
|
||||
"/.hg",
|
||||
"/wp-admin",
|
||||
"/wp-login.php",
|
||||
"/phpmyadmin",
|
||||
"/pma",
|
||||
"/cgi-bin",
|
||||
"/server-status",
|
||||
"/actuator",
|
||||
"/vendor/phpunit",
|
||||
"/boaform",
|
||||
"/HNAP1",
|
||||
"/solr/",
|
||||
"/jenkins/"
|
||||
],
|
||||
"auto_ban": {
|
||||
"enabled": true,
|
||||
"threshold": 10,
|
||||
"window_seconds": 120,
|
||||
"ban_seconds": 900,
|
||||
"scanner_weight": 5,
|
||||
"method_weight": 3,
|
||||
"rate_limit_weight": 0,
|
||||
"invalid_uri_weight": 3
|
||||
},
|
||||
"max_tracked_ips": 100000
|
||||
}
|
||||
26
deploy/public-vps-netbird/.env.example
Normal file
26
deploy/public-vps-netbird/.env.example
Normal file
@@ -0,0 +1,26 @@
|
||||
# Public VPS / edge
|
||||
NETBIRD_VERSION=latest
|
||||
CADDY_VERSION=2.11.4
|
||||
EDGEGUARD_VERSION=0.5.2
|
||||
NETBIRD_PEER_NAME=public-proxy
|
||||
NETBIRD_MANAGEMENT_URL=https://netbird.example.org
|
||||
NETBIRD_SETUP_KEY=REPLACE_ME
|
||||
NETBIRD_LOG_LEVEL=info
|
||||
|
||||
# Public DNS names. Both A/AAAA records point to this VPS.
|
||||
GUAC_HOST=ts.hilden.info
|
||||
SESSIONGUARD_HOST=sessionguard.hilden.info
|
||||
ACME_EMAIL=admin@example.org
|
||||
|
||||
# Fixed private Docker addresses advertised through NetBird Networks.
|
||||
GUAC01_IP=10.201.1.10
|
||||
GUAC02_IP=10.201.2.10
|
||||
GUAC03_IP=10.201.3.10
|
||||
SESSIONGUARD_IP=10.202.0.10
|
||||
|
||||
# Generate e.g. with: openssl rand -hex 32
|
||||
GUAC_LB_SECRET=REPLACE_WITH_RANDOM_SECRET
|
||||
|
||||
# Base image name built by this repository's release workflow.
|
||||
# If your repository name differs, adjust accordingly.
|
||||
EDGEGUARD_IMAGE=git.send.nrw/sendnrw/sessionguard-edgeguard
|
||||
133
deploy/public-vps-netbird/Caddyfile
Normal file
133
deploy/public-vps-netbird/Caddyfile
Normal file
@@ -0,0 +1,133 @@
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
admin off
|
||||
|
||||
# Reduce protocol/parser attack surface without interfering with Guacamole
|
||||
# WebSockets. HTTP/3 can be enabled later if there is a concrete need.
|
||||
servers {
|
||||
protocols h1 h2
|
||||
strict_sni_host on
|
||||
max_header_size 64KB
|
||||
timeouts {
|
||||
read_header 10s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(edge_security_headers) {
|
||||
header {
|
||||
-Server
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
}
|
||||
}
|
||||
|
||||
(edgeguard_check) {
|
||||
# EdgeGuard only listens on 127.0.0.1. These headers are overwritten by
|
||||
# Caddy and therefore cannot be forged by an Internet client.
|
||||
forward_auth 127.0.0.1:9081 {
|
||||
uri /check
|
||||
header_up X-Edge-Client-IP {client_ip}
|
||||
header_up X-Edge-Original-Host {host}
|
||||
header_up X-Edge-Original-Method {method}
|
||||
header_up X-Edge-Original-URI {uri}
|
||||
}
|
||||
}
|
||||
|
||||
# Guacamole public endpoint + SessionGuard Access Auth
|
||||
{$GUAC_HOST} {
|
||||
encode zstd gzip
|
||||
import edge_security_headers
|
||||
|
||||
route {
|
||||
# Security pre-check occurs before OIDC/Auth and before any backend.
|
||||
import edgeguard_check
|
||||
|
||||
# These endpoints belong to SessionGuard, but intentionally live on
|
||||
# the Guacamole hostname so the Access-Auth cookie remains host-bound.
|
||||
handle_path /_sessionguard/* {
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
}
|
||||
|
||||
handle {
|
||||
route {
|
||||
# Never trust identity headers supplied by an Internet client.
|
||||
request_header -X-Guacamole-User
|
||||
request_header -X-SessionGuard-User
|
||||
request_header -X-SessionGuard-Email
|
||||
request_header -X-SessionGuard-Groups
|
||||
request_header -X-Forwarded-User
|
||||
request_header -X-Authenticated-User
|
||||
|
||||
# SessionGuard is the single OIDC/ForwardAuth authority.
|
||||
forward_auth {$SESSIONGUARD_IP}:8080 {
|
||||
uri /auth/verify
|
||||
copy_headers {
|
||||
X-Guacamole-User
|
||||
X-SessionGuard-User
|
||||
X-SessionGuard-Email
|
||||
X-SessionGuard-Groups
|
||||
}
|
||||
}
|
||||
|
||||
# Sticky sessions are important because Guacamole keeps runtime
|
||||
# authentication/session state in the selected webapp process.
|
||||
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
|
||||
lb_policy cookie guac_node {$GUAC_LB_SECRET}
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
health_uri /
|
||||
health_interval 10s
|
||||
health_timeout 3s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/guacamole-access.log {
|
||||
roll_size 100MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
sampling {
|
||||
interval 1s
|
||||
first 200
|
||||
thereafter 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# SessionGuard administration UI, Agent endpoint and APIs.
|
||||
{$SESSIONGUARD_HOST} {
|
||||
encode zstd gzip
|
||||
import edge_security_headers
|
||||
|
||||
route {
|
||||
import edgeguard_check
|
||||
|
||||
# These endpoints are required internally only. Broker requests from
|
||||
# Guacamole workers go directly over NetBird, never via public Caddy.
|
||||
respond /metrics 404
|
||||
respond /api/v1/broker/* 404
|
||||
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output file /var/log/caddy/sessionguard-access.log {
|
||||
roll_size 100MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
sampling {
|
||||
interval 1s
|
||||
first 200
|
||||
thereafter 20
|
||||
}
|
||||
}
|
||||
}
|
||||
74
deploy/public-vps-netbird/README.md
Normal file
74
deploy/public-vps-netbird/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# SessionGuard Public VPS: Caddy + NetBird + EdgeGuard
|
||||
|
||||
This stack keeps public TLS termination and Guacamole load balancing on Caddy,
|
||||
uses NetBird only as the encrypted backend transport, and inserts SessionGuard
|
||||
EdgeGuard as a localhost-only request pre-check.
|
||||
|
||||
## Data path
|
||||
|
||||
```text
|
||||
Internet
|
||||
-> Caddy :443 (TLS, SNI, security headers, sticky load balancing)
|
||||
-> EdgeGuard 127.0.0.1:9081 /check
|
||||
-> SessionGuard Access Auth over NetBird
|
||||
-> Guacamole worker over NetBird
|
||||
```
|
||||
|
||||
EdgeGuard is not in the Guacamole tunnel after the WebSocket connection has
|
||||
been established. It evaluates normal HTTP requests and the WebSocket handshake.
|
||||
|
||||
## What EdgeGuard enforces
|
||||
|
||||
- static IPv4/IPv6 IP/CIDR blacklist;
|
||||
- global request rate limit to protect backends during distributed HTTP floods;
|
||||
- high per-IP request rate limit (NAT-friendly defaults);
|
||||
- tighter, configurable limits for OIDC/login/callback paths;
|
||||
- scanner-path blocking (`/.env`, `/.git`, WordPress/phpMyAdmin probes, etc.);
|
||||
- blocks CONNECT/TRACE/TRACK;
|
||||
- temporary persistent bans for clearly hostile scanner/method behavior;
|
||||
- host allowlist and URI-length validation;
|
||||
- localhost-only health and Prometheus-style metrics endpoints;
|
||||
- automatic config/list reload (15 seconds by default);
|
||||
- bounded per-IP state (100,000 entries by default) to avoid memory exhaustion from rotating source addresses.
|
||||
|
||||
The default `rate_limit_weight` for auto-ban is zero on purpose: legitimate
|
||||
users behind a shared NAT should receive 429 during extreme bursts, but should
|
||||
not cause the whole NAT address to be banned. Scanner probes are weighted much
|
||||
more strongly and are auto-banned after repeated hits.
|
||||
|
||||
## Caddy hardening
|
||||
|
||||
The supplied Caddyfile additionally enables:
|
||||
|
||||
- strict SNI/Host matching;
|
||||
- 10 second request-header timeout;
|
||||
- 64 KiB maximum request headers;
|
||||
- HTTP/1.1 + HTTP/2 only (HTTP/3 disabled to reduce exposed protocol surface);
|
||||
- HSTS and conservative security headers;
|
||||
- public blocking of SessionGuard `/metrics` and `/api/v1/broker/*`;
|
||||
- rotated JSON access logs with sampling during request floods;
|
||||
- Caddy admin API disabled (`admin off`); configuration changes use a container restart.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy `.env.example` to `.env` and set all values.
|
||||
2. Adjust `edgeguard.json` host names if needed.
|
||||
3. Add permanent abusive IPs/CIDRs to `blacklist.txt`.
|
||||
4. Keep `rate-exempt.txt` empty unless you have a known large NAT that really
|
||||
needs exemption from per-IP limits.
|
||||
5. Start with `docker compose up -d`.
|
||||
6. Verify:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9081/healthz -o /dev/null
|
||||
curl -fsS http://127.0.0.1:9081/metrics
|
||||
curl -I https://ts.hilden.info/
|
||||
```
|
||||
|
||||
## Important DDoS boundary
|
||||
|
||||
EdgeGuard protects the application/backends against HTTP request floods and
|
||||
common low-cost scanners. It cannot protect a single VPS if the Internet link
|
||||
or provider edge is saturated. Keep the VPS provider's network firewall and
|
||||
DDoS protection enabled. For a volumetric attack, filtering must happen before
|
||||
traffic reaches the VPS.
|
||||
7
deploy/public-vps-netbird/blacklist.txt
Normal file
7
deploy/public-vps-netbird/blacklist.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# Static IP/CIDR blacklist. One IPv4/IPv6 address or CIDR per line.
|
||||
# Changes are picked up automatically (default: within 15 seconds).
|
||||
#
|
||||
# Examples:
|
||||
# 203.0.113.44
|
||||
# 198.51.100.0/24
|
||||
# 2001:db8:1234::/48
|
||||
81
deploy/public-vps-netbird/docker-compose.yml
Normal file
81
deploy/public-vps-netbird/docker-compose.yml
Normal file
@@ -0,0 +1,81 @@
|
||||
services:
|
||||
netbird:
|
||||
image: netbirdio/netbird:${NETBIRD_VERSION:-latest}
|
||||
container_name: netbird-public-proxy
|
||||
hostname: ${NETBIRD_PEER_NAME:-public-proxy}
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
cap_add:
|
||||
- NET_ADMIN
|
||||
- SYS_ADMIN
|
||||
- SYS_RESOURCE
|
||||
devices:
|
||||
- /dev/net/tun:/dev/net/tun
|
||||
environment:
|
||||
NB_SETUP_KEY: ${NETBIRD_SETUP_KEY:?NETBIRD_SETUP_KEY is required}
|
||||
NB_MANAGEMENT_URL: ${NETBIRD_MANAGEMENT_URL:?NETBIRD_MANAGEMENT_URL is required}
|
||||
NB_LOG_LEVEL: ${NETBIRD_LOG_LEVEL:-info}
|
||||
volumes:
|
||||
- netbird-client:/var/lib/netbird
|
||||
|
||||
edgeguard:
|
||||
image: ${EDGEGUARD_IMAGE:?EDGEGUARD_IMAGE is required}:${EDGEGUARD_VERSION:-latest}
|
||||
container_name: sessionguard-edgeguard
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- netbird
|
||||
command:
|
||||
- -config
|
||||
- /etc/sessionguard-edgeguard/edgeguard.json
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,noexec,nosuid,nodev
|
||||
volumes:
|
||||
- ./edgeguard.json:/etc/sessionguard-edgeguard/edgeguard.json:ro
|
||||
- ./blacklist.txt:/etc/sessionguard-edgeguard/blacklist.txt:ro
|
||||
- ./rate-exempt.txt:/etc/sessionguard-edgeguard/rate-exempt.txt:ro
|
||||
- edgeguard-state:/var/lib/sessionguard-edgeguard
|
||||
|
||||
caddy:
|
||||
image: caddy:${CADDY_VERSION:-2.11.4}
|
||||
container_name: caddy-public
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- netbird
|
||||
- edgeguard
|
||||
environment:
|
||||
ACME_EMAIL: ${ACME_EMAIL:?ACME_EMAIL is required}
|
||||
GUAC_HOST: ${GUAC_HOST:-ts.hilden.info}
|
||||
SESSIONGUARD_HOST: ${SESSIONGUARD_HOST:-sessionguard.hilden.info}
|
||||
GUAC01_IP: ${GUAC01_IP:?GUAC01_IP is required}
|
||||
GUAC02_IP: ${GUAC02_IP:?GUAC02_IP is required}
|
||||
GUAC03_IP: ${GUAC03_IP:?GUAC03_IP is required}
|
||||
SESSIONGUARD_IP: ${SESSIONGUARD_IP:?SESSIONGUARD_IP is required}
|
||||
GUAC_LB_SECRET: ${GUAC_LB_SECRET:?GUAC_LB_SECRET is required}
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,noexec,nosuid,nodev
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- caddy-logs:/var/log/caddy
|
||||
|
||||
volumes:
|
||||
netbird-client:
|
||||
edgeguard-state:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
caddy-logs:
|
||||
77
deploy/public-vps-netbird/edgeguard.json
Normal file
77
deploy/public-vps-netbird/edgeguard.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"listen": "127.0.0.1:9081",
|
||||
"allowed_hosts": [
|
||||
"ts.hilden.info",
|
||||
"sessionguard.hilden.info"
|
||||
],
|
||||
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
|
||||
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
|
||||
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
|
||||
"reload_seconds": 15,
|
||||
"max_uri_length": 8192,
|
||||
"global_limit": {
|
||||
"rate_per_second": 2500,
|
||||
"burst": 5000
|
||||
},
|
||||
"per_ip_limit": {
|
||||
"rate_per_second": 200,
|
||||
"burst": 500
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "guac-access-login",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/login",
|
||||
"rate_per_second": 5,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "guac-access-callback",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/oidc/callback",
|
||||
"rate_per_second": 10,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "sessionguard-oidc",
|
||||
"host": "sessionguard.hilden.info",
|
||||
"path_prefix": "/oidc/",
|
||||
"rate_per_second": 5,
|
||||
"burst": 50
|
||||
}
|
||||
],
|
||||
"blocked_methods": [
|
||||
"CONNECT",
|
||||
"TRACE",
|
||||
"TRACK"
|
||||
],
|
||||
"scanner_path_prefixes": [
|
||||
"/.env",
|
||||
"/.git",
|
||||
"/.svn",
|
||||
"/.hg",
|
||||
"/wp-admin",
|
||||
"/wp-login.php",
|
||||
"/phpmyadmin",
|
||||
"/pma",
|
||||
"/cgi-bin",
|
||||
"/server-status",
|
||||
"/actuator",
|
||||
"/vendor/phpunit",
|
||||
"/boaform",
|
||||
"/HNAP1",
|
||||
"/solr/",
|
||||
"/jenkins/"
|
||||
],
|
||||
"auto_ban": {
|
||||
"enabled": true,
|
||||
"threshold": 10,
|
||||
"window_seconds": 120,
|
||||
"ban_seconds": 900,
|
||||
"scanner_weight": 5,
|
||||
"method_weight": 3,
|
||||
"rate_limit_weight": 0,
|
||||
"invalid_uri_weight": 3
|
||||
},
|
||||
"max_tracked_ips": 100000
|
||||
}
|
||||
6
deploy/public-vps-netbird/rate-exempt.txt
Normal file
6
deploy/public-vps-netbird/rate-exempt.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# Optional CIDRs which bypass per-IP and endpoint rate limits.
|
||||
# They are NOT exempt from the static blacklist, host/method/scanner checks,
|
||||
# or the global overload limit.
|
||||
#
|
||||
# Use this only for known NAT gateways/monitoring systems if necessary.
|
||||
# 192.0.2.10
|
||||
181
docs/EDGE-SECURITY.md
Normal file
181
docs/EDGE-SECURITY.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Public Edge Security (Caddy + SessionGuard EdgeGuard)
|
||||
|
||||
SessionGuard EdgeGuard is a small Go service for the public reverse-proxy host.
|
||||
It is **not** another login and it is not a WAF replacement. It runs locally on
|
||||
`127.0.0.1:9081` and is called by Caddy using `forward_auth` before traffic is
|
||||
sent to SessionGuard or a Guacamole worker.
|
||||
|
||||
## Threat model
|
||||
|
||||
EdgeGuard is intended to reduce the cost of common Internet abuse:
|
||||
|
||||
- repeated scanners and exploit probes;
|
||||
- excessive requests from one source;
|
||||
- request floods intended to exhaust SessionGuard/Guacamole rather than the
|
||||
physical Internet link;
|
||||
- malformed/oversized URIs and unexpected HTTP methods;
|
||||
- accidental exposure of backend-only SessionGuard endpoints.
|
||||
|
||||
It cannot stop a volumetric attack that saturates the VPS uplink. Provider-side
|
||||
DDoS filtering/firewalling remains necessary for that class of attack.
|
||||
|
||||
## Request flow
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> Caddy TLS
|
||||
-> EdgeGuard /check (localhost only)
|
||||
-> 204: continue
|
||||
-> 403/404/405/421/429: stop at edge
|
||||
-> SessionGuard Access Auth
|
||||
-> Guacamole worker
|
||||
```
|
||||
|
||||
Caddy's `forward_auth` sends a lightweight GET subrequest. EdgeGuard therefore
|
||||
checks the initial WebSocket handshake, but it is not in the byte path of the
|
||||
established Guacamole WebSocket/RDP stream.
|
||||
|
||||
## Controls
|
||||
|
||||
### Static blacklist
|
||||
|
||||
`blacklist.txt` accepts one IPv4/IPv6 address or CIDR per line. Comments start
|
||||
with `#`. Changes are automatically picked up.
|
||||
|
||||
```text
|
||||
203.0.113.44
|
||||
198.51.100.0/24
|
||||
2001:db8:1234::/48
|
||||
```
|
||||
|
||||
### Global overload limit
|
||||
|
||||
The global token bucket is intentionally high and protects the application
|
||||
backends when a distributed HTTP flood reaches the VPS. It does not prevent
|
||||
network saturation because TLS and the incoming packets already reached Caddy.
|
||||
|
||||
Default production example:
|
||||
|
||||
```json
|
||||
"global_limit": {
|
||||
"rate_per_second": 2500,
|
||||
"burst": 5000
|
||||
}
|
||||
```
|
||||
|
||||
### Per-IP limit
|
||||
|
||||
The default is deliberately NAT-friendly:
|
||||
|
||||
```json
|
||||
"per_ip_limit": {
|
||||
"rate_per_second": 200,
|
||||
"burst": 500
|
||||
}
|
||||
```
|
||||
|
||||
For environments where many staff share one public NAT IP, do not aggressively
|
||||
lower this value. If a known source really needs exemption, put its address in
|
||||
`rate-exempt.txt`. Exempt sources still pass blacklist, scanner, method, host
|
||||
and global-overload checks.
|
||||
|
||||
### Endpoint-specific limits
|
||||
|
||||
The example uses tighter limits for OIDC login/callback paths. These endpoints
|
||||
do not contain the PocketID password check itself; the limits are intended to
|
||||
protect state/session allocation and redirect processing from floods.
|
||||
|
||||
### Bounded per-IP memory
|
||||
|
||||
`max_tracked_ips` bounds the in-memory table used for per-IP token buckets and
|
||||
offense state. The production example allows 100,000 active source addresses;
|
||||
when the table is full, previously unseen sources receive HTTP 429 instead of
|
||||
causing unbounded memory growth. Stale entries are cleaned up automatically.
|
||||
|
||||
### Scanner detection and temporary bans
|
||||
|
||||
Known irrelevant exploit/scanner paths are denied before they reach the
|
||||
backends. The defaults include `/.env`, `/.git`, WordPress, phpMyAdmin, CGI,
|
||||
Spring Actuator and several common automated exploit probes.
|
||||
|
||||
The example auto-ban weights are:
|
||||
|
||||
- scanner path: 5 points;
|
||||
- blocked method: 3 points;
|
||||
- malformed URI: 3 points;
|
||||
- ordinary rate-limit violation: 0 points.
|
||||
|
||||
At 10 points within 120 seconds, the IP is banned for 900 seconds. Bans are
|
||||
persisted in `/var/lib/sessionguard-edgeguard/state.json` with a short write
|
||||
debounce, so a container restart does not normally remove them while scanner
|
||||
floods cannot force one synchronous disk write per request.
|
||||
|
||||
Rate-limit violations deliberately have weight 0 by default to avoid banning a
|
||||
whole corporate NAT during a legitimate burst.
|
||||
|
||||
### Blocked HTTP methods
|
||||
|
||||
`CONNECT`, `TRACE` and `TRACK` are rejected. SessionGuard/Guacamole continue to
|
||||
use their normal GET/POST/PUT/PATCH/DELETE/OPTIONS behavior.
|
||||
|
||||
### Host allowlist
|
||||
|
||||
Only configured public host names are accepted by EdgeGuard. Caddy additionally
|
||||
uses `strict_sni_host on`, requiring the TLS SNI host and HTTP Host header to
|
||||
match.
|
||||
|
||||
## Caddy hardening
|
||||
|
||||
The supplied public Caddyfile also configures:
|
||||
|
||||
```caddyfile
|
||||
servers {
|
||||
protocols h1 h2
|
||||
strict_sni_host on
|
||||
max_header_size 64KB
|
||||
timeouts {
|
||||
read_header 10s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The short header timeout and smaller header ceiling reduce slow-header/resource
|
||||
exhaustion risk. HTTP/3 is intentionally disabled in the example to reduce the
|
||||
public protocol surface; Guacamole works with HTTP/1.1/WebSocket and HTTP/2.
|
||||
|
||||
Access logs are written to size-limited rotating files. Sampling keeps all normal
|
||||
traffic but reduces log amplification once a single logger exceeds 200 entries
|
||||
per second.
|
||||
|
||||
The public SessionGuard host denies `/metrics` and `/api/v1/broker/*` at Caddy.
|
||||
The Caddy admin API is disabled (`admin off`) on this dedicated public edge;
|
||||
configuration changes are applied by restarting the Caddy container.
|
||||
Guacamole workers call broker APIs directly over NetBird instead.
|
||||
|
||||
## Metrics
|
||||
|
||||
EdgeGuard exposes Prometheus text metrics only on localhost:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9081/metrics
|
||||
```
|
||||
|
||||
Counters include total checks, allows, static-blacklist denies, temporary-ban
|
||||
denies, rate-limit denies, scanner denies and auto-bans.
|
||||
|
||||
## Configuration reload
|
||||
|
||||
`edgeguard.json`, `blacklist.txt` and `rate-exempt.txt` are re-read on the
|
||||
configured interval (15 seconds in the example). An invalid replacement config
|
||||
is logged and the previous working configuration remains active.
|
||||
|
||||
## Operational recommendations
|
||||
|
||||
1. Keep provider/network DDoS protection enabled.
|
||||
2. Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
|
||||
3. Keep NetBird peer/backend ports private.
|
||||
4. Pin Caddy to a currently patched release rather than an old major-only image
|
||||
during controlled production rollouts.
|
||||
5. Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
|
||||
6. Do not put broad office/country CIDRs on the static blacklist without first
|
||||
checking whether legitimate remote users may originate there.
|
||||
@@ -5,7 +5,7 @@
|
||||
<modelVersion>4.0.0</modelVersion>
|
||||
<groupId>info.hilden.sessionguard</groupId>
|
||||
<artifactId>sessionguard-guacamole</artifactId>
|
||||
<version>0.5.0</version>
|
||||
<version>0.5.2</version>
|
||||
<packaging>jar</packaging>
|
||||
<properties>
|
||||
<maven.compiler.release>11</maven.compiler.release>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
|
||||
@media(max-width:720px){.agent-metrics{grid-template-columns:repeat(2,1fr)}.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
|
||||
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
|
||||
|
||||
/* SessionGuard UI v0.5.1 — modal-first responsive console */
|
||||
/* SessionGuard UI v0.5.2 — modal-first responsive console */
|
||||
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
|
||||
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
|
||||
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
|
||||
@@ -68,13 +68,13 @@ body.modal-open{overflow:hidden}
|
||||
@media(max-width:720px){dialog.sg-modal,dialog.sg-modal.wide,dialog.sg-modal.compact{width:100vw;max-width:none;height:100dvh;max-height:100dvh;border-radius:0;border:0}.modal-shell{max-height:100dvh;height:100dvh}.modal-head{padding:13px 14px}.modal-title{font-size:16px}.modal-body.pad,.detail-pane{padding:12px}.detail-tabs{padding:8px}.form{padding:12px}.form-grid,.form-grid.three,.entity-stats{grid-template-columns:1fr}.entity-grid{grid-template-columns:1fr;padding:10px}.mobile-only{display:inline-flex}.table th,.table td{white-space:nowrap}.section-heading .section-actions{width:100%}.section-heading .section-actions button{flex:1}}
|
||||
|
||||
</style></head><body>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.1</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell"><aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Local Agent · v0.5.2</div></div></div><nav class="nav-group"><div class="nav-label">Server</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Übersicht</a><a class="nav-link" href="#sessions-section"><span class="nav-icon">▶</span>Sitzungen</a><a class="nav-link" href="#operations-section"><span class="nav-icon">◇</span>Betriebsdaten</a><a class="nav-link" href="#policy-section"><span class="nav-icon">⚙</span>Lokale Policy</a></nav><div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
<div class="app-main"><header class="topbar"><div class="topbar-left"><button class="secondary menu-toggle" id="menuToggle" type="button">☰</button><div><div class="topbar-title">Lokaler Agent</div><div class="topbar-sub" id="host">Lokaler Terminalserver</div></div></div><div class="topbar-actions"><span class="live-pill"><span class="live-dot"></span>Live · 5s</span><button class="secondary theme-top" id="themeTop" type="button">◐</button></div></header>
|
||||
<main class="page"><section class="page-section" id="overview"><div class="section-heading"><div><div class="eyebrow">Local Control</div><h1>Terminalserver-Status</h1><div class="section-copy">Sitzungen, Profil-Pipeline und Master-Verbindung lokal überwachen.</div></div></div><div class="metrics agent-metrics"><div class="metric-card"><div class="metric-head"><span class="metric-label">Aktiv</span><span class="metric-icon">▶</span></div><div class="value" id="active">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Getrennt</span><span class="metric-icon">Ⅱ</span></div><div class="value" id="disc">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Sitzungen</span><span class="metric-icon">◎</span></div><div class="value" id="total">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Profil-Jobs</span><span class="metric-icon">↕</span></div><div class="value" id="profileJobs">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Cleanup</span><span class="metric-icon">⌫</span></div><div class="value" id="pending">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">RemoteApps</span><span class="metric-icon">◇</span></div><div class="value" id="remoteAppCount">–</div></div><div class="metric-card"><div class="metric-head"><span class="metric-label">Master</span><span class="metric-icon">⇄</span></div><div class="value" style="font-size:14px;margin-top:14px" id="master">–</div></div></div></section>
|
||||
<section class="page-section" id="sessions-section"><div class="section-heading"><div><div class="eyebrow">RDS</div><h2>Sitzungen</h2><div class="section-copy">Aktive und getrennte Benutzer sowie administrative Aktionen.</div></div></div><section class="panel"><div id="sessions"></div></section></section>
|
||||
<section class="page-section" id="operations-section"><div class="section-heading"><div><div class="eyebrow">Operations</div><h2>Betriebsdaten</h2><div class="section-copy">RemoteApps, Profil-Pipeline und lokale Ereignisse kompakt in einer Arbeitsfläche.</div></div></div><section class="panel"><div class="detail-tabs" style="position:static"><button class="detail-tab active" type="button" data-agent-tab="remoteapps">RemoteApps</button><button class="detail-tab" type="button" data-agent-tab="profiles">Profil-Pipeline</button><button class="detail-tab" type="button" data-agent-tab="events">Aktivitätslog</button></div><div class="detail-pane active" data-agent-pane="remoteapps"><div id="remoteapps"></div></div><div class="detail-pane" data-agent-pane="profiles"><div id="profiles"></div></div><div class="detail-pane" data-agent-pane="events"><div id="events"></div></div></section></section>
|
||||
<section class="page-section" id="policy-section"><div class="section-heading"><div><div class="eyebrow">Configuration</div><h2>Lokale Policy</h2><div class="section-copy">Fallback-Konfiguration für Profile, Sessions, Cleanup und Templates.</div></div><div class="section-actions"><button type="button" class="primary-soft" id="openPolicyModal">⚙ Policy bearbeiten</button></div></div><section class="panel"><div class="form"><div class="note"><strong>Fallback-Verhalten:</strong> Die lokale Policy bleibt als Notfallkonfiguration verfügbar. Eine aktive Master-Soll-Policy hat im zentral verwalteten Betrieb Vorrang.</div><div id="policySummary" class="entity-grid" style="padding:0"></div></div></section></section>
|
||||
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.1"></script></body></html>`
|
||||
</main></div></div><dialog class="sg-modal wide" id="policyModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Lokale Fallback-Policy</div><div class="modal-sub">Änderungen werden erst nach „Lokal speichern“ übernommen.</div></div><div class="actions"><span id="policyDirtyPill"></span><button type="button" class="modal-close icon-btn" id="closePolicyModal">×</button></div></div><div class="modal-body"><div id="policy"></div></div></div></dialog><script src="/app.js?v=0.5.2"></script></body></html>`
|
||||
|
||||
const agentJS = `
|
||||
let policyTemplates=[],profileFolders=[],lastSnapshot=null,policyDirty=false,policyLoaded=false;
|
||||
|
||||
306
internal/edgeguard/config.go
Normal file
306
internal/edgeguard/config.go
Normal file
@@ -0,0 +1,306 @@
|
||||
package edgeguard
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/netip"
|
||||
"os"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type RateLimit struct {
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
Burst int `json:"burst"`
|
||||
}
|
||||
|
||||
type RateRule struct {
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host,omitempty"`
|
||||
PathPrefix string `json:"path_prefix"`
|
||||
RatePerSecond float64 `json:"rate_per_second"`
|
||||
Burst int `json:"burst"`
|
||||
}
|
||||
|
||||
type AutoBanConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
Threshold int `json:"threshold"`
|
||||
WindowSeconds int `json:"window_seconds"`
|
||||
BanSeconds int `json:"ban_seconds"`
|
||||
ScannerWeight int `json:"scanner_weight"`
|
||||
MethodWeight int `json:"method_weight"`
|
||||
RateLimitWeight int `json:"rate_limit_weight"`
|
||||
InvalidURIWeight int `json:"invalid_uri_weight"`
|
||||
}
|
||||
|
||||
type Config struct {
|
||||
Listen string `json:"listen"`
|
||||
AllowedHosts []string `json:"allowed_hosts"`
|
||||
BlacklistFile string `json:"blacklist_file"`
|
||||
RateExemptFile string `json:"rate_exempt_file"`
|
||||
StateFile string `json:"state_file"`
|
||||
ReloadSeconds int `json:"reload_seconds"`
|
||||
MaxURILength int `json:"max_uri_length"`
|
||||
MaxTrackedIPs int `json:"max_tracked_ips"`
|
||||
GlobalLimit RateLimit `json:"global_limit"`
|
||||
PerIPLimit RateLimit `json:"per_ip_limit"`
|
||||
Rules []RateRule `json:"rules"`
|
||||
BlockedMethods []string `json:"blocked_methods"`
|
||||
ScannerPathPrefixes []string `json:"scanner_path_prefixes"`
|
||||
AutoBan AutoBanConfig `json:"auto_ban"`
|
||||
}
|
||||
|
||||
type RuntimeConfig struct {
|
||||
Config
|
||||
allowedHosts map[string]struct{}
|
||||
blockedMethods map[string]struct{}
|
||||
blacklist []netip.Prefix
|
||||
rateExempt []netip.Prefix
|
||||
fingerprint string
|
||||
}
|
||||
|
||||
func DefaultConfig() Config {
|
||||
return Config{
|
||||
Listen: "127.0.0.1:9081",
|
||||
ReloadSeconds: 15,
|
||||
MaxURILength: 8192,
|
||||
MaxTrackedIPs: 100000,
|
||||
GlobalLimit: RateLimit{
|
||||
RatePerSecond: 2500,
|
||||
Burst: 5000,
|
||||
},
|
||||
PerIPLimit: RateLimit{
|
||||
RatePerSecond: 200,
|
||||
Burst: 500,
|
||||
},
|
||||
Rules: []RateRule{
|
||||
{Name: "access-login", PathPrefix: "/_sessionguard/auth/login", RatePerSecond: 5, Burst: 100},
|
||||
{Name: "access-callback", PathPrefix: "/_sessionguard/auth/oidc/callback", RatePerSecond: 10, Burst: 100},
|
||||
{Name: "admin-oidc", PathPrefix: "/oidc/", RatePerSecond: 5, Burst: 50},
|
||||
},
|
||||
BlockedMethods: []string{"CONNECT", "TRACE", "TRACK"},
|
||||
ScannerPathPrefixes: []string{
|
||||
"/.env", "/.git", "/.svn", "/.hg", "/wp-admin", "/wp-login.php",
|
||||
"/phpmyadmin", "/pma", "/cgi-bin", "/server-status", "/actuator",
|
||||
"/vendor/phpunit", "/boaform", "/HNAP1", "/solr/", "/jenkins/",
|
||||
},
|
||||
AutoBan: AutoBanConfig{
|
||||
Enabled: true,
|
||||
Threshold: 10,
|
||||
WindowSeconds: 120,
|
||||
BanSeconds: 900,
|
||||
ScannerWeight: 5,
|
||||
MethodWeight: 3,
|
||||
RateLimitWeight: 0,
|
||||
InvalidURIWeight: 3,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func LoadRuntimeConfig(path string) (*RuntimeConfig, error) {
|
||||
cfg := DefaultConfig()
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(b, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("decode config: %w", err)
|
||||
}
|
||||
if err := validateConfig(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rc := &RuntimeConfig{Config: cfg}
|
||||
rc.allowedHosts = make(map[string]struct{}, len(cfg.AllowedHosts))
|
||||
for _, h := range cfg.AllowedHosts {
|
||||
h = normalizeHost(h)
|
||||
if h != "" {
|
||||
rc.allowedHosts[h] = struct{}{}
|
||||
}
|
||||
}
|
||||
rc.blockedMethods = make(map[string]struct{}, len(cfg.BlockedMethods))
|
||||
for _, m := range cfg.BlockedMethods {
|
||||
m = strings.ToUpper(strings.TrimSpace(m))
|
||||
if m != "" {
|
||||
rc.blockedMethods[m] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
rc.blacklist, err = loadPrefixFile(cfg.BlacklistFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load blacklist: %w", err)
|
||||
}
|
||||
rc.rateExempt, err = loadPrefixFile(cfg.RateExemptFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load rate exempt list: %w", err)
|
||||
}
|
||||
|
||||
h := sha256.New()
|
||||
h.Write(b)
|
||||
for _, p := range rc.blacklist {
|
||||
h.Write([]byte("blacklist:" + p.String() + "\n"))
|
||||
}
|
||||
for _, p := range rc.rateExempt {
|
||||
h.Write([]byte("rate-exempt:" + p.String() + "\n"))
|
||||
}
|
||||
rc.fingerprint = hex.EncodeToString(h.Sum(nil))
|
||||
return rc, nil
|
||||
}
|
||||
|
||||
func validateConfig(cfg *Config) error {
|
||||
if strings.TrimSpace(cfg.Listen) == "" {
|
||||
return fmt.Errorf("listen must not be empty")
|
||||
}
|
||||
if cfg.ReloadSeconds <= 0 {
|
||||
cfg.ReloadSeconds = 15
|
||||
}
|
||||
if cfg.MaxURILength <= 0 {
|
||||
cfg.MaxURILength = 8192
|
||||
}
|
||||
if cfg.MaxTrackedIPs <= 0 {
|
||||
cfg.MaxTrackedIPs = 100000
|
||||
}
|
||||
if err := validateRate("global_limit", cfg.GlobalLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := validateRate("per_ip_limit", cfg.PerIPLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
for i := range cfg.Rules {
|
||||
r := &cfg.Rules[i]
|
||||
r.Host = normalizeHost(r.Host)
|
||||
r.PathPrefix = strings.ToLower(strings.TrimSpace(r.PathPrefix))
|
||||
if r.PathPrefix == "" {
|
||||
return fmt.Errorf("rules[%d].path_prefix must not be empty", i)
|
||||
}
|
||||
if r.RatePerSecond <= 0 || r.Burst <= 0 {
|
||||
return fmt.Errorf("rules[%d] must have positive rate_per_second and burst", i)
|
||||
}
|
||||
}
|
||||
if cfg.AutoBan.Enabled {
|
||||
if cfg.AutoBan.Threshold <= 0 || cfg.AutoBan.WindowSeconds <= 0 || cfg.AutoBan.BanSeconds <= 0 {
|
||||
return fmt.Errorf("auto_ban threshold/window_seconds/ban_seconds must be positive")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRate(name string, r RateLimit) error {
|
||||
if r.RatePerSecond <= 0 || r.Burst <= 0 {
|
||||
return fmt.Errorf("%s must have positive rate_per_second and burst", name)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func loadPrefixFile(path string) ([]netip.Prefix, error) {
|
||||
path = strings.TrimSpace(path)
|
||||
if path == "" {
|
||||
return nil, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
var out []netip.Prefix
|
||||
s := bufio.NewScanner(f)
|
||||
lineNo := 0
|
||||
for s.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(s.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
if i := strings.IndexByte(line, '#'); i >= 0 {
|
||||
line = strings.TrimSpace(line[:i])
|
||||
}
|
||||
p, err := parsePrefix(line)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s:%d: %w", path, lineNo, err)
|
||||
}
|
||||
out = append(out, p)
|
||||
}
|
||||
if err := s.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sort.Slice(out, func(i, j int) bool { return out[i].String() < out[j].String() })
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parsePrefix(s string) (netip.Prefix, error) {
|
||||
if p, err := netip.ParsePrefix(s); err == nil {
|
||||
return p.Masked(), nil
|
||||
}
|
||||
a, err := netip.ParseAddr(s)
|
||||
if err != nil {
|
||||
return netip.Prefix{}, fmt.Errorf("invalid IP/CIDR %q", s)
|
||||
}
|
||||
bits := 128
|
||||
if a.Is4() {
|
||||
bits = 32
|
||||
}
|
||||
return netip.PrefixFrom(a, bits), nil
|
||||
}
|
||||
|
||||
func normalizeHost(s string) string {
|
||||
s = strings.TrimSpace(strings.ToLower(s))
|
||||
if i := strings.IndexByte(s, ':'); i > 0 && strings.Count(s, ":") == 1 {
|
||||
s = s[:i]
|
||||
}
|
||||
return strings.TrimSuffix(s, ".")
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) IsBlacklisted(ip netip.Addr) bool {
|
||||
return containsPrefix(r.blacklist, ip)
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) IsRateExempt(ip netip.Addr) bool {
|
||||
return containsPrefix(r.rateExempt, ip)
|
||||
}
|
||||
|
||||
func containsPrefix(prefixes []netip.Prefix, ip netip.Addr) bool {
|
||||
for _, p := range prefixes {
|
||||
if p.Contains(ip) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) HostAllowed(host string) bool {
|
||||
if len(r.allowedHosts) == 0 {
|
||||
return true
|
||||
}
|
||||
_, ok := r.allowedHosts[normalizeHost(host)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) MethodBlocked(method string) bool {
|
||||
_, ok := r.blockedMethods[strings.ToUpper(strings.TrimSpace(method))]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) MatchingRule(host, path string) *RateRule {
|
||||
host = normalizeHost(host)
|
||||
for i := range r.Rules {
|
||||
rule := &r.Rules[i]
|
||||
if rule.Host != "" && rule.Host != host {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(path, rule.PathPrefix) {
|
||||
return rule
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) ReloadInterval() time.Duration {
|
||||
return time.Duration(r.ReloadSeconds) * time.Second
|
||||
}
|
||||
|
||||
func (r *RuntimeConfig) Fingerprint() string { return r.fingerprint }
|
||||
440
internal/edgeguard/guard.go
Normal file
440
internal/edgeguard/guard.go
Normal file
@@ -0,0 +1,440 @@
|
||||
package edgeguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"net/url"
|
||||
"os"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type bucket struct {
|
||||
tokens float64
|
||||
last time.Time
|
||||
}
|
||||
|
||||
type offenseState struct {
|
||||
points int
|
||||
start time.Time
|
||||
}
|
||||
|
||||
type clientState struct {
|
||||
bucket bucket
|
||||
rules map[string]*bucket
|
||||
offense offenseState
|
||||
lastSeen time.Time
|
||||
}
|
||||
|
||||
type persistedState struct {
|
||||
Bans map[string]time.Time `json:"bans"`
|
||||
}
|
||||
|
||||
type Counters struct {
|
||||
Requests atomic.Uint64
|
||||
Allowed atomic.Uint64
|
||||
DeniedBlacklist atomic.Uint64
|
||||
DeniedBan atomic.Uint64
|
||||
DeniedRate atomic.Uint64
|
||||
DeniedGlobal atomic.Uint64
|
||||
DeniedScanner atomic.Uint64
|
||||
DeniedMethod atomic.Uint64
|
||||
DeniedHost atomic.Uint64
|
||||
DeniedInvalid atomic.Uint64
|
||||
DeniedCapacity atomic.Uint64
|
||||
AutoBans atomic.Uint64
|
||||
}
|
||||
|
||||
type Decision struct {
|
||||
Allowed bool
|
||||
StatusCode int
|
||||
Reason string
|
||||
RetryAfter int
|
||||
}
|
||||
|
||||
type Guard struct {
|
||||
mu sync.Mutex
|
||||
cfg atomic.Pointer[RuntimeConfig]
|
||||
global bucket
|
||||
clients map[netip.Addr]*clientState
|
||||
bans map[netip.Addr]time.Time
|
||||
stateDirty bool
|
||||
counters Counters
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewGuard(cfg *RuntimeConfig, logger *slog.Logger) *Guard {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
g := &Guard{
|
||||
clients: make(map[netip.Addr]*clientState),
|
||||
bans: make(map[netip.Addr]time.Time),
|
||||
logger: logger,
|
||||
}
|
||||
g.cfg.Store(cfg)
|
||||
g.loadState(cfg.StateFile)
|
||||
return g
|
||||
}
|
||||
|
||||
func (g *Guard) Config() *RuntimeConfig { return g.cfg.Load() }
|
||||
|
||||
func (g *Guard) ReplaceConfig(cfg *RuntimeConfig) {
|
||||
old := g.cfg.Swap(cfg)
|
||||
if old == nil || old.Fingerprint() != cfg.Fingerprint() {
|
||||
g.logger.Info("edgeguard configuration loaded", "fingerprint", cfg.Fingerprint()[:12])
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) Check(ip netip.Addr, host, method, rawURI string, now time.Time) Decision {
|
||||
g.counters.Requests.Add(1)
|
||||
cfg := g.cfg.Load()
|
||||
if cfg == nil {
|
||||
return Decision{StatusCode: 503, Reason: "configuration unavailable"}
|
||||
}
|
||||
if !ip.IsValid() {
|
||||
g.counters.DeniedInvalid.Add(1)
|
||||
return Decision{StatusCode: 400, Reason: "invalid client ip"}
|
||||
}
|
||||
if len(rawURI) == 0 || len(rawURI) > cfg.MaxURILength {
|
||||
g.counters.DeniedInvalid.Add(1)
|
||||
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
|
||||
return Decision{StatusCode: 414, Reason: "invalid uri"}
|
||||
}
|
||||
|
||||
path, ok := normalizeRequestPath(rawURI)
|
||||
if !ok {
|
||||
g.counters.DeniedInvalid.Add(1)
|
||||
g.addOffense(ip, cfg.AutoBan.InvalidURIWeight, now, cfg)
|
||||
return Decision{StatusCode: 400, Reason: "invalid uri"}
|
||||
}
|
||||
|
||||
if !cfg.HostAllowed(host) {
|
||||
g.counters.DeniedHost.Add(1)
|
||||
return Decision{StatusCode: 421, Reason: "host not allowed"}
|
||||
}
|
||||
if cfg.IsBlacklisted(ip) {
|
||||
g.counters.DeniedBlacklist.Add(1)
|
||||
return Decision{StatusCode: 403, Reason: "blacklisted"}
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
if until, ok := g.bans[ip]; ok {
|
||||
if now.Before(until) {
|
||||
g.mu.Unlock()
|
||||
g.counters.DeniedBan.Add(1)
|
||||
return Decision{StatusCode: 403, Reason: "temporarily banned", RetryAfter: max(1, int(until.Sub(now).Seconds()))}
|
||||
}
|
||||
delete(g.bans, ip)
|
||||
g.stateDirty = true
|
||||
}
|
||||
g.mu.Unlock()
|
||||
|
||||
if cfg.MethodBlocked(method) {
|
||||
g.counters.DeniedMethod.Add(1)
|
||||
g.addOffense(ip, cfg.AutoBan.MethodWeight, now, cfg)
|
||||
return Decision{StatusCode: 405, Reason: "method blocked"}
|
||||
}
|
||||
if scannerPath(path, cfg.ScannerPathPrefixes) {
|
||||
g.counters.DeniedScanner.Add(1)
|
||||
g.addOffense(ip, cfg.AutoBan.ScannerWeight, now, cfg)
|
||||
return Decision{StatusCode: 404, Reason: "scanner path"}
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
if !take(&g.global, cfg.GlobalLimit.RatePerSecond, cfg.GlobalLimit.Burst, now) {
|
||||
g.counters.DeniedGlobal.Add(1)
|
||||
return Decision{StatusCode: 429, Reason: "global rate limit", RetryAfter: 1}
|
||||
}
|
||||
|
||||
cs := g.clients[ip]
|
||||
if cs == nil {
|
||||
if len(g.clients) >= cfg.MaxTrackedIPs {
|
||||
g.counters.DeniedCapacity.Add(1)
|
||||
return Decision{StatusCode: 429, Reason: "edge state capacity", RetryAfter: 1}
|
||||
}
|
||||
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
|
||||
g.clients[ip] = cs
|
||||
}
|
||||
cs.lastSeen = now
|
||||
if !cfg.IsRateExempt(ip) {
|
||||
if !take(&cs.bucket, cfg.PerIPLimit.RatePerSecond, cfg.PerIPLimit.Burst, now) {
|
||||
g.counters.DeniedRate.Add(1)
|
||||
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
|
||||
return Decision{StatusCode: 429, Reason: "per-ip rate limit", RetryAfter: 1}
|
||||
}
|
||||
if rule := cfg.MatchingRule(host, path); rule != nil {
|
||||
rb := cs.rules[rule.Name]
|
||||
if rb == nil {
|
||||
rb = &bucket{}
|
||||
cs.rules[rule.Name] = rb
|
||||
}
|
||||
if !take(rb, rule.RatePerSecond, rule.Burst, now) {
|
||||
g.counters.DeniedRate.Add(1)
|
||||
g.addOffenseLocked(ip, cs, cfg.AutoBan.RateLimitWeight, now, cfg)
|
||||
return Decision{StatusCode: 429, Reason: "endpoint rate limit", RetryAfter: 1}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
g.counters.Allowed.Add(1)
|
||||
return Decision{Allowed: true, StatusCode: 204, Reason: "allow"}
|
||||
}
|
||||
|
||||
func take(b *bucket, rate float64, burst int, now time.Time) bool {
|
||||
if b.last.IsZero() {
|
||||
b.tokens = float64(burst)
|
||||
b.last = now
|
||||
}
|
||||
elapsed := now.Sub(b.last).Seconds()
|
||||
if elapsed > 0 {
|
||||
b.tokens += elapsed * rate
|
||||
if b.tokens > float64(burst) {
|
||||
b.tokens = float64(burst)
|
||||
}
|
||||
b.last = now
|
||||
}
|
||||
if b.tokens < 1 {
|
||||
return false
|
||||
}
|
||||
b.tokens--
|
||||
return true
|
||||
}
|
||||
|
||||
func normalizeRequestPath(rawURI string) (string, bool) {
|
||||
u, err := url.ParseRequestURI(rawURI)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
p := u.EscapedPath()
|
||||
if p == "" {
|
||||
p = "/"
|
||||
}
|
||||
decoded, err := url.PathUnescape(p)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
decoded = strings.ReplaceAll(decoded, "\\", "/")
|
||||
decoded = pathpkg.Clean(decoded)
|
||||
if !strings.HasPrefix(decoded, "/") {
|
||||
decoded = "/" + decoded
|
||||
}
|
||||
return strings.ToLower(decoded), true
|
||||
}
|
||||
|
||||
func scannerPath(path string, prefixes []string) bool {
|
||||
for _, prefix := range prefixes {
|
||||
prefix = strings.ToLower(strings.TrimSpace(prefix))
|
||||
if prefix != "" && strings.HasPrefix(path, prefix) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (g *Guard) addOffense(ip netip.Addr, weight int, now time.Time, cfg *RuntimeConfig) {
|
||||
if weight <= 0 || !cfg.AutoBan.Enabled {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
cs := g.clients[ip]
|
||||
if cs == nil {
|
||||
if len(g.clients) >= cfg.MaxTrackedIPs {
|
||||
return
|
||||
}
|
||||
cs = &clientState{rules: make(map[string]*bucket), lastSeen: now}
|
||||
g.clients[ip] = cs
|
||||
}
|
||||
g.addOffenseLocked(ip, cs, weight, now, cfg)
|
||||
}
|
||||
|
||||
func (g *Guard) addOffenseLocked(ip netip.Addr, cs *clientState, weight int, now time.Time, cfg *RuntimeConfig) {
|
||||
if weight <= 0 || !cfg.AutoBan.Enabled {
|
||||
return
|
||||
}
|
||||
window := time.Duration(cfg.AutoBan.WindowSeconds) * time.Second
|
||||
if cs.offense.start.IsZero() || now.Sub(cs.offense.start) > window {
|
||||
cs.offense = offenseState{start: now}
|
||||
}
|
||||
cs.offense.points += weight
|
||||
cs.lastSeen = now
|
||||
if cs.offense.points < cfg.AutoBan.Threshold {
|
||||
return
|
||||
}
|
||||
until := now.Add(time.Duration(cfg.AutoBan.BanSeconds) * time.Second)
|
||||
g.bans[ip] = until
|
||||
cs.offense = offenseState{}
|
||||
g.counters.AutoBans.Add(1)
|
||||
g.logger.Warn("temporary IP ban", "ip", ip.String(), "until", until.UTC().Format(time.RFC3339))
|
||||
g.stateDirty = true
|
||||
}
|
||||
|
||||
func (g *Guard) Cleanup(now time.Time) {
|
||||
cfg := g.cfg.Load()
|
||||
if cfg == nil {
|
||||
return
|
||||
}
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
for ip, until := range g.bans {
|
||||
if !now.Before(until) {
|
||||
delete(g.bans, ip)
|
||||
g.stateDirty = true
|
||||
}
|
||||
}
|
||||
stale := now.Add(-30 * time.Minute)
|
||||
for ip, cs := range g.clients {
|
||||
if cs.lastSeen.Before(stale) {
|
||||
delete(g.clients, ip)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) ActiveBans(now time.Time) int {
|
||||
g.mu.Lock()
|
||||
defer g.mu.Unlock()
|
||||
n := 0
|
||||
for _, until := range g.bans {
|
||||
if now.Before(until) {
|
||||
n++
|
||||
}
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (g *Guard) loadState(path string) {
|
||||
if strings.TrimSpace(path) == "" {
|
||||
return
|
||||
}
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if !os.IsNotExist(err) {
|
||||
g.logger.Warn("read edgeguard state", "error", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var state persistedState
|
||||
if err := json.Unmarshal(b, &state); err != nil {
|
||||
g.logger.Warn("decode edgeguard state", "error", err)
|
||||
return
|
||||
}
|
||||
now := time.Now()
|
||||
for raw, until := range state.Bans {
|
||||
ip, err := netip.ParseAddr(raw)
|
||||
if err == nil && now.Before(until) {
|
||||
g.bans[ip] = until
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (g *Guard) FlushState() {
|
||||
cfg := g.cfg.Load()
|
||||
if cfg == nil || strings.TrimSpace(cfg.StateFile) == "" {
|
||||
return
|
||||
}
|
||||
|
||||
g.mu.Lock()
|
||||
if !g.stateDirty {
|
||||
g.mu.Unlock()
|
||||
return
|
||||
}
|
||||
state := persistedState{Bans: make(map[string]time.Time)}
|
||||
now := time.Now()
|
||||
for ip, until := range g.bans {
|
||||
if now.Before(until) {
|
||||
state.Bans[ip.String()] = until
|
||||
}
|
||||
}
|
||||
g.stateDirty = false
|
||||
g.mu.Unlock()
|
||||
|
||||
if err := writeState(cfg.StateFile, state); err != nil {
|
||||
g.logger.Error("persist edgeguard state", "error", err)
|
||||
g.mu.Lock()
|
||||
g.stateDirty = true
|
||||
g.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
func writeState(path string, state persistedState) error {
|
||||
b, err := json.MarshalIndent(state, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
|
||||
return fmt.Errorf("mkdir: %w", err)
|
||||
}
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, b, 0o600); err != nil {
|
||||
return fmt.Errorf("write: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return fmt.Errorf("rename: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *Guard) Metrics() string {
|
||||
return fmt.Sprintf(`# HELP sessionguard_edgeguard_requests_total Requests evaluated by EdgeGuard.
|
||||
# TYPE sessionguard_edgeguard_requests_total counter
|
||||
sessionguard_edgeguard_requests_total %d
|
||||
# HELP sessionguard_edgeguard_allowed_total Requests allowed by EdgeGuard.
|
||||
# TYPE sessionguard_edgeguard_allowed_total counter
|
||||
sessionguard_edgeguard_allowed_total %d
|
||||
# HELP sessionguard_edgeguard_denied_blacklist_total Requests denied by static blacklist.
|
||||
# TYPE sessionguard_edgeguard_denied_blacklist_total counter
|
||||
sessionguard_edgeguard_denied_blacklist_total %d
|
||||
# HELP sessionguard_edgeguard_denied_ban_total Requests denied by temporary auto-ban.
|
||||
# TYPE sessionguard_edgeguard_denied_ban_total counter
|
||||
sessionguard_edgeguard_denied_ban_total %d
|
||||
# HELP sessionguard_edgeguard_denied_rate_total Requests denied by per-IP or endpoint rate limits.
|
||||
# TYPE sessionguard_edgeguard_denied_rate_total counter
|
||||
sessionguard_edgeguard_denied_rate_total %d
|
||||
# HELP sessionguard_edgeguard_denied_global_total Requests denied by the global rate limit.
|
||||
# TYPE sessionguard_edgeguard_denied_global_total counter
|
||||
sessionguard_edgeguard_denied_global_total %d
|
||||
# HELP sessionguard_edgeguard_denied_scanner_total Requests denied as scanner paths.
|
||||
# TYPE sessionguard_edgeguard_denied_scanner_total counter
|
||||
sessionguard_edgeguard_denied_scanner_total %d
|
||||
# HELP sessionguard_edgeguard_denied_method_total Requests denied by HTTP method policy.
|
||||
# TYPE sessionguard_edgeguard_denied_method_total counter
|
||||
sessionguard_edgeguard_denied_method_total %d
|
||||
# HELP sessionguard_edgeguard_denied_host_total Requests denied because the public host is not allowed.
|
||||
# TYPE sessionguard_edgeguard_denied_host_total counter
|
||||
sessionguard_edgeguard_denied_host_total %d
|
||||
# HELP sessionguard_edgeguard_denied_invalid_total Requests denied because the client IP or URI was invalid.
|
||||
# TYPE sessionguard_edgeguard_denied_invalid_total counter
|
||||
sessionguard_edgeguard_denied_invalid_total %d
|
||||
# HELP sessionguard_edgeguard_denied_capacity_total Requests denied because the bounded per-IP state table is full.
|
||||
# TYPE sessionguard_edgeguard_denied_capacity_total counter
|
||||
sessionguard_edgeguard_denied_capacity_total %d
|
||||
# HELP sessionguard_edgeguard_autobans_total Temporary bans created.
|
||||
# TYPE sessionguard_edgeguard_autobans_total counter
|
||||
sessionguard_edgeguard_autobans_total %d
|
||||
# HELP sessionguard_edgeguard_active_bans Current temporary bans.
|
||||
# TYPE sessionguard_edgeguard_active_bans gauge
|
||||
sessionguard_edgeguard_active_bans %d
|
||||
`,
|
||||
g.counters.Requests.Load(),
|
||||
g.counters.Allowed.Load(),
|
||||
g.counters.DeniedBlacklist.Load(),
|
||||
g.counters.DeniedBan.Load(),
|
||||
g.counters.DeniedRate.Load(),
|
||||
g.counters.DeniedGlobal.Load(),
|
||||
g.counters.DeniedScanner.Load(),
|
||||
g.counters.DeniedMethod.Load(),
|
||||
g.counters.DeniedHost.Load(),
|
||||
g.counters.DeniedInvalid.Load(),
|
||||
g.counters.DeniedCapacity.Load(),
|
||||
g.counters.AutoBans.Load(),
|
||||
g.ActiveBans(time.Now()),
|
||||
)
|
||||
}
|
||||
217
internal/edgeguard/guard_test.go
Normal file
217
internal/edgeguard/guard_test.go
Normal file
@@ -0,0 +1,217 @@
|
||||
package edgeguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/netip"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func testConfig(t *testing.T, mutate func(*Config)) *RuntimeConfig {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
cfg := DefaultConfig()
|
||||
cfg.Listen = "127.0.0.1:0"
|
||||
cfg.BlacklistFile = filepath.Join(dir, "blacklist.txt")
|
||||
cfg.RateExemptFile = filepath.Join(dir, "rate-exempt.txt")
|
||||
if err := os.WriteFile(cfg.BlacklistFile, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(cfg.RateExemptFile, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg.StateFile = filepath.Join(dir, "state.json")
|
||||
cfg.AllowedHosts = []string{"ts.example.test", "sessionguard.example.test"}
|
||||
if mutate != nil {
|
||||
mutate(&cfg)
|
||||
}
|
||||
b, err := jsonMarshal(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
path := filepath.Join(dir, "edgeguard.json")
|
||||
if err := os.WriteFile(path, b, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
rc, err := LoadRuntimeConfig(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return rc
|
||||
}
|
||||
|
||||
func jsonMarshal(v any) ([]byte, error) {
|
||||
return json.MarshalIndent(v, "", " ")
|
||||
}
|
||||
|
||||
func newTestGuard(cfg *RuntimeConfig) *Guard {
|
||||
return NewGuard(cfg, slog.New(slog.NewTextHandler(os.Stderr, nil)))
|
||||
}
|
||||
|
||||
func TestAllowsNormalRequest(t *testing.T) {
|
||||
g := newTestGuard(testConfig(t, nil))
|
||||
d := g.Check(netip.MustParseAddr("203.0.113.10"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
|
||||
if !d.Allowed || d.StatusCode != 204 {
|
||||
t.Fatalf("unexpected decision: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlacklistCIDR(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
blacklist := filepath.Join(dir, "blacklist.txt")
|
||||
if err := os.WriteFile(blacklist, []byte("203.0.113.0/24\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.AllowedHosts = []string{"ts.example.test"}
|
||||
cfg.BlacklistFile = blacklist
|
||||
cfg.RateExemptFile = ""
|
||||
cfg.StateFile = filepath.Join(dir, "state.json")
|
||||
b, _ := json.Marshal(cfg)
|
||||
configPath := filepath.Join(dir, "edgeguard.json")
|
||||
_ = os.WriteFile(configPath, b, 0o600)
|
||||
rc, err := LoadRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := newTestGuard(rc)
|
||||
d := g.Check(netip.MustParseAddr("203.0.113.99"), "ts.example.test", "GET", "/", time.Unix(1000, 0))
|
||||
if d.StatusCode != 403 || d.Reason != "blacklisted" {
|
||||
t.Fatalf("unexpected decision: %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScannerTriggersAutoBan(t *testing.T) {
|
||||
cfg := testConfig(t, func(c *Config) {
|
||||
c.AutoBan.Threshold = 10
|
||||
c.AutoBan.ScannerWeight = 5
|
||||
})
|
||||
g := newTestGuard(cfg)
|
||||
ip := netip.MustParseAddr("198.51.100.7")
|
||||
now := time.Unix(1000, 0)
|
||||
for i := 0; i < 2; i++ {
|
||||
d := g.Check(ip, "ts.example.test", "GET", "/.env", now.Add(time.Duration(i)*time.Second))
|
||||
if d.StatusCode != 404 {
|
||||
t.Fatalf("scanner request %d: %+v", i, d)
|
||||
}
|
||||
}
|
||||
d := g.Check(ip, "ts.example.test", "GET", "/", now.Add(3*time.Second))
|
||||
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
|
||||
t.Fatalf("expected temp ban, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPerIPRateLimit(t *testing.T) {
|
||||
cfg := testConfig(t, func(c *Config) {
|
||||
c.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 2}
|
||||
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
||||
c.AutoBan.Enabled = false
|
||||
c.Rules = nil
|
||||
})
|
||||
g := newTestGuard(cfg)
|
||||
ip := netip.MustParseAddr("198.51.100.8")
|
||||
now := time.Unix(1000, 0)
|
||||
for i := 0; i < 2; i++ {
|
||||
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
|
||||
t.Fatalf("request %d should be allowed: %+v", i, d)
|
||||
}
|
||||
}
|
||||
d := g.Check(ip, "ts.example.test", "GET", "/", now)
|
||||
if d.StatusCode != 429 {
|
||||
t.Fatalf("expected 429, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRateExemptCIDR(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
exempt := filepath.Join(dir, "exempt.txt")
|
||||
if err := os.WriteFile(exempt, []byte("198.51.100.0/24\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg := DefaultConfig()
|
||||
cfg.AllowedHosts = []string{"ts.example.test"}
|
||||
cfg.BlacklistFile = ""
|
||||
cfg.RateExemptFile = exempt
|
||||
cfg.PerIPLimit = RateLimit{RatePerSecond: 1, Burst: 1}
|
||||
cfg.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
||||
cfg.Rules = nil
|
||||
cfg.StateFile = filepath.Join(dir, "state.json")
|
||||
b, _ := json.Marshal(cfg)
|
||||
configPath := filepath.Join(dir, "edgeguard.json")
|
||||
_ = os.WriteFile(configPath, b, 0o600)
|
||||
rc, err := LoadRuntimeConfig(configPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
g := newTestGuard(rc)
|
||||
ip := netip.MustParseAddr("198.51.100.42")
|
||||
now := time.Unix(1000, 0)
|
||||
for i := 0; i < 20; i++ {
|
||||
if d := g.Check(ip, "ts.example.test", "GET", "/", now); !d.Allowed {
|
||||
t.Fatalf("request %d unexpectedly denied: %+v", i, d)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBlockedMethodAndHost(t *testing.T) {
|
||||
g := newTestGuard(testConfig(t, nil))
|
||||
ip := netip.MustParseAddr("192.0.2.10")
|
||||
now := time.Unix(1000, 0)
|
||||
if d := g.Check(ip, "unknown.example.test", "GET", "/", now); d.StatusCode != 421 {
|
||||
t.Fatalf("expected 421, got %+v", d)
|
||||
}
|
||||
if d := g.Check(ip, "ts.example.test", "TRACE", "/", now); d.StatusCode != 405 {
|
||||
t.Fatalf("expected 405, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEncodedScannerPath(t *testing.T) {
|
||||
g := newTestGuard(testConfig(t, nil))
|
||||
d := g.Check(netip.MustParseAddr("192.0.2.20"), "ts.example.test", "GET", "/.%65nv", time.Unix(1000, 0))
|
||||
if d.StatusCode != 404 {
|
||||
t.Fatalf("expected encoded /.env to be denied, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTemporaryBanPersistsAcrossRestart(t *testing.T) {
|
||||
cfg := testConfig(t, func(c *Config) {
|
||||
c.AutoBan.Threshold = 5
|
||||
c.AutoBan.ScannerWeight = 5
|
||||
})
|
||||
ip := netip.MustParseAddr("203.0.113.77")
|
||||
now := time.Now()
|
||||
g1 := newTestGuard(cfg)
|
||||
if d := g1.Check(ip, "ts.example.test", "GET", "/.env", now); d.StatusCode != 404 {
|
||||
t.Fatalf("expected scanner deny, got %+v", d)
|
||||
}
|
||||
g1.FlushState()
|
||||
g2 := newTestGuard(cfg)
|
||||
d := g2.Check(ip, "ts.example.test", "GET", "/", now.Add(time.Second))
|
||||
if d.StatusCode != 403 || d.Reason != "temporarily banned" {
|
||||
t.Fatalf("expected persisted temporary ban, got %+v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTrackedIPCapacityIsBounded(t *testing.T) {
|
||||
cfg := testConfig(t, func(c *Config) {
|
||||
c.MaxTrackedIPs = 2
|
||||
c.GlobalLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
||||
c.PerIPLimit = RateLimit{RatePerSecond: 1000, Burst: 1000}
|
||||
c.Rules = nil
|
||||
c.AutoBan.Enabled = false
|
||||
})
|
||||
g := newTestGuard(cfg)
|
||||
now := time.Unix(1000, 0)
|
||||
for _, raw := range []string{"192.0.2.1", "192.0.2.2"} {
|
||||
if d := g.Check(netip.MustParseAddr(raw), "ts.example.test", "GET", "/", now); !d.Allowed {
|
||||
t.Fatalf("first two IPs should be tracked: %s %+v", raw, d)
|
||||
}
|
||||
}
|
||||
d := g.Check(netip.MustParseAddr("192.0.2.3"), "ts.example.test", "GET", "/", now)
|
||||
if d.StatusCode != 429 || d.Reason != "edge state capacity" {
|
||||
t.Fatalf("expected bounded-state 429, got %+v", d)
|
||||
}
|
||||
}
|
||||
80
internal/edgeguard/http.go
Normal file
80
internal/edgeguard/http.go
Normal file
@@ -0,0 +1,80 @@
|
||||
package edgeguard
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"net/netip"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
type HTTPServer struct {
|
||||
guard *Guard
|
||||
logger *slog.Logger
|
||||
}
|
||||
|
||||
func NewHTTPServer(g *Guard, logger *slog.Logger) *HTTPServer {
|
||||
if logger == nil {
|
||||
logger = slog.Default()
|
||||
}
|
||||
return &HTTPServer{guard: g, logger: logger}
|
||||
}
|
||||
|
||||
func (s *HTTPServer) Handler() http.Handler {
|
||||
mux := http.NewServeMux()
|
||||
mux.HandleFunc("GET /healthz", s.healthz)
|
||||
mux.HandleFunc("GET /metrics", s.metrics)
|
||||
mux.HandleFunc("GET /check", s.check)
|
||||
return mux
|
||||
}
|
||||
|
||||
func (s *HTTPServer) healthz(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) metrics(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/plain; version=0.0.4; charset=utf-8")
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
_, _ = w.Write([]byte(s.guard.Metrics()))
|
||||
}
|
||||
|
||||
func (s *HTTPServer) check(w http.ResponseWriter, r *http.Request) {
|
||||
rawIP := strings.TrimSpace(r.Header.Get("X-Edge-Client-IP"))
|
||||
ip, err := netip.ParseAddr(rawIP)
|
||||
if err != nil {
|
||||
s.writeDeny(w, http.StatusBadRequest, "invalid client ip", 0)
|
||||
return
|
||||
}
|
||||
host := r.Header.Get("X-Edge-Original-Host")
|
||||
method := r.Header.Get("X-Edge-Original-Method")
|
||||
uri := r.Header.Get("X-Edge-Original-URI")
|
||||
decision := s.guard.Check(ip.Unmap(), host, method, uri, time.Now())
|
||||
if decision.Allowed {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
return
|
||||
}
|
||||
|
||||
// Do not log every denial here: during an attack that would turn logging
|
||||
// itself into a resource-exhaustion vector. Caddy access logs are sampled
|
||||
// and EdgeGuard exposes counters; only state changes such as auto-bans are
|
||||
// emitted as runtime log events.
|
||||
s.writeDeny(w, decision.StatusCode, decision.Reason, decision.RetryAfter)
|
||||
}
|
||||
|
||||
func (s *HTTPServer) writeDeny(w http.ResponseWriter, code int, reason string, retryAfter int) {
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
w.Header().Set("X-Content-Type-Options", "nosniff")
|
||||
if retryAfter > 0 {
|
||||
w.Header().Set("Retry-After", strconv.Itoa(retryAfter))
|
||||
}
|
||||
w.WriteHeader(code)
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"error": http.StatusText(code),
|
||||
"reason": reason,
|
||||
})
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -36,7 +36,7 @@ button{display:inline-flex;align-items:center;justify-content:center;gap:6px;bac
|
||||
@media(max-width:720px){.app-shell{display:block}.sidebar{position:fixed;left:0;top:0;transform:translateX(-102%);width:min(290px,86vw);transition:transform .2s ease;box-shadow:var(--shadow)}body.nav-open .sidebar{transform:translateX(0)}body.nav-open .mobile-overlay{display:block;position:fixed;inset:0;background:rgba(0,0,0,.48);z-index:25}.menu-toggle{display:inline-flex}.topbar{height:60px}.live-pill{display:none}.page{padding:14px}.metrics{grid-template-columns:repeat(2,1fr)}.section-heading{align-items:flex-start;flex-direction:column}.table th,.table td{white-space:nowrap}.form{padding:13px}}
|
||||
@media(max-width:430px){.metrics{grid-template-columns:1fr 1fr}.metric-card,.card{min-height:88px;padding:12px}.value{font-size:22px}.topbar-actions .theme-top{display:none}}
|
||||
|
||||
/* SessionGuard UI v0.5.1 — modal-first responsive console */
|
||||
/* SessionGuard UI v0.5.2 — modal-first responsive console */
|
||||
:root{--radius-lg:22px;--radius-xl:28px;--focus:0 0 0 4px var(--primary-soft)}
|
||||
body{background:radial-gradient(circle at 88% -10%,color-mix(in srgb,var(--primary) 13%,transparent),transparent 34rem),var(--bg)}
|
||||
.page{max-width:1680px}.topbar{box-shadow:0 1px 0 rgba(0,0,0,.03)}
|
||||
@@ -69,7 +69,7 @@ body.modal-open{overflow:hidden}
|
||||
|
||||
</style></head><body>
|
||||
<div class="mobile-overlay" id="mobileOverlay"></div><div class="app-shell">
|
||||
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.1</div></div></div>
|
||||
<aside class="sidebar" id="sidebar"><div class="brand-block"><div class="logo">SG</div><div><div class="brand-name">SessionGuard</div><div class="brand-sub">Control Plane · v0.5.2</div></div></div>
|
||||
<nav class="nav-group"><div class="nav-label">Übersicht</div><a class="nav-link active" href="#overview"><span class="nav-icon">⌂</span>Dashboard</a><a class="nav-link" href="#servers"><span class="nav-icon">▣</span>Terminalserver</a><a class="nav-link" href="#farms-section"><span class="nav-icon">⌘</span>Farms & Broker</a><a class="nav-link" href="#resources-section"><span class="nav-icon">◆</span>Apps & Desktops</a><a class="nav-link" href="#access-section"><span class="nav-icon">◎</span>Access-Sessions</a></nav>
|
||||
<nav class="nav-group"><div class="nav-label">Betrieb</div><a class="nav-link" href="#history-section"><span class="nav-icon">↻</span>Session-Historie</a><a class="nav-link" href="#policies-section"><span class="nav-icon">≋</span>Policy-Historie</a><a class="nav-link" href="#alerts-section"><span class="nav-icon">!</span>Alerts</a><a class="nav-link" href="#audit-section"><span class="nav-icon">✓</span>Audit-Log</a></nav>
|
||||
<div class="sidebar-spacer"></div><div class="sidebar-footer"><button class="secondary theme-toggle" id="themeToggle" type="button">◐ Theme wechseln</button><form action="/logout" method="post"><button class="ghost" style="width:100%">Abmelden</button></form></div></aside>
|
||||
@@ -88,7 +88,7 @@ body.modal-open{overflow:hidden}
|
||||
<dialog class="sg-modal wide" id="agentModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="detailTitle">Terminalserver</div><div class="modal-sub">Live-Daten, Sitzungen und Richtlinien</div></div><button type="button" class="modal-close icon-btn" data-close-modal="agentModal" aria-label="Schließen">×</button></div><div class="modal-body"><div id="detail" class="empty">Server wird geladen…</div></div></div></dialog>
|
||||
<dialog class="sg-modal compact" id="farmModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title">Neue Farm</div><div class="modal-sub">Broker-Ziel und optionale Tag-Selektion definieren</div></div><button type="button" class="modal-close icon-btn" data-close-modal="farmModal">×</button></div><div class="modal-body pad"><div id="farmEditor" class="form"><div class="form-grid"><label>Name<input id="farmName" placeholder="Office"></label><label>Beschreibung<input id="farmDesc" placeholder="Standard Desktop-Farm"></label></div><label>Required Tags (key=value)<textarea id="farmTags" placeholder="role=office"></textarea></label><div class="actions end"><button type="button" class="secondary" data-close-modal="farmModal">Abbrechen</button><button type="button" data-global-action="create-farm">Farm anlegen</button></div></div></div></div></dialog>
|
||||
<dialog class="sg-modal" id="resourceModal"><div class="modal-shell"><div class="modal-head"><div><div class="modal-title" id="resourceModalTitle">Resource anlegen</div><div class="modal-sub">Desktop oder RemoteApp für eine Farm veröffentlichen</div></div><button type="button" class="modal-close icon-btn" data-close-modal="resourceModal">×</button></div><div class="modal-body"><div id="resourceEditor" class="form"><div class="form-grid three"><label>Name<input id="resName" placeholder="Sage 100"></label><label>Typ<select id="resKind"><option value="desktop">Desktop</option><option value="remoteapp">RemoteApp</option></select></label><label>Farm<select id="resFarm"></select></label><label>Guacamole Connection ID<input id="resConnID"></label><label>Guacamole Connection Name<input id="resConnName" placeholder="Sage"></label><label>RemoteApp Alias<input id="resRemoteApp" placeholder="||Sage"></label><label>Guacamole Working Dir<input id="resRemoteDir"></label><label>Guacamole Argumente<input id="resRemoteArgs"></label></div><div class="note" id="remoteAppManageNote"><strong>Agent-gesteuerte RemoteApp-Veröffentlichung</strong><br>SessionGuard hält den Alias auf allen geeigneten Farm-Agents im Sollzustand; fehlerhafte Hosts werden für diese Resource nicht gebrokert.</div><div class="check"><input id="resManage" type="checkbox"><label>RemoteApp auf Farm-Agents automatisch veröffentlichen und überwachen</label></div><div class="form-grid three"><label>Executable auf dem Terminalserver<input id="resRemotePath" placeholder="C:\Program Files\Hersteller\app.exe"></label><label>Icon-Pfad (optional)<input id="resRemoteIcon" placeholder="leer = Executable"></label><label>Icon-Index<input id="resIconIndex" type="number" value="0"></label><label>Command-Line-Policy<select id="resCmd"><option value="0">Keine Argumente erlauben</option><option value="1">Argumente aus RDP/Guacamole erlauben</option><option value="2">Vorgegebene Argumente erzwingen</option></select></label><label>Vorgegebene Argumente<input id="resRemoteRequired"></label><div class="check"><input id="resPortal" type="checkbox"><label>Auch in RD Web Access anzeigen</label></div></div><div class="actions end"><button type="button" class="secondary" data-close-modal="resourceModal">Abbrechen</button><button id="resSave" type="button" data-global-action="save-resource">Resource anlegen</button></div></div></div></div></dialog>
|
||||
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.1"></script></body></html>`
|
||||
<div class="toast" id="toast"></div><script src="/app.js?v=0.5.2"></script></body></html>`
|
||||
|
||||
const masterJS = `
|
||||
let selected=null,current=null,editorAgent=null,resourceEditID=null,policyTemplates=[],profileFolders=[],agentCache=[],farmCache=[],resourceCache=[],brokerLeases=[],policyHistory=[],me=null;const $=id=>document.getElementById(id);const esc=s=>String(s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));function when(v){if(!v)return'–';let d=new Date(v);return Number.isNaN(d.getTime())||d.getFullYear()<2000?'–':d.toLocaleString('de-DE')}function bytes(n){if(!n)return'–';let u=['B','KB','MB','GB','TB'],i=0;while(n>=1024&&i<u.length-1){n/=1024;i++}return n.toFixed(i>1?1:0)+' '+u[i]}function toast(t){let e=$('toast');e.textContent=t;e.style.display='block';setTimeout(()=>e.style.display='none',3000)}async function api(u,o){let r=await fetch(u,o);if(r.status===401){location='/login';return}let j=await r.json().catch(()=>({}));if(!r.ok)throw new Error(j.error||r.statusText);return j}function lines(id){return $(id).value.split('\n').map(x=>x.trim()).filter(Boolean)}function openModal(id){let d=$(id);if(!d)return;document.body.classList.add('modal-open');if(!d.open)d.showModal()}function closeModal(id){let d=$(id);if(!d)return;if(d.open)d.close();if(!document.querySelector('dialog[open]'))document.body.classList.remove('modal-open')}function activateDetailTab(name){document.querySelectorAll('#detail .detail-tab').forEach(b=>b.classList.toggle('active',b.dataset.tab===name));document.querySelectorAll('#detail .detail-pane').forEach(p=>p.classList.toggle('active',p.dataset.pane===name))}
|
||||
|
||||
181
production/EDGE-SECURITY.md
Normal file
181
production/EDGE-SECURITY.md
Normal file
@@ -0,0 +1,181 @@
|
||||
# Public Edge Security (Caddy + SessionGuard EdgeGuard)
|
||||
|
||||
SessionGuard EdgeGuard is a small Go service for the public reverse-proxy host.
|
||||
It is **not** another login and it is not a WAF replacement. It runs locally on
|
||||
`127.0.0.1:9081` and is called by Caddy using `forward_auth` before traffic is
|
||||
sent to SessionGuard or a Guacamole worker.
|
||||
|
||||
## Threat model
|
||||
|
||||
EdgeGuard is intended to reduce the cost of common Internet abuse:
|
||||
|
||||
- repeated scanners and exploit probes;
|
||||
- excessive requests from one source;
|
||||
- request floods intended to exhaust SessionGuard/Guacamole rather than the
|
||||
physical Internet link;
|
||||
- malformed/oversized URIs and unexpected HTTP methods;
|
||||
- accidental exposure of backend-only SessionGuard endpoints.
|
||||
|
||||
It cannot stop a volumetric attack that saturates the VPS uplink. Provider-side
|
||||
DDoS filtering/firewalling remains necessary for that class of attack.
|
||||
|
||||
## Request flow
|
||||
|
||||
```text
|
||||
Browser
|
||||
-> Caddy TLS
|
||||
-> EdgeGuard /check (localhost only)
|
||||
-> 204: continue
|
||||
-> 403/404/405/421/429: stop at edge
|
||||
-> SessionGuard Access Auth
|
||||
-> Guacamole worker
|
||||
```
|
||||
|
||||
Caddy's `forward_auth` sends a lightweight GET subrequest. EdgeGuard therefore
|
||||
checks the initial WebSocket handshake, but it is not in the byte path of the
|
||||
established Guacamole WebSocket/RDP stream.
|
||||
|
||||
## Controls
|
||||
|
||||
### Static blacklist
|
||||
|
||||
`blacklist.txt` accepts one IPv4/IPv6 address or CIDR per line. Comments start
|
||||
with `#`. Changes are automatically picked up.
|
||||
|
||||
```text
|
||||
203.0.113.44
|
||||
198.51.100.0/24
|
||||
2001:db8:1234::/48
|
||||
```
|
||||
|
||||
### Global overload limit
|
||||
|
||||
The global token bucket is intentionally high and protects the application
|
||||
backends when a distributed HTTP flood reaches the VPS. It does not prevent
|
||||
network saturation because TLS and the incoming packets already reached Caddy.
|
||||
|
||||
Default production example:
|
||||
|
||||
```json
|
||||
"global_limit": {
|
||||
"rate_per_second": 2500,
|
||||
"burst": 5000
|
||||
}
|
||||
```
|
||||
|
||||
### Per-IP limit
|
||||
|
||||
The default is deliberately NAT-friendly:
|
||||
|
||||
```json
|
||||
"per_ip_limit": {
|
||||
"rate_per_second": 200,
|
||||
"burst": 500
|
||||
}
|
||||
```
|
||||
|
||||
For environments where many staff share one public NAT IP, do not aggressively
|
||||
lower this value. If a known source really needs exemption, put its address in
|
||||
`rate-exempt.txt`. Exempt sources still pass blacklist, scanner, method, host
|
||||
and global-overload checks.
|
||||
|
||||
### Endpoint-specific limits
|
||||
|
||||
The example uses tighter limits for OIDC login/callback paths. These endpoints
|
||||
do not contain the PocketID password check itself; the limits are intended to
|
||||
protect state/session allocation and redirect processing from floods.
|
||||
|
||||
### Bounded per-IP memory
|
||||
|
||||
`max_tracked_ips` bounds the in-memory table used for per-IP token buckets and
|
||||
offense state. The production example allows 100,000 active source addresses;
|
||||
when the table is full, previously unseen sources receive HTTP 429 instead of
|
||||
causing unbounded memory growth. Stale entries are cleaned up automatically.
|
||||
|
||||
### Scanner detection and temporary bans
|
||||
|
||||
Known irrelevant exploit/scanner paths are denied before they reach the
|
||||
backends. The defaults include `/.env`, `/.git`, WordPress, phpMyAdmin, CGI,
|
||||
Spring Actuator and several common automated exploit probes.
|
||||
|
||||
The example auto-ban weights are:
|
||||
|
||||
- scanner path: 5 points;
|
||||
- blocked method: 3 points;
|
||||
- malformed URI: 3 points;
|
||||
- ordinary rate-limit violation: 0 points.
|
||||
|
||||
At 10 points within 120 seconds, the IP is banned for 900 seconds. Bans are
|
||||
persisted in `/var/lib/sessionguard-edgeguard/state.json` with a short write
|
||||
debounce, so a container restart does not normally remove them while scanner
|
||||
floods cannot force one synchronous disk write per request.
|
||||
|
||||
Rate-limit violations deliberately have weight 0 by default to avoid banning a
|
||||
whole corporate NAT during a legitimate burst.
|
||||
|
||||
### Blocked HTTP methods
|
||||
|
||||
`CONNECT`, `TRACE` and `TRACK` are rejected. SessionGuard/Guacamole continue to
|
||||
use their normal GET/POST/PUT/PATCH/DELETE/OPTIONS behavior.
|
||||
|
||||
### Host allowlist
|
||||
|
||||
Only configured public host names are accepted by EdgeGuard. Caddy additionally
|
||||
uses `strict_sni_host on`, requiring the TLS SNI host and HTTP Host header to
|
||||
match.
|
||||
|
||||
## Caddy hardening
|
||||
|
||||
The supplied public Caddyfile also configures:
|
||||
|
||||
```caddyfile
|
||||
servers {
|
||||
protocols h1 h2
|
||||
strict_sni_host on
|
||||
max_header_size 64KB
|
||||
timeouts {
|
||||
read_header 10s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The short header timeout and smaller header ceiling reduce slow-header/resource
|
||||
exhaustion risk. HTTP/3 is intentionally disabled in the example to reduce the
|
||||
public protocol surface; Guacamole works with HTTP/1.1/WebSocket and HTTP/2.
|
||||
|
||||
Access logs are written to size-limited rotating files. Sampling keeps all normal
|
||||
traffic but reduces log amplification once a single logger exceeds 200 entries
|
||||
per second.
|
||||
|
||||
The public SessionGuard host denies `/metrics` and `/api/v1/broker/*` at Caddy.
|
||||
The Caddy admin API is disabled (`admin off`) on this dedicated public edge;
|
||||
configuration changes are applied by restarting the Caddy container.
|
||||
Guacamole workers call broker APIs directly over NetBird instead.
|
||||
|
||||
## Metrics
|
||||
|
||||
EdgeGuard exposes Prometheus text metrics only on localhost:
|
||||
|
||||
```text
|
||||
http://127.0.0.1:9081/metrics
|
||||
```
|
||||
|
||||
Counters include total checks, allows, static-blacklist denies, temporary-ban
|
||||
denies, rate-limit denies, scanner denies and auto-bans.
|
||||
|
||||
## Configuration reload
|
||||
|
||||
`edgeguard.json`, `blacklist.txt` and `rate-exempt.txt` are re-read on the
|
||||
configured interval (15 seconds in the example). An invalid replacement config
|
||||
is logged and the previous working configuration remains active.
|
||||
|
||||
## Operational recommendations
|
||||
|
||||
1. Keep provider/network DDoS protection enabled.
|
||||
2. Expose only TCP 80/443 publicly; keep EdgeGuard on localhost.
|
||||
3. Keep NetBird peer/backend ports private.
|
||||
4. Pin Caddy to a currently patched release rather than an old major-only image
|
||||
during controlled production rollouts.
|
||||
5. Monitor HTTP 429 and EdgeGuard auto-ban counters before tightening limits.
|
||||
6. Do not put broad office/country CIDRs on the static blacklist without first
|
||||
checking whether legitimate remote users may originate there.
|
||||
@@ -135,3 +135,7 @@ nameserver/match-domain for your AD/internal DNS zone.
|
||||
Because the three workers are stateless apart from the shared `drive` volume and
|
||||
shared PostgreSQL state, drain one worker at the Caddy/NetBird level, update it,
|
||||
then return it to service. Never create three independent Guacamole databases.
|
||||
|
||||
## Public edge security (v0.5.2)
|
||||
|
||||
The `public-vps/` stack now contains SessionGuard EdgeGuard in front of both public hosts. EdgeGuard provides CIDR blacklisting, NAT-friendly rate limiting, scanner detection, bounded per-IP state, persistent temporary bans and localhost-only metrics. The Caddy example disables its admin API, uses bounded/sampled access logs, pins Caddy 2.11.4, and exposes only the proxy service publicly. See `EDGE-SECURITY.md`.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
# Public VPS / edge
|
||||
NETBIRD_VERSION=latest
|
||||
CADDY_VERSION=2
|
||||
CADDY_VERSION=2.11.4
|
||||
EDGEGUARD_VERSION=0.5.2
|
||||
NETBIRD_PEER_NAME=public-proxy
|
||||
NETBIRD_MANAGEMENT_URL=https://netbird.example.org
|
||||
NETBIRD_SETUP_KEY=REPLACE_ME
|
||||
@@ -19,3 +20,7 @@ SESSIONGUARD_IP=10.202.0.10
|
||||
|
||||
# Generate e.g. with: openssl rand -hex 32
|
||||
GUAC_LB_SECRET=REPLACE_WITH_RANDOM_SECRET
|
||||
|
||||
# Base image name built by this repository's release workflow.
|
||||
# If your repository name differs, adjust accordingly.
|
||||
EDGEGUARD_IMAGE=git.send.nrw/sendnrw/sessionguard-edgeguard
|
||||
|
||||
@@ -1,67 +1,133 @@
|
||||
{
|
||||
email {$ACME_EMAIL}
|
||||
admin off
|
||||
|
||||
# Reduce protocol/parser attack surface without interfering with Guacamole
|
||||
# WebSockets. HTTP/3 can be enabled later if there is a concrete need.
|
||||
servers {
|
||||
protocols h1 h2
|
||||
strict_sni_host on
|
||||
max_header_size 64KB
|
||||
timeouts {
|
||||
read_header 10s
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
(edge_security_headers) {
|
||||
header {
|
||||
-Server
|
||||
Strict-Transport-Security "max-age=31536000"
|
||||
X-Content-Type-Options "nosniff"
|
||||
Referrer-Policy "strict-origin-when-cross-origin"
|
||||
Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=(), usb=()"
|
||||
}
|
||||
}
|
||||
|
||||
(edgeguard_check) {
|
||||
# EdgeGuard only listens on 127.0.0.1. These headers are overwritten by
|
||||
# Caddy and therefore cannot be forged by an Internet client.
|
||||
forward_auth 127.0.0.1:9081 {
|
||||
uri /check
|
||||
header_up X-Edge-Client-IP {client_ip}
|
||||
header_up X-Edge-Original-Host {host}
|
||||
header_up X-Edge-Original-Method {method}
|
||||
header_up X-Edge-Original-URI {uri}
|
||||
}
|
||||
}
|
||||
|
||||
# Guacamole public endpoint + SessionGuard Access Auth
|
||||
{$GUAC_HOST} {
|
||||
encode zstd gzip
|
||||
import edge_security_headers
|
||||
|
||||
# These endpoints belong to SessionGuard, but intentionally live on the
|
||||
# Guacamole hostname so the Access-Auth cookie remains host-bound.
|
||||
# handle_path removes /_sessionguard before proxying.
|
||||
handle_path /_sessionguard/* {
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
}
|
||||
route {
|
||||
# Security pre-check occurs before OIDC/Auth and before any backend.
|
||||
import edgeguard_check
|
||||
|
||||
handle {
|
||||
route {
|
||||
# Never trust identity headers supplied by an Internet client.
|
||||
request_header -X-Guacamole-User
|
||||
request_header -X-SessionGuard-User
|
||||
request_header -X-SessionGuard-Email
|
||||
request_header -X-SessionGuard-Groups
|
||||
request_header -X-Forwarded-User
|
||||
request_header -X-Authenticated-User
|
||||
# These endpoints belong to SessionGuard, but intentionally live on
|
||||
# the Guacamole hostname so the Access-Auth cookie remains host-bound.
|
||||
handle_path /_sessionguard/* {
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
}
|
||||
|
||||
# SessionGuard is the single OIDC/ForwardAuth authority.
|
||||
forward_auth {$SESSIONGUARD_IP}:8080 {
|
||||
uri /auth/verify
|
||||
copy_headers {
|
||||
X-Guacamole-User
|
||||
X-SessionGuard-User
|
||||
X-SessionGuard-Email
|
||||
X-SessionGuard-Groups
|
||||
handle {
|
||||
route {
|
||||
# Never trust identity headers supplied by an Internet client.
|
||||
request_header -X-Guacamole-User
|
||||
request_header -X-SessionGuard-User
|
||||
request_header -X-SessionGuard-Email
|
||||
request_header -X-SessionGuard-Groups
|
||||
request_header -X-Forwarded-User
|
||||
request_header -X-Authenticated-User
|
||||
|
||||
# SessionGuard is the single OIDC/ForwardAuth authority.
|
||||
forward_auth {$SESSIONGUARD_IP}:8080 {
|
||||
uri /auth/verify
|
||||
copy_headers {
|
||||
X-Guacamole-User
|
||||
X-SessionGuard-User
|
||||
X-SessionGuard-Email
|
||||
X-SessionGuard-Groups
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Sticky sessions are important because Guacamole keeps runtime
|
||||
# authentication/session state in the selected webapp process.
|
||||
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
|
||||
lb_policy cookie guac_node {$GUAC_LB_SECRET}
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
|
||||
# Guacamole with WEBAPP_CONTEXT=ROOT serves / as its frontend.
|
||||
health_uri /
|
||||
health_interval 10s
|
||||
health_timeout 3s
|
||||
# Sticky sessions are important because Guacamole keeps runtime
|
||||
# authentication/session state in the selected webapp process.
|
||||
reverse_proxy {$GUAC01_IP}:8080 {$GUAC02_IP}:8080 {$GUAC03_IP}:8080 {
|
||||
lb_policy cookie guac_node {$GUAC_LB_SECRET}
|
||||
lb_try_duration 5s
|
||||
lb_try_interval 250ms
|
||||
health_uri /
|
||||
health_interval 10s
|
||||
health_timeout 3s
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
output file /var/log/caddy/guacamole-access.log {
|
||||
roll_size 100MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
sampling {
|
||||
interval 1s
|
||||
first 200
|
||||
thereafter 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# SessionGuard administration UI, Agent endpoint and APIs.
|
||||
{$SESSIONGUARD_HOST} {
|
||||
encode zstd gzip
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
import edge_security_headers
|
||||
|
||||
route {
|
||||
import edgeguard_check
|
||||
|
||||
# These endpoints are required internally only. Broker requests from
|
||||
# Guacamole workers go directly over NetBird, never via public Caddy.
|
||||
respond /metrics 404
|
||||
respond /api/v1/broker/* 404
|
||||
|
||||
reverse_proxy {$SESSIONGUARD_IP}:8080
|
||||
}
|
||||
|
||||
log {
|
||||
output stdout
|
||||
format console
|
||||
output file /var/log/caddy/sessionguard-access.log {
|
||||
roll_size 100MiB
|
||||
roll_keep 5
|
||||
roll_keep_for 168h
|
||||
}
|
||||
format json
|
||||
sampling {
|
||||
interval 1s
|
||||
first 200
|
||||
thereafter 20
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
74
production/public-vps/README.md
Normal file
74
production/public-vps/README.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# SessionGuard Public VPS: Caddy + NetBird + EdgeGuard
|
||||
|
||||
This stack keeps public TLS termination and Guacamole load balancing on Caddy,
|
||||
uses NetBird only as the encrypted backend transport, and inserts SessionGuard
|
||||
EdgeGuard as a localhost-only request pre-check.
|
||||
|
||||
## Data path
|
||||
|
||||
```text
|
||||
Internet
|
||||
-> Caddy :443 (TLS, SNI, security headers, sticky load balancing)
|
||||
-> EdgeGuard 127.0.0.1:9081 /check
|
||||
-> SessionGuard Access Auth over NetBird
|
||||
-> Guacamole worker over NetBird
|
||||
```
|
||||
|
||||
EdgeGuard is not in the Guacamole tunnel after the WebSocket connection has
|
||||
been established. It evaluates normal HTTP requests and the WebSocket handshake.
|
||||
|
||||
## What EdgeGuard enforces
|
||||
|
||||
- static IPv4/IPv6 IP/CIDR blacklist;
|
||||
- global request rate limit to protect backends during distributed HTTP floods;
|
||||
- high per-IP request rate limit (NAT-friendly defaults);
|
||||
- tighter, configurable limits for OIDC/login/callback paths;
|
||||
- scanner-path blocking (`/.env`, `/.git`, WordPress/phpMyAdmin probes, etc.);
|
||||
- blocks CONNECT/TRACE/TRACK;
|
||||
- temporary persistent bans for clearly hostile scanner/method behavior;
|
||||
- host allowlist and URI-length validation;
|
||||
- localhost-only health and Prometheus-style metrics endpoints;
|
||||
- automatic config/list reload (15 seconds by default);
|
||||
- bounded per-IP state (100,000 entries by default) to avoid memory exhaustion from rotating source addresses.
|
||||
|
||||
The default `rate_limit_weight` for auto-ban is zero on purpose: legitimate
|
||||
users behind a shared NAT should receive 429 during extreme bursts, but should
|
||||
not cause the whole NAT address to be banned. Scanner probes are weighted much
|
||||
more strongly and are auto-banned after repeated hits.
|
||||
|
||||
## Caddy hardening
|
||||
|
||||
The supplied Caddyfile additionally enables:
|
||||
|
||||
- strict SNI/Host matching;
|
||||
- 10 second request-header timeout;
|
||||
- 64 KiB maximum request headers;
|
||||
- HTTP/1.1 + HTTP/2 only (HTTP/3 disabled to reduce exposed protocol surface);
|
||||
- HSTS and conservative security headers;
|
||||
- public blocking of SessionGuard `/metrics` and `/api/v1/broker/*`;
|
||||
- rotated JSON access logs with sampling during request floods;
|
||||
- Caddy admin API disabled (`admin off`); configuration changes use a container restart.
|
||||
|
||||
## Installation
|
||||
|
||||
1. Copy `.env.example` to `.env` and set all values.
|
||||
2. Adjust `edgeguard.json` host names if needed.
|
||||
3. Add permanent abusive IPs/CIDRs to `blacklist.txt`.
|
||||
4. Keep `rate-exempt.txt` empty unless you have a known large NAT that really
|
||||
needs exemption from per-IP limits.
|
||||
5. Start with `docker compose up -d`.
|
||||
6. Verify:
|
||||
|
||||
```bash
|
||||
curl -fsS http://127.0.0.1:9081/healthz -o /dev/null
|
||||
curl -fsS http://127.0.0.1:9081/metrics
|
||||
curl -I https://ts.hilden.info/
|
||||
```
|
||||
|
||||
## Important DDoS boundary
|
||||
|
||||
EdgeGuard protects the application/backends against HTTP request floods and
|
||||
common low-cost scanners. It cannot protect a single VPS if the Internet link
|
||||
or provider edge is saturated. Keep the VPS provider's network firewall and
|
||||
DDoS protection enabled. For a volumetric attack, filtering must happen before
|
||||
traffic reaches the VPS.
|
||||
7
production/public-vps/blacklist.txt
Normal file
7
production/public-vps/blacklist.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
# Static IP/CIDR blacklist. One IPv4/IPv6 address or CIDR per line.
|
||||
# Changes are picked up automatically (default: within 15 seconds).
|
||||
#
|
||||
# Examples:
|
||||
# 203.0.113.44
|
||||
# 198.51.100.0/24
|
||||
# 2001:db8:1234::/48
|
||||
@@ -18,13 +18,37 @@ services:
|
||||
volumes:
|
||||
- netbird-client:/var/lib/netbird
|
||||
|
||||
edgeguard:
|
||||
image: ${EDGEGUARD_IMAGE:?EDGEGUARD_IMAGE is required}:${EDGEGUARD_VERSION:-latest}
|
||||
container_name: sessionguard-edgeguard
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- netbird
|
||||
command:
|
||||
- -config
|
||||
- /etc/sessionguard-edgeguard/edgeguard.json
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:size=16m,noexec,nosuid,nodev
|
||||
volumes:
|
||||
- ./edgeguard.json:/etc/sessionguard-edgeguard/edgeguard.json:ro
|
||||
- ./blacklist.txt:/etc/sessionguard-edgeguard/blacklist.txt:ro
|
||||
- ./rate-exempt.txt:/etc/sessionguard-edgeguard/rate-exempt.txt:ro
|
||||
- edgeguard-state:/var/lib/sessionguard-edgeguard
|
||||
|
||||
caddy:
|
||||
image: caddy:${CADDY_VERSION:-2}
|
||||
image: caddy:${CADDY_VERSION:-2.11.4}
|
||||
container_name: caddy-public
|
||||
restart: unless-stopped
|
||||
network_mode: host
|
||||
depends_on:
|
||||
- netbird
|
||||
- edgeguard
|
||||
environment:
|
||||
ACME_EMAIL: ${ACME_EMAIL:?ACME_EMAIL is required}
|
||||
GUAC_HOST: ${GUAC_HOST:-ts.hilden.info}
|
||||
@@ -34,12 +58,24 @@ services:
|
||||
GUAC03_IP: ${GUAC03_IP:?GUAC03_IP is required}
|
||||
SESSIONGUARD_IP: ${SESSIONGUARD_IP:?SESSIONGUARD_IP is required}
|
||||
GUAC_LB_SECRET: ${GUAC_LB_SECRET:?GUAC_LB_SECRET is required}
|
||||
read_only: true
|
||||
cap_drop:
|
||||
- ALL
|
||||
cap_add:
|
||||
- NET_BIND_SERVICE
|
||||
security_opt:
|
||||
- no-new-privileges:true
|
||||
tmpfs:
|
||||
- /tmp:size=64m,noexec,nosuid,nodev
|
||||
volumes:
|
||||
- ./Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- caddy-data:/data
|
||||
- caddy-config:/config
|
||||
- caddy-logs:/var/log/caddy
|
||||
|
||||
volumes:
|
||||
netbird-client:
|
||||
edgeguard-state:
|
||||
caddy-data:
|
||||
caddy-config:
|
||||
caddy-logs:
|
||||
|
||||
77
production/public-vps/edgeguard.json
Normal file
77
production/public-vps/edgeguard.json
Normal file
@@ -0,0 +1,77 @@
|
||||
{
|
||||
"listen": "127.0.0.1:9081",
|
||||
"allowed_hosts": [
|
||||
"ts.hilden.info",
|
||||
"sessionguard.hilden.info"
|
||||
],
|
||||
"blacklist_file": "/etc/sessionguard-edgeguard/blacklist.txt",
|
||||
"rate_exempt_file": "/etc/sessionguard-edgeguard/rate-exempt.txt",
|
||||
"state_file": "/var/lib/sessionguard-edgeguard/state.json",
|
||||
"reload_seconds": 15,
|
||||
"max_uri_length": 8192,
|
||||
"global_limit": {
|
||||
"rate_per_second": 2500,
|
||||
"burst": 5000
|
||||
},
|
||||
"per_ip_limit": {
|
||||
"rate_per_second": 200,
|
||||
"burst": 500
|
||||
},
|
||||
"rules": [
|
||||
{
|
||||
"name": "guac-access-login",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/login",
|
||||
"rate_per_second": 5,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "guac-access-callback",
|
||||
"host": "ts.hilden.info",
|
||||
"path_prefix": "/_sessionguard/auth/oidc/callback",
|
||||
"rate_per_second": 10,
|
||||
"burst": 100
|
||||
},
|
||||
{
|
||||
"name": "sessionguard-oidc",
|
||||
"host": "sessionguard.hilden.info",
|
||||
"path_prefix": "/oidc/",
|
||||
"rate_per_second": 5,
|
||||
"burst": 50
|
||||
}
|
||||
],
|
||||
"blocked_methods": [
|
||||
"CONNECT",
|
||||
"TRACE",
|
||||
"TRACK"
|
||||
],
|
||||
"scanner_path_prefixes": [
|
||||
"/.env",
|
||||
"/.git",
|
||||
"/.svn",
|
||||
"/.hg",
|
||||
"/wp-admin",
|
||||
"/wp-login.php",
|
||||
"/phpmyadmin",
|
||||
"/pma",
|
||||
"/cgi-bin",
|
||||
"/server-status",
|
||||
"/actuator",
|
||||
"/vendor/phpunit",
|
||||
"/boaform",
|
||||
"/HNAP1",
|
||||
"/solr/",
|
||||
"/jenkins/"
|
||||
],
|
||||
"auto_ban": {
|
||||
"enabled": true,
|
||||
"threshold": 10,
|
||||
"window_seconds": 120,
|
||||
"ban_seconds": 900,
|
||||
"scanner_weight": 5,
|
||||
"method_weight": 3,
|
||||
"rate_limit_weight": 0,
|
||||
"invalid_uri_weight": 3
|
||||
},
|
||||
"max_tracked_ips": 100000
|
||||
}
|
||||
6
production/public-vps/rate-exempt.txt
Normal file
6
production/public-vps/rate-exempt.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
# Optional CIDRs which bypass per-IP and endpoint rate limits.
|
||||
# They are NOT exempt from the static blacklist, host/method/scanner checks,
|
||||
# or the global overload limit.
|
||||
#
|
||||
# Use this only for known NAT gateways/monitoring systems if necessary.
|
||||
# 192.0.2.10
|
||||
Binary file not shown.
Reference in New Issue
Block a user