Documentation
¶
Overview ¶
Package policy implements network policy enforcement for plexd mesh nodes.
Index ¶
- Constants
- Variables
- func HandlePolicyUpdated(trigger ReconcileTrigger) api.EventHandler
- func ReconcileHandler(enforcer *Enforcer, iface string) reconcile.ReconcileHandler
- type Config
- type Enforcer
- type FirewallController
- type FirewallRule
- type NftablesController
- func (c *NftablesController) ApplyRules(chain string, rules []FirewallRule) error
- func (c *NftablesController) DeleteChain(chain string) error
- func (c *NftablesController) EnsureChain(chain string) error
- func (c *NftablesController) FlushChain(chain string) error
- func (c *NftablesController) Probe() error
- type PolicyEngine
- type ReconcileTrigger
Constants ¶
const DefaultChainName = "plexd-mesh"
DefaultChainName is the default iptables chain name for policy enforcement.
Variables ¶
var ErrInvalidRuleset = errors.New("policy: invalid ruleset")
ErrInvalidRuleset marks a policy revision the engine cannot translate into firewall rules. It is a permanent property of the revision's rules, not a transient backend failure: retrying the identical revision can never succeed, so callers must not hold state back waiting for it to clear.
Functions ¶
func HandlePolicyUpdated ¶
func HandlePolicyUpdated(trigger ReconcileTrigger) api.EventHandler
HandlePolicyUpdated returns an api.EventHandler that triggers reconciliation when a policy_updated SSE event is received.
func ReconcileHandler ¶
func ReconcileHandler(enforcer *Enforcer, iface string) reconcile.ReconcileHandler
ReconcileHandler returns a reconcile.ReconcileHandler that applies the merged policy's firewall ruleset when the policy fingerprint changes. It returns nil unless diff.PolicyChanged.
The fingerprint short-circuit lives in the differ (PolicyChanged stays false on a fingerprint match), so this handler — and its "policy ruleset applied" log — never fires for revision-only bumps. That log is emitted only once the enforcer confirms the ruleset reached the kernel, so it cannot claim an enforcement that a disabled config or a missing firewall backend skipped.
A transient apply failure propagates so the reconciler holds the snapshot back and retries next cycle. ErrInvalidRuleset does not: the rejected rules are a permanent property of the revision, so propagating it would hold the snapshot back and re-run every handler at the reconcile interval forever with no chance of the same rules parsing. Such a revision is logged and swallowed so the rest of the snapshot converges; the firewall keeps the last successfully applied ruleset, which is fail-closed.
Types ¶
type Config ¶
type Config struct {
// Enabled controls whether policy enforcement is active. A pointer, so an
// unset key stays distinguishable from an explicit false: enforcement is the
// deny-by-default posture and its absence is fatal at startup, so
// `enabled: false` is the operator's only way to run a node without it and
// must survive ApplyDefaults exactly as written.
// Default: true (nil reads as enabled).
Enabled *bool `yaml:"enabled"`
// ChainName is the iptables chain name for firewall rules.
// Default: plexd-mesh.
ChainName string `yaml:"chain_name"`
}
Config holds the configuration for network policy enforcement.
func (*Config) ApplyDefaults ¶
func (c *Config) ApplyDefaults()
ApplyDefaults sets default values for zero-valued fields. Enabled is left untouched — nil already means enabled, and writing a value into it would erase the difference between an omitted key and an operator's `false`.
type Enforcer ¶
type Enforcer struct {
// contains filtered or unexported fields
}
Enforcer combines a PolicyEngine with a FirewallController to enforce network policies on the local node.
func NewEnforcer ¶
func NewEnforcer(engine *PolicyEngine, firewall FirewallController, cfg Config, logger *slog.Logger) *Enforcer
NewEnforcer creates an Enforcer. The firewall parameter may be nil if no firewall backend is available; in that case ApplyFirewallRules is a no-op.
func (*Enforcer) ApplyFirewallRules ¶
ApplyFirewallRules builds firewall rules from the merged policy and applies them via the FirewallController. A nil policy yields a default-deny-only ruleset — deny-by-default is plexd's documented posture.
The bool reports whether the ruleset actually reached the kernel. Enforcement being disabled and the absence of a firewall backend are no-ops that return (false, nil), so callers cannot mistake either for a successful apply and claim an enforcement that never happened.
A ruleset the engine refuses to translate is reported as ErrInvalidRuleset so callers can tell a permanently broken revision from a transient netlink failure.
func (*Enforcer) Preflight ¶ added in v0.3.0
Preflight reports whether this node can enforce policy at all, without changing any kernel state. It answers the question the first ApplyFirewallRules would otherwise answer — but that call happens after registration has spent a one-shot bootstrap token, so a node that can never install a chain has to learn it here instead.
The two no-op paths of ApplyFirewallRules are no-ops here as well: with enforcement disabled or no firewall backend compiled in, there is no enforcement to be unable to perform. A backend that fails the probe is an error — that node was told to enforce and cannot.
type FirewallController ¶
type FirewallController interface {
// Probe reports whether the backend is usable, without changing any kernel
// state. It must exercise the same privileged path the mutating calls take,
// so a missing capability or an unavailable subsystem surfaces here rather
// than on the first EnsureChain.
Probe() error
// EnsureChain creates the named iptables chain if it does not already exist.
EnsureChain(chain string) error
// ApplyRules replaces all rules in the named chain atomically.
ApplyRules(chain string, rules []FirewallRule) error
// FlushChain removes all rules from the named chain.
FlushChain(chain string) error
// DeleteChain deletes the named chain.
// Implementations must be idempotent: deleting a non-existent chain must return nil.
DeleteChain(chain string) error
}
FirewallController abstracts OS-level iptables operations for testability.
type FirewallRule ¶
type FirewallRule struct {
Interface string // network interface name
SrcIP string // source IP (CIDR or single IP)
DstIP string // destination IP (CIDR or single IP)
Port int // destination port (0 = any)
PortTo int // inclusive end of a destination port range, 0 means single-Port match
Protocol string // "tcp", "udp", "icmp", or "" (any)
Action string // "allow" or "deny"
}
FirewallRule describes a single iptables-style packet filter rule.
func (*FirewallRule) Validate ¶
func (r *FirewallRule) Validate() error
Validate checks the rule for semantic correctness and returns an error if any field contains an invalid value.
type NftablesController ¶
type NftablesController struct {
// contains filtered or unexported fields
}
NftablesController implements FirewallController using the Linux nftables subsystem via the google/nftables netlink library. It manages a single IPv4 filter table ("plexd") and creates/destroys chains within it.
func NewNftablesController ¶
func NewNftablesController(logger *slog.Logger) *NftablesController
NewNftablesController returns a new NftablesController.
func (*NftablesController) ApplyRules ¶
func (c *NftablesController) ApplyRules(chain string, rules []FirewallRule) error
ApplyRules replaces all rules in the named chain atomically. It flushes the chain first, then adds each FirewallRule as an nftables rule with appropriate match expressions and verdict.
func (*NftablesController) DeleteChain ¶
func (c *NftablesController) DeleteChain(chain string) error
DeleteChain deletes the named chain. It is idempotent: deleting a non-existent chain returns nil.
func (*NftablesController) EnsureChain ¶
func (c *NftablesController) EnsureChain(chain string) error
EnsureChain creates the named nftables chain if it does not already exist. The chain is created as a base chain with a forward hook in the plexd filter table so that the kernel evaluates its rules for forwarded traffic.
func (*NftablesController) FlushChain ¶
func (c *NftablesController) FlushChain(chain string) error
FlushChain removes all rules from the named chain.
func (*NftablesController) Probe ¶ added in v0.3.0
func (c *NftablesController) Probe() error
Probe reports whether the nftables backend is usable, without changing any kernel state. It dumps the IPv4 table list: nfnetlink gates every message on CAP_NET_ADMIN, dumps included, so a dropped capability or an nf_tables subsystem the kernel does not offer is reported here rather than on the first EnsureChain — which by then has already added a table to its batch.
A read is deliberate: the caller runs this before registration, so it must not leave a table behind on a node that goes on to fail startup for another reason.
type PolicyEngine ¶
type PolicyEngine struct {
// contains filtered or unexported fields
}
PolicyEngine translates the control plane's merged policy rules into concrete firewall rules for the local node.
func NewPolicyEngine ¶
func NewPolicyEngine(logger *slog.Logger) *PolicyEngine
NewPolicyEngine creates a PolicyEngine with the given logger.
func (*PolicyEngine) BuildFirewallRules ¶
func (e *PolicyEngine) BuildFirewallRules(rules []api.PolicyRule, iface string) ([]FirewallRule, error)
BuildFirewallRules converts the merged policy's five-tuple rules into concrete FirewallRule entries for the local node.
The ruleset is ordered and nftables verdicts are terminal: the first matching rule decides accept or drop. Dropping one rule out of the middle therefore changes the verdict for the traffic it covered rather than merely omitting an entry — a deny that carves an exception out of a following broad allow would fail open. Every unparseable rule is therefore an error for the whole set, so the caller keeps the previously installed ruleset and retries.
Actions allow and deny are kept and a log action is the sole skip: it is observational only (the nftables layer has no log verdict) and non-terminating in the control plane's policy language, so omitting it cannot change the accept/drop outcome. Protocols tcp/udp/icmp are kept and any maps to "" (match all). Both CIDRs must be parseable IPv4 prefixes — an empty string reaches nftables as "match any address" and would silently widen the rule. Ports are valid only for tcp/udp and must be a bounded, non-inverted range; a zero or out-of-range port would drop the port match entirely (widening the rule to every port) or wrap through uint16 onto an unrelated port.
The trailing default-deny rule is always appended — including when rules is empty or nil — for a deny-by-default posture.
type ReconcileTrigger ¶
type ReconcileTrigger interface {
TriggerReconcile()
}
ReconcileTrigger is satisfied by *reconcile.Reconciler.