Documentation
¶
Overview ¶
Package nftables is the forward driver for ENGINE_NFTABLES (docs/architecture/forward-sdk.md section 6.1): kernel DNAT, masquerade, counters per direction, balancing maps, named quotas and connection limits in one table, "inet anixops_fwd", which is the only nftables object it ever touches (owner decision H13).
Render (F2b) is pure; Apply, Observe, SetUpstreams and Remove (F2c) run nft and tc through a Runner (ExecRunner on a host; tests run them in a network namespace). Probe checks the host once and fills the Config that New takes, so Render stays a function of its configuration.
Rendered script ¶
Render produces one `nft -f` script, a single transaction (golden examples in contracts/forward/v1/nft). After the header comes the manifest, comment lines Apply reads ("# anixops-hop ...", "# anixops-upstream ..."): every rendered upstream with its weight and priority (failover's backups are not in any map), the strategy, the listener and the bandwidth. Then:
- a "table inet anixops_fwd" block that declares the table with its ownership comment (OwnerComment) and every object of every hop, with no elements and no rules, so the next steps find them on a host that has none yet;
- "flush table inet anixops_fwd" (the rules of every chain) and a "flush set" or "flush map" for each admission set and balancing map (flush table keeps elements);
- a second block that adds the elements and the rules.
Counters, quotas (whose limit is updated in place) and the connection count sets are declared but never flushed, so they keep their values across renders: the counter epoch of a hop only ends when its objects are re-created. The script deletes nothing. A state without nftables hops renders a script of comments only; applying it removes the table.
Apply ¶
Apply reads `nft -j list table inet anixops_fwd` first. A table of that name without OwnerComment is foreign: ErrNotOwned, before anything runs (step 2 of the script would empty it). The generation is checked against the one recorded on the host (ErrStaleGeneration, ErrGenerationConflict). The host runs the artifact when the recorded digest is the artifact's, the table's fingerprint matches the seal recorded after the last change, and the tc objects are the ones the artifact needs: then Apply changes nothing but, for a newer generation, the recorded generation and state_hash. Otherwise it reads the whole ruleset (read only) and refuses a foreign rule that DNATs or redirects one of the artifact's listen ports (ErrConflict), checks the transaction with `nft -c`, adds the tc qdisc and classes it needs, and runs one `nft -f` transaction:
- a table block that declares the hops' counters with the comment "anixops epoch <nonce>" (nft keeps the comment of a counter that exists, so only new counters take the new nonce) and the state sets;
- the rendered script;
- "delete" of every object of the table the script does not declare: the objects of removed hops (their last counters go to the WithRetiredCounters hook) and of limits a hop no longer has;
- the state document.
When nft refuses the transaction the tc additions are undone, so the host keeps its previous state. After it, Apply records the table's new fingerprint (the seal) and deletes the tc classes no longer needed.
State on the host ¶
The driver keeps no state in memory: a new instance (an Agent restart) reads everything from the table. Set anixops_state holds the state document (node_ref, generation, state_hash, digest, and per hop the strategy, the rendered upstreams and the rotation SetUpstreams chose) as base64url JSON cut into 120-character element comments (nft allows 128), rewritten in the same transaction as the rules it describes. Set anixops_seal holds the fingerprint: the SHA-256 of the normalized listing (handles, counter values, quota usage, dynamic set elements and the state sets' elements left out). A table someone damaged no longer matches its seal, and the next Apply repairs it.
Observe and counters ¶
Observe answers the recorded identity, the named counters of every hop (up: original direction, down: reply; packets and bytes) and the rotation. The counter epoch is the nonce of the _up and _down counters' comments (both, joined with a dot when they differ), so it ends exactly when a counter is re-created: a removed and re-added hop, a lost table, a reboot. active_conns and total_conns are not counted by nftables and stay 0.
SetUpstreams ¶
SetUpstreams rewrites the elements of the hop's balancing maps from the selected upstreams with the same slot layout as Render (FAILOVER keeps the best priority among the selected ones, so a selection without the primaries fails over to the backups), records the rotation in the state document in the same transaction, and re-seals the table. Selecting every upstream with weight 0 gives back the rendered elements. A family with no selected upstream has an empty map and its connections are dropped in the _dnat chain.
Bandwidth limits (tc) ¶
On each Config.LimitInterfaces device the driver owns one HTB root qdisc, handle Config.TCHandle (default af00:), and per rate-limited hop two classes at bandwidth_bps: minor 2*mark for the original direction and 2*mark+1 for replies, each selected by a fw filter on the packet mark the _acct chain sets (the hop's mark, plus Config.DirectionBit on replies, under MarkMask|DirectionBit). Unclassified traffic is not shaped. A root qdisc with another handle is foreign: an artifact with rate-limited hops is then ErrConflict and the qdisc is left alone. The kernel's default root qdisc (handle 0:) is replaced, and returns when the driver deletes its own (no limited hop left, Remove).
Versions ¶
The table, counter and set element comments need nft 0.9.7 and Linux 5.10 or later; Probe checks every feature with `nft -c` inside the driver's own table name (never committed) and reports the driver unavailable when the core ones fail. Tested with nft 1.0.9 (Ubuntu 24.04, CI) and nft 1.1.3 on Linux 6.12, on which re-declaring a quota updates its limit and keeps its usage (TestNetnsQuotaKeepsUsage). iproute2 before 6.3 (6.1 on Ubuntu 24.04) prints tc classes and fw filters as text even with -j: the driver reads classes with -j and falls back to the text form (comparing rates as tc prints them), and reads filters as text, which every version prints alike.
Names ¶
Every object of a hop is named r_<route id>_h<hop index>_<suffix>. The route id must be 1 to 64 ASCII letters and digits (Control's ids are ULIDs); any other id is rejected, never escaped. Suffixes:
_up, _down named counters, original and reply direction _quota named quota (Limits.quota_bytes, both directions) _conns dynamic set of the hop's connection mark for ct count _src4, _src6 admission sets (ingress_sources), interval sets _lb4, _lb6 balancing maps: slot -> address . port, interval maps _dnat chain: admission, ct mark, DNAT (nat prerouting) _acct chain: limits and counters (filter forward)
The base chains are prerouting (nat, dstnat: one rule per listener that jumps to the hop's _dnat chain), forward (filter: MSS clamping, then a verdict map from the connection mark to the hop's _acct chain) and postrouting (nat, srcnat: masquerade of the hops' marks).
Only checked literals reach the script: the names above, numbers, and addresses and prefixes parsed by net/netip. Labels, host names, node references and the state's identity never do. Upstreams must be IP literals (the Agent resolves target names before Render) and targets are checked against the hop's target policy again.
Marks ¶
NodeHop.mark is an index from the planner, 1 up to the width of Config.MarkMask (4095 for the default 0x0fff0000), shifted into the mask. The _dnat chain sets it in the connection mark, keeping the other bits; the forward and postrouting chains select the hop by it. For a hop with a bandwidth limit the _acct chain also copies it to the packet mark, with Config.DirectionBit set on reply packets, for the tc classes.
Balancing ¶
Each hop has one map per address family with upstreams, of Config.Slots slots (default 128). The slot is "numgen inc" for ROUND_ROBIN and FAILOVER, "numgen random" for RANDOM and LEAST_CONN, "jhash ip saddr" or "jhash ip6 saddr" for IP_HASH, always modulo Slots. Slots go to upstreams in proportion to their weights (at least one each): one run per upstream for random and hash, interleaved in smooth weighted round-robin order for numgen inc. FAILOVER fills the map with the upstreams of the best (lowest) priority only. LEAST_CONN is weighted random until the Agent re-weights it from connection counts. Because the modulus is fixed, SetUpstreams changes the upstreams in rotation and their weights by rewriting map elements only. A family without upstreams, and any source an admission set does not hold, is dropped in the _dnat chain, so it never reaches local input on the listen port.
Paused hops ¶
A paused hop keeps every object (counters, quota, maps): its _dnat chain drops new connections and its _acct chain drops established ones, uncounted.
Hop errors ¶
A hop is rejected alone, as a *driver.HopError in a *driver.RenderError next to the artifact of the other hops: ErrUnsupported for what the configuration or nftables cannot do (a link other than RAW, a strategy, UDP, IPv6 or a limit that is not enabled, more upstreams than slots, an upstream given by name, an unknown enum value), ErrInvalidState for what validation should have caught (a malformed route id, no listener, port 0, no upstream, an address that is not a literal, a target the policy refuses, a relay or exit without ingress sources, a mark outside the mask). Hops that share a mark or an overlapping listener are all rejected, so the result does not depend on their order.
Index ¶
- Constants
- Variables
- func AllStrategies() []forwardv1.BalanceStrategy
- func Probe(ctx context.Context, r Runner, base Config) (Config, *ProbeReport, error)
- type CommandError
- type Config
- type Driver
- func (d *Driver) Apply(ctx context.Context, a driver.Artifact) (driver.ApplyResult, error)
- func (d *Driver) Capabilities(ctx context.Context) (*forwardv1.EngineCapabilities, error)
- func (d *Driver) Config() Config
- func (d *Driver) Engine() forwardv1.Engine
- func (d *Driver) Observe(ctx context.Context) (driver.Observation, error)
- func (d *Driver) Remove(ctx context.Context) error
- func (d *Driver) Render(state *forwardv1.NodeForwardState) (driver.Artifact, error)
- func (d *Driver) SetUpstreams(ctx context.Context, routeID string, hopIndex uint32, active []driver.Upstream) error
- type ExecRunner
- type Option
- type ProbeReport
- type Runner
Constants ¶
const ( // DefaultMarkMask is the connection mark bits the driver owns: 4095 // hops per node. It is configurable per node to stay clear of other // mark users (Docker, WireGuard, policy routing). DefaultMarkMask uint32 = 0x0fff0000 // DefaultDirectionBit is the packet mark bit that tells tc the // direction of a rate-limited packet: set for reply (down) packets. DefaultDirectionBit uint32 = 0x00000001 // DefaultSlots is the size of every hop's balancing map: the modulus // of its numgen or jhash expression. It is fixed when the hop is // rendered, so SetUpstreams can change which upstreams are in // rotation and their weights by rewriting map elements only. It covers // the most upstreams a hop can have (64 targets plus 16 next-hop nodes // under the PREFERRED direct mode). DefaultSlots uint32 = 128 // MaxSlots bounds Config.Slots. MaxSlots uint32 = 4096 // DefaultTCHandle is the major handle of the HTB root qdisc the driver // owns on each of Config.LimitInterfaces ("af00:"). A root qdisc with // another handle is foreign: the driver never replaces it. DefaultTCHandle uint16 = 0xaf00 )
Defaults of Config (forward-sdk.md section 6.1; owner decision H13 for the mark mask).
const ( // Family and Table name the one nftables table the driver owns. Family = "inet" Table = "anixops_fwd" // OwnerComment is the table's comment, the ownership mark of the // driver contract: a table "inet anixops_fwd" without it is foreign. // nft cannot change a table's comment in place, so it never changes; // the applied generation and digest are recorded in the table's state // set (see hoststate.go). OwnerComment = "anixops-forward-driver v1" )
Names of what the driver owns on a host.
Variables ¶
var ErrInvalidConfig = errors.New("nftables driver: invalid configuration")
ErrInvalidConfig is returned by New for a configuration it cannot use.
Functions ¶
func AllStrategies ¶
func AllStrategies() []forwardv1.BalanceStrategy
AllStrategies lists every balance strategy the driver can render.
func Probe ¶
Probe checks the host the runner reaches and answers base with what the host offers: Version set (or empty with Unavailable saying why: nft missing, no CAP_NET_ADMIN, a kernel or nft too old), and IPv6, UDP, Strategies, Quota, MaxConns and BandwidthLimit turned off where the host lacks them (bandwidth limits need tc and Config.LimitInterfaces). It never changes the host: feature checks run with `nft -c` inside the driver's own table name, and the forward chains of other tables are only read. The Agent probes once at start and passes the result to New, so Render stays a function of the configuration. Only a done context is an error.
Types ¶
type CommandError ¶
CommandError is a command that exited with an error.
func (*CommandError) Error ¶
func (e *CommandError) Error() string
func (*CommandError) Unwrap ¶
func (e *CommandError) Unwrap() error
type Config ¶
type Config struct {
// Version is the engine version Capabilities reports ("nft 1.0.9").
// Empty means the driver is unavailable (Unavailable says why), and
// Capabilities answers Available false. Probe fills it, and turns off
// the features below the host lacks, before New.
Version string
// is empty ("nft not installed", "no CAP_NET_ADMIN").
Unavailable string
// IPv6 and UDP allow hops that need them.
IPv6 bool
UDP bool
// Strategies are the balance strategies the driver renders.
// LEAST_CONN renders as weighted random; the Agent's health loop
// re-weights it from connection counts (forward-sdk.md section 7.1).
Strategies []forwardv1.BalanceStrategy
// BandwidthLimit, Quota and MaxConns allow hops with those limits.
// Render marks packets of rate-limited hops for tc; Apply adds an HTB
// class per hop and direction on every LimitInterfaces device, so
// BandwidthLimit needs at least one (Probe turns it off without).
BandwidthLimit bool
Quota bool
MaxConns bool
// MarkMask is the contiguous run of connection mark bits the driver
// owns. A hop's mark (an index from the planner, 1 up to the mask's
// width) is shifted into it. Bits outside the mask are preserved.
MarkMask uint32
// DirectionBit is the single packet mark bit set on reply packets of
// rate-limited hops; it must lie outside MarkMask.
DirectionBit uint32
// Slots is every balancing map's size; see DefaultSlots.
Slots uint32
// MSSClampInterfaces are the encapsulating egress interfaces (a
// WireGuard or GRE device in front of a private line) on which the
// SYNs of forwarded connections have their MSS clamped to the route's
// MTU. Empty for none.
MSSClampInterfaces []string
// LimitInterfaces are the egress interfaces on which Apply limits the
// bandwidth of rate-limited hops (both directions of a forwarded
// connection leave the node as egress, on the interface facing the
// client or the upstream). Empty for none.
LimitInterfaces []string
// TCHandle is the major handle of the driver's root qdisc on
// LimitInterfaces; see DefaultTCHandle.
TCHandle uint16
}
Config is the driver's static configuration. The zero value is not usable; start from DefaultConfig.
func DefaultConfig ¶
func DefaultConfig() Config
DefaultConfig answers a configuration with every feature on, the default mark mask, direction bit and slots, no MSS clamping and no Version (so an unprobed driver reports itself unavailable).
type Driver ¶
type Driver struct {
// contains filtered or unexported fields
}
Driver is the nftables forward driver. It keeps no state besides its configuration: everything it applied is read back from the host. Apply, SetUpstreams and Remove are serialised; Observe runs between them.
func New ¶
New answers a driver with the given configuration, or ErrInvalidConfig. The configuration usually comes from Probe.
func (*Driver) Apply ¶
Apply makes the host run the artifact (driver package documentation):
- read the table; a table without OwnerComment is ErrNotOwned (the script's flush would empty it);
- check the generation against the recorded one;
- compare: the recorded digest, the table's fingerprint with the seal recorded after the last change, and the tc objects with the ones the artifact needs. All equal is a no-op that at most records the new generation and state_hash;
- refuse foreign DNAT rules on the artifact's listeners (ErrConflict) and foreign root qdiscs on the limit interfaces;
- `nft -c`, then add the tc qdisc and classes the artifact needs, run the transaction (counter epochs for new counters, the script, the deletion of every undeclared object, the state document) with `nft -f`, undoing the tc additions when it fails;
- record the new fingerprint, then delete the tc classes and qdiscs no longer needed.
func (*Driver) Capabilities ¶
Capabilities answers the capabilities of the configuration, which Probe filled from the host: a configuration without a Version is unavailable.
func (*Driver) Observe ¶
Observe reads the table: the recorded identity, the hops' counters and their rotation. A host without the driver's table, or with a foreign one, observes as not applied.
func (*Driver) Remove ¶
Remove deletes the driver's table when it carries OwnerComment, and its tc qdiscs. A foreign table of the same name is left alone.
func (*Driver) Render ¶
Render turns the state's nftables hops into one `nft -f` script; see the package documentation for its shape. Hops it cannot run come back as hop errors in a *driver.RenderError next to the artifact of the others.
func (*Driver) SetUpstreams ¶
func (d *Driver) SetUpstreams(ctx context.Context, routeID string, hopIndex uint32, active []driver.Upstream) error
SetUpstreams rewrites the balancing map elements of one applied hop for the selected upstreams (failover keeps only the best priority among them), records the rotation in the state document in the same transaction, and re-seals the table.
type ExecRunner ¶
type ExecRunner struct {
// NFT and TC are the binaries; empty means "nft" and "tc" from PATH.
NFT, TC string
}
ExecRunner runs nft and tc from PATH (or the paths it is given).
type Option ¶
type Option func(*Driver)
Option configures New.
func WithRetiredCounters ¶
WithRetiredCounters calls f after an Apply that deleted hops, with their last counters (read just before the transaction that deleted them), so the Agent can report the end of their counter epochs. It runs after Apply released the driver, so it may call it.
func WithRunner ¶
WithRunner runs nft and tc through r instead of ExecRunner (tests, a network namespace).
type ProbeReport ¶
type ProbeReport struct {
// Missing lists the features the host lacks, with the reason, as
// "feature: reason".
Missing []string
// Warnings are host conditions the driver does not change but an
// operator should know about: another table's forward chain that drops
// by default (Docker's "ip filter FORWARD" does), which drops the
// driver's forwarded packets unless that table accepts them.
Warnings []string
}
ProbeReport is what Probe found besides the configuration.
type Runner ¶
type Runner interface {
// Run runs name ("nft" or "tc") with args, feeding stdin when it is not
// nil, and answers its standard output. A command that fails answers a
// *CommandError.
Run(ctx context.Context, name string, args []string, stdin []byte) ([]byte, error)
}
Runner runs the host commands the driver needs: nft and tc. The driver never runs anything else. ExecRunner runs them on the host; tests inject a fake, and the network namespace harness one that runs them inside a namespace.