Reticulum-Go/pkg/protect/engine.go
Ivan 5818dc8ba5
refactor: clear revive naming and control-flow warnings
Rename builtin shadows and case-colliding helpers, and tidy return/context/empty-block rules so lint is only add-constant noise.
2026-08-13 10:10:04 -05:00

985 lines
25 KiB
Go

// SPDX-License-Identifier: Apache-2.0
// Copyright (c) 2024-2026 Quad4.io
// Package protect provides IDS-style detect and IPS-style prevent gates for
// interface ingress handler overload stream accepts resources crypto handshakes
// adaptive rate baselines iface cool-down and memory pressure.
package protect
import (
"fmt"
"io"
"os"
"runtime"
"sync"
"sync/atomic"
"time"
"quad4/reticulum-go/pkg/common"
"quad4/reticulum-go/pkg/health"
)
// Decision is the result of an admit check.
type Decision struct {
Allow bool
Trip bool
Reason Reason
}
// Options configures an Engine.
type Options struct {
Mode Mode
MaxPPS float64
MaxBPS float64
FloorPPS float64
FloorBPS float64
MaxConns int
MaxResources int
MaxCrypto int
MaxHandshake int
SoftMemoryLimit int64
HeapWatermark int64
WarnWriter io.Writer
WarnInterval time.Duration
Now func() time.Time
MemorySampleFunc func() uint64
DisableAdaptive bool
DisableCoolDown bool
DisablePeerIsolation bool
StorePath string
AutoLearnMinDuration time.Duration
AutoLearnMinSamples int
NetworkFingerprint string
TransportNode bool
}
type ifaceState struct {
window rateWindow
adapt adaptiveState
adaptSec int64
adaptPeakPPS float64
adaptPeakBPS float64
tripAt []time.Time
coolUntil time.Time
peers map[string]*peerState
}
// peerState is a per-remote-peer rate sub-bucket scoped to one interface.
// It exists so a single sender sharing a listener cannot exhaust the whole
// interface budget and cool down every other peer on it.
type peerState struct {
window rateWindow
tripAt []time.Time
coolUntil time.Time
lastSeen time.Time
}
type warnKey struct {
iface string
reason Reason
}
type warnState struct {
last time.Time
suppressed uint64
}
// Engine is the process protect policy.
type Engine struct {
mode Mode
maxPPS float64
maxBPS float64
floorPPS float64
floorBPS float64
maxConns int
maxResources int
maxCrypto int
maxHandshake int
softMemoryLimit int64
heapWatermark int64
warnWriter io.Writer
warnInterval time.Duration
now func() time.Time
memSample func() uint64
disableAdaptive bool
disableCoolDown bool
disablePeerIsolation bool
storePath string
autoLearnMinDuration time.Duration
autoLearnMinSamples int
transportNode bool
mu sync.Mutex
ifaces map[string]*ifaceState
conns map[string]int
resources int
crypto int
handshake int
warns map[warnKey]*warnState
shedMemory atomic.Bool
tripCounts [reasonCount]atomic.Uint64
autoPhase atomic.Int32
fingerprint string
promoted bool
learnStarted time.Time
stableWindows int
driftWindows int
driftSec int64
driftSecMaxPPS float64
lastPersist time.Time
memStop chan struct{}
memOnce sync.Once
started atomic.Bool
}
var (
defaultMu sync.RWMutex
defaultE = New(Options{Mode: ModeOff})
)
// Default returns the process-wide Engine.
func Default() *Engine {
defaultMu.RLock()
e := defaultE
defaultMu.RUnlock()
return e
}
// SetDefault installs e as the process-wide Engine. Pass nil to restore ModeOff.
func SetDefault(e *Engine) {
defaultMu.Lock()
if e == nil {
defaultE = New(Options{Mode: ModeOff})
} else {
defaultE = e
}
defaultMu.Unlock()
}
// New builds an Engine from opts.
func New(opts Options) *Engine {
if opts.MaxPPS <= 0 {
opts.MaxPPS = DefaultMaxPPS
}
if opts.MaxBPS <= 0 {
opts.MaxBPS = DefaultMaxBPS
}
if opts.FloorPPS <= 0 {
opts.FloorPPS = DefaultFloorPPS
}
if opts.FloorBPS <= 0 {
opts.FloorBPS = DefaultFloorBPS
}
if opts.MaxConns <= 0 {
opts.MaxConns = DefaultMaxConns
}
if opts.MaxResources <= 0 {
opts.MaxResources = DefaultMaxResources
}
if opts.MaxCrypto <= 0 {
opts.MaxCrypto = DefaultMaxCrypto
}
if opts.MaxHandshake <= 0 {
opts.MaxHandshake = DefaultMaxHandshake
}
if opts.HeapWatermark <= 0 {
opts.HeapWatermark = DefaultHeapWatermarkBytes
}
if opts.WarnWriter == nil {
opts.WarnWriter = os.Stdout
}
if opts.WarnInterval <= 0 {
opts.WarnInterval = WarnInterval
}
if opts.Now == nil {
opts.Now = time.Now
}
if opts.MemorySampleFunc == nil {
opts.MemorySampleFunc = func() uint64 {
var ms runtime.MemStats
runtime.ReadMemStats(&ms)
return ms.HeapAlloc
}
}
if opts.AutoLearnMinDuration <= 0 {
opts.AutoLearnMinDuration = AutoLearnMinDuration
}
if opts.AutoLearnMinSamples <= 0 {
opts.AutoLearnMinSamples = AutoLearnMinSamples
}
e := &Engine{
mode: opts.Mode,
maxPPS: opts.MaxPPS,
maxBPS: opts.MaxBPS,
floorPPS: opts.FloorPPS,
floorBPS: opts.FloorBPS,
maxConns: opts.MaxConns,
maxResources: opts.MaxResources,
maxCrypto: opts.MaxCrypto,
maxHandshake: opts.MaxHandshake,
softMemoryLimit: opts.SoftMemoryLimit,
heapWatermark: opts.HeapWatermark,
warnWriter: opts.WarnWriter,
warnInterval: opts.WarnInterval,
now: opts.Now,
memSample: opts.MemorySampleFunc,
disableAdaptive: opts.DisableAdaptive,
disableCoolDown: opts.DisableCoolDown,
disablePeerIsolation: opts.DisablePeerIsolation,
storePath: opts.StorePath,
autoLearnMinDuration: opts.AutoLearnMinDuration,
autoLearnMinSamples: opts.AutoLearnMinSamples,
transportNode: opts.TransportNode,
fingerprint: opts.NetworkFingerprint,
ifaces: make(map[string]*ifaceState),
conns: make(map[string]int),
warns: make(map[warnKey]*warnState),
memStop: make(chan struct{}),
learnStarted: opts.Now(),
}
if opts.Mode == ModeAuto {
e.autoPhase.Store(int32(AutoLearning))
}
e.loadPersisted()
return e
}
// Mode returns the configured mode.
func (e *Engine) Mode() Mode {
if e == nil {
return ModeOff
}
return e.mode
}
// IfaceBaseline returns learned adaptive stats for iface.
func (e *Engine) IfaceBaseline(iface string) (ewmaPPS, ewmaBPS float64, samples int, ready bool) {
if e == nil {
return 0, 0, 0, false
}
e.mu.Lock()
defer e.mu.Unlock()
st := e.ifaces[iface]
if st == nil {
return 0, 0, 0, false
}
return st.adapt.ewmaPPS, st.adapt.ewmaBPS, st.adapt.samples, st.adapt.ready
}
// TripLine returns the effective pps and bps trip limits for iface.
func (e *Engine) TripLine(iface string) (ppsLimit, bpsLimit float64) {
if e == nil {
return 0, 0
}
e.mu.Lock()
defer e.mu.Unlock()
st := e.ifaces[iface]
if st == nil || e.disableAdaptive {
return e.maxPPS, e.maxBPS
}
return st.adapt.tripLine(e.maxPPS, e.maxBPS, e.floorPPS, e.floorBPS)
}
// StartMemoryMonitor begins periodic heap sampling when mode is not off.
func (e *Engine) StartMemoryMonitor() {
if e == nil || e.mode == ModeOff {
return
}
if !e.started.CompareAndSwap(false, true) {
return
}
go e.memoryLoop()
}
// StopMemoryMonitor stops the background sampler and flushes learning state.
func (e *Engine) StopMemoryMonitor() {
if e == nil {
return
}
_ = e.Persist()
if !e.started.Load() {
return
}
e.memOnce.Do(func() {
close(e.memStop)
})
}
func (e *Engine) memoryLoop() {
ticker := time.NewTicker(MemorySampleInterval)
defer ticker.Stop()
persistEvery := PersistInterval
for {
select {
case <-e.memStop:
return
case <-ticker.C:
e.ObserveMemory()
now := e.now()
e.mu.Lock()
due := e.lastPersist.IsZero() || now.Sub(e.lastPersist) >= persistEvery
e.mu.Unlock()
if due {
if err := e.Persist(); err == nil {
e.mu.Lock()
e.lastPersist = now
e.mu.Unlock()
}
}
}
}
}
// ObserveMemory samples heap and updates the shed flag.
func (e *Engine) ObserveMemory() {
if e == nil || e.mode == ModeOff {
return
}
heap := e.memSample()
limit := e.softMemoryLimit
if limit <= 0 {
limit = e.heapWatermark
}
if limit <= 0 {
return
}
tripAt := uint64(float64(limit) * MemoryTripRatio)
clearAt := uint64(float64(limit) * MemoryClearRatio)
if e.shedMemory.Load() {
if heap < clearAt {
e.shedMemory.Store(false)
}
return
}
if heap >= tripAt {
e.shedMemory.Store(true)
e.recordTrip("", ReasonMemory)
}
}
// Shedding reports whether memory pressure shed is active.
func (e *Engine) Shedding() bool {
return e != nil && e.shedMemory.Load()
}
// InCoolDown reports whether iface is currently in cool-down.
func (e *Engine) InCoolDown(iface string) bool {
if e == nil || e.disableCoolDown {
return false
}
now := e.now()
e.mu.Lock()
st := e.ifaces[iface]
ok := st != nil && now.Before(st.coolUntil)
e.mu.Unlock()
return ok
}
// TripCount returns how many times reason has tripped.
func (e *Engine) TripCount(reason Reason) uint64 {
if e == nil || reason < 0 || int(reason) >= len(e.tripCounts) {
return 0
}
return e.tripCounts[reason].Load()
}
func (e *Engine) ifaceLocked(name string) *ifaceState {
st := e.ifaces[name]
if st == nil {
st = &ifaceState{}
e.ifaces[name] = st
}
return st
}
// AdmitPacket checks cool-down adaptive pps/bps and memory shed.
func (e *Engine) AdmitPacket(iface string, nbytes int) Decision {
return e.admitWithOpts(iface, nbytes, AdmitOpts{})
}
func (e *Engine) admitWithOpts(iface string, nbytes int, opts AdmitOpts) Decision {
if e == nil || e.mode == ModeOff {
return Decision{Allow: true}
}
if e.shedMemory.Load() {
return e.decideMemory(iface)
}
now := e.now()
e.mu.Lock()
st := e.ifaceLocked(iface)
if !e.disableCoolDown && now.Before(st.coolUntil) {
e.mu.Unlock()
return e.decide(iface, ReasonCoolDown)
}
e.mu.Unlock()
// Peer fair-share runs before the interface aggregate so one hostile
// sender on a shared listener (a busy TCP accept loop or a UDP socket
// serving many remote peers) trips its own sub-bucket instead of
// exhausting the whole interface budget and cooling down every other
// peer sharing it.
if opts.PeerKey != "" && !e.disablePeerIsolation {
if d, deny := e.checkPeer(iface, opts.PeerKey, nbytes, now); deny {
return d
}
}
e.mu.Lock()
st = e.ifaceLocked(iface)
pps, bps := st.window.add(now, nbytes)
sampled := false
samplePPS, sampleBPS := 0.0, 0.0
floorPPS, floorBPS := scaledFloors(opts.Bitrate, e.floorPPS, e.floorBPS, e.maxPPS, e.maxBPS)
if !e.disableAdaptive {
sampled, samplePPS, sampleBPS = st.noteAdaptive(now, pps, bps)
}
ppsLimit, bpsLimit := e.maxPPS, e.maxBPS
if !e.disableAdaptive {
ppsLimit, bpsLimit = st.adapt.tripLine(e.maxPPS, e.maxBPS, floorPPS, floorBPS)
}
e.mu.Unlock()
overPPS := pps > ppsLimit
overBPS := bps > bpsLimit
if overPPS || overBPS {
if opts.Class.preferKeep() {
strictPPS := ppsLimit * 2
strictBPS := bpsLimit * 2
if pps <= strictPPS && bps <= strictBPS {
// Claimed link/proof class packets ride out bursts up to 2x the
// trip line, but the packet class byte is unauthenticated wire
// data any sender controls. Still record the trip and count it
// toward interface cool-down so sustained abuse of this
// leniency escalates like any other flood instead of being
// invisible to health counters and cool-down forever.
leniencyReason := ReasonPPS
if overBPS && !overPPS {
leniencyReason = ReasonBPS
}
d := e.tripCoolDownOnly(iface, leniencyReason)
if d.Allow {
if sampled {
e.maybePromoteOrDrift(iface, samplePPS, sampleBPS)
}
} else {
e.resetDriftLocked()
}
return d
}
}
e.resetDriftLocked()
if overPPS {
return e.tripWithCoolDown(iface, ReasonPPS)
}
return e.tripWithCoolDown(iface, ReasonBPS)
}
if sampled {
e.maybePromoteOrDrift(iface, samplePPS, sampleBPS)
}
return Decision{Allow: true}
}
// noteAdaptive tracks per-second peak rates and commits one EWMA sample per second.
// Returns true when a sample was committed for the previous second.
func (st *ifaceState) noteAdaptive(now time.Time, pps, bps float64) (sampled bool, samplePPS, sampleBPS float64) {
sec := now.Unix()
if st.adaptSec == 0 {
st.adaptSec = sec
st.adaptPeakPPS = pps
st.adaptPeakBPS = bps
return false, 0, 0
}
if sec == st.adaptSec {
if pps > st.adaptPeakPPS {
st.adaptPeakPPS = pps
}
if bps > st.adaptPeakBPS {
st.adaptPeakBPS = bps
}
return false, 0, 0
}
samplePPS = st.adaptPeakPPS
sampleBPS = st.adaptPeakBPS
st.adapt.observe(samplePPS, sampleBPS)
st.adaptSec = sec
st.adaptPeakPPS = pps
st.adaptPeakBPS = bps
return true, samplePPS, sampleBPS
}
func (e *Engine) resetDriftLocked() {
if e == nil {
return
}
e.mu.Lock()
e.driftWindows = 0
e.mu.Unlock()
}
// accumulateCoolDownTrip records now as a trip timestamp for iface and, once
// CoolDownTripThreshold trips land within CoolDownTripWindow, arms a full
// CoolDownDuration cool-down for the interface. Returns true when cool-down
// was just armed by this call.
func (e *Engine) accumulateCoolDownTrip(iface string) bool {
if e.disableCoolDown {
return false
}
now := e.now()
e.mu.Lock()
st := e.ifaceLocked(iface)
cutoff := now.Add(-CoolDownTripWindow)
kept := st.tripAt[:0]
for _, t := range st.tripAt {
if t.After(cutoff) {
kept = append(kept, t)
}
}
st.tripAt = append(kept, now)
armed := len(st.tripAt) >= CoolDownTripThreshold
if armed {
st.coolUntil = now.Add(CoolDownDuration)
st.tripAt = st.tripAt[:0]
}
e.mu.Unlock()
return armed
}
// decideMemory resolves a shed-memory admission. Heap exhaustion is an
// absolute safety valve rather than a flood-learning signal, so ModeAuto
// enforces it immediately even while still in the learning phase and before
// pps/bps prevention has armed. Explicit ModeDetect stays observe-only,
// matching its documented contract of never blocking.
func (e *Engine) decideMemory(iface string) Decision {
e.recordTrip(iface, ReasonMemory)
if e.mode == ModePrevent || e.mode == ModeAuto {
return Decision{Allow: false, Trip: true, Reason: ReasonMemory}
}
return Decision{Allow: true, Trip: true, Reason: ReasonMemory}
}
// tripCoolDownOnly records a trip for health counters and interface
// cool-down accounting without applying decide()'s per-packet enforcement
// deny. It is used by the prefer-keep leniency band so claimed link/proof
// traffic can still ride out isolated bursts, while sustained abuse of that
// leniency still escalates to a full interface cool-down like any other
// flood, instead of being invisible to metrics and cool-down forever.
func (e *Engine) tripCoolDownOnly(iface string, reason Reason) Decision {
e.recordTrip(iface, reason)
if e.accumulateCoolDownTrip(iface) {
e.recordTrip(iface, ReasonCoolDown)
if e.enforcementMode() == ModePrevent {
return Decision{Allow: false, Trip: true, Reason: ReasonCoolDown}
}
}
return Decision{Allow: true, Trip: true, Reason: reason}
}
// peerLocked returns the sub-bucket for peerKey on st, creating one if
// needed. Must be called with e.mu held. Growth is bounded at
// MaxTrackedPeersPerIface: idle entries are pruned first, then the least
// recently seen entry is evicted if still at capacity, so a flood of
// distinct source identities cannot itself become an unbounded-memory DoS.
func (e *Engine) peerLocked(st *ifaceState, peerKey string, now time.Time) *peerState {
if st.peers == nil {
st.peers = make(map[string]*peerState)
}
ps := st.peers[peerKey]
if ps != nil {
ps.lastSeen = now
return ps
}
if len(st.peers) >= MaxTrackedPeersPerIface {
e.evictStalePeerLocked(st, now)
}
ps = &peerState{lastSeen: now}
st.peers[peerKey] = ps
return ps
}
func (e *Engine) evictStalePeerLocked(st *ifaceState, now time.Time) {
var oldestKey string
var oldestSeen time.Time
for k, ps := range st.peers {
if now.Sub(ps.lastSeen) >= PeerIdleEvictAfter {
delete(st.peers, k)
continue
}
if oldestKey == "" || ps.lastSeen.Before(oldestSeen) {
oldestKey = k
oldestSeen = ps.lastSeen
}
}
if len(st.peers) >= MaxTrackedPeersPerIface && oldestKey != "" {
delete(st.peers, oldestKey)
}
}
// checkPeer enforces a fair-share budget for a single remote peer sharing
// iface, independent of the interface-wide aggregate check in admitWithOpts.
// It is what stops one hostile peer on a shared listener from exhausting
// the whole interface budget and cooling down every other peer on it.
// Returns deny=true when the caller should return the decision immediately
// instead of continuing to the interface-wide check.
func (e *Engine) checkPeer(iface, peerKey string, nbytes int, now time.Time) (Decision, bool) {
e.mu.Lock()
st := e.ifaceLocked(iface)
if !e.disableCoolDown {
if ps := st.peers[peerKey]; ps != nil && now.Before(ps.coolUntil) {
e.mu.Unlock()
return e.decide(iface, ReasonCoolDown), true
}
}
ps := e.peerLocked(st, peerKey, now)
pps, bps := ps.window.add(now, nbytes)
ppsLimit, bpsLimit := st.adapt.tripLine(e.maxPPS, e.maxBPS, e.floorPPS, e.floorBPS)
e.mu.Unlock()
peerPPSLimit := ppsLimit * PeerBudgetFraction
peerBPSLimit := bpsLimit * PeerBudgetFraction
if pps <= peerPPSLimit && bps <= peerBPSLimit {
return Decision{Allow: true}, false
}
reason := ReasonPPS
if bps > peerBPSLimit && pps <= peerPPSLimit {
reason = ReasonBPS
}
return e.tripPeerCoolDown(iface, peerKey, reason), true
}
// tripPeerCoolDown mirrors tripWithCoolDown but scopes cool-down state to a
// single peer bucket instead of the whole interface, so sustained abuse by
// one peer never blocks the other peers sharing the same local interface.
func (e *Engine) tripPeerCoolDown(iface, peerKey string, reason Reason) Decision {
d := e.decide(iface, reason)
if e.disableCoolDown {
return d
}
now := e.now()
e.mu.Lock()
st := e.ifaceLocked(iface)
ps := e.peerLocked(st, peerKey, now)
cutoff := now.Add(-CoolDownTripWindow)
kept := ps.tripAt[:0]
for _, t := range ps.tripAt {
if t.After(cutoff) {
kept = append(kept, t)
}
}
ps.tripAt = append(kept, now)
armed := len(ps.tripAt) >= CoolDownTripThreshold
if armed {
ps.coolUntil = now.Add(CoolDownDuration)
ps.tripAt = ps.tripAt[:0]
}
e.mu.Unlock()
if !armed {
return d
}
e.recordTrip(iface, ReasonCoolDown)
if e.enforcementMode() == ModePrevent {
return Decision{Allow: false, Trip: true, Reason: ReasonCoolDown}
}
return Decision{Allow: true, Trip: true, Reason: ReasonCoolDown}
}
func (e *Engine) tripWithCoolDown(iface string, reason Reason) Decision {
d := e.decide(iface, reason)
if e.disableCoolDown {
return d
}
if e.accumulateCoolDownTrip(iface) {
e.recordTrip(iface, ReasonCoolDown)
if e.enforcementMode() == ModePrevent {
return Decision{Allow: false, Trip: true, Reason: ReasonCoolDown}
}
return Decision{Allow: true, Trip: true, Reason: ReasonCoolDown}
}
return d
}
// AdmitHandler is called when the packet handler semaphore is full.
func (e *Engine) AdmitHandler(iface string) Decision {
if e == nil || e.mode == ModeOff {
return Decision{Allow: true}
}
return e.decide(iface, ReasonHandler)
}
// AdmitConn checks concurrent accepted connections for iface.
func (e *Engine) AdmitConn(iface string) (Decision, func()) {
noop := func() {}
if e == nil || e.mode == ModeOff {
return Decision{Allow: true}, noop
}
if e.shedMemory.Load() {
d := e.decideMemory(iface)
if !d.Allow {
return d, noop
}
}
if e.InCoolDown(iface) {
d := e.decide(iface, ReasonCoolDown)
if !d.Allow {
return d, noop
}
}
e.mu.Lock()
n := e.conns[iface]
over := n >= e.maxConns
if over && e.enforcementMode() == ModePrevent {
e.mu.Unlock()
return e.decide(iface, ReasonConn), noop
}
e.conns[iface] = n + 1
e.mu.Unlock()
var trip Decision
if over {
trip = e.decide(iface, ReasonConn)
}
var once sync.Once
release := func() {
once.Do(func() {
e.mu.Lock()
if e.conns[iface] > 0 {
e.conns[iface]--
}
e.mu.Unlock()
})
}
if over {
trip.Allow = true
return trip, release
}
return Decision{Allow: true}, release
}
// AdmitResource checks concurrent incoming resources and memory shed.
func (e *Engine) AdmitResource(estBytes int64) (Decision, func()) {
noop := func() {}
if e == nil || e.mode == ModeOff {
return Decision{Allow: true}, noop
}
_ = estBytes
if e.shedMemory.Load() {
d := e.decideMemory("")
if !d.Allow {
return d, noop
}
}
e.mu.Lock()
over := e.resources >= e.maxResources
if over && e.enforcementMode() == ModePrevent {
e.mu.Unlock()
return e.decide("", ReasonResource), noop
}
e.resources++
e.mu.Unlock()
var trip Decision
if over {
trip = e.decide("", ReasonResource)
}
var once sync.Once
release := func() {
once.Do(func() {
e.mu.Lock()
if e.resources > 0 {
e.resources--
}
e.mu.Unlock()
})
}
if over {
trip.Allow = true
return trip, release
}
return Decision{Allow: true}, release
}
// AdmitCrypto gates concurrent decrypt and signature verify work.
func (e *Engine) AdmitCrypto(iface string) (Decision, func()) {
return e.admitSlot(iface, ReasonCrypto, &e.crypto, e.maxCrypto)
}
// AdmitHandshake gates concurrent link handshake work.
func (e *Engine) AdmitHandshake(iface string) (Decision, func()) {
return e.admitSlot(iface, ReasonHandshake, &e.handshake, e.maxHandshake)
}
func (e *Engine) admitSlot(iface string, reason Reason, slot *int, limit int) (Decision, func()) {
noop := func() {}
if e == nil || e.mode == ModeOff {
return Decision{Allow: true}, noop
}
if e.shedMemory.Load() {
d := e.decideMemory(iface)
if !d.Allow {
return d, noop
}
}
e.mu.Lock()
over := *slot >= limit
if over && e.enforcementMode() == ModePrevent {
e.mu.Unlock()
return e.decide(iface, reason), noop
}
*slot++
e.mu.Unlock()
var trip Decision
if over {
trip = e.decide(iface, reason)
}
var once sync.Once
release := func() {
once.Do(func() {
e.mu.Lock()
if *slot > 0 {
*slot--
}
e.mu.Unlock()
})
}
if over {
trip.Allow = true
return trip, release
}
return Decision{Allow: true}, release
}
func (e *Engine) decide(iface string, reason Reason) Decision {
e.recordTrip(iface, reason)
if e.enforcementMode() == ModePrevent {
return Decision{Allow: false, Trip: true, Reason: reason}
}
return Decision{Allow: true, Trip: true, Reason: reason}
}
func (e *Engine) recordTrip(iface string, reason Reason) {
if reason > ReasonNone && int(reason) < len(e.tripCounts) {
e.tripCounts[reason].Add(1)
}
health.Inc(iface, reason.HealthKind())
e.warn(iface, reason)
}
func (e *Engine) warn(iface string, reason Reason) {
now := e.now()
key := warnKey{iface: iface, reason: reason}
e.mu.Lock()
st := e.warns[key]
if st == nil {
st = &warnState{}
e.warns[key] = st
}
elapsed := now.Sub(st.last)
if !st.last.IsZero() && elapsed < e.warnInterval {
st.suppressed++
e.mu.Unlock()
return
}
suppressed := st.suppressed
st.suppressed = 0
st.last = now
writer := e.warnWriter
modeLabel := e.mode.String()
if e.mode == ModeAuto {
modeLabel = "auto/" + e.Phase().String()
}
e.mu.Unlock()
ifacePart := iface
if ifacePart == "" {
ifacePart = "-"
}
line := fmt.Sprintf("WARNING: dos_protection %s trip reason=%s iface=%s", modeLabel, reason.String(), ifacePart)
if suppressed > 0 {
line = fmt.Sprintf("%s suppressed=%d", line, suppressed)
}
_, _ = fmt.Fprintln(writer, line)
}
// Package-level helpers use Default().
// AdmitPacket checks the default engine.
func AdmitPacket(iface string, nbytes int) Decision {
return Default().AdmitPacket(iface, nbytes)
}
// AdmitPacketOpts checks the default engine with bitrate and packet class.
func AdmitPacketOpts(iface string, nbytes int, opts AdmitOpts) Decision {
return Default().admitWithOpts(iface, nbytes, opts)
}
// AdmitHandler checks the default engine.
func AdmitHandler(iface string) Decision {
return Default().AdmitHandler(iface)
}
// AdmitConn checks the default engine.
func AdmitConn(iface string) (Decision, func()) {
return Default().AdmitConn(iface)
}
// AdmitResource checks the default engine.
func AdmitResource(estBytes int64) (Decision, func()) {
return Default().AdmitResource(estBytes)
}
// AdmitCrypto checks the default engine.
func AdmitCrypto(iface string) (Decision, func()) {
return Default().AdmitCrypto(iface)
}
// AdmitHandshake checks the default engine.
func AdmitHandshake(iface string) (Decision, func()) {
return Default().AdmitHandshake(iface)
}
// ConfigureFromConfig installs a default engine from dos_protection soft memory limit and optional store path.
func ConfigureFromConfig(modeStr string, softMemoryLimit int64, storePath string, cfg *common.ReticulumConfig) *Engine {
mode, ok := ParseMode(modeStr)
if !ok {
mode = ModeOff
}
opts := Options{
Mode: mode,
SoftMemoryLimit: softMemoryLimit,
StorePath: storePath,
}
if cfg != nil {
opts.TransportNode = cfg.EnableTransport
if cfg.EnableTransport {
opts.AutoLearnMinDuration = AutoLearnMinDuration * 2
}
if cfg.DoSMaxPPS > 0 {
opts.MaxPPS = cfg.DoSMaxPPS
}
if cfg.DoSMaxBPS > 0 {
opts.MaxBPS = cfg.DoSMaxBPS
}
if cfg.DoSFloorPPS > 0 {
opts.FloorPPS = cfg.DoSFloorPPS
}
if cfg.DoSFloorBPS > 0 {
opts.FloorBPS = cfg.DoSFloorBPS
}
if cfg.DoSMaxConns > 0 {
opts.MaxConns = cfg.DoSMaxConns
}
if cfg.DoSMaxResources > 0 {
opts.MaxResources = cfg.DoSMaxResources
}
if cfg.DoSMaxCrypto > 0 {
opts.MaxCrypto = cfg.DoSMaxCrypto
}
if cfg.DoSMaxHandshake > 0 {
opts.MaxHandshake = cfg.DoSMaxHandshake
}
}
e := New(opts)
SetDefault(e)
if mode != ModeOff {
e.StartMemoryMonitor()
}
return e
}