Files
jbergner 7c69432097
Some checks failed
release-tag / release-image (push) Successful in 2m3s
release-main / release-images (push) Failing after 3m9s
RC-2 0.5.2
2026-08-24 05:46:42 +02:00

307 lines
8.0 KiB
Go

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 }