441 lines
12 KiB
Go
441 lines
12 KiB
Go
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()),
|
|
)
|
|
}
|