firewall

package
v0.0.0-...-18f96f7 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 2, 2026 License: AGPL-3.0 Imports: 39 Imported by: 0

Documentation

Index

Constants

View Source
const (
	SourceUnknown      = "unknown"
	SourceWebUI        = "web_ui"
	SourceCLI          = "cli"
	SourceAutoResponse = "auto_response"
	SourceChallenge    = "challenge"
	SourceWhitelist    = "whitelist"
	SourceDynDNS       = "dyndns"
	SourceSystem       = "system"
)
View Source
const (

	// CloudflareCoverageWarning is shared by every operator-facing block
	// response so API, CLI, and finding wording cannot drift apart.
	CloudflareCoverageWarning = "IP is inside a Cloudflare allow range; ports 80/443 from it are still accepted"
)
View Source
const MaxActionMembers = MaxBlockBatchMembers

MaxActionMembers bounds one batch manifest. It matches the block batch applier, so one durable batch can carry one applier batch.

View Source
const MaxBlockBatchMembers = 64

MaxBlockBatchMembers bounds one block batch, as the admission ledger bounds an execution batch (spec 5.5).

View Source
const MaxBlockImports = 256

MaxBlockImports bounds the imports one snapshot lists: the soonest to expire, which admission evicts first among imports.

View Source
const MaxBlockReasonBytes = 256

MaxBlockReasonBytes bounds the reason a batch member writes on its row.

View Source
const MaxShadowBlocks = 20000

MaxShadowBlocks bounds the forecast addresses one admission snapshot counts: a dry-run forecast models at most this many active blocks.

Variables

View Source
var (
	ErrActionDryRun       = errors.New("firewall action suppressed by dry-run policy")
	ErrActionAuditPending = errors.New("firewall action verified; audit delivery pending")
	ErrActionMissing      = errors.New("firewall action not found")
	ErrActionUnknown      = errors.New("firewall action outcome requires recovery")
	ErrActionFailed       = errors.New("firewall action was not applied")
	ErrScanBudget         = errors.New("firewall scan budget exhausted")
)
View Source
var (
	// ErrStateCommitUncertain means the transaction body succeeded but storage
	// did not acknowledge commit. The new revision may be visible; callers must
	// reconcile before retrying and must not infer rollback or durable success.
	ErrStateCommitUncertain = errors.New("firewall state commit outcome uncertain")
	// ErrStateUninitialized means no complete snapshot has been committed.
	ErrStateUninitialized = errors.New("firewall state is uninitialized")
	// ErrStateConflict requires re-reading state before constructing another change.
	ErrStateConflict = errors.New("firewall state revision conflict")
	// ErrStateCorrupt means stored state cannot safely be used or overwritten.
	ErrStateCorrupt = errors.New("firewall state is corrupt or unsupported")
	// ErrStateUnsupported means a newer build wrote the stored value. It is
	// refused like corruption: this build can neither read nor overwrite it.
	ErrStateUnsupported = fmt.Errorf("%w: written by a newer build", ErrStateCorrupt)
)
View Source
var ErrAdmissionOwnsBlock = errors.New("an admission action owns the block")

ErrAdmissionOwnsBlock refuses an automatic rewrite of a block an admission action wrote: its owner retires it (design C8).

View Source
var ErrBlockBatchRejected = errors.New("kernel rejected the block batch")

ErrBlockBatchRejected marks a member that failed because the kernel rejected the whole transaction; a failure before the send does not carry it. A failed state restore carries ErrBlockBatchState instead. The owner retries rejected members one at a time (P9).

View Source
var ErrBlockBatchState = errors.New("firewall batch state is unproven")

ErrBlockBatchState requires the owner to hold automatic execution while the engine's state safety changes lack a durable acknowledgement.

View Source
var ErrBlockChanged = errors.New("block changed since the action; undo is no longer available")

ErrBlockChanged prevents undo from replacing a later firewall decision.

View Source
var ErrConfirmPending = errors.New("a firewall change awaits confirmation")

ErrConfirmPending refuses admission's block batches while an apply-confirmed window can roll the firewall back (design C9).

View Source
var ErrIPProtected = errors.New("ip is protected from auto-block")

ErrIPProtected identifies safety refusals for IP and subnet blocks, including local scopes, the host's own addresses, operator infra_ips, default routes, and subnets overlapping allowed addresses. Callers check errors.Is(err, ErrIPProtected) to treat the refusal as an expected no-op; they neither record a block nor log it as a failure. The triggering finding or incident is unaffected, so suspicious activity attributed to a protected address is still surfaced.

View Source
var ErrLongerBlock = errors.New("IP has a longer block; unblock it explicitly before shortening its lifetime")

ErrLongerBlock requires an explicit unblock before shortening a timed block.

View Source
var ErrPermanentBlock = errors.New("IP is permanently blocked; unblock it explicitly before applying a timed block")

ErrPermanentBlock requires an explicit unblock before a timed Web UI block.

View Source
var ErrStateNotKeptAside = errors.New("firewall state unreadable and not kept aside")

ErrStateNotKeptAside requires a new startup after unreadable state could not be preserved. Retrying with a fresh engine can miss an unsynced rename.

View Source
var ErrUnsupportedPlatform = errors.New("nftables firewall not supported on this platform")

ErrUnsupportedPlatform is returned by every mutating Engine method on platforms without nftables support. The stubs used to return nil, which let callers record blocks that never reached any kernel; failing loudly keeps a miswired caller from trusting a phantom mutation.

Functions

func AppendAudit

func AppendAudit(statePath, action, ip, reason, source string, duration time.Duration)

AppendAudit writes an audit entry to the JSONL audit log. Rotates the log when it exceeds 10 MB.

func CloudflareRangesCover

func CloudflareRangesCover(v4, v6 []string, ip string) bool

CloudflareRangesCover reports whether ip falls inside any of the given Cloudflare CIDR ranges. The input chain accepts Cloudflare edges on TCP 80/443 before the blocked-IP drop, so blocking a covered IP does not stop its web traffic; callers use this to warn the operator at block time. Malformed CIDRs are skipped so one bad cache line cannot hide coverage.

func CountryDBDir

func CountryDBDir(cfg *FirewallConfig, statePath string) string

CountryDBDir returns the directory holding the per-country CIDR files: country_db_path when set, otherwise the geoip directory under the state path, which is where `csm firewall update-geoip` writes. Every consumer (engine, CLI update and lookup) resolves through here so an empty setting cannot leave country blocking armed on one surface and inert on another.

func CoversExpiry

func CoversExpiry(held, wanted time.Time) bool

CoversExpiry reports whether an effect ending at held lasts as long as one asked to end at wanted: a permanent effect, zero held, or one ending no earlier than wanted within the readback slack. A dry-run forecast judges its own blocks by it, so they cover exactly as real ones do.

func EffectiveDOSExemptNets

func EffectiveDOSExemptNets(cfg *FirewallConfig, providerNets []*net.IPNet) (v4, v6 []*net.IPNet)

EffectiveDOSExemptNets returns the union of operator ranges and (when enabled) provider ranges, split into IPv4 and IPv6 *net.IPNet slices. providerNets may be nil. Invalid operator entries are skipped (validation already rejected them at load; this is defense in depth).

func FetchCloudflareIPs

func FetchCloudflareIPs(ctx context.Context) (ipv4, ipv6 []string, err error)

FetchCloudflareIPs downloads the current Cloudflare IP ranges, honouring ctx. A caller shutting down cancels it rather than waiting out the HTTP timeout: on a host that cannot reach cloudflare.com -- an egress-restricted server among them -- that wait is the full timeout, twice.

func InferProvenance

func InferProvenance(action, reason string) string

InferProvenance classifies a firewall entry source from structured action/reason text. This keeps provenance logic centralized instead of spreading fragile string checks throughout the web UI and firewall call sites.

func LoadCFRefreshTime

func LoadCFRefreshTime(statePath string) time.Time

LoadCFRefreshTime reads the last CF refresh time from state.

func LoadCFState

func LoadCFState(statePath string) (ipv4, ipv6 []string)

LoadCFState reads the cached Cloudflare CIDRs.

func LookupIP

func LookupIP(dbPath string, ip string) []string

LookupIP finds which country CIDR files contain the given IP. Returns matching country codes.

func MergeInfraIPs

func MergeInfraIPs(topLevel, firewallSpecific []string) []string

MergeInfraIPs returns the effective firewall infra list in configuration order. Equivalent IP spellings share one entry because nftables enforces the parsed address or network, not the operator's original text.

func ParseOutAllowDst

func ParseOutAllowDst(dst string) (*net.IPNet, error)

ParseOutAllowDst accepts a bare IP or a CIDR and returns it as a network. A bare address becomes a host route; mapped IPv4 prefixes use IPv4 widths.

func ProtocolAllowNames

func ProtocolAllowNames() []string

ProtocolAllowNames lists the protocols firewall.protocol_allow accepts.

func SameBlockedEntry

func SameBlockedEntry(a, b BlockedEntry) bool

SameBlockedEntry compares snapshots without relying on time.Time locations.

func SaveCFState

func SaveCFState(statePath string, ipv4, ipv6 []string, refreshed time.Time) error

SaveCFState persists the Cloudflare CIDRs for status and coverage checks.

func Supported

func Supported() bool

Supported reports whether this build includes the nftables engine.

func UpdateGeoIPDB

func UpdateGeoIPDB(dbPath string, countryCodes []string) (int, error)

UpdateGeoIPDB downloads country CIDR lists from a public source. Creates one file per country code per family: {dbPath}/{CC}.cidr (IPv4) and {dbPath}/{CC}.cidr6 (IPv6). IPv6 is best-effort so a country with no v6 allocation does not fail the update. The return value is the number of CIDR files refreshed.

Types

type ActionElement

type ActionElement struct {
	Key       []byte    `json:"key"`
	End       bool      `json:"end,omitempty"`
	Comment   string    `json:"comment,omitempty"`
	ExpiresAt time.Time `json:"expires_at,omitempty"`
}

type ActionKernel

type ActionKernel interface {
	ObserveFirewallAction(FirewallAction) (ActionObservation, error)
	ApplyFirewallAction(FirewallAction) error
}

ActionKernel compares the affected targets, including provenance and expiry. Recovery only observes; it never calls ApplyFirewallAction.

type ActionMember

type ActionMember struct {
	Request ActionRequest  `json:"request"`
	Budget  *ScanAdmission `json:"budget,omitempty"`
	// Victims are the rows this member removes to make room, exactly as they
	// were, so undo can prove they are still current.
	Victims []BlockedEntry `json:"victims,omitempty"`
}

ActionMember is one immutable single-target request inside a batch, with its admission data and the effects its plan records.

type ActionObservation

type ActionObservation struct{ Before, After bool }

type ActionRequest

type ActionRequest struct {
	ID          string        `json:"id"`
	Operation   string        `json:"operation"`
	Target      string        `json:"target"`
	Reason      string        `json:"reason"`
	Actor       string        `json:"actor"`
	ActorDetail string        `json:"actor_detail,omitempty"`
	Source      string        `json:"source"`
	FindingID   string        `json:"finding_id,omitempty"`
	IncidentID  string        `json:"incident_id,omitempty"`
	UndoOf      string        `json:"undo_of,omitempty"`
	Automatic   bool          `json:"automatic,omitempty"`
	TTL         time.Duration `json:"ttl,omitempty"`
}

ActionRequest is the identity and policy input for one firewall decision. ID identifies a request, not an individual attempt to contact the kernel.

type ActionRuleset

type ActionRuleset struct {
	Generation uint32 `json:"generation_before"`
	Marker     string `json:"marker"`
}

ActionRuleset proves an atomic whole-ruleset application. An unrelated namespace edit invalidates this conservative recovery proof.

type ActionSet

type ActionSet struct {
	Exists   bool            `json:"exists"`
	Name     string          `json:"name"`
	Elements []ActionElement `json:"elements"`
}

ActionSet is detached recovery evidence for one complete nftables set. Expiry is absolute so neither retry nor undo can renew a timed element.

type ActionStore

type ActionStore interface {
	StateStore
	AdmitFirewallAction(FirewallAction) (FirewallAction, bool, error)
	ReadFirewallAction(string) (FirewallAction, error)
	// ReadFirewallBatchMember returns the batch of two or more members that
	// indexes a member ID, or ErrActionMissing.
	ReadFirewallBatchMember(string) (FirewallAction, error)
	PendingFirewallActions() ([]FirewallAction, error)
	TransitionFirewallAction(string, string, string, time.Time) (FirewallAction, error)
	// RetireFirewallAction commits the state an unknown action retires to
	// at the given instant (RetiredActionState) and marks it retired.
	RetireFirewallAction(string, time.Time) (FirewallAction, error)
	FirewallAuditPending() ([]FirewallAction, error)
	AcknowledgeFirewallAudit(string, uint64) error
	ReadFirewallScanBudget(string) (int, error)
}

ActionStore owns atomic admission, accounting, outcome and audit delivery state. Implementations must keep kernel and audit I/O outside transactions.

type AllowedEntry

type AllowedEntry struct {
	IP        string    `json:"ip"`
	Reason    string    `json:"reason"`
	Source    string    `json:"source,omitempty"`
	Port      int       `json:"port,omitempty"`       // 0 = all ports
	ExpiresAt time.Time `json:"expires_at,omitempty"` // zero = permanent
}

AllowedEntry represents an allowed IP with metadata.

type AuditEntry

type AuditEntry struct {
	Timestamp time.Time `json:"timestamp"`
	Action    string    `json:"action"` // block, unblock, allow, remove_allow, flush, apply
	IP        string    `json:"ip,omitempty"`
	Reason    string    `json:"reason,omitempty"`
	Source    string    `json:"source,omitempty"`
	Duration  string    `json:"duration,omitempty"`
}

AuditEntry records a firewall modification for compliance and forensics.

func ReadAuditLog

func ReadAuditLog(statePath string, limit int) []AuditEntry

ReadAuditLog returns the last N audit entries from the log.

type BlockAction

type BlockAction struct {
	Action, IP string
}

BlockAction names the row an admission attempt wrote: its action ID and address.

type BlockAdmission

type BlockAdmission struct {
	// Answers holds one answer per member, in member order.
	Answers []BlockAnswer
	// Temporary is how many timed elements the blocked sets hold, both
	// address families together; Limit is the configured temporary limit,
	// zero for none.
	Temporary, Limit int
	// The timed occupants by recorded ownership (spec 5.6): Admission
	// counts the elements of rows admission actions wrote, Imported those
	// of rows legacy automatic blocks marked, and Unclaimed every other
	// timed element plus two elements per timed subnet row, which
	// admission never evicts.
	Admission, Imported, Unclaimed int
	// Drain bounds how long the last unclaimed or imported occupant has left
	// after collection and member checks; zero when no time remains,
	// -time.Second when a kernel expiry is missing.
	Drain time.Duration
	// ShadowHeld names the forecast addresses the sets already hold as a
	// timed or an untimed element.
	ShadowHeld map[string]bool
	// Imports lists the imports a member may evict, soonest expiry first,
	// at most MaxBlockImports; none while startup could not prove
	// admission outcomes.
	Imports []BlockImport
}

BlockAdmission is a read-only answer for one admission turn, from one snapshot of the state file and the kernel: each member's local checks and the temporary occupancy. No member takes a temporary slot and no repeated member is refused: the caller decides which members are ready and checks them with ReadyCapacity.

func (BlockAdmission) Full

func (a BlockAdmission) Full() bool

Full reports whether the temporary set has no slot left for any member.

func (BlockAdmission) ReadyCapacity

func (a BlockAdmission) ReadyCapacity(ready []BlockMember) []BlockRefusal

ReadyCapacity answers the members a turn is ready to run, in order, as a batch check answers its capacity: a member repeating an earlier one's address or action is a duplicate, and once the occupancy reaches the limit the rest find the set full. Only a member that passes takes a slot.

type BlockAnswer

type BlockAnswer struct {
	Action, IP string
	BlockCheck
}

BlockAnswer is an admission snapshot's answer for one member, naming the member it answers.

type BlockBatchResult

type BlockBatchResult struct {
	Action   FirewallAction
	Refusals []BlockRefusal
}

BlockBatchResult is what a durable block batch did: the action it ran or replayed, and, in call order, why each member was left out (empty when the member ran). A replayed batch reports no refusals.

type BlockCheck

type BlockCheck struct {
	Refusal  BlockRefusal
	Existing ExistingBlock
}

BlockCheck is the batch check's answer for one member: its refusal and, for an existing effect, the block that holds the address.

type BlockEffect

type BlockEffect string

BlockEffect is what a batch did to one member.

const (
	// BlockApplied: the kernel holds the member's element as encoded.
	BlockApplied BlockEffect = "applied"
	// BlockFailed: proven not applied; nothing of it is in the kernel.
	BlockFailed BlockEffect = "failed"
	// BlockUnknown: the element may or may not be in the kernel. Its row
	// stays, marked unverified.
	BlockUnknown BlockEffect = "unknown"
)

type BlockImport

type BlockImport struct {
	ID        string
	IP        string
	ExpiresAt time.Time
}

BlockImport is a known automatic block admission may evict (spec 6): a timed, verified row a legacy automatic block stamped, alone at its canonical address, whose element the snapshot proves live. ID is its effect name, which stays the same while the row does.

type BlockMember

type BlockMember struct {
	Action    string
	IP        string
	Reason    string
	ExpiresAt time.Time
	Victim    BlockVictim
}

BlockMember is one timed full-address block an admission attempt may apply: the attempt's ID, the canonical address, the reason written on its row and the absolute expiry its element never outlives. Victim is the admission block whose slot it takes, as the ledger claimed it; zero when it takes a free slot.

type BlockOutcome

type BlockOutcome string

BlockOutcome reports what the firewall engine actually did in response to a BlockIPOutcome call. Auto-response callers consult it to decide whether to apply local side effects (state.IPs append, BlocksThisHour++, permanent threat-db insert, AUTO-BLOCK finding) - those should only fire when the kernel firewall was mutated.

Returned values:

  • BlockOutcomeLive: nftables was mutated. Caller should record the block locally and emit the operator-facing Critical "AUTO-BLOCK" finding.
  • BlockOutcomeDryRun: auto_response.dry_run intercepted the call. No kernel mutation occurred. Caller should NOT record a real block; emit a Warning-level dry-run notice instead so operators can see what would have been blocked without believing the block landed.
  • BlockOutcomeAllowed: the verdict callback returned "allow", so CSM intentionally did not block. Caller should NOT record a block and should NOT emit an AUTO-BLOCK finding (the panel already knows it downgraded the decision).
  • BlockOutcomeAllowlisted: the IP is on a soft-allow list (operator allowed_ips, a port-specific operator allow, or a verified-bot range), so the auto-block path declined to block it. The nftables input chain drops @blocked_ips before it accepts operator allows, so an allowlisted IP added to blocked_ips would still be dropped; keeping it out of the set is the only safe fix. Operator `firewall deny` uses BlockIPForce, which bypasses this gate, so an explicit deny still wins. Caller should NOT record a block.
  • BlockOutcomeNoop: the IP was already blocked, or a guard (infra-IP, malformed IP, deny-limit) rejected the call. Caller should treat the call as a no-op locally.
const (
	BlockOutcomeLive        BlockOutcome = "live"
	BlockOutcomeDryRun      BlockOutcome = "dry_run"
	BlockOutcomeAllowed     BlockOutcome = "allowed"
	BlockOutcomeAllowlisted BlockOutcome = "allowlisted"
	BlockOutcomeNoop        BlockOutcome = "noop"
)

type BlockRefusal

type BlockRefusal string

BlockRefusal says why a batch member cannot run; empty means it can.

const (
	// BlockRefusedInvalid: a malformed action ID, address, reason or
	// expiry, or an address not in canonical form.
	BlockRefusedInvalid BlockRefusal = "invalid"
	// BlockRefusedExpired: less than a millisecond of the block is left, or
	// a durable batch member too short to outlive its verification.
	BlockRefusedExpired BlockRefusal = "expired"
	// BlockRefusedUnsupported: the address family is not enabled.
	BlockRefusedUnsupported BlockRefusal = "unsupported"
	// BlockRefusedProtected: infra, the host's own address or one that is
	// never a packet's source.
	BlockRefusedProtected BlockRefusal = "protected"
	// BlockRefusedAllowed: an operator allow or a verified crawler.
	BlockRefusedAllowed BlockRefusal = "allowlisted"
	// BlockRefusedDuplicate: an earlier member names the same address or
	// action.
	BlockRefusedDuplicate BlockRefusal = "duplicate"
	// BlockRefusedExisting: a verified row and its kernel element already
	// block the address for at least the member's lifetime: an untimed
	// element for a permanent row, or one with the time a timed row has
	// left when that row ends no earlier than the member (spec 5.16).
	BlockRefusedExisting BlockRefusal = "existing_effect"
	// BlockRefusedPending: a row or a kernel element names the address
	// without proving an enforced block that covers the member: the row is
	// unverified, has no element or one with another lifetime, an element
	// has no row, or the proven block ends before the member. Recovery or
	// that block's end has to settle it first.
	BlockRefusedPending BlockRefusal = "pending_recovery"
	// BlockRefusedFull: no temporary deny slot is left.
	BlockRefusedFull BlockRefusal = "set_full"
)

type BlockResult

type BlockResult struct {
	Effect   BlockEffect
	Refusal  BlockRefusal
	Existing ExistingBlock
	Err      error
	Victim   BlockVictimEffect
}

BlockResult is one member's outcome: its effect, the check that refused it before the kernel batch, or the error behind a failed or unknown one. Existing names the block behind an existing-effect refusal. Victim is what the batch did to the member's victim; empty when it named none.

type BlockVictim

type BlockVictim struct {
	Action    string
	IP        string
	ExpiresAt time.Time
}

BlockVictim is an admission block a member removes to take its slot (spec 5.6): its action, its canonical address and the absolute expiry it was applied with.

type BlockVictimEffect

type BlockVictimEffect string

BlockVictimEffect is what a batch did to a member's victim.

const (
	// VictimRemoved: deleted in the member's transaction, and the readback
	// proved it gone.
	VictimRemoved BlockVictimEffect = "removed"
	// VictimSpared: never deleted, because the member did not run, the
	// kernel rejected the transaction or the victim no longer qualified.
	VictimSpared BlockVictimEffect = "spared"
	// VictimUnproven: the deletion was sent, but neither an uncertain
	// reply nor the readback proves it.
	VictimUnproven BlockVictimEffect = "unproven"
)

type BlockedEntry

type BlockedEntry struct {
	IP        string    `json:"ip"`
	Reason    string    `json:"reason"`
	Source    string    `json:"source,omitempty"`
	BlockedAt time.Time `json:"blocked_at"`
	ExpiresAt time.Time `json:"expires_at"` // zero = permanent
	// ActionID names the admission attempt that wrote the row; empty for
	// every other block. Older builds ignore it.
	ActionID string `json:"action_id,omitempty"`
	// Unverified marks a row whose kernel element is unproven: the attempt
	// ended unknown or has not been settled. It is not reported as a block
	// and not restored into the kernel.
	Unverified bool `json:"unverified,omitempty"`
	// Automatic marks a timed row a legacy automatic block wrote: explicit
	// provenance, where Source is read from the reason text (spec 6).
	// Admission rows carry ActionID instead. Older builds ignore it.
	Automatic bool `json:"automatic,omitempty"`
}

BlockedEntry represents a blocked IP with metadata.

type DynDNSResolver

type DynDNSResolver struct {
	// contains filtered or unexported fields
}

DynDNSResolver periodically resolves hostnames and updates the firewall allowed set.

func NewDynDNSResolver

func NewDynDNSResolver(hosts []string, engine interface {
	AllowIP(ip string, reason string) error
	RemoveAllowIPBySource(ip string, source string) error
}) *DynDNSResolver

NewDynDNSResolver creates a resolver for the given hostnames.

func (*DynDNSResolver) AddHost

func (d *DynDNSResolver) AddHost(host string)

AddHost appends a hostname to the resolver's host list. It is safe to call concurrently. Used in tests to add hosts after construction.

func (*DynDNSResolver) RegisterInfraHost

func (d *DynDNSResolver) RegisterInfraHost(host string)

RegisterInfraHost marks host as an infra hostname. Every subsequent successful resolution will, in addition to AllowIP, call engine.UpdateInfraResolved so the resolved IPs feed the infra-block guard. Idempotent. Wire an infra engine via SetInfraEngine before the resolver's first tick to make this effective.

func (*DynDNSResolver) Run

func (d *DynDNSResolver) Run(stopCh <-chan struct{})

Run starts the periodic resolver. Blocks until stopCh is closed.

func (*DynDNSResolver) SetFindingSink

func (d *DynDNSResolver) SetFindingSink(sink func(host string))

SetFindingSink installs the callback invoked when a host has been unresolvable for longer than gracePeriod. Called from the daemon at startup, after the alert pipeline is wired.

func (*DynDNSResolver) SetInfraEngine

func (d *DynDNSResolver) SetInfraEngine(eng interface {
	UpdateInfraResolved(host string, ips []string)
	DropInfraResolved(host string)
})

SetInfraEngine wires the engine that receives infra-mode resolution updates. Setting it to nil disables infra routing without affecting the regular allowed-IPs path.

func (*DynDNSResolver) UnresolvableHosts

func (d *DynDNSResolver) UnresolvableHosts() []string

UnresolvableHosts lists infra_ips hostnames currently failing to resolve beyond the grace period.

type Engine

type Engine struct {
	// contains filtered or unexported fields
}

Engine manages the nftables firewall ruleset. Manages the nftables ruleset via netlink.

func ConnectExisting

func ConnectExisting(cfg *FirewallConfig, statePath string) (*Engine, error)

ConnectExisting connects to an already-running CSM firewall. Used by CLI commands to modify the live ruleset without reapplying all rules.

func NewEngine

func NewEngine(cfg *FirewallConfig, statePath string) (*Engine, error)

NewEngine creates a new nftables firewall engine.

func (*Engine) AllowIP

func (e *Engine) AllowIP(ip string, reason string) error

AllowIP adds an IP to the allowed set and persists it. If the IP is currently blocked, the block is removed first.

func (*Engine) AllowIPPort

func (e *Engine) AllowIPPort(ip string, port int, proto string, reason string) (resultErr error)

AllowIPPort adds a port-specific IP allow. The rule is persisted to state and applied on the next Apply(). For immediate effect, call Apply() after.

func (*Engine) Apply

func (e *Engine) Apply() (resultErr error)

Apply builds and atomically applies the complete nftables ruleset. All operations (delete old table + create new table/rules + populate persisted block/allow entries) are batched into a single netlink transaction. If the flush fails, the kernel keeps whatever ruleset was running before - the server is never left without a firewall. Equally important: the new ruleset never appears with EMPTY blocked sets between the table-swap and the persisted-state load; an attacker IP from state.json is blocked from the moment the new table becomes the live one.

func (*Engine) ApplyBlockBatch

func (e *Engine) ApplyBlockBatch(members []BlockMember) ([]BlockResult, error)

ApplyBlockBatch runs the members of one batch: it checks them again against the current state and kernel, writes every remaining row (with its action ID) in one state write, and in one netlink transaction on a fresh connection with a deadline deletes each qualifying victim and adds the members' elements; then it reads the sets back. A member is applied only when the readback shows its element as encoded, and its victim is removed only when the readback of the victim's set shows it gone; a member whose removal is unproven is unknown. A victim's row stays until the owner retires it after the ledger records the outcome, so a restart before that restores it. A victim that could expire before the kernel runs the transaction is spared and its member alone fails set_full. A kernel rejection, including the ENOENT of a victim removed before the send, fails every member and restores the state; the owner retries with new charged attempts (spec 5.5). Any other uncertainty leaves the members unknown, their rows marked unverified. An error means nothing was written or sent.

func (*Engine) AttachLifecycle

func (e *Engine) AttachLifecycle(l *Lifecycle) error

AttachLifecycle is a construction-time injection, not a migration. The owner must already have initialized the complete store and own the daemon lock.

func (*Engine) BlockActionsUnproven

func (e *Engine) BlockActionsUnproven() error

BlockActionsUnproven is the cause SetBlockActionsUnproven recorded, or nil.

func (*Engine) BlockAdmissionSnapshot

func (e *Engine) BlockAdmissionSnapshot(members []BlockMember, shadow []string) (BlockAdmission, error)

BlockAdmissionSnapshot answers an admission turn without changing anything: each member's local checks against one snapshot of the state file and the kernel, as a batch check makes them, and the temporary occupancy from the same snapshot. No member takes a slot and no repeat is a duplicate (BlockAdmission.ReadyCapacity checks the members the caller is ready to run). shadow names the canonical addresses a forecast holds, at most MaxShadowBlocks; the answer counts those the sets already hold. An error refuses the whole call: too many members or addresses, a malformed or repeated address, durable state, or no snapshot. Remote checks run outside the engine lock.

func (*Engine) BlockBatchRequest

func (e *Engine) BlockBatchRequest(header ActionRequest, members []ActionRequest, budget *ScanAdmission) (BlockBatchResult, error)

BlockBatchRequest runs automatic temporary blocks as one durable batch (spec 5.5). Each member is checked on its own against one committed snapshot and refused members are left out; the rest share one admission, one kernel transaction and one outcome. A lone valid member runs as an ordinary one-request action under its own ID. A repeated call naming a stored batch observes it and never plans it again. Nothing evicts for a batch: a full temporary set refuses the member.

func (*Engine) BlockIP

func (e *Engine) BlockIP(ip string, reason string, timeout time.Duration) error

BlockIP adds an IP to the blocked set with optional timeout. timeout 0 = permanent block.

Thin wrapper over BlockIPOutcome that discards the outcome. Existing callers that only need success/error semantics keep working; auto- response callers should use BlockIPOutcome so they can suppress local side effects (state mutation, AUTO-BLOCK alert) when the kernel was not actually touched.

func (*Engine) BlockIPForUndo

func (e *Engine) BlockIPForUndo(ip, reason string, ttl time.Duration) (before, after *BlockedEntry, resultErr error)

BlockIPForUndo captures both sides of a forced operator block under the mutation lock. A later CLI decision must never become part of this undo.

func (*Engine) BlockIPForce

func (e *Engine) BlockIPForce(ip string, reason string, timeout time.Duration) (resultErr error)

BlockIPForce adds an IP to the blocked set unconditionally, bypassing the auto_response.dry_run gate. Use this for operator-initiated commands (CLI, Web UI manual block) where the operator has explicitly decided to block.

func (*Engine) BlockIPForcePreserveLifetime

func (e *Engine) BlockIPForcePreserveLifetime(ip, reason string, timeout time.Duration) (resultErr error)

BlockIPForcePreserveLifetime is the Web UI timed-block path. The guard and replacement share the engine lock, including concurrent CLI promotions.

func (*Engine) BlockIPOutcome

func (e *Engine) BlockIPOutcome(ip string, reason string, timeout time.Duration) (outcome BlockOutcome, resultErr error)

BlockIPOutcome is the AUTO-RESPONSE entry point. It performs the same guards, verdict-callback consultation, and dry-run gating as BlockIP, but additionally reports which path was taken via BlockOutcome so the caller can decide whether to record local state. See the BlockOutcome godoc for the meaning of each return value.

Operator-initiated commands (csm firewall block, Web UI manual block) must call BlockIPForce instead, which skips the dry-run gate unconditionally.

func (*Engine) BlockIPOutcomeWithFindingID

func (e *Engine) BlockIPOutcomeWithFindingID(ip, reason string, timeout time.Duration, findingID string) (outcome BlockOutcome, resultErr error)

BlockIPOutcomeWithFindingID preserves the originating audit identity across every automatic outcome without changing the block policy or result.

func (*Engine) BlockIPRequest

func (e *Engine) BlockIPRequest(req ActionRequest, budget *ScanAdmission) (BlockOutcome, error)

BlockIPRequest carries durable request identity and source-specific admission policy through the existing automatic and manual safety gates.

func (*Engine) BlockSubnet

func (e *Engine) BlockSubnet(cidr string, reason string, timeout time.Duration) (resultErr error)

BlockSubnet adds a CIDR range to the blocked subnets set (IPv4 or IPv6). timeout 0 = permanent block.

func (*Engine) BlockSubnetRequest

func (e *Engine) BlockSubnetRequest(req ActionRequest, budget *ScanAdmission) (resultErr error)

func (*Engine) BlockSubnetWithFindingID

func (e *Engine) BlockSubnetWithFindingID(cidr, reason string, timeout time.Duration, findingID string) (resultErr error)

BlockSubnetWithFindingID records a causal finding for an automatic subnet decision. Manual and maintenance callers use BlockSubnet without one.

func (*Engine) BlockedCount

func (e *Engine) BlockedCount() int

BlockedCount returns the number of live blocked IP entries the engine is enforcing. Sourced from the same state file Status() uses, so `/api/v1/status` and `csm firewall status` agree on the number. Expired entries are pruned by loadStateFile before being counted.

func (*Engine) BlockedSubnetCovering

func (e *Engine) BlockedSubnetCovering(ip string) (string, bool)

BlockedSubnetCovering reports the blocked CIDR (if any) that contains ip. The input-chain drops blocked_nets before the allowed_ips accept, so an allow on an IP inside a blocked subnet has no effect: the subnet drop still fires. Callers surface this so an operator is not told an IP is reachable when a subnet rule still blocks it. The subnet block stays authoritative by design (see subnetSafetyGuardLocked); this only reports, it does not unblock.

func (*Engine) BlockedSubnets

func (e *Engine) BlockedSubnets() []SubnetEntry

BlockedSubnets returns a snapshot of the active persisted subnet blocks. The returned slice is a fresh copy: loadStateFile reuses the warm shared state cache, so the slice it returns can alias internal engine state. Copy it here so a caller mutating the result cannot corrupt the cache. SubnetEntry is a value type (strings + time.Time, no reference fields), so a slice copy is a sufficient deep copy.

func (*Engine) CheckBlockBatch

func (e *Engine) CheckBlockBatch(members []BlockMember) ([]BlockRefusal, error)

CheckBlockBatch answers, before anything is reserved, which members of a batch can run: each is checked on its own against one snapshot of the state file and the kernel, and only the members that fail are refused. An error refuses the whole call: an empty or oversized batch, durable state, or no snapshot.

func (*Engine) CheckBlockBatchEffects

func (e *Engine) CheckBlockBatchEffects(members []BlockMember) ([]BlockCheck, error)

CheckBlockBatchEffects is CheckBlockBatch with the block that holds the address of each member refused as an existing effect.

func (*Engine) CleanExpiredAllows

func (e *Engine) CleanExpiredAllows() int

CleanExpiredAllows removes expired temporary allows from the set and state. An IP is only removed from nftables if no non-expired entries remain for it. Called periodically by the daemon.

func (*Engine) CleanExpiredSubnets

func (e *Engine) CleanExpiredSubnets() int

CleanExpiredSubnets removes expired temporary subnet blocks from nftables and state.

func (*Engine) CloudflareCovers

func (e *Engine) CloudflareCovers(ip string) bool

CloudflareCovers reports whether ip is inside a cached Cloudflare range, meaning a block of it leaves TCP 80/443 reachable (the CF accept precedes the blocked drop in the input chain).

func (*Engine) CloudflareIPs

func (e *Engine) CloudflareIPs() (ipv4, ipv6 []string)

CloudflareIPs returns the currently configured Cloudflare CIDRs from the cached state.

func (*Engine) Config

func (e *Engine) Config() *FirewallConfig

Config returns the ruleset input the engine currently applies.

func (*Engine) DropInfraResolved

func (e *Engine) DropInfraResolved(host string)

DropInfraResolved clears all resolved IPs for a host. Equivalent to UpdateInfraResolved(host, nil); separate name surfaces operator intent at call sites that purposefully retire a hostname.

func (*Engine) DurableActionsEnabled

func (e *Engine) DurableActionsEnabled() bool

func (*Engine) FirewallScanBudget

func (e *Engine) FirewallScanBudget(window string) (int, error)

func (*Engine) FlushBlocked

func (e *Engine) FlushBlocked() (resultErr error)

FlushBlocked removes all IPs from the blocked set and clears persisted state.

func (*Engine) HoldBlockBatches

func (e *Engine) HoldBlockBatches(held bool)

HoldBlockBatches holds admission's member checks, batches, probes and retirements while an apply-confirmed window is open: its rollback restores the firewall from a snapshot and would erase the rows they write (design C9). The read-only admission snapshot of a forecast still answers. It waits for a call in flight.

func (*Engine) IsAllowed

func (e *Engine) IsAllowed(ip string) bool

IsAllowed reports whether ip is on the operator allowed_ips set, read from the in-memory cache built from state.json. Mirrors IsBlocked, including the canonical-form retry for IPv4-mapped IPv6 callers.

func (*Engine) IsBlocked

func (e *Engine) IsBlocked(ip string) bool

IsBlocked returns true if the IP is currently in the engine's blocked state. Uses the persisted state file (which is cleaned of expired entries on load).

The lookup is O(1) via the blockedIPIndex map populated from the cached state. Linear scans over the parsed slice are gone -- on hosts with hundreds of persisted blocks the scan was the dominant cost of every connection-handler IsBlocked check.

func (*Engine) IsBlockedLive

func (e *Engine) IsBlockedLive(ip string) (bool, error)

IsBlockedLive queries the live nftables set, not the in-memory cache built from state.json. The cache can drift from the kernel when nft auto-expires entries faster than CSM rewrites state.json, or when an out-of-band flush happens. Reconcile loops should consult this method so the local tracker shrinks in lock-step with the kernel; per-packet hot paths should stay on IsBlocked since this issues a netlink RTT.

Malformed IPs are reported as absent. Netlink and engine-initialization failures are returned so callers can keep their cached answer instead of deleting local state on a transient lookup failure.

func (*Engine) IsSubnetBlocked

func (e *Engine) IsSubnetBlocked(cidr string) bool

IsSubnetBlocked returns true if the CIDR is present in the persisted subnet block state.

func (*Engine) LeaseNow

func (e *Engine) LeaseNow() time.Time

LeaseNow reads the lease clock for a caller outside the engine: the admission owner sets new expiries on it, so a wall clock step back cannot shorten them once the engine encodes their timeouts.

func (*Engine) LiveBlockedSet

func (e *Engine) LiveBlockedSet() (LiveBlockedSnapshot, error)

LiveBlockedSet dumps the blocked v4 and v6 sets once and returns a membership snapshot. IsBlockedLive issues a full set dump per call, so a reconcile pass over N tracked IPs cost N dumps of the entire set; callers testing more than one IP should snapshot instead.

A nil set is left uncovered rather than reported empty, and so is one whose dump failed: the returned snapshot always describes whichever families did answer, with the failures joined into the error. Callers keep their cached view for uncovered families instead of erasing local state on a transient netlink error, while a family that dumped cleanly stays reconciled.

func (*Engine) MarkBlockActionsUnverified

func (e *Engine) MarkBlockActionsUnverified(actions []string) (int, error)

MarkBlockActionsUnverified flags the rows written by the named attempts as unproven, in one state write, and reports how many changed. Startup calls it with the ledger's unresolved attempts before Apply, so their rows are neither restored nor reported as blocks. While a failed state write holds batches it writes the safe state even when nothing is named, and a successful write releases the hold.

func (*Engine) PendingActions

func (e *Engine) PendingActions() ([]FirewallAction, error)

PendingActions reports the actions recovery could not settle. While any exists the engine refuses new mutations, so this is what an operator needs to see before deciding anything.

func (*Engine) ProbeBlockState

func (e *Engine) ProbeBlockState() error

ProbeBlockState proves the current snapshot without sending an action.

func (*Engine) PromoteToPermanentBlock

func (e *Engine) PromoteToPermanentBlock(ip, reason string) (resultErr error)

PromoteToPermanentBlock upgrades an existing temporary block on ip to a permanent one: it clears the kernel timeout by deleting the timed element and re-adding it without a timeout, and zeroes ExpiresAt in state. The ordinary block path cannot do this during PermBlock escalation because it skips an already-blocked IP, so the kernel timeout would otherwise expire the block the operator wanted made permanent. Returns an error if the IP is not currently blocked (nothing to promote).

func (*Engine) PromoteToPermanentBlockWithFindingID

func (e *Engine) PromoteToPermanentBlockWithFindingID(ip, reason, findingID string) (resultErr error)

PromoteToPermanentBlockWithFindingID ties escalation to its triggering observation while preserving the existing permanent-block transaction.

func (*Engine) RecoverActions

func (e *Engine) RecoverActions() error

func (*Engine) RefreshDOSExemptSets

func (e *Engine) RefreshDOSExemptSets(providerNets []*net.IPNet) error

RefreshDOSExemptSets repopulates the dos_exempt_nets[6] interval sets with a new provider overlay in a single batched kernel transaction. If the kernel batch fails the previous set contents remain active and dosExemptProviderNets is not updated, preserving the last-known good state.

func (*Engine) RemoveAllowIP

func (e *Engine) RemoveAllowIP(ip string) (resultErr error)

RemoveAllowIP removes an IP from the allowed set and state.

func (*Engine) RemoveAllowIPBySource

func (e *Engine) RemoveAllowIPBySource(ip, source string) (resultErr error)

RemoveAllowIPBySource removes only allow entries from a specific source. The IP is only removed from the nftables set if no other sources remain.

func (*Engine) RemoveAllowIPPort

func (e *Engine) RemoveAllowIPPort(ip string, port int, proto string) (resultErr error)

RemoveAllowIPPort removes a port-specific IP allow from state.

func (*Engine) RemoveBlockActionRows

func (e *Engine) RemoveBlockActionRows(actions []string) (int, error)

RemoveBlockActionRows removes, in one state write, the rows the named attempts wrote and reports how many went. Startup calls it with the ledger's settled attempts (failed, or retired after their expiry) once every unproven one is marked: nothing they applied can remain, so their rows only hold an address. An error reports no completed removal; the next start reads the saved state and tries again.

func (*Engine) ResolveAction

func (e *Engine) ResolveAction(id, outcome, detail string) (FirewallAction, error)

ResolveAction records an outcome an operator established by hand for an action the kernel cannot prove. The committed cache follows the outcome, whether it came from the operator or from the kernel proving it after all.

func (*Engine) ResolvedInfraIPs

func (e *Engine) ResolvedInfraIPs(hosts []string) []string

ResolvedInfraIPs copies the last successful DNS answers for the requested infrastructure hosts. Callers choose which configured hosts they protect.

func (*Engine) RestoreBlockIfUnchanged

func (e *Engine) RestoreBlockIfUnchanged(ip string, expected, prior *BlockedEntry) (resultErr error)

RestoreBlockIfUnchanged applies a saved lifetime only while the action's firewall snapshot still matches. The comparison and mutation share e.mu.

func (*Engine) RetireBlockActions

func (e *Engine) RetireBlockActions(actions []BlockAction) ([]bool, error)

RetireBlockActions reports, for each expired unknown attempt or evicted import, whether nothing it may have applied can remain in the kernel (spec 5.4): its address has no element, or the element belongs to a verified row of another action. An import stays held while its own row proves the live element, even if an alias does too. Each clear row (same action ID or import name and address) goes in one state write, under the same engine lock as the snapshot, so no batch writes the address in between. An element still there otherwise keeps the attempt held, as after a forward wall step that made its expiry look past, and so does an address whose family the snapshot cannot read. An error answers nothing.

func (*Engine) RuleCounts

func (e *Engine) RuleCounts() RuleCounts

RuleCounts returns the cardinality of every firewall rule category from the engine state file with expired temp bans pruned. Callers needing a live count (e.g. Prometheus gauges) must use this rather than the bbolt store, which holds only the migration-time snapshot.

func (*Engine) RulesetSnapshot

func (e *Engine) RulesetSnapshot() (current, applied string, err error)

RulesetSnapshot returns the live rules and the baseline captured immediately after the last successful Apply. The engine lock keeps our own concurrent reconfiguration from splitting the pair. An empty baseline means capture failed or this engine attached to an existing table without applying it.

func (*Engine) SetBlockActionsUnproven

func (e *Engine) SetBlockActionsUnproven(cause error)

SetBlockActionsUnproven records, in memory only, why startup could not read the ledger. The rows its attempts wrote are restored as before, but none is ever chosen to make room for a new block: nothing proves which of them the kernel enforces. The next start reads the ledger again.

func (*Engine) SetBootClock

func (e *Engine) SetBootClock(read func() (bootID string, sinceBoot time.Duration, err error))

SetBootClock gives the engine the boot identity and the time since boot, read together. Without it the engine keeps no lease checkpoint.

func (*Engine) SetConfig

func (e *Engine) SetConfig(cfg *FirewallConfig)

SetConfig replaces the ruleset input the next Apply builds from. The engine was constructed with a value copy of the firewall block taken at daemon start, so every re-apply path (`csm firewall restart`, `apply-confirmed`, SIGHUP) rebuilt the old rules while reporting success and the operator's csm.yaml edit landed unprotected at the next restart. A nil cfg is ignored.

func (*Engine) SetDOSExemptProviderNets

func (e *Engine) SetDOSExemptProviderNets(nets []*net.IPNet)

SetDOSExemptProviderNets stores the mail-provider IP ranges used by the dos_exempt_nets set. The daemon calls this before Apply() and on each provider refresh. Nil is valid when the daemon has no provider data yet; createSets() will produce an empty set in that case.

func (*Engine) SetDryRunEnabledFunc

func (e *Engine) SetDryRunEnabledFunc(fn func() bool)

SetDryRunEnabledFunc installs the callback BlockIP uses to decide whether auto_response.dry_run should intercept an automatic block. Nil means live.

func (*Engine) SetDryRunRecorder

func (e *Engine) SetDryRunRecorder(fn func(ip, reason string, timeout time.Duration))

func (*Engine) SetLeaseFloor

func (e *Engine) SetLeaseFloor(floor time.Time)

SetLeaseFloor moves the lease clock forward to floor when it is behind it. The daemon passes the admission clock's saved time before the first Apply: a lower bound for a new boot, where the lease checkpoint proves no downtime (lease_checkpoint_linux.go).

func (*Engine) SetShutdownContext

func (e *Engine) SetShutdownContext(ctx context.Context)

SetShutdownContext installs a context whose cancellation aborts any in-flight verdict callback. The daemon ties this to its stopCh so a graceful shutdown does not have to wait for an unresponsive panel callback to return.

func (*Engine) SetSoftAllowChecker

func (e *Engine) SetSoftAllowChecker(fn func(ip string) bool)

SetSoftAllowChecker installs the callback the auto-block path consults to decide whether an IP belongs to a verified-bot range. Nil disables the verified-bot side of the soft-allow gate (operator allowed_ips is still honoured). The daemon wires this to threatintel so the firewall package stays free of that import.

func (*Engine) SetVerdictAsker

func (e *Engine) SetVerdictAsker(fn func(ctx context.Context, ip, reason string) (string, string, string, error))

SetVerdictAsker installs the verdict callback the daemon constructs at startup. Nil disables the verdict callback (the gate skips entirely).

func (*Engine) StateSetAside

func (e *Engine) StateSetAside() string

StateSetAside names where an unreadable state file was kept when the engine first read its state, or "" when it read it.

func (*Engine) Status

func (e *Engine) Status() map[string]interface{}

Status returns current firewall statistics.

Takes e.mu so the cached state can be read coherently. Before the cache existed loadStateFile was lock-free because every call did its own ReadFile + Unmarshal; now that loadStateFile mutates the shared cache + index, the lock is required.

func (*Engine) TempAllowIP

func (e *Engine) TempAllowIP(ip string, reason string, timeout time.Duration) error

TempAllowIP adds a temporary allow with expiry. Uses the same allowed set but tracks expiry in state - CleanExpiredAllows removes them periodically.

func (*Engine) UnblockIP

func (e *Engine) UnblockIP(ip string) (resultErr error)

UnblockIP removes an IP from the blocked set and state.

func (*Engine) UnblockIPForUndo

func (e *Engine) UnblockIPForUndo(ip string) (before *BlockedEntry, resultErr error)

UnblockIPForUndo captures the removed block in the same critical section.

func (*Engine) UnblockSubnet

func (e *Engine) UnblockSubnet(cidr string) (resultErr error)

UnblockSubnet removes a CIDR range from the blocked subnets set (IPv4 or IPv6).

func (*Engine) UndoAction

func (e *Engine) UndoAction(req ActionRequest) (FirewallAction, error)

func (*Engine) UpdateCloudflareSet

func (e *Engine) UpdateCloudflareSet(ipv4, ipv6 []string) error

UpdateCloudflareSet flushes and repopulates the Cloudflare nftables sets.

func (*Engine) UpdateInfraResolved

func (e *Engine) UpdateInfraResolved(host string, ips []string)

UpdateInfraResolved records the IP set last resolved for an infra hostname. Replaces any previous entry for that host so the resolver's per-tick refresh leaves no stale ghost IPs. Pass an empty ips slice to remove the host entirely (e.g. when DNS stopped resolving).

func (*Engine) ValidateSubnetBlock

func (e *Engine) ValidateSubnetBlock(cidr string) error

ValidateSubnetBlock runs the same safety and capability checks as BlockSubnet without changing persisted or kernel firewall state.

type ExistingBlock

type ExistingBlock struct {
	ID        string
	ExpiresAt time.Time
}

ExistingBlock names the block that already holds an address, so a member refused as an existing effect can link to it (spec 5.17): the admission action that wrote its row, or "eff_" and a digest of an operator or earlier row's identity, and when it ends (zero: permanent). The same row gets the same ID on every check and after a restart.

type FirewallAction

type FirewallAction struct {
	Ruleset      *ActionRuleset `json:"ruleset,omitempty"`
	KernelBefore []ActionSet    `json:"kernel_before,omitempty"`
	KernelAfter  []ActionSet    `json:"kernel_after,omitempty"`
	Request      ActionRequest  `json:"request"`
	// Members is the batch manifest. Without it the action is one member, its
	// Request and Budget, as every record written before manifests is. With
	// it, a batch of several members uses Request as its header.
	Members  []ActionMember `json:"members,omitempty"`
	Before   FirewallState  `json:"before"`
	After    FirewallState  `json:"after"`
	Revision uint64         `json:"revision"`
	Phase    string         `json:"phase"`
	// Retired marks an unknown outcome recovery settled as expired_unproven:
	// every effect had expired and the kernel held the retired state. The
	// outcome stays unknown, but it no longer holds later mutations.
	Retired      bool           `json:"retired,omitempty"`
	CreatedAt    time.Time      `json:"created_at"`
	UpdatedAt    time.Time      `json:"updated_at"`
	Detail       string         `json:"detail,omitempty"`
	Budget       *ScanAdmission `json:"budget,omitempty"`
	AuditVersion uint64         `json:"audit_version"`
	AuditAck     uint64         `json:"audit_ack"`
}

FirewallAction retains both complete states until the kernel outcome can be proved. Admission does not publish After as committed firewall state.

func (FirewallAction) ActionMembers

func (a FirewallAction) ActionMembers() []ActionMember

ActionMembers returns the batch manifest, deriving the single member of an action that carries none. Callers must not modify the result.

func (FirewallAction) IsRequest

func (a FirewallAction) IsRequest(req ActionRequest) bool

IsRequest reports whether a is the one-member action for exactly req, so a batch whose header happens to match is never replayed as one request.

func (FirewallAction) SameManifest

func (a FirewallAction) SameManifest(b FirewallAction) bool

SameManifest reports whether b is the same batch as a: the same header, the same members in the same order and the same effects. Admission data is not compared: a replay in a later window keeps the charge taken first.

type FirewallConfig

type FirewallConfig struct {
	Enabled bool `yaml:"enabled"`

	// Open ports (IPv4)
	TCPIn  []int `yaml:"tcp_in"`
	TCPOut []int `yaml:"tcp_out"`
	UDPIn  []int `yaml:"udp_in"`
	UDPOut []int `yaml:"udp_out"`

	// IPv6 - enable dual-stack filtering
	IPv6    bool  `yaml:"ipv6"`
	TCP6In  []int `yaml:"tcp6_in"`  // if empty, uses tcp_in
	TCP6Out []int `yaml:"tcp6_out"` // if empty, uses tcp_out
	UDP6In  []int `yaml:"udp6_in"`  // if empty, uses udp_in
	UDP6Out []int `yaml:"udp6_out"` // if empty, uses udp_out

	// Ports restricted to infra IPs only
	RestrictedTCP []int `yaml:"restricted_tcp"`

	// RequiredTCPOut declares outbound TCP ports a service on this host needs.
	// It is checked, never merged: validation warns when an effective outbound
	// family policy omits one, so an integration can state its
	// requirement in its own conf.d fragment and have `csm doctor` verify it
	// against the effective policy.
	RequiredTCPOut []int `yaml:"required_tcp_out"`

	// TCPOutAllow permits outbound TCP to a specific destination on a port
	// range. tcp_out is []int and cannot express a range, so a host acting as
	// a client for a range-using protocol (passive FTP data channels) has no
	// way to state its need without opening every high port to the internet.
	// Empty means no destination-scoped egress, which is the prior behaviour.
	TCPOutAllow []OutAllowRule `yaml:"tcp_out_allow"`

	// ProtocolAllow accepts an IP protocol that has no ports, such as VRRP
	// between the members of a high-availability pair, to and from listed
	// peers only. tcp/udp/icmp have their own keys and are not accepted here.
	ProtocolAllow []ProtocolAllowRule `yaml:"protocol_allow"`

	// Passive FTP range
	PassiveFTPStart int `yaml:"passive_ftp_start"`
	PassiveFTPEnd   int `yaml:"passive_ftp_end"`

	// Infra IPs (CIDR notation)
	InfraIPs []string `yaml:"infra_ips"`

	// Rate limiting (per-source nftables meters). SYN/conn-rate/UDP are
	// dual-stack (IPv6 keyed per /64); ConnLimit is IPv4-only (per-source
	// ct count uses nf_conncount, whose GC has a kernel UAF on the el8 kernel).
	ConnRateLimit      int  `yaml:"conn_rate_limit"` // new connections per minute per source (IPv6 per /64)
	SYNFloodProtection bool `yaml:"syn_flood_protection"`
	ConnLimit          int  `yaml:"conn_limit"` // max concurrent connections per IPv4 source, IPv4 only (0 = disabled)

	// Per-port flood protection - per-source rate limit per port and IP family.
	PortFlood []PortFloodRule `yaml:"port_flood"`

	// UDP flood protection - per-source rate limit on UDP packets (IPv6 per /64)
	UDPFlood      bool `yaml:"udp_flood"`
	UDPFloodRate  int  `yaml:"udp_flood_rate"`  // packets per second
	UDPFloodBurst int  `yaml:"udp_flood_burst"` // burst allowance

	// Country blocking
	CountryBlock  []string `yaml:"country_block"` // ISO country codes
	CountryDBPath string   `yaml:"country_db_path"`

	// Ports to drop silently without logging (reduces log noise from scanners)
	DropNoLog []int `yaml:"drop_nolog"`

	// Max blocked IPs (prevents memory exhaustion, 0 = unlimited)
	DenyIPLimit     int `yaml:"deny_ip_limit"`
	DenyTempIPLimit int `yaml:"deny_temp_ip_limit"`

	// Outbound SMTP restriction - block outgoing mail except from allowed users
	SMTPBlock      bool     `yaml:"smtp_block"`
	SMTPAllowUsers []string `yaml:"smtp_allow_users"` // usernames allowed to send
	SMTPPorts      []int    `yaml:"smtp_ports"`

	// Dynamic DNS - resolve hostnames to IPs, update allowed set periodically
	DynDNSHosts []string `yaml:"dyndns_hosts"`

	// Logging
	LogDropped bool `yaml:"log_dropped"`
	LogRate    int  `yaml:"log_rate"` // log entries per minute

	// DoS-exempt ranges - source CIDRs excluded from connection/mail-port
	// meters and auto-subnet escalation. Intended for shared-source ranges
	// such as carrier CGNAT blocks and well-known mail-provider egress.
	DOSExemptRanges             []string `yaml:"dos_exempt_ranges"`
	DOSExemptKnownMailProviders *bool    `yaml:"dos_exempt_known_mail_providers"`
}

FirewallConfig defines the nftables firewall configuration.

func DefaultConfig

func DefaultConfig() *FirewallConfig

DefaultConfig returns a sensible default firewall configuration matching a typical cPanel server.

func (*FirewallConfig) ExemptKnownMailProviders

func (c *FirewallConfig) ExemptKnownMailProviders() bool

ExemptKnownMailProviders reports whether bundled mail-provider ranges are included in the DoS-exempt set. Defaults to true when unset.

type FirewallState

type FirewallState struct {
	Blocked     []BlockedEntry   `json:"blocked"`
	BlockedNet  []SubnetEntry    `json:"blocked_nets"`
	Allowed     []AllowedEntry   `json:"allowed"`
	PortAllowed []PortAllowEntry `json:"port_allowed"`
}

FirewallState is persisted to disk for restore on restart.

func LoadState

func LoadState(statePath string) (*FirewallState, error)

LoadState reads the authoritative firewall state file directly without requiring a running engine. A missing state file is a valid fresh-host state and returns an empty FirewallState. Only enforced blocks are returned: an unverified row is held for recovery and never restored to the kernel.

func RetiredActionState

func RetiredActionState(a FirewallAction, at time.Time) (FirewallState, bool)

RetiredActionState is the committed state an unknown block action retires to as expired_unproven (spec 5.4): every member target is timed and expired, and every row its Before and After states disagree on is a verified timed block that has expired at at. Committed state keeps the rows both states agree on. Any other action reports false and waits for an operator's Resolve: one that is not a block, changes anything but blocked rows, or has an effect that is permanent, unverified or still live.

type Lifecycle

type Lifecycle struct {
	Store ActionStore
	Audit func(FirewallAction) error

	// RetirementClockTrusted runs with the engine lock held and must not
	// call back into the engine. Nil or false refuses retirement; an error
	// also reaches recovery's caller, even if the returned bool is true.
	RetirementClockTrusted func() (bool, error)
	// contains filtered or unexported fields
}

Lifecycle is owned by one engine. Its lock serializes execution with recovery and undo, while the store protects admission against competing requests.

func (*Lifecycle) DeliverAudit

func (l *Lifecycle) DeliverAudit() error

func (*Lifecycle) Execute

func (l *Lifecycle) Execute(plan FirewallAction, kernel ActionKernel) (FirewallAction, error)

Execute admits once and executes only a fresh request. Repeated IDs inspect the original evidence; they never renew the plan or replay a kernel batch.

func (*Lifecycle) Recover

func (l *Lifecycle) Recover(kernel ActionKernel) error

Recover proves incomplete outcomes without executing host mutations. A proven rejected action is recovered successfully, though its result is failed.

func (*Lifecycle) Resolve

func (l *Lifecycle) Resolve(id, outcome, detail string, kernel ActionKernel) (FirewallAction, error)

Resolve records the outcome an operator established by hand for an action the kernel could not prove or retire. Kernel evidence still wins: a proven outcome is recorded instead of the asserted one, and a settled action is never rewritten. Detail names who decided, and reaches the audit trail.

func (*Lifecycle) Undo

func (l *Lifecycle) Undo(req ActionRequest, kernel ActionKernel) (FirewallAction, error)

Undo admits an inverse state transition, never a command string. A whole action needs its complete result current; a member needs its own row current.

type LiveBlockedSnapshot

type LiveBlockedSnapshot struct {
	V4    map[string]struct{}
	V6    map[string]struct{}
	HasV4 bool
	HasV6 bool
}

LiveBlockedSnapshot is a point-in-time membership view of the kernel's blocked IP sets, taken by dumping each configured family set once.

HasV4 / HasV6 record which families the snapshot actually covers. A set can be absent -- firewall.ipv6 disabled leaves the v6 set nil -- and an uncovered family must never read as "not blocked", or a reconcile pass would prune every tracked block of that family on its first cycle.

func (LiveBlockedSnapshot) Contains

func (s LiveBlockedSnapshot) Contains(ip string) (blocked, known bool)

Contains reports whether ip is in the live blocked set. known is false when the snapshot does not cover the IP's address family, in which case the caller must keep its cached answer rather than treat ip as unblocked.

Family selection mirrors the per-IP path: an IPv4-mapped IPv6 address resolves against the v4 set, because that is the set the block path keys it into. A malformed IP is reported as definitively absent, matching IsBlockedLive.

type OutAllowRule

type OutAllowRule struct {
	Dst       string `yaml:"dst"`
	PortStart int    `yaml:"port_start"`
	PortEnd   int    `yaml:"port_end"`
}

OutAllowRule permits outbound TCP to one destination on a port range. Dst is an IP or CIDR; 0.0.0.0/0 and ::/0 mean any destination in that family.

type PortAllowEntry

type PortAllowEntry struct {
	IP     string `json:"ip"`
	Port   int    `json:"port"`
	Proto  string `json:"proto"` // "tcp" or "udp"
	Reason string `json:"reason"`
	Source string `json:"source,omitempty"`
}

PortAllowEntry represents a port-specific IP allow (e.g. tcp|in|d=PORT|s=IP).

type PortFloodRule

type PortFloodRule struct {
	Port    int    `yaml:"port"`
	Proto   string `yaml:"proto"`   // "tcp" or "udp"
	Hits    int    `yaml:"hits"`    // max new connections
	Seconds int    `yaml:"seconds"` // time window in seconds
}

PortFloodRule defines per-port connection rate limiting.

type ProtocolAllowRule

type ProtocolAllowRule struct {
	Protocol string   `yaml:"protocol"`
	Peers    []string `yaml:"peers"`
}

ProtocolAllowRule accepts one portless IP protocol from and to its peers. Each peer is an IP or CIDR, parsed as a tcp_out_allow destination.

type RuleCounts

type RuleCounts struct {
	Blocked     int
	Allowed     int
	Subnets     int
	PortAllowed int
}

RuleCounts holds firewall rule cardinalities sourced from the engine state file, which is the authoritative store. The parallel bbolt fw:* buckets are written only during migration, so anything counting live rules must read the engine, not the store. Expired temp bans are excluded.

func (RuleCounts) Total

func (c RuleCounts) Total() int

Total returns the sum across all rule categories.

type ScanAdmission

type ScanAdmission struct {
	Window string `json:"window"`
	Limit  int    `json:"limit"`
}

ScanAdmission uses the existing scan hourly policy. A nil admission is exempt; callers must not charge non-scan sources to the scan budget.

type StateStore

type StateStore interface {
	ReadFirewallState() (FirewallState, uint64, error)
	ReplaceFirewallState(expectedRevision uint64, state FirewallState) (uint64, error)
}

StateStore is the firewall-owned, lossless persistence contract. It does not activate the engine's storage cutover. No method filters, normalizes, merges, infers provenance, renews expiry, or touches the kernel.

Read returns detached state and its revision, or only an error. Replace atomically replaces all four collections if the expected revision matches. Revision zero initializes a previously uninitialized snapshot. A successful replacement returns the new revision; an error returns zero. Callers must not mutate input slices during a call. Times retain their persisted instant and offset, not process-local monotonic clock or location metadata.

Action admission must later share the private store transaction that replaces state; callers must not compose this operation with separate action writes.

type SubnetEntry

type SubnetEntry struct {
	CIDR      string    `json:"cidr"`
	Reason    string    `json:"reason"`
	Source    string    `json:"source,omitempty"`
	BlockedAt time.Time `json:"blocked_at"`
	ExpiresAt time.Time `json:"expires_at,omitempty"`
}

SubnetEntry represents a blocked CIDR range.

Directories

Path Synopsis
Package rollback implements the firewall settings tentative-apply workflow: a save with a deadline that auto-reverts unless the operator confirms before the timer expires.
Package rollback implements the firewall settings tentative-apply workflow: a save with a deadline that auto-reverts unless the operator confirms before the timer expires.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL