go-firewall/backup.go
James Coleman a036c8e6e9 Add TCPUDP protocol, coverage relation, and drop read-side merging
Introduce TCPUDP as the protocol analog of FamilyAny and DirAny: a merged
value spanning both transports, distinct from ProtocolAny (which matches
every IP protocol and carries no port). Backends whose native syntax holds
both transports in one row (nftables, ufw, apf) store and read it as one
rule; the rest fan it out with expandProtocols. Removing one transport of a
merged row splits it via splitMergedRow, which composes the family and
protocol splits so an nftables row merged on both axes leaves a correct,
non-overlapping remainder. NAT rejects TCPUDP with ErrUnsupportedNAT.

Remove read-side merging. GetRules now reports the firewall's actual rows
and never synthesizes a FamilyAny, TCPUDP, or DirAny rule by pairing up
separately-stored ones, so mergeFamilies, mergeDirections and their helpers
are gone and mergedInsertIndex becomes logicalInsertIndex. Rules are instead
compared by coverage: the new exported Rule.Covers / Rule.CoveredBy (and the
NATRule pair) expand a rule across family, transport and direction and decide
containment cell by cell, which is what lets Sync stay a no-op against its
own output whichever representation a backend chose.

Extract the systemd/SysV service helpers out of the iptables backend into
services.go so every Linux backend shares one implementation, and document
the multi-state rule model and the coverage helpers in the README.
2026-07-09 17:52:19 -05:00

269 lines
9.1 KiB
Go

package firewall
import (
"context"
"encoding/json"
"fmt"
"io"
"strings"
)
// Backup JSON serialization so snapshots can be persisted to disk or moved
// between hosts. Enum values are marshaled as their stable string names to keep
// backups readable across library versions. The two generic helpers below keep
// the per-type boilerplate to one line.
// marshalEnum renders an enum value as its canonical string name.
func marshalEnum[T ~uint8](v T, str func(T) string) ([]byte, error) {
return json.Marshal(str(v))
}
// unmarshalEnum parses an enum value from its canonical string name via parse.
func unmarshalEnum[T ~uint8](data []byte, parse func(string) (T, error)) (T, error) {
var zero T
var s string
if err := json.Unmarshal(data, &s); err != nil {
return zero, err
}
return parse(s)
}
// MarshalJSON renders the action as its stable name (e.g. "accept").
func (a Action) MarshalJSON() ([]byte, error) { return marshalEnum(a, Action.String) }
// UnmarshalJSON parses an action from its stable name. The sentinel "invalid"
// (ActionInvalid) is accepted on the wire for round-trip fidelity even though
// ParseAction rejects it as caller input.
func (a *Action) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(s string) (Action, error) {
if strings.EqualFold(strings.TrimSpace(s), "invalid") {
return ActionInvalid, nil
}
return ParseAction(s)
})
if err != nil {
return err
}
*a = v
return nil
}
// MarshalJSON renders the family as its stable name (e.g. "ipv4").
func (f Family) MarshalJSON() ([]byte, error) { return marshalEnum(f, Family.String) }
// UnmarshalJSON parses a family from its stable name.
func (f *Family) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseFamily)
if err != nil {
return err
}
*f = v
return nil
}
// MarshalJSON renders the protocol as its stable name (e.g. "tcp").
func (p Protocol) MarshalJSON() ([]byte, error) { return marshalEnum(p, Protocol.String) }
// UnmarshalJSON parses a protocol from its stable name. An unrecognized token is
// rejected rather than silently widening the rule to ProtocolAny; only the
// wildcard spellings ("any" or empty) resolve to ProtocolAny.
func (p *Protocol) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(s string) (Protocol, error) {
proto := GetProtocol(s)
t := strings.TrimSpace(s)
if proto == ProtocolAny && t != "" && !strings.EqualFold(t, "any") {
return 0, fmt.Errorf("unknown protocol %q", s)
}
return proto, nil
})
if err != nil {
return err
}
*p = v
return nil
}
// MarshalJSON renders the connection-state set as a comma-joined name list.
func (s ConnState) MarshalJSON() ([]byte, error) { return marshalEnum(s, ConnState.String) }
// UnmarshalJSON parses a connection-state set from a comma-joined name list.
func (s *ConnState) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, func(tok string) (ConnState, error) { return ParseConnState(tok) })
if err != nil {
return err
}
*s = v
return nil
}
// MarshalJSON renders the rate unit as its stable name (e.g. "minute").
func (u RateUnit) MarshalJSON() ([]byte, error) { return marshalEnum(u, RateUnit.String) }
// UnmarshalJSON parses a rate unit from its stable name.
func (u *RateUnit) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseRateUnit)
if err != nil {
return err
}
*u = v
return nil
}
// MarshalJSON renders the NAT kind as its stable name (e.g. "dnat").
func (k NATKind) MarshalJSON() ([]byte, error) { return marshalEnum(k, NATKind.String) }
// UnmarshalJSON parses a NAT kind from its stable name.
func (k *NATKind) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseNATKind)
if err != nil {
return err
}
*k = v
return nil
}
// MarshalJSON renders the direction as its stable name (e.g. "input").
func (d Direction) MarshalJSON() ([]byte, error) { return marshalEnum(d, Direction.String) }
// UnmarshalJSON parses a direction from its stable name.
func (d *Direction) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseDirection)
if err != nil {
return err
}
*d = v
return nil
}
// MarshalJSON renders the set type as its stable name (e.g. "hash:net").
func (t SetType) MarshalJSON() ([]byte, error) { return marshalEnum(t, SetType.String) }
// UnmarshalJSON parses a set type from its stable name.
func (t *SetType) UnmarshalJSON(data []byte) error {
v, err := unmarshalEnum(data, ParseSetType)
if err != nil {
return err
}
*t = v
return nil
}
// captureBackupState fills a backup's DefaultPolicy and AddressSets from the
// backend, for the backends that advertise those features. It is shared by every
// Backup implementation so a snapshot captures the full managed state — not just
// filter/NAT rules — without each backend re-probing capabilities. A backend that
// advertises neither feature leaves both fields nil, so the snapshot is unchanged.
func captureBackupState(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
caps := mgr.Capabilities()
if caps.DefaultPolicy {
// Best-effort: a backend that cannot report a single coherent default policy
// (iptables with out-of-band divergent IPv4/IPv6 chain policies) captures none
// rather than failing the whole backup. A genuine read error would already have
// surfaced from the GetRules call the Backup makes before this, which reads the
// same source; leaving the field nil then makes Restore leave the policy as it
// finds it.
if policy, err := mgr.GetDefaultPolicy(ctx, zoneName); err == nil {
b.DefaultPolicy = policy
}
}
if caps.AddressSets {
sets, err := mgr.GetAddressSets(ctx)
if err != nil {
return err
}
b.AddressSets = sets
}
return nil
}
// restoreBackupSets recreates a backup's address sets so a set-referencing rule
// (@set) resolves on Restore. It runs before the filter rules are re-added. Only
// backends that advertise AddressSets act. cleanFirst removes each set before
// recreating it: a caller whose restore has already cleared the rules that could
// reference a set (a container backend that flushes its table/anchor) passes true
// so the set is rebuilt from a clean slate — needed for nftables, whose
// AddAddressSet is a no-op on an existing set and so would not reconcile its
// entries. A caller whose old rules are still loaded when sets are recreated
// (a tag/rewrite backend) passes false and relies on AddAddressSet's own
// idempotent create-or-repopulate (ipset -exist, pfctl -T add).
func restoreBackupSets(ctx context.Context, mgr Manager, b *Backup, cleanFirst bool) error {
if b == nil || !mgr.Capabilities().AddressSets {
return nil
}
for _, set := range b.AddressSets {
if cleanFirst {
if err := mgr.RemoveAddressSet(ctx, set.Name); err != nil {
return err
}
}
if err := mgr.AddAddressSet(ctx, set); err != nil {
return err
}
}
return nil
}
// applyBackupPolicy re-asserts a backup's default policy, after the rules are
// restored (the policy is independent of rule order). Only backends that
// advertise DefaultPolicy act, and a nil snapshot policy (a backend that captured
// none) is left unchanged.
func applyBackupPolicy(ctx context.Context, mgr Manager, zoneName string, b *Backup) error {
if b == nil || b.DefaultPolicy == nil || !mgr.Capabilities().DefaultPolicy {
return nil
}
return mgr.SetDefaultPolicy(ctx, zoneName, b.DefaultPolicy)
}
// WriteBackup encodes backup as JSON to w so it can be persisted to disk (or
// moved to another host) and later replayed with ReadBackup or RestoreReader.
//
// The encoding is portable: enum fields are written as their stable string
// names, so a backup survives a reordering of the library's iota constants and
// is readable on a host running a different version of the library. Per-rule
// counters (Packets/Bytes) are carried through for the record but are ignored
// when a backup is restored (they are not part of rule identity).
//
// f, _ := os.Create("backup.json")
// _ = firewall.WriteBackup(f, backup)
func WriteBackup(w io.Writer, backup *Backup) error {
if backup == nil {
return fmt.Errorf("backup cannot be nil")
}
if w == nil {
return fmt.Errorf("writer cannot be nil")
}
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
return enc.Encode(backup)
}
// ReadBackup decodes a backup previously written by WriteBackup from r.
//
// f, _ := os.Open("backup.json")
// backup, _ := firewall.ReadBackup(f)
// _ = mgr.Restore(ctx, zone, backup)
func ReadBackup(r io.Reader) (*Backup, error) {
if r == nil {
return nil, fmt.Errorf("reader cannot be nil")
}
var backup Backup
if err := json.NewDecoder(r).Decode(&backup); err != nil {
return nil, err
}
return &backup, nil
}
// RestoreReader reads a backup from r and applies it via mgr.Restore. It is the
// streaming counterpart of Manager.Restore: a caller can replay a backup
// straight from a file or network reader without first buffering it into a
// *Backup. A read error is returned before any rules are touched.
//
// f, _ := os.Open("backup.json")
// _ = firewall.RestoreReader(ctx, mgr, zone, f)
func RestoreReader(ctx context.Context, mgr Manager, zoneName string, r io.Reader) error {
backup, err := ReadBackup(r)
if err != nil {
return err
}
return mgr.Restore(ctx, zoneName, backup)
}