Skip to content

Network Policy Enforcement

The internal/policy package enforces network policies on mesh nodes. It evaluates policies from the control plane to determine peer visibility (which peers a node can communicate with) and generates iptables firewall rules for packet-level enforcement.

The package integrates with internal/reconcile for periodic convergence and with internal/api for real-time SSE-driven policy updates.

Data Flow

Control Plane


┌─────────────┐     ┌──────────────┐
│ StateResponse│────▶│ PolicyEngine │
│  .Policies   │     └──────┬───────┘
│  .Peers      │            │
└─────────────┘     ┌───────┴────────┐
                    │                │
                    ▼                ▼
            ┌────────────┐   ┌────────────────┐
            │ FilterPeers│   │BuildFirewallRules│
            └─────┬──────┘   └───────┬─────────┘
                  │                  │
                  ▼                  ▼
            ┌──────────┐     ┌──────────────────┐
            │ WireGuard│     │FirewallController │
            │ Manager  │     │  (iptables/nft)   │
            └──────────┘     └──────────────────┘

Policies flow from the control plane via api.StateResponse. The PolicyEngine evaluates them to produce two outputs: a filtered peer list (fed to wireguard.Manager) and firewall rules (applied via FirewallController). The Enforcer orchestrates both paths, and ReconcileHandler wires it into the reconciliation loop.

Config

Config holds policy enforcement parameters.

FieldTypeDefaultDescription
EnabledbooltrueWhether policy enforcement is active
ChainNamestringplexd-meshiptables chain name for firewall rules
go
cfg := policy.Config{}
cfg.ApplyDefaults() // Enabled=true, ChainName="plexd-mesh"
if err := cfg.Validate(); err != nil {
    log.Fatal(err)
}

Default Heuristic

ApplyDefaults uses zero-value detection: on a fully zero-valued Config, Enabled is set to true. If ChainName is already set (indicating explicit construction), Enabled is left as-is. This allows Config{Enabled: false} to disable enforcement after ApplyDefaults.

Validation Rules

FieldRuleError Message
ChainNameMust not be empty when Enabledpolicy: config: ChainName must not be empty when enabled

Validation is skipped entirely when Enabled is false.

FirewallRule

Describes a single iptables-style packet filter rule.

go
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)
    Protocol  string // "tcp", "udp", or "" (any)
    Action    string // "allow" or "deny"
}

Validation Rules

FieldRuleError Message
ActionMust be "allow" or "deny"policy: firewall rule: invalid action "..."
PortMust be 0–65535policy: firewall rule: invalid port N
ProtocolMust be "", "tcp", or "udp"policy: firewall rule: invalid protocol "..."
PortRequires protocol if > 0policy: firewall rule: port N requires a protocol

FirewallController

Interface abstracting OS-level iptables operations. The production implementation is provided externally; this package defines and consumes the interface.

go
type FirewallController interface {
    EnsureChain(chain string) error
    ApplyRules(chain string, rules []FirewallRule) error
    FlushChain(chain string) error
    DeleteChain(chain string) error
}
MethodDescription
EnsureChainCreates the named chain if it does not already exist
ApplyRulesReplaces all rules in the named chain atomically
FlushChainRemoves all rules from the named chain
DeleteChainDeletes the named chain; idempotent on non-existent chain

PolicyEngine

Evaluates network policies to determine peer visibility and generate firewall rules.

Constructor

go
func NewPolicyEngine(logger *slog.Logger) *PolicyEngine

Logger is tagged with component=policy.

FilterPeers

go
func (e *PolicyEngine) FilterPeers(peers []api.Peer, policies []api.Policy, localNodeID string) []api.Peer

Returns the subset of peers the local node is allowed to communicate with.

ScenarioBehavior
No policiesNo peers returned (deny-by-default)
Allow rules existOnly peers matching a bidirectional allow rule are returned
Deny-only rulesNo peers returned (deny does not grant visibility)
Wildcard "*"Matches any node ID in src or dst position

Bidirectional matching: A peer is visible if any allow rule references both the local node and the peer in either Src/Dst direction. This means {Src: "node-A", Dst: "node-B", Action: "allow"} allows communication in both directions.

BuildFirewallRules

go
func (e *PolicyEngine) BuildFirewallRules(
    policies []api.Policy,
    localNodeID string,
    iface string,
    peersByID map[string]string,
) []FirewallRule

Converts api.PolicyRule entries into concrete FirewallRule entries for the local node.

  • peersByID maps peer IDs to mesh IPs for address resolution
  • Only rules where Src or Dst matches localNodeID (or "*") are included
  • Wildcard "*" resolves to "0.0.0.0/0" in the generated firewall rule
  • Rules with invalid protocols (not "", "tcp", or "udp") are skipped with a warning log
  • A default-deny rule dropping all traffic on the interface is appended as the last rule
  • Rules referencing unknown peer IDs produce rules with empty IP fields

Enforcer

Combines a PolicyEngine with a FirewallController to enforce policies on the local node.

Constructor

go
func NewEnforcer(
    engine *PolicyEngine,
    firewall FirewallController,
    cfg Config,
    logger *slog.Logger,
) *Enforcer
  • Applies config defaults via cfg.ApplyDefaults()
  • firewall may be nil — only peer filtering is functional in that case

Methods

MethodSignatureDescription
FilterPeers(peers []api.Peer, policies []api.Policy, localNodeID string) []api.PeerFilters peers; passthrough when disabled
ApplyFirewallRules(policies []api.Policy, localNodeID string, iface string, peersByID map[string]string) errorBuilds and applies rules; no-op when disabled or nil firewall
Teardown() errorFlushes and deletes firewall chain; safe with nil firewall

Behavior by State

EnabledfirewallFilterPeersApplyFirewallRulesTeardown
truenon-nilEngine-filteredRules appliedChain removed
truenilEngine-filteredNo-op (warn logged)No-op
falseanyAll peers returnedNo-opNo-op/chain removed

Error Prefixes

MethodPrefix
ApplyFirewallRulespolicy: enforce:
Teardownpolicy: teardown:

ReconcileHandler

Factory function returning a reconcile.ReconcileHandler that enforces policies during reconciliation cycles.

go
func ReconcileHandler(
    enforcer *Enforcer,
    wgMgr *wireguard.Manager,
    localNodeID, localMeshIP, iface string,
) reconcile.ReconcileHandler

The returned handler maintains an internal allowedPeers map (closure state) that tracks which peers are currently added to WireGuard, enabling incremental add/remove across cycles.

Processing Order

  1. Skip check — if StateDiff contains no policy or peer changes, return nil
  2. Filter peers — evaluate policies via Enforcer.FilterPeers
  3. Apply firewall rules — via Enforcer.ApplyFirewallRules
  4. Remove revoked peers — peers in allowedPeers but not in the new filtered set are removed via wgMgr.RemovePeerByID
  5. Add new peers — peers in the new filtered set but not in allowedPeers are added via wgMgr.AddPeer
  6. Update stateallowedPeers is replaced with the new set

Drift Detection

The handler checks StateDiff for any of:

FieldTriggers Handler
PeersToAddYes
PeersToRemoveYes
PeersToUpdateYes
PoliciesToAddYes
PoliciesToRemoveYes

If none of these fields are populated, the handler is a no-op.

Error Handling

Individual failures (firewall apply, peer remove, peer add) are collected and returned as an aggregated error via errors.Join. This ensures the reconciler marks the cycle as failed and retries.

Registration

go
enforcer := policy.NewEnforcer(engine, fwCtrl, policy.Config{}, logger)
mgr := wireguard.NewManager(ctrl, wireguard.Config{}, logger)

r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(policy.ReconcileHandler(enforcer, mgr, nodeID, meshIP, "plexd0"))

HandlePolicyUpdated

Factory function returning an api.EventHandler for real-time policy updates via SSE.

go
func HandlePolicyUpdated(trigger ReconcileTrigger) api.EventHandler

When a policy_updated SSE event is received, the handler calls trigger.TriggerReconcile() to request an immediate reconciliation cycle. The event payload is not parsed — any policy update triggers a full reconcile.

ReconcileTrigger

go
type ReconcileTrigger interface {
    TriggerReconcile()
}

Satisfied by *reconcile.Reconciler. Extracted as an interface for testability.

Registration

go
dispatcher := api.NewEventDispatcher(logger)
dispatcher.Register(api.EventPolicyUpdated, policy.HandlePolicyUpdated(reconciler))

Enforcement Behavior

Note: The policy enforcement model is under active development. The behavior described here reflects the current design and may change in future versions.

  • Policies are pushed by the control plane via the policy_updated SSE event. plexd does not poll for policy changes — they are applied as soon as received (and verified via signature).
  • Filtering operates at L3/L4 (IP, port, protocol) on the plexd0 mesh interface using nftables rules.
  • The default stance is deny-all: no mesh traffic is permitted unless explicitly allowed by a policy rule.
  • Peer visibility filtering: In addition to firewall rules, plexd controls which peers are configured in the WireGuard interface. Peers not authorized by policy are not added to the interface, preventing even handshake-level communication.
  • Policy rules are scoped to mesh IPs (10.100.x.x/32) and cannot reference external IPs or hostnames.
  • On policy update, plexd computes a diff against the current nftables ruleset and applies only the changes (add/remove rules), minimizing disruption.

Integration Points

Reconciliation Loop

The policy reconcile handler plugs into internal/reconcile alongside the WireGuard handler. Both are invoked sequentially on each cycle:

go
r := reconcile.NewReconciler(client, reconcile.Config{}, logger)
r.RegisterHandler(wireguard.ReconcileHandler(mgr))
r.RegisterHandler(policy.ReconcileHandler(enforcer, mgr, nodeID, meshIP, "plexd0"))

SSE Real-Time Updates

HandlePolicyUpdated triggers reconciliation when the control plane pushes a policy_updated event. The reconciliation cycle then fetches fresh state and re-evaluates all policies.

WireGuard Manager

The policy handler uses wireguard.Manager to add and remove peers:

Manager MethodUsed When
AddPeerA peer becomes allowed by policy change
RemovePeerByIDA peer is revoked by policy change

Control Plane Types

TypePackageUsage
api.Peerinternal/apiPeer identity and WireGuard config
api.Policyinternal/apiPolicy with ID and rules
api.PolicyRuleinternal/apiSrc, Dst, Port, Protocol, Action
api.StateResponseinternal/apiDesired state from control plane
api.SignedEnvelopeinternal/apiSSE event wrapper
api.EventPolicyUpdatedinternal/apiEvent type constant "policy_updated"

Graceful Shutdown

Call Enforcer.Teardown() to clean up firewall chains:

go
<-ctx.Done()
if err := enforcer.Teardown(); err != nil {
    logger.Warn("policy teardown failed", "error", err)
}