-
nlink 0.25.0 Stable
released this
2026-07-15 06:30:54 +00:00 | 0 commits to master since this releaseAdded
- Path-based netns create/delete:
namespace::create_path/
namespace::delete_path. Persist a network namespace at an arbitrary
bind-mount path instead of theip netnsconvention/var/run/netns/<name>,
so applications can own their netns directory (clearer ownership, no
collisions with operatorip netns add).create/deletenow delegate to
the path-based core; behavior for the name-based API is unchanged. On a
failedcreate_pathany 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. Infalliblebool(mirrorsexists)
distinguishing a live netns bind-mount from a stale marker file left by
an unclean shutdown, via astatfs(2)nsfs-magic check (/proc/mountsis
unreliable under mount-namespace propagation). Lets a path-based caller
detect-and-clear a stale marker beforecreate_path, which refuses an
existing path.is_namespaceis the named wrapper resolving against
/var/run/netns/<name>.
Fixed
-
nftables:
NftablesConfig::applydestroyed every table it did not declare
(#190).diff()scheduled every table in the kernel that was not in
the declared config for deletion, andapply()committed those deletes in
the atomic batch.NFT_MSG_DELTABLEcascades — all chains, rules, sets and
flowtables in the table go with it — andlist_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 destroyedip 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::labwas
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)),
mirroringDiffOptions::purgeon theNetworkConfigside, 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_APPENDwas never set onNFT_MSG_NEWRULE— the constant was defined
atmessage.rs:237and 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_batchdiscarded mid-batch kernel errors (#199, #209).
Transactionnumbered 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 belowbegin_seq") was false, and its one-sided
if seq > end_seq { continue }filter threw away theNLMSGERRfor a
rejected op. The kernel aborted the batch, itsBATCH_ENDACK never came,
and the caller got an opaque 30-secondError::Timeoutnaming nothing. (This
is the hang thenftables_reconcileintegration tests were wrapped in a
30s timeout to survive.) Conversely, an inner seq that happened to equal
end_seqhad its per-op ACK mistaken for the BATCH_END ACK, returning
Ok(())for a batch that never committed.send_batchnow 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-localNFTA_SET_ID. Transaction
messages also now carry a pid, which they never did. Separately (#209), a
strayNLMSG_DONEno 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/DeclaredSetBuilderare exported (#210). They
are the type of the public fieldNftablesDiff::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
protocolandprioritywere discarded (#201). Every filter
config (U32Filter,FlowerFilter,MatchallFilter, …) has public
.protocol()/.priority()setters, andadd_filter/replace_filter
hardcodedETH_P_IPand 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.FilterConfiggains defaulted
protocol()/priority()accessors (defaultsNone, so external impls
stay source-compatible, matching howset_chainwas added); the shipped
configs override them. NoteMatchallFilterandFlowerFilterdefault to
ETH_P_ALL, so filters that were silently IPv4-only now match everything,
as their builders always claimed. HtbQdiscConfig::r2qnever reached the wire (#213). The field, its
.r2q()setter and ther2qtoken inparse_paramswere all live, but
TcHtbGlob::new()hardcodedrate2quantum: 10with 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
testedtcm_info != 0, buttc_fill_qdisc()setstcm_infoto 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 tcmsgis byte-identical for qdiscs, classes and
filters; the only reliable discriminator is thenlmsg_type, which lives
in the netlink header.TcMessagenow carries it (via a defaulted
FromNetlink::set_msg_typehook the dump and event paths call), and
is_qdisc()/is_class()/is_filter()classify on it. They return
falserather than guessing when the type is unknown.is_qdisc()is new.TCA_STATS_BASIC_HWclobberedTCA_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 intostats_basic/stats_basic_hw;bytes()andpackets()
keep returning the software total.
- Filter
-
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/gbpsare BYTES per second, not bits (#203). iproute2
matches its suffix table withstrcasecmp, so lowercasembpshits the
MBps = 8_000_000entry. nlink mapped the whole family to bits, so every
such string was 8x too small — the exact bits-vs-bytes confusion the
Ratenewtype was introduced to kill. Suffix matching is now
case-insensitive too. The unit test atutil/rate.rs:520was 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 100meant 100 seconds — a 1,000,000x error that
effectively stopped the interface passing traffic.m/min/hare no
longer accepted (tc(8)has no minute suffix, and it made a mistypedms
silently 60,000x too long). - Negative rates and sizes error instead of becoming 0 (#217).
f64 as u64saturates, soget_rate("-5mbit")returnedOk(0)and the
kernel then rejected it with a bareEINVALrather than nlink's
contract-mandated"htb: invalid rate ...". A negative duration was
worse —Duration::from_secs_f64(-1.0)panics, reachable straight
fromNetemConfig::parse_params. Percent::from_strdoc (#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_fractionnow.get_sizealso accepts the binarykib/mib/gib/tibspellings 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/psched—grep -ri pschedreturned zero hits. The kernel runs
tc_tbf_qopt.buffer,tc_htb_opt.bufferandtc_police.burstthrough
PSCHED_TICKS2NS(), so every one of them was wrong:- TBF (#192) wrote the burst as raw bytes. At
rate 1mbitwith the
default 32 KiB burst the kernel read 32 768 ticks (~2.1 ms), computed
max_size ~= 262bytes, andtbf_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. EveryRateLimiter,PerHostLimiterandPerPeerImpairershape
goes through this path. The hardcodedhz = 1000is also gone: iproute2's
get_hz()returns 1e9 on modern kernels, which makes the default burst
exactly one MTU rather thanrate/1000bytes larger. - police (#194) wrote the burst as bytes and never emitted
TCA_POLICE_RATEat all.tcf_police_init()callsqdisc_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'scell_log
is 0. Both held, so a policer with a rate could not install. - The HTB rate table was built with
cell_log = 3while the
accompanyingTcRateSpecleftcell_log = 0, soqdisc_get_rtab()
discardedTCA_HTB_RTAB/CTABoutright — 1024 wasted bytes per class
add, and ATM/ADSLlinklayerhandling 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
realtcyielded a burst off by15.625 / rate, andreconcile
considered a wildly wrong burst to be in sync.
New public
nlink::netlink::pschedmodule:Psched(the cached
/proc/net/pschedclock, with the modern constants as an infallible
fallback), plustc_calc_xmittime/tc_calc_xmitsize/tc_calc_rtable
— ports of iproute2'stc_core.c.tc_calc_rtabletakes&mut TcRateSpec
and stampscell_log/linklayer/cell_alignitself, so a table can no
longer disagree with its own spec. TBF now also emits the byte-valued
TCA_TBF_BURST/TCA_TBF_PBURSTescape 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 existingwrite_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. - TBF (#192) wrote the burst as raw bytes. At
-
setns thread discipline (#185). Four related hardenings on the
per-threadsetnsstate 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::createhas 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 ofsetns-ing the calling thread. - Breaking:
NamespaceGuardis 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.awaiton 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 inDropafter the explicit call.
-
Missing-namespace errors are now classifiable via
is_not_found()
(#184).namespace::open{,_path,_pid},namespace::enter{,_path}
(and thusexecute_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 stringlyError::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_pathuses), and every other open failure passes through as
Error::Iowith its errno intact, sois_permission_denied()& co.
classify correctly too. -
NamespaceWatcher::recv()now actually waits for events (#183). The
inotify fd is always nonblocking (theinotifycrate initializes it with
IN_NONBLOCK), so the previous directread_eventscall surfaced
WouldBlockas an immediateError::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-nativeEventStream(whosestreamfeature 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 →Deletedround-trip).
Breaking (semver-only):NamespaceWatcher/NamespaceEventStreamno
longer implementUnwindSafe/RefUnwindSafe— the tokioAsyncFd
insideEventStreamisn'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_pathhardening (follow-up to #181, same cycle so
no released behavior changes):- The failure rollback removes only directories that are still empty
(remove_dirwalking upward instead ofremove_dir_all), so a failing
create can no longer delete a marker a concurrentcreate_path
installed under a shared ancestor. - The marker file is created with
create_new— a lost race against a
concurrent create (or an operatorip 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
InvalidMessagethat no predicate classified), completing the
detect-stale-and-retry loopis_namespace_pathenables. is_namespace_pathdocuments thatNSFS_MAGICidentifies nsfs, not
the namespace type — a bind-mount of a PID/mount/UTS namespace also
reportstrue.
- The failure rollback removes only directories that are still empty
-
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 frompolicy(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 forDeclaredTable::flags
and for a flowtable's devices/priority/flags.NftablesDiffgains
chains_to_modifyandtables_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_DROPverdict — norejectexpression 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 realrejectexpression. -
nftables:
Expr::Redirectnever redirected (#206). The encoder wrote
NFTA_NAT_REG_PROTO_MIN— an attribute from the nat namespace — into a
redirexpression nest.redirhas its own namespace (NFTA_REDIR_*),
where that id means something else entirely, so the kernel ignored the port
and the redirect landed nowhere. TheNFTA_REDIR_*constants are now
defined and the encoder uses them. -
nftables: three
SetKeyTypedatatype ids were wrong (#207).InetProto,
MarkandIfIndexcarried ids that did not match nft'senum 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'sconcat_subtype_add(t, n)shifts the
accumulator left, so component 0 ends up in the high bits, not the low
ones. Verified againstnft --debug=netlink. -
nftables:
emit_tlvwrote little-endian attribute headers (#212). The
writer usedto_le_bytesfornla_len/nla_typewhile every reader in the
crate parses them native-endian. Same value on x86_64, silently wrong on a
big-endian target. Both sides areto_ne_bytesnow. -
dispatcher: an
events()stream hung forever after a fatal recv error
(#202).fail_allcleared thependingmap but neverevent_listeners, so
in-flight requests saw the error while every event stream kept its sender
alive. Anmpsc::ReceiveryieldsNoneonly once everySenderis gone, so
thePoll::Ready(None) => take_fatal_error()arm was unreachable and the
stream parked onPoll::Pendingforever — 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 withNotify::notify_waiters(), which stores no permit
and wakes only tasks already registered as waiters. The driver re-creates its
notified()future eachselect!iteration, so it is unregistered for the
whole ofroute_bufferand between iterations — and aConnection::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_batchnever fanned out ENOBUFS (#220).recv_msgand
poll_recvboth tell the resync subscribers the kernel dropped multicast
frames; thesyscall_batchsiblings (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
Noneand duplex was garbage (#196). The
kernel has a singleETHTOOL_A_LINKMODES_OURS = 3; nlink invented two
variants there (Supported+Advertised), which shifted every later
attribute id up by one.Speedthen readDUPLEX— a 1-byte attribute, so
thelen >= 4guard never matched and speed silently read asNoneon
every interface, forever — whileDuplexreadMASTER_SLAVE_CFGand
reported garbage. On the write path a speedu32landed in an id the kernel's
policy declaresNLA_U8, soset_link_modeswith a speed could only ever
EINVAL. Both the request path and the multicast event decoder were affected.
OURScarries both answers at once — supported modes in the bitset's mask,
advertised modes in its value — soLinkModes::supportedand::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:EthtoolStringSetomittedETH_SS_FEATURES(4), so every set id from 4 up
was one too low — asking forLinkModesactually requestedPHY_TUNABLES
(#227). It also invented two string sets that do not exist.EthtoolLinkinfoAttrtransposedTP_MDIXandTP_MDIX_CTRL, so the
reported MDI-X status and its control setting were swapped (#228).EthtoolCmd::PhyGetwas 44, but the kernel insertedMODULE_FW_FLASH_ACT
there and movedPHY_GETto 45 — so a PHY query would have sent the NIC a
module firmware-flash activate (#229). Unused so far; fixed while free.EthtoolStatsAttromitted the kernel'sPADat 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
BssStatusstarted at 1 where the kernel starts at 0, so an
associated BSS decoded asIbssJoined; devlinkPortFlavouromitted
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/linuxon 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
ChannelWidthstopped atWidth320 = 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
/0prefix panicked the dump path (#204).ip_matchesbuilt
its mask withu32::MAX << (32 - prefix_len), which forprefix_len == 0is
a shift by the full width: a panic in debug builds, and in release a shift
count masked back to zero — leavingmask = u32::MAX, so "any address"
became an exact match on0.0.0.0and matched nothing.src 0.0.0.0/0is 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-sideip_matchescalls 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/8therefore
rejected the dual-stack listener's::ffff:10.1.2.3socket 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,exactgoes false, and the backstop decides. The
Nnf::NotHostnode is gone, so it cannot come back. -
sockdiag: five documented
InetFilterbuilders did nothing (#223).
local_addr,remote_addr,interface,markandcgroup_idwere 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 forUnixFilter::socket_typesand
path_pattern:.unix().stream().path("/run/foo")returned every unix socket
of every type. The addresses now lower kernel-side asS_COND/D_COND; the
rest are applied client-side. -
sockdiag:
FilterExpr::parseaccepted trailing garbage (#224). The parser
stopped at the first token it did not recognize and returnedOk, leaving the
remainder unread."sport = :22 && dport = :80"parsed as just
sport = :22— a superset of the sockets asked for, and markedexact, 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_allocandwmem_queuedwere swapped (#222). In
struct inet_diag_meminfo, offset 4 isidiag_wmem=sk_wmem_queuedand
offset 12 isidiag_tmem=sk_wmem_alloc— nlink read them the other way
round, so ss-styleskmem:output hadtandwtransposed and
total_mem()summed the wrong pair. -
sockdiag:
MemInfo.rcvbuf/sndbufcould never be populated (#197). They
live only inINET_DIAG_SKMEMINFO, and no builder requested it — so they
read as a flat0forever and downstream dashboards graphed a clean,
believable, permanently-zero line. AddsInetFilterBuilder::with_sk_mem_info(),
and theMEMINFOandSKMEMINFOarms now merge instead of the later one
clobbering the earlier (withwith_all_extensions(), whichever the kernel
emitted last used to win). The five SKMEMINFO-only fields are now
Option<u32>:Nonemeans you did not request it, which is a different
statement fromSome(0)— the conflation is what kept this invisible. -
sockdiag:
SocketFilter::mptcp()was a mislabelled TCP dump (#225).
IPPROTO_MPTCPis 262 and does not fitinet_diag_req_v2.sdiag_protocol
(a__u8); nlink sent the truncated byte (262 & 0xff == 6 == plain TCP) and
never emitted theINET_DIAG_REQ_PROTOCOLattribute the kernel added for
exactly this case. The result was every TCP socket on the box, each stamped
Protocol::Mptcp. Relatedly,INET_DIAG_INFOwas always decoded as
struct tcp_inforegardless of protocol — for SCTP it carriesstruct 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 anEINVALon a malformed bytecode program
was reported as asock_destroyfailure, which is precisely the wrong place
to look. -
DeclaredSet/DeclaredSetBuilderwere unnameable (#210). They are the
element type of the publicNftablesDiff::sets_to_addfield, 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_TIMEOUTwrapped instead of saturating (#211). A conntrack timeout
aboveu32::MAXseconds 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_MAXLENcounts the NUL).
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Path-based netns create/delete:
-
nlink 0.24.0 Stable
released this
2026-07-03 11:57:48 +00:00 | 33 commits to master since this releaseUpgrading? 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,TcpInfotail fields,
subscribe_all()breadth).Added
- Rule / Nexthop / NsId / MDB events (#165).
NetworkEventgained
NewRule/DelRule(RuleMessage),NewNexthop/DelNexthop
(Nexthop),NewNsId/DelNsId(NsIdMessage) andNewMdb/DelMdb
(MdbEntry) variants with matchingas_*/into_*accessors —
policy-routing, nexthop-object, namespace-ID and bridge-multicast-DB
changes now surface as typed events. NewRtnetlinkGroup::{Nexthop, Mdb}subscription variants plusRTNLGRP_IPV4_NETCONF/
RTNLGRP_IPV6_NETCONF/RTNLGRP_MDB/RTNLGRP_NEXTHOP/
RTNLGRP_BRVLANgroup constants. - Socket → process attribution (#162). sock_diag deliberately
reports no PID, so everyss -p-style consumer re-implemented the
/proc/<pid>/fd→socket:[inode]scan. It now lives in the library
(featuresockdiag):SocketOwnerMap::scan()is one amortized
/procwalk,resolve(inode)yieldsProcessRef { 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-exposedInetSocket.cgroup_id: cgroup-v2 ID →
cgroupfs path (resolve_relativeyieldssystem.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 -pnow
consumes the library version; new example
sockdiag_socket_owners. - Typed nftables rule-expression decoding (#164).
RuleInfo
previously exposed only rawexpression_bytes; consumers wanting
per-rule counters hand-parsed NFTA TLVs. New read-sideRuleExpr
enum (Counter { packets, bytes }with live kernel values,
Verdict,Meta,Cmp,Immediate,Payload, and a lossless
Unknown { name, data }fallback) plusRuleInfo::expressions()
and theRuleInfo::counter() -> Option<(packets, bytes)>shortcut.
Decoding is lazy and infallible — anything undecodable demotes to
Unknownverbatim;expression_bytesand 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_existsonConnection<Route>—Ok(bool)
instead of an error when there's nothing to delete, mirroring
the nftablesdel_*_if_existsfamily.- WireGuard device bootstrap:
WireguardConfig::diffreports
absent declared devices in the new
WireguardConfigDiff::devices_to_addinstead of hard-failing;
WireguardConfig::ensure_devices(&Connection<Route>)creates the
missing links idempotently;facade::apply::wireguard*wires the
two together so a bareWireguardConfigapplies end-to-end. WireguardConfigDiffserializes (featureserde) 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}_intake a
NamespaceSpec(named / path / PID — container support); new
NamespaceSpec::connection_asyncfor GENL families. RateLimiter::reconcile{,_dry_run,_with_options}— idempotent
convergence mirroringPerHostLimiter::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_DIRre-exported at the crate root. (LinkChanges
already implementedDisplay.)
- 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
completeFilterExprgrammar —src/dsthost conditions
(inet_diag_hostcond, v4/v6/prefix),or/not(De Morgan
push-down + jump structure; the kernel has no NOT opcode),!=,
andstate(hoisted into the request header'sidiag_states—
there is no state opcode) — via the new
compile_filter(&FilterExpr) -> CompiledFilter { states, bytecode, exact }. NewInetFilter::expr+ builderfilter_expr(): the
dump path compiles the expression once, runs it kernel-side, and
applies the client-sidematches()backstop whenever the lowering
over-approximates (never under-approximates).nlink-ss
expressions now pre-filter kernel-side. Emission discipline makes
the kernel'sinet_diag_bc_auditpass structural (everyyes=
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/BBRINFOnow parse into typed
CcInfo::{Vegas,Dctcp,Bbr}on the newInetSocket.cc_info
(BBR bandwidth assembled from the wire's lo/hi split, bytes/sec).
Request withwith_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 examplessockdiag_rate_top(per-process bandwidth
top),sockdiag_filter_expr(compile_filterintrospection +
CcInfodisplay) andconfig_stack(theStackfacade end-to-end
incl.change_count()and the--applyrunner pattern);events_monitornow usessubscribe_all()and
prints the rule/nexthop/nsid/MDB families;nftables_firewall
decodes dumped rule expressions and per-rule counters;
ratelimit_simpledemonstratesRateLimiter::reconcile
idempotence.docs/library.mdgained 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
checksext & (1 << (attr - 1)), butmask()returned1 << attr
— sowith_mem_info()actually requestedINET_DIAG_INFO,
with_tcp_info()requested VEGASINFO, and so on, one extension
over. Every variant's bit is now pinned by a unit test. -
TcpInfotail fields were never populated (#171).
parse_tcp_infostopped at byte 168, sobytes_sent,
bytes_retrans,busy_time,rwnd_limited,sndbuf_limited,
delivered,delivered_ce,dsack_dups,reord_seen,
rcv_ooopackandsnd_wnd— all declared onTcpInfo— silently
read 0 on every kernel. The parser now decodes through byte 232
(offsets pinned to uapilinux/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 omittedNsId,Ipv4RuleandIpv6Rule(and the new
Nexthop/Mdbgroups didn't exist), so those changes were silent
under the "all" subscription. Consumers matching exhaustively on
NetworkEventwere already forced to handle unknown variants
(#[non_exhaustive]); the new variants simply start arriving.ConfigDiff,StackDiffandWireguardConfigDiffare 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 thestream_sas()/stream_sps()
streaming variants) appended a zeroedxfrm_usersa_info/
xfrm_userpolicy_infostruct as the dump body. The XFRM dump
callbacks parse the whole body as netlink attributes (hdrlen = 0),
so the kernel logged a ratelimitednetlink: 224 bytes leftover after parsing attributeswarning (168 for policies) on every poll.
Dump requests are now header-only, matchingip xfrm state/policy.
Results were and remain correct — this kills the log noise.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Rule / Nexthop / NsId / MDB events (#165).
-
v0.23.0 Stable
released this
2026-06-30 21:24:32 +00:00 | 53 commits to master since this releaseLarge, 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) monitor —
Connection<Xfrm>: EventSource(ip xfrm monitorequivalent). - Declarative nftables sets —
DeclaredSet+ element-level diff inside the atomic batch (also fixes anERANGEthat broke every set create). - Reflector / watch-cache —
Store<K,V>+ReflectExt::reflect, a kube-rs-style cache over the resync event stream (no new dependency). NetworkConfigdeclarative purge — opt-in full reconcile, fenced to global-scope addresses on managed interfaces +static/bootmain-table routes (links/qdiscs never purged).- JSON Schema for
NetworkConfigvia the newschemarsfeature. - 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, …;Ratedeliberately excluded to keep its unit-safety guarantee). - Bridge
BRIDGE_VLANDB_ENTRY/GOPTS, net_shaper group shaping,WireguardConfig::{from_wg_quick,client}, TC peditmungeDSL,Connection<Ovpn>::attach_socket, GENL command unification.
Breaking changes (mechanical)
Chain::new(table, name)now returnsResult(add?; nftables names are the validatedTableName/ChainNamenewtypes —&strcallers otherwise unchanged).- Parsed
LinkStatsread via accessors (stats.rx_bytes()). SurveyInfo/StationInfo/PhyInfo/Band/Frequencyare now#[non_exhaustive].
See the migration guide and the full changelog.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
nlink 0.22.0 Stable
released this
2026-06-28 23:32:53 +00:00 | 107 commits to master since this releaseChanged
- 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 producenlink-ip,
nlink-tc, … matchingnlink-config/nlink-ethtool, so a demo
binary never shadows the real iproute2/iw/ethtool tool onPATH.
Cargo package names were alreadynlink-*; only the[[bin]] name
changed (and the CLI tests'CARGO_BIN_EXE_*references). Invoke as
nlink-ip link show(orcargo run -p nlink-ip -- link show).
Added
-
sockdiag:
INET_DIAG_REQ_BYTECODEkernel-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 byss --sport/--dport) into an
inet_diag_bc_opprogram attached asINET_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 publicsockdiag::bytecodemodule exposesfor_ports(used by
the dump path) and a generalcompile(&FilterExpr)that also lowers
sport/dportcomparisons (=/</<=/>/>=) combined with
and; unsupported predicates (!=, address/prefix,state, any
or/not) yieldNoneand 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'sinet_diag_bc_audit
with EINVAL (loud), not mis-applied. Byte-exact layout is unit-tested;
on-kernel semantics are validated by arequire_root!()integration
test under the privileged CI (now built withlab,sockdiag). Purely
additive — no public type changed shape. -
ethtool: RSS read (#119) — closes #119.
Connection::<Ethtool>::get_rssreads 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). NewEthtoolRssAttrenum +Rsstype.
Surfaced asnlink-ethtool -x eth0. With FEC-set + module-EEPROM, all
three #119 ethtool gaps are landed. -
ethtool: module-EEPROM read (#119).
Connection::<Ethtool>::get_module_eepromreads 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 newethtool_get_withhelper sends a
parameterizeddoitGET (request flag, no dump) and returns the
single reply, parsing outETHTOOL_A_MODULE_EEPROM_DATA. Surfaced as
nlink-ethtool -m eth0(hex dump). Second of #119's three ethtool gaps. -
ethtool:
set_fecFEC setter (#119).Connection::<Ethtool>::set_fec
configures Forward Error Correction via aFecBuilder
(ETHTOOL_MSG_FEC_SET):.mode("rs")selects encodings (a named
ETHTOOL_A_FEC_MODESbitset),.auto(bool)toggles negotiation.
Mode names are the kernel-labelled bitset namesget_fecreports
(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.GateActionadmits 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/BOOTTIMEor numeric), and a repeatable
sched-entry open|close <interval> [<ipv> [<max-octets>]]schedule.
The entry list is built as nestedTCA_GATE_ONE_ENTRYitems under
TCA_GATE_ENTRY_LIST(gate-open is anNLA_FLAG). Newaction::gate
wire module. 19 action parsers.xt/iptis intentionally not
modelled:act_iptrequires aTCA_IPT_TARGxt_entry_targetblob
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:encodewrites selected metadata into an IFE Ethernet
header (with optionaldst/srcMAC +typeethertype overrides),
decodereads it back. Each metadata item isallowed (carry the
skb's value) orused with an explicit override. Newaction::ife
wire module (tc_ife=tc_gen+ flags; metadata rides in the nested
TCA_IFE_METALSTlist). 18 action parsers. Second of #118's actions. -
TC action:
ctinfotyped config (#118).CtinfoActionrestores
conntrack metadata into the packet — a DSCP value (under a state mask)
into the IP DS field viadscp <mask>[/<statemask>], and/or the
connection mark intoskb->markviacpmark [<mask>](barecpmark
uses the full mask), scoped byzone. Masks parse as0x-hex or
decimal; at least one ofdscp/cpmarkis required. New
action::ctinfowire module (tc_ctinfo=tc_genheader; params
ride as separate attrs). 17 action parsers. First of #118's four
actions. -
TC filter:
rsvp/rsvp6classifier typed config (#117).
RsvpFiltermatches flows bysession(destination) andsender
(source) address, narrowed byipprotoandtunnelid, with
classid/flowidandchain. The address family is inferred from the
addresses — an IPv6 session/sender selects thersvp6kernel
classifier, IPv4 selectsrsvp— so a single config + both bin
dispatch arms cover the pair. Newfilter::rsvpwire 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;
thetunnel <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, soAtmConfigis 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
MessageBuilderfor that case. 35 qdisc parsers total. With this,
choke/gred/atm/pfifo_fastfrom #116 are all landed. -
TC qdisc:
gred(Generic RED) setup-phase typed config (#116).
GredConfigmodels the GRED setup message — virtual-queue count
(DPs, 1..=16), default VQ, GRIO flag, and global bytelimit— sent
asstruct tc_gred_soptunderTCA_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 plainRedConfigalso sidesteps), so
parse_paramsrejects 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. Newqdisc::gredwire module. -
TC qdiscs:
chokeandpfifo_fasttyped configs (#116). Two
more qdisc kinds reach typed-first parity (33 qdisc parsers total).
ChokeConfigis a RED-family AQM that sharesstruct tc_red_qopt
withRedConfigand adds CHOKe differential dropping; its
parse_paramsmirrors RED (limit/min/max/probability/ecn/
harddrop, withavpkt/burst/bandwidthreported as not-modelled
rather than silently dropped).PfifoFastConfigis 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 thetcbin
dispatch + recognised-kinds list. First of the #115 coverage epic. -
nftbin: declarativereconcile/diffover theNftablesConfig
engine (#109). Newnft reconcile <file>reads a desired-state
ruleset (the sameadd table/add chain/add rulegrammar as
apply), folds it into anNftablesConfig, diffs it against the
live ruleset, and applies the minimal change set via the library's
diff/apply/apply_reconcileengine (the same one theconfig
binary uses for interfaces).nft diff <file>is the read-only
preview;--dry-run/--no-retrytunereconcile. Because it is
desired-state,delete/flushlines are rejected (removal is
inferred from the diff) —nft applyremains 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 declarativeNetworkConfigtree wasSerialize-only; it now
also implements a validatingserde::Deserializebehind the
serdefeature, 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 thedefaultkeyword for0.0.0.0/0/::/0),
MACs asaa:bb:cc:dd:ee:ff. Validation is preserved rather than
bypassed — deserialization goes through the same parse paths as the
builders (via serde'stry_from/intoconversion 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 helpersNetworkConfig::{from_json_str, to_json_string, to_json_string_pretty}. Theserdefeature now also pulls
serde_jsonfor these. Additive (new trait impls + methods);
cargo-semver-checksclean. -
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_FAMILYwithsdiag_family = AF_PACKET)
dump and parsespacket_diag_msg+PACKET_DIAG_INFO/UID/FANOUT/
MEMINFOintoPacketSocket(bound interface, protocol, type, uid,
fanout, recv/send queues). NewConnection<SockDiag>::query_packet_sockets
convenience method, surfaced in thessbin as-0/--packet. -
ethtool/library: Forward Error Correction read (#29). New
Connection<Ethtool>::get_fec/get_fec_by_nameover
ETHTOOL_MSG_FEC_GET: configured-modes list, auto-negotiation flag,
and the active FEC mode (rawETHTOOL_LINK_MODE_FEC_*bit). Surfaced
asethtool 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_timervia anEeeBuilder. Surfaced in
theethtoolbin aseee(show) andset-eee(set). -
ethtool/library: Wake-on-LAN get/set (#29). New
Connection<Ethtool>::get_wol/set_wol(plus_by_name)
overETHTOOL_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 aWolBuilder.
Unknown mode names are rejected before the request is sent. Surfaced
in theethtoolbin aswol/-w(show) andset-wol/-W(with a
nonesentinel to disable all), printing thep u m b a g s f/d
flag string ethtool(8) uses. -
tc/library:mplsandskbmodactions (#29). Two new typed
TC actions in the standard*Action+parse_paramsshape, wired
intotc action add … mpls|skbmod.MplsActionpushes/pops/modifies
an MPLS shim or decrements its TTL (label,tc,ttl,bos,
protocol) viastruct tc_mpls.SkbmodActionrewrites L2
src/dst MAC and/or ethertype, or swaps MAC (set smac|dmac|etype,
swap mac) viastruct tc_skbmod. Brings the typed-action count to
16. (The remainingife/gate/ctinfo/xtactions stay deferred —
metadata-encap / time-gated-schedule / iptables-target wrappers that
are complex or niche.) -
tc/library:tcindexfilter (#29). NewTcindexFiltertyped
config (hash,mask,shift,fall_through/pass_on,classid,
chain) over theTCA_TCINDEX_*attributes, wired intotc filter add … tcindex. The canonical companion to thedsmarkqdisc for
DiffServ classification. Brings the typed-filter count to 10. (rsvp
remains deferred — a session/sender classful filter that is rarely
used and needs thetc_rsvp_pinfoselector struct.) -
wifi/library:del_station(kick an AP client) (#29). New
Connection<Nl80211>::del_station(iface, mac)/
del_station_by_indexsendNL80211_CMD_DEL_STATIONto
deauthenticate an associated station, surfaced asnlink-wifi del-station <iface> <mac>. Complements the existing read-only
stationlisting for AP-mode management. -
tc/library:hhfanddsmarkqdiscs (#29). Two more typed
qdisc configs wired intotc qdisc add … hhf|dsmark.HhfConfig
drives the Heavy-Hitter Filter (limit,quantum,hh_limit,
reset_timeout,admit_bytes,evict_timeout,nonhh_weight) over
the individualTCA_HHF_*attributes (timeouts sent as microseconds,
matching the kernel'susecs_to_jiffies).DsmarkConfigdrives the
DiffServ-marking qdisc'sindices/default_index/set_tc_index
(per-indexmask/valueremain class-level). Brings the typed-qdisc
count to 31. -
tc/library:sfbandmultiqqdiscs (#29). Two more typed
qdisc configs in the standard*Config+ builder + strict
parse_paramsshape, wired intotc qdisc add … sfb|multiq.
SfbConfigdrives Stochastic Fair Blue (limit,rehash,db,
max,target,increment,decrement,penalty_rate,
penalty_burst) packed intostruct tc_sfb_qoptunder
TCA_SFB_PARMS.MultiqConfigdrives the band-per-tx-queue qdisc —
parameterless (the kernel derives band count from the device's
tx-queue count), writing a zeroedstruct tc_multiq_qopt. Brings the
typed-qdisc count to 29. -
tc/library:cbsandskbprioqdiscs (#29). Two new typed
qdisc configs with the standard*Config+ fluent builder +
strictparse_paramsshape, wired intotc qdisc add … cbs|skbprio.
CbsConfigdrives the IEEE 802.1Qav Credit-Based Shaper (AVB/TSN):
idleslope/sendslope(kbit/s),hicredit/locredit(bytes), and
offloadpacked intostruct tc_cbs_qoptunderTCA_CBS_PARMS.
SkbprioConfigdrives the SKB-priority queue (limitpackets).
Brings the typed-qdisc count to 27. -
tcbin:filterparity — show across all parents,--chain,
partial delete (#19).tc filter show dev Xnow lists filters
across every parent (root, ingress, clsact, …) instead of silently
defaulting torootonly — previously ingress/clsact filters
vanished from the listing; pass--parent <handle>to narrow.
filter add/replace/change … --chain Nsets the filter chain index
(tc(8)chain N), threaded generically through every kind via a new
defaultedFilterConfig::set_chaintrait method (additive; all nine
shipped configs override it).filter delregained partial form:
supplying both--protocoland--priodeletes 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-configbin:apply --reconcile+ higher-fidelity capture
(#22).apply --reconciledrives the library'sapply_reconcile
(recomputes the diff each attempt, bounded retry on transient kernel
contention); mutually exclusive with--dry-run.capturenow decodes
qdisc options (htb/tbf/netem/fq_codel parameters) into the
optionsmap instead of leaving it empty, and unmodelled route/rule
types are warned about and preserved verbatim rather than silently
dropped toNone. The duplicatedOutputFormatenum (capture +
example) is unified inschema.rs. -
nftbin: idempotent deletes + richerlist rules(#21).nft delete table/chain/rule/setnow uses the*_if_existslibrary
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 missingdel_set_if_existslibrary method.
list rulesnow also surfaces each rule'sposition,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). -
ipbin:macsec add/set/del(TX/RX SA + RX SC) (#17). The
ip macsecobject was show-only; it now mutates MACsec secure
associations over the existingConnection<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--antargets an RX SA within it;set
updates an existing SA;delremoves the SA/SC. The device is
resolved to an ifindex up front so the*_by_indexcalls are
namespace-safe, and--an > 3/ odd-length hex are rejected before
reaching the (panicking) builder. -
bridgebin:monitorcommand (#25). Streams bridge FDB changes
in real time —bridge monitorsubscribes toRTNLGRP_NEIGHand
printsNewFdb/DelFdbevents (text or--json, with-tfor
timestamps), mirroringbridge monitor fdb. (MDB-event monitoring is
deferred until the library modelsRTM_*MDBnotifications.) -
bridgebin:fdb add --extern-learnandfdb show --brport(#25).
fdb add … --extern-learnmarks an entryNTF_EXT_LEARNED(a
user-space control plane owns it; the kernel won't age/overwrite it) —
backed by a newFdbEntryBuilder::extern_learn()library setter.fdb show <bridge> --brport <port>lists only the entries learned on a
given bridge port via the existingget_fdb_for_port, mirroring
bridge fdb show br <dev> brport <port>. -
ipbin: realnetns set <name> <nsid>and nsid display innetns list(#17).netns setwas a stub that printed "Setting…" and
returnedOkwithout doing anything; it now sendsRTM_NEWNSID(new
Connection::set_nsidlibrary method) to assign the id — or
auto-allocate onauto/-1, reading the chosen id back.netns list
previously always showed no id becauseget_namespace_idreturned
None; it now queries each namespace via the existingget_nsid
(RTM_GETNSID), so(id: N)appears for namespaces that have one. -
tcbin: implementqdisc show --invisible(#19). The flag was
parsed but ignored, sotc qdisc show invisiblebehaved identically
to a plain show. It now setsTCA_DUMP_INVISIBLEon the
RTM_GETQDISCdump (via the newConnection::get_qdiscs_full
library method), so the kernel also returns the auto-created default
qdiscs it normally hides. -
wgbin:addconfconfig-file apply (#23). Completes the
setconf/syncconf/addconftrio overWireguardConfig.addconf
is the additive form — it appends the file's peers/settings without
removing existing peers. (Because the library'sapplynever prunes
unlisted peers, it currently coincides withsetconf; the two diverge
oncesetconfgains real unlisted-peer pruning.) -
ssbin: 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
--onelinethose segments are now space-joined onto the socket's row,
so each socket occupies exactly one line (matching realss -O). -
ethtoolbin: surface already-parsed link detail inshow(#27).
shownow 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 inshow pause. All
appear in both the text and--jsonoutput, with stable string
mappings for the MDI-X / ext-state enums. -
wifibin: show more station detail (#24).wifi stationnow
prints the already-parsedsignal_avg_dbm,connected_time_secs, and
inactive_time_msfields (previously dropped), alongside the existing
signal / bitrate / byte counters. -
wgbin: incremental allowed-ips + private-key unset (#23).wg set … --allowed-ipsnow 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/nftbins: extend unit coverage of the pure parsers (#20,
#21). Addednfttests forparse_family(+ aliases),parse_hook
(incl. the netdev/inetingresssplit), andparse_cidr; andss
tests forformat_addr(unspecified-address and zero-port rendering). -
ipbin: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_indexlibrary 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). -
wgbin:setconf/syncconfconfig-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'sWireguardConfig, thenwg setconf <if> <file>applies it and
wg syncconf <if> <file>applies it with bounded retry (the documented
reconcile shape). The parser is the inverse ofwg showconfand is
strict — unknown keys, malformed values, and a[Peer]without a
PublicKeyare hard errors (matching realwg setconf, which rejects
wg-quick-only keys likeAddress/DNS/MTU). No library change; the
parser lives in the binary (precedent: theconfigbinary's schema). -
wgbin:--jsonforshowandshowconf(#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 --jsonreveals them, since it is a config dump. Bringswg
into line with the other binaries' machine-readable output. -
bridge/diagbins: unit tests for the pure parse/format helpers
(#25, #28). Both binaries previously shipped with zero tests. Added
non-root unit coverage forbridge'sparse_vid_range(VLAN id/range)
andOnOffflag parser, anddiag'sparse_severity,severity_icon,
oper_state_str,format_bytes, plus the severity ordering the
--min-severityfilter relies on.
Added
- CLI parse-test suites for
ss,bridge, andnlink-config
(#20, #25, #22). Each bin gained atests/cli_parsing.rsmirroring
theip/tcpattern (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, andconfig exampleis run end-to-end (it emits
an embedded sample with no kernel access).
Fixed
-
ssbin: JSON output honors display flags + process parity (#20).
ss -jpreviously dumped thetcp_info/mem_info/congestion/
markblocks unconditionally whenever the kernel returned them,
regardless of the-i/-m/-eflags, and never emitted the
owning-process data that-pshows in text. The JSON now mirrors
the text columns:tcp_info(+congestion) only with-i,
mem_infoonly with-m, the extendedinterface/markfields
only with-e, an activetimeronly with-o, and a structured
processarray (comm/pid/fd) only with-p. Unit tests cover
the gating in both directions. -
ipbin:sr tunsrc show --pretty+ POC version string (#17).
sr tunsrc show -jhand-rolled its JSON and ignored the global
--prettyflag; it now builds the object viaserde_jsonso
--prettyis honored and the address is correctly escaped. The
ip --version/--helpstrings now identify the binary as the
nlink proof-of-concept ("not iproute2") so it can't be confused
with a realip(8). -
tcbin: namespace-safeqdisc/class/filter show(#19). The
three show paths resolved the device name→ifindex via
nlink::util::get_ifindex(a/sys/class/netread 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 vlanoutput (#25, #19).
Thebridge(vlan/fdb/mdb) andtc(chain) JSON show paths
used.expect("JSON serialization"), a latent panic path; they now
return a properErrorvia a sharedto_json_stringhelper.
bridge vlan show --jsonalso groups interfaces through a HashMap, so
its output order was nondeterministic — it now sorts by ifindex for
stable, diffable JSON. -
ipbin: namespace-safemaddr showinterface resolution (#17).
maddrresolved 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 thatmaddr add/delis
intentionally not offered: the kernel exposes static L2 multicast only
via theSIOCADDMULTI/SIOCDELMULTIioctls, out of scope for this
netlink-first demo. -
tcbin: remove the dead-b/--batchflag (#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. -
bridgebin: remove the dead-s/--statsflag (#25). It was
plumbed intoOutputOptionsbut never read by anyfdb/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. -
ssbin: ss-expression filters no longer pass non-inet sockets
unconditionally (#20).FilterExpr::matches_socket_inforeturned
truefor every unix/netlink/packet socket, soss -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 theInetMatchexclusion the binary
already applies to--sport/--dport/--src/--dst. -
ipbin: namespace-safeneighbor show/flushdevice filter (#17).
Both resolved the device viaget_neighbors_by_name, which reads the
name from the host/sysand so returns wrong results inside a foreign
netns. They now resolve the ifindex over netlink (get_link_by_name)
and useget_neighbors_by_index, per the CLAUDE.md "prefer_by_index"
policy. A missing device is now a clear error instead of an empty list. -
ipbin: strict-parse the link-add mode helpers (#17). The bond
mode/xmit_hash_policy/lacp_rate,macvlan/macvtapmode,
ipvlanmode, and VLANprotocolparsers silently mapped unknown
input to a default (e.g.mode bogus→balance-rr), hiding typos.
They now returnError::InvalidMessageon unrecognized input, in line
with the CLAUDE.md strict-parse contract. -
ipbin: kill silent no-ops inaddr/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/devwere silently ignored — they
are now wired to the tunnel builders (tos/pmtudiscerror as "not
modelled" forvti, 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. -
ipbin: honor-j/-pintunnel showandnexthop show(#17).
tunnel showdiscarded the user's output flags entirely (it passed
OutputOptions::default()), andnexthop showalways pretty-printed
JSON regardless of-p. Both now thread the realOutputOptions
through, so-j/-pbehave consistently with the other objects. -
devlinkbin: type-awareparam set(#26). The value was inferred
lossily (bool → u32 → string), so au8/u16parameter or an all-digit
string label silently became au32.param setnow reads the
parameter's declared type viaget_paramfirst 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. -
nftbin: real chain type/hook/priority/policy inlist chains(#21).
The text and JSON output hardcodedtype filter hook <n>and showed the
raw hook number.list chainsnow prints the actualchain_type,
hook name (family-aware: netdev → ingress/egress, else the L3 hooks),
priority, defaultpolicy, and bounddevicefrom the kernel's
ChainInfo. -
devlinkbin: readablemonitoroutput (#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. -
ipbin: namespace-safenexthop showdevice 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 singleRTM_GETLINKdump 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- All demo binaries are now
-
v0.21.0 Stable
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*Messagetypes that get field-visibility tightening.Breaking changes
DpllPin::fractional_frequency_offset_pptwidened fromOption<i32>
toOption<i64>. Silent truncation on overflow was the audit
finding that motivated the 0.20.1 deprecation. The
_i64parallel field added in 0.20.1 is now the single field; the
deprecatedi32field is removed.- Raw-
u8route-rule API removed.Connection::<Route>::flush_rules,
get_rules_for_family, anddel_rule_by_priorityno longer take
family: u8. The_typed(AddressFamily)siblings from 0.20.1 take
over the original names; the_typedsuffix is dropped. QdiscBuilder::loss(f64)removed. Useloss_pct(Percent).Verdict::Jump(String)andVerdict::Goto(String)removed. Use
Verdict::JumpTo(ChainName)/Verdict::GotoTo(ChainName).
RuleBuilder::jump/gotonow take pre-validatedChainName
infallibly; newtry_jump/try_gotosiblings take&strand
returnResult<Self>.RuleMessagefields demoted frompubtopub(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.shCI gate locks the
convention.AddressMessage/LinkMessage/NeighborMessage/
RouteMessage/TcMessagegain#[non_exhaustive](fields stay
pub; bin construction is the source of literals).
Added
-
Plan 197 — Declarative
OvpnConfigfor OpenVPN data-channel
offload. NewConnection<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 thepeer-del-ntf/key-swap-ntf/
peer-float-ntfmulticast notifications viasubscribe_peers().
DeclarativeOvpnConfig::diff().apply()andapply_reconcile
mirrorWireguardConfig'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. Theattach_socketcross-netns SCM_RIGHTS
helper is plumbed but returnsError::NotSupportedpending a
sendmsg-layer cmsghdr refactor (deferred to 0.21.x); same-netns
callers already work viaOvpnPeer::socket = Some(fd). -
Plan 234 — F1 follow-on: Dispatcher foundation + ENOBUFS
routing. Newnetlink::dispatchermodule provides an
Arc-shared,Send + Syncbroadcast surface that the
NetlinkSocketrecv path routes ENOBUFS into. Every multicast
subscriber sharing anArc<Connection>receives
ResyncMarker::ResyncStarton socket overflow (deliberate
divergence from neli's silent-drop behaviour — see CLAUDE.md
## Concurrency).Connection<P>exposesdispatcher() -> &Dispatcher.
The F1tokio::sync::Mutexstays 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 declarativeQdiscBuilderfor
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
*Messageaccessor sweep. Four
more types fromcrates/nlink/src/netlink/{bridge_vlan,fdb,mpls,nexthop}
get per-field accessors +#[non_exhaustive]+pub(crate)field
demotion.audit-message-accessor-convention.shextended to
cover them. -
Plan 229 — Doc-drift sweep + new CI gates. ~16 doc-comment
sites flipped (syncconn.events()→.await, deprecated builder
references replaced, WGprivate_keycorrected post-PR-#9). New
scripts/audit-recipe-drift.sh(blocking) catches stale recipe
patterns. Newdoctest-nlinkGHA job runscargo 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_socketcross-netns SCM_RIGHTS helper (Plan 197) —
needs a sendmsg-layercmsghdrrefactor ofNetlinkSocket.
Same-netns fd passing already works.
Notes
cargo-semver-checksagainstv0.20.1is 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
v0.20.1 Stable
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 againstv0.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.rsconstant-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 tofrom_ne_bytes. New
scripts/audit-bytes-le.shCI gate fails the build iffrom_le_bytes
reappears incrates/nlink/src/netlink/. Newcargo check --target s390x-unknown-linux-gnuCI job locks the BE-compile
contract. - Plan 224 —
recv_msgMSG_TRUNC handling + auto-grow.recv_msg
passesMSG_TRUNC, detects kernel-side truncation against the buffer
size, auto-grows up to a 1 MiB cap and retries once, then escalates to
a newError::Truncated { received, buffer_size }variant with
is_truncated()predicate. NewError::Backpressurevariant for
EWOULDBLOCKretry exhaustion (B19). - Plan 225 — WireGuard
parse_timespecrobustness. Validates
secs >= 0+0 <= nanos < 1e9before constructingDuration;
usesSystemTime::checked_addto handle far-future overflow.
ReturnsNoneon malformed input instead of panicking. Closes
AUDIT_BUGS B5 (verified by reviewer repro). - Plan 226 — DPLL
sintcodegen runtime. Addednlink-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). NewDpllPin::fractional_frequency_offset_ppt_i64: Option<i64>field holds the full-width value alongside the existing
(now deprecated)Option<i32>field. Newffo_ppt_i64()accessor. - Plan 227 — Typed
AddressFamilynewtype. New
nlink::AddressFamily(u8)withv4()/v6()/bridge()/ etc.
constructors. New*_typedmethods onConnection<Route>
(flush_rules_typed,get_rules_typed,
del_rule_by_priority_typed) takeAddressFamily. The raw-u8
originals are deprecated (removal 0.21). - Plan 228 — Typed
PercentonQdiscBuilder::loss. New
loss_pct(Percent)setter validates / clamps; originalloss(f64)
deprecated (removal 0.21). Mirrors the 0.13 typed-units rollout —
closes the units-confusion bug class for the declarative path.
Scope limited toloss; the other 5 netem setters
(duplicate/corrupt/reorder/loss_correlation/
delay_correlation) don't exist on the declarativeQdiscBuilder
(imperativeNetemConfigalready takesPercent); deferred to 0.21. - Plan 230 — Typed
ChainName+Verdict::JumpTo/Verdict::GotoTo
variants. Newnftables::ChainName(String)newtype validates
non-empty / no-interior-NUL /<= NFT_NAME_MAXLEN-1. New
Verdict::JumpTo(ChainName)andVerdict::GotoTo(ChainName)variants
ride the existing#[non_exhaustive]enum. TheVerdict::Jump(String)
/Verdict::Goto(String)originals are deprecated (removal 0.21). - Plan 231 —
RuleMessageper-field accessors. 19 new accessor
methods alongside the existingpubfields.family_typed()returns
the newAddressFamilyfrom 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_innermsg_startunderflow), B10
(parse_string_from_bytesUTF-8 swallowing — now logs at WARN), B11
(WireguardConfig::apply.expectpanic path), B15
(audit.rs::AuditStatussize check uses>=), B18 (nlmsg_align
overflow on > 2 GiB; newnlmsg_align_checked), B19
(socket::sendEWOULDBLOCKretry — newError::Backpressure).
B13 / B14 / B17 / B20 deferred or judged non-bugs; documented
inline. - Plan 233 —
DumpStream::with_skip_malformedopt-in. Default
behaviour stays fuse-on-malformed (correctness > liveness for snapshot
dumps). New.with_skip_malformed(true)setter switches to
flatten-mode with WARN-leveltracingper skip. Per-stream-API
audit table + dump-vs-event policy documented inline. - Plan 237 — Audit-script self-test pattern. Four new
scripts/test-audit-*.shself-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_i64field orffo_ppt_i64()accessor.Connection::<Route>::flush_rules(family: u8)— use
flush_rules_typed(AddressFamily). Same forget_rules_for_family
anddel_rule_by_priority(raw-u8variants).QdiscBuilder::loss(f64)— useloss_pct(Percent). Closes the
units-confusion bug class for the declarative netem path.Verdict::Jump(String)andVerdict::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_*.mdstubs preserved in git history.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- Plan 222.2-4 — Sizeof gate broader coverage. Extended the
-
v0.20.0 Stable
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_spalways returned EEXIST) and a
CtKey::Expirationdiscriminant change that was loading the
wrong conntrack field.The
CtKey::Expirationdiscriminant change (7 → 5) is a
breaking ABI change at the byte level: pre-0.20 anyone using
CtKey::Expiration as u32got7(the wrong value); post-0.20
they get5(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_FLUSHPOLICYwas hardcoded
to28, which is the kernel UAPI value forXFRM_MSG_FLUSHSA.
Same miscount also brokeflush_sa()(was sending
XFRM_MSG_UPDPOLICY = 25with an 8-byte body the kernel
expected to be 168 bytes). - CRITICAL:
Connection::<Xfrm>::update_sa()andupdate_sp()
always returned EEXIST when the target existed. Both methods
sentXFRM_MSG_NEWSA/NEWPOLICYwithNLM_F_REPLACE; XFRM
dispatches bynlmsg_typealone and ignoresNLM_F_REPLACE,
so the kernel calledxfrm_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_SRCADDRattribute ID was9(=XFRMA_LTIME_VAL).
Everydel_sa/get_sawith 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 to13. - CRITICAL:
XFRMA_OFFLOAD_DEVattribute ID was26
(=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 truncatedxfrm_address_filterand silently
installed a software-only SA. Corrected to28.
Fixed — nftables
CtKey::Expirationconstant (Plan 221)- HIGH:
CtKey::Expirationenum discriminant was7
(=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 to5; the previously-shadowed
variantsCtKey::Secmark,CtKey::Helper, and
CtKey::L3Protocolwere 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_uapilocks 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_keyfor privileged callers. The parse path is factored
through a sharedparse_key()helper that also normalizes the
kernel's all-zeros sentinel (returned for a keyless device on a
privileged read) toNonefor bothprivate_keyand
public_key, matching the peerpreshared_keyarm. Thewgbin
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}guardnftitself emits in aninetchain: 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 aninetchain and unrecoverable
bynft list ruleset. - The
bitwisewriter now emitsNFTA_BITWISE_OP(every
prefix-masked match) and thenatwriter 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
natwriter skipsNFTA_NAT_FLAGSwhen the derived
flags == 0(no addr and no port), mirroringnft_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 fluentRule::snat_*helpers) would
reintroduce a phantom diff that the rest of this fix removes. - One-time migration impact for
apply_reconcileusers:
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.
- L3-version-specific matchers now prepend the
-
Modprobe
nft_natandwireguardin 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
- CRITICAL:
-
v0.19.0 Stable
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_purgeremoved;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_removecollections, 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_offsetfield type:Option<i32>→
Option<i64>(Plan 206 H1). The kernel field is declared
s64perDocumentation/netlink/specs/dpll.yaml(atto-
seconds × 1000). Pre-0.19 nlink routed the attribute through
parse_i32_attrwhich reads only the low 4 bytes of the 8-
byte payload, silently truncating high bits on LE platforms.
Telco/PTP/SyncE values routinely exceedi32::MAX(a 1 ns
offset = 1e9 in those units), producing nonsense readings.
Now correctly typed asi64and parsed via a new
__rt::parse_i64_attr/emit_i64_attrhelper pair; the
GenlMessagederive recognizesi64field types. Migration:
callers reading.phase_offsetexplicitly need to update the
type annotation;.phase_offset_ns()accessor unchanged
(stillOption<i64>). 1 new regression test
(pin_phase_offset_round_trips_value_above_i32_max) pins
the high-bit round-trip. -
Hook::Ingresssplit intoHook::NetdevIngressand
Hook::InetIngress;Hook::NetdevEgressadded (Plan 211 M1).
Pre-0.19Hook::Ingressalways encoded0, which was correct
only forFamily::Netdev/Family::Bridge. OnFamily::Inet,
ingress isNF_INET_INGRESS = 5, so the old encoding silently
installed the chain onPrerouting(also hook 0) — every
Family::Inetingress 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 againstinclude/uapi/linux/netfilter.hand
include/uapi/linux/netfilter_netdev.h. Newnft_hookmodule
insys_sizeofpins the kernel hook numbers. -
nftables verdict constants
NFT_JUMP/NFT_GOTOcorrected
to match kernel UAPI (Plan 204 C1). Pre-0.19 nlink shipped
NFT_JUMP = -2andNFT_GOTO = -3. The kernel's
enum nft_verdictsdefines them as-3and-4respectively;
-2isNFT_BREAK(terminate rule evaluation). Code building
Verdict::Jump(chain)previously wrote-2on the wire, which
the kernel interpreted as "terminate", silently breaking every
subroutine rule. The newNFT_BREAK = -2constant is added for
completeness. Source-level no-op for users ofVerdict::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_sizeofmodule + 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 assertingsize_of::<NlinkType>() == KERNEL_SIZE.
Catches the Plan 204 class of silent wire-format defects at
cargo testtime; future struct field changes that drift from
the kernel layout fail the test immediately. The test pass
surfaced an additional latent bug:XfrmUserTmplwas 62
bytes (kernel: 64) — fixed by adding a 2-byte explicit pad
betweenfamilyandsaddrto match kernel natural
alignment.
Fixed
-
NatExpr.addrre-typed fromOption<Ipv4Addr>to the
NatAddrenum (PR #6, @avionix-g) —NatAddrhas three
variants:None(port-only NAT),V4(Ipv4Addr)(IPv4
address recorded), andReg(a non-v4 address — e.g. v6 —
loaded intoR0with noIpv4Addrto record). Before this
change, the encoder emittedNFTA_NAT_REG_ADDR_MINonly
whenaddr.is_some(), so a v6 NAT (16-byte address in
R0, noIpv4Addrto 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 constructingNatExpr
as a struct literal or matching onaddr. The
NatExpr::{snat,dnat}+.addr()and
Rule::{snat,dnat,snat_v6,dnat_v6}builders are
unaffected. v4 wire output is byte-identical. -
ApplyOptionsis now#[non_exhaustive]+ builder-shaped
(Plan 188 §2.2) — struct-literal construction no longer
compiles. Build viawith_*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 assertingSome(-N)
break — update toSome(N).
Added
Error::DumpInterruptedvariant +is_dump_interrupted()
predicate (post-cycle bug-hunt) — the kernel sets the
NLM_F_DUMP_INTRflag 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. NowConnection::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,
iproute2warns 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
-
XfrmUserpolicyInfobody was 4 bytes shorter than kernel
expected —add_sprejected with EINVAL on every kernel
version (Plan 204 C2). The kernel's
struct xfrm_userpolicy_infouses natural alignment (not
packed); after the four trailing__u8fields 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. Kernelxfrm_add_policy()calls
nlmsg_parse_deprecated(nlh, sizeof(*p), ...)requiring
nlmsg_len >= NLMSG_HDRLEN + 168. Theadd_spAPI has been
silently non-functional since the XFRM family shipped. Fix
adds explicit_pad: [u8; 4]and asys_sizeofregression
test. -
XfrmUserpolicyIdbody was 4 bytes longer than kernel
expected —del_sp/get_spbrittle 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_padto[u8; 3]. -
XfrmUserTmplwas 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 betweenfamily(u16 at offset 24) andsaddr
(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"(perDEVLINK_GENL_MCGRP_CONFIG_NAME
ininclude/uapi/linux/devlink.h). Every
Connection::<Devlink>::subscribe()call returned
Error::FamilyNotFound. Fix changes the constant to
"config"plus thesys_sizeofregression test. -
Error::is_not_foundnow matchesError::Io(ENOENT)and
Error::Io(ENODEV)(Plan 212 M9). Brings the predicate
into symmetry withis_busy,is_permission_denied,
is_already_existswhich Plan 187 §2.5 already routed
througherrno(). Code callinge.is_not_found()on an
Error::Iocarrying ENOENT/ENODEV now correctly returns
true. 3 new regression tests. -
Connection::send_ack_innersurfaces 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::cacheRwLock poison handling (Plan 212
M17): previouslyread/write().unwrap()would panic on
poisoning; now recovers viaunwrap_or_else(into_inner).
Hardens against future panics inside the locked region
(currently unreachable; defense-in-depth). -
WireGuard
PublicKeyaccepts 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 returnedNone. Newparse_ssid_from_ies
helper + 6 unit tests covering well-formed, vendor-prepended,
missing, truncated, non-UTF-8, and empty IE chains. -
bins/nftrejects unknown--typeand--policytokens
(Plan 209 H5 — security UX). Pre-0.19 a typo on--policy
silently fell through to ACCEPT (--policy drpofordrop
produced an accept-everything firewall). Same hazard for
--typechain type. Now both error explicitly:
unknown policydrpo— expecteddroporaccept``. -
bins/wg set --private-key /pathpropagates 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 /typoexited 0 and the
user believed the new key was installed. Now the read error
surfaces immediately via?. -
bins/tc actionparses TC action attributes via zerocopy
ref_from_prefixinstead 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,policeparameter blocks, both
JSON and text formatters). -
NetworkConfig correctness pass: 6 silent reconcile-divergence
bugs fixed (Plan 207).-
H2 — link
masterchange detection (config/diff.rs).
Pre-0.19 the diff comparedOption<String>(declared) vs
Option<u32>(kernel ifindex) treating any (Some, Some)
pair as equal. Bridge-port reassignment (master: "br0"vs
kernelmaster: 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_routeuses
NLM_F_REPLACEso the kernel atomically swaps the existing
route — no del+add window. Most common reconcile op in
multi-router topologies. -
H4 —
apply_reconcilerecomputes 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_countbecomes
the cumulative sum across attempts. Empty diff at retry
start short-circuits as success. -
M3 —
remove_routeforwards table identity
(config/apply.rs). Pre-0.19 the_tableparameter 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 onlyVlan { parent }and
Macvlan { parent }were modeled. Declaring
vxlan42.underlay_dev("eth0")beforeeth0, or
dummy0.master("br0")beforebr0, in the same batch
silently failed at apply (same shape as Plan 186 §3c). -
M10 —
LinkState::Downuses 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-Upoperationally even when admin-up, so
LinkState::Downdeclared on a no-carrier admin-up
interface silently no-op'd. Now readsifi_flags & IFF_UP
(admin state). -
M18 — atomic
replace_qdiscvia NLM_F_REPLACE
(config/apply.rs). Pre-0.19 del+add sequence left a
transientpfifo_fast/mqwindow between the delete and
the new add; if the add failed the interface kept the
kernel-default qdisc not the previous declared one. Now
usesConnection::replace_qdisc*(atomic
RTM_NEWQDISC + NLM_F_REPLACE). Falls back to del+add for
Ingress/Clsactpseudo-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
existingnetwork_config_apply.rsintegration suite under
the privileged-CI gate. -
-
10 protocol recv-loops wrapped in
with_timeout+ seq
filter +NLM_F_DUMP_INTRdetection (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 asError::Timeout
after the configured budget andError::DumpInterruptedper
the Plan 208 contract.wg_commandstale-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 implementsSyncbut
concurrent.await-ed calls on a shared connection race on
recv and can produce dualError::Timeout. Recommended
pattern: oneConnection<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
andserdefeatures added to the features table. -
lib.rs landing-page doc-comment updated (Plan 214):
the stale "_by_namereads/sys/class/net/" claim removed
(Plan 192 D4 made both lookups netlink-correct);addr.address
doctest reference updated toaddr.address()accessor. -
Error::is_dump_interrupteddoctest type fix (Plan 214):
wasVec<nlink::Link>(doesn't exist), now
Vec<nlink::netlink::LinkMessage>. -
nftables-declarative-config.mdrecipe usesDisplay
instead of deprecated.summary()(Plan 214).
Earlier post-cycle fixes (
5ef0808)-
Batch::send_chunkcould hang indefinitely on dropped
per-op ACK (post-cycle bug-hunt) — the nftables/route batch
send-chunk recv loop did NOT run underConnection::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
inwith_timeout, so a dropped ACK surfaces asError::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 withoutwith_timeout, so
a kernel that dropped the response hung forever; they also
matched onnlmsg_typeonly and would have accepted a stale
frame from a prior request on the same socket. Both now match
nlmsg_seqfirst 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_socketbypassed
Error::from_errno*factory + had no seq filter / timeout
(post-cycle bug-hunt) — constructedError::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::newsilently
truncatednla_lentou16on > 65 KB payloads (post-cycle
bug-hunt) — the kernel'snla_lenfield is au16, 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'stcm_infopacking 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. -
AttrIterparser-robustness contract was unverified (post-
cycle bug-hunt) —AttrIteris the equivalent ofMessageIter
for nested attribute walking; every parser in the lib uses it
(hundreds of call sites). Plan 193 §2.3 added robustness tests
forMessageIter(found a real infinite-loop bug in the
process), but the matchingAttrIterhad zero tests —
future refactors could silently turn the safereturn None
paths into panics or infinite loops. 13 new tests pin the
three CLAUDE.md## Parser robustnessrules onAttrIter:
zero-length attribute terminates iteration without loop;
truncatedlen > bufferterminates; under-minlen < NLA_HDRLEN
terminates; accept-larger-than-expected payload is forward-
compatible;NLA_F_NESTED/NLA_F_NET_BYTEORDERflag bits
are masked fromkind()(preventing the vishvananda/netlink
#1104 bug class). Same bug-by-test-writing pattern as Plan 193
§2.3. -
TC filter
tcm_infopacking — kernel-EINVAL on every
add_filter*call with explicit protocol+priority (PR #7,
@nuclearcat) —add_filter_by_index_full(and the
siblingreplace/change/deletepaths infilter.rs,
plus the ratelimit ingress filter) packedtcm_infoas
(protocol << 16) | prioritywith nohtons. The kernel
usesTC_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
singleTcMsg::with_filter_info(protocol, priority)
chokepoint, restores accessor symmetry on
TcMessage::protocol()/priority()(which were
self-inconsistently broken — matched the buggy pack while
the unusedfilter_protocol()/filter_priority()
matched the kernel). 4 new unit tests pin iproute2's
exact wire layout + add the
pre_fix_layout_was_transposedregression 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-checksdoesn'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 onNatExpr.addr→NatAddrfor the type-level
fix. NewRule::dnat_v6(Ipv6Addr, Option<u16>)and
Rule::snat_v6(Ipv6Addr, Option<u16>)builders emit
Family::Ip6in the NAT expr (matching the address
family, not the chain'sFamily::Inet), load the 16-byte
address intoR0+ optional port intoR1. 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/SrcNatvs
prerouting/DstNat) prove the kernel stored exactly the
expr bytes nlink rendered. -
Error::is_busy,is_already_exists,is_permission_denied
catchError::Iovariants (Plan 187 §2.5) — these three
predicates matched onSelf::Kernel*variant directly,
missing theError::Io(io_err)case carrying the same
errno viaraw_os_error(). Same bug class as Plan 185's
is_no_buffer_spacefix. Single-point fix:Error::errno()
now unwrapsError::Ioviaraw_os_error(), so every
predicate that goes througherrno()inherits the right
shape.is_busyandis_try_againare used by
NftablesConfig::apply_reconcileretry classification —
a rawEBUSY/EAGAINfrom the socket layer no longer
bypasses the retry budget. Plan 185's defensive branch in
is_no_buffer_spaceis now redundant and removed. New
predicate_io_shape_sweeptest pins the contract for 10
predicates; future additions inherit it. -
MessageIterinfinite 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::nextreturnedSome(Err(...))on both
theNlMsgHdr::from_bytesfailure and the
msg_len < HDRLEN || msg_len > data.len()guard, but
forgot to advanceself.datapast the malformed
bytes. Subsequentnext()calls returned the same
Errindefinitely, hanging the long-lived multicast
subscribers Plans 185 + 191 introduced. Fix: in both
error branches, setself.data = &[]before
returning so the next poll yieldsNone. Bug class
matches neli #305 (whole-batch abort on one malformed
message) — same shape, different surface. 4 new
stream.rstests 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 theDisplayimpl
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 fromdiff.summary()todiff.to_string()
or use the{}placeholder informat!/println!.
Added
-
Rule::dnat_v6(Ipv6Addr, Option<u16>)+Rule::snat_v6(Ipv6Addr, Option<u16>)(PR #6, @avionix-g) — IPv6 NAT helpers, the
counterparts todnat/snat. Each loads the 16-byte address
intoR0(and the optional port intoR1) and emitsFamily::Ip6
in the NAT expr to match the address family (not the chain's
Family::Inet). Use onip6orinetNAT chains. Closes a silent
encoder bug whereaddr.is_some()was used as the proxy for
"register in use" — see the breaking-change entry onNatExpr.addr. -
Post-cycle audit backfill — closes gaps surfaced by
the 0.19 plan-by-plan audit:- Plan 196:
PublicKeynewtype withFromStr(base64)Displayround-trip,DebugviaDisplay. Inline
32-byte base64 codec (no new crate dep). 6 new unit
tests pinfE/wpxQ6/M6OmF5j4dvbY3FbCEXc3KlBL2QqAYjE0WI=
test vector + zero/max boundary cases + invalid-input
rejection.
- Plan 196:
Displayimpl onWireguardConfigDiff
rendering+ peer,~ peer (endpoint, allowed_ips),
- peerper change. Empty diff renders "no changes". - Plan 196:
WireguardConfig::apply_reconcile(conn, opts)mirroringNetworkConfig::apply_reconcile—
bounded EBUSY/EAGAIN retry with exponential backoff,
reuses the sharedReconcileOptionsshape. - Plan 192 §2.7: CLAUDE.md
## util::ifname sysfs reads
sub-section under the existing namespace-safety section.
Newscripts/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/outsidesysctl.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::applyround-trip - Plan 188 §2.4
NetworkConfig::apply_reconcilehappy path - Plan 188 §2.7
del_table_if_existsidempotence
(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
Stackorchestration + re-apply no-op
Plus Plan 196 + Plan 199 module-gated tests
(require_module!("wireguard")) covering full GENL
round-trip + watcher polling.
- Plan 188 §2.1
- Plan 196:
-
High-level facade APIs (Plan 200) — three thin
compositional wrappers + a unifiedStacktype that
collapse the typed surface's 5–15-line boilerplate into
one-liners.nlink::facade::apply::network(cfg).await?— opens a
freshConnection<Route>+ callsapply.
Same shape fornftables(...)(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'sinto_events_with_resyncwith the
factory closure built for you).nftables_changes()
same for Plan 185.wireguard_changes(opts).await?
returns aWireguardWatcherfor the polling path
(Plan 199 — kernel has no multicast).nlink::facade::Stack— bundlesNetworkConfig+
NftablesConfig+WireguardConfigwith optional
layers.Stack::applycalls them in dependency order
(RTNETLINK → nftables → WireGuard), returning a
StackApplyReportwith per-layer counters.
Stack::diffaggregates 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 theStackDiff/StackApplyReport
no-op semantics.
-
Declarative WireGuard configuration (Plan 196) —
the GENL-family twin ofNetworkConfig/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).awaitcomputes the symmetric diff
against current kernel state;cfg.apply(&conn).await
dispatches the kernel mutations.WireguardConfigDiff/DeviceChanges/PeerChanges
public diff types.change_countcounts kernel
calls, not dirty fields (a single SET_DEVICE collapses
all device-level changes into one write).allowed_ipsdiff is order-independent — declaring
in one order vs the kernel reporting in another
doesn't churn.WireguardApplyResultreportsdevice_writes+
peer_writes+peer_removalsseparately.- Privacy-key caveat: the kernel never returns
private_key/preshared_keyonGET_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 kernelwireguardGENL family declares
n_mcgrps = 0; verified 2026-05-31 via
drivers/net/wireguard/netlink.cupstream. There is no
native event-subscription surface — every WG monitoring
tool pollsGET_DEVICEon a cadence. nlink now ships a
typed poll-and-diff primitive so consumers don't
re-implement that machinery per app.WireguardEventenum:PeerAdded,PeerRemoved,
PeerEndpointChanged,PeerHandshakeRefreshed,
PeerAllowedIpsChanged. First poll emitsPeerAdded
for every existing peer (initial-inventory semantics,
matching Plan 185 / 191 snapshot shape).WireguardWatchOptionsbuilder:.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)returnsResult
(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. TheWireguardEventenum 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) andConcat(Vec<_>)
(composite key used in rules likeip saddr . tcp dport).type_id()forConcatpacks each
component's 6-bit type ID into sequential slots,
matching the kernel'snft_set_ext_concat
layout.len()returns the per-component sum after
4-byte alignment padding. Note:SetKeyTypelost
Copy(theConcatvariant carries aVec); the
enum is#[non_exhaustive]so this is mitigated, but
any downstreamlet k: SetKeyType = ...;that
expectedCopysemantics needs a.clone(). The
imperativeSetbuilder + downstream wire-emit code
in the lib was unaffected.
The fuller Plan 198 —DeclaredSetdeclarative type,
SetFlagsbitflags, 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
InetProtowire constants +Concatlength/packing
on 1/2/3-component shapes. -
#[must_use]on the diff + result + report types
(Plan 201 §2.1, scoped subset) —ConfigDiff,
NftablesDiff,ApplyResult, bothReconcileReports
(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
ReconcileReportwere updated tolet _ = ...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 +ParsedNextHopRTA_MULTIPATHparser (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
throughConnection<Route>::get_routes()lost their
nexthop list. The drift-detection consequence: any
NetworkConfigcarrying 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).ParsedNextHopstruct:ifindex+weight(1-based,
matchingip route+ imperativeNextHop::weight) +
flags+gateway: Option<IpAddr>.RouteMessage::multipath()accessor returning
Option<&[ParsedNextHop]>.RTA_MULTIPATH = 9const 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 thertnetlinkRust crate hit recently:concurrent_dumps_on_shared_connection_route_correctly
spawns 16 concurrentget_links()calls on a shared
Arc<Connection>and asserts every dump sees the
pre-createddummy0. 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_otherspawns
16 concurrentLabNamespace::newcalls, 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.
-
ResyncStreamExtcombinators on resync streams
(Plan 195 §2.1 + §2.3) — kube-rs-style composable
adapters overConnection<{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;Markeritems always
pass through (they're state-machine signals).map_event(f)— projects the innerTto a
domain-specific type via the closure;Markeritems
pass through unchanged.
default_backoff()+StreamBackoff(Plan 195 §2.2)
deferred — most consumers handle restart backoff at the
spawn-loop level viatokio::time::sleep; in-stream
backoff would needtokio::time::SleepPin 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.rsrewrote 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_interfaceis 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).
- D4:
-
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_resyncfrom Plan 185 +impl EventSource for Route+RtnetlinkGroupenum from 0.17) was already
in place; this commit ships the Route-specific layer:
rtnetlink_snapshot()walks the current state via the
existingget_links/get_addresses/get_routes/
get_neighborsmethods, returning aVec<NetworkEvent>
ofNew*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).
-
serdefeature flag — opt-inSerializederives on every
public diff/result/report type (Plan 189) — gated by a new
top-levelserdefeature (opt-in only; included infull).
JSON shape conventions: structs userename_all = "kebab-case"
(links-to-add, notlinks_to_add); enums use
rename_all = "snake_case"so unit variants emit bare strings
("inet", not{"Inet": null}).
Types gainingSerialize(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(bodyfield skipped),DeclaredFlowtable,
RuleHandle,ApplyResult,ApplyError(errorfield
serialized as theDisplaystring),ReconcileOptions
(tc recipe + nftables — both shapes),ReconcileReport
(tc recipe + nftables),StaleObject,UnmanagedObject,
TcHandle,FilterPriority.
Use case:apply --check --jsonenvelopes for CI gates and
IaC tooling. The kebab-case shape matches nlink-lab's
existing schema convention.Deserializeis not derived
this commit — the diff types are not user-constructible
(they're products ofcompute_diff), so round-trip
deserialization adds no consumer value. Closes
nlink-feedback §9 + W4.
5 new JSON-shape unit tests incrate::serde_tests(gated
onfeature = "serde"). -
ConfigDiff::applyinherent method (Plan 188 §2.1) —
matchesNftablesDiff::apply's shape from Plan 157.let diff = cfg.diff(&conn).await?; println!("{diff}"); diff.apply(&conn, ApplyOptions::default()).await?;More efficient than
NetworkConfig::applywhen 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 theIflaAttrenum 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. ImperativeOvpnLink~50 LOC
(matching theIfbLinkshape). Declarative path: zero-arg
LinkBuilder::ovpn()plus theOvpnenum variant.
Useful for inventory tools that need to detect ovpn
interfaces. Peer / cipher config stays in the GENLovpn
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::netkitDeclaredLinkType::Netkit(Plan 190 §2.3a)** —
BPF-programmable veth pair. ImperativeNetkitLink+
NetkitMode+NetkitPolicy+NetkitScrubalready
shipped in 0.16; this lifts them toNetworkConfig. 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 vianlink::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
LinkBuildercovering the previously-imperative-only bond
knobs.DeclaredLinkType::Bondgrew matching
Option<...>fields. The imperativeBondLinkalready
exposes all of these; the apply-path arm forwards them.
ExistingAdSelect+LacpRateenums re-exported via the
config types module asBondAdSelect/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::Vxlangrewlocal: 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 (IPv6localvalues silently dropped today —
the imperative layer is IPv4-only for tunnel-source IPs,
matching the existingremotehandling). 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 — VXLANcompute_diffparity against the
kernel's IFLA_VXLAN_* attribute dump would need an
IFLA_INFO_DATA parser; for now re-apply replays the
create. Note:DeclaredLinkType::Vxlanwidening
(already#[non_exhaustive]) requires..rest-pattern
in downstream matches. -
LinkBuilder::vlan_protocol(p)+VlanProtocolenum
(Plan 190 §2.2) — declarative-path VLAN protocol selector.
The imperativeVlanLinkgains a typed.protocol(VlanProtocol)
setter alongside the existing.qinq()shortcut.
DeclaredLinkType::Vlangrew aprotocol: Option<VlanProtocol>
field;None== kernel default (802.1Q). Use
VlanProtocol::Dot1adfor Q-in-Q.VlanProtocolis
#[non_exhaustive]. Closes nlink-feedback §12. Note:
widensDeclaredLinkType::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
imperativeVrfLinkshipped already; this lifts it to
NetworkConfig. Members enslave via the existing
LinkBuilder::masterchain. 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 byrequire_module!("vrf")).
Closes nlink-feedback §13 VRF half (WG half deferred to
Plan 196 for 0.20). Note: this widens the
DeclaredLinkTypeenum (already#[non_exhaustive]);
downstream pattern matches without..rest-pattern would
break, see migration guide §"Plan 190". -
Topo-sort
links_to_addso parent-before-child holds
regardless of declared order (Plan 186 §3c) —
NetworkConfig::applynow 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
—NetworkConfigconstructed from aHashMap(where the
child happens to iterate first) now works. The
NetworkConfig::linkdocstring 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'sresolve_interfaceis 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 ofNetworkConfig::apply, mirroring
NftablesDiff::apply_reconcile(Plan 157, 0.16). Retries
onError::is_busy()/is_try_again()up to
opts.max_retriestimes 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-rootReconcileOptions(the TC recipe shape with
fallback_to_apply/dry_run). Plan 187'serrno()
Io-shape fix means raw socket-layerEBUSY/EAGAIN
classifies correctly now. -
LinkChanges::Display(Plan 188 §2.5) —ConfigDiff::Display
can renderlinks_to_modifyrows compactly:
~ link eth0 (mtu=9000, up). Wraps the existingsummary()
(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
existingdel_*methods. ReturnOk(true)when the resource
was deleted,Ok(false)when it didn't exist (kernel ENOENT).
Replaces the universallet _ = 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 deepestnlink::Errorin the chain,contexts()collects
every layer outer-to-inner. New namedChainWalkiterator
struct exposed at the crate root. Rustdoc onError::Kernel
warns about the boxed-source trap + points consumers at
chain_walkas 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). Newscripts/audit-recv-loop-error-handling.sh
CI gate fails on a?operator inside aMessageIter::new
walking loop instream.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 nowasync
(Finding B). Acquires the connection's request lock for the
stream's lifetime so concurrent streams on a shared
Arc<Connection>no longer race onpoll_recv. Same change
cascades through:Connection<Route>::into_events_with_resync/subscribe_all_with_resync→async fnConnection<Nftables>::into_events_with_resync/subscribe_all_with_resync→async fnnlink::facade::watch::{route_changes,route_changes_in_namespace,nftables_changes,nftables_changes_in_namespace}→async fn
Migration: add
.awaitat 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&mutwas a stale artefact of routing through
AsyncFd::get_mut. Same fix on:Connection<Nftables>::subscribe/subscribe_all/subscribe_all_with_resyncConnection<Netfilter>::subscribe/subscribe_allConnection<Wireguard|Macsec|Mptcp|Ethtool|Nl80211|Devlink>::subscribeConnection<Route>::subscribe_all_with_resync- GENL macro-generated
subscribe_groupon macro-defined families - Internal
NetlinkSocket::add_membership/drop_membership
Migration: remove
mutfromConnection<P>bindings if it
was only there forsubscribe*. ConnectionPool's
PooledConnectionnow supportspool.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 selfaccessor on Connection; obsolete now that
add_membershipis&self. Internal API (pub(crate));
the lib's own callers refactored toself.socket().add_membership(group). -
Connection<P>::request_lockfield type:
Mutex<()>→Arc<Mutex<()>>(Finding B prerequisite).
Required so streams can hold anOwnedMutexGuardwhose
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 isConnection::<P>::new()).
Concurrency: F1 closed across all protocols
-
F1 —
Connection<P>request lock closes the rtnetlink
#131 shape: two tasks sharingArc<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::Timeoutafter Plan 171's 30s default). Added
tokio::sync::MutexonConnection<P>, acquired at every
send+recv-loop method via a newpub(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 hatchesConnection<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_correctlytest
(originally#[ignore]'d when this bug was discovered) is
now green on CI. A second
concurrent_ack_requests_on_shared_connection_succeedtest
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+EventSubscriptionlifetime
lock. Stream-shape APIs were the remaining concurrency
hole: twoDumpStreams on a shared connection would both
callsocket.poll_recvand steal each other's frames.
Closed by storing anOwnedMutexGuard<()>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::createthread-bleed.
unshare(CLONE_NEWNET)is scoped to the calling thread,
not the process. When called from a tokio worker thread
(LabNamespace::newin 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
matchingsetns()restored it — including anyConnection<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 aConnection<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 onMessageIterparse
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.rsfrom_le_byteson host-order fields.
4 sites usedu16::from_le_bytes/u32::from_le_bytesfor
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 hoststo_le_bytesandto_ne_bytes
coincide so this regressed silently; the audit caught it via
kernel-source cross-check. -
N4 (HIGH) —
RouteMessage::write_todropped 5 fields.
source,iif,pref,expires,multipath(the Plan 202
ECMP nexthop chain) were parsed but never written, silently
dropping them onget → mutate → setround-trips. Added 5
builder setters (.source(addr, prefix_len),.iif(ifindex),
.pref(p),.expires(secs),.multipath(Vec<ParsedNextHop>))- 5 emit branches + a
write_attr_multipathhelper that
mirrorsparse_multipath(rtnexthop chain with nested
RTA_GATEWAY). ECMP route replay through the typed API works
now. Roundtrip regression test added.
- 5 emit branches + a
-
N5 (HIGH) —
NeighborMessage::write_todropped 6 fields.
probes,port(BIG-endian — VXLAN UDP port),vni,
ifindex_attr,master,cache_infowere 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_eventsfirst-failure
killed whole watcher. Plan 199's per-interface loop used
?to propagateget_device_by_nameerrors. Deleting one
watched interface out-of-band aborted the entire poll cycle —
all other interfaces' updates silently dropped, watcher
stuck. Fixed:matchon each per-iface result, log
tracing::warn!on error, emitPeerRemovedfor tracked
peers on the failed iface, drop it fromself.previous,
continue. -
N7 (HIGH) —
Stack::applyhad 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: callself.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/_tunnelsdropped
orphan RANGE_BEGIN. Consecutive RANGE_BEGIN markers (no
intervening RANGE_END) silently overwrote the prior
range_startand 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 priorrange_startas a single
VLAN/tunnel with atracing::warn!, then start the new
range. Symmetric fix for the VLAN + tunnel parsers. -
N9 (MEDIUM) — 6 sibling parsers used
le_u16for
host-ordernla_len/nla_type+ wrong mask inrule.rs.
messages/{rule,address,link,neighbor,route,tc}.rsall
parsedstruct nlattrheaders as little-endian. Also
rule.rsmasked0x7fffinstead of canonical
NLA_TYPE_MASK = 0x3fff, so any future kernel attr shipped
withNLA_F_NET_BYTEORDERwould silently miss every match
arm. Fixed all 6 totake(2)+from_ne_bytes(winnow has
none_u16); rule.rs usesNLA_TYPE_MASK. -
Finding A (HIGH) — subscribe blocked through ConnectionPool.
See breaking changes above. -
Finding C (MEDIUM) —
Pool::invalidatecapacity decay.
PooledConnection::invalidatedropped the broken connection
without replacing it. After N invalidates a size-N pool's
acquire()would block indefinitely. Fixed: added a
Factory<P>trait onPoolInnercapturing the namespace +
sync/async build mode.invalidatenowtokio::spawns a
task that callsfactory.build().awaitandtry_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_controlmissed F1
lock. Send-only path didn't acquirerequest_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_ACKis not set; cn_proc doesn't emit one). -
Finding E —
Batch::send_chunkstale-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 rawNFTA_RULE_EXPRESSIONSbytes 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_CODEis actually__be32on the wire — the
test correctly usesto_be_bytes()to assert the
protocol-correct encoding. -
Plan 211 M1 —
Hook::InetIngresskernel 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_resynclive events.
Asserts that a live multicast event (a dummy link addition)
arrives wrapped inResyncedEvent::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 concurrentadd_linkcalls on a shared
Arc<Connection<Route>>; all must succeed and the final
dump must see all 16 dummies.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
nlink 0.18.0 Stable
released this
2026-05-29 10:22:27 +00:00 | 345 commits to master since this releaseAdded
-
DeclaredChainBuilder::chain_type(ChainType)(Plan 180)
— closes the parity gap between the imperative
Chain::chain_typeand declarativeDeclaredChainpaths.
Required for declarative NAT chain reconcile via
NftablesConfig::diff().apply(): a chain hooking
prerouting/postroutingwithoutchain_type(ChainType::Nat)
defaults toChainType::Filter, and anymasquerade/
snat/dnatverdict refuses to load withEOPNOTSUPP.
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). WiresNFTA_HOOK_DEV(constant 3 inside
theNFTA_CHAIN_HOOKnest). Required forFamily::Netdev
base chains; ignored on other families. Both imperative and
declarative paths gained the setter.ChainInfonow exposes
device: Option<String>populated from dump responses, and
is now#[non_exhaustive](only construction site is
internalparse_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 onConnection<Nftables>. Mirror the
existinglist_rules(table, family)shape: each new method
emits the correspondingNFTA_*_TABLEattribute +
nfgen_familyso the kernel returns only matching entities.
More efficient thanlist_*().filter(...)on hosts with
many tables. The unfiltered counterparts keep working
unchanged. Integration test
list_in_filters_match_only_target_tableexercises all
four_inshapes against a two-table fixture. -
Error::ext_ack() -> Option<&str>+
Error::ext_ack_offset() -> Option<u32>(Plan 182) —
inherent accessors over theKernel/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 existingerrno() -> Option<i32>shape. -
impl Display for NftablesDiff+impl Display for ConfigDiff(Plan 183) —println!("{diff}")now works
directly. Wraps the existingsummary()methods, so the
rendered output is unchanged fromdiff.summary(). -
Ipv4Route::default_route()/
Ipv6Route::default_route()(Plan 184) — self-documenting
zero-arg constructors for0.0.0.0/0and::/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 freshConnection<Nftables>on demand,
receive aStream<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 asResynced(...)items between
ResyncMarker::ResyncStart/ResyncEndmarkers. The owned
form (into_events_with_resync) returns a'static + Send
stream that'stokio::spawn-friendly; the borrowed form
(subscribe_all_with_resync) keeps&mut selfaround for
ad-hoc queries on the same connection. New module
nftables::resyncexportsnftables_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.
WiresNFT_MSG_NEWSET/NFT_MSG_DELSETthrough
parse_nftables_eventusing the existingparse_setparser.
Set elements (NFT_MSG_NEWSETELEM) remain unwired; open an
issue if you need them.NftablesEventcarries
#[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 forConnectionFactory<P>at the crate root; the
first cut shipped a non-genericnftables::resync::ConnectionFactory
pinned toNftables. Aligned now: both types live in
nlink::netlink::resync(re-exported asnlink::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 freepub(crate)functions so the tests can
inspect the on-wire bytes without socket I/O. No behavioral
change to the publiclist_*_inmethods. -
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'sSO_RCVBUFto 256 bytes,
spawns a 2k-iteration rule-add flood from a second connection,
drains the resync stream slowly, asserts the
ResyncStart → Resynced(...) → ResyncEndmarker sequence.
Needed a newNetlinkSocket::set_rcvbuf(bytes)helper
(SO_RCVBUFFORCE— requiresCAP_NET_ADMIN, matches the
existing root-gated test scope). -
ChainInfo.chain_typeis nowOption<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_chainmaps the kernel's"filter"/"nat"/"route"
string into the typedChainTypevariant; unrecognised
values (kernel can grow new chain types) yieldNone.
AddedChainType::from_kernel_string(&str) -> Option<Self>
as the canonical mapping. Affects only downstream code that
readChainInfo.chain_typedirectly — typed match arms keep
working, stringly comparisons (== Some("nat".into())) need
to become== Some(ChainType::Nat).
Breaking changes (lib internals)
events_with_resyncis now lifetime-generic (Plan 185) —
the snapshot-future bound went fromSend + 'staticto
Send + 'a. Existing call sites that handed in'static
closures keep working unchanged (the'aparameter defaults
via lifetime elision to whatever satisfies the caller). The
refactor unlocks the borrowedsubscribe_all_with_resync
variant whose snapshot future doesn't need to outlive the
caller's stack frame.
Downloads
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-
-
nlink 0.17.0 Stable
released this
2026-05-26 07:05:54 +00:00 | 362 commits to master since this releaseBreaking changes
-
Registerdiscriminants changed (Plan 178) — switched
fromNFT_REG32_xx(8..=11, 4-byte register aliases) to the
canonicalNFT_REG_xform (1..=4, 16-byte registers).
Downstream code that castRegister::R0 as u32and stored
the literal value8will see1instead. The lib's
wire-format behavior is unchanged from the kernel's
perspective — the kernel canonicalizes both forms to
NFT_REG_1internally — but anyone matching the raw integer
needs an audit. Enum now carries#[repr(u32)], locking the
memory layout so theas u32cast is well-defined. -
NftablesDiff::rules_to_deletetuple shape: changed
fromVec<(String, Family, RuleHandle)>to
Vec<(String, Family, String, RuleHandle)>— the chain name
is now carried explicitly. The kernel rejects aDELRULE
with an emptyNFTA_RULE_CHAINeven when
NFTA_RULE_HANDLEpins the rule, contrary to an earlier
assumption. Plan 178 closeout.
Fixed
-
Connection::send_request/send_ackno longer error
when the socket is also subscribed to multicast groups —
both recv paths did a singlerecv_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 onrecv_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 aConnectionthat's alsosubscribe'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_batchno longer hangs on a
missing batch-end ACK (Plan 170) — the recv-loop didn't
filter bynlmsg_seqand terminated on the first per-op ACK
rather than the BATCH_END's ACK. A sequence-skew on the GHA
rust:bookwormcontainer surfaced this as a 22-minute hang
on the 0.16 cut CI. Fix: seq-filter + end-seq termination +
NLM_F_ACKonNFNL_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 7nftables_reconcile::*tests this
blocked; the remaining 3 became Plan 178. -
NftablesConfig::diffbody-bytes false-positive (Plan 178)
— keyed rules were flagged asto_replaceon 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_tlvin the diff path: walks both sides of
the comparison as TLV trees, stripsNLA_F_NESTED(0x8000)
andNLA_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: f64normalized severity (Plan 169 Phase 3)
—Diagnostics::find_bottleneck()now returns aBottleneck
with ascore: f64field (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'sBottleneckType(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 inNetworkConfigcaller chains. The two parse-
error types are now#[from]variants onnlink::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.rsupdated 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_shapercaps). 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-macrosisn'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 30aftercargo publish -p nlink- macroswith a poll loop oncargo search(5-min cap).
Pre-flight checks: clean tree, on the cycle branch, Cargo.toml
version matches the arg, cargo + gh auth present. - skips
Changed
-
Connection<P>operations now time out after 30 s by
default (Plan 171) — everyConnection<P>method that
performs a netlink round-trip (every getter, setter, dump,
batch commit) now wraps the underlyingrecvloop in a
30-secondtokio::time::timeout. Before 0.17.0, the timeout
was opt-in viaConnection::timeout(Duration)and the
default wasNone— 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 thedump_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-friendlytracing-subscriberhonoringRUST_LOG.
Auto-invoked bynlink::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=traceso a
hang surfaces which method was in flight at the failure
point..github/workflows/integration-tests.ymlmodprobes
nf_tables+nf_flow_tableexplicitly (previously relied
on kernel auto-load; documents intent + survives locked-down
containers).crates/nlink/tests/integration/IGNORED.mdcatalogs 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
-
Source code (ZIP)
0 downloads
-
Source code (TAR.GZ)
0 downloads
-