enforce

package
v0.1.4 Latest Latest
Warning

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

Go to latest
Published: Aug 25, 2026 License: AGPL-3.0 Imports: 21 Imported by: 0

Documentation

Overview

Package enforce implements sdk.Enforcer backed by nftables.

Privilege separation: the NftablesEnforcer (this package) runs inside the main daemon as an unprivileged user. It communicates with the ezyshield-enforcer helper (CAP_NET_ADMIN) over a unix socket using newline-delimited JSON. The helper accepts only the fixed verb set {add, del, flush, list, ping} with typed, validated arguments — no raw nft syntax is ever passed from caller to helper.

Index

Constants

View Source
const CodeAlreadyAbsent = "already_absent"

CodeAlreadyAbsent is returned on a successful "del" or "allow_del" when the target element was already gone from the nftables set — for example because nft's native per-element `timeout` fired between the client's list and delete. The end state (absent) is what the caller wanted, so OK is still true; the code lets the client log this at DEBUG instead of ERROR.

View Source
const DefaultSocketPath = "/run/ezyshield-enforcer/enforcer.sock"

DefaultSocketPath is the unix socket used by the enforcer helper.

View Source
const FeatureCustomNames = "custom_names"

FeatureCustomNames is advertised by helpers that honor Request.Table / Request.Set. Daemons configured with non-default names require it.

Variables

View Source
var ErrCFThrottled = errors.New("cloudflare API throttled")

ErrCFThrottled marks a Cloudflare mutation that kept being rate limited after every backoff attempt. The daemon treats a throttle-only enforcement failure as transient (a streak is required before DEGRADED) — see IsThrottleOnly and internal/daemon/enfstate.go.

View Source
var ErrGateRefused = errors.New("refused by allowlist/anti-lockout gate")

ErrGateRefused marks a Ban refused by the centralized allowlist / anti-lockout gate. Callers can detect it with errors.Is.

Functions

func IsThrottleOnly added in v0.1.2

func IsThrottleOnly(err error) bool

IsThrottleOnly reports whether every failure in err's tree is a transient Cloudflare throttle (ErrCFThrottled). A mixed tree — throttle on one enforcer joined with a real failure on another — returns false, so real failures still degrade enforcement state immediately.

func NewCloudflareEnforcer

func NewCloudflareEnforcer(ctx context.Context, cfg *config.CloudflareCfg, allowlist []netip.Prefix) (sdk.Enforcer, error)

NewCloudflareEnforcer constructs a Cloudflare enforcer from cfg. It dispatches on cfg.Mode: empty or "lists" → CloudflareListsEnforcer (account-level Lists API, default), "rulesets" → CloudflareEnforcer (per-zone WAF Custom Rules, legacy). ctx is the service lifetime context; background debounce flushes are bounded by it.

Types

type AllowlistSyncer

type AllowlistSyncer interface {
	Allow(ctx context.Context, prefix netip.Prefix) error
	Unallow(ctx context.Context, prefix netip.Prefix) error
	SyncAllowlist(ctx context.Context, want []netip.Prefix) error
}

AllowlistSyncer is the optional interface an Enforcer implements to mirror the daemon's allowlist into local firewall state — the nftables @allowed / @allowed6 sets that back the ADR-0007 layer-4 anti-lockout accept rules. It is structurally identical to the daemon's private allowlistSyncer assertion, so satisfying one satisfies the other. Kept out of sdk.Enforcer proper because edge enforcers (Cloudflare) have no matching concept.

Wrapper enforcers (Gate, MultiEnforcer) MUST forward these methods: hiding them makes the daemon's type assertion fail and silently disables the kernel-level anti-lockout backstop (issue #317).

type CloudflareEnforcer

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

CloudflareEnforcer maintains a single WAF Custom Rule per zone (phase=http_request_firewall_custom) containing all blocked IPs in one expression: (ip.src in {ip1 ip2 cidr3}). When the expression exceeds cfExprMax bytes (~200 IPs), additional rules are created with descriptions ezyshield-blocklist-2, ezyshield-blocklist-3, … The API token is resolved once at construction time and never appears in logs or errors.

func (*CloudflareEnforcer) Ban

Ban adds the target IP/CIDR to every configured zone's ezyshield WAF rule. Returns an error without touching the API if the target is allowlisted. ASN/Country targets are not supported.

func (*CloudflareEnforcer) Name

func (e *CloudflareEnforcer) Name() string

Name implements sdk.Enforcer. Returns "cloudflare" for the default (unnamed/single-account) case to preserve backward compatibility, and "cloudflare[<name>]" when an instance name is configured — used by MultiEnforcer logging to disambiguate failures across accounts.

func (*CloudflareEnforcer) Sync

func (e *CloudflareEnforcer) Sync(ctx context.Context, want []sdk.Target) error

Sync replaces each zone's ezyshield blocklist with exactly the given targets. Allowlisted targets are silently skipped. Push is always synchronous.

func (*CloudflareEnforcer) Unban

func (e *CloudflareEnforcer) Unban(ctx context.Context, t sdk.Target) error

Unban removes the target IP/CIDR from every zone's ezyshield WAF rule.

type CloudflareListsEnforcer

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

CloudflareListsEnforcer maintains a single Cloudflare account-level Custom IP List ("Lists API") containing every ezyshield-banned IP. A single API call per list propagates to all zones that reference the list. When zone_ids is set, WAF Custom Rules are automatically managed in each zone.

Ownership is per instance (issue #486): this daemon reconciles ONLY items carrying its own "ezyshield:<instance>" comment tag. Items written by other EzyShield instances sharing the account, legacy bare-"ezyshield" items (unless adopt_legacy_items), and manual entries are all left untouched on Sync/Unban — several servers can share the free plan's single list additively.

The API token is resolved once at construction time and never logged.

func NewCFListsEnforcerForTestWithZones

func NewCFListsEnforcerForTestWithZones(ctx context.Context, token, baseURL, accountID, listName string, zoneIDs []string) *CloudflareListsEnforcer

NewCFListsEnforcerForTestWithZones is exported for testing WAF rule management.

func NewCloudflareListsEnforcer

func NewCloudflareListsEnforcer(ctx context.Context, cfg *config.CloudflareCfg, allowlist []netip.Prefix) (*CloudflareListsEnforcer, error)

NewCloudflareListsEnforcer constructs a Lists-mode enforcer from cfg. ctx is the service lifetime context; background debounce flushes are bounded by it. cfg.APIToken is resolved at construction time; the resolved value is not stored anywhere except this struct's private token field.

func (*CloudflareListsEnforcer) Ban

Ban adds the target IP/CIDR to the desired set and pushes (immediate or debounced). Refuses allowlisted targets without contacting the API. ASN/Country targets are not supported.

func (*CloudflareListsEnforcer) Name

func (e *CloudflareListsEnforcer) Name() string

Name implements sdk.Enforcer. Returns "cloudflare" for the default (unnamed/single-account) case to preserve backward compatibility, and "cloudflare[<name>]" when an instance name is configured — used by MultiEnforcer logging to disambiguate failures across accounts.

func (*CloudflareListsEnforcer) Sync

func (e *CloudflareListsEnforcer) Sync(ctx context.Context, want []sdk.Target) error

Sync replaces the desired set with exactly the given targets (modulo allowlist). Push is synchronous. Items not managed by ezyshield are left untouched. If zone_ids are configured, WAF rules are also managed per zone.

func (*CloudflareListsEnforcer) Unban

Unban removes the target IP/CIDR from the desired set and pushes.

type Gate

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

Gate wraps an Enforcer (typically the MultiEnforcer) and refuses any Ban — and silently filters any Sync desired-state entry — whose target overlaps the allowlist/admin CIDRs or covers an active operator SSH peer. Unban always passes through: removing a ban can never lock anyone out.

ASN and Country targets pass through unchecked: they cannot be compared against an IP allowlist here; their corroboration rules live in the decision engine.

func NewGate

func NewGate(inner sdk.Enforcer, allowlist []netip.Prefix, sshPeers func() []netip.Addr) *Gate

NewGate wraps inner with the centralized guard. allowlist should carry the policy allowlist plus admin_cidrs (same slice the enforcers receive); it is canonicalized on construction (IPv4-mapped spellings become plain IPv4, see normalizeGatePrefix) so a mapped policy entry still protects its plain-v4 range. sshPeers is typically decision.ProcSSHPeers; nil disables the peer check (the allowlist check always runs).

func (*Gate) Allow

func (g *Gate) Allow(ctx context.Context, prefix netip.Prefix) error

Allow forwards the allowlist addition to the inner enforcer's @allowed mirror. Allowlist mutations are never gated: widening the allowlist can only restore access, never lock anyone out, and this path is itself the anti-lockout backstop the gate exists to protect (issue #317). Inners without a local allowlist mirror (edge-only setups) make this a no-op.

func (*Gate) Ban

func (g *Gate) Ban(ctx context.Context, t sdk.Target) error

Ban refuses guarded targets with an audited refusal before any enforcer sees them; everything else is forwarded to the inner enforcer.

func (*Gate) Name

func (g *Gate) Name() string

Name returns the inner enforcer's name; the gate is transparent in logs that identify enforcement backends.

func (*Gate) Sync

func (g *Gate) Sync(ctx context.Context, want []sdk.Target) error

Sync filters guarded targets out of the desired state with an audited refusal each, so a reconcile can never re-introduce them downstream.

func (*Gate) SyncAllowlist

func (g *Gate) SyncAllowlist(ctx context.Context, want []netip.Prefix) error

SyncAllowlist forwards the desired allowlist state to the inner enforcer's @allowed mirror; no-op when the inner has none. Entries are canonicalized (issue #405): a mapped prefix forwarded verbatim would land in the nftables @allowed v6 set as a dead entry protecting nothing. Duplicates that two spellings of one range collapse into are harmless downstream (nft set semantics; NftablesEnforcer.SyncAllowlist deduplicates via its want-set).

func (*Gate) Unallow

func (g *Gate) Unallow(ctx context.Context, prefix netip.Prefix) error

Unallow forwards the allowlist removal to the inner enforcer's @allowed mirror; no-op when the inner has none.

func (*Gate) Unban

func (g *Gate) Unban(ctx context.Context, t sdk.Target) error

Unban always passes through: removing a ban cannot violate the invariant. The spelling is still canonicalized so an unban in the mapped form removes the plain-v4 entry Ban wrote, instead of missing it (issue #405).

type MultiEnforcer

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

MultiEnforcer fans Ban/Unban/Sync out to multiple underlying enforcers. All enforcers are always called; individual failures are logged as warnings and combined into a single returned error.

func NewMulti

func NewMulti(enforcers ...sdk.Enforcer) *MultiEnforcer

NewMulti returns a MultiEnforcer wrapping the given enforcers in order.

func (*MultiEnforcer) Allow

func (m *MultiEnforcer) Allow(ctx context.Context, prefix netip.Prefix) error

Allow forwards the allowlist addition to every enforcer that mirrors the allowlist locally (AllowlistSyncer); enforcers without the concept (edge blockers like Cloudflare) are skipped. Individual failures are logged and joined, matching Ban/Unban/Sync semantics (issue #317).

func (*MultiEnforcer) Ban

func (m *MultiEnforcer) Ban(ctx context.Context, t sdk.Target) error

Ban calls Ban on every enforcer, logging individual failures.

func (*MultiEnforcer) Name

func (m *MultiEnforcer) Name() string

Name returns a combined name like "nftables+cloudflare".

func (*MultiEnforcer) Sync

func (m *MultiEnforcer) Sync(ctx context.Context, want []sdk.Target) error

Sync calls Sync on every enforcer.

func (*MultiEnforcer) SyncAllowlist

func (m *MultiEnforcer) SyncAllowlist(ctx context.Context, want []netip.Prefix) error

SyncAllowlist forwards the desired allowlist state to every AllowlistSyncer enforcer.

func (*MultiEnforcer) Unallow

func (m *MultiEnforcer) Unallow(ctx context.Context, prefix netip.Prefix) error

Unallow forwards the allowlist removal to every AllowlistSyncer enforcer.

func (*MultiEnforcer) Unban

func (m *MultiEnforcer) Unban(ctx context.Context, t sdk.Target) error

Unban calls Unban on every enforcer.

type NftablesEnforcer

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

NftablesEnforcer sends ban/unban/sync commands to the privileged ezyshield-enforcer helper over a unix socket (JSON lines).

Belt-and-suspenders: the allowlist is re-checked here before every Ban call so that an accidental direct invocation cannot bypass the decision engine's primary allowlist guard (AGENTS.md Hard Rule §1).

func New

func New(socketPath string, allowlist []netip.Prefix, opts ...Option) *NftablesEnforcer

New creates a NftablesEnforcer. socketPath defaults to DefaultSocketPath when empty. allowlist should mirror the policy engine's runtime allowlist.

func (*NftablesEnforcer) Allow

func (e *NftablesEnforcer) Allow(ctx context.Context, prefix netip.Prefix) error

Allow adds prefix to the nftables @allowed set via the enforcer helper. The allowlist supremacy invariant (AGENTS.md §2) is enforced at the same hook where drops happen — see initTable in cmd/ezyshield-enforcer/nft.go. Called by the daemon whenever an allowlist entry is added.

func (*NftablesEnforcer) Ban

Ban adds the target to the nftables blocked set via the enforcer helper. Returns an error without contacting the helper if the target is allowlisted.

func (*NftablesEnforcer) Name

func (e *NftablesEnforcer) Name() string

Name implements sdk.Enforcer.

func (*NftablesEnforcer) Sync

func (e *NftablesEnforcer) Sync(ctx context.Context, want []sdk.Target) error

Sync reconciles the nftables blocked set with the desired target list. It is called at daemon startup (to apply bans_active) and periodically. Allowlisted targets in want are silently skipped.

func (*NftablesEnforcer) SyncAllowlist

func (e *NftablesEnforcer) SyncAllowlist(ctx context.Context, want []netip.Prefix) error

SyncAllowlist reconciles the nftables @allowed sets with the desired list of prefixes. Called at daemon startup after loading the persisted allowlist from the store, and after any bulk mutation. Mirrors Sync for the block set.

func (*NftablesEnforcer) Unallow

func (e *NftablesEnforcer) Unallow(ctx context.Context, prefix netip.Prefix) error

Unallow removes prefix from the nftables @allowed set. Called when the operator explicitly revokes an allowlist entry or when an entry expires. Missing element is treated as success (idempotent, race-safe).

func (*NftablesEnforcer) Unban

func (e *NftablesEnforcer) Unban(ctx context.Context, t sdk.Target) error

Unban removes the target from the nftables blocked set via the enforcer helper. A CodeAlreadyAbsent response is intentionally collapsed into nil: `ezyshield unban <ip>` is defined as idempotent, and callers (CLI, admin API) already treat a missing target as success — surfacing the code here would just add ceremony without changing behaviour.

type Option

type Option func(*NftablesEnforcer)

Option configures a NftablesEnforcer.

func WithNames

func WithNames(table, set string) Option

WithNames sets the nftables table and set names from config. Values must already have passed config validation (nftnames.Resolve); New re-resolves them defensively and panics on programmer error (invalid names reaching this point mean config validation was bypassed).

type Request

type Request struct {
	Verb       string `json:"verb"`
	IP         string `json:"ip,omitempty"`
	TTLSeconds int64  `json:"ttl_seconds,omitempty"` // 0 = permanent
	Table      string `json:"table,omitempty"`
	Set        string `json:"set,omitempty"`
}

Request is sent from the main daemon to the privileged enforcer helper. IP must be a valid netip.Addr or netip.Prefix string; raw nft syntax is never accepted and will be rejected by the helper.

Table/Set carry the operator-configured nftables names (issue #268). Empty means "helper defaults" — requests from older daemons therefore keep working unchanged. The helper re-validates the names itself via internal/nftnames (it never trusts the daemon), pins the first resolved name set for its lifetime, and rejects requests naming anything else.

type Response

type Response struct {
	OK    bool     `json:"ok"`
	Error string   `json:"error,omitempty"`
	Code  string   `json:"code,omitempty"`
	IPs   []string `json:"ips,omitempty"` // populated for "list" verb
	// Features is populated for the "caps" verb: the helper's supported
	// optional capabilities. The daemon probes this before relying on a
	// capability an older helper would silently ignore (issue #268 — a
	// custom table name must never silently fall back to the default).
	Features []string `json:"features,omitempty"`
}

Response is returned by the helper for every request.

Code is an optional, stable machine-readable annotation that lets the client distinguish "informational" outcomes without parsing the free-form Error text (or nft's raw stderr, which shifts across versions — see issue #39). Only well-known constants below are ever set; unknown codes should be treated by the client as "no annotation".

Jump to

Keyboard shortcuts

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