• v0.25.0 430cf679d2

    nlink 0.25.0 Stable

    p13marc released this 2026-07-15 06:30:54 +00:00 | 0 commits to master since this release

    Added

    • Path-based netns create/delete: namespace::create_path /
      namespace::delete_path.
      Persist a network namespace at an arbitrary
      bind-mount path instead of the ip netns convention /var/run/netns/<name>,
      so applications can own their netns directory (clearer ownership, no
      collisions with operator ip netns add). create/delete now delegate to
      the path-based core; behavior for the name-based API is unchanged. On a
      failed create_path any parent directory it had to create is rolled back, and
      a live bind mount is torn down before rollback, so a transient unshare/mount/
      restore failure can't leak a half-created tree or a pinned mount. Paths are
      passed to the kernel byte-exact (non-UTF-8 paths are honored, not mangled).
    • Live-netns predicate: namespace::is_namespace_path /
      namespace::is_namespace.
      Infallible bool (mirrors exists)
      distinguishing a live netns bind-mount from a stale marker file left by
      an unclean shutdown, via a statfs(2) nsfs-magic check (/proc/mounts is
      unreliable under mount-namespace propagation). Lets a path-based caller
      detect-and-clear a stale marker before create_path, which refuses an
      existing path. is_namespace is the named wrapper resolving against
      /var/run/netns/<name>.

    Fixed

    • nftables: NftablesConfig::apply destroyed every table it did not declare
      (#190).
      diff() scheduled every table in the kernel that was not in
      the declared config for deletion, and apply() committed those deletes in
      the atomic batch. NFT_MSG_DELTABLE cascades — all chains, rules, sets and
      flowtables in the table go with it — and list_tables() is unscoped, with no
      ownership marker consulted.

      So on any host also running Docker, firewalld, libvirt or kube-proxy,
      following the declarative-config recipe with a config declaring one table
      atomically destroyed ip nat (Docker), inet firewalld, ip6 filter, and
      everything else on the box. The host lost its firewall and NAT rules in a
      single commit, with no warning and no dry-run prompt. nlink::lab was
      shielded only because it applies inside a fresh netns.

      diff() now never emits table deletions. Purge is opt-in via
      diff_with_options(&conn, &NftDiffOptions::default().purge_tables(true)),
      mirroring DiffOptions::purge on the NetworkConfig side, and is reachable
      only through the explicit diff → inspect → apply flow. The
      recipe sentence "the library
      only deletes what it owns" was true of rules and not of tables; it now says
      so, and is now true of tables too.

    • nftables: rules installed in reverse declaration order (#195).
      NLM_F_APPEND was never set on NFT_MSG_NEWRULE — the constant was defined
      at message.rs:237 and used nowhere in the crate. nf_tables_newrule()
      appends to the chain tail only when it is set; otherwise it prepends.
      nftables is first-match-wins, so this inverted policy: a declared
      [accept tcp/22, drop] installed as [drop, accept] and SSH was blocked.
      No test pinned rule order, which is why it survived; one does now.

    • nftables: send_batch discarded mid-batch kernel errors (#199, #209).
      Transaction numbered its inner messages from its own counter starting at 1,
      unrelated to the socket's — so the recv loop's stated invariant ("inner seqs
      are strictly below begin_seq") was false, and its one-sided
      if seq > end_seq { continue } filter threw away the NLMSGERR for a
      rejected op
      . The kernel aborted the batch, its BATCH_END ACK never came,
      and the caller got an opaque 30-second Error::Timeout naming nothing. (This
      is the hang the nftables_reconcile integration tests were wrapped in a
      30s timeout to survive.) Conversely, an inner seq that happened to equal
      end_seq had its per-op ACK mistaken for the BATCH_END ACK, returning
      Ok(()) for a batch that never committed.

      send_batch now allocates every inner seq from the socket counter at send
      time, so the batch occupies one contiguous [begin_seq..=end_seq] window and
      every response matches exactly — the CLAUDE.md recv-loop rule 1. The socket
      is now the only seq authority; Transaction's counter is reduced to what it
      always really was, an allocator for the batch-local NFTA_SET_ID. Transaction
      messages also now carry a pid, which they never did. Separately (#209), a
      stray NLMSG_DONE no longer reports success: nfnetlink never terminates a
      batch with DONE, so the arm could only fire on a stale frame left by a
      cancelled dump (dumps and batches share the fd) — reporting an uncommitted
      batch as committed.

    • nftables: DeclaredSet / DeclaredSetBuilder are exported (#210). They
      are the type of the public field NftablesDiff::sets_to_add, so leaving
      them unexported made that field unnameable from outside the crate.

    • TC config fields that never reached the wire (#201, #213, #214, #215).
      Four cases of a value being accepted, stored, and then silently dropped —
      the compiler could not warn about any of them, because the field was
      assigned; it just was never read again.

      • Filter protocol and priority were discarded (#201). Every filter
        config (U32Filter, FlowerFilter, MatchallFilter, …) has public
        .protocol() / .priority() setters, and add_filter / replace_filter
        hardcoded ETH_P_IP and auto-priority instead of reading them. On a
        clsact/ingress hook that meant IPv6, ARP and VLAN traffic never hit the
        filter at all
        , and explicit priority ordering was ignored, so filters
        landed in an unintended evaluation order relative to operator-installed
        rules — with no error either way. FilterConfig gains defaulted
        protocol() / priority() accessors (defaults None, so external impls
        stay source-compatible, matching how set_chain was added); the shipped
        configs override them. Note MatchallFilter and FlowerFilter default to
        ETH_P_ALL, so filters that were silently IPv4-only now match everything,
        as their builders always claimed.
      • HtbQdiscConfig::r2q never reached the wire (#213). The field, its
        .r2q() setter and the r2q token in parse_params were all live, but
        TcHtbGlob::new() hardcoded rate2quantum: 10 with no override. This is
        the "silent skipping is a bug" contract violated one layer below the
        parser: the parser dutifully accepted the token and the encoder threw it
        away, leaving class quanta wrong for high-rate hierarchies. Adds
        TcHtbGlob::with_r2q.
      • TcMessage::is_filter() returned true for every qdisc (#214). It
        tested tcm_info != 0, but tc_fill_qdisc() sets tcm_info to the qdisc
        refcount — always ≥ 1. So every qdisc claimed to be a filter, and
        filter_protocol() handed back refcount bits reinterpreted as an
        ethertype. struct tcmsg is byte-identical for qdiscs, classes and
        filters; the only reliable discriminator is the nlmsg_type, which lives
        in the netlink header. TcMessage now carries it (via a defaulted
        FromNetlink::set_msg_type hook the dump and event paths call), and
        is_qdisc() / is_class() / is_filter() classify on it. They return
        false rather than guessing when the type is unknown. is_qdisc() is new.
      • TCA_STATS_BASIC_HW clobbered TCA_STATS_BASIC (#215). The software
        total (id 1) and the hardware-offloaded subset of it (id 7) shared a
        match arm, so the HW value — arriving last, since 7 > 1 — overwrote the
        total. On a NIC with tc offload, bytes() / packets() therefore reported
        only the offloaded portion, often 0 when nothing had been offloaded
        yet. Split into stats_basic / stats_basic_hw; bytes() and packets()
        keep returning the software total.
    • tc-string grammar now matches tc(8) (#203, #216, #217, #221). Three
      silent misreadings of values users already pass. See
      docs/migration_guide/0.24.0-to-0.25.0.md
      — these change behaviour with no compiler error.

      • mbps/kbps/gbps are BYTES per second, not bits (#203). iproute2
        matches its suffix table with strcasecmp, so lowercase mbps hits the
        MBps = 8_000_000 entry. nlink mapped the whole family to bits, so every
        such string was 8x too small — the exact bits-vs-bytes confusion the
        Rate newtype was introduced to kill. Suffix matching is now
        case-insensitive too. The unit test at util/rate.rs:520 was pinning
        the bug (100mbps == mbit(100)); it now asserts == mbit(800).
      • A bare number in a time slot is MICROseconds (#216). tc(8)'s internal
        unit is 1e6; nlink read a suffix-less number as seconds. So
        netem delay 100 meant 100 seconds — a 1,000,000x error that
        effectively stopped the interface passing traffic. m/min/h are no
        longer accepted (tc(8) has no minute suffix, and it made a mistyped ms
        silently 60,000x too long).
      • Negative rates and sizes error instead of becoming 0 (#217).
        f64 as u64 saturates, so get_rate("-5mbit") returned Ok(0) and the
        kernel then rejected it with a bare EINVAL rather than nlink's
        contract-mandated "htb: invalid rate ...". A negative duration was
        worse — Duration::from_secs_f64(-1.0) panics, reachable straight
        from NetemConfig::parse_params.
      • Percent::from_str doc (#221). It claimed "0.5" was "treated as a
        fraction"; the code reads it as 0.5 percent, which is the
        tc(8)-correct behaviour. The doc was wrong, not the code — but anyone
        who believed it had a 100x error in their loss rate. Points at
        Percent::from_fraction now.
      • get_size also accepts the binary kib/mib/gib/tib spellings its
        own rustdoc had always advertised and which it used to reject.
    • psched tick conversion — TBF, HTB and police were all mis-programmed
      (#191, #192, #193, #194, #218).
      Several TC wire fields that read like
      byte counts or microsecond durations are actually psched ticks (1 tick
      = 64 ns, so 15.625 ticks per µs). nlink never converted, and never read
      /proc/net/pschedgrep -ri psched returned zero hits. The kernel runs
      tc_tbf_qopt.buffer, tc_htb_opt.buffer and tc_police.burst through
      PSCHED_TICKS2NS(), so every one of them was wrong:

      • TBF (#192) wrote the burst as raw bytes. At rate 1mbit with the
        default 32 KiB burst the kernel read 32 768 ticks (~2.1 ms), computed
        max_size ~= 262 bytes, and tbf_enqueue() then dropped every packet
        larger than 262 bytes
        — a total blackhole for normal traffic. The
        correct value is 4 096 000 ticks, 125x larger.
      • HTB (#193) computed the buffer in microseconds and labelled it ticks,
        losing the 15.625 factor. A 100 mbit class landed with a token bucket of
        ~900 bytes — below one MTU, so it could never burst a full-size
        packet. Every RateLimiter, PerHostLimiter and PerPeerImpairer shape
        goes through this path. The hardcoded hz = 1000 is also gone: iproute2's
        get_hz() returns 1e9 on modern kernels, which makes the default burst
        exactly one MTU rather than rate/1000 bytes larger.
      • police (#194) wrote the burst as bytes and never emitted
        TCA_POLICE_RATE at all. tcf_police_init() calls qdisc_get_rtab() for
        any non-zero rate and fails the action when it returns NULL — which it
        does both when the attribute is absent and when the ratespec's cell_log
        is 0. Both held, so a policer with a rate could not install.
      • The HTB rate table was built with cell_log = 3 while the
        accompanying TcRateSpec left cell_log = 0, so qdisc_get_rtab()
        discarded TCA_HTB_RTAB/CTAB outright — 1024 wasted bytes per class
        add, and ATM/ADSL linklayer handling could never work.
      • The decode side (#218) took the kernel's tick-valued buffer straight
        into a field named and typed as bytes, so reading back a qdisc created by
        real tc yielded a burst off by 15.625 / rate, and reconcile
        considered a wildly wrong burst to be in sync.

      New public nlink::netlink::psched module: Psched (the cached
      /proc/net/psched clock, with the modern constants as an infallible
      fallback), plus tc_calc_xmittime / tc_calc_xmitsize / tc_calc_rtable
      — ports of iproute2's tc_core.c. tc_calc_rtable takes &mut TcRateSpec
      and stamps cell_log/linklayer/cell_align itself, so a table can no
      longer disagree with its own spec. TBF now also emits the byte-valued
      TCA_TBF_BURST/TCA_TBF_PBURST escape hatches iproute2 sends.

      These bugs survived because there was no test anywhere in the crate that
      asserted a single byte
      of a TC payload — the existing write_options
      tests built a message and threw it away. A writer and reader wrong in the
      same direction agree with each other. There is now a byte-level harness and
      a set of assertions pinned to values computed by hand from iproute2's
      algorithm, plus root-gated lab tests that round-trip through the kernel.

    • setns thread discipline (#185). Four related hardenings on the
      per-thread setns state management:

      • NetlinkSocket::new_in_namespace (and everything above it:
        Connection::new_in_namespace*, namespace::connection_for*) now runs
        the namespace-sensitive half — setns + socket(2) — on a dedicated
        worker thread that exits instead of restoring, the same discipline
        namespace::create has used since 0.19. The calling thread's namespace
        is never touched, so the restore-failed/poisoned-tokio-worker class is
        structurally gone, and a panic during socket creation can no longer
        strand the caller in the target netns. Error::NamespaceRestoreFailed
        is retained for compatibility but no longer produced.
      • The namespace::{get,set}_sysctl* helpers likewise run on a dedicated
        entered worker thread instead of setns-ing the calling thread.
      • Breaking: NamespaceGuard is now !Send — namespace membership is
        per-thread, so a guard dropped on a different thread than the one that
        entered would restore the wrong thread. Moving it across threads
        (including holding it across an .await on a multi-threaded runtime)
        is now a compile error; any code that did so was already incorrect at
        runtime.
      • NamespaceGuard::restore() no longer performs a second, redundant
        restore in Drop after the explicit call.
    • Missing-namespace errors are now classifiable via is_not_found()
      (#184).
      namespace::open{,_path,_pid}, namespace::enter{,_path}
      (and thus execute_in + the {get,set}_sysctl* helpers), and
      NetlinkSocket::new_in_namespace_path (and thus
      Connection::new_in_namespace_path, namespace::connection_for*, and
      NamespaceSpec::connection*) previously wrapped all open failures —
      including ENOENT — in stringly Error::InvalidMessage, so
      connection_for("gone") on a deleted namespace couldn't be handled with
      the documented recovery predicates. ENOENT now maps to the typed
      Error::NamespaceNotFound (carrying the path, the same shape
      delete_path uses), and every other open failure passes through as
      Error::Io with its errno intact, so is_permission_denied() & co.
      classify correctly too.

    • NamespaceWatcher::recv() now actually waits for events (#183). The
      inotify fd is always nonblocking (the inotify crate initializes it with
      IN_NONBLOCK), so the previous direct read_events call surfaced
      WouldBlock as an immediate Error::Io(EAGAIN) whenever no event was
      already queued — the watcher could never perform its documented
      "wait until an event is available" contract, and the module's own doc
      example errored on its first iteration. recv() is now built on the
      crate's tokio-native EventStream (whose stream feature was already
      enabled but unused), parking the task on the fd until an event arrives.
      Watch-transition logic (parent-dir ↔ netns-dir) is unchanged. Events
      whose namespace name is not UTF-8 are now skipped with a
      tracing::warn! instead of silently dropped. Regression-tested
      unprivileged (recv must park, not error) and under the privileged CI
      gate (create → Created, delete → Deleted round-trip).
      Breaking (semver-only): NamespaceWatcher/NamespaceEventStream no
      longer implement UnwindSafe/RefUnwindSafe — the tokio AsyncFd
      inside EventStream isn't unwind-safe, unlike the old raw fd + buffer.
      This is the 0.25.0 version-bump trigger for the cycle; no functional
      API changed.

    • namespace::create_path hardening (follow-up to #181, same cycle so
      no released behavior changes):

      • The failure rollback removes only directories that are still empty
        (remove_dir walking upward instead of remove_dir_all), so a failing
        create can no longer delete a marker a concurrent create_path
        installed under a shared ancestor.
      • The marker file is created with create_new — a lost race against a
        concurrent create (or an operator ip netns add) now fails with EEXIST
        instead of truncating the winner's marker.
      • The already-exists rejection is ErrorKind::AlreadyExists-shaped and
        Error::is_already_exists() matches it (previously a stringly
        InvalidMessage that no predicate classified), completing the
        detect-stale-and-retry loop is_namespace_path enables.
      • is_namespace_path documents that NSFS_MAGIC identifies nsfs, not
        the namespace type — a bind-mount of a PID/mount/UTS namespace also
        reports true.
    • nftables: a chain, table or flowtable whose attributes drifted produced
      an empty diff (#200, #208).
      diff() matched declared objects against the
      kernel by name only. Flipping a chain from policy(Accept) to
      policy(Drop) — or changing its hook, priority, type or device — was
      therefore a no-op: the chain existed, so nothing was scheduled, apply()
      committed an empty batch, and the operator was told the reconcile succeeded
      while the box stayed default-allow. Same shape for DeclaredTable::flags
      and for a flowtable's devices/priority/flags. NftablesDiff gains
      chains_to_modify and tables_to_modify; all five chain attributes, table
      flags and the flowtable triple are now compared and re-emitted.

    • nftables: Rule::reject() was a plain drop (#205). It pushed a bare
      NF_DROP verdict — no reject expression exists in the encoder at all — so
      a rule the caller declared as reject silently blackholed the packet and
      the client hung to its TCP timeout instead of failing fast. New
      Expr::Reject { reject_type, icmp_code } emits a real reject expression.

    • nftables: Expr::Redirect never redirected (#206). The encoder wrote
      NFTA_NAT_REG_PROTO_MIN — an attribute from the nat namespace — into a
      redir expression nest. redir has its own namespace (NFTA_REDIR_*),
      where that id means something else entirely, so the kernel ignored the port
      and the redirect landed nowhere. The NFTA_REDIR_* constants are now
      defined and the encoder uses them.

    • nftables: three SetKeyType datatype ids were wrong (#207). InetProto,
      Mark and IfIndex carried ids that did not match nft's enum datatypes,
      so a set declared with those key types was created with the wrong type and
      the kernel rejected (or silently mis-keyed) every element. EtherAddr's key
      length was 8 where a MAC is 6. Concatenated key types packed their
      components in the wrong order — nft's concat_subtype_add(t, n) shifts the
      accumulator left, so component 0 ends up in the high bits, not the low
      ones. Verified against nft --debug=netlink.

    • nftables: emit_tlv wrote little-endian attribute headers (#212). The
      writer used to_le_bytes for nla_len/nla_type while every reader in the
      crate parses them native-endian. Same value on x86_64, silently wrong on a
      big-endian target. Both sides are to_ne_bytes now.

    • dispatcher: an events() stream hung forever after a fatal recv error
      (#202).
      fail_all cleared the pending map but never event_listeners, so
      in-flight requests saw the error while every event stream kept its sender
      alive. An mpsc::Receiver yields None only once every Sender is gone, so
      the Poll::Ready(None) => take_fatal_error() arm was unreachable and the
      stream parked on Poll::Pending forever — no error, no termination, no
      resync. From the consumer's side it was indistinguishable from an idle network,
      which means any reconnect logic hanging off the error branch never ran.

    • dispatcher: shutdown() could be lost, leaking the driver task and its fd
      (#219).
      It signalled with Notify::notify_waiters(), which stores no permit
      and wakes only tasks already registered as waiters. The driver re-creates its
      notified() future each select! iteration, so it is unregistered for the
      whole of route_buffer and between iterations — and a Connection::drop
      landing in that window had its signal silently discarded. The driver then
      looped forever on a socket nobody could reach: the task never exited and the fd
      never closed, leaking one of each per connection under churn. notify_one()
      stores the permit.

    • socket: recv_batch never fanned out ENOBUFS (#220). recv_msg and
      poll_recv both tell the resync subscribers the kernel dropped multicast
      frames; the syscall_batch siblings (recv_batch / poll_recv_batch) did
      not, so under that feature a resync stream never learned it had missed anything
      and never re-dumped. Narrow in practice (the batch paths are reachable only
      from dumps), fixed for consistency.

    • ethtool: link speed was always None and duplex was garbage (#196). The
      kernel has a single ETHTOOL_A_LINKMODES_OURS = 3; nlink invented two
      variants there (Supported + Advertised), which shifted every later
      attribute id up by one. Speed then read DUPLEX — a 1-byte attribute, so
      the len >= 4 guard never matched and speed silently read as None on
      every interface, forever
      — while Duplex read MASTER_SLAVE_CFG and
      reported garbage. On the write path a speed u32 landed in an id the kernel's
      policy declares NLA_U8, so set_link_modes with a speed could only ever
      EINVAL. Both the request path and the multicast event decoder were affected.
      OURS carries both answers at once — supported modes in the bitset's mask,
      advertised modes in its value — so LinkModes::supported and ::advertised
      are both populated from it.

    • UAPI constant drift, and the gate that ends the class (#227–#232). Five
      more hand-transcribed enums had drifted from the kernel:

      • EthtoolStringSet omitted ETH_SS_FEATURES (4), so every set id from 4 up
        was one too low — asking for LinkModes actually requested PHY_TUNABLES
        (#227). It also invented two string sets that do not exist.
      • EthtoolLinkinfoAttr transposed TP_MDIX and TP_MDIX_CTRL, so the
        reported MDI-X status and its control setting were swapped (#228).
      • EthtoolCmd::PhyGet was 44, but the kernel inserted MODULE_FW_FLASH_ACT
        there and moved PHY_GET to 45 — so a PHY query would have sent the NIC a
        module firmware-flash activate (#229). Unused so far; fixed while free.
      • EthtoolStatsAttr omitted the kernel's PAD at index 1 and shipped values
        its own doc-comments described as wrong, while the connection module carried
        private shadow constants to work around its own public enum (#230).
      • nl80211 BssStatus started at 1 where the kernel starts at 0, so an
        associated BSS decoded as IbssJoined; devlink PortFlavour omitted
        UNUSED, so an unused port decoded as a PCI subfunction (#231).

      scripts/audit-uapi-constants.sh (#232) now diffs every #[repr(uN)] enum
      in the crate against /usr/include/linux on every push
      — 644 discriminants
      across 62 enums. Every enum must be classified: mapped to a kernel prefix, or
      declared nlink-only in the allowlist. An unclassified enum fails the build,
      because an unclassified enum is an unchecked one.

      It found two more drifts on its first run, neither of which anyone had
      reported:

      • EthtoolCoalesceAttr's CQE-mode pair. The kernel lists TX before RX
        here, unlike every other RX/TX pair in the enum; nlink had them the natural
        way round, and therefore backwards.
      • nl80211 ChannelWidth stopped at Width320 = 8, but the kernel had since
        inserted the five S1G narrow widths (1/2/4/8/16 MHz) at 8..=12 and pushed
        320 MHz out to 13. A Wi-Fi 7 320 MHz channel decoded as "unknown channel
        width", and an S1G 1 MHz channel decoded as 320 MHz — the widest possible
        answer to the narrowest possible channel.
    • sockdiag: a /0 prefix panicked the dump path (#204). ip_matches built
      its mask with u32::MAX << (32 - prefix_len), which for prefix_len == 0 is
      a shift by the full width: a panic in debug builds, and in release a shift
      count masked back to zero — leaving mask = u32::MAX, so "any address"
      became an exact match on 0.0.0.0 and matched nothing. src 0.0.0.0/0 is a
      legitimate ss filter and reaches this straight from user input, on the path
      every dump takes.

    • sockdiag: not src <v4> silently dropped v4-mapped IPv6 sockets (#198).
      The bytecode compiler lowered a negated host condition kernel-side. But the
      kernel's hostcond deliberately matches an AF_INET cond against a v4-mapped
      AF_INET6 entry, where nlink's client-side ip_matches calls that a
      cross-family non-match — so the kernel cond is a strict superset, and
      complementing a superset yields a subset. not src 10.0.0.0/8 therefore
      rejected the dual-stack listener's ::ffff:10.1.2.3 socket that the filter
      should have kept, and because the kernel had already dropped it, the
      client-side backstop could not bring it back. This broke the module's own
      stated invariant ("the compiler never emits a program that could
      under-approximate"). Negated host conds are no longer lowered at all; the
      conjunct is dropped, exact goes false, and the backstop decides. The
      Nnf::NotHost node is gone, so it cannot come back.

    • sockdiag: five documented InetFilter builders did nothing (#223).
      local_addr, remote_addr, interface, mark and cgroup_id were assigned
      by their builders and read by no code anywhere — so
      SocketFilter::tcp().local_addr("10.0.0.1") returned every TCP socket on the
      box
      , with no error and no warning. Same for UnixFilter::socket_types and
      path_pattern: .unix().stream().path("/run/foo") returned every unix socket
      of every type. The addresses now lower kernel-side as S_COND/D_COND; the
      rest are applied client-side.

    • sockdiag: FilterExpr::parse accepted trailing garbage (#224). The parser
      stopped at the first token it did not recognize and returned Ok, leaving the
      remainder unread. "sport = :22 && dport = :80" parsed as just
      sport = :22 — a superset of the sockets asked for, and marked exact, so
      even the client-side backstop would not narrow it. Full input consumption is
      now required. (or(/and( without a separating space are accepted.)

    • sockdiag: MemInfo.wmem_alloc and wmem_queued were swapped (#222). In
      struct inet_diag_meminfo, offset 4 is idiag_wmem = sk_wmem_queued and
      offset 12 is idiag_tmem = sk_wmem_alloc — nlink read them the other way
      round, so ss-style skmem: output had t and w transposed and
      total_mem() summed the wrong pair.

    • sockdiag: MemInfo.rcvbuf/sndbuf could never be populated (#197). They
      live only in INET_DIAG_SKMEMINFO, and no builder requested it — so they
      read as a flat 0 forever and downstream dashboards graphed a clean,
      believable, permanently-zero line. Adds InetFilterBuilder::with_sk_mem_info(),
      and the MEMINFO and SKMEMINFO arms now merge instead of the later one
      clobbering the earlier (with with_all_extensions(), whichever the kernel
      emitted last used to win). The five SKMEMINFO-only fields are now
      Option<u32>: None means you did not request it, which is a different
      statement from Some(0) — the conflation is what kept this invisible.

    • sockdiag: SocketFilter::mptcp() was a mislabelled TCP dump (#225).
      IPPROTO_MPTCP is 262 and does not fit inet_diag_req_v2.sdiag_protocol
      (a __u8); nlink sent the truncated byte (262 & 0xff == 6 == plain TCP) and
      never emitted the INET_DIAG_REQ_PROTOCOL attribute the kernel added for
      exactly this case. The result was every TCP socket on the box, each stamped
      Protocol::Mptcp
      . Relatedly, INET_DIAG_INFO was always decoded as
      struct tcp_info regardless of protocol — for SCTP it carries struct sctp_info, which is long enough to clear every length guard, so the decode
      produced plausible-looking garbage RTT and cwnd. The decode is now gated on
      the protocol.

    • sockdiag: every dump error claimed to come from sock_destroy (#226). The
      inet, unix and netlink dump paths all tagged kernel errors with the one
      operation that had not run — so an EINVAL on a malformed bytecode program
      was reported as a sock_destroy failure, which is precisely the wrong place
      to look.

    • DeclaredSet / DeclaredSetBuilder were unnameable (#210). They are the
      element type of the public NftablesDiff::sets_to_add field, but were
      never re-exported — so a downstream user could read the field and could not
      write down the type of what they read. Both are exported now.

    • CTA_TIMEOUT wrapped instead of saturating (#211). A conntrack timeout
      above u32::MAX seconds truncated modulo 2^32 — a very long timeout could
      wrap to a very short one. It now saturates. The nftables set-name bound is
      255, not 256 (NFT_NAME_MAXLEN counts the NUL).

    Downloads
  • v0.24.0 c7667ad0b4

    nlink 0.24.0 Stable

    p13marc released this 2026-07-03 11:57:48 +00:00 | 33 commits to master since this release

    Upgrading? See
    docs/migration_guide/0.23.0-to-0.24.0.md
    — a sockdiag/events/nftables depth release; the breaking surface is
    mechanical (#[non_exhaustive] on the three diff types, two new
    sockdiag struct fields) plus behaviour-visible bug fixes
    (InetExtension::mask() off-by-one, TcpInfo tail fields,
    subscribe_all() breadth).

    Added

    • Rule / Nexthop / NsId / MDB events (#165). NetworkEvent gained
      NewRule/DelRule (RuleMessage), NewNexthop/DelNexthop
      (Nexthop), NewNsId/DelNsId (NsIdMessage) and NewMdb/DelMdb
      (MdbEntry) variants with matching as_*/into_* accessors —
      policy-routing, nexthop-object, namespace-ID and bridge-multicast-DB
      changes now surface as typed events. New RtnetlinkGroup::{Nexthop, Mdb} subscription variants plus RTNLGRP_IPV4_NETCONF /
      RTNLGRP_IPV6_NETCONF / RTNLGRP_MDB / RTNLGRP_NEXTHOP /
      RTNLGRP_BRVLAN group constants.
    • Socket → process attribution (#162). sock_diag deliberately
      reports no PID, so every ss -p-style consumer re-implemented the
      /proc/<pid>/fdsocket:[inode] scan. It now lives in the library
      (feature sockdiag): SocketOwnerMap::scan() is one amortized
      /proc walk, resolve(inode) yields ProcessRef { pid, start_time, comm, fd }start_time (stat field 22) makes (pid, start_time)
      a PID-reuse-safe identity. CgroupPathMap::scan() is the companion
      join for the already-exposed InetSocket.cgroup_id: cgroup-v2 ID →
      cgroupfs path (resolve_relative yields system.slice/foo.service
      shapes for unit/container joins). Both are best-effort snapshots —
      short-lived sockets and unreadable (other users') processes resolve
      to nothing; kernel 6.5+ BPF socket iterators are the race-free
      successor, this is the unprivileged baseline. nlink-ss -p now
      consumes the library version; new example
      sockdiag_socket_owners.
    • Typed nftables rule-expression decoding (#164). RuleInfo
      previously exposed only raw expression_bytes; consumers wanting
      per-rule counters hand-parsed NFTA TLVs. New read-side RuleExpr
      enum (Counter { packets, bytes } with live kernel values,
      Verdict, Meta, Cmp, Immediate, Payload, and a lossless
      Unknown { name, data } fallback) plus RuleInfo::expressions()
      and the RuleInfo::counter() -> Option<(packets, bytes)> shortcut.
      Decoding is lazy and infallible — anything undecodable demotes to
      Unknown verbatim; expression_bytes and the diff's byte-wise
      body comparison are unchanged.
    • Ergonomics batch (#169).
      • del_link_if_exists / del_route_v4_if_exists / _v6 /
        del_address_if_exists / del_qdisc_if_exists /
        del_filter_if_exists on Connection<Route>Ok(bool)
        instead of an error when there's nothing to delete, mirroring
        the nftables del_*_if_exists family.
      • WireGuard device bootstrap: WireguardConfig::diff reports
        absent declared devices in the new
        WireguardConfigDiff::devices_to_add instead of hard-failing;
        WireguardConfig::ensure_devices(&Connection<Route>) creates the
        missing links idempotently; facade::apply::wireguard* wires the
        two together so a bare WireguardConfig applies end-to-end.
      • WireguardConfigDiff serializes (feature serde) for typed
        drift reporting — public keys as base64, secret material redacted
        to presence booleans, keepalive as whole seconds.
      • Namespace-spec facade variants: Stack::{apply,diff}_in,
        facade::{apply,diff}::{network,nftables,wireguard}_in take a
        NamespaceSpec (named / path / PID — container support); new
        NamespaceSpec::connection_async for GENL families.
      • RateLimiter::reconcile{,_dry_run,_with_options} — idempotent
        convergence mirroring PerHostLimiter::reconcile (zero kernel
        calls when nothing drifted; covers the egress HTB shape, the IFB
        device, the ingress hook + redirect, and the IFB-side HTB shape).
      • StackDiff::change_count() / StackApplyReport::change_count();
        NETNS_RUN_DIR re-exported at the crate root. (LinkChanges
        already implemented Display.)
    • Per-socket TCP byte-rate tracking (#171).
      SocketRateTracker::ingest(snapshot, at) turns consecutive TCP
      dumps into per-socket goodput deltas keyed by the kernel socket
      cookie (never the inode — inodes get reused across poll intervals):
      SocketRate { cookie, tx_goodput_bps, rx_goodput_bps, retrans_ratio }. Bounded (cookies absent for N ingests evict),
      robust to counter regressions and same-instant double ingests. The
      architectural constraints are now documented on the sockdiag
      module: TCP-only (UDP diag has NO cumulative byte counters —
      rqueue/wqueue are queue depths, never diff them), goodput ≠ wire
      throughput, short flows invisible to polling (BPF socket iterators
      are the successor).
    • Full INET_DIAG_BC bytecode compiler + kernel-side expression
      filtering (#163).
      The sockdiag bytecode compiler now lowers the
      complete FilterExpr grammar — src/dst host conditions
      (inet_diag_hostcond, v4/v6/prefix), or/not (De Morgan
      push-down + jump structure; the kernel has no NOT opcode), !=,
      and state (hoisted into the request header's idiag_states
      there is no state opcode) — via the new
      compile_filter(&FilterExpr) -> CompiledFilter { states, bytecode, exact }. New InetFilter::expr + builder filter_expr(): the
      dump path compiles the expression once, runs it kernel-side, and
      applies the client-side matches() backstop whenever the lowering
      over-approximates (never under-approximates). nlink-ss
      expressions now pre-filter kernel-side. Emission discipline makes
      the kernel's inet_diag_bc_audit pass structural (every yes =
      own size); a faithful audit-oracle reimplementation runs over every
      compiled program in the unit tests, and the full matrix (or / not /
      hostconds v4+v6 / prefix / state hoist / nesting) was verified
      against a live kernel.
    • Congestion-control info structs (#163). INET_DIAG_VEGASINFO /
      DCTCPINFO / BBRINFO now parse into typed
      CcInfo::{Vegas,Dctcp,Bbr} on the new InetSocket.cc_info
      (BBR bandwidth assembled from the wire's lo/hi split, bytes/sec).
      Request with with_cc_info() — one kernel extension bit gates all
      three; which arrives depends on the socket's CC algorithm.
    • Documentation & examples for the 0.24 surface. New recipe
      per-process-bandwidth
      (attribution + rate tracking + kernel-side filtering end-to-end);
      new runnable examples sockdiag_rate_top (per-process bandwidth
      top), sockdiag_filter_expr (compile_filter introspection +
      CcInfo display) and config_stack (the Stack facade end-to-end
      incl. change_count() and the --apply runner pattern); events_monitor now uses subscribe_all() and
      prints the rule/nexthop/nsid/MDB families; nftables_firewall
      decodes dumped rule expressions and per-rule counters;
      ratelimit_simple demonstrates RateLimiter::reconcile
      idempotence. docs/library.md gained a Socket Diagnostics chapter
      and WireGuard-bootstrap / reconcile / event-coverage notes; README
      and CLAUDE.md refreshed to match.

    Fixed (sockdiag)

    • InetExtension::mask() was off by one (#163). The kernel
      checks ext & (1 << (attr - 1)), but mask() returned 1 << attr
      — so with_mem_info() actually requested INET_DIAG_INFO,
      with_tcp_info() requested VEGASINFO, and so on, one extension
      over. Every variant's bit is now pinned by a unit test.

    • TcpInfo tail fields were never populated (#171).
      parse_tcp_info stopped at byte 168, so bytes_sent,
      bytes_retrans, busy_time, rwnd_limited, sndbuf_limited,
      delivered, delivered_ce, dsack_dups, reord_seen,
      rcv_ooopack and snd_wnd — all declared on TcpInfo — silently
      read 0 on every kernel. The parser now decodes through byte 232
      (offsets pinned to uapi linux/tcp.h), with rule-1 guards so both
      older (shorter) and newer (longer) kernel structs parse cleanly.

    Changed (breaking)

    • subscribe_all() now joins every typed-event group (#165)
      previously it omitted NsId, Ipv4Rule and Ipv6Rule (and the new
      Nexthop/Mdb groups didn't exist), so those changes were silent
      under the "all" subscription. Consumers matching exhaustively on
      NetworkEvent were already forced to handle unknown variants
      (#[non_exhaustive]); the new variants simply start arriving.
    • ConfigDiff, StackDiff and WireguardConfigDiff are now
      #[non_exhaustive] (#165)
      — literal construction and functional-
      update syntax outside nlink no longer compile. Construct them via
      NetworkConfig::diff / Stack::diff / WireguardConfig::diff
      (the only ways that ever made sense); this lets future diff
      categories (e.g. #169's WireGuard device bootstrap) land without a
      major bump.

    Fixed

    • XFRM dumps no longer spam the kernel log (#160).
      Connection::<Xfrm>::get_security_associations() /
      get_security_policies() (and the stream_sas() / stream_sps()
      streaming variants) appended a zeroed xfrm_usersa_info /
      xfrm_userpolicy_info struct as the dump body. The XFRM dump
      callbacks parse the whole body as netlink attributes (hdrlen = 0),
      so the kernel logged a ratelimited netlink: 224 bytes leftover after parsing attributes warning (168 for policies) on every poll.
      Dump requests are now header-only, matching ip xfrm state/policy.
      Results were and remain correct — this kills the log noise.
    Downloads
  • v0.23.0 6b34c0fb95

    v0.23.0 Stable

    p13marc released this 2026-06-30 21:24:32 +00:00 | 53 commits to master since this release

    Large, mostly-additive release closing the #134–#137 epic. On crates.io: nlink + nlink-macros.

    Highlights

    • Full nl80211 read audit — PHY/wiphy band capabilities (with split-dump reassembly), scan/BSS, station-info, channel survey; two attribute id-drift wire fixes.
    • XFRM (IPsec) monitorConnection<Xfrm>: EventSource (ip xfrm monitor equivalent).
    • Declarative nftables setsDeclaredSet + element-level diff inside the atomic batch (also fixes an ERANGE that broke every set create).
    • Reflector / watch-cacheStore<K,V> + ReflectExt::reflect, a kube-rs-style cache over the resync event stream (no new dependency).
    • NetworkConfig declarative purge — opt-in full reconcile, fenced to global-scope addresses on managed interfaces + static/boot main-table routes (links/qdiscs never purged).
    • JSON Schema for NetworkConfig via the new schemars feature.
    • Opt-in dispatcher mode is feature-complete — Connection::with_dispatcher() lets events, streaming dumps, and requests coexist on one connection.
    • Ergonomic newtype conversions — lossless From/Into (Bytes⇄u64, TcHandle⇄u32, …; Rate deliberately excluded to keep its unit-safety guarantee).
    • Bridge BRIDGE_VLANDB_ENTRY/GOPTS, net_shaper group shaping, WireguardConfig::{from_wg_quick,client}, TC pedit munge DSL, Connection<Ovpn>::attach_socket, GENL command unification.

    Breaking changes (mechanical)

    • Chain::new(table, name) now returns Result (add ?; nftables names are the validated TableName/ChainName newtypes — &str callers otherwise unchanged).
    • Parsed LinkStats read via accessors (stats.rx_bytes()).
    • SurveyInfo/StationInfo/PhyInfo/Band/Frequency are now #[non_exhaustive].

    See the migration guide and the full changelog.

    Downloads
  • v0.22.0 28ea3a8a41

    nlink 0.22.0 Stable

    p13marc released this 2026-06-28 23:32:53 +00:00 | 107 commits to master since this release

    Changed

    • All demo binaries are now nlink- prefixed. The eight that
      previously built as the bare system-tool name (ip, tc, ss,
      nft, bridge, wg, wifi, devlink) now produce nlink-ip,
      nlink-tc, … matching nlink-config/nlink-ethtool, so a demo
      binary never shadows the real iproute2/iw/ethtool tool on PATH.
      Cargo package names were already nlink-*; only the [[bin]] name
      changed (and the CLI tests' CARGO_BIN_EXE_* references). Invoke as
      nlink-ip link show (or cargo run -p nlink-ip -- link show).

    Added

    • sockdiag: INET_DIAG_REQ_BYTECODE kernel-side filtering (#120) —
      closes #120 and the #115 coverage epic.
      The inet dump now lowers a
      filter's exact source/destination port (InetFilter::local_port /
      remote_port, set by ss --sport/--dport) into an
      inet_diag_bc_op program attached as INET_DIAG_REQ_BYTECODE, so the
      kernel pre-filters and only matching sockets cross into userspace
      (previously the dump returned every socket and the bin discarded most).
      The new public sockdiag::bytecode module exposes for_ports (used by
      the dump path) and a general compile(&FilterExpr) that also lowers
      sport/dport comparisons (=/</<=/>/>=) combined with
      and; unsupported predicates (!=, address/prefix, state, any
      or/not) yield None and fall back to the full dump. Any
      client-side filtering stays as the correctness backstop, so the
      pre-filter can only reduce traffic, never change results — and a
      malformed program is rejected by the kernel's inet_diag_bc_audit
      with EINVAL (loud), not mis-applied. Byte-exact layout is unit-tested;
      on-kernel semantics are validated by a require_root!() integration
      test under the privileged CI (now built with lab,sockdiag). Purely
      additive — no public type changed shape.

    • ethtool: RSS read (#119) — closes #119.
      Connection::<Ethtool>::get_rss reads Receive Side Scaling settings
      (ETHTOOL_MSG_RSS_GET) for the default or a given context: the
      indirection table (ETHTOOL_A_RSS_INDIR, an array of receive-queue
      indices), the hash key (ETHTOOL_A_RSS_HKEY), and the raw hash-function
      bitmask (ETHTOOL_A_RSS_HFUNC). New EthtoolRssAttr enum + Rss type.
      Surfaced as nlink-ethtool -x eth0. With FEC-set + module-EEPROM, all
      three #119 ethtool gaps are landed.

    • ethtool: module-EEPROM read (#119).
      Connection::<Ethtool>::get_module_eeprom reads raw SFP/QSFP module
      EEPROM bytes (ETHTOOL_MSG_MODULE_EEPROM_GET) via a
      ModuleEepromRequest (offset/length + page/bank/I2C-address setters,
      length bounded 1..=128). A new ethtool_get_with helper sends a
      parameterized doit GET (request flag, no dump) and returns the
      single reply, parsing out ETHTOOL_A_MODULE_EEPROM_DATA. Surfaced as
      nlink-ethtool -m eth0 (hex dump). Second of #119's three ethtool gaps.

    • ethtool: set_fec FEC setter (#119). Connection::<Ethtool>::set_fec
      configures Forward Error Correction via a FecBuilder
      (ETHTOOL_MSG_FEC_SET): .mode("rs") selects encodings (a named
      ETHTOOL_A_FEC_MODES bitset), .auto(bool) toggles negotiation.
      Mode names are the kernel-labelled bitset names get_fec reports
      (correct-by-construction round-trip); common spellings
      (off/none/rs/baser/llrs) are normalised. Surfaced as
      nlink-ethtool set-fec eth0 rs --auto off. First of #119's three
      ethtool gaps.

    • TC action: gate (IEEE 802.1Qci PSFP) typed config (#118) — closes
      the modelable side of #118.
      GateAction admits packets only during
      the open windows of a cyclic time schedule (TSN per-stream gating):
      priority, base-time/cycle-time/cycle-time-ext (Durations, ns
      on the wire, tc(8) time strings accepted), clockid
      (TAI/REALTIME/MONOTONIC/BOOTTIME or numeric), and a repeatable
      sched-entry open|close <interval> [<ipv> [<max-octets>]] schedule.
      The entry list is built as nested TCA_GATE_ONE_ENTRY items under
      TCA_GATE_ENTRY_LIST (gate-open is an NLA_FLAG). New action::gate
      wire module. 19 action parsers. xt/ipt is intentionally not
      modelled
      : act_ipt requires a TCA_IPT_TARG xt_entry_target blob
      built via libxtables, with no pure-netlink construction path — a
      hook/index-only config would be rejected with EINVAL, so it's
      documented as out-of-scope under #118 rather than shipped broken.

    • TC action: ife (Inter-FE) typed config (#118). IfeAction
      tunnels skb metadata (mark / priority / tcindex) between forwarding
      elements: encode writes selected metadata into an IFE Ethernet
      header (with optional dst/src MAC + type ethertype overrides),
      decode reads it back. Each metadata item is allowed (carry the
      skb's value) or used with an explicit override. New action::ife
      wire module (tc_ife = tc_gen + flags; metadata rides in the nested
      TCA_IFE_METALST list). 18 action parsers. Second of #118's actions.

    • TC action: ctinfo typed config (#118). CtinfoAction restores
      conntrack metadata into the packet — a DSCP value (under a state mask)
      into the IP DS field via dscp <mask>[/<statemask>], and/or the
      connection mark into skb->mark via cpmark [<mask>] (bare cpmark
      uses the full mask), scoped by zone. Masks parse as 0x-hex or
      decimal; at least one of dscp/cpmark is required. New
      action::ctinfo wire module (tc_ctinfo = tc_gen header; params
      ride as separate attrs). 17 action parsers. First of #118's four
      actions.

    • TC filter: rsvp / rsvp6 classifier typed config (#117).
      RsvpFilter matches flows by session (destination) and sender
      (source) address, narrowed by ipproto and tunnelid, with
      classid/flowid and chain. The address family is inferred from the
      addresses — an IPv6 session/sender selects the rsvp6 kernel
      classifier, IPv4 selects rsvp — so a single config + both bin
      dispatch arms cover the pair. New filter::rsvp wire module
      (tc_rsvp_pinfo/tc_rsvp_gpi). Port-level matching is not
      modelled
      : session <addr>/<port> uses a protocol-specific
      general-port-info offset, so an embedded /<port> is rejected with a
      clear not-modelled error rather than emitting a zeroed/incorrect GPI;
      the tunnel <id> skip <n> creation form is likewise rejected. 11
      filter parsers total.

    • TC qdisc: atm (sch_atm) typed config (#116) — closes the qdisc
      side of #116.
      The ATM qdisc is classful and takes no qdisc-level
      options, so AtmConfig is a unit config (tc qdisc add … root atm
      instantiates the classful qdisc). VC binding lives in the classes
      (TCA_ATM_FD + friends), which require the file descriptor of a live,
      application-owned ATM socket — no portable/testable surface — so nlink
      does not model an ATM class config; the rustdoc points at a hand-rolled
      MessageBuilder for that case. 35 qdisc parsers total. With this,
      choke/gred/atm/pfifo_fast from #116 are all landed.

    • TC qdisc: gred (Generic RED) setup-phase typed config (#116).
      GredConfig models the GRED setup message — virtual-queue count
      (DPs, 1..=16), default VQ, GRIO flag, and global byte limit — sent
      as struct tc_gred_sopt under TCA_GRED_DPS (+ TCA_GRED_LIMIT). This
      is the mandatory first step that installs the virtual-queue table; a
      setup-only GRED is a complete, valid operation. Per-VQ RED
      parameterization is deliberately not modelled
      : it requires the
      256-byte RED stab probability table the kernel demands under
      TCA_GRED_STAB (which plain RedConfig also sidesteps), so
      parse_params rejects per-VQ tokens (min/max/avpkt/bandwidth/
      DP/probability/prio/…) with a clear not-modelled error rather than
      emitting a message the kernel would refuse — tracked as a #115 follow-up.
      34 qdisc parsers total. New qdisc::gred wire module.

    • TC qdiscs: choke and pfifo_fast typed configs (#116). Two
      more qdisc kinds reach typed-first parity (33 qdisc parsers total).
      ChokeConfig is a RED-family AQM that shares struct tc_red_qopt
      with RedConfig and adds CHOKe differential dropping; its
      parse_params mirrors RED (limit/min/max/probability/ecn/
      harddrop, with avpkt/burst/bandwidth reported as not-modelled
      rather than silently dropped). PfifoFastConfig is a unit config —
      pfifo_fast's bands and priomap are hardcoded kernel-side, so it
      accepts no options; adding it explicitly restores the kernel-default
      qdisc after a root replacement. Both wired into the tc bin
      dispatch + recognised-kinds list. First of the #115 coverage epic.

    • nft bin: declarative reconcile/diff over the NftablesConfig
      engine (#109).
      New nft reconcile <file> reads a desired-state
      ruleset (the same add table/add chain/add rule grammar as
      apply), folds it into an NftablesConfig, diffs it against the
      live ruleset, and applies the minimal change set via the library's
      diff/apply/apply_reconcile engine (the same one the config
      binary uses for interfaces). nft diff <file> is the read-only
      preview; --dry-run / --no-retry tune reconcile. Because it is
      desired-state, delete/flush lines are rejected (removal is
      inferred from the diff) — nft apply remains the imperative,
      single-transaction path. The rule-spec parser is shared with
      apply (STRICT: an unrecognized token fails the whole parse, never
      a silent drop). This is deliberately not a serde-Deserialize
      path: nft rule bodies are low-level VM byte expressions, so the
      human-facing format is the rule DSL lowered through the typed
      builders. Parser unit tests cover the fold, the desired-state
      rejections, and strict error propagation.

    • library: strongly-typed, round-trippable NetworkConfig (#108).
      The declarative NetworkConfig tree was Serialize-only; it now
      also implements a validating serde::Deserialize behind the
      serde feature, so the typed config round-trips through YAML/JSON
      directly (no stringly intermediate schema). The human-facing forms
      are idiomatic: addresses/routes round-trip as CIDR strings (route
      destinations as the default keyword for 0.0.0.0/0 / ::/0),
      MACs as aa:bb:cc:dd:ee:ff. Validation is preserved rather than
      bypassed — deserialization goes through the same parse paths as the
      builders (via serde's try_from/into conversion attributes), so
      an out-of-range prefix, a malformed gateway, or a bad MAC is a
      deserialize error, not a silently-accepted struct. New
      high-level helpers NetworkConfig::{from_json_str, to_json_string, to_json_string_pretty}. The serde feature now also pulls
      serde_json for these. Additive (new trait impls + methods);
      cargo-semver-checks clean.

    • ss/library: AF_PACKET socket diagnostics (#29). query_packet
      was a stub returning an empty list; it now issues a real
      PACKET_DIAG (SOCK_DIAG_BY_FAMILY with sdiag_family = AF_PACKET)
      dump and parses packet_diag_msg + PACKET_DIAG_INFO/UID/FANOUT/
      MEMINFO into PacketSocket (bound interface, protocol, type, uid,
      fanout, recv/send queues). New Connection<SockDiag>::query_packet_sockets
      convenience method, surfaced in the ss bin as -0/--packet.

    • ethtool/library: Forward Error Correction read (#29). New
      Connection<Ethtool>::get_fec/get_fec_by_name over
      ETHTOOL_MSG_FEC_GET: configured-modes list, auto-negotiation flag,
      and the active FEC mode (raw ETHTOOL_LINK_MODE_FEC_* bit). Surfaced
      as ethtool fec. Read-only for now — a typed FEC setter needs the
      kernel-specific mode bit-names and is left to a follow-up.

    • ethtool/library: Energy-Efficient Ethernet get/set (#29). New
      Connection<Ethtool>::get_eee/set_eee (+ _by_name) over
      ETHTOOL_MSG_EEE_{GET,SET}: read active/enabled/TX-LPI state, the
      TX-LPI timer, and the advertised/peer EEE link-mode lists; set
      enabled/tx_lpi/tx_lpi_timer via an EeeBuilder. Surfaced in
      the ethtool bin as eee (show) and set-eee (set).

    • ethtool/library: Wake-on-LAN get/set (#29). New
      Connection<Ethtool>::get_wol / set_wol (plus _by_name)
      over ETHTOOL_MSG_WOL_{GET,SET}: read supported/active modes + the
      SecureOn password, and enable modes by name (phy, ucast, mcast,
      bcast, arp, magic, magicsecure, filter) via a WolBuilder.
      Unknown mode names are rejected before the request is sent. Surfaced
      in the ethtool bin as wol/-w (show) and set-wol/-W (with a
      none sentinel to disable all), printing the p u m b a g s f/d
      flag string ethtool(8) uses.

    • tc/library: mpls and skbmod actions (#29). Two new typed
      TC actions in the standard *Action + parse_params shape, wired
      into tc action add … mpls|skbmod. MplsAction pushes/pops/modifies
      an MPLS shim or decrements its TTL (label, tc, ttl, bos,
      protocol) via struct tc_mpls. SkbmodAction rewrites L2
      src/dst MAC and/or ethertype, or swaps MAC (set smac|dmac|etype,
      swap mac) via struct tc_skbmod. Brings the typed-action count to
      16. (The remaining ife/gate/ctinfo/xt actions stay deferred —
      metadata-encap / time-gated-schedule / iptables-target wrappers that
      are complex or niche.)

    • tc/library: tcindex filter (#29). New TcindexFilter typed
      config (hash, mask, shift, fall_through/pass_on, classid,
      chain) over the TCA_TCINDEX_* attributes, wired into tc filter add … tcindex. The canonical companion to the dsmark qdisc for
      DiffServ classification. Brings the typed-filter count to 10. (rsvp
      remains deferred — a session/sender classful filter that is rarely
      used and needs the tc_rsvp_pinfo selector struct.)

    • wifi/library: del_station (kick an AP client) (#29). New
      Connection<Nl80211>::del_station(iface, mac) /
      del_station_by_index send NL80211_CMD_DEL_STATION to
      deauthenticate an associated station, surfaced as nlink-wifi del-station <iface> <mac>. Complements the existing read-only
      station listing for AP-mode management.

    • tc/library: hhf and dsmark qdiscs (#29). Two more typed
      qdisc configs wired into tc qdisc add … hhf|dsmark. HhfConfig
      drives the Heavy-Hitter Filter (limit, quantum, hh_limit,
      reset_timeout, admit_bytes, evict_timeout, nonhh_weight) over
      the individual TCA_HHF_* attributes (timeouts sent as microseconds,
      matching the kernel's usecs_to_jiffies). DsmarkConfig drives the
      DiffServ-marking qdisc's indices/default_index/set_tc_index
      (per-index mask/value remain class-level). Brings the typed-qdisc
      count to 31.

    • tc/library: sfb and multiq qdiscs (#29). Two more typed
      qdisc configs in the standard *Config + builder + strict
      parse_params shape, wired into tc qdisc add … sfb|multiq.
      SfbConfig drives Stochastic Fair Blue (limit, rehash, db,
      max, target, increment, decrement, penalty_rate,
      penalty_burst) packed into struct tc_sfb_qopt under
      TCA_SFB_PARMS. MultiqConfig drives the band-per-tx-queue qdisc —
      parameterless (the kernel derives band count from the device's
      tx-queue count), writing a zeroed struct tc_multiq_qopt. Brings the
      typed-qdisc count to 29.

    • tc/library: cbs and skbprio qdiscs (#29). Two new typed
      qdisc configs with the standard *Config + fluent builder +
      strict parse_params shape, wired into tc qdisc add … cbs|skbprio.
      CbsConfig drives the IEEE 802.1Qav Credit-Based Shaper (AVB/TSN):
      idleslope/sendslope (kbit/s), hicredit/locredit (bytes), and
      offload packed into struct tc_cbs_qopt under TCA_CBS_PARMS.
      SkbprioConfig drives the SKB-priority queue (limit packets).
      Brings the typed-qdisc count to 27.

    • tc bin: filter parity — show across all parents, --chain,
      partial delete (#19).
      tc filter show dev X now lists filters
      across every parent (root, ingress, clsact, …) instead of silently
      defaulting to root only — previously ingress/clsact filters
      vanished from the listing; pass --parent <handle> to narrow.
      filter add/replace/change … --chain N sets the filter chain index
      (tc(8) chain N), threaded generically through every kind via a new
      defaulted FilterConfig::set_chain trait method (additive; all nine
      shipped configs override it). filter del regained partial form:
      supplying both --protocol and --prio deletes one filter, supplying
      neither flushes every filter on the parent (tc filter del dev X parent 1:); a half-specified tuple is rejected rather than guessed.

    • nlink-config bin: apply --reconcile + higher-fidelity capture
      (#22).
      apply --reconcile drives the library's apply_reconcile
      (recomputes the diff each attempt, bounded retry on transient kernel
      contention); mutually exclusive with --dry-run. capture now decodes
      qdisc options (htb/tbf/netem/fq_codel parameters) into the
      options map instead of leaving it empty, and unmodelled route/rule
      types are warned about and preserved verbatim rather than silently
      dropped to None. The duplicated OutputFormat enum (capture +
      example) is unified in schema.rs.

    • nft bin: idempotent deletes + richer list rules (#21). nft delete table/chain/rule/set now uses the *_if_exists library
      variants, so deleting a missing object is a clean no-op ("… did not
      exist (no-op)") instead of an error — matching declarative teardown
      expectations. Added the missing del_set_if_exists library method.
      list rules now also surfaces each rule's position, comment, and
      expression-payload size (text + JSON), rather than only the handle;
      full expression disassembly stays out of scope (the library keeps rule
      expressions as raw bytes by design).

    • ip bin: macsec add/set/del (TX/RX SA + RX SC) (#17). The
      ip macsec object was show-only; it now mutates MACsec secure
      associations over the existing Connection<Macsec> GENL API. add tx --an <0-3> --key <hex> [--key-id <hex>] [--pn N] [--active on|off]
      installs a TX SA; add rx --sci <hex|dec> creates an RX secure
      channel, and adding --an targets an RX SA within it; set
      updates an existing SA; del removes the SA/SC. The device is
      resolved to an ifindex up front so the *_by_index calls are
      namespace-safe, and --an > 3 / odd-length hex are rejected before
      reaching the (panicking) builder.

    • bridge bin: monitor command (#25). Streams bridge FDB changes
      in real time — bridge monitor subscribes to RTNLGRP_NEIGH and
      prints NewFdb/DelFdb events (text or --json, with -t for
      timestamps), mirroring bridge monitor fdb. (MDB-event monitoring is
      deferred until the library models RTM_*MDB notifications.)

    • bridge bin: fdb add --extern-learn and fdb show --brport (#25).
      fdb add … --extern-learn marks an entry NTF_EXT_LEARNED (a
      user-space control plane owns it; the kernel won't age/overwrite it) —
      backed by a new FdbEntryBuilder::extern_learn() library setter. fdb show <bridge> --brport <port> lists only the entries learned on a
      given bridge port via the existing get_fdb_for_port, mirroring
      bridge fdb show br <dev> brport <port>.

    • ip bin: real netns set <name> <nsid> and nsid display in netns list (#17). netns set was a stub that printed "Setting…" and
      returned Ok without doing anything; it now sends RTM_NEWNSID (new
      Connection::set_nsid library method) to assign the id — or
      auto-allocate on auto/-1, reading the chosen id back. netns list
      previously always showed no id because get_namespace_id returned
      None; it now queries each namespace via the existing get_nsid
      (RTM_GETNSID), so (id: N) appears for namespaces that have one.

    • tc bin: implement qdisc show --invisible (#19). The flag was
      parsed but ignored, so tc qdisc show invisible behaved identically
      to a plain show. It now sets TCA_DUMP_INVISIBLE on the
      RTM_GETQDISC dump (via the new Connection::get_qdiscs_full
      library method), so the kernel also returns the auto-created default
      qdiscs it normally hides.

    • wg bin: addconf config-file apply (#23). Completes the
      setconf/syncconf/addconf trio over WireguardConfig. addconf
      is the additive form — it appends the file's peers/settings without
      removing existing peers. (Because the library's apply never prunes
      unlisted peers, it currently coincides with setconf; the two diverge
      once setconf gains real unlisted-peer pruning.)

    • ss bin: implement -O/--oneline (#20). The flag was parsed and
      stored but never read, so each socket's extended/memory/TCP-info/timer
      detail always wrapped onto \t-prefixed continuation lines. With
      --oneline those segments are now space-joined onto the socket's row,
      so each socket occupies exactly one line (matching real ss -O).

    • ethtool bin: surface already-parsed link detail in show (#27).
      show now prints the data the library already decoded but the binary
      dropped: PHY address, MDI-X status (+ configured control), Signal
      Quality Index (sqi/sqi_max), the extended link state + substate
      (why a link is down), and pause-frame statistics in show pause. All
      appear in both the text and --json output, with stable string
      mappings for the MDI-X / ext-state enums.

    • wifi bin: show more station detail (#24). wifi station now
      prints the already-parsed signal_avg_dbm, connected_time_secs, and
      inactive_time_ms fields (previously dropped), alongside the existing
      signal / bitrate / byte counters.

    • wg bin: incremental allowed-ips + private-key unset (#23). wg set … --allowed-ips now honors the + prefix to add ranges without
      replacing the peer's set (a plain list still replaces, matching real
      wg set); a - prefix (single-range removal) returns a clear "not
      modelled" error since the library lacks the WGALLOWEDIP remove flag, and
      mixing plain/+ is rejected. wg set … --private-key /dev/null (or an
      empty file) now unsets the device key (all-zero key = remove) instead of
      failing the length check.

    • ss / nft bins: extend unit coverage of the pure parsers (#20,
      #21).
      Added nft tests for parse_family (+ aliases), parse_hook
      (incl. the netdev/inet ingress split), and parse_cidr; and ss
      tests for format_addr (unspecified-address and zero-port rendering).

    • ip bin: ip link set <dev> --netns <name|pid> (#17). Moves a
      device into another network namespace, wiring the existing
      set_link_netns_by_index / set_link_netns_pid_by_index library APIs.
      The ifindex is resolved once up front so the move is namespace-safe; a
      numeric argument is treated as a target PID, anything else as a named
      netns under /var/run/netns (mirrors iproute2).

    • wg bin: setconf / syncconf config-file apply (#23). A new
      bin-local parser reads the kernel-level WireGuard config-file format
      ([Interface] PrivateKey/ListenPort/FwMark, [Peer]
      PublicKey/PresharedKey/Endpoint/AllowedIPs/PersistentKeepalive) into the
      library's WireguardConfig, then wg setconf <if> <file> applies it and
      wg syncconf <if> <file> applies it with bounded retry (the documented
      reconcile shape). The parser is the inverse of wg showconf and is
      strict — unknown keys, malformed values, and a [Peer] without a
      PublicKey are hard errors (matching real wg setconf, which rejects
      wg-quick-only keys like Address/DNS/MTU). No library change; the
      parser lives in the binary (precedent: the config binary's schema).

    • wg bin: --json for show and showconf (#23). Both gain
      -j/--json (+ -p/--pretty) emitting a stable device/peer shape. show
      hides the private/preshared keys (mirroring the (hidden) text output);
      showconf --json reveals them, since it is a config dump. Brings wg
      into line with the other binaries' machine-readable output.

    • bridge / diag bins: unit tests for the pure parse/format helpers
      (#25, #28).
      Both binaries previously shipped with zero tests. Added
      non-root unit coverage for bridge's parse_vid_range (VLAN id/range)
      and OnOff flag parser, and diag's parse_severity, severity_icon,
      oper_state_str, format_bytes, plus the severity ordering the
      --min-severity filter relies on.

    Added

    • CLI parse-test suites for ss, bridge, and nlink-config
      (#20, #25, #22).
      Each bin gained a tests/cli_parsing.rs mirroring
      the ip/tc pattern (assert_cmd + predicates). The suites are
      hermetic — they exercise clap's parse phase (root + per-subcommand
      --help, invalid input, arg conflicts, typed-value rejection) which
      completes before any netlink socket opens, so they run as a non-root
      user with no live kernel. They lock in the hardening-campaign
      additions (ss --packet/--oneline/--sport, bridge fdb add --extern-learn, config apply --reconcile--dry-run) against
      silent regression, and config example is run end-to-end (it emits
      an embedded sample with no kernel access).

    Fixed

    • ss bin: JSON output honors display flags + process parity (#20).
      ss -j previously dumped the tcp_info/mem_info/congestion/
      mark blocks unconditionally whenever the kernel returned them,
      regardless of the -i/-m/-e flags, and never emitted the
      owning-process data that -p shows in text. The JSON now mirrors
      the text columns: tcp_info (+ congestion) only with -i,
      mem_info only with -m, the extended interface/mark fields
      only with -e, an active timer only with -o, and a structured
      process array (comm/pid/fd) only with -p. Unit tests cover
      the gating in both directions.

    • ip bin: sr tunsrc show --pretty + POC version string (#17).
      sr tunsrc show -j hand-rolled its JSON and ignored the global
      --pretty flag; it now builds the object via serde_json so
      --pretty is honored and the address is correctly escaped. The
      ip --version/--help strings now identify the binary as the
      nlink proof-of-concept ("not iproute2") so it can't be confused
      with a real ip(8).

    • tc bin: namespace-safe qdisc/class/filter show (#19). The
      three show paths resolved the device name→ifindex via
      nlink::util::get_ifindex (a /sys/class/net read in the calling
      process's
      mount namespace), which returns the wrong index inside a
      foreign netns. They now resolve over netlink via
      Connection::get_link_by_name (RTM_GETLINK in the connection's
      netns). The mutating paths were already netlink-safe via
      resolve_interface.

    • bin JSON robustness + deterministic bridge vlan output (#25, #19).
      The bridge (vlan/fdb/mdb) and tc (chain) JSON show paths
      used .expect("JSON serialization"), a latent panic path; they now
      return a proper Error via a shared to_json_string helper.
      bridge vlan show --json also groups interfaces through a HashMap, so
      its output order was nondeterministic — it now sorts by ifindex for
      stable, diffable JSON.

    • ip bin: namespace-safe maddr show interface resolution (#17).
      maddr resolved the interface name→index map by scanning
      /sys/class/net, which always reads the host namespace and so showed
      wrong indices inside a foreign netns. It now resolves the map over
      netlink (RTM_GETLINK). The /proc/net/{dev_mcast,igmp,igmp6} reads
      stay — they are per-netns and the kernel-blessed source for multicast
      membership (no netlink equivalent). Documented that maddr add/del is
      intentionally not offered: the kernel exposes static L2 multicast only
      via the SIOCADDMULTI/SIOCDELMULTI ioctls, out of scope for this
      netlink-first demo.

    • tc bin: remove the dead -b/--batch flag (#19). It was a
      global option declared "(not yet implemented)" and never read — a
      silent no-op that violated the strict-no-op contract. Dropped rather
      than left lying; a real batch interpreter can return as a focused
      feature.

    • bridge bin: remove the dead -s/--stats flag (#25). It was
      plumbed into OutputOptions but never read by any fdb/vlan/mdb
      output path — a silent no-op. The bridge entry types don't carry
      per-entry statistics (NDA_CACHEINFO ages, MDB timers, per-VLAN
      counters) yet, so there's nothing to show; surfacing real stats needs
      library support first. Dropped rather than left lying.

    • ss bin: ss-expression filters no longer pass non-inet sockets
      unconditionally (#20).
      FilterExpr::matches_socket_info returned
      true for every unix/netlink/packet socket, so ss -x 'sport = :22'
      kept all unix sockets. Every predicate in the grammar
      (sport/dport/src/dst/state) reads inet fields, so an
      expression is inherently inet-only; a non-inet socket can't satisfy it
      and is now excluded — matching the InetMatch exclusion the binary
      already applies to --sport/--dport/--src/--dst.

    • ip bin: namespace-safe neighbor show/flush device filter (#17).
      Both resolved the device via get_neighbors_by_name, which reads the
      name from the host /sys and so returns wrong results inside a foreign
      netns. They now resolve the ifindex over netlink (get_link_by_name)
      and use get_neighbors_by_index, per the CLAUDE.md "prefer _by_index"
      policy. A missing device is now a clear error instead of an empty list.

    • ip bin: strict-parse the link-add mode helpers (#17). The bond
      mode/xmit_hash_policy/lacp_rate, macvlan/macvtap mode,
      ipvlan mode, and VLAN protocol parsers silently mapped unknown
      input to a default (e.g. mode bogusbalance-rr), hiding typos.
      They now return Error::InvalidMessage on unrecognized input, in line
      with the CLAUDE.md strict-parse contract.

    • ip bin: kill silent no-ops in addr/tunnel/rule (#17).
      ip addr add … peer <addr> now actually sets the peer address (it was
      parsed and dropped); a bad peer value or family mismatch errors. ip tunnel add … tos/pmtudisc/nopmtudisc/dev were silently ignored — they
      are now wired to the tunnel builders (tos/pmtudisc error as "not
      modelled" for vti, which lacks them; --pmtudisc + --nopmtudisc
      together is rejected as contradictory). ip rule … nop/goto, which
      the rule builder does not model, now error instead of silently sending
      a plain table-lookup rule.

    • ip bin: honor -j/-p in tunnel show and nexthop show (#17).
      tunnel show discarded the user's output flags entirely (it passed
      OutputOptions::default()), and nexthop show always pretty-printed
      JSON regardless of -p. Both now thread the real OutputOptions
      through, so -j/-p behave consistently with the other objects.

    • devlink bin: type-aware param set (#26). The value was inferred
      lossily (bool → u32 → string), so a u8/u16 parameter or an all-digit
      string label silently became a u32. param set now reads the
      parameter's declared type via get_param first and parses the value
      into it — rejecting out-of-range or non-boolean input — and only falls
      back to inference when the parameter can't be read.

    • nft bin: real chain type/hook/priority/policy in list chains (#21).
      The text and JSON output hardcoded type filter hook <n> and showed the
      raw hook number. list chains now prints the actual chain_type,
      hook name (family-aware: netdev → ingress/egress, else the L3 hooks),
      priority, default policy, and bound device from the kernel's
      ChainInfo.

    • devlink bin: readable monitor output (#26). devlink monitor
      printed raw {:?} Debug for each event. It now renders a concise line
      per event (new device …, new port … (eth3), health event …,
      flash update …), with a Debug fallback for future event variants.

    • ip bin: namespace-safe nexthop show device names (#17). The
      ifindex→name resolution scanned /sys/class/net/*/ifindex, which reads
      the calling process's mount namespace and prints the wrong (or no) device
      name for a nexthop dumped from a foreign netns. It now resolves names via
      a single RTM_GETLINK dump on the connection (get_interface_names),
      which is always relative to the connection's netns, and reuses the map for
      every nexthop instead of re-scanning sysfs per entry.

    Downloads
  • v0.21.0 91f6d5a6cf

    v0.21.0 Stable

    p13marc released this 2026-06-04 12:06:07 +00:00 | 214 commits to master since this release

    [0.21.0] - 2026-06-04

    Major release. Closes the remaining audit-derived plans from the
    0.20-cycle suite plus the discretionary plans (197, 234) that were
    seeded at cycle open. Breaks ABI on five APIs that were deprecated in
    0.20.1 and on five *Message types that get field-visibility tightening.

    Breaking changes

    • DpllPin::fractional_frequency_offset_ppt widened from Option<i32>
      to Option<i64>.
      Silent truncation on overflow was the audit
      finding that motivated the 0.20.1 deprecation. The
      _i64 parallel field added in 0.20.1 is now the single field; the
      deprecated i32 field is removed.
    • Raw-u8 route-rule API removed. Connection::<Route>::flush_rules,
      get_rules_for_family, and del_rule_by_priority no longer take
      family: u8. The _typed(AddressFamily) siblings from 0.20.1 take
      over the original names; the _typed suffix is dropped.
    • QdiscBuilder::loss(f64) removed. Use loss_pct(Percent).
    • Verdict::Jump(String) and Verdict::Goto(String) removed. Use
      Verdict::JumpTo(ChainName) / Verdict::GotoTo(ChainName).
      RuleBuilder::jump / goto now take pre-validated ChainName
      infallibly; new try_jump / try_goto siblings take &str and
      return Result<Self>.
    • RuleMessage fields demoted from pub to pub(crate) + #[non_exhaustive].
      Use the per-field accessor methods shipped in 0.20.1 Plan 231.
      Sibling types — BridgeVlanEntry, FdbEntry, MplsRoute,
      Nexthop, NexthopGroupMember, NsIdMessage — get the same
      treatment plus accessor methods. New
      scripts/audit-message-accessor-convention.sh CI gate locks the
      convention. AddressMessage / LinkMessage / NeighborMessage /
      RouteMessage / TcMessage gain #[non_exhaustive] (fields stay
      pub; bin construction is the source of literals).

    Added

    • Plan 197 — Declarative OvpnConfig for OpenVPN data-channel
      offload.
      New Connection<Ovpn> GENL family covers 8 commands +
      3 multicast notifications (kernel 6.16+): peer_new / peer_set
      / peer_get / peer_dump / peer_del, key_new / key_get /
      key_swap / key_del, plus the peer-del-ntf / key-swap-ntf /
      peer-float-ntf multicast notifications via subscribe_peers().
      Declarative OvpnConfig::diff().apply() and apply_reconcile
      mirror WireguardConfig's pattern. Recipe at
      docs/recipes/openvpn-dco.md. Runnable example at
      crates/nlink/examples/genl/ovpn.rs. 44 lib + 7 root-gated
      integration tests. The attach_socket cross-netns SCM_RIGHTS
      helper is plumbed but returns Error::NotSupported pending a
      sendmsg-layer cmsghdr refactor (deferred to 0.21.x); same-netns
      callers already work via OvpnPeer::socket = Some(fd).

    • Plan 234 — F1 follow-on: Dispatcher foundation + ENOBUFS
      routing.
      New netlink::dispatcher module provides an
      Arc-shared, Send + Sync broadcast surface that the
      NetlinkSocket recv path routes ENOBUFS into. Every multicast
      subscriber sharing an Arc<Connection> receives
      ResyncMarker::ResyncStart on socket overflow (deliberate
      divergence from neli's silent-drop behaviour — see CLAUDE.md
      ## Concurrency). Connection<P> exposes dispatcher() -> &Dispatcher.
      The F1 tokio::sync::Mutex stays for unicast requests pending the
      per-seq pipelining batch (queued as 0.21.x follow-on); this commit
      is the broadcast-side foundation + the socket hook + 24 new tests
      (13 unit + 11 integration including per-family wiring smoke checks
      for wireguard / macsec / mptcp / ethtool / nl80211 / devlink / dpll).

    • Plan 228 extension — Declarative netem parity setters. Five
      new *_pct(Percent) setters on the declarative QdiscBuilder for
      netem: duplicate_pct, corrupt_pct, reorder_pct,
      loss_correlation_pct, delay_correlation_pct. Closes the
      declarative-vs-imperative netem gap noted in 0.20.1's "Deferred to
      0.21" list.

    • Plan 231 extension — Sibling *Message accessor sweep. Four
      more types from crates/nlink/src/netlink/{bridge_vlan,fdb,mpls,nexthop}
      get per-field accessors + #[non_exhaustive] + pub(crate) field
      demotion. audit-message-accessor-convention.sh extended to
      cover them.

    • Plan 229 — Doc-drift sweep + new CI gates. ~16 doc-comment
      sites flipped (sync conn.events().await, deprecated builder
      references replaced, WG private_key corrected post-PR-#9). New
      scripts/audit-recipe-drift.sh (blocking) catches stale recipe
      patterns. New doctest-nlink GHA job runs cargo test --doc
      non-blocking initially (per Plan 229 §3 — promoted to blocking
      after one cycle of stability).

    • Migration guide. docs/migration_guide/0.20.0-to-0.21.0.md
      with before/after code samples for every breaking removal +
      quick-fix cheat sheet. README index updated.

    Deferred to 0.21.x

    • Plan 234 per-seq unicast pipelining — the dispatcher's
      broadcast-side foundation ships here; per-seq oneshot
      registration + pending-map demux through the dispatcher's recv
      loop is the obvious next batch (~30 callsites across 6 files).
      The public API stays unchanged so the follow-on is a pure
      internal refactor.
    • attach_socket cross-netns SCM_RIGHTS helper (Plan 197) —
      needs a sendmsg-layer cmsghdr refactor of NetlinkSocket.
      Same-netns fd passing already works.

    Notes

    • cargo-semver-checks against v0.20.1 is expected to fail on the
      breaking changes — that's the point of 0.21.0.
    • Discretionary plans 235 (GENL command unification — was paired with
      Plan 234) and 236 (adversarial-input rubric — meta-plan, folded
      into per-plan test docs) are not in this release; 235 becomes a
      natural pairing for the Plan 234 unicast-pipelining follow-on.
    Downloads
  • v0.20.1 d56ded946a

    v0.20.1 Stable

    p13marc released this 2026-06-04 10:09:49 +00:00 | 235 commits to master since this release

    [0.20.1] - 2026-06-04

    Patch release. Additive only — no breaking changes; cargo-semver-checks
    clean against v0.20.0. Closes the remaining audit-derived plans the
    0.20.0 emergency hotfix had to defer.

    Added

    • Plan 222.2-4 — Sizeof gate broader coverage. Extended the
      sys_sizeof.rs constant-value gate (shipped in 0.20.0 with XFRM + nft
      CT phase) to cover TC HTB / flower keys, IFLA / RTA / ctnetlink, and
      DPLL / devlink / ethtool. Each module pins ~10-20 most-used constants
      against kernel UAPI v6.13 reference values.
    • Plan 223 — Big-endian wire-parsing sweep. Three from_le_bytes
      sites that 0.19 N3 missed (netfilter.rs, tc/action.rs,
      nftables/config/diff.rs) corrected to from_ne_bytes. New
      scripts/audit-bytes-le.sh CI gate fails the build if from_le_bytes
      reappears in crates/nlink/src/netlink/. New cargo check --target s390x-unknown-linux-gnu CI job locks the BE-compile
      contract.
    • Plan 224 — recv_msg MSG_TRUNC handling + auto-grow. recv_msg
      passes MSG_TRUNC, detects kernel-side truncation against the buffer
      size, auto-grows up to a 1 MiB cap and retries once, then escalates to
      a new Error::Truncated { received, buffer_size } variant with
      is_truncated() predicate. New Error::Backpressure variant for
      EWOULDBLOCK retry exhaustion (B19).
    • Plan 225 — WireGuard parse_timespec robustness. Validates
      secs >= 0 + 0 <= nanos < 1e9 before constructing Duration;
      uses SystemTime::checked_add to handle far-future overflow.
      Returns None on malformed input instead of panicking. Closes
      AUDIT_BUGS B5 (verified by reviewer repro).
    • Plan 226 — DPLL sint codegen runtime. Added nlink-macros
      runtime support for the kernel's variable-length signed-integer wire
      format (nla_put_sint: 4 bytes if value fits in s32, 8 bytes
      otherwise). New DpllPin::fractional_frequency_offset_ppt_i64: Option<i64> field holds the full-width value alongside the existing
      (now deprecated) Option<i32> field. New ffo_ppt_i64() accessor.
    • Plan 227 — Typed AddressFamily newtype. New
      nlink::AddressFamily(u8) with v4() / v6() / bridge() / etc.
      constructors. New *_typed methods on Connection<Route>
      (flush_rules_typed, get_rules_typed,
      del_rule_by_priority_typed) take AddressFamily. The raw-u8
      originals are deprecated (removal 0.21).
    • Plan 228 — Typed Percent on QdiscBuilder::loss. New
      loss_pct(Percent) setter validates / clamps; original loss(f64)
      deprecated (removal 0.21). Mirrors the 0.13 typed-units rollout —
      closes the units-confusion bug class for the declarative path.
      Scope limited to loss; the other 5 netem setters
      (duplicate / corrupt / reorder / loss_correlation /
      delay_correlation) don't exist on the declarative QdiscBuilder
      (imperative NetemConfig already takes Percent); deferred to 0.21.
    • Plan 230 — Typed ChainName + Verdict::JumpTo / Verdict::GotoTo
      variants.
      New nftables::ChainName(String) newtype validates
      non-empty / no-interior-NUL / <= NFT_NAME_MAXLEN-1. New
      Verdict::JumpTo(ChainName) and Verdict::GotoTo(ChainName) variants
      ride the existing #[non_exhaustive] enum. The Verdict::Jump(String)
      / Verdict::Goto(String) originals are deprecated (removal 0.21).
    • Plan 231 — RuleMessage per-field accessors. 19 new accessor
      methods alongside the existing pub fields. family_typed() returns
      the new AddressFamily from Plan 227. Field visibility unchanged —
      field-to-pub(crate) demotion deferred to 0.21 alongside the
      audit-script gate.
    • Plan 232 — Bug-hunt LOW-tier batch. Closes B6 (Error::from_errno
      redundancy), B9 (send_dump_inner msg_start underflow), B10
      (parse_string_from_bytes UTF-8 swallowing — now logs at WARN), B11
      (WireguardConfig::apply .expect panic path), B15
      (audit.rs::AuditStatus size check uses >=), B18 (nlmsg_align
      overflow on > 2 GiB; new nlmsg_align_checked), B19
      (socket::send EWOULDBLOCK retry — new Error::Backpressure).
      B13 / B14 / B17 / B20 deferred or judged non-bugs; documented
      inline.
    • Plan 233 — DumpStream::with_skip_malformed opt-in. Default
      behaviour stays fuse-on-malformed (correctness > liveness for snapshot
      dumps). New .with_skip_malformed(true) setter switches to
      flatten-mode with WARN-level tracing per skip. Per-stream-API
      audit table + dump-vs-event policy documented inline.
    • Plan 237 — Audit-script self-test pattern. Four new
      scripts/test-audit-*.sh self-tests verify the failure paths of the
      audit-by-grep CI scripts (recv-loop, sysfs-in-lib, example-registration,
      example-feature-gating). Each test injects a deliberately-broken
      fixture and asserts non-zero exit + message match. Closes the
      silent-broken-script class.

    Deprecated (removal: 0.21.0)

    • DpllPin::fractional_frequency_offset_ppt: Option<i32> — silently
      truncates kernel-supplied FFO values that don't fit in s32. Use the
      new _i64 field or ffo_ppt_i64() accessor.
    • Connection::<Route>::flush_rules(family: u8) — use
      flush_rules_typed(AddressFamily). Same for get_rules_for_family
      and del_rule_by_priority (raw-u8 variants).
    • QdiscBuilder::loss(f64) — use loss_pct(Percent). Closes the
      units-confusion bug class for the declarative netem path.
    • Verdict::Jump(String) and Verdict::Goto(String) — use
      Verdict::JumpTo(ChainName::new(...)?) / Verdict::GotoTo(...)
      which validate the chain name and reject interior NULs.

    Deferred to 0.21.0

    • Plan 229 (doc-drift sweep) — to be done in 0.21 after the
      typed-API surface stabilises.
    • Plan 234 (NlRouter dispatcher), Plan 235 (GENL command unification),
      Plan 236 (adversarial-input rubric — meta-plan with no code), Plan
      197 (declarative ovpn) — discretionary plans, slotted into 0.21
      cycle.
    • Various per-plan extensions documented inline in commit messages and
      the _changelog_batch_*.md stubs preserved in git history.
    Downloads
  • v0.20.0 666a18e0d3

    v0.20.0 Stable

    p13marc released this 2026-06-04 09:12:09 +00:00 | 261 commits to master since this release

    [0.20.0] - 2026-06-04

    Emergency release with breaking wire-format corrections.

    Connection::<Xfrm>::flush_sp() was silently flushing all
    Security Associations instead of all Security Policies since the
    XFRM family shipped. Four XFRM constants (XFRM_MSG_FLUSHSA,
    XFRM_MSG_FLUSHPOLICY, XFRMA_SRCADDR, XFRMA_OFFLOAD_DEV)
    were miscounted from the kernel UAPI enum and produced
    catastrophically wrong wire bytes. Plus two dispatch errors
    (update_sa/update_sp always returned EEXIST) and a
    CtKey::Expiration discriminant change that was loading the
    wrong conntrack field.

    The CtKey::Expiration discriminant change (7 → 5) is a
    breaking ABI change at the byte level: pre-0.20 anyone using
    CtKey::Expiration as u32 got 7 (the wrong value); post-0.20
    they get 5 (the correct value). The change requires a minor
    version bump in 0.x.y semver. The remainder of the 0.20-cycle
    plan suite (sweeps 222.2-237 + the typed-API tightening +
    discretionary 197/234/235) slides forward into 0.21.0.

    All shipped nlink versions yanked back to 0.15.x; the
    minimum-viable version is now 0.20.0.

    Fixed — XFRM constants (Plan 221)

    • CRITICAL: Connection::<Xfrm>::flush_sp() was silently
      flushing all Security Associations instead of all Security
      Policies.
      Root cause: XFRM_MSG_FLUSHPOLICY was hardcoded
      to 28, which is the kernel UAPI value for XFRM_MSG_FLUSHSA.
      Same miscount also broke flush_sa() (was sending
      XFRM_MSG_UPDPOLICY = 25 with an 8-byte body the kernel
      expected to be 168 bytes).
    • CRITICAL: Connection::<Xfrm>::update_sa() and update_sp()
      always returned EEXIST when the target existed.
      Both methods
      sent XFRM_MSG_NEWSA/NEWPOLICY with NLM_F_REPLACE; XFRM
      dispatches by nlmsg_type alone and ignores NLM_F_REPLACE,
      so the kernel called xfrm_state_add/xfrm_policy_insert(excl=1)
      → EEXIST whenever the target SA/SP existed. Fixed to use
      XFRM_MSG_UPDSA / XFRM_MSG_UPDPOLICY (newly added constants
      with the corrected values 26 and 25).
    • CRITICAL: XFRMA_SRCADDR attribute ID was 9 (= XFRMA_LTIME_VAL).
      Every del_sa/get_sa with a src-address filter was emitting
      a 16-byte address under the lifetime-struct attribute ID.
      Strict-checking kernels EINVALed; lenient ones silently
      dropped the filter. Corrected to 13.
    • CRITICAL: XFRMA_OFFLOAD_DEV attribute ID was 26
      (= XFRMA_ADDRESS_FILTER).
      Plan 153.1's IPsec offload
      feature has been broken since it shipped — strict-checking
      kernels EINVALed on the size mismatch; lenient kernels parsed
      8 bytes as a truncated xfrm_address_filter and silently
      installed a software-only SA. Corrected to 28.

    Fixed — nftables CtKey::Expiration constant (Plan 221)

    • HIGH: CtKey::Expiration enum discriminant was 7
      (= NFT_CT_L3PROTOCOL).
      Every rule using
      Expr::Ct { key: CtKey::Expiration } was loading the conntrack
      L3 protocol byte instead of the relative expiration time in
      milliseconds. Corrected to 5; the previously-shadowed
      variants CtKey::Secmark, CtKey::Helper, and
      CtKey::L3Protocol were added so the kernel values are all
      reachable through the typed enum.

    Added — constant-value sizeof gate (Plan 222.1)

    • New sys_sizeof::xfrm_msg_type / xfrm_attr / nft_ct_keys
      modules.
      Mirror the kernel UAPI v6.13 values for every
      constant the Plan 221 hotfix touched + the surrounding common
      values for safety. A new test family
      plan_222_1_*_match_kernel_uapi locks the corrected discriminants
      at build time so a future commit can't re-introduce the
      off-by-N enum-counting error. The pre-existing
      sys_sizeof::xfrm (struct sizes) + nft_verdict (verdict
      values) gates cover adjacent territory; this is the
      constant-ID half.

    Fixed — 0.20-cycle pre-work (PRs #9, #10, follow-up)

    These three changes already shipped to master between the 0.19.0
    cut and this hotfix. They are not 0.19.1-specific but are
    included in 0.19.1 by virtue of the master state.

    • Make Connection::<Wireguard>::get_device* return the device
      private_key for privileged callers.
      The parse path is factored
      through a shared parse_key() helper that also normalizes the
      kernel's all-zeros sentinel (returned for a keyless device on a
      privileged read) to None for both private_key and
      public_key, matching the peer preshared_key arm. The wg bin
      renders the real key when set instead of a hardcoded
      (hidden)/(none).

    • Fix permanent phantom diffs in NftablesConfig::diff. The diff
      byte-compares nlink's lowered expressions against what the kernel
      echoes on dump; several matchers lowered to less than the kernel's
      canonical form, so a reapply re-emitted the rule on every reconcile.

      • L3-version-specific matchers now prepend the meta nfproto == ip{v4,v6} guard nft itself emits in an inet chain: the
        address matchers (match_{s,d}addr_{v4,v6} + _not) and the ICMP
        type matchers (match_icmp_type/match_icmpv6_type). Its absence
        also made the load ambiguous in an inet chain and unrecoverable
        by nft list ruleset.
      • The bitwise writer now emits NFTA_BITWISE_OP (every
        prefix-masked match) and the nat writer emits
        NFTA_NAT_REG_{ADDR,PROTO}_MAX + NFTA_NAT_FLAGS (every
        snat/dnat) — attributes the kernel fills in and echoes. Latent
        since the original nftables support (0.10.0).
      • The nat writer skips NFTA_NAT_FLAGS when the derived
        flags == 0 (no addr and no port), mirroring nft_nat_dump's
        own behaviour. Without this, the no-addr-no-port path
        (Expr::Nat(NatExpr::snat(family)) constructed via the typed
        internals, bypassing the fluent Rule::snat_* helpers) would
        reintroduce a phantom diff that the rest of this fix removes.
      • One-time migration impact for apply_reconcile users:
        rulesets installed by nlink ≤ 0.19 will diff non-empty on the
        first post-upgrade reconcile because the in-kernel form lacks
        the new attributes. The diff converges after one apply.
    • Modprobe nft_nat and wireguard in CI so the related
      round-trip tests run instead of silently skipping.

    Yanked

    • 0.19.0, 0.18.0, 0.17.0, 0.16.0, 0.15.x — all shipped
      with the XFRM constant defects. Use 0.20.0+.
    Downloads
  • v0.19.0 b1dd7bf203

    v0.19.0 Stable

    p13marc released this 2026-05-31 20:05:30 +00:00 | 273 commits to master since this release

    [0.19.0] - 2026-05-31

    Breaking changes

    • ApplyOptions::with_purge removed; ConfigDiff::*_to_remove
      collections removed
      (Plan 205 Option B). The feature was
      silently non-functional in 0.18 — the diff phase never
      populated the *_to_remove collections, so the apply path's
      if options.purge { ... } branches were dead code that lied
      about what they did. Pre-0.19 callers passing
      .with_purge(true) thought kernel state was being reconciled;
      it wasn't. Rather than continue shipping the silent lie, the
      knob is now gone. Migration: for the "remove undeclared
      resources" use case, use the imperative API
      (Connection::del_link / del_address / del_route /
      del_qdisc). A correctly-wired purge with a kernel-managed-
      resource exclusion list (IPv6 link-local, multicast, lo,
      link-local prefix routes) is queued for 0.20.

    • DpllPin::phase_offset field type: Option<i32>
      Option<i64>
      (Plan 206 H1). The kernel field is declared
      s64 per Documentation/netlink/specs/dpll.yaml (atto-
      seconds × 1000). Pre-0.19 nlink routed the attribute through
      parse_i32_attr which reads only the low 4 bytes of the 8-
      byte payload, silently truncating high bits on LE platforms.
      Telco/PTP/SyncE values routinely exceed i32::MAX (a 1 ns
      offset = 1e9 in those units), producing nonsense readings.
      Now correctly typed as i64 and parsed via a new
      __rt::parse_i64_attr / emit_i64_attr helper pair; the
      GenlMessage derive recognizes i64 field types. Migration:
      callers reading .phase_offset explicitly need to update the
      type annotation; .phase_offset_ns() accessor unchanged
      (still Option<i64>). 1 new regression test
      (pin_phase_offset_round_trips_value_above_i32_max) pins
      the high-bit round-trip.

    • Hook::Ingress split into Hook::NetdevIngress and
      Hook::InetIngress; Hook::NetdevEgress added
      (Plan 211 M1).
      Pre-0.19 Hook::Ingress always encoded 0, which was correct
      only for Family::Netdev / Family::Bridge. On Family::Inet,
      ingress is NF_INET_INGRESS = 5, so the old encoding silently
      installed the chain on Prerouting (also hook 0) — every
      Family::Inet ingress chain was attached to the wrong hook.
      Migration: pick the variant matching the chain family.
      Hook::is_valid_for_family(Family) validates at build time.
      Verified against include/uapi/linux/netfilter.h and
      include/uapi/linux/netfilter_netdev.h. New nft_hook module
      in sys_sizeof pins the kernel hook numbers.

    • nftables verdict constants NFT_JUMP / NFT_GOTO corrected
      to match kernel UAPI
      (Plan 204 C1). Pre-0.19 nlink shipped
      NFT_JUMP = -2 and NFT_GOTO = -3. The kernel's
      enum nft_verdicts defines them as -3 and -4 respectively;
      -2 is NFT_BREAK (terminate rule evaluation). Code building
      Verdict::Jump(chain) previously wrote -2 on the wire, which
      the kernel interpreted as "terminate", silently breaking every
      subroutine rule. The new NFT_BREAK = -2 constant is added for
      completeness. Source-level no-op for users of Verdict::Jump /
      Verdict::Goto; runtime behavior changes from silently broken
      to kernel-correct. Verified against kernel
      include/uapi/linux/netfilter/nf_tables.h.

    Added

    • nlink::netlink::sys_sizeof module + 9 wire-format byte-exact
      regression tests
      (Plan 213). Hosts the kernel UAPI struct
      sizes (XfrmUserpolicyInfo = 168 bytes, XfrmUserpolicyId =
      64 bytes, etc.) and the nft verdict constants, with a test
      per type asserting size_of::<NlinkType>() == KERNEL_SIZE.
      Catches the Plan 204 class of silent wire-format defects at
      cargo test time; future struct field changes that drift from
      the kernel layout fail the test immediately. The test pass
      surfaced an additional latent bug: XfrmUserTmpl was 62
      bytes (kernel: 64) — fixed by adding a 2-byte explicit pad
      between family and saddr to match kernel natural
      alignment.

    Fixed

    • NatExpr.addr re-typed from Option<Ipv4Addr> to the
      NatAddr enum (PR #6, @avionix-g)
      NatAddr has three
      variants: None (port-only NAT), V4(Ipv4Addr) (IPv4
      address recorded), and Reg (a non-v4 address — e.g. v6 —
      loaded into R0 with no Ipv4Addr to record). Before this
      change, the encoder emitted NFTA_NAT_REG_ADDR_MIN only
      when addr.is_some(), so a v6 NAT (16-byte address in
      R0, no Ipv4Addr to carry) silently dropped the address
      register. The enum models "register in use" and "the IPv4
      value to record" as one value so the illegal
      (addr recorded, register unused) state is
      unrepresentable. Breaking for code constructing NatExpr
      as a struct literal or matching on addr. The
      NatExpr::{snat,dnat} + .addr() and
      Rule::{snat,dnat,snat_v6,dnat_v6} builders are
      unaffected. v4 wire output is byte-identical.

    • ApplyOptions is now #[non_exhaustive] + builder-shaped
      (Plan 188 §2.2)
      — struct-literal construction no longer
      compiles. Build via with_* setters instead:

      // Before
      ApplyOptions { dry_run: true, ..Default::default() }
      // 0.19+
      ApplyOptions::default().with_dry_run(true)
      

      Mirrors ReconcileOptions (Plan 163). The lockdown enables
      growing the option set in future minors without semver
      breakage.

    • Error::from_errno* factories now normalize via .abs()
      (Plan 187 §2.1)
      — passing positive or negative errno
      produces the same stored POSIX value. Before 0.19 the
      factory silently negated the input — from_errno_ext_ack(1, ..)
      produced stored -1, surfaced as a footgun in nlink-lab's
      unit tests. The fix is purely additive for the kernel-side
      call sites that always passed the kernel's signed-negative
      errno; only direct test/mock callers asserting Some(-N)
      break — update to Some(N).

    Added

    • Error::DumpInterrupted variant + is_dump_interrupted()
      predicate (post-cycle bug-hunt)
      — the kernel sets the
      NLM_F_DUMP_INTR flag on a dump message when the snapshot
      iterator's underlying data structure was mutated between
      frames, signaling that the returned set is inconsistent. Pre-
      0.19 nlink silently accepted the partial dump; the user had no
      way to know the data was stale. Now Connection::send_dump
      (and every typed dump wrapper that goes through it —
      get_links, get_routes, get_neighbors, get_addrs,
      get_rules, get_qdiscs, get_classes, get_filters,
      get_nexthops, get_actions) returns
      Error::DumpInterrupted. Callers can retry with their own
      bound — Cilium uses 30 attempts, vishvananda/netlink uses 24,
      iproute2 warns once and accepts the partial. The new
      NlMsgHdr::is_dump_interrupted() accessor also lets stream
      consumers detect interruption on individual frames.
      References: kernel docs/userspace-api/netlink/intro.html,
      vishvananda/netlink#1163, pyroute2#874, Cilium safenetlink.

    Fixed

    • XfrmUserpolicyInfo body was 4 bytes shorter than kernel
      expected — add_sp rejected with EINVAL on every kernel
      version
      (Plan 204 C2). The kernel's
      struct xfrm_userpolicy_info uses natural alignment (not
      packed); after the four trailing __u8 fields the struct
      pads to the next u64 boundary for a total of 168 bytes.
      nlink used #[repr(C, packed)] with no trailing pad,
      emitting 164 bytes. Kernel xfrm_add_policy() calls
      nlmsg_parse_deprecated(nlh, sizeof(*p), ...) requiring
      nlmsg_len >= NLMSG_HDRLEN + 168. The add_sp API has been
      silently non-functional since the XFRM family shipped. Fix
      adds explicit _pad: [u8; 4] and a sys_sizeof regression
      test.

    • XfrmUserpolicyId body was 4 bytes longer than kernel
      expected — del_sp / get_sp brittle on strict-checking
      kernels
      (Plan 204 C3). nlink's _pad: [u8; 7] produced a
      68-byte body; the kernel struct is 64 bytes (selector + u32
      index + u8 dir + 3 pad). On lenient kernels the extra 4
      bytes were parsed as a malformed trailing nlattr and silently
      skipped. On strict-checking kernels (≥5.0 with
      NETLINK_GET_STRICT_CHK, enableable via Plan 155.2), the
      kernel rejected with EINVAL. Fix trims _pad to [u8; 3].

    • XfrmUserTmpl was 62 bytes (kernel: 64) — discovered by
      Plan 213 sizeof test
      (Plan 204 hidden sibling of C2/C3).
      The kernel struct uses natural alignment (not packed); the
      2-byte gap between family (u16 at offset 24) and saddr
      (xfrm_address_t, align 4) was missing from nlink's packed
      representation. Fix adds explicit _pad_saddr: [u8; 2]
      between the two fields.

    • Devlink multicast subscription was broken — group name
      mismatch
      (Plan 204 C4). nlink looked up "devlink" in the
      kernel's CTRL_ATTR_MCAST_GROUPS table; the kernel registers
      the group as "config" (per DEVLINK_GENL_MCGRP_CONFIG_NAME
      in include/uapi/linux/devlink.h). Every
      Connection::<Devlink>::subscribe() call returned
      Error::FamilyNotFound. Fix changes the constant to
      "config" plus the sys_sizeof regression test.

    • Error::is_not_found now matches Error::Io(ENOENT) and
      Error::Io(ENODEV)
      (Plan 212 M9). Brings the predicate
      into symmetry with is_busy, is_permission_denied,
      is_already_exists which Plan 187 §2.5 already routed
      through errno(). Code calling e.is_not_found() on an
      Error::Io carrying ENOENT/ENODEV now correctly returns
      true. 3 new regression tests.

    • Connection::send_ack_inner surfaces explicit error on
      unexpected matching-seq data response
      (Plan 212 M16) instead
      of silently looping for the next frame (which would hit the
      30s timeout). Defense-in-depth against kernel behavior
      divergence.

    • Connection::cache RwLock poison handling (Plan 212
      M17): previously read/write().unwrap() would panic on
      poisoning; now recovers via unwrap_or_else(into_inner).
      Hardens against future panics inside the locked region
      (currently unreachable; defense-in-depth).

    • WireGuard PublicKey accepts unpadded base64 (Plan 215
      M12). Pre-0.19 the decoder required exactly 44 chars + the
      trailing =; some YAML/JSON serializers strip optional
      base64 padding (RFC 4648 §3.2). Now both 43-char and 44-char
      forms decode correctly. 1 new regression test.

    • nl80211 SSID parser walks the IE chain (Plan 215 M13)
      instead of assuming element-id 0 is the first IE.
      Vendor-specific IEs (id=221) sometimes precede the SSID;
      pre-fix those BSSes returned None. New parse_ssid_from_ies
      helper + 6 unit tests covering well-formed, vendor-prepended,
      missing, truncated, non-UTF-8, and empty IE chains.

    • bins/nft rejects unknown --type and --policy tokens
      (Plan 209 H5 — security UX). Pre-0.19 a typo on --policy
      silently fell through to ACCEPT (--policy drpo for drop
      produced an accept-everything firewall). Same hazard for
      --type chain type. Now both error explicitly:
      unknown policy drpo— expecteddroporaccept``.

    • bins/wg set --private-key /path propagates file read
      errors
      (Plan 209 H6 — security UX). Pre-0.19 a missing
      private-key file or base64-decode failure silently dropped the
      key set; wg set wg0 --private-key /typo exited 0 and the
      user believed the new key was installed. Now the read error
      surfaces immediately via ?.

    • bins/tc action parses TC action attributes via zerocopy
      ref_from_prefix instead of raw-pointer casts
      (Plan 209
      H11). Pre-0.19 the code did
      unsafe { &*(attr_data.as_ptr() as *const TcGact) } which is
      UB on strict-alignment architectures (some ARM, MIPS) — Vec<u8>'s
      data pointer has no alignment guarantee. Now uses zerocopy's
      alignment-checked parser, eliminating the UB. Three sites
      updated (gact, mirred, police parameter blocks, both
      JSON and text formatters).

    • NetworkConfig correctness pass: 6 silent reconcile-divergence
      bugs fixed
      (Plan 207).

      • H2 — link master change detection (config/diff.rs).
        Pre-0.19 the diff compared Option<String> (declared) vs
        Option<u32> (kernel ifindex) treating any (Some, Some)
        pair as equal. Bridge-port reassignment (master: "br0" vs
        kernel master: ifindex(br1)) silently no-op'd. Now resolves
        existing.master() ifindex → name via the diff's name map
        and compares strings.

      • H3 — route gateway / dev / metric change detection
        (config/diff.rs). Pre-0.19 the diff identity tuple was
        (dst, prefix, table) only; changing the gateway on the
        same route produced an empty diff. Now compares the full
        route key (including gateway/oif/metric); any mismatch
        queues a route for re-emission. add_route uses
        NLM_F_REPLACE so the kernel atomically swaps the existing
        route — no del+add window. Most common reconcile op in
        multi-router topologies.

      • H4 — apply_reconcile recomputes diff per retry
        (config/mod.rs). Pre-0.19, on EBUSY the reconcile loop
        re-ran the full original apply against changed kernel state,
        producing EEXIST that masked the original EBUSY. Now each
        retry computes a fresh diff against current state and
        targets only what's still missing. change_count becomes
        the cumulative sum across attempts. Empty diff at retry
        start short-circuits as success.

      • M3 — remove_route forwards table identity
        (config/apply.rs). Pre-0.19 the _table parameter was
        discarded; routes in non-default tables (table ≠ 254)
        could never be purged — kernel returned ESRCH which
        is_not_found() swallowed silently.

      • M5 — topo-sort handles VXLAN underlay + master deps
        (config/diff.rs). Pre-0.19 only Vlan { parent } and
        Macvlan { parent } were modeled. Declaring
        vxlan42.underlay_dev("eth0") before eth0, or
        dummy0.master("br0") before br0, in the same batch
        silently failed at apply (same shape as Plan 186 §3c).

      • M10 — LinkState::Down uses IFF_UP flag, not OperState
        (config/diff.rs). Pre-0.19 the comparison read
        IFLA_OPERSTATE (RFC 2863 operational state, carrier-
        dependent). Dummy/veth interfaces with no carrier stayed
        non-Up operationally even when admin-up, so
        LinkState::Down declared on a no-carrier admin-up
        interface silently no-op'd. Now reads ifi_flags & IFF_UP
        (admin state).

      • M18 — atomic replace_qdisc via NLM_F_REPLACE
        (config/apply.rs). Pre-0.19 del+add sequence left a
        transient pfifo_fast/mq window between the delete and
        the new add; if the add failed the interface kept the
        kernel-default qdisc not the previous declared one. Now
        uses Connection::replace_qdisc* (atomic
        RTM_NEWQDISC + NLM_F_REPLACE). Falls back to del+add for
        Ingress/Clsact pseudo-qdiscs (kernel rejects REPLACE
        on those).

      2 new unit tests pin the topo-sort dep extensions
      (topo_sort_promotes_vxlan_underlay_before_vxlan,
      topo_sort_promotes_master_before_slave). Integration
      verification for the diff/apply changes lands via the
      existing network_config_apply.rs integration suite under
      the privileged-CI gate.

    • 10 protocol recv-loops wrapped in with_timeout + seq
      filter + NLM_F_DUMP_INTR detection
      (Plan 208 Phase 1+2):
      xfrm.rs::{get_security_associations, get_security_policies},
      netfilter.rs::get_conntrack_family,
      fib_lookup.rs::lookup_with_options,
      sockdiag.rs::{query_inet_family, query_unix_typed, query_netlink_typed},
      Connection::<Generic>::{query_family, command, dump_command}.
      Pre-0.19 each could hang indefinitely if the kernel dropped a
      response, and dump variants would silently use an
      interrupted-dump snapshot. Now surface as Error::Timeout
      after the configured budget and Error::DumpInterrupted per
      the Plan 208 contract. wg_command stale-frame race deferred
      pending GENL command unification (Plan 208 Phase 3+4 — bigger
      refactor).

    Documentation

    • Connection<P> doc-comment now describes the concurrent-
      use caveat
      (Plan 212 M15). The type implements Sync but
      concurrent .await-ed calls on a shared connection race on
      recv and can produce dual Error::Timeout. Recommended
      pattern: one Connection<P> per task, or use
      ConnectionPool<P> for fan-out. Architectural NlRouter-style
      dispatch fix tracked for 0.20.

    • README.md updated to 0.19 install lines + tuntap-async
      and serde features added to the features table.

    • lib.rs landing-page doc-comment updated (Plan 214):
      the stale "_by_name reads /sys/class/net/" claim removed
      (Plan 192 D4 made both lookups netlink-correct); addr.address
      doctest reference updated to addr.address() accessor.

    • Error::is_dump_interrupted doctest type fix (Plan 214):
      was Vec<nlink::Link> (doesn't exist), now
      Vec<nlink::netlink::LinkMessage>.

    • nftables-declarative-config.md recipe uses Display
      instead of deprecated .summary()
      (Plan 214).

    Earlier post-cycle fixes (5ef0808)

    • Batch::send_chunk could hang indefinitely on dropped
      per-op ACK (post-cycle bug-hunt)
      — the nftables/route batch
      send-chunk recv loop did NOT run under Connection::with_timeout,
      so a kernel that lost the ACK for any single batched op would
      leave the call waiting forever. Plan 171's 30s default
      Connection timeout was supposed to catch every recv-loop;
      Plan 172 missed wiring it here. Now the recv loop is wrapped
      in with_timeout, so a dropped ACK surfaces as Error::Timeout
      after the configured budget instead of an indefinite hang.

    • audit.rs::{get_status, get_tty_status, get_features} had
      no timeout + no seq filter (post-cycle bug-hunt)
      — the three
      Audit RPCs raw-recv_msg()-looped without with_timeout, so
      a kernel that dropped the response hung forever; they also
      matched on nlmsg_type only and would have accepted a stale
      frame from a prior request on the same socket. Both now match
      nlmsg_seq first and run under the connection timeout. Same
      hazard class as Plan 172 but on a non-rtnetlink protocol that
      the original audit missed.

    • sockdiag.rs::destroy_tcp_socket bypassed
      Error::from_errno* factory + had no seq filter / timeout
      (post-cycle bug-hunt)
      — constructed Error::Kernel { errno: -errno, ... }
      by hand instead of routing through
      Error::from_errno_with_context_ext_ack, so the stored errno
      shape diverged from the Plan 187 sign-normalization invariant
      and ext-ack info was dropped. Now uses the factory + seq filter

      • 30s timeout wrap.
    • MessageBuilder::nest_end + NlAttr::new silently
      truncated nla_len to u16 on > 65 KB payloads (post-cycle
      bug-hunt)
      — the kernel's nla_len field is a u16, so a
      caller building a >64 KB nested attribute would have its
      header silently wrap to a tiny value, producing a malformed
      message the kernel would either reject (best case) or
      misinterpret the wrapped length and skip past the real payload
      bytes (worst case). No caller hit this today — the bug was
      latent waiting for a future caller — but the silent-corruption
      shape was identical to PR #7's tcm_info packing footgun
      (where transposed bytes silently broke every TC filter add).
      Now: debug_assert! panic with a clear "exceeds u16::MAX wire
      limit" message in debug builds, saturating cast in release so
      the kernel rejects the message rather than misinterpreting a
      wrapped length. 2 new boundary tests
      (nest_end_just_under_u16_max_boundary_succeeds,
      nest_end_over_u16_max_panics_in_debug) pin the contract.

    • AttrIter parser-robustness contract was unverified (post-
      cycle bug-hunt)
      AttrIter is the equivalent of MessageIter
      for nested attribute walking; every parser in the lib uses it
      (hundreds of call sites). Plan 193 §2.3 added robustness tests
      for MessageIter (found a real infinite-loop bug in the
      process), but the matching AttrIter had zero tests
      future refactors could silently turn the safe return None
      paths into panics or infinite loops. 13 new tests pin the
      three CLAUDE.md ## Parser robustness rules on AttrIter:
      zero-length attribute terminates iteration without loop;
      truncated len > buffer terminates; under-min len < NLA_HDRLEN
      terminates; accept-larger-than-expected payload is forward-
      compatible; NLA_F_NESTED / NLA_F_NET_BYTEORDER flag bits
      are masked from kind() (preventing the vishvananda/netlink
      #1104 bug class). Same bug-by-test-writing pattern as Plan 193
      §2.3.

    • TC filter tcm_info packing — kernel-EINVAL on every
      add_filter* call with explicit protocol+priority (PR #7,
      @nuclearcat)
      add_filter_by_index_full (and the
      sibling replace/change/delete paths in filter.rs,
      plus the ratelimit ingress filter) packed tcm_info as
      (protocol << 16) | priority with no htons. The kernel
      uses TC_H_MAKE(prio << 16, htons(proto)) — priority in
      the upper 16 bits, ethernet protocol in the lower 16 bits
      in network byte order. With the halves transposed the
      kernel read e.g. protocol=0x0800/prio=200 as
      protocol=200/prio=2048 and returned EINVAL; every TC
      filter add with an explicit ethernet protocol failed. The
      ratelimit ingress filter was silently installed under the
      wrong ethertype. Fix routes every pack site through a
      single TcMsg::with_filter_info(protocol, priority)
      chokepoint, restores accessor symmetry on
      TcMessage::protocol() / priority() (which were
      self-inconsistently broken — matched the buggy pack while
      the unused filter_protocol() / filter_priority()
      matched the kernel). 4 new unit tests pin iproute2's
      exact wire layout + add the
      pre_fix_layout_was_transposed regression guard
      documenting what the kernel parsed pre-fix. 1 new
      root-gated integration test
      (test_filter_add_explicit_protocol_priority) asserts a
      real filter add accepts. Runtime semantic break for
      TcMessage::protocol() / priority() accessor return
      values — they now return the kernel-correct values
      instead of the transposed garbage; signature unchanged so
      cargo-semver-checks doesn't flag it, but document the
      shift. Verified on kernel 6.17.

    • IPv6 NAT silently dropped the address register
      (PR #6, @avionix-g)
      — see the ### Breaking changes
      entry on NatExpr.addrNatAddr for the type-level
      fix. New Rule::dnat_v6(Ipv6Addr, Option<u16>) and
      Rule::snat_v6(Ipv6Addr, Option<u16>) builders emit
      Family::Ip6 in the NAT expr (matching the address
      family, not the chain's Family::Inet), load the 16-byte
      address into R0 + optional port into R1. 3 new unit
      tests + 2 root-gated diff-idempotency integration tests
      (dnat_v6_rule_round_trips, snat_v6_rule_round_trips)
      on separate hooks (postrouting/SrcNat vs
      prerouting/DstNat) prove the kernel stored exactly the
      expr bytes nlink rendered.

    • Error::is_busy, is_already_exists, is_permission_denied
      catch Error::Io variants (Plan 187 §2.5)
      — these three
      predicates matched on Self::Kernel* variant directly,
      missing the Error::Io(io_err) case carrying the same
      errno via raw_os_error(). Same bug class as Plan 185's
      is_no_buffer_space fix. Single-point fix: Error::errno()
      now unwraps Error::Io via raw_os_error(), so every
      predicate that goes through errno() inherits the right
      shape. is_busy and is_try_again are used by
      NftablesConfig::apply_reconcile retry classification —
      a raw EBUSY/EAGAIN from the socket layer no longer
      bypasses the retry budget. Plan 185's defensive branch in
      is_no_buffer_space is now redundant and removed. New
      predicate_io_shape_sweep test pins the contract for 10
      predicates; future additions inherit it.

    • MessageIter infinite loop on truncated / malformed
      netlink frames (Plan 193 §2.3 + CLAUDE.md §"Parser
      robustness" rule 2)
      — surfaced while writing the
      parse-events skip regression tests this cycle:
      MessageIter::next returned Some(Err(...)) on both
      the NlMsgHdr::from_bytes failure and the
      msg_len < HDRLEN || msg_len > data.len() guard, but
      forgot to advance self.data past the malformed
      bytes. Subsequent next() calls returned the same
      Err indefinitely, hanging the long-lived multicast
      subscribers Plans 185 + 191 introduced. Fix: in both
      error branches, set self.data = &[] before
      returning so the next poll yields None. Bug class
      matches neli #305 (whole-batch abort on one malformed
      message) — same shape, different surface. 4 new
      stream.rs tests pin the contract: empty buffer,
      unknown msg-type, garbage payload on known msg-type,
      truncated frame.

    Deprecated

    • ConfigDiff::summary() + NftablesDiff::summary()
      (Plan 188 §2.6)
      — Plan 183 (0.18) made the Display impl
      on both diff types share the same renderer; the two methods
      produce byte-for-byte identical output. Pick the Rust idiom
      (Display); remove the legacy method in 0.20.
      Update call sites from diff.summary() to diff.to_string()
      or use the {} placeholder in format!/println!.

    Added

    • Rule::dnat_v6(Ipv6Addr, Option<u16>) + Rule::snat_v6(Ipv6Addr, Option<u16>) (PR #6, @avionix-g) — IPv6 NAT helpers, the
      counterparts to dnat / snat. Each loads the 16-byte address
      into R0 (and the optional port into R1) and emits Family::Ip6
      in the NAT expr to match the address family (not the chain's
      Family::Inet). Use on ip6 or inet NAT chains. Closes a silent
      encoder bug where addr.is_some() was used as the proxy for
      "register in use" — see the breaking-change entry on NatExpr.addr.

    • Post-cycle audit backfill — closes gaps surfaced by
      the 0.19 plan-by-plan audit:

      • Plan 196: PublicKey newtype with FromStr (base64)
        • Display round-trip, Debug via Display. Inline
          32-byte base64 codec (no new crate dep). 6 new unit
          tests pin fE/wpxQ6/M6OmF5j4dvbY3FbCEXc3KlBL2QqAYjE0WI=
          test vector + zero/max boundary cases + invalid-input
          rejection.
      • Plan 196: Display impl on WireguardConfigDiff
        rendering + peer, ~ peer (endpoint, allowed_ips),
        - peer per change. Empty diff renders "no changes".
      • Plan 196: WireguardConfig::apply_reconcile(conn, opts) mirroring NetworkConfig::apply_reconcile
        bounded EBUSY/EAGAIN retry with exponential backoff,
        reuses the shared ReconcileOptions shape.
      • Plan 192 §2.7: CLAUDE.md ## util::ifname sysfs reads
        sub-section under the existing namespace-safety section.
        New scripts/audit-sysfs-in-lib.sh + CI gate in
        .github/workflows/rust.yml. Fails the build if any
        /sys/class/net/ or /proc/sys/ read appears in
        crates/nlink/src/netlink/ outside sysctl.rs. Skips
        rustdoc comments via in-script prefix filter.
      • Integration test backfill (tests/integration/ cycle_0_19_backfill.rs, 6 root-gated tests):
        • Plan 188 §2.1 ConfigDiff::apply round-trip
        • Plan 188 §2.4 NetworkConfig::apply_reconcile happy path
        • Plan 188 §2.7 del_table_if_exists idempotence
          (cold/warm/cold-again triplet)
        • Plan 202 §2.3 multipath route round-trip — the
          headline test the parser plan was named to fix
        • Plan 200 §2.1 facade apply::network_in_namespace
          composition + diff symmetry
        • Plan 200 §2.4 Stack orchestration + re-apply no-op
          Plus Plan 196 + Plan 199 module-gated tests
          (require_module!("wireguard")) covering full GENL
          round-trip + watcher polling.
    • High-level facade APIs (Plan 200) — three thin
      compositional wrappers + a unified Stack type that
      collapse the typed surface's 5–15-line boilerplate into
      one-liners.

      • nlink::facade::apply::network(cfg).await? — opens a
        fresh Connection<Route> + calls apply.
        Same shape for nftables(...) (computes diff +
        applies), wireguard(...) (uses the async GENL
        family-resolution path). *_in_namespace(ns, cfg)
        siblings for each.
      • nlink::facade::diff::* — symmetric drift-detection
        wrappers (NetworkConfig → ConfigDiff, NftablesConfig →
        NftablesDiff, WireguardConfig → WireguardConfigDiff).
      • nlink::facade::watch::route_changes()
        one-line resync-wrapped event stream for RTNETLINK
        (mirrors Plan 191's into_events_with_resync with the
        factory closure built for you). nftables_changes()
        same for Plan 185. wireguard_changes(opts).await?
        returns a WireguardWatcher for the polling path
        (Plan 199 — kernel has no multicast).
      • nlink::facade::Stack — bundles NetworkConfig +
        NftablesConfig + WireguardConfig with optional
        layers. Stack::apply calls them in dependency order
        (RTNETLINK → nftables → WireGuard), returning a
        StackApplyReport with per-layer counters.
        Stack::diff aggregates per-layer diffs into
        StackDiff::is_empty() for fast "is anything dirty"
        checks.
      • ovpn intentionally absent — the kernel ovpn family is
        bleeding-edge (6.16+) and nlink ships only the link
        half (Plan 190 §2.3b). Plan 197 (GENL-side ovpn
        declarative) needs the imperative ovpn GENL family
        implemented first; deferred to a future cycle for
        kernel-ABI stability.
        3 unit tests on the StackDiff / StackApplyReport
        no-op semantics.
    • Declarative WireGuard configuration (Plan 196)
      the GENL-family twin of NetworkConfig / NftablesConfig.
      WireguardConfig::new().device("wg0", |d| ...) builder
      shape with .private_key, .listen_port, .fwmark,
      and .peer(public_key, |p| ...) accepting .endpoint,
      .persistent_keepalive, .preshared_key, .allowed_ip.
      cfg.diff(&conn).await computes the symmetric diff
      against current kernel state; cfg.apply(&conn).await
      dispatches the kernel mutations.

      • WireguardConfigDiff / DeviceChanges / PeerChanges
        public diff types. change_count counts kernel
        calls, not dirty fields (a single SET_DEVICE collapses
        all device-level changes into one write).
      • allowed_ips diff is order-independent — declaring
        in one order vs the kernel reporting in another
        doesn't churn.
      • WireguardApplyResult reports device_writes +
        peer_writes + peer_removals separately.
      • Privacy-key caveat: the kernel never returns
        private_key / preshared_key on GET_DEVICE. When
        declared in the config, they're ALWAYS written
        (idempotent at the WG protocol layer — no handshake
        storm — but costs one extra SET call per re-apply).
        Omit them after first apply for zero-op re-applies.
      • Apply uses replace_allowed_ips() for in-config peers
        so the declarative model is "this is the full set",
        not "merge."
        13 new unit tests on the pure diff logic: builder
        round-trips, private-key dirty semantics, listen_port
        match/mismatch, peer add/remove/modify, endpoint
        change, allowed_ips set difference, allowed_ips
        order-independent comparison, change_count
        aggregation.
    • WireGuard polling watcher (Plan 199, redesigned)
      the kernel wireguard GENL family declares
      n_mcgrps = 0; verified 2026-05-31 via
      drivers/net/wireguard/netlink.c upstream. There is no
      native event-subscription surface — every WG monitoring
      tool polls GET_DEVICE on a cadence. nlink now ships a
      typed poll-and-diff primitive so consumers don't
      re-implement that machinery per app.

      • WireguardEvent enum: PeerAdded, PeerRemoved,
        PeerEndpointChanged, PeerHandshakeRefreshed,
        PeerAllowedIpsChanged. First poll emits PeerAdded
        for every existing peer (initial-inventory semantics,
        matching Plan 185 / 191 snapshot shape).
      • WireguardWatchOptions builder: .interval(d) +
        .interface(name). Default cadence 1 s. The watcher
        does NOT auto-enumerate WG-kind interfaces — caller
        specifies the set explicitly.
      • WireguardWatcher::new(conn, opts) returns Result
        (validates non-empty interfaces);
        next_events().await -> Result<Vec<WireguardEvent>>
        sleeps then polls + diffs. connection() /
        into_connection() give callers their socket back.
      • diff_device_states(ifname, prev, curr) pure
        function exposed for callers wiring custom polling
        cadences.
      • If the kernel grows multicast support (Linus Lotz's
        2021 patch is "Awaiting Upstream" — never merged),
        this watcher will be replaced with a multicast
        subscriber and the polling path will become a
        compatibility shim. The WireguardEvent enum shape
        stays the same either way.
        11 new unit tests on the pure-function diff. Closes
        what Plan 191 §8 punted to a separate plan.
    • SetKeyType::InetProto + Concat(Vec<_>) (Plan 198
      §2.1, scoped subset)
      — extends the nftables set key
      taxonomy with the two real-world variants the
      research-agent audit flagged: InetProto (single u8
      protocol — tcp/udp/icmp) and Concat(Vec<_>)
      (composite key used in rules like ip saddr . tcp dport). type_id() for Concat packs each
      component's 6-bit type ID into sequential slots,
      matching the kernel's nft_set_ext_concat
      layout. len() returns the per-component sum after
      4-byte alignment padding. Note: SetKeyType lost
      Copy (the Concat variant carries a Vec); the
      enum is #[non_exhaustive] so this is mitigated, but
      any downstream let k: SetKeyType = ...; that
      expected Copy semantics needs a .clone(). The
      imperative Set builder + downstream wire-emit code
      in the lib was unaffected.
      The fuller Plan 198 — DeclaredSet declarative type,
      SetFlags bitflags, element diff, DeclaredTableBuilder::set
      — stays as a 0.20 follow-up; this commit ships the
      imperative taxonomy extension so future declarative
      work has the right wire types. 5 new unit tests pin
      InetProto wire constants + Concat length/packing
      on 1/2/3-component shapes.

    • #[must_use] on the diff + result + report types
      (Plan 201 §2.1, scoped subset)
      ConfigDiff,
      NftablesDiff, ApplyResult, both ReconcileReports
      (TC recipe + nftables) gain #[must_use] with a
      specific message pointing the caller at the right
      accessor (.apply(), .is_success(), .is_noop()).
      Catches the easy-to-forget shape "build a diff,
      forget to call apply()." Three in-tree integration
      test sites that intentionally discarded a
      ReconcileReport were updated to let _ = ... to
      silence the new lint. Plan 201's broader sweep
      (every *Builder, From/Into, Display,
      #[inline]) remains as a polish backlog; this commit
      ships the highest-leverage subset matching the cycle's
      existing diff/apply API surface.

    • **RouteMessage::multipath() accessor + ParsedNextHop

      • RTA_MULTIPATH parser (Plan 202)** — closes a gap
        surfaced by Plan 193 §2.2's audit: nlink could WRITE
        multipath routes (write_multipath_v4 / _v6) but
        didn't PARSE them back. Multipath routes round-tripped
        through Connection<Route>::get_routes() lost their
        nexthop list. The drift-detection consequence: any
        NetworkConfig carrying a multipath route would see
        "kernel has no nexthops; config wants 2" forever, even
        though the kernel ACK'd the write.
        Adds:
      • parse_multipath(data, family) walker — defensive
        guards per Plan 193 §2.2 + CLAUDE.md §"Parser
        robustness" rule 2 (rtnh_len < HDRLEN aborts;
        rtnh_len > remaining bytes aborts; rtnh_len == 0
        aborts; offset.max(HDRLEN) advance prevents stall).
      • ParsedNextHop struct: ifindex + weight (1-based,
        matching ip route + imperative NextHop::weight) +
        flags + gateway: Option<IpAddr>.
      • RouteMessage::multipath() accessor returning
        Option<&[ParsedNextHop]>.
      • RTA_MULTIPATH = 9 const in attr_ids.
        6 unit tests pin the contract: normal walk, empty
        buffer, zero-length rtnh, undersized rtnh header,
        truncated chain, garbage nested attrs. The zero-
        length + truncated tests guard against the
        netlink-packet-route #152 infinite-loop shape.
    • Concurrent-stress regression tests (Plan 194)
      two new root-gated integration tests preempting
      bug-shapes the rtnetlink Rust crate hit recently:

      • concurrent_dumps_on_shared_connection_route_correctly
        spawns 16 concurrent get_links() calls on a shared
        Arc<Connection> and asserts every dump sees the
        pre-created dummy0. Pins nlink's seq-routing
        correctness against the kind of bug rtnetlink #131
        surfaced (replies routed to the wrong receiver).
      • concurrent_namespaces_dont_corrupt_each_other spawns
        16 concurrent LabNamespace::new calls, each with a
        uniquely-named dummy interface; verifies each
        namespace's dump returns only its own dummy. Pins
        the namespace creation path against rtnetlink #132's
        race-shape.
        Both tests are expected to GO GREEN — Plan 170's seq-
        filter + Plan 172's recv-loop audit defenses are
        already in place. If either turns red on the
        privileged-CI gate, a follow-up fix lands per Plan 194
        §3.2 / §3.3.
    • ResyncStreamExt combinators on resync streams
      (Plan 195 §2.1 + §2.3)
      — kube-rs-style composable
      adapters over Connection<{Route,Nftables}>::into_events_with_resync's
      output. The trait blanket-impls over any
      Stream<Item = Result<ResyncedEvent<T>>> + Unpin, so it
      applies to both watchers without per-protocol
      duplication.
      Adapters shipped:

      • predicate_filter(key_fn) — drops consecutive
        Event(T) / Resynced(T) items whose key matches the
        previously-emitted item's key; Marker items always
        pass through (they're state-machine signals).
      • map_event(f) — projects the inner T to a
        domain-specific type via the closure; Marker items
        pass through unchanged.
        default_backoff() + StreamBackoff (Plan 195 §2.2)
        deferred — most consumers handle restart backoff at the
        spawn-loop level via tokio::time::sleep; in-stream
        backoff would need tokio::time::Sleep Pin gymnastics
        that don't justify the LOC without a current consumer
        ask. 4 new unit tests pin the dedup + map + marker
        passthrough + error propagation contracts.
    • Documentation + tracing-span audit (Plan 192 D4 + W7)

      • D4: link.rs rewrote 10 "namespace-safe variant that
        avoids reading from /sys/class/net/" docstrings to remove
        the misleading claim. The name-based and index-based
        constructors are both netlink-correct; the difference is
        purely ergonomic. Plan 186 §1's audit confirmed
        resolve_interface is netlink-based end-to-end.
      • W7: Audit + backfill #[tracing::instrument] on
        Connection<P> public methods that grew without spans:
        enable_strict_checking, set_ext_ack, for_namespace,
        subscribe_all, dump_typed, and the 7 streaming-dump
        wrappers (stream_links, stream_routes,
        stream_neighbors, stream_addresses, stream_qdiscs,
        stream_classes, stream_filters). Trivial accessors
        (socket, state, timeout, etc.) deliberately stay
        bare per CLAUDE.md observability guidance. Closes the
        "every Connection method, every netlink request/ack/dump
        cycle" contract from CLAUDE.md §Observability.
      • D1 / D5 / D6 / D2-D3 already shipped in Plans 186 §3c,
        188 §2.2, 188 §2.6, 187 respectively (per the 0.19
        consolidation-pass cross-references).
    • Connection<Route>::into_events_with_resync +
      subscribe_all_with_resync + rtnetlink_snapshot
      (Plan 191 §2.5 + §2.6)
      — RTNETLINK twin of Plan 185's
      nftables resync wrappers. The infra (ConnectionFactory<P>

      • events_with_resync from Plan 185 + impl EventSource for Route + RtnetlinkGroup enum from 0.17) was already
        in place; this commit ships the Route-specific layer:
        rtnetlink_snapshot() walks the current state via the
        existing get_links / get_addresses / get_routes /
        get_neighbors methods, returning a Vec<NetworkEvent>
        of New* variants in the kernel's natural emit order.
        Connection<Route>::into_events_with_resync(factory) is
        the spawn-friendly owned form; subscribe_all_with_resync
        borrows for caller-held queries. Both subscribe to every
        rtnetlink multicast group before returning the stream.
        Closes nlink-feedback §15 + W2 (lab Plan 158d's polling
        fallback can now become subscribe-based watch).
    • serde feature flag — opt-in Serialize derives on every
      public diff/result/report type (Plan 189)
      — gated by a new
      top-level serde feature (opt-in only; included in full).
      JSON shape conventions: structs use rename_all = "kebab-case"
      (links-to-add, not links_to_add); enums use
      rename_all = "snake_case" so unit variants emit bare strings
      ("inet", not {"Inet": null}).
      Types gaining Serialize (in this commit):
      ConfigDiff, NftablesDiff, LinkChanges, DeclaredLink,
      DeclaredLinkType, DeclaredAddress, DeclaredRoute,
      DeclaredRouteType, DeclaredQdisc, DeclaredQdiscType,
      QdiscParent, LinkState, MacvlanMode, BondMode,
      VlanProtocol, NetkitMode, NetkitPolicy, NetkitScrub,
      AdSelect, LacpRate, Family, Hook, ChainType,
      Priority, Policy, DeclaredTable, DeclaredChain,
      DeclaredRule (body field skipped), DeclaredFlowtable,
      RuleHandle, ApplyResult, ApplyError (error field
      serialized as the Display string), ReconcileOptions
      (tc recipe + nftables — both shapes), ReconcileReport
      (tc recipe + nftables), StaleObject, UnmanagedObject,
      TcHandle, FilterPriority.
      Use case: apply --check --json envelopes for CI gates and
      IaC tooling. The kebab-case shape matches nlink-lab's
      existing schema convention. Deserialize is not derived
      this commit — the diff types are not user-constructible
      (they're products of compute_diff), so round-trip
      deserialization adds no consumer value. Closes
      nlink-feedback §9 + W4.
      5 new JSON-shape unit tests in crate::serde_tests (gated
      on feature = "serde").

    • ConfigDiff::apply inherent method (Plan 188 §2.1)
      matches NftablesDiff::apply's shape from Plan 157.

      let diff = cfg.diff(&conn).await?;
      println!("{diff}");
      diff.apply(&conn, ApplyOptions::default()).await?;
      

      More efficient than NetworkConfig::apply when you already
      hold a diff — saves one re-dump round-trip.

    • RouteBuilder::default_v4() + default_v6()
      (Plan 188 §2.3)
      — declarative-side mirror of
      Ipv4Route::default_route() / Ipv6Route::default_route()
      (Plan 184). Self-documenting:

      RouteBuilder::default_v4().via("192.0.2.1")
      
    • GSO/GRO/TSO cap parsing on LinkMessage (Plan 190
      §2.3c)
      — 7 new u32 accessors: gso_max_segs,
      gso_max_size, gro_max_size, tso_max_size,
      tso_max_segs, gso_ipv4_max_size,
      gro_ipv4_max_size. The 4 legacy caps were already
      defined in the IflaAttr enum but not extracted by the
      message parser; this commit adds the parsing AND the
      two new IPv4-specific caps from kernel 6.6+
      (IFLA_GSO_IPV4_MAX_SIZE=63,
      IFLA_GRO_IPV4_MAX_SIZE=64). All 7 accept-larger-than-
      expected on attribute length per CLAUDE.md
      §"Parser robustness" rule 1. Useful for throughput
      tuning on heterogeneous NICs (mixed v4/v6 workloads on
      the same box). 3 new unit tests: parses all 7 caps,
      absent-attrs-stay-None, IflaAttr enum numeric pinning.

    • ovpn link half (kernel 6.16+) — OvpnLink +
      LinkBuilder::ovpn + DeclaredLinkType::Ovpn
      (Plan 190 §2.3b)
      — minimal in-kernel OpenVPN
      data-channel-offload link. Imperative OvpnLink ~50 LOC
      (matching the IfbLink shape). Declarative path: zero-arg
      LinkBuilder::ovpn() plus the Ovpn enum variant.
      Useful for inventory tools that need to detect ovpn
      interfaces. Peer / cipher config stays in the GENL ovpn
      family — deferred to Plan 197 in 0.20 as a parallel
      declarative track alongside WireGuard's peer config.
      2 new unit tests.

    • **netkit declarative path (kernel 6.7+) — LinkBuilder::netkit

      • DeclaredLinkType::Netkit (Plan 190 §2.3a)** —
        BPF-programmable veth pair. Imperative NetkitLink +
        NetkitMode + NetkitPolicy + NetkitScrub already
        shipped in 0.16; this lifts them to NetworkConfig. Five
        setters: netkit_mode (L2/L3), netkit_primary_policy /
        netkit_peer_policy (Forward/Blackhole),
        netkit_scrub / netkit_peer_scrub (kernel 6.10+).
        Enums re-exported via nlink::netlink::config::types.
        Use case: Cilium-style no-bridge service-mesh data plane.
        3 new unit tests.
    • Bond options gap-fill: bond_ad_select, bond_lacp_rate,
      bond_downdelay, bond_updelay, bond_resend_igmp
      (Plan 190 §8)
      — 5 new declarative-path setters on
      LinkBuilder covering the previously-imperative-only bond
      knobs. DeclaredLinkType::Bond grew matching
      Option<...> fields. The imperative BondLink already
      exposes all of these; the apply-path arm forwards them.
      Existing AdSelect + LacpRate enums re-exported via the
      config types module as BondAdSelect / BondLacpRate (no
      new types — single source of truth). Closes the
      consolidation-pass §8 expansion. 3 new unit tests.

    • LinkBuilder::vxlan_local / vxlan_port /
      vxlan_underlay_dev (Plan 190 §2.1)
      — declarative-path
      coverage for the three VXLAN knobs nlink-lab §10 flagged.
      DeclaredLinkType::Vxlan grew local: Option<IpAddr>,
      port: Option<u16>, underlay_dev: Option<String>. The
      imperative VxlanLink already exposes .local(Ipv4Addr) /
      .port(u16) / .dev(name); the apply-path arm forwards
      all three (IPv6 local values silently dropped today —
      the imperative layer is IPv4-only for tunnel-source IPs,
      matching the existing remote handling). 3 new unit
      tests + 1 root-gated integration test reproducing the
      nlink-lab 158e Slice 4 case. Note: idempotent re-apply
      coverage (Plan 190 §2.1 ¶"Idempotence implication") is
      deferred — VXLAN compute_diff parity against the
      kernel's IFLA_VXLAN_* attribute dump would need an
      IFLA_INFO_DATA parser; for now re-apply replays the
      create. Note: DeclaredLinkType::Vxlan widening
      (already #[non_exhaustive]) requires .. rest-pattern
      in downstream matches.

    • LinkBuilder::vlan_protocol(p) + VlanProtocol enum
      (Plan 190 §2.2)
      — declarative-path VLAN protocol selector.
      The imperative VlanLink gains a typed .protocol(VlanProtocol)
      setter alongside the existing .qinq() shortcut.
      DeclaredLinkType::Vlan grew a protocol: Option<VlanProtocol>
      field; None == kernel default (802.1Q). Use
      VlanProtocol::Dot1ad for Q-in-Q. VlanProtocol is
      #[non_exhaustive]. Closes nlink-feedback §12. Note:
      widens DeclaredLinkType::Vlan — downstream pattern matches
      must use .. rest-pattern (the in-tree integration test
      config.rs:136 was updated to demonstrate).
      4 new unit tests.

    • LinkBuilder::vrf(table) + DeclaredLinkType::Vrf
      (Plan 190 §2.3)
      — declarative-path VRF coverage. The
      imperative VrfLink shipped already; this lifts it to
      NetworkConfig. Members enslave via the existing
      LinkBuilder::master chain. The Plan 186 §3c topo-sort
      makes VRF declared after its members still apply
      correctly. Three new unit tests + two new root-gated
      integration tests (gated by require_module!("vrf")).
      Closes nlink-feedback §13 VRF half (WG half deferred to
      Plan 196 for 0.20). Note: this widens the
      DeclaredLinkType enum (already #[non_exhaustive]);
      downstream pattern matches without .. rest-pattern would
      break, see migration guide §"Plan 190".

    • Topo-sort links_to_add so parent-before-child holds
      regardless of declared order (Plan 186 §3c)

      NetworkConfig::apply now stable-sorts the new-links list
      so a VLAN whose parent dummy is in the same apply lands
      AFTER the parent. Independent links keep their declared
      order (the sort is stable). Lifts the "declare parent
      first" footgun that the nlink-lab 158e Slice 3 case hit
      NetworkConfig constructed from a HashMap (where the
      child happens to iterate first) now works. The
      NetworkConfig::link docstring documents the new
      order-independence guarantee. 7 new unit tests pin the
      sort behavior.

    • Integration repro for the VLAN parent ifindex race
      (Plan 186 phase 1)
      — three new root-gated tests in
      tests/integration/network_config_apply.rs:
      vlan_parent_dummy_in_same_apply_succeeds (headline),
      vlan_parent_dummy_declared_in_either_order (hash-defeating
      order; tolerantly records pre-topo-sort behavior),
      vlan_parent_already_exists_in_kernel (control). Plan 186's
      audit found nlink's resolve_interface is netlink-based
      end-to-end (no cache, no sysfs) — the maintainer's
      hypotheses were wrong. The integration repro ships as a
      permanent regression guard; if green, the symptom may not be
      reproducible in our harness and the topo-sort + ordering
      docstring still ship as defensive additions.

    • NetworkConfig::apply_reconcile (Plan 188 §2.4)
      bounded-retry sibling of NetworkConfig::apply, mirroring
      NftablesDiff::apply_reconcile (Plan 157, 0.16). Retries
      on Error::is_busy() / is_try_again() up to
      opts.max_retries times with exponential backoff. For
      RTNETLINK the transient surface is smaller than nftables —
      no batch-end races — but VRF table allocation + neighbor
      cache pressure still benefit. Uses the nftables-side
      ReconcileOptions (the retry-budget shape), NOT the
      crate-root ReconcileOptions (the TC recipe shape with
      fallback_to_apply / dry_run). Plan 187's errno()
      Io-shape fix means raw socket-layer EBUSY/EAGAIN
      classifies correctly now.

    • LinkChanges::Display (Plan 188 §2.5)ConfigDiff::Display
      can render links_to_modify rows compactly:
      ~ link eth0 (mtu=9000, up). Wraps the existing summary()
      (which may itself be deprecated in 0.20).

    • Connection<Nftables>::del_{table,chain,rule}_if_exists
      (Plan 188 §2.7 / feedback W8)
      — idempotent siblings of the
      existing del_* methods. Return Ok(true) when the resource
      was deleted, Ok(false) when it didn't exist (kernel ENOENT).
      Replaces the universal let _ = conn.del_table(...).await;
      ignore pattern.

    • Error::chain_walk + root_cause + contexts (Plan 187 §2.2)
      iterator over the source chain that transparently unwraps
      Box<nlink::Error> (the trap the maintainer hit in their 158b
      work). Plus two convenience shortcuts: root_cause() returns
      the deepest nlink::Error in the chain, contexts() collects
      every layer outer-to-inner. New named ChainWalk iterator
      struct exposed at the crate root. Rustdoc on Error::Kernel
      warns about the boxed-source trap + points consumers at
      chain_walk as the escape hatch.

    • Parser robustness policy + CI gate (Plan 193 — Phase 1)
      CLAUDE.md gains a new §"Parser robustness" section
      documenting the three defensive-parsing rules used across
      the lib (accept-larger-than-expected on fixed-size structs,
      pathological-length input guards on header-driven chain
      walks, recoverable per-message parse failures in event
      parsers). New scripts/audit-recv-loop-error-handling.sh
      CI gate fails on a ? operator inside a MessageIter::new
      walking loop in stream.rs. Preempts the bug classes
      tracked by netlink-packet-route #232, #152, and neli #305.
      No consumer action required — the lib already follows the
      rules; the policy + gate prevent future drift.

    Post-cycle audit batch (closes F1 + N1-N9 + Findings A-D)

    A four-agent adversarial audit run after the main 0.19 cycle
    work surfaced one more architectural bug, twelve correctness
    bugs, and four test-coverage gaps. All eleven verified findings
    shipped; Finding E was refuted (false-positive). Tracked
    internally as Plan 194 closeout + the post-audit batch.

    Breaking changes (post-cycle batch)

    • Connection<P>::events() / into_events() are now async
      (Finding B).
      Acquires the connection's request lock for the
      stream's lifetime so concurrent streams on a shared
      Arc<Connection> no longer race on poll_recv. Same change
      cascades through:

      • Connection<Route>::into_events_with_resync / subscribe_all_with_resyncasync fn
      • Connection<Nftables>::into_events_with_resync / subscribe_all_with_resyncasync fn
      • nlink::facade::watch::{route_changes,route_changes_in_namespace,nftables_changes,nftables_changes_in_namespace}async fn

      Migration: add .await at every call site. ~30 line changes
      across nlink's own bins/examples; downstream consumers will
      see a wave of "future used without await" errors during the
      bump.

    • Connection<P>::subscribe() / subscribe_all() /
      subscribe_group() flipped &mut self&self
      (Finding A).
      The underlying syscall is
      setsockopt(SOL_NETLINK, NETLINK_ADD_MEMBERSHIP) which is
      fd-level; the &mut was a stale artefact of routing through
      AsyncFd::get_mut. Same fix on:

      • Connection<Nftables>::subscribe / subscribe_all / subscribe_all_with_resync
      • Connection<Netfilter>::subscribe / subscribe_all
      • Connection<Wireguard|Macsec|Mptcp|Ethtool|Nl80211|Devlink>::subscribe
      • Connection<Route>::subscribe_all_with_resync
      • GENL macro-generated subscribe_group on macro-defined families
      • Internal NetlinkSocket::add_membership / drop_membership

      Migration: remove mut from Connection<P> bindings if it
      was only there for subscribe*. ConnectionPool's
      PooledConnection now supports pool.acquire().await?.subscribe_all()?
      — concurrent subscribe from multiple tasks sharing
      Arc<Connection> is also a legitimate pattern now.

    • Connection::socket_mut() removed. Was the last
      &mut self accessor on Connection; obsolete now that
      add_membership is &self. Internal API (pub(crate));
      the lib's own callers refactored to self.socket().add_membership(group).

    • Connection<P>::request_lock field type:
      Mutex<()>Arc<Mutex<()>> (Finding B prerequisite).

      Required so streams can hold an OwnedMutexGuard whose
      lifetime is independent of the parent's borrow scope.
      #[non_exhaustive] struct so this is source-compatible for
      downstream code that never constructed a Connection literal,
      which is all of them (the constructor is Connection::<P>::new()).

    Concurrency: F1 closed across all protocols

    • F1 — Connection<P> request lock closes the rtnetlink
      #131 shape: two tasks sharing Arc<Connection<P>> would race
      on the recv side, with task A's recv loop consuming task B's
      response from the socket buffer and discarding it via the
      seq filter. Both tasks then blocked indefinitely (or surfaced
      Error::Timeout after Plan 171's 30s default). Added
      tokio::sync::Mutex on Connection<P>, acquired at every
      send+recv-loop method via a new pub(crate) lock_request()
      helper.

      Coverage swept across the central methods in connection.rs
      (send_request_inner, send_ack_inner, send_dump_inner) plus
      every protocol-specific send+recv-loop in
      genl/{wireguard,macsec,mptcp,ethtool,nl80211,devlink}/connection.rs,
      nftables/connection.rs, sockdiag.rs, xfrm.rs, audit.rs,
      netfilter.rs, fib_lookup.rs, batch.rs, plus the public
      GENL escape hatches Connection<P>::command() /
      dump_command() (initially missed in the lock sweep — closed
      in a follow-up commit). 42+ acquire sites across 14 files.

      Regression coverage: Plan 194's
      concurrent_dumps_on_shared_connection_route_correctly test
      (originally #[ignore]'d when this bug was discovered) is
      now green on CI. A second
      concurrent_ack_requests_on_shared_connection_succeed test
      added for ACK-style coverage.

      Trade-off: concurrent ops on a shared Arc<Connection> now
      serialize cleanly (the kernel processes a single socket FIFO
      anyway). For true parallel throughput use
      ConnectionPool<P> — each task gets its own connection.

    • Finding B — DumpStream + EventSubscription lifetime
      lock.
      Stream-shape APIs were the remaining concurrency
      hole: two DumpStreams on a shared connection would both
      call socket.poll_recv and steal each other's frames.
      Closed by storing an OwnedMutexGuard<()> inside each stream
      struct; acquired in the async constructor, released on stream
      drop. See breaking-change entries above for the API-shape
      fallout.

    Verified bugs (post-cycle audit)

    • N1 (CRITICAL) — namespace::create thread-bleed.
      unshare(CLONE_NEWNET) is scoped to the calling thread,
      not the process. When called from a tokio worker thread
      (LabNamespace::new in tests, app code that creates netns
      via tokio runtime), every other tokio task scheduled on that
      worker temporarily observed the new empty namespace until the
      matching setns() restored it — including any Connection<P>
      they constructed, which silently bound to the wrong netns.
      Fix: isolate the unshare+mount+setns sequence on a freshly
      std::thread::spawn'd worker so the bleed is bounded to a
      dedicated thread that does nothing else.

    • N2 (HIGH) — Malformed multicast frame killed unrelated
      request.
      When a Connection<P> was both subscribed
      (multicast) and performing requests, the recv loop saw both
      unicast replies AND multicast frames through the same
      recv_msg().await. A ? propagation on MessageIter parse
      errors fired BEFORE the seq filter could discard the frame,
      killing the request. Fixed: skip parse failures silently in
      the per-frame loop (extends Plan 193 §2.3 rule 3 to
      subscribed-connection request paths). 3 recv loops in
      connection.rs touched.

    • N3 (HIGH) — xfrm.rs from_le_bytes on host-order fields.
      4 sites used u16::from_le_bytes / u32::from_le_bytes for
      netlink attribute headers and XFRM algo fields, which the
      kernel emits in host byte order. Silently broken on big-endian
      platforms (s390x, sparc64, PowerPC-BE). Fixed to
      from_ne_bytes. On LE hosts to_le_bytes and to_ne_bytes
      coincide so this regressed silently; the audit caught it via
      kernel-source cross-check.

    • N4 (HIGH) — RouteMessage::write_to dropped 5 fields.
      source, iif, pref, expires, multipath (the Plan 202
      ECMP nexthop chain) were parsed but never written, silently
      dropping them on get → mutate → set round-trips. Added 5
      builder setters (.source(addr, prefix_len), .iif(ifindex),
      .pref(p), .expires(secs), .multipath(Vec<ParsedNextHop>))

      • 5 emit branches + a write_attr_multipath helper that
        mirrors parse_multipath (rtnexthop chain with nested
        RTA_GATEWAY). ECMP route replay through the typed API works
        now. Roundtrip regression test added.
    • N5 (HIGH) — NeighborMessage::write_to dropped 6 fields.
      probes, port (BIG-endian — VXLAN UDP port), vni,
      ifindex_attr, master, cache_info were parsed but never
      written. Blocked typed VXLAN FDB programming via
      NeighborMessageBuilder — users had to drop to raw
      MessageBuilder. Added 6 builder setters + 6 emit branches +
      write_attr_u16_be (for NDA_PORT) + write_attr_cache_info.
      Roundtrip regression test added.

    • N6 (HIGH) — WireguardWatcher::next_events first-failure
      killed whole watcher.
      Plan 199's per-interface loop used
      ? to propagate get_device_by_name errors. Deleting one
      watched interface out-of-band aborted the entire poll cycle —
      all other interfaces' updates silently dropped, watcher
      stuck. Fixed: match on each per-iface result, log
      tracing::warn! on error, emit PeerRemoved for tracked
      peers on the failed iface, drop it from self.previous,
      continue.

    • N7 (HIGH) — Stack::apply had no pre-flight validation.
      Failure in the WireGuard layer after network + nftables
      succeeded left the host in a partial state (interfaces +
      firewall up, no VPN). Fixed: call self.diff().await? first
      to validate every set layer against current kernel state
      before any mutation. Catches the high-value failure modes
      (missing kernel module, invalid key, family-resolution
      failure, permission, missing netns). Residual race window
      documented in the rustdoc.

    • N8 (MEDIUM) — parse_af_spec_vlans / _tunnels dropped
      orphan RANGE_BEGIN.
      Consecutive RANGE_BEGIN markers (no
      intervening RANGE_END) silently overwrote the prior
      range_start and dropped the entire prior range. Trailing
      RANGE_BEGIN at end of chain also dropped. Plan 193 rule 2
      ("pathological-length input guards") requires defensive
      handling. Fixed: emit prior range_start as a single
      VLAN/tunnel with a tracing::warn!, then start the new
      range. Symmetric fix for the VLAN + tunnel parsers.

    • N9 (MEDIUM) — 6 sibling parsers used le_u16 for
      host-order nla_len/nla_type + wrong mask in rule.rs.

      messages/{rule,address,link,neighbor,route,tc}.rs all
      parsed struct nlattr headers as little-endian. Also
      rule.rs masked 0x7fff instead of canonical
      NLA_TYPE_MASK = 0x3fff, so any future kernel attr shipped
      with NLA_F_NET_BYTEORDER would silently miss every match
      arm. Fixed all 6 to take(2) + from_ne_bytes (winnow has
      no ne_u16); rule.rs uses NLA_TYPE_MASK.

    • Finding A (HIGH) — subscribe blocked through ConnectionPool.
      See breaking changes above.

    • Finding C (MEDIUM) — Pool::invalidate capacity decay.
      PooledConnection::invalidate dropped the broken connection
      without replacing it. After N invalidates a size-N pool's
      acquire() would block indefinitely. Fixed: added a
      Factory<P> trait on PoolInner capturing the namespace +
      sync/async build mode. invalidate now tokio::spawns a
      task that calls factory.build().await and try_sends the
      replacement into the pool's mpsc. Capacity recovers.
      Integration test
      (pool_invalidate_replenishes_capacity) asserts the
      replenish lands.

    • Finding D (LOW) — Connector::send_proc_control missed F1
      lock.
      Send-only path didn't acquire request_lock,
      violating the doc invariant. Fixed: acquire the lock for the
      send. Also corrected misleading comment "NLMSG_DONE" → the
      actual value (0) is NLMSG_NOOP. No ACK to drain
      (NLM_F_ACK is not set; cn_proc doesn't emit one).

    • Finding E — Batch::send_chunk stale-seq window: REFUTED.
      Original audit agent claimed the recv loop's seq matching
      accepted stale frames from earlier requests via a > end_seq
      window check. Re-read: the code uses per-op exact seq
      matching (ops.iter().position(|op| op.seq == header.nlmsg_seq)),
      not a window. Agent hallucinated the check. No fix needed;
      documented for future audit cycles.

    Test backfill

    • Plan 204 C1 — Verdict::Jump + Verdict::Goto kernel
      round-trip.
      Asserts the rule survives the kernel commit
      AND the raw NFTA_RULE_EXPRESSIONS bytes contain the
      big-endian encoding of the correct verdict constants
      (NFT_JUMP = -3, NFT_GOTO = -4) and NOT the pre-fix wrong
      constant (NFT_BREAK = -2). CI surfaced that
      NFTA_VERDICT_CODE is actually __be32 on the wire — the
      test correctly uses to_be_bytes() to assert the
      protocol-correct encoding.

    • Plan 211 M1 — Hook::InetIngress kernel acceptance.
      Installs a Prerouting chain AND an InetIngress chain at the
      same priority on the same Inet family table. Pre-fix the
      second chain would EEXIST (both aliased to hook 0); post-fix
      InetIngress = NF_INET_INGRESS (5) so they coexist. Skips
      gracefully on kernels < 5.10.

    • Plan 191 — Route subscribe_all_with_resync live events.
      Asserts that a live multicast event (a dummy link addition)
      arrives wrapped in ResyncedEvent::Event(NewLink) through the
      resync stream. The Route-side glue is a separate code path
      from the Nftables side; this guards against a silent
      regression that would drop the wrapper.

    • F1 sweep gap — concurrent_ack_requests_on_shared_connection_succeed.
      Extends Plan 194's regression coverage to ACK-style ops.
      16 concurrent add_link calls on a shared
      Arc<Connection<Route>>; all must succeed and the final
      dump must see all 16 dummies.

    Downloads
  • 0.18.0 3c6dd77c70

    nlink 0.18.0 Stable

    p13marc released this 2026-05-29 10:22:27 +00:00 | 345 commits to master since this release

    Added

    • DeclaredChainBuilder::chain_type(ChainType) (Plan 180)
      — closes the parity gap between the imperative
      Chain::chain_type and declarative DeclaredChain paths.
      Required for declarative NAT chain reconcile via
      NftablesConfig::diff().apply(): a chain hooking
      prerouting/postrouting without chain_type(ChainType::Nat)
      defaults to ChainType::Filter, and any masquerade/
      snat/dnat verdict refuses to load with EOPNOTSUPP.
      Unblocks downstream consumers (e.g. nlink-lab) migrating
      to the declarative path. Mirrors the imperative builder's
      rustdoc + invariants.

    • Chain::device(name) + DeclaredChainBuilder::device(name)
      (Plan 180, adjacent gap)
      — bind a netdev base chain to a
      specific interface (type filter hook ingress device eth0 priority -150). Wires NFTA_HOOK_DEV (constant 3 inside
      the NFTA_CHAIN_HOOK nest). Required for Family::Netdev
      base chains; ignored on other families. Both imperative and
      declarative paths gained the setter. ChainInfo now exposes
      device: Option<String> populated from dump responses, and
      is now #[non_exhaustive] (only construction site is
      internal parse_chain — no breaking change for downstream).

    • list_tables_in(family) / list_chains_in(table, family)
      / list_flowtables_in(table, family) /
      list_sets_in(table, family) (Plan 181)
      — server-side
      filtered dump methods on Connection<Nftables>. Mirror the
      existing list_rules(table, family) shape: each new method
      emits the corresponding NFTA_*_TABLE attribute +
      nfgen_family so the kernel returns only matching entities.
      More efficient than list_*().filter(...) on hosts with
      many tables. The unfiltered counterparts keep working
      unchanged. Integration test
      list_in_filters_match_only_target_table exercises all
      four _in shapes against a two-table fixture.

    • Error::ext_ack() -> Option<&str> +
      Error::ext_ack_offset() -> Option<u32> (Plan 182)

      inherent accessors over the Kernel / KernelWithContext
      variants' fields. Saves consumers from writing a 5-line
      match | _ => ceremony at every site (forced by the
      #[non_exhaustive] attribute on those variants). Matches
      the existing errno() -> Option<i32> shape.

    • impl Display for NftablesDiff + impl Display for ConfigDiff (Plan 183)println!("{diff}") now works
      directly. Wraps the existing summary() methods, so the
      rendered output is unchanged from diff.summary().

    • Ipv4Route::default_route() /
      Ipv6Route::default_route() (Plan 184)
      — self-documenting
      zero-arg constructors for 0.0.0.0/0 and ::/0.
      Equivalent to the iproute2-muscle-memory
      Ipv4Route::new("0.0.0.0", 0) form; pick whichever reads
      better in context.

    • Connection<Nftables>::into_events_with_resync(factory) +
      subscribe_all_with_resync(factory) (Plan 185)

      ENOBUFS-resilient nftables event watching. Mirrors
      kube_rs::watcher(api, cfg) -> Stream: hand in a factory
      closure that opens a fresh Connection<Nftables> on demand,
      receive a Stream<Item = Result<ResyncedEvent<NftablesEvent>>>.
      When the kernel drops events under pressure (-ENOBUFS),
      the wrapper re-dumps the ruleset via a fresh connection and
      emits the snapshot as Resynced(...) items between
      ResyncMarker::ResyncStart / ResyncEnd markers. The owned
      form (into_events_with_resync) returns a 'static + Send
      stream that's tokio::spawn-friendly; the borrowed form
      (subscribe_all_with_resync) keeps &mut self around for
      ad-hoc queries on the same connection. New module
      nftables::resync exports nftables_snapshot,
      ConnectionFactory, ConnectionFuture, and the
      OwnedResyncStream / BorrowedResyncStream<'_> type aliases.
      Recipe: docs/recipes/nftables-watch-with-resync.md.
      Closes nlink-lab Wishlist item 5.

    • NftablesEvent::NewSet(SetInfo) +
      DelSet(SetInfo) (Plan 185, bundled)
      — bundled with
      the resync wrapper because the snapshot enumerates sets, so
      drift-detection consumers need to see set creates/deletes.
      Wires NFT_MSG_NEWSET / NFT_MSG_DELSET through
      parse_nftables_event using the existing parse_set parser.
      Set elements (NFT_MSG_NEWSETELEM) remain unwired; open an
      issue if you need them. NftablesEvent carries
      #[non_exhaustive] already, so this is non-breaking.

    Audit fixes

    • ConnectionFactory<P> + ConnectionFuture<P> are now
      generic + live at the crate root (Plan 185 audit)
      — Plan 185
      spec called for ConnectionFactory<P> at the crate root; the
      first cut shipped a non-generic nftables::resync::ConnectionFactory
      pinned to Nftables. Aligned now: both types live in
      nlink::netlink::resync (re-exported as nlink::ConnectionFactory<P>
      / nlink::ConnectionFuture<P>). Existing call sites add the
      <Nftables> turbofish, matching the established
      Connection<P>::new() pattern. Future protocol watchers can
      reuse the same alias without redefining it.

    • Plan 181 wire-shape unit tests landed — 4 tests covering
      build_list_tables_request / build_list_chains_request /
      build_list_flowtables_request / build_list_sets_request,
      matching Plan 181 §5 acceptance. Extracted the request-builder
      bodies into free pub(crate) functions so the tests can
      inspect the on-wire bytes without socket I/O. No behavioral
      change to the public list_*_in methods.

    • Plan 185 ENOBUFS-recovery integration test landed — new
      into_events_with_resync_recovers_from_enobufs (root-gated)
      drives the wrapper end-to-end through a real kernel overflow:
      shrinks the multicast subscriber's SO_RCVBUF to 256 bytes,
      spawns a 2k-iteration rule-add flood from a second connection,
      drains the resync stream slowly, asserts the
      ResyncStart → Resynced(...) → ResyncEnd marker sequence.
      Needed a new NetlinkSocket::set_rcvbuf(bytes) helper
      (SO_RCVBUFFORCE — requires CAP_NET_ADMIN, matches the
      existing root-gated test scope).

    • ChainInfo.chain_type is now Option<ChainType> (was
      Option<String>) (Plan 180 audit)
      — Plan 180 spec called
      for a typed enum on the dump-side field; the first cut
      shipped a raw string for parser convenience. Aligned now:
      parse_chain maps the kernel's "filter"/"nat"/"route"
      string into the typed ChainType variant; unrecognised
      values (kernel can grow new chain types) yield None.
      Added ChainType::from_kernel_string(&str) -> Option<Self>
      as the canonical mapping. Affects only downstream code that
      read ChainInfo.chain_type directly — typed match arms keep
      working, stringly comparisons (== Some("nat".into())) need
      to become == Some(ChainType::Nat).

    Breaking changes (lib internals)

    • events_with_resync is now lifetime-generic (Plan 185)
      the snapshot-future bound went from Send + 'static to
      Send + 'a. Existing call sites that handed in 'static
      closures keep working unchanged (the 'a parameter defaults
      via lifetime elision to whatever satisfies the caller). The
      refactor unlocks the borrowed subscribe_all_with_resync
      variant whose snapshot future doesn't need to outlive the
      caller's stack frame.
    Downloads
  • 0.17.0 1a38a10f4a

    nlink 0.17.0 Stable

    p13marc released this 2026-05-26 07:05:54 +00:00 | 362 commits to master since this release

    Breaking changes

    • Register discriminants changed (Plan 178) — switched
      from NFT_REG32_xx (8..=11, 4-byte register aliases) to the
      canonical NFT_REG_x form (1..=4, 16-byte registers).
      Downstream code that cast Register::R0 as u32 and stored
      the literal value 8 will see 1 instead. The lib's
      wire-format behavior is unchanged from the kernel's
      perspective — the kernel canonicalizes both forms to
      NFT_REG_1 internally — but anyone matching the raw integer
      needs an audit. Enum now carries #[repr(u32)], locking the
      memory layout so the as u32 cast is well-defined.

    • NftablesDiff::rules_to_delete tuple shape: changed
      from Vec<(String, Family, RuleHandle)> to
      Vec<(String, Family, String, RuleHandle)> — the chain name
      is now carried explicitly. The kernel rejects a DELRULE
      with an empty NFTA_RULE_CHAIN even when
      NFTA_RULE_HANDLE pins the rule, contrary to an earlier
      assumption. Plan 178 closeout.

    Fixed

    • Connection::send_request / send_ack no longer error
      when the socket is also subscribed to multicast groups

      both recv paths did a single recv_msg() and bailed if the
      returned frame happened to carry only multicast events
      (seq=0) instead of the unicast ACK to the just-sent
      request. They now loop on recv_msg() until a frame with a
      matching seq arrives, ignoring unrelated multicast events
      along the way. Same canonical shape Plan 172 enforced on the
      dump-loops; the 30s default operation timeout (Plan 171)
      bounds the loop. Affected the rare pattern of issuing
      unicast requests on a Connection that's also subscribe'd
      to a group the request mutates (e.g. an event-monitor
      connection that also creates the interface it's about to
      observe).

    • Connection::<Nftables>::send_batch no longer hangs on a
      missing batch-end ACK (Plan 170)
      — the recv-loop didn't
      filter by nlmsg_seq and terminated on the first per-op ACK
      rather than the BATCH_END's ACK. A sequence-skew on the GHA
      rust:bookworm container surfaced this as a 22-minute hang
      on the 0.16 cut CI. Fix: seq-filter + end-seq termination +
      NLM_F_ACK on NFNL_MSG_BATCH_END. The canonical
      recv-loop shape is now documented in CLAUDE.md and audited
      across all 9 lib recv-loops (see Plan 172 under "Changed").
      Un-ignored 4 of the 7 nftables_reconcile::* tests this
      blocked; the remaining 3 became Plan 178.

    • NftablesConfig::diff body-bytes false-positive (Plan 178)
      — keyed rules were flagged as to_replace on every idempotent
      re-diff, churning kernel state on every reapply for any caller
      of the declarative-config reconcile loop. Three coordinated
      fixes (see "Breaking changes" above for the two API-level
      ones; the third is the diff-path internal):

      • normalize_tlv in the diff path: walks both sides of
        the comparison as TLV trees, strips NLA_F_NESTED (0x8000)
        and NLA_F_NET_BYTEORDER (0x4000) hint bits, and sorts
        sibling attributes by type at every depth. Closes the
        writer-vs-kernel divergence on the NESTED bit (writer set
        it on every nest; kernel doesn't on its outgoing
        serialization) and intra-nest attribute ordering (writer
        emits in source order; kernel in canonical numeric order).
      • Un-ignored 3 nftables_reconcile::* tests (idempotent
        reapply, replace, delete) that were #[ignore]'d under
        Plan 178 tracking. They now exercise the full diff +
        apply + re-diff cycle including delete-by-handle.

    Added

    • Bottleneck::score: f64 normalized severity (Plan 169 Phase 3)
      Diagnostics::find_bottleneck() now returns a Bottleneck
      with a score: f64 field (range 0.0..=1.0) computed from
      drop_rate × 0.6 + backlog_pressure × 0.3 + error_rate × 0.1,
      saturating at 1.0. Backlog and error components are gated on
      the bottleneck's BottleneckType (only counted when
      applicable), so a pure hardware-error bottleneck scores on
      the error component alone. Useful for sorting multiple
      bottlenecks by severity in a controller dashboard. 6 unit
      tests cover empty input, pure-component scores, composite
      scores, saturation, and type-gating.

    • From<AddressParseError> + From<RouteParseError> for
      nlink::Error (Plan 173)
      — removes the
      .map_err(|e| nlink::Error::InvalidMessage(e.to_string()))?
      ceremony in NetworkConfig caller chains. The two parse-
      error types are now #[from] variants on nlink::Error, so
      ? propagates them cleanly:

      // before
      let addr: Address = "10.0.0.1/24"
          .parse()
          .map_err(|e: AddressParseError| nlink::Error::InvalidMessage(e.to_string()))?;
      // after
      let addr: Address = "10.0.0.1/24".parse()?;
      

      examples/config/declarative.rs updated accordingly.

    • docs/release-validation-manual.md (Plan 176) — pre-cut
      hardware-validation checklist for the lib paths no CI can
      exercise (XFRM IPsec offload, Devlink rate, Devlink port
      function state, net_shaper caps). Documents per-feature
      expected outcome + failure-mode triage. The cut script
      (scripts/cut-release.sh) points at this file before the
      irreversible publish step. Introduces the
      `> ⚠ No CI coverage — manually validated YYYY-MM-DD against

      ` CHANGELOG annotation convention for future
      hardware-only feature entries. Self-hosted-runner +
      vendor-cloud-lab paths sketched as future plans for the day
      a real downstream needs CI coverage on these paths.

    • scripts/cut-release.sh (Plan 175) — one-shot orchestrator
      for an nlink release cut. Walks the Plan 167 sequence end-to-
      end with confirmation prompts at the irreversible steps
      (publish, merge, tag-push). Bakes in the three friction points
      hit during the 0.16 cut:

      • skips cargo publish -p nlink --dry-run (known false
        negative — nlink-macros isn't on crates.io yet at that
        point);
      • automates the ## [Unreleased]## [X.Y.Z] - YYYY-MM-DD
        CHANGELOG promotion;
      • detects when the CHANGELOG section exceeds GitHub's 125000-
        char release-body limit and falls back to a "highlights +
        link to the full file" template;
      • replaces the manual sleep 30 after cargo publish -p nlink- macros with a poll loop on cargo search (5-min cap).

      Pre-flight checks: clean tree, on the cycle branch, Cargo.toml
      version matches the arg, cargo + gh auth present.

    Changed

    • Connection<P> operations now time out after 30 s by
      default (Plan 171)
      — every Connection<P> method that
      performs a netlink round-trip (every getter, setter, dump,
      batch commit) now wraps the underlying recv loop in a
      30-second tokio::time::timeout. Before 0.17.0, the timeout
      was opt-in via Connection::timeout(Duration) and the
      default was None — a kernel that never responded would hang
      the call indefinitely. Driven by the 0.16 cut's evidence (a
      22-minute GHA hang that should have been a clean
      Error::Timeout). Override per-Connection with
      .timeout(Duration); opt out with .no_timeout().

      Callers whose ops legitimately take > 30 s should bump the
      timeout explicitly via .timeout(...) or stream the dump in
      chunks via the dump_stream* APIs (which apply the timeout
      per-chunk, not over the whole dump).

    • All 9 recv-loops in the lib routed through
      self.with_timeout (Plan 172)
      — the Plan 170 hang pattern
      (no seq filter + indefinite block on missing DONE marker)
      was audited across every lib recv-loop; 8 of 9 were already
      structurally defensive but lacked the Plan 171 timeout
      wrap. Sites updated: nftables::{send_batch, nft_dump},
      genl/{wireguard, macsec, mptcp, ethtool} dump-collection,
      genl/devlink (3 loops), genl/nl80211
      (collect_dump_responses, wait_ack). The canonical recv-
      loop shape is documented in CLAUDE.md "Recv-loop shape
      (canonical)" — required for any new loop added to the lib.

    • CI observability (Plan 174) — three related improvements
      so the next hidden hang takes 1 CI iteration to diagnose
      instead of 3:

      • nlink::lab::init_test_tracing() (lab-feature only) wires a
        libtest-friendly tracing-subscriber honoring RUST_LOG.
        Auto-invoked by nlink::require_root!() /
        require_root_void!() so every integration test path emits
        the lib's #[tracing::instrument] spans without per-test
        boilerplate. The integration CI job sets
        RUST_LOG=nlink=debug,nlink::netlink::nftables=trace so a
        hang surfaces which method was in flight at the failure
        point.
      • .github/workflows/integration-tests.yml modprobes
        nf_tables + nf_flow_table explicitly (previously relied
        on kernel auto-load; documents intent + survives locked-down
        containers).
      • crates/nlink/tests/integration/IGNORED.md catalogs every
        #[ignore]'d test (13 total — 12 diagnostics.rs migration
        candidates tracked by Plan 179 + 1 kernel-build-dependent
        conntrack test) with reason category + tracking plan;
        scripts/audit-ignored-tests.sh (wired into rust.yml as
        audit-ignored-tests) fails on any new ignore missing a
        catalog entry.
    Downloads