npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@push.rocks/smartnftables

v4.11.0

Published

A TypeScript module for managing nftables rules including NAT, firewall, and rate limiting with a high-level API.

Readme

@push.rocks/smartnftables

A TypeScript module for managing Linux nftables rules with a high-level, type-safe API. Handles NAT (DNAT/SNAT/masquerade), firewall rules, IP sets, and rate limiting — all from clean, declarative TypeScript.

Issue Reporting and Security

For reporting bugs, issues, or security vulnerabilities, please visit community.foss.global/. This is the central community hub for all issue reporting. Developers who sign and comply with our contribution agreement and go through identification can also get a code.foss.global/ account to submit Pull Requests directly.

Install

pnpm install @push.rocks/smartnftables
# or
npm install @push.rocks/smartnftables

The legacy SmartNftables helper tracks rules without applying them when it lacks root privileges. ManagedNftables always rejects unsupported enforcement; it has no dry-run or memory-only enforcement mode.

Managed workload policy

ManagedNftables provides a separate native owner for complete, interface-bound IPv4 policy. It requires Linux 6.9 or newer, CAP_NET_ADMIN in its network namespace, and working nftables OWNER/PERSIST support. The published native matrix is Linux x86_64 and arm64, statically linked with musl. Actual kernel support is checked when applying policy; starting the compiler does not establish enforcement.

The native binary is the @push.rocks/smartrust sidecar smartnftables. An optional absolute binaryPath (on ManagedNftables, ManagedDockerForwarding and inspectForwardHooks) selects only that file; an unusable one fails as UNAVAILABLE. Without it, a Node.js single executable application runs the sidecar it embeds under the name smartnftables (built with @git.zone/tssea), and an installed package runs its own dist_rust build for the host architecture. PATH is never searched.

An optional caller-owned networkNamespaceFd selects an already-created Linux network namespace. Keep that descriptor valid through start(). The native process validates and enters its inherited copy before readiness, owner identity capture or netlink activity, then closes the copy. It enters while it runs a single thread and refuses otherwise; the allocator's background threads start only after the entry, so every thread lives in the target namespace. Invalid descriptors and denied entry fail before readiness, and so does a failed start of the allocator's background threads. The option is process configuration and never enters openOwner, policy commands or durable receipts. Native callers use --management --network-namespace-fd 3 with an inherited descriptor at FD 3.

The caller owns the namespace lifetime, links and routes. Retain a namespace owner across process loss and recovery: PERSIST retains policy only while its namespace exists. An entered process keeps its namespace membership if the source descriptor is closed, but a replacement process requires a valid descriptor. Receipts bind the actual target namespace device and inode; recovery in another namespace rejects. Await complete policy and process cleanup before releasing the caller's final namespace owner. Namespace entry does not create uplink authority, drain conntrack flows or authorize address and port reuse.

The Linux x86_64 musl candidate passes isolated Linux 6.18.58 kernel tests, including inherited namespace entry, retained policy after process loss, recovery in the original namespace, rejection in another namespace, and denied entry before readiness. ARM binaries are built; privileged ARM and complete Pallet workload integration remain separate qualification requirements.

The caller owns workload links and durable state. Keep links disabled until their policy is confirmed, serialize link changes with policy operations, and disable them before cleanup. Persist the full prepared transition before calling reconcile(), then retain the full returned receipt in the caller's SmartData store. This library does not persist runtime state in files.

import { ManagedNftables } from '@push.rocks/smartnftables';

const owner = new ManagedNftables({
  ownerId: 'node-policy',
  instanceId: 'caller_retained_unique_instance',
  tableName: 'snft_workloads',
});
await owner.start(); // Compiler/IPC readiness only; no kernel policy yet.

const target = await owner.prepare({
  schemaVersion: 1,
  revision: 1,
  endpoints: [
    { id: 'workload-a', interfaceIndex: 12, interfaceName: 'worka', interfaceKind: 'veth', sourcePrefixes: ['10.81.1.2/32'] },
    { id: 'workload-b', interfaceIndex: 13, interfaceName: 'workb', interfaceKind: 'veth', sourcePrefixes: ['10.81.2.2/32'] },
  ],
  rules: [
    {
      sourceEndpoint: 'workload-a', destinationEndpoint: 'workload-b',
      sourcePrefix: '10.81.1.2/32', destinationPrefix: '10.81.2.2/32',
      protocol: 'tcp', sourcePort: null, destinationPort: 443,
    },
    {
      sourceEndpoint: 'workload-b', destinationEndpoint: 'workload-a',
      sourcePrefix: '10.81.2.2/32', destinationPrefix: '10.81.1.2/32',
      protocol: 'tcp', sourcePort: 443, destinationPort: null,
    },
  ],
});
const transition = { previous: null, target };
// Persist this complete transition before the next operation.
const applied = await owner.reconcile(transition);
// Persist the complete applied result before enabling the caller-owned links.
const status = await owner.inspect();
if (!status.enforced) throw new Error('Workload policy is not confirmed.');

// After disabling/quiescing these workload links:
await owner.release(applied);
await owner.close();

Interface numbers and names in this example must be replaced with the caller's actual, already-created links. Native RTM_GETLINK checks the name/index pair, the declared veth or L3 tun kind and absence of a bridge/master attachment. The caller must prove and own each veth peer and workload network namespace. Both selectors must match for a grant. A stale name/index pair rejects application; renaming either selector retains denial. Do not reuse interface identities or workload addresses while their previous grants remain outstanding.

| Method | Behavior | | --- | --- | | start() | Starts one private native process and returns idle status. | | failureSignal | Stable per-instance AbortSignal for lost owner confidence, including an unexpected idle process exit. Its OWNER_LOST reason carries the failure as cause. It never resets or confirms cleanup. | | prepare(policy) | Validates, canonicalizes and hashes an inert complete policy without kernel writes. Its lists may come in any order. While the owner holds the graph it applied, a policy that differs from that graph's policy only in units is sorted into canonical order and prepared from those units alone, with the full preparation's output and refusals (see "Element deltas"). | | reconcile({ previous, target }) | Applies the target in one kernel transaction. previous is null initially, otherwise the full prior applied result. From the graph this owner applied and still holds, a target that differs only in policy units is admitted and compiled from those units alone and sent as one batch of exactly their elements; any other target is validated whole and replaces the entire graph (see "Element deltas"). | | lastApply | What the last successful reconcile() did: { mode, batches, batchMessages, batchBytes, elementsRead, unitsPrepared, unitsCompiled, elementsValidated, linksChecked, routeNetlinkRequests, engineMicros }, mode one of delta, replace and unchanged. Null until a reconcile() resolves and while the next one runs. | | lastPrepare | What the last successful prepare() did: { mode, unitsPrepared, unitsCompiled, elementsValidated, engineMicros }, mode delta or full. Null until a prepare() resolves and while the next one runs. | | inspect() | Reads the current owned graph and reports enforced, pending intent and a bounded error code. | | release(applied) | Verifies the exact retained graph and confirms deletion; repeats are idempotent. | | releaseTransition({ identity, transition }) | Selects terminal cleanup using the original full identity and transition when the applied result is unavailable. Accepts absence or deletes the exact previous/target graph without requiring surviving interfaces. | | detach(applied) | Selects terminal retention, verifies the same graph with PERSIST-only flags after dropping socket ownership, then joins the native process. Returns { detached: true }; retain the full applied result. | | closeRetaining() | Stops command admission, joins admitted operations and terminates the native process without requesting deletion or confirming retention. Use when the exact applied result is unavailable. | | close() | Stops command admission, joins admitted policy operations, confirms owned-table deletion unless retention was selected, then joins the native process. Failure retains the owner for explicit recovery. |

Schema-v1 policies are directed. Return traffic requires its own grant; there is no broad connection-tracking bypass. A null endpoint denotes the actual local host, with an explicit prefix that cannot overlap any endpoint. Such a grant applies only to input/output. Forwarding requires two explicit endpoint references; a relay TUN is an endpoint with interfaceKind: 'tun' and its authenticated remote source prefixes. The relay owner must authenticate that remote source authority. Managed interfaces reject other IPv4 traffic and IPv6 traffic. Ethernet/ARP is outside these inet hooks; workload links must use the qualified routed layout. Unmatched interfaces retain their existing behavior. The compiler creates only inet input, forward and output filter chains; it does not configure links, routes, NAT, DNS, host defaults, forwarding sysctls, or relay sessions. An ungranted destination is denied independently of DNS answers.

The schema permits at most 32 interfaces, 16 source prefixes per interface and 128 directed rules, additionally bounded to 768 compiled operations and 100,000 encoded bytes. IPv4 prefixes must be canonical, non-overlapping local allocations; loopback, unspecified and multicast allocations reject. Ports require explicit TCP/UDP and all nullable fields must be present. The facade rejects getters, proxies, live objects and concurrent commands rather than queuing unbounded work.

One NETLINK_NETFILTER socket exclusively owns the table with OWNER/PERSIST flags. An acknowledged batch commits the complete graph atomically. Lost acknowledgements retain the exact pending transition; retry only that same body. Reconnect/restart uses the same caller-retained owner, instance, table and boot/namespace receipt. Recovery checks the complete graph before and after orphan adoption. The kernel's wrapping generation counter only fences concurrent transactions; it is never a durable revision or sufficient ownership proof. The counter is namespace-wide, so any other nftables writer in the namespace, including a second owner, moves it. A read or batch that loses that race had no effect and is retried, up to 16 times: reads of an owned table need no generation match, and a batch on a table this socket owns is fenced on a fresh generation. Only exhausted retries answer CONFLICT. A batch requests acknowledgements only for its begin and end; Linux still acknowledges every failed operation, so a large batch cannot overrun the socket's receive queue and lose the commit acknowledgement. After a refused or unacknowledged step the owner reads the table: a step whose target is in place succeeds, and a table still holding exactly the previous graph under the socket that held it before the request leaves the owner intact. A request that first had to claim the table never does: the claim changed the kernel. Foreign owners, unexpected tables, chains, rules, sets or objects inside the owned table reject without deletion. Other tables coexist independently. Linux's family-wide chain dump is explicitly filtered by table identity before graph comparison; foreign chains are never included in replacement or cleanup.

PERSIST deliberately retains accepted static grants if the native process crashes. It supplies no wall-clock lease or timed revocation. The caller must keep revocation pending until policy replacement or workload fencing is confirmed. Host reboot invalidates the old boot receipt; the caller must fence its old link/workload lifecycle before starting a new owner instance. Cleanup failures and uncertain operations must not be reported as successful release or used to transfer authority.

Subscribe to failureSignal before starting or admitting policy work, and check failureSignal.aborted when attaching to an existing instance. Unexpected child exit, ambiguous operation failures, native inspection reporting failed ownership, and unconfirmed cleanup abort the signal once with ManagedNftablesError code OWNER_LOST. Local input rejection, effect-free native compiler validation and native INVALID and EXHAUSTED responses do not abort it. Nor does a native refusal whose errorData is exactly { owner: 'intact' }: the request changed nothing the owner holds, either refused before any kernel effect or proved by reading the table, with nothing pending, and it rejects with its own code. Other native errors can occur after kernel effects or ownership loss and conservatively abort it. The abort reason's cause names the failure: the native error's code, UNAVAILABLE for process exit, the inspected status error (CONFLICT without one) or CLEANUP_UNCONFIRMED. Successful orderly close, including a cancelled startup, does not signal an unexpected failure; ambiguous mutations still signal loss while close is draining them.

The caller must fence packet admission on loss. Recovery operations and exact retries remain available as documented, but successful recovery never resets the signal. It does not monitor foreign kernel changes while idle or prove policy withdrawal: PERSIST policy can remain after process death. Still await the chosen joined cleanup method and retain unresolved policy/allocation authority.

For an orderly restart that must preserve policy, persist the complete applied result and call await owner.detach(applied) before calling close(). The native operation validates the exact receipt, graph, interfaces, boot and namespace. It accepts this socket's OWNER/PERSIST table or an exact orphan; it never adopts a foreign owner. After closing the owner socket it reads the graph again and requires the same handle and compiled body with PERSIST-only flags. Native inspection then reports state: 'retained'. The facade returns success only after child termination.

Calling detach() irreversibly selects retention cleanup, including invalid input, transport failure, timeout, native rejection or malformed acknowledgement. Subsequent close() only joins and terminates; it never sends a table-deletion command. It cannot undo a close() already admitted earlier. Other policy operations reject. Resolve any pending policy transition by exact replay before requesting detachment. If a detach ACK is lost while the process remains available, only the same captured body may be retried. After process loss, start a fresh owner with identical options and retry detach(applied) against the exact orphan. Absent graphs, foreign active owners, changed interfaces, handles, bodies or boot/namespace identities reject. Native release/reconcile/close commands reject after a valid detach intent, even when post-drop verification fails; explicit recovery requires the retained body.

If reconciliation remains ambiguous and no complete applied result is available, call await owner.closeRetaining(). This permanently stops command admission, joins admitted work, and terminates and joins the native child without sending detachPolicy or closeOwner. It does not inspect the graph or acknowledge retention, enforcement, release, or namespace survival. An undelivered reconcile may have left no graph; a lost reply may have left the original or target graph. Keep the complete original transition durably and recover it through a fresh owner with identical options. Never fabricate an applied receipt to request detachment.

When cleanup is required instead of recovering enforcement, persist a IManagedNftTransitionRelease containing the identity returned by the original start() and the full original transition. After fencing workload packet admission and joining the old owner, start a fresh owner with the same options:

// Both values come from the caller's durable journal, before the lost apply ACK.
const cleanup = { identity: originalIdentity, transition: originalTransition };
await recoveryOwner.releaseTransition(cleanup);
await recoveryOwner.close();

releaseTransition() validates the original boot and namespace, canonical target, complete previous result when present, table identity and entire rule graph. It accepts an absent table, the previous graph, or the target graph; it never creates or replaces policy. Deleted or changed interfaces do not prevent this cleanup. Orphan adoption checks the graph before and after claiming OWNER, binds the prior or already-observed handle, and rejects another live owner. Deletion uses the exact handle and a generation-fenced batch, followed by an absence check.

Once admitted, only that captured cleanup body may be retried. Other policy writes, preparation and detachment reject; inspection never claims enforcement. An ordinary close() retries the selected cleanup before joining the child. closeRetaining() only joins, allowing a fresh process to retry the durably retained cleanup after an ambiguous acknowledgement. Local inert-input or busy rejection does not select cleanup. A successful release acknowledges that table operation at observation time, including an exact retry after a lost delete ACK. It does not prove packet drain, clear conntrack, release allocations, or cross a changed boot/namespace.

After closeRetaining(), all policy commands, including detach(), reject. Concurrent cleanup calls share the same join. A failed join keeps the native owner and retention choice for retry through closeRetaining() or close(). An ordinary close() admitted first keeps its deletion choice across failures; a later closeRetaining() rejects instead of reversing that choice.

Detachment confirms retention at observation time. A completed facade retry returns its existing acknowledgement; it is not a fresh kernel inspection. Retention does not guarantee future enforcement, namespace survival or reboot ordering, and does not withdraw external authority, drain packets, clear conntrack or release leases. A separately created owner can later recover the full policy and perform an explicit release after the caller has fenced that authority and packet lifecycle.

Apply and release receipts describe this owner's table and policy operations. They do not certify that packets admitted under an earlier graph have drained: foreign NFQUEUE or other deferred packet owners may still hold such packets. The caller must qualify the complete packet path or independently fence the workload before treating revocation as complete or reusing its addresses and interfaces. Table deletion does not release IP allocation authority or clean up conntrack/NAT state. This restriction also applies to private veth/TUN forwarding.

Refusals: INVALID and EXHAUSTED

Every failure is a ManagedNftablesError with a stable code. Two codes are refusals of the input itself, raised by the facade's capture or by the native owner before any kernel work:

  • INVALID: the input is outside the contract (schema, identifiers, addresses, overlaps, authority, or a bound of the topology's shape). A smaller policy does not help; the input must change.
  • EXHAUSTED: the policy is too big for one atomic replacement. It exceeds the compiled budget described under the router below, or an input count that exists to hold that budget. A smaller policy may fit.

Both carry the native refusal text in reason (bounded, printable ASCII, never policy content) and in the message, Managed nftables EXHAUSTED: <reason>. EXHAUSTED also names its bound in details, { bound, limit, actual }:

| bound | limit | Counts | | --- | --- | --- | | ruleBytes | 100,000 | encoded rules, chains and sets of the target | | targetBytes | 212,000 | the complete target with its set elements | | operations | 768 | messages of the target | | endpoints | 32 (v1), 128 (router) | private endpoints | | links | 128 | router links | | rules | 128 (v1), 1024 (router) | private directed rules | | grants | 1024 | router egress grants across every generation | | hostGrants | 1024 | host grants of one scope | | workloadGrants | 1024 | router workload grants | | publishedPorts | 1024 | publications of one scope | | localTcpPortOwners | 8 | loopback TCP port owners of the pool guard | | privateMeshes | 128 | private meshes of the router | | privateMeshMembers | 1024 | members across the router's private meshes | | localServices | 16 | local services of the router | | localServiceMembers | 1024 | members across the router's local services | | restoreRules | 192 | Docker forwarding contribution rules | | restoreBytes | 100,000 | Docker forwarding contribution command bytes |

actual is always above limit. It is exact for input counts and for the schema-v1 budget, which measures its complete program. For the other compiled budgets (ruleBytes, targetBytes, operations, restoreRules, restoreBytes) compilation stops at the first message or rule past the limit, so the complete policy needs at least actual. Input counts are checked before their entries are validated, so a smaller policy can still be INVALID. Every other bound (identifier and interface-name lengths, source prefixes per endpoint, addresses per link, protected prefixes, platform endpoints, active generations, handoffs and allocations, ranges per allocation, guarded pools, the IPC and capture limits) describes the shape of the topology and stays INVALID. Other codes, and the facade's own local rejections, carry neither reason nor details. A native refusal outside this shape is PROTOCOL. A failed close() rejects CLEANUP_UNCONFIRMED, whose cause is the ManagedNftablesError that left cleanup unconfirmed, with the native code when the native owner refused.

try {
  prepared = await owner.prepare(policy);
} catch (error) {
  if (error instanceof ManagedNftablesError && error.code === 'EXHAUSTED' && error.details?.bound === 'publishedPorts') {
    // Publish fewer ports and prepare again.
  } else throw error;
}

Combined router egress and host transit

ManagedNftables<IManagedNftPolicyV2> accepts the exported schema-v2 policy. The prepared/applied/transition/status interfaces accept the same policy type parameter; existing callers default to the unchanged schema-v1 contract. V1 canonical digests and compiled bytes are preserved. V2 uses its own hash domain. An owner cannot transition between v1 private, v2 router, v2 host transit, and v2 allocation-pool guard policy kinds.

| V2 scope | Required authority and behavior | | --- | --- | | routerEgress | Private endpoints and rules, one exact links binding per endpoint, a separate veth handoff, protection, and active generations. Private veth/TUN/local DNS and egress share one table so terminal private denial cannot override a separate egress table. Optional publishedPorts add the inbound second hop from the handoff to a workload endpoint, one port or a port range; both directions are classified into the default conntrack zone ahead of every leased classifier, so a published endpoint port is dedicated to its publication and never becomes leased egress. A symmetric publication also lets the workload open flows from its published ports. Optional hostGrants forward exact host-origin flows from the handoff to a workload endpoint. Optional workloadGrants let one workload open one exact port of another, one way. Optional privateMeshes let every member of a mesh reach every other, and optional localServices let each member reach a router-local service such as DNS, each member one set element. | | hostTransit | Exact handoff link/allocations pairs, complete protection, an explicit veth or Ethernet uplink, and its current snatAddress. It checks each handoff's leased source address and protocol/port range, default conntrack zone, direction, uplink, and protected destinations before outer SNAT. Optional publishedPorts add inbound uplink destination NAT inside the same generation, one port or a port range, and a symmetric publication also carries the workload's own flows from its published ports out through the uplink. Optional hostGrants let the host's own address on a handoff dial exact workload ports. Optional localPlatformEndpoints serve platform endpoints on the host's own addresses to leased flows. Optional exclusiveForwarding makes the table the host's only forwarding owner: every other forwarded packet drops. | | allocationPoolGuard | An authenticated authorityDigest and complete current allocation-pool prefixes. Installs host-wide IPv4 destination denial before any handoff exists, without link, uplink or SNAT dependencies. Optional hostGrants and publishedPorts are its only exceptions. Optional localTcpPortOwners restrict loopback TCP ports to one local user each. |

allocationPoolGuard accepts 1–64 canonical, disjoint RFC1918 prefixes. Supply the actual allocation pools, not the broader protected union containing management LANs, resolvers and platform endpoints. It applies to IPv4 INPUT, FORWARD and OUTPUT at filter priority 0, after normal destination NAT. It drops an original conntrack destination in a pool. Reply-direction packets additionally check their current source, then return; all remaining packets check their current destination. This denies DNAT into or away from a pool and untracked current-destination traffic, while permitting reverse-SNAT replies to authorized transit sources. It grants no egress permissions; exact hostTransit and router policies remain separate owners. Unrelated host traffic and IPv6 retain their existing behavior; the guard never denies forwarding between other links. On a host that forwards only for the handoffs, see Exclusive forwarding.

For example, a guard policy is { schemaVersion: 2, revision: 1, scope: { kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16', '10.241.0.0/16'] } }. Hashing binds the supplied body, but does not authenticate the authority or prove its completeness. An empty hostTransit.handoffs array provides no host-wide denial and is not a substitute for this scope.

The caller must retain the authenticated authority, complete transition and applied receipt, verify inspect().enforced, and qualify the complete packet path. This guard alone proves neither early-boot/late-shutdown ordering nor protection after kernel reboot. It does not fence foreign packet queues, later packet rewrites, offloads or proxies, and supplies no quarantine release, conntrack drainage or allocation-reuse evidence. Historical leases remain the caller's durable ownership responsibility. PERSIST retains this table only while its network namespace exists; release() and ordinary close() delete it, so revoke any external protection claim before release, or use the verified retention operation described above.

Local bindings include name, index, kind, MAC (null for L3 TUN), interface-link index, and required IPv4 addresses. RTM_GETLINK/RTM_GETADDR verify those local facts at apply, recovery and inspection. The route socket sets NETLINK_GET_STRICT_CHK (Linux 4.20 and later), so a link's address read answers that link's addresses only. Ethernet must be unbridged driver-backed Ethernet, including VirtIO; virtual VLAN/bond/bridge/dummy kinds are not inferred uplinks. The caller retains actual peer namespaces and link-generation ownership. These serialized facts are not native lifetime capabilities or a continuous link-change monitor. Address, DHCP and route changes require caller fencing.

protection carries an authority digest, non-overlapping protected IPv4 prefixes and exact platform endpoint IDs/address/protocol/port tuples. The caller must authenticate and supply complete authority. Hashing does not prove completeness. A public grant means its declared IPv4 prefix excluding the protected union; only an explicit platform endpoint grant admits a protected destination. Host checks cover both the current packet destination and original conntrack destination, so foreign DNAT cannot turn a protected destination into a public exception or redirect public traffic into protected space. INPUT diversion, local OUTPUT into handoffs, unmatched handoff traffic and IPv6 forwarding are denied.

Every handoff address and platform endpoint lies in the protected prefixes. The hostTransit uplink addresses need not: the authority is the platform's address space, and a node's uplink lease and snatAddress is usually a public address outside it. The host barrier protects the uplink addresses itself, adding each one no protected prefix covers as a /32 after the authority's prefixes, so a forwarded flow whose current or original destination is the host's uplink address, or one sourced from it toward a handoff, is denied like any protected destination. A scope whose uplink the prefixes already cover compiles exactly as before.

Published host ports

hostTransit.publishedPorts is optional. Absent and empty are the same canonical policy, so an unpublished scope keeps its exact previous digest and compiled bytes. Each entry is { protocol, hostPort, targetPort, targetAddress, hostIp }, with an optional hostPortEnd and an optional symmetric. hostIp must be an exact current uplink address, which rtnetlink verifies at apply, recovery and inspection; there is no wildcard, secondary-link or default-route inference. targetAddress must be a current leased transitSourceAddress of one allocation, and that binding is what selects the handoff link of the admission; an address that no active lease holds, a local host address or a bare workload prefix rejects. Ports are 1–65535, one protocol/hostPort is published exactly once across the scope including across uplink addresses, and a port published on the snatAddress may not fall inside any leased outbound source-port range of the same protocol, because outer SNAT translates to that same address.

hostPortEnd publishes the range hostPort..hostPortEnd. It must be greater than hostPort, and targetPort must equal hostPort: a range keeps every port, so port p of the uplink reaches port p of targetAddress. One port has exactly one canonical form, without hostPortEnd. Ranges follow the same rules as single ports: no two publications of one protocol overlap anywhere in the scope, and no published port on the snatAddress falls inside a leased range. A range translates the address only, which never changes a port.

Publications are set elements, not rules. The generation compiles named sets and maps of concatenated fields in which the port fields are inclusive intervals (the kernel's pipapo backend), so one element holds one port or a whole range: published_port maps a one-port publication's uplink address, protocols and port to its target address and port, published_address maps a range to its target address alone, and published_forward holds every publication's inbound tuple. A constant number of rules looks them up whatever the number of publications: one destination NAT rule per map in this table's own prerouting nat chain at priority -100, and four forward admissions ahead of the capture barrier — the ESTABLISHED original direction, a NEW opening per protocol with opening TCP restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply. Every rule checks the uplink, the default zone and the direction, and every key carries the exact handoff (index and name), the packet and conntrack protocols, the translated current address and port and the original conntrack destination, so foreign NAT cannot borrow the exception for another flow. Published traffic is not source-translated, so the workload observes the actual client address. The chain, its sets, its translations and its admissions are created, replaced and deleted with the generation: a target without the entry, a failed batch, or release removes them, and a caller never needs a separate teardown. Owner verification reads every set back with its description, every element with its interval bounds and map data, and every lookup with its registers, so apply, inspection, adoption and recovery reject a changed element exactly as they reject a changed rule.

Without symmetric, a publication carries inbound flows only: a flow the workload opens from its target address and port toward the uplink is denied. With symmetric: true, a flow from exactly targetAddress and the published target port(s) on the exact handoff may leave through the uplink, NEW (a TCP opening only with SYN) or ESTABLISHED, with its ESTABLISHED replies, and outer source NAT translates it to hostIp and the published uplink port. These flows are elements of one more map, published_symmetric, keyed on the handoff, protocols and the live and original source and holding the uplink address and port span; four admissions look it up as a set and one postrouting rule translates through it. The workload's outbound flows therefore leave from the same address and port its clients reach, as Docker host-mode NAT kept them; a range keeps each port unless the host itself already holds that exact tuple on hostIp. The admissions follow the capture barrier, so every protected current or original destination, the platform endpoints included, stays denied. A symmetric target port may not fall inside a leased range of targetAddress, and may not overlap the target ports of another publication of the same address and protocol. Absent and false are the same canonical policy, digest and compiled graph.

Input bounds are 1024 entries. The rule graph does not grow with them: with every kind of publication present the host compiles at most eleven rules and four sets, about 13,400 bytes of the 100,000-byte rule reserve, and each publication costs about 150 to 300 bytes of set elements, which share the target budget described under host grants. 64 symmetric ranges take about 19,600 element bytes. The caller still owns the route to targetAddress through that handoff, the workload listener, and every other packet owner on the host. As with leased egress, an ACCEPT here cannot override Docker's independent FORWARD DROP; the ManagedDockerForwarding contribution below admits every inbound and symmetric publication of the barrier through it, with the leased flows.

Each router generation binds an immutable lease reference, transit source address, leased TCP/UDP source-port ranges, a nonzero conntrack zone and a 16-byte label. Each directed grant selects one exact leased protocol range for SNAT. A grant's source is a workload veth or the actual router-local host with an exact assigned IPv4 source address; TUN endpoints retain private routing only. Router-local DNS and relay transports therefore need explicit local-origin grants. Raw PREROUTING and OUTPUT classify before conntrack; filter rules validate current and original tuples, links, direction, zone, state and generation label on every packet. Opening TCP requires SYN with FIN/RST/ACK clear and permits ECN negotiation. Replies must match the labelled original flow and current directed grant. There is no broad ESTABLISHED/RELATED bypass.

Published workload ports

routerEgress.publishedPorts is optional and is the second hop of a published host port. Absent and empty are the same canonical policy, so an unpublished scope keeps its exact previous digest and compiled bytes. Each entry is { protocol, transitPort, endpointPort, endpointAddress, transitSourceAddress }, with an optional transitPortEnd and an optional symmetric. transitSourceAddress must be a current leased generation address that is present on handoff, which rtnetlink verifies at apply, recovery and inspection; the handoff can carry several current leased addresses, so the arrival address is always explicit and never inferred. transitPort is the targetPort of the matching hostTransit publication. endpointAddress must be an address of one current veth workload endpoint of this scope: a router-local link or gateway address, the transit address, a TUN endpoint's private space, a platform endpoint, loopback and IPv6 all reject. Ports are 1–65535, one protocol/transitPort is published exactly once across the scope, and a published port may not fall inside any leased outbound source-port range of the same protocol on that transit address, because source NAT translates to that same address.

transitPortEnd publishes the range transitPort..transitPortEnd, the second hop of a host range. It must be greater than transitPort, and endpointPort must equal transitPort, so every port keeps its number on both hops; one port has exactly one canonical form, without transitPortEnd. No two publications of one protocol overlap anywhere in the scope, and no published port falls inside a leased range of its transit address. The destination NAT of a range translates the address only.

Publications are set elements here too: published_arrival (protocol, transit address and port), published_endpoint (workload link, protocol, address and port, the union of every publication's workload ports as disjoint canonical intervals), the published_port and published_address translation maps and published_forward, with the port fields as inclusive intervals. A constant number of rules looks them up whatever the number of publications: one destination NAT rule per map in this table's own prerouting nat chain at priority -100, two raw classifications, and four forward admissions placed ahead of the capture barrier — the ESTABLISHED original direction, a NEW opening per protocol with opening TCP restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply. The handoff ingress classification precedes the barrier's protected-source denial, because the uplink or management network that reaches a published port can itself be protected space (an uplink address outside the protected prefixes is still guarded as a /32). The endpoint ingress classification matches the exact workload link, address and published endpoint port, and precedes every leased classifier of every generation: raw runs before conntrack, so a client whose source port equals a leased grant's destination port would otherwise carry the workload's published packets into that generation, where the publication's flow does not exist. Both classifications match the arriving tuple only, set no generation zone and leave the flow in the default conntrack zone. The private anti-spoof guards still run first, and every admission checks the handoff, the default zone and the direction, and looks up the exact workload link (index and name), both protocols, the translated current address/port and the original conntrack destination.

A published endpoint port is therefore dedicated to its publication: a new outbound flow that the workload opens from that exact address and port on that exact link stays in the default zone too and is never translated by the leased outbound source NAT. Without symmetric such a flow is denied. Inbound published traffic is not source-translated on either hop, so the workload observes the actual client address. The chain, its sets, its translations, its classifications and its admissions are created, replaced and deleted with the generation: a target without the entry, a failed batch, or release removes them.

With symmetric: true the workload may open flows from exactly endpointAddress and the published endpoint port(s) on its exact link toward the handoff, NEW (a TCP opening only with SYN) or ESTABLISHED, with their ESTABLISHED replies, and source NAT translates them to transitSourceAddress and the published transit port. With the matching symmetric hostTransit publication the flow reaches the uplink from hostIp and the published uplink port, the same address and port its clients reach, as Docker host-mode NAT kept it; a range keeps each port unless that exact tuple is already taken on the translated address. These flows are elements of the published_symmetric map, keyed on the workload link, protocols and the live and original source and holding the transit address and port span, behind four admissions and one postrouting translation. Both directions are already in the default zone through the publication's endpoint and handoff classifications, so no leased classifier can capture them. The admissions follow the capture barrier, so every protected current or original destination, the platform endpoints included, stays denied; outbound flows anywhere else still need their leased grants. The endpoint ports of a symmetric publication may not overlap the endpoint ports of another publication of the same address and protocol, since each flow has exactly one published source. Absent and false are the same canonical policy, digest and compiled graph.

Input bounds are 1024 entries. The rule graph does not grow with them: with every kind of publication present the router compiles at most thirteen rules and six sets, about 15,100 bytes of the 100,000-byte rule reserve, and each publication costs about 250 to 450 bytes of set elements, which share the target budget with the router's endpoints, rules and grants. 64 symmetric ranges take about 28,000 element bytes. The caller still owns both hops: the exact matching hostTransit publication, the route to the workload through this router, the workload listener and every other packet owner.

Host grants

A host grant lets the node's own host network namespace dial one exact port of one local workload at the workload's address, from the host's own address on the current handoff (the transit host address). It is how a controller that runs on the node, such as a reverse proxy, reaches a workload without loopback publication, route_localnet, loopback DNAT or loosened martian filtering. Each entry is { protocol, sourceAddress, destinationAddress, destinationPort }: tcp or udp, two exact unicast IPv4 addresses and one port 1–65535. Prefixes, wildcards, ranges, lists, loopback, broadcast, IPv6 and duplicate tuples reject. There is no source-port selection and no translation.

The flow crosses three tables, so the caller compiles the same grants into each scope, and each scope binds them to the authority it proves:

  • allocationPoolGuard.hostGrants: destinationAddress lies in a guarded pool. The admissions sit in OUTPUT (original direction) and INPUT (reply) ahead of the guard. Everything else still meets the unconditional guard, including forwarded traffic and a translated flow that merely arrives at a granted tuple.
  • hostTransit.hostGrants: sourceAddress is an exact current address of exactly one handoff link and not an uplink address; destinationAddress is protected space that is neither host-local, a platform endpoint nor a leased transit source address. The admissions bind the exact handoff that holds the source address and precede its terminal OUTPUT and INPUT denials; forwarding and the uplink are unchanged.
  • routerEgress.hostGrants: destinationAddress is an exact current veth workload endpoint address of this scope, and sourceAddress is protected space that is neither router-local, a platform endpoint nor any workload's address. One raw handoff ingress classification precedes the barrier's protected-source denial, sets no generation zone and leaves the flow in the default conntrack zone; the forward admissions bind the handoff and the exact workload link, follow the private anti-spoof guards, and sit beside the publications ahead of the capture barrier. The workload's replies need no classifier: their destination is protected, so they return to the default zone ahead of every leased classifier, even when the host's source port equals a leased grant's destination port.

The grants of a scope live in one named set, host_grant, and the rule graph does not grow with them: four admissions per scope — ESTABLISHED original direction, a NEW UDP opening, a NEW TCP opening restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply — each check the default conntrack zone and the direction, then look up one concatenated key. The key carries every remaining operand: the variable interface's index and name (the handoff in host transit, the workload link in the router; the guard has none), the packet protocol and the conntrack protocol, the live source, destination and workload port, and the originally tracked source, destination and port. An element is one grant whose live tuple equals its original tuple, so a translated flow never matches and foreign NAT cannot borrow an admission for another flow. The router's raw classification looks up the exact arriving protocol, addresses and port in a second set, host_grant_arrival. Owner verification reads every set back with all of its elements, so inspection, adoption and recovery reject a changed or foreign set exactly as they reject a changed rule, and while its owner lives the table's OWNER flag refuses every other socket's element change.

Absent and empty are the same canonical policy, so a scope without grants keeps its exact previous digest and compiled bytes and has no host grant set. Withdrawing a grant removes its element in one atomic batch (the last grant takes the set and its rules with a full replacement), so a target without the entry, a failed batch or release removes it, and an established flow stops with it. Apply grants hop by hop from the workload side (router, host transit, guard) and withdraw them in the reverse order; each table is atomic on its own, and a partially applied grant only stays dark.

Host-local platform endpoints

A platform endpoint normally lives elsewhere and host transit forwards leased flows to it. When the host serves an endpoint itself — a relay listener, a data port or a VPN hub on an address the host holds — the leased flow ends in the host's INPUT instead, where every handoff arrival is denied. The optional hostTransit.localPlatformEndpoints lists the ids of protection.platformEndpoints the host serves:

protection: { authorityDigest, prefixes: ['10.0.0.0/8', '192.0.2.0/24'], platformEndpoints: [
  { id: 'relay', address: '192.0.2.2', protocol: 'tcp', port: 8443 }] },
localPlatformEndpoints: ['relay'],

Each id names an existing endpoint whose address is an exact requiredIpv4Addresses entry of the uplink or of a handoff link, so rtnetlink verifies that the host holds it at apply, recovery and inspection. The refusal of a platform endpoint on a host address is lifted only for declared ids; unknown, duplicate or unheld ids reject. The router scope grants its workloads the endpoint like any platform endpoint (destination: { kind: 'platform', endpointId }).

INPUT admits the original direction only from the exact handoff that carries a lease, with the lease's transit source address and a source port in one of its ranges for the endpoint's protocol, in the default conntrack zone, to the exact endpoint address and port in both the live and the originally tracked tuple, NEW (a TCP opening only with SYN) or ESTABLISHED. OUTPUT admits only the ESTABLISHED reply back through that handoff. A flow translated in transit, another source address or port, another endpoint port or protocol and any host-origin opening toward the transit address keep meeting the handoff denials. The source port is checked against the leased range in both tuples, not for equality between them. Each leased range adds one INPUT and one OUTPUT jump when an endpoint of its protocol is declared, and each endpoint adds three rules. Absent and empty are the same canonical policy, digest and compiled bytes. The caller keeps a declared endpoint's address outside every guarded allocation pool and owns the listener.

Exclusive forwarding

A host that forwards IPv4 (net.ipv4.conf.all.forwarding=1) routes between all of its links. On a Docker host, Docker set that switch and also the iptables FORWARD policy DROP, and ManagedDockerForwarding admits the handoff flows through it. On a host without Docker nothing denies forwarding that does not touch a handoff: host transit governs only handoff traffic, and the allocation-pool guard only pool destinations, so a LAN peer could use the host as its gateway, the uplink could hairpin, and two other links could exchange traffic. hostTransit.exclusiveForwarding: true closes that path inside the barrier's own table:

const transit: IManagedNftPolicyV2 = { schemaVersion: 2, revision, scope: {
  kind: 'hostTransit', protection, handoffs, uplink, snatAddress, exclusiveForwarding: true } };

The forward chain ends in one unconditional drop, after every rule of the scope. The host then forwards exactly what the scope admits: the leased flows of each handoff to the uplink with their ESTABLISHED replies, the inbound publications and their replies, and the symmetric publications' flows. Everything else that reaches the forward hook drops, whatever its links: IPv4 between the uplink, a LAN or any other link, a hairpin through the uplink, flows that were established before the member was applied, and forwarded IPv6. There is no ESTABLISHED/RELATED bypass, as everywhere in this scope, so ICMP errors for leased flows stay denied as before. Allocation pools need no exception: pool addresses are routed inside the router namespace and leave it translated to the transit source, so on the host only transit addresses cross the forward hook, and the pool guard denies pool destinations there anyway. IPv6 is dropped because the scope's packet model is IPv4 only; a host whose IPv6 forwarding is off never presents IPv6 to the hook. Input, output and NAT are unchanged, and so are the host's own flows: host grants and host-local platform endpoints are INPUT and OUTPUT traffic. With no handoffs the scope forwards nothing at all.

nftables runs the forward base chain of every table, and a drop in any one of them is final, while an accept only ends its own chain. The member therefore drops forwarding that another owner (Docker, a container or VM bridge, a VPN router, a second routing daemon) accepts, and accepting in this table still cannot override another owner's drop. Use it only on a host where this table is the sole forwarding owner; on a Docker host keep it absent and use the Docker contribution below, which refuses an exclusive barrier. inspectForwardHooks(), described below, reports the other forward owners of the namespace for that decision. The member does not fence flowtable offload, packet queues, proxies or anything outside the forward hook.

The drop is part of the scope's graph: it is created, verified, adopted, recovered, replaced and released with the rest of the table in one batch, and a replacement without the member or release() removes it, so forwarding resumes for every link the moment the table goes. This package sets no sysctl. The denial holds only while an exclusive table is applied, so the caller enables host forwarding only after an exclusive scope is enforced and disables it before releasing that scope, or keeps an exclusive scope applied between generations (with no handoffs it is a pure host-wide forwarding denial) and replaces it in place. Persistent forwarding in sysctl.d would make the host forward unfiltered after every boot until the scope is applied again, so leave the boot default off. Absent and false are the same canonical policy, digest and compiled bytes; the member adds one rule.

Workload grants

routerEgress.workloadGrants is optional: a stateful one-way flow between two workloads behind the same router, such as an ingress workload reaching a target workload's service port across organizations. Each entry is { protocol, sourceAddress, destinationAddress, destinationPort }: tcp or udp, the exact current addresses of two different veth workload endpoints of the scope and one port 1–65535. Prefixes, wildcards, ranges, router-local and platform-endpoint addresses, TUN endpoints, both ends on one endpoint and duplicates reject. The source opens and the destination only answers; the reverse direction is a second grant.

Four FORWARD admissions follow the private anti-spoof guards whatever the number of grants: ESTABLISHED original direction, a NEW UDP opening, a NEW TCP opening restricted to SYN with FIN/RST/ACK clear, and the ESTABLISHED reply, each in the default conntrack zone. Every admission looks up the incoming link with the source address and the outgoing link with the destination address (index and name) in the set workload_link, and the protocols with the live and originally tracked tuple in the set workload_grant, so a spoofed, renamed or translated flow never matches and the destination can never open toward the source. Endpoint prefixes are protected, so both directions stay in the default zone ahead of every leased classifier. Owner verification reads both sets back element by element. Up to 1024 grants fit; the sets share the scope's target budget with hostGrants and every other set, and 1024 of each fit together beside the reference router; a scope beyond the budget is refused before any kernel work. Absent and empty are the same canonical policy, digest and compiled bytes, and withdrawal removes the grant's elements in one atomic batch, which also stops established flows.

These flows need no private rules. A private rule is stateless: answering through private rules needs a reverse rule, which would also let the destination open toward the source.

Private meshes

routerEgress.privateMeshes is optional: all-pairs private forwarding, such as the workloads of one private network. Each mesh is { id, members }; a member is { endpoint, prefix }, the id of one of the scope's endpoints and a canonical prefix inside one of its sourcePrefixes (a workload's /32, or one remote prefix behind a relay TUN). Every member may forward packets of any protocol to every other member behind another endpoint link, exactly as the directed rules of every such ordered pair would (protocol: 'any', no ports), and like them it is stateless. A member never reaches a member on its own link through the router, so the remote members behind one relay TUN stay apart. A member may belong to several meshes and reaches the members of each; meshes never join each other's members.

A mesh of N members replaces N(N−1) directed rules: with the four DNS rules each workload needs, a full mesh of directed rules exceeds the 1024 router rules at 31 workloads (EXHAUSTED, rules, 1054). A mesh is one interval set, mesh_<n> for the n-th mesh in id order, keyed by the member's link index and prefix, behind one rule in the regular chain mesh: the incoming link and source and the outgoing link and destination are both members. FORWARD jumps to that chain after the directed rules, for IPv4 between two endpoint links (index and name, as private_link holds them) that are not one link: the exact set mesh_hairpin holds every endpoint link as both interfaces. A packet no mesh admits returns and meets the rest of FORWARD unchanged.

id is unique, 1–64 letters, digits, _ and -, and orders the meshes; members are sorted. A mesh has at least one member; an unknown endpoint, a prefix outside the endpoint's, a non-canonical prefix, two overlapping members of one endpoint, a duplicate id and any other field reject with INVALID. Up to 128 meshes and 1024 members across them are admitted, but the rule reserve binds the meshes first: each costs one set and one rule, 740 rule bytes, so 42 meshes fit beside the 56-workload node described below. A member costs one element.

Local services

routerEgress.localServices is optional: router-local services that private members reach, such as the DNS resolver on each workload's gateway. Each service is { id, protocols, port, members }; a member is { endpoint, prefix, address }, an endpoint member as in a mesh and the exact router address it reaches the service at. Each member may send to its address and port from any source port, and the address may answer from the port to any port of the member, for every listed protocol, exactly as the two directed rules per member and protocol would (INPUT for the request, OUTPUT for the answer). address is protected space that no endpoint's prefix covers, like the router side of a directed rule.

A service is one interval set, service_<n> in id order, keyed by the member's link index, prefix and address, behind two rules per protocol: INPUT looks up the incoming link, source and destination of a request to the port, OUTPUT the outgoing link, destination and source of an answer from it, each with its link's index and name in private_link. protocols holds tcp, udp or both, each once; port is 1–65535. Up to 16 services and 1024 members across them are admitted; a duplicate or overlapping member of one endpoint and address rejects with INVALID.

Absent and empty meshes and services are the same canonical policy, digest and compiled graph. A member that joins a mesh or service is one element of the compiled target and changes no rule, chain or set declaration; a workload that joins a meshed node with DNS and public egress adds the same 11 elements at any size, so the owner applies that ADD as one element delta of the same size (13 messages and 1,432 bytes in the qualification, at 2 workloads as at 56). A node of the router budget table below with every workload in one mesh and DNS as one service compiles 69,032 rule bytes whatever its size, and 79,032 element bytes at 56 workloads; 103 such workloads fit the target budget, where the same node with DNS as directed rules and no mesh holds 89.

Meshes and services decide every packet exactly as the directed rules of every pair and member they replace: the tests compare both graphs over every combination of links, addresses, protocols and ports of a node with two meshes, a shared member and two remote members behind the relay TUN.

Publications through the allocation-pool guard

A hostTransit publication translates an inbound flow from the uplink into the router's transit address, which lies in a guarded pool, so the guard denies it by its current destination and its replies by their current source. allocationPoolGuard.publishedPorts is optional and lists the publications the guard admits: each { protocol, hostPort, hostPortEnd?, targetPort, targetAddress } names the published port or range, and the exact pool address and port it is translated to (a range keeps every port, so targetPort equals hostPort). The target must be a unicast address inside a listed pool; each protocol and port is published at most once; up to 1024 fit. Compile the same publications into the guard and into the host-transit scope that translates them, which binds the uplink address and the handoff link the guard does not check.

const guard: IManagedNftPolicyV2 = { schemaVersion: 2, revision: 1, scope: {
  kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16', '10.241.0.0/16'],
  publishedPorts: [{ protocol: 'tcp', hostPort: 443, targetPort: 443, targetAddress: '10.240.0.1' },
    { protocol: 'udp', hostPort: 20000, hostPortEnd: 20200, targetPort: 20000, targetAddress: '10.240.0.1' }] } };

The admissions sit in the guard chain right after its denial of every pool as an original destination, so only a flow translated from outside every pool can match, and ahead of every current-address denial: an ESTABLISHED packet of either direction and a NEW opening per protocol (TCP only with SYN and FIN/RST/ACK clear), in the default conntrack zone, each one lookup of the protocol, the reply tuple's source address and port (where the flow was translated to, in both directions) and the originally tracked destination port in the interval set pool_published. Another target, target port, published port, protocol or zone, a non-SYN opening and an original destination inside a pool meet the guard unchanged. A publication is the exception of every hook it crosses; a forwarded publication crosses FORWARD alone. Absent and empty are the same canonical policy, digest and compiled bytes. The largest guard (64 pools, 1024 host grants, 8 loopback port owners and 1024 publications) compiles to 99,688 rule bytes and 74,640 element bytes at revision 1.

Loopback TCP port owners

allocationPoolGuard.localTcpPortOwners is optional and restricts a loopback TCP service to one local user, such as a container runtime's CRI stream server on 127.0.0.1:10010 that only root may reach. Each entry is { address, port, uid }: one exact IPv4 loopback host address in 127.0.0.0/8 (not the network or broadcast address), one port 1–65535 and one uid 0–4294967294. Prefixes, wildcards, IPv6, ranges, uid lists and a second entry for the same address and port reject; the limit is 8 entries. Absent and empty are the same canonical policy, so a guard without owners keeps its exact previous digest and compiled bytes.

const guard: IManagedNftPolicyV2 = { schemaVersion: 2, revision: 1, scope: {
  kind: 'allocationPoolGuard', authorityDigest, prefixes: ['10.240.0.0/16'],
  localTcpPortOwners: [{ address: '127.0.0.1', port: 10010, uid: 0 }] } };

Each entry compiles two rules at the head of the guard's base chains, ahead of the host grant admissions and the pool guard, so nothing earlier in the table accepts past them:

  • OUTPUT (filter priority 0, after ordinary output destination NAT): IPv4 TCP to the exact address and port with meta skuid != uid is rejected with a TCP reset, so another user's connect() fails at once with ECONNREFUSED. This covers every packet, not only the opening, and a flow a foreign output DNAT redirects to the port. meta skuid is the socket file's file-system uid as the user namespace owning the network namespace sees it, the initial one on a host: a process in a user namespace that shares the host network namespace is matched by its host uid, and a user namespace's root is not uid 0. A packet without a socket file carries no uid and does not match: a kernel reply, the teardown of an orphaned socket, or an in-kernel socket such as an NFS or CIFS client, which only a privileged mount creates.
  • INPUT: the exact address and port arriving on any interface but loopback (ifindex 1 in every network namespace) are dropped, so route_localnet or a prerouting DNAT cannot expose the service to another host.

The rules match the exact destination address. The service must bind exactly that address; a wildcard listener stays reachable through the host's other addresses. IPv6 is not covered, so the service must not listen on ::1 or ::. A socket keeps the uid it was created with, so a root process that hands its connected socket to another user delegates that connection. The reject expression needs the kernel's nft_reject_inet module (autoloaded on stock kernels). Owner verification reads back every rule, including the comparison operator, the uid and the reject type, so apply, inspection, adoption and recovery reject a changed rule. The entries are applied, retained, recovered and released with the rest of the guard's table; after a reboot the caller applies its retained intent again, like every other member.

Input bounds are 1024 grants per scope, the maximum a network projection carries. Elements are sent 256 to a message. At 1024 grants the reference router needs about 98 KB of elements (two sets), host transit about 66 KB and the guard about 45 KB.

Element deltas

No set is CONSTANT, and every rule's userdata stamp names the compiled graph's structure (snft2:<sha256 of every message but set elements>:<ordinal>) instead of the policy revision, which lives with the full digest in the one element of the set revision (the revision and the digest, 40 bytes big endian, a key Linux stores and never looks up). Two targets that differ only in set elements therefore compile the same chains, rules and set declarations, and reconcile() has two cases:

  • The owner applied previous itself, through the socket that still owns the table, nothing failed since, and the target differs from it only in units: the items of its unit lists (a router's endpoints, links, directed rules, grants, publications, host and workload grants and mesh and service members; a host's or a guard's publications and host grants). Everything else is the structure, and the