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
- Variables
- func IsThrottleOnly(err error) bool
- func NewCloudflareEnforcer(ctx context.Context, cfg *config.CloudflareCfg, allowlist []netip.Prefix) (sdk.Enforcer, error)
- type AllowlistSyncer
- type CloudflareEnforcer
- type CloudflareListsEnforcer
- type Gate
- func (g *Gate) Allow(ctx context.Context, prefix netip.Prefix) error
- func (g *Gate) Ban(ctx context.Context, t sdk.Target) error
- func (g *Gate) Name() string
- func (g *Gate) Sync(ctx context.Context, want []sdk.Target) error
- func (g *Gate) SyncAllowlist(ctx context.Context, want []netip.Prefix) error
- func (g *Gate) Unallow(ctx context.Context, prefix netip.Prefix) error
- func (g *Gate) Unban(ctx context.Context, t sdk.Target) error
- type MultiEnforcer
- func (m *MultiEnforcer) Allow(ctx context.Context, prefix netip.Prefix) error
- func (m *MultiEnforcer) Ban(ctx context.Context, t sdk.Target) error
- func (m *MultiEnforcer) Name() string
- func (m *MultiEnforcer) Sync(ctx context.Context, want []sdk.Target) error
- func (m *MultiEnforcer) SyncAllowlist(ctx context.Context, want []netip.Prefix) error
- func (m *MultiEnforcer) Unallow(ctx context.Context, prefix netip.Prefix) error
- func (m *MultiEnforcer) Unban(ctx context.Context, t sdk.Target) error
- type NftablesEnforcer
- func (e *NftablesEnforcer) Allow(ctx context.Context, prefix netip.Prefix) error
- func (e *NftablesEnforcer) Ban(ctx context.Context, t sdk.Target) error
- func (e *NftablesEnforcer) Name() string
- func (e *NftablesEnforcer) Sync(ctx context.Context, want []sdk.Target) error
- func (e *NftablesEnforcer) SyncAllowlist(ctx context.Context, want []netip.Prefix) error
- func (e *NftablesEnforcer) Unallow(ctx context.Context, prefix netip.Prefix) error
- func (e *NftablesEnforcer) Unban(ctx context.Context, t sdk.Target) error
- type Option
- type Request
- type Response
Constants ¶
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.
const DefaultSocketPath = "/run/ezyshield-enforcer/enforcer.sock"
DefaultSocketPath is the unix socket used by the enforcer helper.
const FeatureCustomNames = "custom_names"
FeatureCustomNames is advertised by helpers that honor Request.Table / Request.Set. Daemons configured with non-default names require it.
Variables ¶
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.
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
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.
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.
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 ¶
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 ¶
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 ¶
Ban refuses guarded targets with an audited refusal before any enforcer sees them; everything else is forwarded to the inner enforcer.
func (*Gate) Name ¶
Name returns the inner enforcer's name; the gate is transparent in logs that identify enforcement backends.
func (*Gate) Sync ¶
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 ¶
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).
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 ¶
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) Name ¶
func (m *MultiEnforcer) Name() string
Name returns a combined name like "nftables+cloudflare".
func (*MultiEnforcer) SyncAllowlist ¶
SyncAllowlist forwards the desired allowlist state to every AllowlistSyncer 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 ¶
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 ¶
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 ¶
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 ¶
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 ¶
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.
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".