mirror of
https://github.com/jmcorgan/fips.git
synced 2026-10-06 03:28:24 +00:00
Merge branch 'master' into next
Carries the seven security fixes up from the mainline. Six of them apply here; the seventh does not, because this branch's FSP rekey is Noise XX where the mainline's is XK. Landed complete: the coordinate-cache guard and its test, the flap dampening re-arm with its clamp and its reporting, the traversal target filter, the signal freshness bound, the path MTU floor with its cache release, and the routing-signal sender binding. The floor constant is where the mainline moved it, in the protocol layer with the upper layer re-exporting it. Deferred to a follow-on commit: never destroy an established session on an unauthenticated setup message. Its established-session arm is written against XK, whose msg1 and msg3 handling and whose dual-initiation states differ from this branch's, so the arm has to be re-implemented rather than merged. Everything that fix touches is held back with it — the clock-free core's abandonment decision and its snapshot field, the shell arm that executes it, the three session-entry helpers, and the tests and test helper that drive them — so the branch does not carry half of it. The typed rejection variants and the counters merged cleanly and read zero until the fix lands. Two mechanical adaptations were needed for the fixes that did apply. The routing-signal test helpers build a handshake in a given lifecycle state and had to use this branch's XX constructors, which take no pinned remote static. The shared test-node builder gained an MTU parameter on the mainline; here it delegates to the builder this branch already had, and its two existing callers pass the value the one-argument version hardcoded, so their behaviour is unchanged. Green: fmt, build, clippy --all-targets -D warnings and test --lib at 1903 passed.
This commit is contained in:
+207
@@ -447,6 +447,53 @@ with v0.4.x or earlier peers.
|
||||
a private parent directory it creates or recognizes as a canonical FIPS
|
||||
runtime directory.
|
||||
|
||||
- A SessionDatagram carrying a truncated inner FSP payload no longer panics the
|
||||
forwarding path. The coordinate-cache warm path sliced the inner payload at
|
||||
the full 12-byte header offset while guarding only with the 4-byte common
|
||||
prefix parser, so an inner payload of 4 to 11 bytes with phase 0x0 and the
|
||||
Coords Present flag set indexed past the end of the slice. Because the
|
||||
receive loop is the process's main future, the panic terminated the daemon
|
||||
rather than a task, and under the packaged systemd unit the node restarted
|
||||
into the same frame. The warm path now applies the same
|
||||
`FspEncryptedHeader` guard the local-delivery path already used, which
|
||||
additionally means a malformed frame carrying a non-zero protocol version or
|
||||
the Unencrypted flag alongside Coords Present is dropped rather than having
|
||||
its body read as coordinates. Any peer that had completed a link handshake
|
||||
could trigger this, and admission is default-open. Frames rejected by that
|
||||
guard are now counted in the forwarding statistics as
|
||||
`warm_malformed_packets` and `warm_malformed_bytes`, visible over the control
|
||||
socket and on the fipstop Routing State pane, so a node being fed malformed
|
||||
frames is distinguishable from a quiet one at the default log level. The
|
||||
count is not a packet drop: the frame is still delivered or forwarded, and
|
||||
only the coordinate-cache warm attempt is abandoned. The existing debug log
|
||||
now also carries the frame's protocol version and flags, which separate a
|
||||
short frame from a bad-version or Unencrypted-flagged one.
|
||||
|
||||
- Flap dampening can now engage more than once in the lifetime of a node.
|
||||
The arming check tested whether a dampening deadline had ever been set
|
||||
rather than whether one was still in effect, so the first episode
|
||||
disarmed the mechanism permanently: a node in a second flap storm went on
|
||||
switching parents under hold-down alone, and neither the `flap_dampened`
|
||||
counter nor the "Flap dampening engaged" warning fired again, so the
|
||||
storm was invisible to anyone watching that counter. A lapsed episode is
|
||||
now retired explicitly, clearing both the deadline and the switch
|
||||
counter, so a second episode requires a fresh threshold of switches
|
||||
within one window rather than re-engaging on the first switch after
|
||||
lapse. Hold-down was unaffected throughout and continued to limit
|
||||
discretionary switching, which is why the practical effect at shipped
|
||||
settings was lost visibility and a lost escalation tier rather than
|
||||
unrestrained flapping. Every path that can engage an episode now reports
|
||||
it, including a re-engagement during parent-loss recovery, which was
|
||||
previously silent. The warning names which path armed the episode
|
||||
(`trigger`) and how long discretionary parent switching stays suppressed
|
||||
(`dampening_secs`), using the same `trigger` values as the parent-switch
|
||||
logs beside it, so the two can be read together.
|
||||
|
||||
- A `node.tree.flap_dampening_secs` large enough to overflow the monotonic
|
||||
clock no longer panics the node when dampening engages; the value is
|
||||
capped at one year, beyond which an episode is indistinguishable from
|
||||
permanent.
|
||||
|
||||
- The maintainer address published in package metadata no longer bounces. The
|
||||
crate authors field, the Debian package maintainer and upstream contact,
|
||||
both AUR PKGBUILD maintainer lines and the FreeBSD package manifest carried
|
||||
@@ -627,6 +674,141 @@ with v0.4.x or earlier peers.
|
||||
overwrite it. Anonymous dials still promote whoever answers, which is what
|
||||
shared-media discovery means.
|
||||
|
||||
- The influence a remote party has over path MTU is now bounded, and the
|
||||
per-destination path MTU cache has a way back. The `path_mtu` field is an
|
||||
unsigned per-hop transit annotation carried outside the signed proof, and the
|
||||
`MtuExceeded` and `PathBroken` signals arrive unencrypted with no sender
|
||||
check, so any forwarder — or anyone who can reach the node — could lower it,
|
||||
and it was accepted with no minimum. A single `MtuExceeded` carrying a very
|
||||
small value drove a session's path MTU to zero, after which every packet to
|
||||
that destination was answered with an ICMPv6 Packet Too Big instead of being
|
||||
sent: a blackhole that lasted until the daemon restarted. The same value
|
||||
reached the SYN-time TCP MSS clamp, where anything at or below 137 saturates
|
||||
to a segment size of zero and the band just above it yields single digits.
|
||||
Values below an actionable minimum are now ignored rather than applied or
|
||||
stored, at the three places a remote value is acted on: the path MTU state
|
||||
machine, the reactive `MtuExceeded` write, and the discovery response, whose
|
||||
coordinates are still cached so refusing the annotation cannot become a way
|
||||
to deny discovery. The MSS clamp additionally refuses to write a zero. Each
|
||||
of the three refusals logs a warning and increments its own counter in the
|
||||
error-signal family, so an operator can tell them apart without scraping
|
||||
logs: they carry different meanings, one being an authenticated peer inside
|
||||
an established session, one an unencrypted signal anyone able to reach the
|
||||
node can send at will, and one a verified discovery response whose unsigned
|
||||
annotation a forwarder on the reverse path rewrote. Because those three
|
||||
refusals are the only way a remote value reaches the per-destination store,
|
||||
the SYN-time clamp does not apply the minimum a second time when it reads
|
||||
that store: a small value there is one the node derived from its own outgoing
|
||||
link, which is exact rather than suspect, and BLE in particular negotiates a
|
||||
link MTU per connection that lands under the minimum routinely. The clamp
|
||||
refuses only a stored value admitting no TCP payload byte at all, at 137 or
|
||||
below, where the segment size saturates to zero and the clamp would be
|
||||
skipped entirely; it logs that at trace rather than warn, since it sits on
|
||||
the per-packet path, and the peer's link promotion reports it once instead.
|
||||
A stored per-destination path MTU is released when the path is invalidated by
|
||||
a `PathBroken` report, by session idle expiry, or by handshake timeout, and
|
||||
the link MTU read from the local transport is reseeded in its place, so a
|
||||
directly connected peer does not lose its own measurement along with the
|
||||
remote claim. Locally derived MTUs are not subject to the minimum, at the
|
||||
seed or at the clamp. Legitimate narrow paths are unaffected: adaptation to
|
||||
hops well below the IPv6 minimum, which the mesh does use, continues to work.
|
||||
|
||||
- A session setup message naming an already-established peer no longer replaces
|
||||
that peer's session. The handler did this whenever `node.rekey.enabled` was
|
||||
false: it ran a fresh responder handshake and overwrote the entry, discarding
|
||||
the live keys. The message carries no authenticator and its source address is
|
||||
an envelope field, so anyone able to reach a node could name an established
|
||||
peer and take that session down, repeatedly, and hold it down by repeating
|
||||
the message. The established case now always arms the handshake alongside the
|
||||
running session and adopts the new keys only after a msg3 whose authenticated
|
||||
static key matches the key the session was opened with, which is the check
|
||||
the rekey path already applied; a peer that genuinely restarted still
|
||||
re-establishes, and a forged setup leaves the session carrying traffic. This
|
||||
changes no wire format and adds no configuration: a node with rekey disabled
|
||||
already answered such a message, it simply destroyed the session afterwards.
|
||||
|
||||
- The session drain sweep and the cut-over that retires an old key epoch now
|
||||
run whether or not periodic rekey is enabled. Both sat behind the
|
||||
periodic-rekey gate, so a node with rekey disabled that adopted new keys held
|
||||
the superseded ones for the life of the session.
|
||||
|
||||
- A session rekey armed by a peer's setup message is now abandoned if the
|
||||
matching msg3 never arrives, rather than persisting for the life of the
|
||||
session. A stuck one made the node treat a later genuine setup message as a
|
||||
simultaneous initiation and drop it, which would otherwise have turned the
|
||||
fix above into a lasting block on re-establishment for roughly half of peer
|
||||
pairs. Only the armed handshake expires, and only it: a rekey that completed
|
||||
is the key epoch the peer has already moved to, since it exists only because
|
||||
a msg3 carrying that peer's authenticated key arrived and the sender of that
|
||||
msg3 promotes the new epoch on an unconditional two-second timer. Expiring
|
||||
those keys on any timer would drop every later frame from that peer, so they
|
||||
are now held until the peer's own frame promotes them, a newer completed
|
||||
rekey replaces them, or the session goes away. What the wait does bound is
|
||||
precedence, not the keys: a completed rekey outranks a fresh setup message
|
||||
from that peer only until it has waited a full idle timeout, after which the
|
||||
setup is answered normally, so a peer that restarted while we held such a
|
||||
session is no longer refused for as long as our own sends keep the session
|
||||
from idling out. The handshake timeout logs at INFO, since it costs nothing,
|
||||
and a completed session displaced by a newer one at WARN, since that does
|
||||
throw away keys the peer may hold. Session counters record the arming of a
|
||||
handshake by a setup message, each of the three ways such a message is
|
||||
refused, and each displaced session, so a node under a sustained spray of
|
||||
setup messages shows a rate rather than nothing; the per-message log lines
|
||||
stay at DEBUG because an unauthenticated sender can drive them at line rate.
|
||||
These counters are not yet readable through the control socket.
|
||||
|
||||
- Traversal punch targets taken from a peer's offer or answer are now
|
||||
filtered and bounded. A rendezvous-enabled node previously punched every
|
||||
address a signed offer named, including loopback, link-local, multicast,
|
||||
broadcast, unspecified and CGNAT addresses, and placed no limit on how
|
||||
many candidates one offer could carry. Any npub could
|
||||
therefore have a node emit a burst of UDP packets at addresses of the
|
||||
sender's choosing, carrying the node's own source address. Candidates in
|
||||
the never-routable ranges are now rejected, IPv4-mapped IPv6 forms are
|
||||
canonicalized before the check so they cannot slip past it, candidates
|
||||
with port 0 are dropped, private-range candidates are punched only when
|
||||
they share a /24 with one of our own addresses (which is what same-LAN
|
||||
traversal already required of its own path), and the planned target list
|
||||
is capped at eight. A peer's reflexive address is checked against the
|
||||
never-routable ranges but not against the /24 rule, so a deployment whose
|
||||
STUN server sits inside the private network keeps working. A malformed
|
||||
address in a peer's signal now drops that one candidate instead of
|
||||
failing the whole traversal. A node also records what it declined: one
|
||||
log record per planning attempt carries how many candidates the peer
|
||||
offered, how many were planned, the count refused in each class and one
|
||||
sample address, at warning level for the shapes no honest peer produces
|
||||
and at debug level for the routine off-subnet case. Same-LAN and
|
||||
reflexive traversal are otherwise unaffected.
|
||||
|
||||
- Traversal offers and answers dated in the future are now rejected. The
|
||||
freshness check measured a message's age with a saturating subtraction, which
|
||||
yields zero for any timestamp ahead of the local clock, so the age test could
|
||||
not fail for a future-dated signal and no other term bounded the issue time
|
||||
from above. A signal claiming to be issued arbitrarily far in the future was
|
||||
accepted as strictly fresh, which voided the property that the freshness
|
||||
window is narrower than the session-id replay window (300s by default) and
|
||||
left the replay cache as the sole defence against a captured offer being
|
||||
replayed. Forward-dating is now tolerated only up to the same 60s of clock
|
||||
skew already allowed in the other direction, and a signal accepted under that
|
||||
grace reports the skew outcome, so the existing clock-skew log fires for a
|
||||
peer whose clock is ahead just as it does for one whose clock is behind. The
|
||||
declared expiry timestamp is also no longer trusted beyond the issue time plus
|
||||
the configured TTL, so a sender cannot widen its own acceptance window by
|
||||
inflating that field. A single timestamp is now acceptable over at most the
|
||||
signalling TTL plus 60s on each side, 240s under the shipped defaults.
|
||||
Rejections are also now distinguishable in the log: a stale signal and a
|
||||
future-dated one no longer share one reason string, and the inbound-offer
|
||||
path, whose only surface was an unattributed debug line below the default log
|
||||
level, now names the peer and the session and warns for the rejection classes
|
||||
that relay delivery lag cannot produce (future-dated, identity-mismatch and
|
||||
malformed offers), leaving an ordinary stale offer quiet. As with the existing
|
||||
inbound rate-limit warning, an unauthenticated remote peer can drive that
|
||||
line. A failure of our own offer's freshness during answer validation is
|
||||
reported against the offer rather than mislabelled as the answer's, and the
|
||||
tolerated-acceptance log now carries the issue and expiry stamps and no longer
|
||||
attributes the acceptance to clock skew, since a peer configured with a longer
|
||||
signalling TTL than ours now reaches it too.
|
||||
|
||||
- The FSP session address is now bound to the peer key the Noise handshake
|
||||
authenticated, on both the initial and the rekey path. The responder recorded
|
||||
a session under the source address carried in the datagram without ever
|
||||
@@ -736,6 +918,31 @@ with v0.4.x or earlier peers.
|
||||
download now checks a per-architecture pinned SHA-256, with the hash
|
||||
provenance recorded honestly, upstream publishing no checksum document.
|
||||
|
||||
- The three routing signals (`CoordsRequired`, `PathBroken`, `MtuExceeded`) are
|
||||
no longer acted on unless this node has itself bound the destination address
|
||||
they name, either by initiating a session toward it or by completing the
|
||||
Noise handshake that binds an address to a peer's static key. These signals
|
||||
carry no end-to-end authentication, so until now any admitted mesh member
|
||||
could send one naming any address and have its effects applied: a path-MTU
|
||||
clamp written for an arbitrary address, a cached-coordinate flush for an
|
||||
arbitrary address, and a discovery and warmup cycle for an arbitrary address.
|
||||
The `MtuExceeded` case was the sharpest, because its write into the
|
||||
address-keyed path-MTU lookup that the TUN reader consults at TCP MSS clamp
|
||||
time sat outside the session guard and so required no session, no peer
|
||||
relationship and no prior state at all. A half-open session created by an
|
||||
inbound handshake that has not yet proved its address does not admit these
|
||||
signals, so a forged session opening cannot be used to unlock them. Signals
|
||||
from a genuine on-path forwarder are unaffected: the reporter may be any node
|
||||
at any distance. This does not make the sender authentic, which nothing
|
||||
short of a wire format change can do. Rejected signals are counted as
|
||||
unknown-session rejections, and additionally on four new error-signal
|
||||
counters visible through `show routing`, `show metrics` and the fipstop
|
||||
routing pane: `unbound_coords`, `unbound_broken` and `unbound_mtu` give the
|
||||
refused count per signal type, against the existing per-type arrival
|
||||
counters as the denominator, and `unbound_forged` counts the subset whose
|
||||
claimed source and destination pairing no honest forwarder could produce.
|
||||
The drop log line now carries the signal type and the refusal class.
|
||||
|
||||
## [0.4.1] - 2026-07-19
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -924,7 +924,8 @@ The primary stability mechanisms are implemented:
|
||||
`flap_dampening_secs = 120`): if a node switches parents more than 4
|
||||
times within 60s, an extended 120s hold-down is imposed. Mandatory
|
||||
switches (parent loss, root change) bypass dampening. The flap counter
|
||||
resets when the window expires naturally.
|
||||
resets when the window expires and again when a dampening episode lapses,
|
||||
so each episode requires a fresh threshold of switches within one window.
|
||||
|
||||
These mechanisms compose to bound announcement traffic even under rapid link
|
||||
flapping. The hold-down timer limits the rate of parent switches (at most
|
||||
|
||||
@@ -334,7 +334,7 @@ cutover.
|
||||
|
||||
| Parameter | Type | Default | Description |
|
||||
|-----------|------|---------|-------------|
|
||||
| `node.rekey.enabled` | bool | `true` | Enable periodic Noise rekey on all links and sessions |
|
||||
| `node.rekey.enabled` | bool | `true` | Initiate periodic Noise rekey on links and sessions. A peer-driven session rekey is still answered when this is off, so session keys can still rotate |
|
||||
| `node.rekey.after_secs` | u64 | `120` | Initiate rekey after this many seconds on a session |
|
||||
| `node.rekey.after_messages` | u64 | `65536` | Initiate rekey after this many messages sent on a session |
|
||||
|
||||
|
||||
@@ -59,6 +59,13 @@ fn draw_routing_state(
|
||||
"Recent Requests",
|
||||
helpers::u64_field(data, "recent_requests"),
|
||||
),
|
||||
// Not a drop: the frame is still delivered or forwarded, only the
|
||||
// coordinate-cache warm attempt was abandoned. It belongs here beside
|
||||
// the cache it failed to warm, not in the Dropped section.
|
||||
(
|
||||
"Warm Malformed",
|
||||
fwd_value(data, "warm_malformed_packets", "warm_malformed_bytes"),
|
||||
),
|
||||
]);
|
||||
|
||||
let block = helpers::pane_block(" Routing State ", focused);
|
||||
@@ -284,6 +291,13 @@ fn draw_routing_stats(
|
||||
("Coords Required", err("coords_required")),
|
||||
("Path Broken", err("path_broken")),
|
||||
("MTU Exceeded", err("mtu_exceeded")),
|
||||
("PMTU Notif < Floor", err("path_mtu_notif_below_floor")),
|
||||
("MTU Exceeded < Floor", err("mtu_exceeded_below_floor")),
|
||||
("Lookup PMTU < Floor", err("lookup_resp_mtu_below_floor")),
|
||||
("Coords Required Refused", err("unbound_coords")),
|
||||
("Path Broken Refused", err("unbound_broken")),
|
||||
("MTU Exceeded Refused", err("unbound_mtu")),
|
||||
("Forged Pairing", err("unbound_forged")),
|
||||
],
|
||||
));
|
||||
right.push(Line::from(""));
|
||||
|
||||
@@ -908,7 +908,7 @@ fn routing_state_values_aligned() {
|
||||
"identity_cache_entries": 5,
|
||||
"pending_lookups": [],
|
||||
"recent_requests": 7,
|
||||
"forwarding": {},
|
||||
"forwarding": { "warm_malformed_packets": 5, "warm_malformed_bytes": 640 },
|
||||
"discovery": {},
|
||||
"error_signals": {},
|
||||
"congestion": {}
|
||||
@@ -932,6 +932,18 @@ fn routing_state_values_aligned() {
|
||||
coord_val, ident_val,
|
||||
"routing state values share a column: {coord:?} vs {ident:?}"
|
||||
);
|
||||
|
||||
// The forwarding helpers fall back to 0 on a missing key, so a mistyped
|
||||
// key would render "0 pkts" forever without failing anything. Assert the
|
||||
// fixture's nonzero value actually reaches the row.
|
||||
let warm = lines
|
||||
.iter()
|
||||
.find(|r| r.contains("Warm Malformed"))
|
||||
.expect("routing state shows the abandoned-warm row");
|
||||
assert!(
|
||||
warm.contains("5 pkts"),
|
||||
"warm-malformed row reads its counter keys: {warm:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Graphs by-peer summary list: the min/max/last numeric columns are
|
||||
@@ -1139,7 +1151,7 @@ fn routing_focused_pane_scrolls() {
|
||||
// column is the taller of the two, so scrolling fully to the bottom would
|
||||
// over-scroll the right column past Congestion; this offset lands the
|
||||
// Congestion region inside the short window instead.
|
||||
app1.scroll_offsets.insert((Tab::Routing, 2), 24);
|
||||
app1.scroll_offsets.insert((Tab::Routing, 2), 28);
|
||||
let buf1 = testkit::render(100, 20, |frame, area| {
|
||||
super::routing::draw(frame, &app1, area);
|
||||
});
|
||||
|
||||
@@ -33,8 +33,15 @@
|
||||
},
|
||||
"error_signals": {
|
||||
"coords_required": 0,
|
||||
"lookup_resp_mtu_below_floor": 0,
|
||||
"mtu_exceeded": 0,
|
||||
"path_broken": 0
|
||||
"mtu_exceeded_below_floor": 0,
|
||||
"path_broken": 0,
|
||||
"path_mtu_notif_below_floor": 0,
|
||||
"unbound_broken": 0,
|
||||
"unbound_coords": 0,
|
||||
"unbound_forged": 0,
|
||||
"unbound_mtu": 0
|
||||
},
|
||||
"forwarding": {
|
||||
"decode_error_bytes": 0,
|
||||
@@ -60,7 +67,9 @@
|
||||
"route_tree_down_cross": 0,
|
||||
"route_tree_up": 0,
|
||||
"ttl_exhausted_bytes": 0,
|
||||
"ttl_exhausted_packets": 0
|
||||
"ttl_exhausted_packets": 0,
|
||||
"warm_malformed_bytes": 0,
|
||||
"warm_malformed_packets": 0
|
||||
},
|
||||
"identity_cache_entries": 0,
|
||||
"lookup": {
|
||||
|
||||
@@ -29,7 +29,9 @@
|
||||
"route_tree_down_cross": 0,
|
||||
"route_tree_up": 0,
|
||||
"ttl_exhausted_bytes": 0,
|
||||
"ttl_exhausted_packets": 0
|
||||
"ttl_exhausted_packets": 0,
|
||||
"warm_malformed_bytes": 0,
|
||||
"warm_malformed_packets": 0
|
||||
},
|
||||
"ipv6_addr": "fd1b:4788:b7ab:7a43:6a61:1fc5:9fb1:e34c",
|
||||
"is_leaf_only": false,
|
||||
|
||||
@@ -12,7 +12,7 @@ use crate::node::reject::ForwardingReject;
|
||||
use crate::node::{Node, NodeError, NodeRoutingView};
|
||||
use crate::proto::fsp::wire::{
|
||||
FSP_COMMON_PREFIX_SIZE, FSP_HEADER_SIZE, FSP_PHASE_ESTABLISHED, FSP_PHASE_MSG1, FSP_PHASE_MSG2,
|
||||
FspCommonPrefix, parse_encrypted_coords,
|
||||
FspCommonPrefix, FspEncryptedHeader, parse_encrypted_coords,
|
||||
};
|
||||
use crate::proto::fsp::{SessionAck, SessionSetup};
|
||||
use crate::proto::link::{SessionDatagram, SessionDatagramRef};
|
||||
@@ -268,8 +268,25 @@ impl Node {
|
||||
FSP_PHASE_ESTABLISHED if prefix.has_coords() => {
|
||||
// CP flag set: coords in cleartext between header and ciphertext.
|
||||
// Parse coords from the cleartext section after the 12-byte header.
|
||||
// inner starts after the 4-byte prefix, so we need 8 more bytes
|
||||
// for the counter (header is 12 total = 4 prefix + 8 counter).
|
||||
// Re-parse with the encrypted-header parser — the same guard the
|
||||
// local-delivery path uses — so the slice below is bounded by
|
||||
// FSP_ENCRYPTED_MIN_SIZE and not by the 4-byte prefix check.
|
||||
if FspEncryptedHeader::parse(datagram.payload).is_none() {
|
||||
// Counter is the always-on surface; the debug fields are the
|
||||
// drill-down that separates a short frame from a bad version
|
||||
// or a U-flagged one. The level stays at debug: any peer past
|
||||
// the handshake can drive this at line rate.
|
||||
self.metrics()
|
||||
.forwarding
|
||||
.record_warm_malformed(datagram.payload.len());
|
||||
debug!(
|
||||
len = datagram.payload.len(),
|
||||
version = prefix.version,
|
||||
flags = prefix.flags,
|
||||
"Not a well-formed encrypted FSP message; not warming coords"
|
||||
);
|
||||
return;
|
||||
}
|
||||
let coord_data = &datagram.payload[FSP_HEADER_SIZE..];
|
||||
match parse_encrypted_coords(coord_data) {
|
||||
Ok((src_coords, dest_coords, _bytes_consumed)) => {
|
||||
|
||||
@@ -290,10 +290,34 @@ impl Node {
|
||||
now_ms,
|
||||
path_mtu,
|
||||
} => {
|
||||
self.coord_cache
|
||||
.insert_with_path_mtu(target, coords, now_ms, path_mtu);
|
||||
// The annotation is unsigned and accumulates hop by hop, so
|
||||
// any forwarder on the reverse path can lower it. A value
|
||||
// below the actionable floor cannot describe a usable path,
|
||||
// so treat it as absent: cache the coordinates, which are
|
||||
// what the proof covers, and store no path MTU from this
|
||||
// response at all.
|
||||
if path_mtu < crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU {
|
||||
warn!(
|
||||
target = %self.peer_display_name(&target),
|
||||
path_mtu = path_mtu,
|
||||
floor = crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU,
|
||||
"LookupResponse carries a path MTU below the actionable floor; \
|
||||
caching coordinates without it"
|
||||
);
|
||||
self.metrics().errors.lookup_resp_mtu_below_floor.inc();
|
||||
self.coord_cache.insert(target, coords, now_ms);
|
||||
} else {
|
||||
self.coord_cache
|
||||
.insert_with_path_mtu(target, coords, now_ms, path_mtu);
|
||||
}
|
||||
}
|
||||
LookupAction::WritePathMtu { target, path_mtu } => {
|
||||
// Refused as absent on the CacheCoords arm the core always
|
||||
// pairs with this one, so there is nothing to mirror; the
|
||||
// warning and the counter are emitted there, once.
|
||||
if path_mtu < crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU {
|
||||
continue;
|
||||
}
|
||||
// Mirror path_mtu into the FipsAddress-keyed read-only lookup
|
||||
// map used by the TUN reader/writer at TCP MSS clamp time.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&target);
|
||||
@@ -744,6 +768,22 @@ impl Node {
|
||||
return;
|
||||
};
|
||||
let link_mtu = transport.link_mtu(addr);
|
||||
// A locally derived MTU is deliberately exempt from the actionable
|
||||
// floor, so this seeds the value either way, and a narrow link is not
|
||||
// by itself worth reporting: BLE negotiates its MTU per connection and
|
||||
// lands below the floor routinely, where the tight clamp the seed
|
||||
// produces is exactly what the flow needs. Warn only where the link
|
||||
// admits no TCP payload byte at all, since there the SYN-time clamp
|
||||
// has nothing usable to derive and drops the peer onto the
|
||||
// conservative fallback ceiling for as long as the link stands.
|
||||
if crate::upper::icmp::mss_ceiling(link_mtu) == 0 {
|
||||
warn!(
|
||||
peer = %self.peer_display_name(peer_addr),
|
||||
link_mtu = link_mtu,
|
||||
"Link MTU leaves no room for a TCP payload byte; TCP to this peer \
|
||||
will not work until the link or the transport's mtu setting changes"
|
||||
);
|
||||
}
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(peer_addr);
|
||||
let Ok(mut map) = self.path_mtu_lookup.write() else {
|
||||
warn!(
|
||||
|
||||
@@ -245,8 +245,7 @@ impl Node {
|
||||
"Parent switched after first RTT measurement"
|
||||
);
|
||||
if flap_dampened {
|
||||
self.metrics().tree.flap_dampened.inc();
|
||||
warn!("Flap dampening engaged: excessive parent switches detected");
|
||||
self.note_flap("first-rtt");
|
||||
}
|
||||
self.send_tree_announce_to_all().await;
|
||||
let all_peers: Vec<crate::NodeAddr> = self.peers.keys().copied().collect();
|
||||
|
||||
@@ -57,6 +57,35 @@ struct PipelinedSend<'a> {
|
||||
dest_coords: Option<&'a crate::proto::stp::TreeCoordinate>,
|
||||
}
|
||||
|
||||
/// Outcome of the routing-signal admission test.
|
||||
///
|
||||
/// `Unbound` and `Forged` are both refusals, kept apart because they mean
|
||||
/// different things to an operator. `Unbound` is consistent with a benign
|
||||
/// race — a signal arriving just after a local session teardown. `Forged`
|
||||
/// is not consistent with any honest emitter, so it is the sharper
|
||||
/// indicator and is counted separately.
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
enum SignalVerdict {
|
||||
/// The named destination is an address this node bound itself.
|
||||
Admit,
|
||||
/// The src/dest pairing is structurally impossible for a legitimate
|
||||
/// emitter.
|
||||
Forged,
|
||||
/// No qualifying session entry exists for the named destination.
|
||||
Unbound,
|
||||
}
|
||||
|
||||
impl SignalVerdict {
|
||||
/// Short stable label for the `verdict` log field.
|
||||
fn label(self) -> &'static str {
|
||||
match self {
|
||||
Self::Admit => "admit",
|
||||
Self::Forged => "forged",
|
||||
Self::Unbound => "unbound",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Node {
|
||||
/// Handle a locally-delivered session datagram payload.
|
||||
///
|
||||
@@ -108,13 +137,13 @@ impl Node {
|
||||
let error_body = &inner[1..];
|
||||
match RoutingSignalType::from_byte(error_type) {
|
||||
Some(RoutingSignalType::CoordsRequired) => {
|
||||
self.handle_coords_required(error_body).await;
|
||||
self.handle_coords_required(src_addr, error_body).await;
|
||||
}
|
||||
Some(RoutingSignalType::PathBroken) => {
|
||||
self.handle_path_broken(error_body).await;
|
||||
self.handle_path_broken(src_addr, error_body).await;
|
||||
}
|
||||
Some(RoutingSignalType::MtuExceeded) => {
|
||||
self.handle_mtu_exceeded(error_body).await;
|
||||
self.handle_mtu_exceeded(src_addr, error_body).await;
|
||||
}
|
||||
_ => {
|
||||
debug!(error_type, "Unknown plaintext error signal type");
|
||||
@@ -1405,6 +1434,23 @@ impl Node {
|
||||
return;
|
||||
};
|
||||
|
||||
// `apply_notification` refuses a sub-floor value, but it returns the
|
||||
// same `false` it returns for the ordinary "no change" case, which is
|
||||
// the common one. Test the floor here so the refusal is visible: this
|
||||
// arrives on the decrypted service-payload path, so a value this low
|
||||
// means an authenticated peer we hold a session with is sending
|
||||
// something unusable.
|
||||
if notif.path_mtu < crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU {
|
||||
warn!(
|
||||
src = %peer_name,
|
||||
reported_mtu = notif.path_mtu,
|
||||
floor = crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU,
|
||||
"PathMtuNotification reports a path MTU below the actionable floor; ignoring"
|
||||
);
|
||||
self.metrics.errors.path_mtu_notif_below_floor.inc();
|
||||
return;
|
||||
}
|
||||
|
||||
let old_mtu = mmp.path_mtu.current_mtu();
|
||||
let changed = mmp
|
||||
.path_mtu
|
||||
@@ -1470,13 +1516,58 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether a routing signal naming `dest`, arriving in a datagram
|
||||
/// claiming source `src`, may be acted on, and if not, which kind of
|
||||
/// refusal it is.
|
||||
///
|
||||
/// `src` is the SessionDatagram's `src_addr`: a plain wire field,
|
||||
/// authenticated only hop-by-hop by FMP Noise and never end to end. It
|
||||
/// is therefore logged, not trusted. What is enforced here is that this
|
||||
/// node has bound `dest` itself, either by initiating toward it or by
|
||||
/// completing Noise XK, which binds the address to the peer's static
|
||||
/// key (see the address-mismatch check in `handle_session_msg3`). A
|
||||
/// responder entry that is still awaiting msg3 does NOT qualify: it is
|
||||
/// keyed on an address the sender merely claimed, so admitting it would
|
||||
/// let one forged SessionSetup unlock a signal about any address.
|
||||
///
|
||||
/// The first two clauses reject nothing legitimate, and so return
|
||||
/// `Forged` rather than `Unbound`. A datagram whose destination is this
|
||||
/// node takes the deliver-local branch before any forwarding, so no node
|
||||
/// ever emits a signal naming us as `dest`; and the emitter is by
|
||||
/// construction a transit node for the datagram it is reporting on, so
|
||||
/// it is never itself that datagram's destination.
|
||||
///
|
||||
/// This narrows who can be targeted; it does not authenticate the
|
||||
/// sender, which nothing short of a wire format change can do.
|
||||
fn signal_verdict(&self, src: &NodeAddr, dest: &NodeAddr) -> SignalVerdict {
|
||||
if dest == self.node_addr() || src == dest {
|
||||
return SignalVerdict::Forged;
|
||||
}
|
||||
if self
|
||||
.sessions
|
||||
.get(dest)
|
||||
.is_some_and(|e| e.is_established() || e.is_initiator())
|
||||
{
|
||||
SignalVerdict::Admit
|
||||
} else {
|
||||
SignalVerdict::Unbound
|
||||
}
|
||||
}
|
||||
|
||||
/// Handle a CoordsRequired error signal from a transit router.
|
||||
///
|
||||
/// The router couldn't route our packet because it lacks cached
|
||||
/// coordinates for the destination. Send a standalone CoordsWarmup
|
||||
/// immediately (rate-limited), trigger discovery, and reset the
|
||||
/// warmup counter for subsequent data packets.
|
||||
async fn handle_coords_required(&mut self, inner: &[u8]) {
|
||||
///
|
||||
/// `src_addr` is the datagram's claimed source and is not
|
||||
/// end-to-end authenticated; see `signal_verdict`.
|
||||
pub(in crate::node) async fn handle_coords_required(
|
||||
&mut self,
|
||||
src_addr: &NodeAddr,
|
||||
inner: &[u8],
|
||||
) {
|
||||
self.metrics().errors.coords_required.inc();
|
||||
|
||||
let msg = match CoordsRequired::decode(inner) {
|
||||
@@ -1487,6 +1578,25 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// The premise: this signal carries no end-to-end authentication, so
|
||||
// the body's `dest_addr` is attacker-chosen. Everything below acts on
|
||||
// it — warmup send, discovery, warmup-counter reset — so the gate has
|
||||
// to run before any of that, and ahead of the rate limiter, whose
|
||||
// state would otherwise be keyed on an attacker-chosen address.
|
||||
let verdict = self.signal_verdict(src_addr, &msg.dest_addr);
|
||||
if verdict != SignalVerdict::Admit {
|
||||
debug!(src = %src_addr, dest = %msg.dest_addr, reporter = %msg.reporter,
|
||||
signal = "CoordsRequired", verdict = verdict.label(),
|
||||
"Routing signal names an address this node has not bound; dropping");
|
||||
self.metrics().errors.unbound.coords.inc();
|
||||
if verdict == SignalVerdict::Forged {
|
||||
self.metrics().errors.unbound.forged.inc();
|
||||
}
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Session(SessionReject::UnknownSession));
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(
|
||||
dest = %msg.dest_addr,
|
||||
reporter = %msg.reporter,
|
||||
@@ -1544,7 +1654,10 @@ impl Node {
|
||||
/// The router has coordinates but still can't route to the destination.
|
||||
/// Send a standalone CoordsWarmup immediately (rate-limited), invalidate
|
||||
/// cached coordinates, trigger re-discovery, and reset the warmup counter.
|
||||
async fn handle_path_broken(&mut self, inner: &[u8]) {
|
||||
///
|
||||
/// `src_addr` is the datagram's claimed source and is not
|
||||
/// end-to-end authenticated; see `signal_verdict`.
|
||||
pub(in crate::node) async fn handle_path_broken(&mut self, src_addr: &NodeAddr, inner: &[u8]) {
|
||||
self.metrics().errors.path_broken.inc();
|
||||
|
||||
let msg = match PathBroken::decode(inner) {
|
||||
@@ -1555,6 +1668,26 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// The premise: this signal carries no end-to-end authentication, so
|
||||
// the body's `dest_addr` is attacker-chosen. `plan_path_broken` emits
|
||||
// its coord-cache invalidation unconditionally, and the path-MTU
|
||||
// release below is likewise unguarded, so both act on whatever address
|
||||
// the body names unless the gate refuses it here, in the shell, which
|
||||
// is the only layer that knows who sent the datagram.
|
||||
let verdict = self.signal_verdict(src_addr, &msg.dest_addr);
|
||||
if verdict != SignalVerdict::Admit {
|
||||
debug!(src = %src_addr, dest = %msg.dest_addr, reporter = %msg.reporter,
|
||||
signal = "PathBroken", verdict = verdict.label(),
|
||||
"Routing signal names an address this node has not bound; dropping");
|
||||
self.metrics().errors.unbound.broken.inc();
|
||||
if verdict == SignalVerdict::Forged {
|
||||
self.metrics().errors.unbound.forged.inc();
|
||||
}
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Session(SessionReject::UnknownSession));
|
||||
return;
|
||||
}
|
||||
|
||||
debug!(
|
||||
dest = %msg.dest_addr,
|
||||
reporter = %msg.reporter,
|
||||
@@ -1597,6 +1730,10 @@ impl Node {
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
// The path this destination's stored MTU described is gone, so release
|
||||
// it rather than carrying it onto whatever path replaces it.
|
||||
self.path_mtu_lookup_release(&msg.dest_addr);
|
||||
|
||||
if !has_cached_identity {
|
||||
debug!(dest = %msg.dest_addr,
|
||||
"Skipping discovery after PathBroken: no cached identity for target");
|
||||
@@ -1620,7 +1757,10 @@ impl Node {
|
||||
/// A transit router couldn't forward our packet because it exceeded the
|
||||
/// next-hop transport MTU. Apply the reported bottleneck MTU to our
|
||||
/// PathMtuState for the affected session, causing an immediate decrease.
|
||||
pub(in crate::node) async fn handle_mtu_exceeded(&mut self, inner: &[u8]) {
|
||||
///
|
||||
/// `src_addr` is the datagram's claimed source and is not
|
||||
/// end-to-end authenticated; see `signal_verdict`.
|
||||
pub(in crate::node) async fn handle_mtu_exceeded(&mut self, src_addr: &NodeAddr, inner: &[u8]) {
|
||||
self.metrics().errors.mtu_exceeded.inc();
|
||||
|
||||
let msg = match MtuExceeded::decode(inner) {
|
||||
@@ -1631,6 +1771,28 @@ impl Node {
|
||||
}
|
||||
};
|
||||
|
||||
// The premise: this signal carries no end-to-end authentication, so
|
||||
// the body's `dest_addr` is attacker-chosen, and the `path_mtu_lookup`
|
||||
// write further down needs no session, no peer relationship and no
|
||||
// prior state to reach. This gate is about WHICH address may be
|
||||
// written; the floor guard below is about WHAT value may be written.
|
||||
// They are independent refusals — a bound destination can still carry
|
||||
// an unusable value — so neither subsumes the other and each keeps its
|
||||
// own counter.
|
||||
let verdict = self.signal_verdict(src_addr, &msg.dest_addr);
|
||||
if verdict != SignalVerdict::Admit {
|
||||
debug!(src = %src_addr, dest = %msg.dest_addr, reporter = %msg.reporter,
|
||||
signal = "MtuExceeded", verdict = verdict.label(),
|
||||
"Routing signal names an address this node has not bound; dropping");
|
||||
self.metrics().errors.unbound.mtu.inc();
|
||||
if verdict == SignalVerdict::Forged {
|
||||
self.metrics().errors.unbound.forged.inc();
|
||||
}
|
||||
self.stats_mut()
|
||||
.record_reject(RejectReason::Session(SessionReject::UnknownSession));
|
||||
return;
|
||||
}
|
||||
|
||||
let peer_name = self.peer_display_name(&msg.dest_addr);
|
||||
debug!(
|
||||
dest = %peer_name,
|
||||
@@ -1659,12 +1821,32 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
// The admission gate above restricts which addresses may be written,
|
||||
// not which values. Any node at any distance may legitimately report a
|
||||
// bottleneck for a destination this node has bound, so refuse to store
|
||||
// one too small to describe a usable path; a stored value that low
|
||||
// drives the SYN-time MSS clamp into single digits or zero.
|
||||
if msg.mtu < crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU {
|
||||
warn!(
|
||||
dest = %peer_name,
|
||||
reporter = %msg.reporter,
|
||||
bottleneck_mtu = msg.mtu,
|
||||
floor = crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU,
|
||||
"MtuExceeded reports a path MTU below the actionable floor; ignoring"
|
||||
);
|
||||
self.metrics().errors.mtu_exceeded_below_floor.inc();
|
||||
return;
|
||||
}
|
||||
|
||||
// Mirror the bottleneck into the FipsAddress-keyed lookup used by
|
||||
// the TUN reader/writer at TCP MSS clamp time. Discovery's reverse-
|
||||
// path response can carry a value too generous for the actual
|
||||
// forward path; the reactive signal from a forwarder that actually
|
||||
// dropped a packet is authoritative for "what fits". Keep the
|
||||
// tighter of existing-or-new — never loosen the clamp.
|
||||
//
|
||||
// The admission gate above, not this block, is what restricts which
|
||||
// addresses can be written here.
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&msg.dest_addr);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => {
|
||||
|
||||
@@ -443,6 +443,7 @@ impl Node {
|
||||
info!(dest = %name, "Session handshake timed out, removing");
|
||||
self.sessions.remove(addr);
|
||||
self.pending_tun_packets.remove(addr);
|
||||
self.path_mtu_lookup_release(addr);
|
||||
}
|
||||
|
||||
// Second pass: collect resend candidates
|
||||
@@ -520,6 +521,7 @@ impl Node {
|
||||
}
|
||||
self.sessions.remove(&addr);
|
||||
self.pending_tun_packets.remove(&addr);
|
||||
self.path_mtu_lookup_release(&addr);
|
||||
debug!(
|
||||
dest = %name,
|
||||
idle_secs = timeout_ms / 1000,
|
||||
|
||||
@@ -67,6 +67,8 @@ pub struct ForwardingMetrics {
|
||||
pub received_bytes: Counter,
|
||||
pub decode_error_packets: Counter,
|
||||
pub decode_error_bytes: Counter,
|
||||
pub warm_malformed_packets: Counter,
|
||||
pub warm_malformed_bytes: Counter,
|
||||
pub ttl_exhausted_packets: Counter,
|
||||
pub ttl_exhausted_bytes: Counter,
|
||||
pub delivered_packets: Counter,
|
||||
@@ -110,6 +112,22 @@ impl ForwardingMetrics {
|
||||
self.delivered_bytes.add(bytes as u64);
|
||||
}
|
||||
|
||||
/// Record a coordinate-cache warm attempt abandoned because the frame was
|
||||
/// not a well-formed encrypted FSP message.
|
||||
///
|
||||
/// This is **not** a packet drop. The frame is still delivered or
|
||||
/// forwarded by the normal path; only the opportunistic warm attempt was
|
||||
/// abandoned, so this must never be folded into the rejection family or
|
||||
/// rendered as dropped traffic. `bytes` is the payload size of the frame
|
||||
/// whose warm attempt was abandoned, not volume dropped; it is carried so
|
||||
/// the counter can be rendered as a packets-and-bytes pair like its
|
||||
/// siblings.
|
||||
#[inline]
|
||||
pub fn record_warm_malformed(&self, bytes: usize) {
|
||||
self.warm_malformed_packets.inc();
|
||||
self.warm_malformed_bytes.add(bytes as u64);
|
||||
}
|
||||
|
||||
/// Record a forwarded (transit) packet of `bytes` payload.
|
||||
#[inline]
|
||||
pub fn record_forwarded(&self, bytes: usize) {
|
||||
@@ -176,6 +194,8 @@ impl ForwardingMetrics {
|
||||
received_bytes: self.received_bytes.get(),
|
||||
decode_error_packets: self.decode_error_packets.get(),
|
||||
decode_error_bytes: self.decode_error_bytes.get(),
|
||||
warm_malformed_packets: self.warm_malformed_packets.get(),
|
||||
warm_malformed_bytes: self.warm_malformed_bytes.get(),
|
||||
ttl_exhausted_packets: self.ttl_exhausted_packets.get(),
|
||||
ttl_exhausted_bytes: self.ttl_exhausted_bytes.get(),
|
||||
delivered_packets: self.delivered_packets.get(),
|
||||
@@ -417,12 +437,55 @@ impl CongestionMetrics {
|
||||
}
|
||||
}
|
||||
|
||||
/// Routing signals refused by the sender-binding admission gate, split by
|
||||
/// signal type.
|
||||
///
|
||||
/// The sibling counters on `ErrorMetrics` count arrivals, incremented before
|
||||
/// the gate runs; these count the subset that was refused. Read together they
|
||||
/// give the refused fraction per signal type, which is what separates a node
|
||||
/// nobody is talking to from a node with a genuinely broken path from a node
|
||||
/// being fed forged signals.
|
||||
#[derive(Default)]
|
||||
pub struct UnboundSignals {
|
||||
/// `CoordsRequired` refused because this node has not bound the
|
||||
/// destination address the signal names.
|
||||
pub coords: Counter,
|
||||
/// `PathBroken` refused because this node has not bound the destination
|
||||
/// address the signal names.
|
||||
pub broken: Counter,
|
||||
/// `MtuExceeded` refused because this node has not bound the destination
|
||||
/// address the signal names.
|
||||
pub mtu: Counter,
|
||||
/// Subset of the above whose src/dest pairing is structurally impossible
|
||||
/// for a legitimate emitter: the signal names this node as the
|
||||
/// destination, or claims a source equal to the destination it names.
|
||||
/// Neither can arise from an honest on-path forwarder, so any count here
|
||||
/// is a fabricated signal rather than ordinary session churn.
|
||||
pub forged: Counter,
|
||||
}
|
||||
|
||||
/// Error-signal metric counters.
|
||||
#[derive(Default)]
|
||||
pub struct ErrorMetrics {
|
||||
pub coords_required: Counter,
|
||||
pub path_broken: Counter,
|
||||
pub mtu_exceeded: Counter,
|
||||
/// `PathMtuNotification`s ignored for carrying a path MTU below the
|
||||
/// actionable floor. This signal arrives inside an established session
|
||||
/// on the decrypted path, so a rising count means an authenticated peer
|
||||
/// is misconfigured or misbehaving.
|
||||
pub path_mtu_notif_below_floor: Counter,
|
||||
/// `MtuExceeded` signals whose bottleneck was ignored for falling below
|
||||
/// the actionable floor. The signal is unencrypted, unauthenticated and
|
||||
/// unmetered, so a rising count on its own is the forged-signal
|
||||
/// signature; `mtu_exceeded` counts the whole population.
|
||||
pub mtu_exceeded_below_floor: Counter,
|
||||
/// `LookupResponse` path MTU annotations ignored for falling below the
|
||||
/// actionable floor. The response carried a verified proof, so a rising
|
||||
/// count means a forwarder on the reverse path is mangling the unsigned
|
||||
/// annotation.
|
||||
pub lookup_resp_mtu_below_floor: Counter,
|
||||
pub unbound: UnboundSignals,
|
||||
}
|
||||
|
||||
impl ErrorMetrics {
|
||||
@@ -432,6 +495,13 @@ impl ErrorMetrics {
|
||||
coords_required: self.coords_required.get(),
|
||||
path_broken: self.path_broken.get(),
|
||||
mtu_exceeded: self.mtu_exceeded.get(),
|
||||
path_mtu_notif_below_floor: self.path_mtu_notif_below_floor.get(),
|
||||
mtu_exceeded_below_floor: self.mtu_exceeded_below_floor.get(),
|
||||
lookup_resp_mtu_below_floor: self.lookup_resp_mtu_below_floor.get(),
|
||||
unbound_coords: self.unbound.coords.get(),
|
||||
unbound_broken: self.unbound.broken.get(),
|
||||
unbound_mtu: self.unbound.mtu.get(),
|
||||
unbound_forged: self.unbound.forged.get(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2727,6 +2727,50 @@ impl Node {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop the remote-learned path MTU for a destination whose path is no
|
||||
/// longer valid, then restore what is known locally.
|
||||
///
|
||||
/// Entries in `path_mtu_lookup` come from two sources: values a remote
|
||||
/// party supplied (discovery responses, `MtuExceeded`, path MTU
|
||||
/// notifications) and the link MTU this node reads from its own transport
|
||||
/// configuration for a directly connected peer. When the path is declared
|
||||
/// broken or the session goes away, the remote-supplied value describes a
|
||||
/// path that no longer exists and must not outlive it, but the locally
|
||||
/// derived one is still true. Removing the entry and then re-running the
|
||||
/// link-peer seed keeps the second while discarding the first; a plain
|
||||
/// removal would silently drop a direct peer back to the conservative
|
||||
/// ceiling until its link re-handshakes.
|
||||
fn path_mtu_lookup_release(&self, addr: &NodeAddr) {
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(addr);
|
||||
match self.path_mtu_lookup.write() {
|
||||
Ok(mut map) => {
|
||||
if map.remove(&fips_addr).is_some() {
|
||||
tracing::debug!(
|
||||
dest = %self.peer_display_name(addr),
|
||||
fips_addr = %fips_addr,
|
||||
"Released path_mtu_lookup entry for an invalidated path"
|
||||
);
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!(
|
||||
fips_addr = %fips_addr,
|
||||
error = %e,
|
||||
"path_mtu_lookup write lock poisoned; entry not released"
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// The write guard above must be dropped before the seed runs: it takes
|
||||
// the same lock, and `std::sync::RwLock` is not re-entrant.
|
||||
if let Some(peer) = self.peers.get(addr)
|
||||
&& let Some(transport_id) = peer.transport_id()
|
||||
&& let Some(transport_addr) = peer.current_addr().cloned()
|
||||
{
|
||||
self.seed_path_mtu_for_link_peer(addr, transport_id, &transport_addr);
|
||||
}
|
||||
}
|
||||
|
||||
/// Number of end-to-end sessions.
|
||||
pub fn session_count(&self) -> usize {
|
||||
self.sessions.len()
|
||||
|
||||
@@ -209,6 +209,23 @@ pub enum SessionReject {
|
||||
/// Tracked via
|
||||
/// [`SessionStats::rekey_key_mismatch`](crate::node::stats::SessionStats).
|
||||
RekeyKeyMismatch,
|
||||
/// A setup message named an established peer while our own rekey of
|
||||
/// that session was in flight, and our address sorted smaller, so the
|
||||
/// tie-break kept us as initiator and their msg1 was dropped. Tracked
|
||||
/// via [`SessionStats::rekey_tiebreak`](crate::node::stats::SessionStats).
|
||||
RekeyTiebreak,
|
||||
/// A setup message named an established peer while our own rekey of
|
||||
/// that session was in flight, and our address sorted larger, so we
|
||||
/// abandoned our rekey and answered as responder. The message carries
|
||||
/// no authenticator, so a sustained rate here means local key rotation
|
||||
/// is being suppressed. Tracked via
|
||||
/// [`SessionStats::rekey_yielded`](crate::node::stats::SessionStats).
|
||||
RekeyYielded,
|
||||
/// A setup message named an established peer that already holds a
|
||||
/// completed rekey awaiting cut-over, so the message was dropped
|
||||
/// rather than arming a second handshake. Tracked via
|
||||
/// [`SessionStats::rekey_pending`](crate::node::stats::SessionStats).
|
||||
RekeyPending,
|
||||
}
|
||||
|
||||
/// MMP rejection reasons.
|
||||
|
||||
@@ -38,7 +38,11 @@
|
||||
//!
|
||||
//! - `path_mtu_lookup` is an event-driven cache (`Arc<RwLock<HashMap>>`)
|
||||
//! populated from observed path-MTU discovery traffic, not loaded from a
|
||||
//! file. There is nothing to poll. (Its read side could adopt the same
|
||||
//! file. There is nothing to poll. Release is event-driven for the same
|
||||
//! reason: an entry is dropped when the path it describes is declared
|
||||
//! invalid (a `PathBroken` report, session idle expiry, or handshake
|
||||
//! timeout) and the locally derived link MTU is reseeded in its place, so
|
||||
//! there is no expiry sweep either. (Its read side could adopt the same
|
||||
//! lock-free `ArcSwap` shape in the future, but that is an optimization, not
|
||||
//! a reload.)
|
||||
//! - `nostr_rendezvous` is an async spawned subsystem, not a snapshot of disk
|
||||
|
||||
+13
-8
@@ -151,11 +151,15 @@ pub(crate) struct SessionEntry {
|
||||
rekey_initiator: bool,
|
||||
/// Dampening: last time peer sent us a rekey msg1 (Unix ms).
|
||||
last_peer_rekey_ms: u64,
|
||||
/// When the FSP rekey handshake completed (initiator sent msg3, Unix ms).
|
||||
/// Drives the initiator's liveness-bound cutover timer. Cleared on
|
||||
/// cutover. The timer is no longer safety-critical: overlapping-epoch
|
||||
/// trial-decrypt covers any cutover skew. It only bounds how long the
|
||||
/// initiator advertises the old K-bit.
|
||||
/// When this side's FSP rekey handshake completed and produced the
|
||||
/// `pending` session (Unix ms): the initiator sending msg3, or the
|
||||
/// responder accepting it. Cleared on cutover.
|
||||
///
|
||||
/// On the initiator it drives the liveness-bound cutover timer, which
|
||||
/// is no longer safety-critical: overlapping-epoch trial-decrypt covers
|
||||
/// any cutover skew, so it only bounds how long the initiator
|
||||
/// advertises the old K-bit. On the responder it dates the wait for the
|
||||
/// peer's cut-over (`pending_stale`) and never expires the keys.
|
||||
rekey_completed_ms: u64,
|
||||
/// Encoded SessionMsg3 payload retained for retransmission (initiator).
|
||||
/// Set when the rekey initiator sends msg3; cleared once the responder
|
||||
@@ -319,7 +323,6 @@ impl SessionEntry {
|
||||
}
|
||||
|
||||
/// Whether this node initiated the Noise handshake.
|
||||
#[cfg_attr(not(test), allow(dead_code))]
|
||||
pub(crate) fn is_initiator(&self) -> bool {
|
||||
self.is_initiator
|
||||
}
|
||||
@@ -456,7 +459,8 @@ impl SessionEntry {
|
||||
}
|
||||
}
|
||||
|
||||
/// When the FSP rekey handshake completed (initiator sent msg3).
|
||||
/// When this side's FSP rekey handshake completed: the initiator
|
||||
/// sending msg3, or the responder accepting it.
|
||||
pub(crate) fn rekey_completed_ms(&self) -> u64 {
|
||||
self.rekey_completed_ms
|
||||
}
|
||||
@@ -471,7 +475,8 @@ impl SessionEntry {
|
||||
self.rekey_jitter_secs
|
||||
}
|
||||
|
||||
/// Record when the FSP rekey handshake completed (initiator side).
|
||||
/// Record when this side's FSP rekey handshake completed and the
|
||||
/// `pending` session appeared.
|
||||
pub(crate) fn set_rekey_completed_ms(&mut self, ms: u64) {
|
||||
self.rekey_completed_ms = ms;
|
||||
}
|
||||
|
||||
@@ -40,6 +40,36 @@ pub struct SessionStats {
|
||||
/// the key the session was established with. The rekey is
|
||||
/// abandoned and the existing session is left intact.
|
||||
pub rekey_key_mismatch: u64,
|
||||
/// A setup message naming an already-established peer armed a
|
||||
/// responder-side handshake alongside the running session and a
|
||||
/// SessionAck was sent. The message carries no authenticator, so this
|
||||
/// counts genuine peer restarts and forged setups alike; its rate is
|
||||
/// the signal that something is spraying setup messages, which the
|
||||
/// per-message DEBUG line cannot carry safely at line rate.
|
||||
pub rekey_armed: u64,
|
||||
/// A setup message named an established peer while our own rekey of
|
||||
/// that session was in flight and our address sorted smaller, so the
|
||||
/// tie-break dropped their msg1 and kept us as initiator.
|
||||
pub rekey_tiebreak: u64,
|
||||
/// A setup message named an established peer while our own rekey of
|
||||
/// that session was in flight and our address sorted larger, so we
|
||||
/// abandoned our own rekey and answered as responder. A sustained
|
||||
/// rate here means local key rotation is being suppressed.
|
||||
pub rekey_yielded: u64,
|
||||
/// A setup message named an established peer that already holds a
|
||||
/// completed rekey awaiting cut-over, so the message was dropped
|
||||
/// rather than arming a second handshake.
|
||||
pub rekey_pending: u64,
|
||||
/// A responder-side handshake armed by a peer's setup message passed
|
||||
/// the handshake timeout without a msg3 and was discarded. The
|
||||
/// established session is retained.
|
||||
pub rekey_expired: u64,
|
||||
/// A completed rekey session still waiting for the peer's cut-over was
|
||||
/// replaced by a newer one, completed from a msg3 carrying the same
|
||||
/// authenticated peer key. This drops key material the peer may
|
||||
/// already have adopted, so a sustained rate means one side keeps
|
||||
/// rekeying while the other never appears on the new epoch.
|
||||
pub pending_replaced: u64,
|
||||
}
|
||||
|
||||
impl SessionStats {
|
||||
@@ -49,6 +79,12 @@ impl SessionStats {
|
||||
bad_state: self.bad_state,
|
||||
addr_mismatch: self.addr_mismatch,
|
||||
rekey_key_mismatch: self.rekey_key_mismatch,
|
||||
rekey_armed: self.rekey_armed,
|
||||
rekey_tiebreak: self.rekey_tiebreak,
|
||||
rekey_yielded: self.rekey_yielded,
|
||||
rekey_pending: self.rekey_pending,
|
||||
rekey_expired: self.rekey_expired,
|
||||
pending_replaced: self.pending_replaced,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +94,9 @@ impl SessionStats {
|
||||
SessionReject::BadState => self.bad_state += 1,
|
||||
SessionReject::AddrMismatch => self.addr_mismatch += 1,
|
||||
SessionReject::RekeyKeyMismatch => self.rekey_key_mismatch += 1,
|
||||
SessionReject::RekeyTiebreak => self.rekey_tiebreak += 1,
|
||||
SessionReject::RekeyYielded => self.rekey_yielded += 1,
|
||||
SessionReject::RekeyPending => self.rekey_pending += 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,6 +266,8 @@ pub struct ForwardingStatsSnapshot {
|
||||
pub received_bytes: u64,
|
||||
pub decode_error_packets: u64,
|
||||
pub decode_error_bytes: u64,
|
||||
pub warm_malformed_packets: u64,
|
||||
pub warm_malformed_bytes: u64,
|
||||
pub ttl_exhausted_packets: u64,
|
||||
pub ttl_exhausted_bytes: u64,
|
||||
pub delivered_packets: u64,
|
||||
@@ -324,6 +365,12 @@ pub struct SessionStatsSnapshot {
|
||||
pub bad_state: u64,
|
||||
pub addr_mismatch: u64,
|
||||
pub rekey_key_mismatch: u64,
|
||||
pub rekey_armed: u64,
|
||||
pub rekey_tiebreak: u64,
|
||||
pub rekey_yielded: u64,
|
||||
pub rekey_pending: u64,
|
||||
pub rekey_expired: u64,
|
||||
pub pending_replaced: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -348,6 +395,13 @@ pub struct ErrorSignalStatsSnapshot {
|
||||
pub coords_required: u64,
|
||||
pub path_broken: u64,
|
||||
pub mtu_exceeded: u64,
|
||||
pub path_mtu_notif_below_floor: u64,
|
||||
pub mtu_exceeded_below_floor: u64,
|
||||
pub lookup_resp_mtu_below_floor: u64,
|
||||
pub unbound_coords: u64,
|
||||
pub unbound_broken: u64,
|
||||
pub unbound_mtu: u64,
|
||||
pub unbound_forged: u64,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, Serialize)]
|
||||
@@ -398,6 +452,33 @@ mod tests {
|
||||
assert_eq!(stats.addr_mismatch, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stats_record_reject_separates_the_three_rekey_arming_refusals() {
|
||||
let mut stats = SessionStats::default();
|
||||
stats.record_reject(SessionReject::RekeyTiebreak);
|
||||
stats.record_reject(SessionReject::RekeyYielded);
|
||||
stats.record_reject(SessionReject::RekeyYielded);
|
||||
stats.record_reject(SessionReject::RekeyPending);
|
||||
assert_eq!(stats.rekey_tiebreak, 1);
|
||||
assert_eq!(stats.rekey_yielded, 2);
|
||||
assert_eq!(stats.rekey_pending, 1);
|
||||
assert_eq!(stats.rekey_armed, 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stats_snapshot_carries_the_rekey_arming_counters() {
|
||||
let mut stats = SessionStats::default();
|
||||
stats.record_reject(SessionReject::RekeyTiebreak);
|
||||
stats.rekey_armed = 7;
|
||||
stats.rekey_expired = 3;
|
||||
stats.pending_replaced = 2;
|
||||
let snap = stats.snapshot();
|
||||
assert_eq!(snap.rekey_tiebreak, 1);
|
||||
assert_eq!(snap.rekey_armed, 7);
|
||||
assert_eq!(snap.rekey_expired, 3);
|
||||
assert_eq!(snap.pending_replaced, 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_stats_snapshot_carries_identity_binding_counters() {
|
||||
let mut stats = SessionStats::default();
|
||||
|
||||
@@ -905,6 +905,100 @@ async fn test_originator_stores_path_mtu_in_cache() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_originator_ignores_sub_floor_path_mtu_but_still_caches_coords() {
|
||||
// The path_mtu annotation accumulates hop by hop outside the signed proof,
|
||||
// so any forwarder on the reverse path can lower it. A value below the
|
||||
// actionable floor must be treated as absent rather than stored — but the
|
||||
// coordinates it travelled with are proof-covered and must still land,
|
||||
// otherwise a value-poisoning vector becomes a discovery-denial one.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
|
||||
let target_identity = Identity::generate();
|
||||
let target = *target_identity.node_addr();
|
||||
let target_fips = crate::FipsAddress::from_node_addr(&target);
|
||||
let root = make_node_addr(0xF0);
|
||||
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
|
||||
|
||||
node.register_identity(target, target_identity.pubkey_full());
|
||||
|
||||
let proof_data = LookupResponse::proof_bytes(801, &target, &coords);
|
||||
let proof = target_identity.sign(&proof_data);
|
||||
|
||||
let mut response = LookupResponse::new(801, target, coords.clone(), proof);
|
||||
response.path_mtu = 64;
|
||||
|
||||
let payload = &response.encode()[1..];
|
||||
node.handle_lookup_response(&from, payload).await;
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as u64)
|
||||
.unwrap_or(0);
|
||||
|
||||
assert!(
|
||||
node.coord_cache().contains(&target, now_ms),
|
||||
"coordinates must still be cached; the proof covers them"
|
||||
);
|
||||
assert_eq!(
|
||||
node.coord_cache().get_entry(&target).unwrap().path_mtu(),
|
||||
None,
|
||||
"a sub-floor annotation must not reach the coordinate cache"
|
||||
);
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&target_fips),
|
||||
None,
|
||||
"a sub-floor annotation must not reach the MSS clamp lookup"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().errors.lookup_resp_mtu_below_floor.get(),
|
||||
1,
|
||||
"refusing the annotation must be visible on a counter, not only in a log"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_actionable_lookup_response_path_mtu_does_not_bump_below_floor_counter() {
|
||||
// Discriminating half of the sub-floor counter check: the refusal counter
|
||||
// is only useful if an ordinary verified response leaves it alone.
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
|
||||
let target_identity = Identity::generate();
|
||||
let target = *target_identity.node_addr();
|
||||
let target_fips = crate::FipsAddress::from_node_addr(&target);
|
||||
let root = make_node_addr(0xF0);
|
||||
let coords = TreeCoordinate::from_addrs(vec![target, root]).unwrap();
|
||||
|
||||
node.register_identity(target, target_identity.pubkey_full());
|
||||
|
||||
let proof_data = LookupResponse::proof_bytes(802, &target, &coords);
|
||||
let proof = target_identity.sign(&proof_data);
|
||||
|
||||
let mut response = LookupResponse::new(802, target, coords.clone(), proof);
|
||||
response.path_mtu = 1280;
|
||||
|
||||
let payload = &response.encode()[1..];
|
||||
node.handle_lookup_response(&from, payload).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&target_fips),
|
||||
Some(1280),
|
||||
"an actionable annotation must reach the MSS clamp lookup"
|
||||
);
|
||||
assert_eq!(
|
||||
node.coord_cache().get_entry(&target).unwrap().path_mtu(),
|
||||
Some(1280),
|
||||
"an actionable annotation must reach the coordinate cache"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().errors.lookup_resp_mtu_below_floor.get(),
|
||||
0,
|
||||
"an actionable annotation must not bump the below-floor counter"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_originator_lookup_response_keeps_tighter_path_mtu_lookup() {
|
||||
// Regression: a LookupResponse carrying a looser (larger) path_mtu must
|
||||
|
||||
@@ -1109,3 +1109,90 @@ fn test_sample_transport_congestion() {
|
||||
node.sample_transport_congestion();
|
||||
assert!(!node.transport_drops[&tid].dropping);
|
||||
}
|
||||
|
||||
/// Acceptance: an inner FSP payload of 4 to 11 bytes with phase 0x0 and the
|
||||
/// CP flag set is dropped rather than panicking the forwarding path. That
|
||||
/// window sits between the common prefix parser's 4-byte floor and the
|
||||
/// 12-byte header slice the warm path takes, so before the fix the first
|
||||
/// iteration panicked with a range start index out of range.
|
||||
#[tokio::test]
|
||||
async fn test_coord_cache_warming_short_inner_payload_is_dropped_not_panic() {
|
||||
let mut node = make_node();
|
||||
let from = make_node_addr(0xAA);
|
||||
let src_addr = make_node_addr(0x01);
|
||||
let dest_addr = make_node_addr(0x02);
|
||||
|
||||
let now_ms = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap()
|
||||
.as_millis() as u64;
|
||||
|
||||
for extra in 0..=7 {
|
||||
let mut data_payload = vec![0x00, FSP_FLAG_CP, 0x00, 0x00];
|
||||
data_payload.resize(4 + extra, 0x00);
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, dest_addr, data_payload).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&src_addr, now_ms).is_none(),
|
||||
"Short inner payload must not warm src coords"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&dest_addr, now_ms).is_none(),
|
||||
"Short inner payload must not warm dest coords"
|
||||
);
|
||||
// Anti-vacuity: only a datagram that ran past the warm call reaches the
|
||||
// TTL gate. `received_packets` is charged before decode and so would
|
||||
// count a datagram rejected earlier.
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.ttl_exhausted_packets.get(),
|
||||
8,
|
||||
"each short-inner-payload datagram must run past the warm call to the TTL gate"
|
||||
);
|
||||
// Discriminating: separates "the guard fired" from "coords parsed and
|
||||
// yielded nothing", which the cache assertions above cannot tell apart.
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.warm_malformed_packets.get(),
|
||||
8,
|
||||
"each short-inner-payload datagram must be counted as an abandoned warm attempt"
|
||||
);
|
||||
|
||||
// Inner lengths 12 to 27 document the new 28-byte floor: they do not
|
||||
// panic today either, so this half is not discriminating.
|
||||
for len in 12..=27 {
|
||||
let mut data_payload = vec![0x00, FSP_FLAG_CP, 0x00, 0x00];
|
||||
data_payload.resize(len, 0x00);
|
||||
|
||||
let dg = SessionDatagram::new(src_addr, dest_addr, data_payload).with_ttl(1);
|
||||
let encoded = dg.encode();
|
||||
node.handle_session_datagram(&from, &encoded[1..], false)
|
||||
.await;
|
||||
}
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&src_addr, now_ms).is_none(),
|
||||
"Payload below the encrypted minimum must not warm src coords"
|
||||
);
|
||||
assert!(
|
||||
node.coord_cache().get(&dest_addr, now_ms).is_none(),
|
||||
"Payload below the encrypted minimum must not warm dest coords"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.ttl_exhausted_packets.get(),
|
||||
24,
|
||||
"every datagram in both loops must reach the TTL gate"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().forwarding.warm_malformed_packets.get(),
|
||||
24,
|
||||
"every datagram in both loops must be counted as an abandoned warm attempt"
|
||||
);
|
||||
assert!(
|
||||
node.metrics().forwarding.warm_malformed_bytes.get() > 0,
|
||||
"the byte counter must move alongside the packet counter"
|
||||
);
|
||||
}
|
||||
|
||||
+771
-8
@@ -1338,8 +1338,8 @@ async fn rekey_cutover_preserves_data_plane() {
|
||||
let cfg1 = crate::config::Config::new();
|
||||
|
||||
let mut nodes = vec![
|
||||
make_test_node_with_config(cfg0).await,
|
||||
make_test_node_with_config(cfg1).await,
|
||||
make_test_node_with_config(cfg0, 1280).await,
|
||||
make_test_node_with_config(cfg1, 1280).await,
|
||||
];
|
||||
|
||||
// FMP peering + FSP session between the two loopback nodes.
|
||||
@@ -2557,13 +2557,57 @@ fn build_mtu_exceeded_inner(dest: &NodeAddr, reporter: &NodeAddr, mtu: u16) -> V
|
||||
buf
|
||||
}
|
||||
|
||||
/// Install the half-open entry an inbound SessionSetup creates: keyed on an
|
||||
/// address the sender merely claimed, awaiting msg3, not initiated by us.
|
||||
///
|
||||
/// This is the shape an attacker manufactures with one forged handshake
|
||||
/// opening, so a routing signal naming `claimed` must not be admitted by it.
|
||||
fn install_halfopen(node: &mut Node, claimed: NodeAddr) {
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
// This branch's FSP rekey is Noise XX, so the responder is built without
|
||||
// a pinned remote static; the entry's lifecycle state is what the helper
|
||||
// is establishing, not the handshake pattern.
|
||||
let handshake = HandshakeState::new_responder(node.identity().keypair());
|
||||
let placeholder = node.identity().keypair().public_key();
|
||||
let entry = crate::node::session::SessionEntry::new(
|
||||
claimed,
|
||||
placeholder,
|
||||
EndToEndState::AwaitingMsg3(handshake),
|
||||
1000,
|
||||
false,
|
||||
);
|
||||
node.sessions.insert(claimed, entry);
|
||||
}
|
||||
|
||||
/// Install the entry `initiate_session` creates: an address this node chose
|
||||
/// itself, with the handshake still in flight and MMP not yet initialized.
|
||||
fn install_initiating(node: &mut Node, remote: &Identity) {
|
||||
use crate::noise::HandshakeState;
|
||||
|
||||
// Noise XX on this branch: the initiator learns the remote static during
|
||||
// the handshake rather than pinning it up front.
|
||||
let handshake = HandshakeState::new_initiator(node.identity().keypair());
|
||||
let remote_addr = *remote.node_addr();
|
||||
let entry = crate::node::session::SessionEntry::new(
|
||||
remote_addr,
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Initiating(handshake),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_mtu_exceeded_writes_path_mtu_lookup_when_empty() {
|
||||
use crate::node::tests::spanning_tree::make_test_node;
|
||||
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let remote = Identity::generate();
|
||||
install_established_session_with_mmp(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
@@ -2573,7 +2617,7 @@ async fn test_handle_mtu_exceeded_writes_path_mtu_lookup_when_empty() {
|
||||
);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
tn.node.handle_mtu_exceeded(&inner).await;
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
@@ -2588,7 +2632,9 @@ async fn test_handle_mtu_exceeded_tightens_existing_path_mtu_lookup() {
|
||||
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let remote = Identity::generate();
|
||||
install_established_session_with_mmp(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
@@ -2597,7 +2643,7 @@ async fn test_handle_mtu_exceeded_tightens_existing_path_mtu_lookup() {
|
||||
tn.node.path_mtu_lookup_insert(dest_fips, 1500);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
tn.node.handle_mtu_exceeded(&inner).await;
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
@@ -2612,7 +2658,9 @@ async fn test_handle_mtu_exceeded_keeps_tighter_existing_path_mtu_lookup() {
|
||||
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let remote = Identity::generate();
|
||||
install_established_session_with_mmp(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
@@ -2622,7 +2670,7 @@ async fn test_handle_mtu_exceeded_keeps_tighter_existing_path_mtu_lookup() {
|
||||
tn.node.path_mtu_lookup_insert(dest_fips, 1280);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1500);
|
||||
tn.node.handle_mtu_exceeded(&inner).await;
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
@@ -2631,6 +2679,595 @@ async fn test_handle_mtu_exceeded_keeps_tighter_existing_path_mtu_lookup() {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_mtu_exceeded_below_floor_leaves_path_mtu_lookup_untouched() {
|
||||
use crate::node::tests::spanning_tree::make_test_node;
|
||||
|
||||
// MtuExceeded is an unencrypted signal that any admitted member can send
|
||||
// for any destination this node has bound. A bottleneck this small cannot
|
||||
// describe a real path; storing it would drive the SYN-time MSS clamp to a
|
||||
// single-digit or zero segment size. The session is installed so the
|
||||
// admission gate lets the signal through and the floor is what refuses it;
|
||||
// without one this would pass whether or not the floor exists.
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 100);
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
None,
|
||||
"a sub-floor MtuExceeded must leave no path_mtu_lookup entry behind"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_sub_floor_mtu_exceeded_is_counted_separately_from_all_mtu_exceeded() {
|
||||
use crate::node::tests::spanning_tree::make_test_node;
|
||||
|
||||
// `mtu_exceeded` counts every MtuExceeded regardless of value, so the
|
||||
// sub-floor subset is not separable from it. The signal is unencrypted,
|
||||
// unauthenticated and unmetered, so that subset climbing on its own is
|
||||
// the forged-signal signature and needs its own counter.
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
// Bound the destination so the admission gate admits the signal and the
|
||||
// floor is what classifies it.
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
|
||||
assert_eq!(
|
||||
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
||||
0,
|
||||
"counter starts at zero on a fresh node"
|
||||
);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(
|
||||
&dest,
|
||||
&reporter,
|
||||
crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU - 1,
|
||||
);
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
||||
1,
|
||||
"a sub-floor MtuExceeded must bump the below-floor counter"
|
||||
);
|
||||
|
||||
// The counter must discriminate: an actionable bottleneck is stored and
|
||||
// must bump only the all-signals counter.
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.metrics().errors.mtu_exceeded_below_floor.get(),
|
||||
1,
|
||||
"an actionable MtuExceeded must not bump the below-floor counter"
|
||||
);
|
||||
assert_eq!(
|
||||
tn.node.metrics().errors.mtu_exceeded.get(),
|
||||
2,
|
||||
"the all-signals counter must count both, sub-floor and actionable"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_handle_mtu_exceeded_at_the_floor_still_writes_path_mtu_lookup() {
|
||||
use crate::node::tests::spanning_tree::make_test_node;
|
||||
|
||||
// The guard must reject only what is below the floor. Without this the
|
||||
// floor could be widened arbitrarily and the test above would not notice.
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
let floor = crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU;
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, floor);
|
||||
tn.node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
Some(floor),
|
||||
"a bottleneck exactly at the floor is actionable and must be stored"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_forged_mtu_exceeded_of_zero_does_not_blackhole_the_session() {
|
||||
// The security property itself. MtuExceeded arrives unencrypted with no
|
||||
// sender check, so anyone who can reach this node can inject one. Applied
|
||||
// unfiltered, a reported MTU of zero drives the session's path MTU to
|
||||
// zero, and from then on the TUN send gate answers every packet with an
|
||||
// ICMPv6 Packet Too Big instead of sending it: a total blackhole for that
|
||||
// destination that survives until the daemon restarts.
|
||||
let edges = vec![(0, 1)];
|
||||
let mut nodes = run_tree_test(2, &edges, false).await;
|
||||
verify_tree_convergence(&nodes);
|
||||
populate_all_coord_caches(&mut nodes);
|
||||
|
||||
let node0_addr = *nodes[0].node.node_addr();
|
||||
let node1_addr = *nodes[1].node.node_addr();
|
||||
let node1_pubkey = nodes[1].node.identity().pubkey_full();
|
||||
|
||||
let src_fips = crate::FipsAddress::from_node_addr(&node0_addr);
|
||||
let dst_fips = crate::FipsAddress::from_node_addr(&node1_addr);
|
||||
|
||||
nodes[0]
|
||||
.node
|
||||
.initiate_session(node1_addr, node1_pubkey)
|
||||
.await
|
||||
.unwrap();
|
||||
for _ in 0..3 {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
process_available_packets(&mut nodes).await;
|
||||
}
|
||||
assert!(
|
||||
nodes[0]
|
||||
.node
|
||||
.get_session(&node1_addr)
|
||||
.unwrap()
|
||||
.state()
|
||||
.is_established()
|
||||
);
|
||||
|
||||
// Forge the signal: an MtuExceeded claiming the path to node 1 carries
|
||||
// nothing at all, reported by a node that is not on the path.
|
||||
let reporter = NodeAddr::from_bytes([0xEE; 16]);
|
||||
let inner = build_mtu_exceeded_inner(&node1_addr, &reporter, 0);
|
||||
nodes[0].node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
let (tun_tx, tun_rx) = std::sync::mpsc::channel();
|
||||
nodes[0].node.supervisor.tun_tx = Some(tun_tx);
|
||||
|
||||
let payload = vec![0u8; 560];
|
||||
let ipv6_packet = build_ipv6_packet(&src_fips, &dst_fips, &payload);
|
||||
assert_eq!(ipv6_packet.len(), 600);
|
||||
assert!(
|
||||
ipv6_packet.len() <= nodes[0].node.effective_ipv6_mtu() as usize,
|
||||
"the packet must fit the local MTU, so any PTB comes from the forged signal"
|
||||
);
|
||||
|
||||
nodes[0].node.handle_tun_outbound(ipv6_packet).await;
|
||||
|
||||
let tun_messages: Vec<Vec<u8>> = std::iter::from_fn(|| tun_rx.try_recv().ok()).collect();
|
||||
assert!(
|
||||
tun_messages.is_empty(),
|
||||
"a forged MtuExceeded of zero must not turn ordinary packets into \
|
||||
ICMPv6 Packet Too Big; got {} message(s)",
|
||||
tun_messages.len()
|
||||
);
|
||||
|
||||
cleanup_nodes(&mut nodes).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_broken_releases_path_mtu_lookup_entry() {
|
||||
use crate::node::tests::spanning_tree::make_test_node;
|
||||
use crate::proto::routing::PathBroken;
|
||||
|
||||
// A PathBroken report declares the path to a destination gone. The stored
|
||||
// path MTU described that path, so it must not be carried onto whatever
|
||||
// path replaces it — otherwise a value learned once (or injected once)
|
||||
// outlives every route change until the daemon restarts.
|
||||
let mut tn = make_test_node().await;
|
||||
|
||||
// The signal is only acted on for a destination this node has itself
|
||||
// bound, so the release is reachable only behind an installed session.
|
||||
// Without one the admission gate refuses the signal and this test would
|
||||
// observe the entry surviving for the wrong reason.
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut tn.node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
tn.node.path_mtu_lookup_insert(dest_fips, 700);
|
||||
assert_eq!(tn.node.path_mtu_lookup_get(&dest_fips), Some(700));
|
||||
|
||||
// Build the body the dispatcher would hand the handler: encode() prepends
|
||||
// a 4-byte FSP prefix and a msg_type byte, both already consumed there.
|
||||
let encoded = PathBroken::new(dest, reporter).encode();
|
||||
let inner = &encoded[5..];
|
||||
assert!(
|
||||
PathBroken::decode(inner).is_ok(),
|
||||
"the test body must decode, or the handler returns early and the \
|
||||
assertion below observes nothing"
|
||||
);
|
||||
|
||||
tn.node.handle_path_broken(&reporter, inner).await;
|
||||
|
||||
assert_eq!(
|
||||
tn.node.path_mtu_lookup_get(&dest_fips),
|
||||
None,
|
||||
"PathBroken must release the stored path MTU for the dead path"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Routing-signal admission: the named destination must be an address this
|
||||
// node bound itself, either by initiating toward it or by completing the
|
||||
// handshake that binds an address to a peer's static key. These signals carry
|
||||
// no end-to-end authentication, so without that gate any mesh member can name
|
||||
// any address and have the effects applied.
|
||||
// ============================================================================
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mtu_exceeded_naming_a_dest_with_no_session_does_not_touch_path_mtu_lookup() {
|
||||
let mut node = make_node();
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
assert!(
|
||||
node.path_mtu_lookup_get(&dest_fips).is_none(),
|
||||
"lookup should start empty for this destination"
|
||||
);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&dest_fips),
|
||||
None,
|
||||
"a signal naming an address with no session must not write the clamp"
|
||||
);
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
1,
|
||||
"the refusal must be counted against the MtuExceeded counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.coords.get(),
|
||||
0,
|
||||
"an MtuExceeded refusal must not bump the CoordsRequired counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.broken.get(),
|
||||
0,
|
||||
"an MtuExceeded refusal must not bump the PathBroken counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
0,
|
||||
"an absent session is an unbound refusal, not a forged pairing"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.mtu_exceeded.get(),
|
||||
1,
|
||||
"the arrival counter is the denominator and counts refused arrivals too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mtu_exceeded_naming_a_dest_whose_entry_is_an_unauthenticated_responder_handshake_is_dropped()
|
||||
{
|
||||
let mut node = make_node();
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
// One forged SessionSetup naming `dest` would leave exactly this entry.
|
||||
install_halfopen(&mut node, dest);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&dest_fips),
|
||||
None,
|
||||
"a half-open entry keyed on a claimed address must not admit the signal"
|
||||
);
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
1,
|
||||
"a half-open entry is an unbound refusal for MtuExceeded"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
0,
|
||||
"a half-open entry is a plausible pairing, not a forged one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mtu_exceeded_for_a_session_we_initiated_seeds_path_mtu_lookup_before_establishment() {
|
||||
let mut node = make_node();
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&dest_fips),
|
||||
Some(1280),
|
||||
"an address we chose ourselves must still seed the clamp during handshake"
|
||||
);
|
||||
assert_eq!(
|
||||
node.metrics().errors.unbound.mtu.get(),
|
||||
0,
|
||||
"an admitted signal must not be counted as refused"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mtu_exceeded_from_a_third_party_forwarder_still_tightens_an_active_session() {
|
||||
let mut node = make_node();
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_established_session_with_mmp(&mut node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
// A real transit reporter is neither us nor the destination.
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
let inner = build_mtu_exceeded_inner(&dest, &reporter, 1280);
|
||||
node.handle_mtu_exceeded(&reporter, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&dest_fips),
|
||||
Some(1280),
|
||||
"an on-path forwarder's report must still tighten the clamp"
|
||||
);
|
||||
assert_eq!(
|
||||
node.sessions
|
||||
.get(&dest)
|
||||
.and_then(|e| e.mmp())
|
||||
.map(|m| m.path_mtu.current_mtu()),
|
||||
Some(1280),
|
||||
"the session-side path MTU must also decrease"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_broken_naming_a_dest_with_no_session_does_not_flush_cached_coords() {
|
||||
use crate::proto::routing::PathBroken;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let coords = node.tree_state().my_coords().clone();
|
||||
node.coord_cache_mut().insert(dest, coords, 1000);
|
||||
|
||||
let encoded = PathBroken::new(dest, reporter).encode();
|
||||
node.handle_path_broken(&reporter, &encoded[5..]).await;
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&dest, 1000).is_some(),
|
||||
"a signal naming an address with no session must not flush its coords"
|
||||
);
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.broken.get(),
|
||||
1,
|
||||
"the refusal must be counted against the PathBroken counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
0,
|
||||
"a PathBroken refusal must not bump the MtuExceeded counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.coords.get(),
|
||||
0,
|
||||
"a PathBroken refusal must not bump the CoordsRequired counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
0,
|
||||
"an absent session is an unbound refusal, not a forged pairing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_broken_naming_a_dest_whose_entry_is_an_unauthenticated_responder_handshake_does_not_flush_cached_coords()
|
||||
{
|
||||
use crate::proto::routing::PathBroken;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let coords = node.tree_state().my_coords().clone();
|
||||
node.coord_cache_mut().insert(dest, coords, 1000);
|
||||
|
||||
// One forged SessionSetup naming `dest` would leave exactly this entry.
|
||||
install_halfopen(&mut node, dest);
|
||||
|
||||
let encoded = PathBroken::new(dest, reporter).encode();
|
||||
node.handle_path_broken(&reporter, &encoded[5..]).await;
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&dest, 1000).is_some(),
|
||||
"a half-open entry keyed on a claimed address must not admit the signal"
|
||||
);
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.broken.get(),
|
||||
1,
|
||||
"a half-open entry is an unbound refusal for PathBroken"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
0,
|
||||
"a half-open entry is a plausible pairing, not a forged one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_path_broken_for_a_session_we_initiated_still_flushes_cached_coords() {
|
||||
use crate::proto::routing::PathBroken;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_initiating(&mut node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
let coords = node.tree_state().my_coords().clone();
|
||||
node.coord_cache_mut().insert(dest, coords, 1000);
|
||||
|
||||
let encoded = PathBroken::new(dest, reporter).encode();
|
||||
node.handle_path_broken(&reporter, &encoded[5..]).await;
|
||||
|
||||
assert!(
|
||||
node.coord_cache().get(&dest, 1000).is_none(),
|
||||
"handshake-time recovery must still flush coords for an address we chose"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_coords_required_naming_a_dest_with_no_session_is_counted_as_an_unknown_session_reject()
|
||||
{
|
||||
use crate::proto::routing::CoordsRequired;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
let dest = NodeAddr::from_bytes([0xCC; 16]);
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
|
||||
let encoded = CoordsRequired::new(dest, reporter).encode();
|
||||
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
// The gate runs ahead of the response rate limiter, so a second
|
||||
// identical signal is rejected the same way rather than being
|
||||
// absorbed by rate-limiter state keyed on an attacker-chosen address.
|
||||
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
||||
assert_eq!(node.stats().session.unknown_session, 2);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.coords.get(),
|
||||
2,
|
||||
"both refusals must be counted against the CoordsRequired counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.broken.get(),
|
||||
0,
|
||||
"a CoordsRequired refusal must not bump the PathBroken counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
0,
|
||||
"a CoordsRequired refusal must not bump the MtuExceeded counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
0,
|
||||
"an absent session is an unbound refusal, not a forged pairing"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.coords_required.get(),
|
||||
2,
|
||||
"the arrival counter is the denominator and counts refused arrivals too"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_mtu_exceeded_whose_claimed_source_is_the_destination_it_names_is_dropped() {
|
||||
let mut node = make_node();
|
||||
|
||||
let remote = Identity::generate();
|
||||
install_established_session_with_mmp(&mut node, &remote);
|
||||
let dest = *remote.node_addr();
|
||||
let dest_fips = crate::FipsAddress::from_node_addr(&dest);
|
||||
|
||||
// The emitter of a routing signal is by construction a transit node for
|
||||
// the datagram it is reporting on, so it is never that datagram's own
|
||||
// destination. A signal claiming otherwise is malformed.
|
||||
let inner = build_mtu_exceeded_inner(&dest, &dest, 1280);
|
||||
node.handle_mtu_exceeded(&dest, &inner).await;
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&dest_fips),
|
||||
None,
|
||||
"a signal whose claimed source is the destination it names must be dropped"
|
||||
);
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
1,
|
||||
"the refusal must still be counted against the MtuExceeded counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
1,
|
||||
"a src equal to the dest it names is a structurally impossible pairing"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_coords_required_naming_this_node_as_the_destination_counts_a_forged_pairing() {
|
||||
use crate::proto::routing::CoordsRequired;
|
||||
|
||||
let mut node = make_node();
|
||||
|
||||
// A datagram addressed to this node is delivered locally before any
|
||||
// forwarding, so no honest transit router ever emits a signal naming
|
||||
// us as the destination. This clause can only be reached by fabrication.
|
||||
let dest = *node.node_addr();
|
||||
let reporter = NodeAddr::from_bytes([0xBB; 16]);
|
||||
|
||||
let encoded = CoordsRequired::new(dest, reporter).encode();
|
||||
node.handle_coords_required(&reporter, &encoded[5..]).await;
|
||||
|
||||
assert_eq!(node.stats().session.unknown_session, 1);
|
||||
let errors = &node.metrics().errors;
|
||||
assert_eq!(
|
||||
errors.unbound.coords.get(),
|
||||
1,
|
||||
"the refusal must be counted against the CoordsRequired counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.forged.get(),
|
||||
1,
|
||||
"a signal naming this node as the destination is a forged pairing"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.broken.get(),
|
||||
0,
|
||||
"a CoordsRequired refusal must not bump the PathBroken counter"
|
||||
);
|
||||
assert_eq!(
|
||||
errors.unbound.mtu.get(),
|
||||
0,
|
||||
"a CoordsRequired refusal must not bump the MtuExceeded counter"
|
||||
);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Proactive PathMtuNotification → path_mtu_lookup focused unit tests
|
||||
//
|
||||
@@ -2751,6 +3388,132 @@ fn test_handle_path_mtu_notification_no_session_no_op() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_sub_floor_path_mtu_notification_is_ignored_and_counted() {
|
||||
// The state machine returns the same `false` for a sub-floor refusal as
|
||||
// for an ordinary no-change, so without a counter at the caller the
|
||||
// refusal is indistinguishable from the common case. This arrives on the
|
||||
// decrypted path, so a rising count means an authenticated peer is
|
||||
// sending unusable values.
|
||||
let mut node = make_node();
|
||||
let remote = Identity::generate();
|
||||
let remote_addr = *remote.node_addr();
|
||||
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
||||
|
||||
install_established_session_with_mmp(&mut node, &remote);
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
||||
0,
|
||||
"counter starts at zero on a fresh node"
|
||||
);
|
||||
|
||||
let body = build_path_mtu_notification_body(crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU - 1);
|
||||
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
||||
1,
|
||||
"a sub-floor PathMtuNotification must bump the below-floor counter"
|
||||
);
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&remote_fips),
|
||||
None,
|
||||
"a sub-floor PathMtuNotification must leave no path_mtu_lookup entry"
|
||||
);
|
||||
|
||||
// The counter must discriminate: an actionable value is applied and must
|
||||
// not bump it.
|
||||
let body = build_path_mtu_notification_body(1280);
|
||||
node.handle_session_path_mtu_notification(&remote_addr, &body);
|
||||
|
||||
assert_eq!(
|
||||
node.metrics().errors.path_mtu_notif_below_floor.get(),
|
||||
1,
|
||||
"an actionable PathMtuNotification must not bump the below-floor counter"
|
||||
);
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&remote_fips),
|
||||
Some(1280),
|
||||
"the actionable value must still be applied after a refused one"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_idle_session_purge_keeps_link_peer_path_mtu_seed() {
|
||||
use crate::peer::ActivePeer;
|
||||
use crate::transport::udp::UdpTransport;
|
||||
use crate::transport::{TransportHandle, packet_channel};
|
||||
|
||||
// Releasing on idle expiry must not throw away what local configuration
|
||||
// knows. Idle expiry removes an end-to-end session; the FMP link to a
|
||||
// directly connected peer stays up, and its link MTU is seeded only on
|
||||
// link promotion. A blanket removal here would drop that peer to the
|
||||
// conservative ceiling for every later flow until the link re-handshakes.
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
|
||||
let (transport_packet_tx, _transport_packet_rx) = packet_channel(64);
|
||||
let transport_id = TransportId::new(1);
|
||||
let mut udp = UdpTransport::new(
|
||||
transport_id,
|
||||
Some("udp1".to_string()),
|
||||
crate::config::UdpConfig {
|
||||
bind_addr: Some("127.0.0.1:0".to_string()),
|
||||
mtu: Some(1452),
|
||||
..Default::default()
|
||||
},
|
||||
transport_packet_tx,
|
||||
);
|
||||
udp.start_async().await.unwrap();
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Udp(udp));
|
||||
|
||||
// A directly connected peer, seeded from its link MTU the way FMP
|
||||
// promotion seeds it, with an end-to-end session on top.
|
||||
let remote = Identity::generate();
|
||||
let remote_addr = *remote.node_addr();
|
||||
let remote_fips = crate::FipsAddress::from_node_addr(&remote_addr);
|
||||
let transport_addr = TransportAddr::from_string("127.0.0.1:2121");
|
||||
|
||||
let peer_identity = PeerIdentity::from_pubkey_full(remote.pubkey_full());
|
||||
let mut peer = ActivePeer::new(peer_identity, LinkId::new(7), 0);
|
||||
peer.set_current_addr(transport_id, transport_addr.clone());
|
||||
node.peers.insert(remote_addr, peer);
|
||||
|
||||
node.seed_path_mtu_for_link_peer(&remote_addr, transport_id, &transport_addr);
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&remote_fips),
|
||||
Some(1452),
|
||||
"precondition: the direct-link seed is in place"
|
||||
);
|
||||
|
||||
let session = make_noise_session(node.identity(), &remote);
|
||||
let entry = crate::node::session::SessionEntry::new(
|
||||
remote_addr,
|
||||
remote.pubkey_full(),
|
||||
EndToEndState::Established(session),
|
||||
1000,
|
||||
true,
|
||||
);
|
||||
node.sessions.insert(remote_addr, entry);
|
||||
|
||||
node.purge_idle_sessions(1000 + 92_000);
|
||||
assert_eq!(node.session_count(), 0, "precondition: the session expired");
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup_get(&remote_fips),
|
||||
Some(1452),
|
||||
"idle expiry must leave the locally derived link MTU in place"
|
||||
);
|
||||
|
||||
for transport in node.transports.values_mut() {
|
||||
transport.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Session identity binding: XX msg3 source address / static key
|
||||
// ============================================================================
|
||||
|
||||
@@ -152,29 +152,14 @@ async fn make_test_node_inner_with_identity(
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a loopback test node from an explicit `Config`, e.g. to set the
|
||||
/// `node.rekey` thresholds a rekey-behaviour test needs. Otherwise identical to
|
||||
/// [`make_test_node`] (default 1280 MTU, in-process loopback transport).
|
||||
pub(super) async fn make_test_node_with_config(config: crate::config::Config) -> TestNode {
|
||||
let mut node = make_node_with(config);
|
||||
let transport_id = TransportId::new(1);
|
||||
|
||||
let (tx, rx) = tokio::sync::mpsc::unbounded_channel::<ReceivedPacket>();
|
||||
let addr = next_loopback_addr();
|
||||
|
||||
LOOPBACK_REGISTRY.lock().unwrap().insert(addr.clone(), tx);
|
||||
|
||||
let loopback =
|
||||
LoopbackTransport::with_mtu(transport_id, addr.clone(), 1280, LOOPBACK_REGISTRY.clone());
|
||||
node.transports
|
||||
.insert(transport_id, TransportHandle::Loopback(loopback));
|
||||
|
||||
TestNode {
|
||||
node,
|
||||
transport_id,
|
||||
packet_rx: rx,
|
||||
addr,
|
||||
}
|
||||
/// Create a loopback test node from an explicit `Config` and transport MTU,
|
||||
/// e.g. to set the `node.rekey` thresholds a rekey-behaviour test needs.
|
||||
///
|
||||
/// Node configuration is immutable after construction (see `make_node_with`),
|
||||
/// so a test that needs a non-default setting must supply the `Config` here
|
||||
/// rather than poking the node afterwards.
|
||||
pub(super) async fn make_test_node_with_config(config: Config, mtu: u16) -> TestNode {
|
||||
make_test_node_inner(config, mtu).await
|
||||
}
|
||||
|
||||
/// Initiate a Noise handshake from nodes[i] to nodes[j].
|
||||
@@ -920,6 +905,12 @@ pub(super) async fn run_tree_test_with_mtus(
|
||||
nodes.push(make_test_node_with_mtu(mtu).await);
|
||||
}
|
||||
|
||||
converge_nodes(nodes, edges).await
|
||||
}
|
||||
|
||||
/// Drive the given nodes to convergence over `edges` and assert every edge
|
||||
/// established a bidirectional peer.
|
||||
async fn converge_nodes(mut nodes: Vec<TestNode>, edges: &[(usize, usize)]) -> Vec<TestNode> {
|
||||
for &(i, j) in edges {
|
||||
initiate_handshake(&mut nodes, i, j).await;
|
||||
}
|
||||
|
||||
@@ -1802,6 +1802,53 @@ async fn test_seed_path_mtu_inserts_when_empty() {
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_seeded_narrow_link_mtu_reaches_the_clamp_as_a_tight_ceiling() {
|
||||
// The seed and the SYN-time MSS clamp are two halves of one mechanism: the
|
||||
// seed writes the node's own outgoing link MTU, the clamp reads it. A
|
||||
// narrow link is the case that matters, because BLE negotiates its MTU per
|
||||
// connection and lands below the remote-value floor routinely, and a
|
||||
// direct link has no forwarder to answer an over-large segment with
|
||||
// MtuExceeded. Driving the real seed rather than inserting into the map
|
||||
// pins that the clamp honours what the seed actually stores.
|
||||
let mut node = make_node();
|
||||
let (packet_tx, packet_rx) = packet_channel(64);
|
||||
node.supervisor.packet_tx = Some(packet_tx);
|
||||
node.packet_rx = Some(packet_rx);
|
||||
|
||||
let udp = make_udp_transport_with_mtu(1, 240).await;
|
||||
node.transports.insert(TransportId::new(1), udp);
|
||||
|
||||
let peer_addr = make_node_addr(0xEE);
|
||||
let fips_addr = crate::FipsAddress::from_node_addr(&peer_addr);
|
||||
let transport_addr = TransportAddr::from_string("10.0.0.6:2121");
|
||||
|
||||
node.seed_path_mtu_for_link_peer(&peer_addr, TransportId::new(1), &transport_addr);
|
||||
|
||||
assert_eq!(
|
||||
node.path_mtu_lookup
|
||||
.read()
|
||||
.unwrap()
|
||||
.get(&fips_addr)
|
||||
.copied(),
|
||||
Some(240),
|
||||
"the seed stores a narrow link MTU unchanged"
|
||||
);
|
||||
// 240 - 77 encap - 40 IPv6 - 20 TCP = 103. A clamp that discarded the
|
||||
// seeded value would advertise the 1143 conservative ceiling instead, and
|
||||
// every full-size segment would be refused by the transport with no
|
||||
// feedback to the TCP stack.
|
||||
assert_eq!(
|
||||
crate::upper::tun::per_flow_max_mss(&node.path_mtu_lookup, fips_addr.as_bytes(), 1360),
|
||||
103,
|
||||
"the clamp must honour the seeded link MTU, not fall back to 1143"
|
||||
);
|
||||
|
||||
for transport in node.transports.values_mut() {
|
||||
transport.stop().await.ok();
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_seed_path_mtu_keeps_tighter_existing_value() {
|
||||
let mut node = make_node();
|
||||
|
||||
+37
-14
@@ -15,6 +15,20 @@ use super::reject::TreeReject;
|
||||
use super::{Node, NodeError};
|
||||
use tracing::{debug, info, trace, warn};
|
||||
|
||||
impl Node {
|
||||
/// Report a flap-dampening engagement: one counter tick and one warning
|
||||
/// naming which path armed the episode and how long discretionary parent
|
||||
/// switching stays suppressed.
|
||||
pub(super) fn note_flap(&self, trigger: &str) {
|
||||
self.metrics().tree.flap_dampened.inc();
|
||||
warn!(
|
||||
trigger = trigger,
|
||||
dampening_secs = self.tree_state.dampening_secs(),
|
||||
"Flap dampening engaged, discretionary parent switching suppressed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Sign a node's own tree declaration, writing the 64-byte signature back into
|
||||
/// it. The key-crypto boundary (§6): `proto::stp` owns the declaration data and
|
||||
/// the pure `signing_bytes()` serialization; the shell owns the `secp256k1`
|
||||
@@ -385,8 +399,7 @@ impl Node {
|
||||
"Parent switched, invalidated downstream coord cache entries, announcing to all peers"
|
||||
);
|
||||
if flap_dampened {
|
||||
self.metrics().tree.flap_dampened.inc();
|
||||
warn!("Flap dampening engaged: excessive parent switches detected");
|
||||
self.note_flap("announce");
|
||||
}
|
||||
|
||||
self.send_tree_announce_to_all().await;
|
||||
@@ -438,10 +451,8 @@ impl Node {
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
if self
|
||||
.tree_state
|
||||
.handle_parent_lost(&peer_costs, timestamp, mono_now_ms)
|
||||
{
|
||||
let outcome = self.tree_state.recover(&peer_costs, timestamp, mono_now_ms);
|
||||
if outcome.changed {
|
||||
// Clone identity up front to avoid a split borrow against the
|
||||
// &mut self.tree_state / &mut self.coord_cache calls below (cold path).
|
||||
let our_identity = self.identity().clone();
|
||||
@@ -462,6 +473,9 @@ impl Node {
|
||||
.invalidate_other_roots(self.tree_state.root());
|
||||
self.reset_lookup_backoff();
|
||||
self.send_tree_announce_to_all().await;
|
||||
if outcome.dampened {
|
||||
self.note_flap("loop-detected");
|
||||
}
|
||||
}
|
||||
}
|
||||
TreeDecision::AncestryUpdate { parent, new_seq } => {
|
||||
@@ -489,8 +503,17 @@ impl Node {
|
||||
// Clone identity up front to avoid a split borrow against the
|
||||
// &mut self.tree_state / &mut self.coord_cache calls below (cold path).
|
||||
let our_identity = self.identity().clone();
|
||||
self.tree_state
|
||||
.set_parent(parent, new_seq, timestamp, mono_now_ms);
|
||||
let flap_dampened =
|
||||
self.tree_state
|
||||
.set_parent(parent, new_seq, timestamp, mono_now_ms);
|
||||
// Defensive rather than reachable: this arm is selected only
|
||||
// when the declared parent is unchanged, so `set_parent`'s
|
||||
// `parent_changed` is false and no episode can be armed here.
|
||||
// Kept so the reporting stays complete if the classify core's
|
||||
// guard ever admits a different parent on this path.
|
||||
if flap_dampened {
|
||||
self.note_flap("ancestry-update");
|
||||
}
|
||||
self.tree_state.recompute_coords();
|
||||
if let Err(e) =
|
||||
sign_declaration(self.tree_state.my_declaration_mut(), &our_identity)
|
||||
@@ -636,8 +659,7 @@ impl Node {
|
||||
"Parent switched via periodic cost re-evaluation"
|
||||
);
|
||||
if flap_dampened {
|
||||
self.metrics().tree.flap_dampened.inc();
|
||||
warn!("Flap dampening engaged: excessive parent switches detected");
|
||||
self.note_flap("periodic");
|
||||
}
|
||||
|
||||
self.send_tree_announce_to_all().await;
|
||||
@@ -748,10 +770,8 @@ impl Node {
|
||||
// Removal is not a pure classify: `handle_parent_lost` is a &mut mutator
|
||||
// whose returned `changed` bool IS the decision. Drive it and map the
|
||||
// outcome onto the TreeDecision vocabulary.
|
||||
let decision = if self
|
||||
.tree_state
|
||||
.handle_parent_lost(&peer_costs, now_secs, mono_now_ms)
|
||||
{
|
||||
let outcome = self.tree_state.recover(&peer_costs, now_secs, mono_now_ms);
|
||||
let decision = if outcome.changed {
|
||||
TreeDecision::ParentLost
|
||||
} else {
|
||||
TreeDecision::NoChange
|
||||
@@ -785,6 +805,9 @@ impl Node {
|
||||
is_root = self.tree_state.is_root(),
|
||||
"Tree state updated after parent loss"
|
||||
);
|
||||
if outcome.dampened {
|
||||
self.note_flap("parent-loss");
|
||||
}
|
||||
true
|
||||
}
|
||||
TreeDecision::NoChange => false,
|
||||
|
||||
+137
-48
@@ -25,7 +25,10 @@ use super::signal::{
|
||||
validate_traversal_answer_for_offer,
|
||||
};
|
||||
use super::stun::observe_traversal_addresses;
|
||||
use super::traversal::{nonce, now_ms, planned_remote_endpoints, run_punch_attempt};
|
||||
use super::traversal::{
|
||||
PunchTargetTally, is_doc_ip, is_never_punchable_ip, is_private_ip, nonce, now_ms,
|
||||
planned_remote_endpoints, run_punch_attempt,
|
||||
};
|
||||
use super::traversal_machine::{OfferDisposition, SeenDecision, TraversalMachine};
|
||||
use super::types::{
|
||||
ADVERT_IDENTIFIER, ADVERT_KIND, ADVERT_VERSION, BootstrapError, BootstrapEvent,
|
||||
@@ -45,11 +48,75 @@ fn short_npub(npub: &str) -> String {
|
||||
.unwrap_or_else(|| npub.to_string())
|
||||
}
|
||||
|
||||
fn short_id(id: &str) -> String {
|
||||
if id.len() > 8 {
|
||||
id[..8].to_string()
|
||||
/// Whether an inbound-offer rejection belongs to a class that cannot be
|
||||
/// explained by ordinary relay delivery lag, and therefore warrants a warning
|
||||
/// on a node running at the default log level. A stale offer is benign and is
|
||||
/// deliberately excluded.
|
||||
pub(super) fn adversarial_offer_reject(err: &BootstrapError) -> bool {
|
||||
matches!(
|
||||
err,
|
||||
BootstrapError::Protocol(reason)
|
||||
if reason == "future-dated-offer"
|
||||
|| reason == "identity-mismatch"
|
||||
|| reason == "invalid-offer"
|
||||
)
|
||||
}
|
||||
|
||||
/// Shorten a peer-supplied identifier for logging.
|
||||
///
|
||||
/// Truncates on a character boundary rather than a byte index. The input is a
|
||||
/// session id taken straight from a remote party's JSON with no charset
|
||||
/// validation, and slicing by byte offset panics when the boundary falls
|
||||
/// inside a multi-byte character.
|
||||
pub(super) fn short_id(id: &str) -> String {
|
||||
id.chars().take(8).collect()
|
||||
}
|
||||
|
||||
/// Record, once per planning call, the punch candidates a peer named that we
|
||||
/// declined to punch.
|
||||
///
|
||||
/// One aggregated record rather than one per candidate: a peer's candidate
|
||||
/// list is unbounded, so per-candidate logging would trade the packet
|
||||
/// amplification the filter closes for a log amplification. `warn` is used for
|
||||
/// the shapes no honest peer produces, because `info` is the level a shipped
|
||||
/// node collects by default and those refusals are the ones an operator needs
|
||||
/// to see; the routine off-subnet case stays at `debug`.
|
||||
fn log_refusals(tally: &PunchTargetTally, peer: &str, session: &str) {
|
||||
if tally.offered <= tally.admitted && tally.capped == 0 {
|
||||
return;
|
||||
}
|
||||
let sample = tally.sample.as_deref().unwrap_or("-");
|
||||
let reflexive = tally.reflexive.unwrap_or("-");
|
||||
if tally.suspicious() {
|
||||
warn!(
|
||||
peer = %peer,
|
||||
session = %session,
|
||||
offered = tally.offered,
|
||||
admitted = tally.admitted,
|
||||
unparsable = tally.unparsable,
|
||||
zeroport = tally.zeroport,
|
||||
unroutable = tally.unroutable,
|
||||
offsubnet = tally.offsubnet,
|
||||
capped = tally.capped,
|
||||
reflexive = %reflexive,
|
||||
sample = %sample,
|
||||
"traversal: punch candidates refused"
|
||||
);
|
||||
} else {
|
||||
id.to_string()
|
||||
debug!(
|
||||
peer = %peer,
|
||||
session = %session,
|
||||
offered = tally.offered,
|
||||
admitted = tally.admitted,
|
||||
unparsable = tally.unparsable,
|
||||
zeroport = tally.zeroport,
|
||||
unroutable = tally.unroutable,
|
||||
offsubnet = tally.offsubnet,
|
||||
capped = tally.capped,
|
||||
reflexive = %reflexive,
|
||||
sample = %sample,
|
||||
"traversal: punch candidates refused"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,26 +128,11 @@ fn endpoint_summary(endpoints: &[OverlayEndpointAdvert]) -> String {
|
||||
.join(",")
|
||||
}
|
||||
|
||||
fn is_unroutable_direct_advert_ip(ip: std::net::IpAddr) -> bool {
|
||||
match ip {
|
||||
std::net::IpAddr::V4(v4) => {
|
||||
v4.is_private()
|
||||
|| v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_broadcast()
|
||||
|| v4.is_documentation()
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
|
||||
}
|
||||
std::net::IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_unique_local()
|
||||
|| v6.is_multicast()
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
/// Addresses an advert must not name as a directly dialable endpoint: the
|
||||
/// never-punchable ranges plus the private and documentation ones, which are
|
||||
/// useless to a peer that found the advert on a relay.
|
||||
pub(super) fn is_unroutable_direct_advert_ip(ip: std::net::IpAddr) -> bool {
|
||||
is_never_punchable_ip(ip) || is_private_ip(ip) || is_doc_ip(ip)
|
||||
}
|
||||
|
||||
pub(super) fn endpoint_advert_is_publicly_usable(endpoint: &OverlayEndpointAdvert) -> bool {
|
||||
@@ -758,13 +810,35 @@ impl NostrRendezvous {
|
||||
continue;
|
||||
};
|
||||
let runtime = Arc::clone(&self);
|
||||
let peer_short = short_npub(&sender_npub);
|
||||
let session_short = short_id(&offer.session_id);
|
||||
tokio::spawn(async move {
|
||||
let _permit = permit;
|
||||
if let Err(err) = runtime
|
||||
.handle_incoming_offer(offer, unwrapped.sender, sender_npub)
|
||||
.await
|
||||
{
|
||||
debug!(error = %err, "failed to handle traversal offer");
|
||||
// An offer arriving stale is the expected
|
||||
// consequence of relay lag and stays at debug.
|
||||
// The remaining classes cannot arise from lag,
|
||||
// so they are the operator's only evidence that
|
||||
// a node is being fed malformed or forged
|
||||
// signals, and must clear the default level.
|
||||
if adversarial_offer_reject(&err) {
|
||||
warn!(
|
||||
peer = %peer_short,
|
||||
session = %session_short,
|
||||
error = %err,
|
||||
"rejected traversal offer"
|
||||
);
|
||||
} else {
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
session = %session_short,
|
||||
error = %err,
|
||||
"failed to handle traversal offer"
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1107,7 +1181,9 @@ impl NostrRendezvous {
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
session = %short_id(&offer.session_id),
|
||||
"traversal: answer accepted within clock-skew tolerance"
|
||||
answer_issued_at = answer.payload.issued_at,
|
||||
answer_expires_at = answer.payload.expires_at,
|
||||
"traversal: answer accepted within freshness tolerance"
|
||||
);
|
||||
}
|
||||
if !answer.payload.accepted {
|
||||
@@ -1119,12 +1195,13 @@ impl NostrRendezvous {
|
||||
));
|
||||
}
|
||||
|
||||
let remotes = planned_remote_endpoints(
|
||||
let (remotes, tally) = planned_remote_endpoints(
|
||||
&offer.local_addresses,
|
||||
offer.reflexive_address.as_ref(),
|
||||
&answer.payload.local_addresses,
|
||||
answer.payload.reflexive_address.as_ref(),
|
||||
)?;
|
||||
log_refusals(&tally, &peer_short, &short_id(&session_id));
|
||||
|
||||
let remote_addr = run_punch_attempt(
|
||||
&base_socket,
|
||||
@@ -1190,8 +1267,9 @@ impl NostrRendezvous {
|
||||
peer = %peer_short,
|
||||
session = %short_id(&offer.session_id),
|
||||
offer_issued_at = offer.issued_at,
|
||||
offer_expires_at = offer.expires_at,
|
||||
offer_received_at = offer_received_at,
|
||||
"traversal: offer accepted within clock-skew tolerance"
|
||||
"traversal: offer accepted within freshness tolerance"
|
||||
);
|
||||
}
|
||||
// Collapse the dual-`auto_connect` four-socket dance to a single
|
||||
@@ -1311,14 +1389,15 @@ impl NostrRendezvous {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
let remotes = planned_remote_endpoints(
|
||||
let (remotes, tally) = planned_remote_endpoints(
|
||||
&answer.local_addresses,
|
||||
answer.reflexive_address.as_ref(),
|
||||
&offer.local_addresses,
|
||||
offer.reflexive_address.as_ref(),
|
||||
)?;
|
||||
log_refusals(&tally, &peer_short, &short_id(&offer.session_id));
|
||||
|
||||
if let Ok(remote_addr) = run_punch_attempt(
|
||||
let punch = run_punch_attempt(
|
||||
&base_socket,
|
||||
&offer.session_id,
|
||||
&remotes,
|
||||
@@ -1328,23 +1407,33 @@ impl NostrRendezvous {
|
||||
.expect("accepted answers always include a punch hint"),
|
||||
Duration::from_secs(self.config.attempt_timeout_secs),
|
||||
)
|
||||
.await
|
||||
{
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
session = %short_id(&offer.session_id),
|
||||
remote = %remote_addr,
|
||||
"traversal: responder punch succeeded"
|
||||
);
|
||||
let _ = self.event_tx.send(BootstrapEvent::Established {
|
||||
traversal: EstablishedTraversal::new(
|
||||
offer.session_id,
|
||||
offer.sender_npub,
|
||||
remote_addr,
|
||||
base_socket,
|
||||
)
|
||||
.with_transport_name("nostr-nat"),
|
||||
});
|
||||
.await;
|
||||
match punch {
|
||||
Ok(remote_addr) => {
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
session = %short_id(&offer.session_id),
|
||||
remote = %remote_addr,
|
||||
"traversal: responder punch succeeded"
|
||||
);
|
||||
let _ = self.event_tx.send(BootstrapEvent::Established {
|
||||
traversal: EstablishedTraversal::new(
|
||||
offer.session_id,
|
||||
offer.sender_npub,
|
||||
remote_addr,
|
||||
base_socket,
|
||||
)
|
||||
.with_transport_name("nostr-nat"),
|
||||
});
|
||||
}
|
||||
Err(err) => {
|
||||
debug!(
|
||||
peer = %peer_short,
|
||||
session = %short_id(&offer.session_id),
|
||||
error = %err,
|
||||
"traversal: responder punch failed"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let _ = self.publish_delete(&relays, [answer_event.id]).await;
|
||||
|
||||
+69
-16
@@ -92,6 +92,20 @@ pub(super) enum FreshnessOutcome {
|
||||
FreshWithinSkewTolerance,
|
||||
}
|
||||
|
||||
/// Why a freshness check rejected a signal. The two classes are operationally
|
||||
/// different and must not be collapsed into one reason string: one is the
|
||||
/// expected consequence of relay lag, the other cannot arise from lag at all.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum FreshnessReject {
|
||||
/// Issued too long ago: past the configured TTL plus skew tolerance.
|
||||
/// Benign; relay delivery delay produces this routinely.
|
||||
Stale,
|
||||
/// Issued further ahead of the local clock than the skew tolerance
|
||||
/// allows. Not reachable through delivery delay, so it means either a
|
||||
/// clock broken past the tolerance or a forged stamp.
|
||||
FutureDated,
|
||||
}
|
||||
|
||||
pub(super) fn validate_offer_freshness(
|
||||
offer: &TraversalOffer,
|
||||
now: u64,
|
||||
@@ -103,8 +117,13 @@ pub(super) fn validate_offer_freshness(
|
||||
return Err(BootstrapError::Protocol("invalid-offer".to_string()));
|
||||
}
|
||||
let outcome = match check_freshness(offer.issued_at, offer.expires_at, now, signal_ttl_ms) {
|
||||
Some(o) => o,
|
||||
None => return Err(BootstrapError::Protocol("expired-offer".to_string())),
|
||||
Ok(o) => o,
|
||||
Err(FreshnessReject::Stale) => {
|
||||
return Err(BootstrapError::Protocol("expired-offer".to_string()));
|
||||
}
|
||||
Err(FreshnessReject::FutureDated) => {
|
||||
return Err(BootstrapError::Protocol("future-dated-offer".to_string()));
|
||||
}
|
||||
};
|
||||
if offer.sender_npub != actual_sender_npub || offer.recipient_npub != local_npub {
|
||||
return Err(BootstrapError::Protocol("identity-mismatch".to_string()));
|
||||
@@ -187,13 +206,27 @@ pub(super) fn validate_traversal_answer_for_offer(
|
||||
}
|
||||
let offer_outcome = match check_freshness(offer.issued_at, offer.expires_at, now, signal_ttl_ms)
|
||||
{
|
||||
Some(o) => o,
|
||||
None => return Err(BootstrapError::Protocol("expired-answer".to_string())),
|
||||
Ok(o) => o,
|
||||
Err(FreshnessReject::Stale) => {
|
||||
return Err(BootstrapError::Protocol(
|
||||
"expired-offer-in-answer".to_string(),
|
||||
));
|
||||
}
|
||||
Err(FreshnessReject::FutureDated) => {
|
||||
return Err(BootstrapError::Protocol(
|
||||
"future-dated-offer-in-answer".to_string(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let answer_outcome =
|
||||
match check_freshness(answer.issued_at, answer.expires_at, now, signal_ttl_ms) {
|
||||
Some(o) => o,
|
||||
None => return Err(BootstrapError::Protocol("expired-answer".to_string())),
|
||||
Ok(o) => o,
|
||||
Err(FreshnessReject::Stale) => {
|
||||
return Err(BootstrapError::Protocol("expired-answer".to_string()));
|
||||
}
|
||||
Err(FreshnessReject::FutureDated) => {
|
||||
return Err(BootstrapError::Protocol("future-dated-answer".to_string()));
|
||||
}
|
||||
};
|
||||
if offer.session_id != answer.session_id || answer.in_reply_to != offer.nonce {
|
||||
return Err(BootstrapError::Protocol("session-mismatch".to_string()));
|
||||
@@ -246,25 +279,45 @@ pub(super) fn estimate_clock_skew(
|
||||
Some(((t2 - t1) + (t3 - t4)) / 2)
|
||||
}
|
||||
|
||||
/// Returns Some(outcome) if the (issued_at, expires_at) pair is acceptable
|
||||
/// Returns Ok(outcome) if the (issued_at, expires_at) pair is acceptable
|
||||
/// against `now` under the configured TTL plus `FRESHNESS_SKEW_TOLERANCE_MS`
|
||||
/// of clock-skew grace on each side. Returns None if the message is
|
||||
/// genuinely outside the tolerated window.
|
||||
/// of clock-skew grace on each side. Returns Err with the rejection class if
|
||||
/// the message is genuinely outside the tolerated window.
|
||||
///
|
||||
/// Both sides are bounded. Backwards, a signal is accepted up to
|
||||
/// `signal_ttl_ms + FRESHNESS_SKEW_TOLERANCE_MS` after it was issued.
|
||||
/// Forwards, a signal issued ahead of the local clock is accepted only within
|
||||
/// `FRESHNESS_SKEW_TOLERANCE_MS` and only as tolerated, never as strictly
|
||||
/// fresh. The wire `expires_at` is chosen by the sender and is independent of
|
||||
/// its `issued_at`, so it is clamped to the issuer's own stamp plus our
|
||||
/// configured TTL; a sender may shorten its own expiry but cannot widen the
|
||||
/// window we apply. One stamp is therefore acceptable over
|
||||
/// `signal_ttl_ms + 2 * FRESHNESS_SKEW_TOLERANCE_MS` of wall clock, whatever
|
||||
/// the sender declares.
|
||||
fn check_freshness(
|
||||
issued_at: u64,
|
||||
expires_at: u64,
|
||||
now: u64,
|
||||
signal_ttl_ms: u64,
|
||||
) -> Option<FreshnessOutcome> {
|
||||
let strict_ok = expires_at > now && now.saturating_sub(issued_at) <= signal_ttl_ms;
|
||||
) -> Result<FreshnessOutcome, FreshnessReject> {
|
||||
let effective_expiry = expires_at.min(issued_at.saturating_add(signal_ttl_ms));
|
||||
// Exactly one of these is non-zero: a saturating signed age without a
|
||||
// signed type.
|
||||
let ahead = issued_at.saturating_sub(now);
|
||||
let age = now.saturating_sub(issued_at);
|
||||
|
||||
let strict_ok = ahead == 0 && effective_expiry > now && age <= signal_ttl_ms;
|
||||
if strict_ok {
|
||||
return Some(FreshnessOutcome::Fresh);
|
||||
return Ok(FreshnessOutcome::Fresh);
|
||||
}
|
||||
let tolerated_ok = expires_at.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS) > now
|
||||
&& now.saturating_sub(issued_at) <= signal_ttl_ms + FRESHNESS_SKEW_TOLERANCE_MS;
|
||||
if ahead > FRESHNESS_SKEW_TOLERANCE_MS {
|
||||
return Err(FreshnessReject::FutureDated);
|
||||
}
|
||||
let tolerated_ok = effective_expiry.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS) > now
|
||||
&& age <= signal_ttl_ms.saturating_add(FRESHNESS_SKEW_TOLERANCE_MS);
|
||||
if tolerated_ok {
|
||||
Some(FreshnessOutcome::FreshWithinSkewTolerance)
|
||||
Ok(FreshnessOutcome::FreshWithinSkewTolerance)
|
||||
} else {
|
||||
None
|
||||
Err(FreshnessReject::Stale)
|
||||
}
|
||||
}
|
||||
|
||||
+497
-7
@@ -1,18 +1,23 @@
|
||||
use std::collections::HashSet;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
|
||||
use nostr::prelude::{EventBuilder, Kind, RelayUrl, Tag, Timestamp};
|
||||
|
||||
use super::runtime::{NostrRendezvous, signal_relays};
|
||||
use super::runtime::{
|
||||
NostrRendezvous, adversarial_offer_reject, is_unroutable_direct_advert_ip, short_id,
|
||||
signal_relays,
|
||||
};
|
||||
use super::signal::{
|
||||
FreshnessOutcome, build_signal_event, create_traversal_answer, create_traversal_offer,
|
||||
estimate_clock_skew, validate_offer_freshness, validate_traversal_answer_for_offer,
|
||||
};
|
||||
use super::stun::{parse_stun_binding_success, parse_stun_url};
|
||||
use super::traversal::{
|
||||
PunchStrategy, build_punch_packet, now_ms, parse_punch_packet, plan_punch_targets,
|
||||
planned_remote_endpoints, session_hash,
|
||||
PunchStrategy, build_punch_packet, is_doc_ip, is_never_punchable_ip, is_private_ip, now_ms,
|
||||
parse_punch_packet, plan_punch_targets, planned_remote_endpoints, session_hash,
|
||||
};
|
||||
use super::traversal_machine::suppress_responder_for_own_initiator;
|
||||
use super::types::BootstrapError;
|
||||
use super::{
|
||||
ADVERT_IDENTIFIER, ADVERT_KIND, ADVERT_VERSION, OverlayAdvert, OverlayEndpointAdvert,
|
||||
OverlayTransportKind, PunchHint, PunchPacketKind, TraversalAddress,
|
||||
@@ -384,7 +389,7 @@ fn rejects_answer_with_mismatched_actual_sender() {
|
||||
|
||||
#[test]
|
||||
fn plans_reflexive_targets_before_lan() {
|
||||
let planned = plan_punch_targets(
|
||||
let (planned, _tally) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("192.168.1.20", 63000)],
|
||||
@@ -397,7 +402,7 @@ fn plans_reflexive_targets_before_lan() {
|
||||
|
||||
#[test]
|
||||
fn simulated_lan_scenario_includes_lan_target_and_succeeds() {
|
||||
let planned = plan_punch_targets(
|
||||
let (planned, _tally) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("192.168.1.20", 63000)],
|
||||
@@ -414,7 +419,7 @@ fn simulated_lan_scenario_includes_lan_target_and_succeeds() {
|
||||
|
||||
#[test]
|
||||
fn simulated_symmetric_nat_scenario_requires_fallback() {
|
||||
let planned = plan_punch_targets(
|
||||
let (planned, _tally) = plan_punch_targets(
|
||||
&[addr("10.0.0.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("10.0.1.10", 63000)],
|
||||
@@ -431,7 +436,7 @@ fn simulated_symmetric_nat_scenario_requires_fallback() {
|
||||
|
||||
#[test]
|
||||
fn planned_remote_endpoints_include_private_and_reflexive_paths() {
|
||||
let endpoints = planned_remote_endpoints(
|
||||
let (endpoints, _tally) = planned_remote_endpoints(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("192.168.1.20", 63000)],
|
||||
@@ -443,6 +448,245 @@ fn planned_remote_endpoints_include_private_and_reflexive_paths() {
|
||||
assert!(endpoints.contains(&"198.51.100.20:63000".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_remote_endpoints_reject_never_punchable_remote_candidates() {
|
||||
// An empty local list is the shipped `share_local_candidates=false`
|
||||
// shape, where every non-reflexive target comes from the ungated
|
||||
// reflexive-to-remote-candidate pairing.
|
||||
let (endpoints, _tally) = planned_remote_endpoints(
|
||||
&[],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[
|
||||
addr("127.0.0.1", 63000),
|
||||
addr("224.0.0.1", 63000),
|
||||
addr("169.254.1.1", 63000),
|
||||
addr("255.255.255.255", 63000),
|
||||
addr("0.0.0.0", 63000),
|
||||
addr("100.64.1.2", 63000),
|
||||
addr("8.8.8.8", 0),
|
||||
],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
)
|
||||
.expect("endpoint planning should succeed");
|
||||
|
||||
assert_eq!(
|
||||
endpoints,
|
||||
vec!["198.51.100.20:63000".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_remote_endpoints_drop_private_candidate_outside_our_subnet() {
|
||||
let (endpoints, _tally) = planned_remote_endpoints(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("10.9.9.9", 63000)],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
)
|
||||
.expect("endpoint planning should succeed");
|
||||
|
||||
assert!(!endpoints.contains(&"10.9.9.9:63000".parse().unwrap()));
|
||||
assert!(endpoints.contains(&"198.51.100.20:63000".parse().unwrap()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_remote_endpoints_reject_ipv4_mapped_private_candidate() {
|
||||
let (endpoints, _tally) = planned_remote_endpoints(
|
||||
&[],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("::ffff:10.0.0.1", 63000)],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
)
|
||||
.expect("endpoint planning should succeed");
|
||||
|
||||
assert_eq!(
|
||||
endpoints,
|
||||
vec!["198.51.100.20:63000".parse::<SocketAddr>().unwrap()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn planned_remote_endpoints_cap_targets_from_an_oversized_candidate_list() {
|
||||
// Public candidates throughout, so the cap and not the address filter is
|
||||
// what bounds the result.
|
||||
let mut remotes = Vec::new();
|
||||
for host in 1..=150u8 {
|
||||
remotes.push(addr(&format!("203.0.113.{host}"), 63000));
|
||||
remotes.push(addr(&format!("198.51.100.{host}"), 63000));
|
||||
}
|
||||
|
||||
let (endpoints, tally) = planned_remote_endpoints(
|
||||
&[],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&remotes,
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
)
|
||||
.expect("endpoint planning should succeed");
|
||||
|
||||
assert!(tally.capped > 0, "the cap should have discarded targets");
|
||||
assert!(tally.suspicious());
|
||||
assert!(
|
||||
endpoints.len() <= 8,
|
||||
"expected at most 8 endpoints, got {}",
|
||||
endpoints.len()
|
||||
);
|
||||
}
|
||||
|
||||
/// Guards the deployment whose STUN server sits inside the private network,
|
||||
/// so the observed reflexive address is itself private. Applying the /24 gate
|
||||
/// to a peer's reflexive address would drop it and remove the only branch
|
||||
/// that works across arbitrary NATs; this test reds if anyone does that.
|
||||
#[test]
|
||||
fn planned_remote_endpoints_keep_private_reflexive_when_stun_is_on_the_lan() {
|
||||
let (endpoints, _tally) = planned_remote_endpoints(
|
||||
&[],
|
||||
Some(&addr("192.168.1.10", 62000)),
|
||||
&[],
|
||||
Some(&addr("192.168.1.20", 63000)),
|
||||
)
|
||||
.expect("endpoint planning should succeed");
|
||||
|
||||
assert!(endpoints.contains(&"192.168.1.20:63000".parse().unwrap()));
|
||||
}
|
||||
|
||||
/// The four refusal classes tell four different operational stories, so a
|
||||
/// change that collapses them into one counter, or that makes the warning
|
||||
/// fire on the benign dual-homed shape, has to red here.
|
||||
#[test]
|
||||
fn refused_punch_candidates_are_counted_by_class_and_sampled() {
|
||||
let (_planned, tally) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[
|
||||
addr("127.0.0.1", 63000),
|
||||
addr("203.0.113.5", 0),
|
||||
addr("10.9.9.9", 63000),
|
||||
addr("not-an-ip", 63000),
|
||||
],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
);
|
||||
|
||||
assert_eq!(tally.offered, 5);
|
||||
assert_eq!(tally.unroutable, 1);
|
||||
assert_eq!(tally.zeroport, 1);
|
||||
assert_eq!(tally.offsubnet, 1);
|
||||
assert_eq!(tally.unparsable, 1);
|
||||
assert_eq!(tally.sample.as_deref(), Some("127.0.0.1:63000"));
|
||||
assert_eq!(tally.reflexive, None);
|
||||
assert!(tally.admitted > 0, "the reflexive path should still plan");
|
||||
assert!(tally.suspicious());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_clean_plan_and_an_off_subnet_only_plan_are_not_suspicious() {
|
||||
let (_planned, clean) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("192.168.1.20", 63000)],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
);
|
||||
assert_eq!(clean.offsubnet, 0);
|
||||
assert!(!clean.suspicious());
|
||||
|
||||
let (_planned, off_subnet) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("10.9.9.9", 63000)],
|
||||
Some(&addr("198.51.100.20", 63000)),
|
||||
);
|
||||
assert_eq!(off_subnet.offsubnet, 1);
|
||||
assert!(off_subnet.admitted > 0);
|
||||
assert!(!off_subnet.suspicious());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_offer_whose_every_candidate_is_refused_is_suspicious() {
|
||||
let (planned, tally) = plan_punch_targets(
|
||||
&[],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("127.0.0.1", 63000), addr("224.0.0.1", 63000)],
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(planned.is_empty());
|
||||
assert_eq!(tally.admitted, 0);
|
||||
assert_eq!(tally.unroutable, 2);
|
||||
assert!(tally.suspicious());
|
||||
}
|
||||
|
||||
/// A peer's reflexive address is refused on its own terms: losing it removes
|
||||
/// the only branch that works across arbitrary NATs, so it is recorded apart
|
||||
/// from the host-candidate counts.
|
||||
#[test]
|
||||
fn a_refused_reflexive_address_is_recorded_apart_from_the_candidates() {
|
||||
let (_planned, tally) = plan_punch_targets(
|
||||
&[addr("192.168.1.10", 62000)],
|
||||
Some(&addr("203.0.113.10", 62000)),
|
||||
&[addr("192.168.1.20", 63000)],
|
||||
Some(&addr("127.0.0.1", 63000)),
|
||||
);
|
||||
|
||||
assert_eq!(tally.reflexive, Some("never-routable"));
|
||||
assert_eq!(tally.unroutable, 0);
|
||||
assert_eq!(tally.sample.as_deref(), Some("127.0.0.1:63000"));
|
||||
assert!(tally.suspicious());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_address_predicates_match_the_old_advert_predicate() {
|
||||
// Expected values are hand-derived from the single disjunction the advert
|
||||
// filter used before the split, which is the spec for this refactor.
|
||||
let cases = [
|
||||
("127.0.0.1", true),
|
||||
("::1", true),
|
||||
("0.0.0.0", true),
|
||||
("::", true),
|
||||
("224.0.0.1", true),
|
||||
("ff02::1", true),
|
||||
("255.255.255.255", true),
|
||||
("169.254.1.1", true),
|
||||
("fe80::1", true),
|
||||
("192.0.2.1", true),
|
||||
("198.51.100.1", true),
|
||||
("203.0.113.1", true),
|
||||
("100.64.0.1", true),
|
||||
("100.127.255.255", true),
|
||||
("100.128.0.1", false),
|
||||
("10.0.0.1", true),
|
||||
("192.168.1.1", true),
|
||||
("172.16.0.1", true),
|
||||
("fd00::1", true),
|
||||
("8.8.8.8", false),
|
||||
("2001:4860:4860::8888", false),
|
||||
];
|
||||
|
||||
for (text, expected) in cases {
|
||||
let ip = text.parse::<IpAddr>().unwrap();
|
||||
assert_eq!(
|
||||
is_unroutable_direct_advert_ip(ip),
|
||||
expected,
|
||||
"unexpected advert verdict for {text}"
|
||||
);
|
||||
assert_eq!(
|
||||
is_never_punchable_ip(ip) || is_private_ip(ip) || is_doc_ip(ip),
|
||||
expected,
|
||||
"the split predicates disagree with the advert filter for {text}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// The documentation ranges are held out of the punch filter deliberately, so
|
||||
/// that the reflexive addresses these tests and lab topologies use as public
|
||||
/// stand-ins keep working. An advert must still not name one.
|
||||
#[test]
|
||||
fn documentation_addresses_are_punchable_but_not_advertisable() {
|
||||
let ip = "198.51.100.20".parse::<IpAddr>().unwrap();
|
||||
|
||||
assert!(!is_never_punchable_ip(ip));
|
||||
assert!(!is_private_ip(ip));
|
||||
assert!(is_unroutable_direct_advert_ip(ip));
|
||||
}
|
||||
|
||||
/// B4: strict-fresh path returns Fresh; the offer is well within TTL and
|
||||
/// not expired.
|
||||
#[test]
|
||||
@@ -527,6 +771,240 @@ fn freshness_responder_clock_far_ahead_is_rejected() {
|
||||
assert!(err.to_string().contains("expired-offer"), "{}", err);
|
||||
}
|
||||
|
||||
/// An offer dated far ahead of the local clock is rejected. The age term
|
||||
/// saturates to zero for any future stamp, so nothing but the forward bound
|
||||
/// can catch this.
|
||||
#[test]
|
||||
fn freshness_offer_dated_far_in_the_future_is_rejected() {
|
||||
let offer = create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_600_000,
|
||||
60_000,
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
None,
|
||||
);
|
||||
|
||||
// Issued ten minutes ahead of the validating clock.
|
||||
let err = validate_offer_freshness(
|
||||
&offer,
|
||||
1_700_000_000_000,
|
||||
60_000,
|
||||
"npub1client",
|
||||
"npub1server",
|
||||
)
|
||||
.expect_err("offer dated far in the future should be rejected");
|
||||
assert!(err.to_string().contains("future-dated-offer"), "{}", err);
|
||||
}
|
||||
|
||||
/// An offer dated slightly ahead of the local clock is accepted, but reports
|
||||
/// the skew outcome so the operator-facing clock-skew log fires. It must not
|
||||
/// report strict freshness.
|
||||
#[test]
|
||||
fn freshness_offer_dated_slightly_in_the_future_reports_skew_tolerance() {
|
||||
let offer = create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_010_000,
|
||||
60_000,
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
None,
|
||||
);
|
||||
|
||||
// Issued 10s ahead, inside the 60s tolerance.
|
||||
let result = validate_offer_freshness(
|
||||
&offer,
|
||||
1_700_000_000_000,
|
||||
60_000,
|
||||
"npub1client",
|
||||
"npub1server",
|
||||
)
|
||||
.expect("offer inside the forward tolerance should be accepted");
|
||||
assert_eq!(result, FreshnessOutcome::FreshWithinSkewTolerance);
|
||||
}
|
||||
|
||||
/// The wire `expires_at` cannot widen the window past the issuer's own stamp
|
||||
/// plus our configured TTL. A sender declaring a 600s expiry gets the same
|
||||
/// treatment at our 60s TTL boundary as one declaring 60s.
|
||||
#[test]
|
||||
fn freshness_ignores_an_expires_at_inflated_beyond_issued_at_plus_ttl() {
|
||||
let offer = create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_000_000,
|
||||
600_000, // expires_at = 1_700_000_600_000, ten times our TTL
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
None,
|
||||
);
|
||||
|
||||
// Age exactly our TTL: the clamped expiry equals now, so strict freshness
|
||||
// cannot fire and the tolerated branch accepts.
|
||||
let result = validate_offer_freshness(
|
||||
&offer,
|
||||
1_700_000_060_000,
|
||||
60_000,
|
||||
"npub1client",
|
||||
"npub1server",
|
||||
)
|
||||
.expect("offer at the clamped expiry should still be tolerated");
|
||||
assert_eq!(result, FreshnessOutcome::FreshWithinSkewTolerance);
|
||||
}
|
||||
|
||||
/// Pins the forward bound to `FRESHNESS_SKEW_TOLERANCE_MS` exactly: 60_000ms
|
||||
/// ahead is accepted, 60_001ms ahead is not.
|
||||
#[test]
|
||||
fn freshness_offer_at_the_forward_skew_limit_is_accepted_and_one_ms_beyond_is_rejected() {
|
||||
let now = 1_700_000_000_000;
|
||||
let build = |issued: u64| {
|
||||
create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
issued,
|
||||
60_000,
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
None,
|
||||
)
|
||||
};
|
||||
|
||||
let at_limit = build(now + 60_000);
|
||||
let result = validate_offer_freshness(&at_limit, now, 60_000, "npub1client", "npub1server")
|
||||
.expect("offer exactly at the forward tolerance should be accepted");
|
||||
assert_eq!(result, FreshnessOutcome::FreshWithinSkewTolerance);
|
||||
|
||||
let past_limit = build(now + 60_001);
|
||||
let err = validate_offer_freshness(&past_limit, now, 60_000, "npub1client", "npub1server")
|
||||
.expect_err("offer one millisecond past the forward tolerance should be rejected");
|
||||
assert!(err.to_string().contains("future-dated-offer"), "{}", err);
|
||||
}
|
||||
|
||||
/// The forward bound covers the answer path too. The initiator is the side
|
||||
/// that binds a socket and punches on an accepted answer, so a future-dated
|
||||
/// answer is the more consequential half.
|
||||
#[test]
|
||||
fn freshness_answer_dated_far_in_the_future_is_rejected() {
|
||||
let offer = create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_000_000,
|
||||
60_000,
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
Some("stun:example.org:3478".to_string()),
|
||||
);
|
||||
let answer = create_traversal_answer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_600_000, // ten minutes ahead of the validating clock
|
||||
60_000,
|
||||
"answer-1".to_string(),
|
||||
"npub1server".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"offer-1".to_string(),
|
||||
true,
|
||||
Some(addr("198.51.100.20", 63000)),
|
||||
vec![addr("192.168.1.20", 63000)],
|
||||
Some("stun:example.org:3478".to_string()),
|
||||
Some(PunchHint {
|
||||
start_at_ms: 1_700_000_002_000,
|
||||
interval_ms: 200,
|
||||
duration_ms: 10_000,
|
||||
}),
|
||||
None,
|
||||
Some(1_700_000_000_400),
|
||||
);
|
||||
|
||||
let err = validate_traversal_answer_for_offer(
|
||||
&offer,
|
||||
&answer,
|
||||
1_700_000_000_900,
|
||||
60_000,
|
||||
"npub1server",
|
||||
"npub1client",
|
||||
)
|
||||
.expect_err("answer dated far in the future should be rejected");
|
||||
assert!(err.to_string().contains("future-dated-answer"), "{}", err);
|
||||
}
|
||||
|
||||
/// The answer path re-checks our own offer, and a failure there must be
|
||||
/// reported as the offer's, not the answer's, so the operator can tell a
|
||||
/// backwards local clock step from a bad reply.
|
||||
#[test]
|
||||
fn answer_validation_reports_a_stale_offer_as_the_offers_own_failure() {
|
||||
let offer = create_traversal_offer(
|
||||
"sess-1".to_string(),
|
||||
1_700_000_000_000,
|
||||
60_000,
|
||||
"offer-1".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"npub1server".to_string(),
|
||||
Some(addr("203.0.113.10", 62000)),
|
||||
vec![addr("192.168.1.10", 62000)],
|
||||
None,
|
||||
);
|
||||
// The answer is issued now; only the offer is beyond TTL + tolerance.
|
||||
let now = 1_700_000_130_000;
|
||||
let answer = create_traversal_answer(
|
||||
"sess-1".to_string(),
|
||||
now,
|
||||
60_000,
|
||||
"answer-1".to_string(),
|
||||
"npub1server".to_string(),
|
||||
"npub1client".to_string(),
|
||||
"offer-1".to_string(),
|
||||
true,
|
||||
Some(addr("198.51.100.20", 63000)),
|
||||
vec![addr("192.168.1.20", 63000)],
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
|
||||
let err = validate_traversal_answer_for_offer(
|
||||
&offer,
|
||||
&answer,
|
||||
now,
|
||||
60_000,
|
||||
"npub1server",
|
||||
"npub1client",
|
||||
)
|
||||
.expect_err("an offer past tolerated expiry should reject the round trip");
|
||||
assert!(
|
||||
err.to_string().contains("expired-offer-in-answer"),
|
||||
"{}",
|
||||
err
|
||||
);
|
||||
}
|
||||
|
||||
/// Only the inbound-offer rejection classes that cannot be produced by relay
|
||||
/// delivery lag escalate to a warning; a stale offer stays quiet.
|
||||
#[test]
|
||||
fn only_the_non_lag_offer_rejections_escalate_to_a_warning() {
|
||||
let protocol = |reason: &str| BootstrapError::Protocol(reason.to_string());
|
||||
|
||||
assert!(adversarial_offer_reject(&protocol("future-dated-offer")));
|
||||
assert!(adversarial_offer_reject(&protocol("identity-mismatch")));
|
||||
assert!(adversarial_offer_reject(&protocol("invalid-offer")));
|
||||
|
||||
assert!(!adversarial_offer_reject(&protocol("expired-offer")));
|
||||
assert!(!adversarial_offer_reject(&BootstrapError::Nostr(
|
||||
"relay unreachable".to_string()
|
||||
)));
|
||||
}
|
||||
|
||||
/// B5a: the NTP-style skew estimator returns the responder's apparent
|
||||
/// clock offset relative to the initiator. Symmetric one-way delays of
|
||||
/// 50ms each plus a +500ms responder skew should yield ≈+500ms.
|
||||
@@ -960,3 +1438,15 @@ async fn nostr_liveness_reports_finished_once_the_handles_are_taken() {
|
||||
"no installed handles (post-shutdown) reads as finished"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn short_id_truncates_a_multibyte_session_id_on_a_character_boundary_without_panicking() {
|
||||
// The session id arrives as an unvalidated string in a remote party's JSON,
|
||||
// so a byte-index slice can land inside a multi-byte character. Byte 8 of
|
||||
// this input is the middle of the euro sign.
|
||||
assert_eq!(short_id("aaaaaaa\u{20AC}zzzz"), "aaaaaaa\u{20AC}");
|
||||
// Ascii behaviour is unchanged: long truncates to eight, short passes through.
|
||||
assert_eq!(short_id("abcdefghij"), "abcdefgh");
|
||||
assert_eq!(short_id("abc"), "abc");
|
||||
assert_eq!(short_id(""), "");
|
||||
}
|
||||
|
||||
+281
-23
@@ -1,4 +1,4 @@
|
||||
use std::net::SocketAddr;
|
||||
use std::net::{IpAddr, SocketAddr};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
|
||||
|
||||
@@ -22,6 +22,16 @@ pub(super) enum PunchStrategy {
|
||||
Mixed,
|
||||
}
|
||||
|
||||
/// Upper bound on the punch targets one session may plan.
|
||||
///
|
||||
/// The candidate generator (`local_addresses_from_port`) tops out near eight
|
||||
/// entries on a dual-stack host with four interfaces and is typically three or
|
||||
/// four, and an honest peer contributes one reflexive address plus the few
|
||||
/// candidates that share a /24 with us. Eight therefore covers every pairing a
|
||||
/// real session needs while bounding one accepted signal to 8 x 50 rounds =
|
||||
/// 400 packets, about 21 KB on the wire at 52 bytes each for IPv4.
|
||||
const MAX_PUNCH_TARGETS: usize = 8;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(super) struct PlannedPunchTarget {
|
||||
pub(super) strategy: PunchStrategy,
|
||||
@@ -29,6 +39,9 @@ pub(super) struct PlannedPunchTarget {
|
||||
pub(super) remote_source: AddressSource,
|
||||
pub(super) local: TraversalAddress,
|
||||
pub(super) remote: TraversalAddress,
|
||||
/// The remote address already parsed and canonicalized by `admit_remote`,
|
||||
/// so the endpoint list never has to re-parse peer-supplied text.
|
||||
pub(super) remote_ip: IpAddr,
|
||||
}
|
||||
|
||||
fn same_subnet_24(left: &TraversalAddress, right: &TraversalAddress) -> bool {
|
||||
@@ -37,13 +50,248 @@ fn same_subnet_24(left: &TraversalAddress, right: &TraversalAddress) -> bool {
|
||||
left_parts.len() == 4 && right_parts.len() == 4 && left_parts[..3] == right_parts[..3]
|
||||
}
|
||||
|
||||
/// Addresses that are never a plausible destination for a punch packet, for
|
||||
/// an advert endpoint or for anything else we would send to directly.
|
||||
///
|
||||
/// Private and unique-local ranges are deliberately absent: they are usable
|
||||
/// on a shared LAN, and `is_private_ip` covers them separately so each caller
|
||||
/// can decide whether a private destination makes sense for it. The
|
||||
/// documentation ranges are absent for the same reason, in `is_doc_ip`.
|
||||
pub(super) fn is_never_punchable_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => {
|
||||
v4.is_loopback()
|
||||
|| v4.is_link_local()
|
||||
|| v4.is_unspecified()
|
||||
|| v4.is_multicast()
|
||||
|| v4.is_broadcast()
|
||||
|| (v4.octets()[0] == 100 && (v4.octets()[1] & 0xc0) == 64)
|
||||
}
|
||||
IpAddr::V6(v6) => {
|
||||
v6.is_loopback()
|
||||
|| v6.is_unspecified()
|
||||
|| v6.is_multicast()
|
||||
|| (v6.segments()[0] & 0xffc0) == 0xfe80
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Addresses that only reach a host sharing our local network.
|
||||
pub(super) fn is_private_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_private(),
|
||||
IpAddr::V6(v6) => v6.is_unique_local(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The IPv4 documentation ranges: 192.0.2.0/24, 198.51.100.0/24 and
|
||||
/// 203.0.113.0/24.
|
||||
///
|
||||
/// Held apart from `is_never_punchable_ip` because these ranges stand in for
|
||||
/// public addresses throughout this crate's traversal tests and in lab
|
||||
/// topologies that route them internally. An advert must still not name one,
|
||||
/// so the advert filter keeps this term.
|
||||
pub(super) fn is_doc_ip(ip: IpAddr) -> bool {
|
||||
match ip {
|
||||
IpAddr::V4(v4) => v4.is_documentation(),
|
||||
IpAddr::V6(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Why one peer-supplied candidate was refused as a punch destination.
|
||||
///
|
||||
/// The four variants are the operationally distinct stories: malformed text,
|
||||
/// a port that can never be punched, an address that is never routable, and
|
||||
/// an otherwise valid private address on a network we are not attached to.
|
||||
/// They stay distinct because the response to each differs; the target cap is
|
||||
/// counted separately, since it is not a verdict on any one candidate.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(super) enum RejectClass {
|
||||
/// The address text did not parse as an IP address.
|
||||
Unparsable,
|
||||
/// The candidate named port 0.
|
||||
ZeroPort,
|
||||
/// The address is in a range we never punch (loopback, link-local,
|
||||
/// unspecified, multicast, broadcast or CGNAT).
|
||||
NeverRoutable,
|
||||
/// A private address that shares no /24 with any of our own addresses.
|
||||
OffSubnet,
|
||||
}
|
||||
|
||||
impl RejectClass {
|
||||
/// The stable field value naming this class in a log record.
|
||||
pub(super) fn label(&self) -> &'static str {
|
||||
match self {
|
||||
RejectClass::Unparsable => "unparsable",
|
||||
RejectClass::ZeroPort => "zeroport",
|
||||
RejectClass::NeverRoutable => "never-routable",
|
||||
RejectClass::OffSubnet => "off-subnet",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The per-class counts of one planning call, for a single aggregated log
|
||||
/// record.
|
||||
///
|
||||
/// Aggregated deliberately: `remote_addresses` is unbounded, so a record per
|
||||
/// refused candidate would turn a peer's oversized signal into log volume.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq)]
|
||||
pub(super) struct PunchTargetTally {
|
||||
/// Candidates the peer offered, including its reflexive address.
|
||||
pub(super) offered: usize,
|
||||
/// Targets planned after vetting and the cap.
|
||||
pub(super) admitted: usize,
|
||||
/// Candidates whose address text did not parse.
|
||||
pub(super) unparsable: usize,
|
||||
/// Candidates naming port 0.
|
||||
pub(super) zeroport: usize,
|
||||
/// Candidates in a never-routable range.
|
||||
pub(super) unroutable: usize,
|
||||
/// Private candidates sharing no /24 with us.
|
||||
pub(super) offsubnet: usize,
|
||||
/// Planned targets discarded by the target cap.
|
||||
pub(super) capped: usize,
|
||||
/// The class label that refused the peer's reflexive address, if it was
|
||||
/// refused. Held apart from the candidate counts because losing the
|
||||
/// reflexive branch removes every path that works across arbitrary NATs,
|
||||
/// which is a materially different story from losing a host candidate.
|
||||
pub(super) reflexive: Option<&'static str>,
|
||||
/// The first `ip:port` refused as never-routable or zero-port, kept to one
|
||||
/// entry so peer-supplied text cannot inflate the record.
|
||||
pub(super) sample: Option<String>,
|
||||
}
|
||||
|
||||
impl PunchTargetTally {
|
||||
/// Whether this planning call looks like an attack rather than a routine
|
||||
/// mismatch.
|
||||
///
|
||||
/// No honest implementation offers unparsable text, port 0, a
|
||||
/// never-routable address or more candidates than the cap allows, and an
|
||||
/// entirely refused offer is the reflector case itself. An off-subnet-only
|
||||
/// refusal is the ordinary dual-homed shape and is not suspicious.
|
||||
///
|
||||
/// A refused reflexive address always counts: the /24 gate does not apply
|
||||
/// to it, so the only ways it can be refused are the attacker-shaped ones.
|
||||
pub(super) fn suspicious(&self) -> bool {
|
||||
self.unroutable + self.zeroport + self.unparsable + self.capped > 0
|
||||
|| (self.offered > 0 && self.admitted == 0)
|
||||
|| self.reflexive.is_some()
|
||||
}
|
||||
|
||||
/// Record one refused candidate against its class, keeping the first
|
||||
/// sample.
|
||||
fn refuse(&mut self, class: RejectClass, candidate: &TraversalAddress) {
|
||||
match class {
|
||||
RejectClass::Unparsable => self.unparsable += 1,
|
||||
RejectClass::ZeroPort => self.zeroport += 1,
|
||||
RejectClass::NeverRoutable => self.unroutable += 1,
|
||||
RejectClass::OffSubnet => self.offsubnet += 1,
|
||||
}
|
||||
self.note(class, candidate);
|
||||
}
|
||||
|
||||
/// Record the refusal of the peer's reflexive address, which is counted
|
||||
/// on its own rather than with the host candidates.
|
||||
fn refuse_reflexive(&mut self, class: RejectClass, candidate: &TraversalAddress) {
|
||||
self.reflexive = Some(class.label());
|
||||
self.note(class, candidate);
|
||||
}
|
||||
|
||||
/// Keep the first never-routable or zero-port address seen, and only that
|
||||
/// one.
|
||||
fn note(&mut self, class: RejectClass, candidate: &TraversalAddress) {
|
||||
if self.sample.is_none()
|
||||
&& matches!(class, RejectClass::NeverRoutable | RejectClass::ZeroPort)
|
||||
{
|
||||
self.sample = Some(format!("{}:{}", candidate.ip, candidate.port));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse and vet one peer-supplied traversal candidate.
|
||||
///
|
||||
/// Returns the parsed address, or the class of the check that refused it.
|
||||
/// `lan_refs` are our own addresses that a private candidate must share a /24
|
||||
/// with. `apply_private_gate` is false for the peer's reflexive address: a
|
||||
/// STUN server inside the private network legitimately reports a private
|
||||
/// reflexive address, and dropping it would remove the only branch that works
|
||||
/// across arbitrary NATs.
|
||||
fn admit_remote(
|
||||
candidate: &TraversalAddress,
|
||||
lan_refs: &[TraversalAddress],
|
||||
apply_private_gate: bool,
|
||||
) -> Result<IpAddr, RejectClass> {
|
||||
// Canonicalize the IPv4-mapped form so `::ffff:10.0.0.1` cannot present
|
||||
// itself as a public v6 address and slip past the checks below.
|
||||
let ip = match candidate
|
||||
.ip
|
||||
.parse::<IpAddr>()
|
||||
.map_err(|_| RejectClass::Unparsable)?
|
||||
{
|
||||
IpAddr::V6(v6) => match v6.to_ipv4_mapped() {
|
||||
Some(v4) => IpAddr::V4(v4),
|
||||
None => IpAddr::V6(v6),
|
||||
},
|
||||
v4 => v4,
|
||||
};
|
||||
if candidate.port == 0 {
|
||||
return Err(RejectClass::ZeroPort);
|
||||
}
|
||||
if is_never_punchable_ip(ip) {
|
||||
return Err(RejectClass::NeverRoutable);
|
||||
}
|
||||
if apply_private_gate
|
||||
&& is_private_ip(ip)
|
||||
&& !lan_refs.iter().any(|our| same_subnet_24(our, candidate))
|
||||
{
|
||||
return Err(RejectClass::OffSubnet);
|
||||
}
|
||||
Ok(ip)
|
||||
}
|
||||
|
||||
pub(super) fn plan_punch_targets(
|
||||
local_addresses: &[TraversalAddress],
|
||||
local_reflexive_address: Option<&TraversalAddress>,
|
||||
remote_addresses: &[TraversalAddress],
|
||||
remote_reflexive_address: Option<&TraversalAddress>,
|
||||
) -> Vec<PlannedPunchTarget> {
|
||||
) -> (Vec<PlannedPunchTarget>, PunchTargetTally) {
|
||||
let mut planned = Vec::new();
|
||||
let mut tally = PunchTargetTally {
|
||||
offered: remote_addresses.len() + usize::from(remote_reflexive_address.is_some()),
|
||||
..PunchTargetTally::default()
|
||||
};
|
||||
|
||||
// Our own addresses a peer's private candidate has to share a /24 with.
|
||||
// The local reflexive address joins the set when it is itself private,
|
||||
// which is what keeps a LAN-STUN deployment able to match while the
|
||||
// shipped `share_local_candidates=false` leaves the local list empty.
|
||||
let mut lan_refs = local_addresses.to_vec();
|
||||
if let Some(reflexive) = local_reflexive_address
|
||||
&& reflexive.ip.parse::<IpAddr>().is_ok_and(is_private_ip)
|
||||
{
|
||||
lan_refs.push(reflexive.clone());
|
||||
}
|
||||
|
||||
// Everything on the remote side is peer-supplied, so it is vetted once
|
||||
// here and the branches below only ever see admitted candidates.
|
||||
let remote_reflexive =
|
||||
remote_reflexive_address.and_then(|remote| match admit_remote(remote, &lan_refs, false) {
|
||||
Ok(ip) => Some((remote, ip)),
|
||||
Err(class) => {
|
||||
tally.refuse_reflexive(class, remote);
|
||||
None
|
||||
}
|
||||
});
|
||||
let remote_candidates = remote_addresses
|
||||
.iter()
|
||||
.filter_map(|remote| match admit_remote(remote, &lan_refs, true) {
|
||||
Ok(ip) => Some((remote, ip)),
|
||||
Err(class) => {
|
||||
tally.refuse(class, remote);
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
let mut push_unique = |target: PlannedPunchTarget| {
|
||||
if !planned.iter().any(|existing| existing == &target) {
|
||||
@@ -55,13 +303,14 @@ pub(super) fn plan_punch_targets(
|
||||
// arbitrary network topologies. Try this before any host-candidate path
|
||||
// so we don't latch onto a misleading asymmetric route (e.g. an offer's
|
||||
// private host candidate that we can reach one-way via a routed VPN).
|
||||
if let (Some(local), Some(remote)) = (local_reflexive_address, remote_reflexive_address) {
|
||||
if let (Some(local), Some((remote, remote_ip))) = (local_reflexive_address, remote_reflexive) {
|
||||
push_unique(PlannedPunchTarget {
|
||||
strategy: PunchStrategy::Reflexive,
|
||||
local_source: AddressSource::Reflexive,
|
||||
remote_source: AddressSource::Reflexive,
|
||||
local: local.clone(),
|
||||
remote: remote.clone(),
|
||||
remote_ip,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -69,21 +318,22 @@ pub(super) fn plan_punch_targets(
|
||||
// Only fires when both sides exposed local candidates AND they share a
|
||||
// /24 prefix.
|
||||
for local in local_addresses {
|
||||
for remote in remote_addresses {
|
||||
for (remote, remote_ip) in &remote_candidates {
|
||||
if same_subnet_24(local, remote) {
|
||||
push_unique(PlannedPunchTarget {
|
||||
strategy: PunchStrategy::Lan,
|
||||
local_source: AddressSource::Local,
|
||||
remote_source: AddressSource::Local,
|
||||
local: local.clone(),
|
||||
remote: remote.clone(),
|
||||
remote: (*remote).clone(),
|
||||
remote_ip: *remote_ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mixed paths cover hairpin and one-side-public scenarios.
|
||||
if let Some(remote) = remote_reflexive_address {
|
||||
if let Some((remote, remote_ip)) = remote_reflexive {
|
||||
for local in local_addresses {
|
||||
push_unique(PlannedPunchTarget {
|
||||
strategy: PunchStrategy::Mixed,
|
||||
@@ -91,51 +341,56 @@ pub(super) fn plan_punch_targets(
|
||||
remote_source: AddressSource::Reflexive,
|
||||
local: local.clone(),
|
||||
remote: remote.clone(),
|
||||
remote_ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(local) = local_reflexive_address {
|
||||
for remote in remote_addresses {
|
||||
for (remote, remote_ip) in &remote_candidates {
|
||||
push_unique(PlannedPunchTarget {
|
||||
strategy: PunchStrategy::Mixed,
|
||||
local_source: AddressSource::Reflexive,
|
||||
remote_source: AddressSource::Local,
|
||||
local: local.clone(),
|
||||
remote: remote.clone(),
|
||||
remote: (*remote).clone(),
|
||||
remote_ip: *remote_ip,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
planned
|
||||
tally.capped = planned.len().saturating_sub(MAX_PUNCH_TARGETS);
|
||||
planned.truncate(MAX_PUNCH_TARGETS);
|
||||
tally.admitted = planned.len();
|
||||
(planned, tally)
|
||||
}
|
||||
|
||||
/// Socket addresses to punch, in plan order and deduplicated.
|
||||
///
|
||||
/// Every remote address is vetted and parsed during planning, so a malformed
|
||||
/// address in a peer's signal now costs that one candidate instead of failing
|
||||
/// the whole traversal. The `Result` is kept so the call sites are unchanged
|
||||
/// and a future check can fail the plan again.
|
||||
pub(super) fn planned_remote_endpoints(
|
||||
local_addresses: &[TraversalAddress],
|
||||
local_reflexive_address: Option<&TraversalAddress>,
|
||||
remote_addresses: &[TraversalAddress],
|
||||
remote_reflexive_address: Option<&TraversalAddress>,
|
||||
) -> Result<Vec<SocketAddr>, BootstrapError> {
|
||||
) -> Result<(Vec<SocketAddr>, PunchTargetTally), BootstrapError> {
|
||||
let mut remotes = Vec::new();
|
||||
for target in plan_punch_targets(
|
||||
let (planned, tally) = plan_punch_targets(
|
||||
local_addresses,
|
||||
local_reflexive_address,
|
||||
remote_addresses,
|
||||
remote_reflexive_address,
|
||||
) {
|
||||
let remote = SocketAddr::new(
|
||||
target
|
||||
.remote
|
||||
.ip
|
||||
.parse()
|
||||
.map_err(|_| BootstrapError::Protocol("invalid-remote-ip".to_string()))?,
|
||||
target.remote.port,
|
||||
);
|
||||
);
|
||||
for target in planned {
|
||||
let remote = SocketAddr::new(target.remote_ip, target.remote.port);
|
||||
if !remotes.contains(&remote) {
|
||||
remotes.push(remote);
|
||||
}
|
||||
}
|
||||
Ok(remotes)
|
||||
Ok((remotes, tally))
|
||||
}
|
||||
|
||||
pub(super) async fn run_punch_attempt(
|
||||
@@ -217,8 +472,11 @@ pub(super) fn nonce() -> String {
|
||||
/// that delay instead and can cost a single punch attempt, which retries. Early
|
||||
/// eviction from the replay window cannot admit a replay under the shipped
|
||||
/// defaults, because the freshness window a replayed offer would also have to
|
||||
/// satisfy (`signal_ttl_secs` plus `FRESHNESS_SKEW_TOLERANCE_MS`, 180s) is
|
||||
/// strictly narrower than the replay window itself (`replay_window_secs`, 300s).
|
||||
/// satisfy (`signal_ttl_secs` plus `FRESHNESS_SKEW_TOLERANCE_MS` on each side,
|
||||
/// 240s under the shipped defaults) is strictly narrower than the replay window
|
||||
/// itself (`replay_window_secs`, 300s). The margin holds while
|
||||
/// `signal_ttl_secs + 120 < replay_window_secs`; nothing in config validation
|
||||
/// enforces that relation today.
|
||||
pub(super) fn now_ms() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
|
||||
@@ -53,3 +53,32 @@ pub const MAX_SESSION_REPORT_INTERVAL_MS: u64 = 10_000;
|
||||
|
||||
/// Session-layer cold-start report interval (before SRTT is available).
|
||||
pub const SESSION_COLD_START_INTERVAL_MS: u64 = 1_000;
|
||||
|
||||
// --- Path MTU ---
|
||||
|
||||
/// Smallest remote-supplied transport path MTU this node will act on.
|
||||
///
|
||||
/// The `path_mtu` field is an unsigned per-hop transit annotation carried
|
||||
/// outside `proof_bytes`, and the `MtuExceeded` and `PathBroken` signals
|
||||
/// arrive unencrypted, so any forwarder on the path can lower it. Below this
|
||||
/// value the quantities derived from it degenerate: at a transport MTU of 137
|
||||
/// or less, [`mss_ceiling`] saturates to a TCP MSS of zero, at 138 it is a
|
||||
/// single byte, and the derived MSS stays under a hundred all the way to 236.
|
||||
/// At the floor itself the derived inner IPv6 MTU is 179 and the TCP MSS is
|
||||
/// 119, clear of both the zero cliff and that band.
|
||||
///
|
||||
/// A candidate below the floor is ignored — treated as no information at all,
|
||||
/// never applied and never stored — rather than clamped, because clamping
|
||||
/// would fabricate an estimate the node has no basis for. Locally derived link
|
||||
/// MTUs are not subject to the floor; it applies only to values a remote party
|
||||
/// supplied. A local value is exact, so the SYN-time clamp honours it however
|
||||
/// small and refuses only the zero cliff, which no provenance makes usable.
|
||||
///
|
||||
/// It lives here rather than beside the arithmetic that consumes it because it
|
||||
/// is a protocol policy decision — how little a remote party may claim before
|
||||
/// this node stops believing it — and the path-MTU state machine that owns
|
||||
/// that rule is in this module. The upper layer re-exports it, so
|
||||
/// `crate::upper::icmp::MIN_ACTIONABLE_PATH_MTU` continues to resolve.
|
||||
///
|
||||
/// [`mss_ceiling`]: crate::upper::icmp::mss_ceiling
|
||||
pub const MIN_ACTIONABLE_PATH_MTU: u16 = 256;
|
||||
|
||||
@@ -85,6 +85,6 @@ impl fmt::Display for MmpMode {
|
||||
pub use limits::{
|
||||
COLD_START_SAMPLES, DEFAULT_COLD_START_INTERVAL_MS, DEFAULT_LOG_INTERVAL_SECS,
|
||||
DEFAULT_OWD_WINDOW_SIZE, EWMA_LONG_ALPHA, EWMA_SHORT_ALPHA, MAX_REPORT_INTERVAL_MS,
|
||||
MAX_SESSION_REPORT_INTERVAL_MS, MIN_REPORT_INTERVAL_MS, MIN_SESSION_REPORT_INTERVAL_MS,
|
||||
SESSION_COLD_START_INTERVAL_MS,
|
||||
MAX_SESSION_REPORT_INTERVAL_MS, MIN_ACTIONABLE_PATH_MTU, MIN_REPORT_INTERVAL_MS,
|
||||
MIN_SESSION_REPORT_INTERVAL_MS, SESSION_COLD_START_INTERVAL_MS,
|
||||
};
|
||||
|
||||
@@ -129,7 +129,20 @@ impl PathMtuState {
|
||||
///
|
||||
/// `now_ms` is the injected monotonic time in milliseconds. Returns `true`
|
||||
/// if the effective MTU changed.
|
||||
///
|
||||
/// A reported value below [`MIN_ACTIONABLE_PATH_MTU`] is ignored entirely.
|
||||
/// The notification carries a remote party's claim about the path, and
|
||||
/// below that floor the claim cannot describe a usable path: acting on it
|
||||
/// drives the send gate into answering every packet with an ICMPv6 Packet
|
||||
/// Too Big instead of sending it. Returning `false` leaves whatever the
|
||||
/// local seed established and correctly reports "no change".
|
||||
///
|
||||
/// [`MIN_ACTIONABLE_PATH_MTU`]: super::limits::MIN_ACTIONABLE_PATH_MTU
|
||||
pub fn apply_notification(&mut self, reported_mtu: u16, now_ms: u64) -> bool {
|
||||
if reported_mtu < super::limits::MIN_ACTIONABLE_PATH_MTU {
|
||||
return false;
|
||||
}
|
||||
|
||||
if reported_mtu < self.current_mtu {
|
||||
// Decrease: immediate
|
||||
self.current_mtu = reported_mtu;
|
||||
@@ -141,7 +154,7 @@ impl PathMtuState {
|
||||
if reported_mtu > self.current_mtu {
|
||||
// Increase: track consecutive notifications
|
||||
if reported_mtu == self.pending_increase_mtu {
|
||||
self.consecutive_increase_count += 1;
|
||||
self.consecutive_increase_count = self.consecutive_increase_count.saturating_add(1);
|
||||
} else {
|
||||
// Different value: reset sequence
|
||||
self.pending_increase_mtu = reported_mtu;
|
||||
|
||||
@@ -2,5 +2,6 @@
|
||||
|
||||
mod algorithms;
|
||||
mod core;
|
||||
mod path_mtu;
|
||||
mod state;
|
||||
mod wire;
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! Source-side `PathMtuState::apply_notification` tests: the actionable floor
|
||||
//! on a remote-supplied value, and the increase-sequence counter.
|
||||
|
||||
use crate::proto::mmp::MIN_ACTIONABLE_PATH_MTU;
|
||||
use crate::proto::mmp::path_mtu::PathMtuState;
|
||||
|
||||
#[test]
|
||||
fn apply_notification_ignores_a_decrease_below_the_actionable_floor() {
|
||||
// The reported value comes from a remote party. Driving current_mtu into
|
||||
// this band turns the TUN send gate into a blackhole: every packet is
|
||||
// answered with a Packet Too Big instead of being sent. Sub-floor values
|
||||
// are ignored outright, not clamped, so the locally seeded value survives
|
||||
// untouched.
|
||||
for reported in [0u16, 1, 137, 138, 200, 255] {
|
||||
let mut state = PathMtuState::new();
|
||||
state.seed_source_mtu(1400);
|
||||
|
||||
let changed = state.apply_notification(reported, 1_000);
|
||||
|
||||
assert!(
|
||||
!changed,
|
||||
"reported path MTU {reported} is below the floor and must report no change"
|
||||
);
|
||||
assert_eq!(
|
||||
state.current_mtu(),
|
||||
1400,
|
||||
"reported path MTU {reported} must leave the seeded value intact"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_notification_accepts_a_decrease_at_the_actionable_floor() {
|
||||
// The floor must not swallow the smallest value the node does act on, nor
|
||||
// the legitimately narrow hops the mesh actually carries.
|
||||
for reported in [MIN_ACTIONABLE_PATH_MTU, 576, 800] {
|
||||
let mut state = PathMtuState::new();
|
||||
state.seed_source_mtu(1400);
|
||||
|
||||
let changed = state.apply_notification(reported, 1_000);
|
||||
|
||||
assert!(changed, "reported path MTU {reported} must be applied");
|
||||
assert_eq!(state.current_mtu(), reported);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn apply_notification_increase_counter_does_not_overflow_on_a_repeated_value() {
|
||||
// The counter is a u8 and resets only when the value changes or the
|
||||
// increase is accepted. Acceptance additionally requires the sequence to
|
||||
// span two notification intervals, so a peer repeating one higher value
|
||||
// fast enough stays in the increase branch indefinitely.
|
||||
let mut state = PathMtuState::new();
|
||||
state.seed_source_mtu(1000);
|
||||
|
||||
for _ in 0..600 {
|
||||
state.apply_notification(1200, 1_000);
|
||||
}
|
||||
|
||||
assert_eq!(
|
||||
state.current_mtu(),
|
||||
1000,
|
||||
"the increase is not yet due, so the effective MTU must be unchanged"
|
||||
);
|
||||
}
|
||||
+33
-4
@@ -16,6 +16,12 @@
|
||||
//! setters accept seconds (matching the node config) and store the value scaled
|
||||
//! to milliseconds so the comparisons stay in one unit.
|
||||
|
||||
/// Longest dampening episode representable on the monotonic clock, in
|
||||
/// milliseconds. A year is indistinguishable from permanent for this
|
||||
/// mechanism; the bound is what keeps a hostile `flap_dampening_secs` from
|
||||
/// overflowing the stamp.
|
||||
const MAX_FLAP_DAMPENING_MS: u64 = 365 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/// Flap-dampening / hold-down state for a node's parent selection.
|
||||
///
|
||||
/// Groups the flap-detection timers behind a single struct so the tree
|
||||
@@ -70,7 +76,17 @@ impl FlapDampener {
|
||||
) {
|
||||
self.flap_threshold = threshold;
|
||||
self.flap_window = window_secs.saturating_mul(1000);
|
||||
self.flap_dampening_duration = dampening_secs.saturating_mul(1000);
|
||||
self.flap_dampening_duration = dampening_secs
|
||||
.saturating_mul(1000)
|
||||
.min(MAX_FLAP_DAMPENING_MS);
|
||||
}
|
||||
|
||||
/// How long a dampening episode suppresses discretionary parent
|
||||
/// switching, in seconds, after the configured value is clamped.
|
||||
///
|
||||
/// Feeds the log field on the engagement warning.
|
||||
pub(crate) fn dampening_secs(&self) -> u64 {
|
||||
self.flap_dampening_duration / 1000
|
||||
}
|
||||
|
||||
/// Stamp the time of a parent switch (called on every `set_parent`,
|
||||
@@ -84,6 +100,17 @@ impl FlapDampener {
|
||||
/// Returns true if dampening was just engaged. `now_ms` is the injected
|
||||
/// monotonic time in milliseconds.
|
||||
pub(crate) fn record_parent_switch(&mut self, now_ms: u64) -> bool {
|
||||
// Retire a lapsed episode here rather than lazily. Clearing the
|
||||
// deadline and the counter together is what makes each episode cost a
|
||||
// fresh threshold of switches inside one window: switches taken during
|
||||
// an episode (mandatory ones bypass the veto) would otherwise carry
|
||||
// into the next window and re-engage on a single switch after lapse.
|
||||
if self.flap_dampening_until.is_some() && !self.is_flap_dampened(now_ms) {
|
||||
self.flap_dampening_until = None;
|
||||
self.flap_count = 0;
|
||||
self.flap_window_start = None;
|
||||
}
|
||||
|
||||
// Reset window if expired or not started
|
||||
match self.flap_window_start {
|
||||
Some(start) if now_ms.saturating_sub(start) < self.flap_window => {
|
||||
@@ -95,9 +122,11 @@ impl FlapDampener {
|
||||
}
|
||||
}
|
||||
|
||||
// Check threshold
|
||||
if self.flap_count >= self.flap_threshold && self.flap_dampening_until.is_none() {
|
||||
self.flap_dampening_until = Some(now_ms + self.flap_dampening_duration);
|
||||
// Check threshold. The dampening test is redundant with the retirement
|
||||
// above in this control flow; it is kept so the gate reads correctly on
|
||||
// its own and survives an edit that moves the retirement.
|
||||
if self.flap_count >= self.flap_threshold && !self.is_flap_dampened(now_ms) {
|
||||
self.flap_dampening_until = Some(now_ms.saturating_add(self.flap_dampening_duration));
|
||||
return true;
|
||||
}
|
||||
false
|
||||
|
||||
+49
-4
@@ -8,6 +8,20 @@ use super::limits::FlapDampener;
|
||||
use super::{CoordEntry, ParentDeclaration, TreeCoordinate};
|
||||
use crate::NodeAddr;
|
||||
|
||||
/// What a parent-loss recovery did: whether the tree state changed, and
|
||||
/// whether the recovery switch was the one that armed a dampening episode.
|
||||
///
|
||||
/// Crate-internal on purpose. The published entry point is
|
||||
/// [`TreeState::handle_parent_lost`], whose `bool` return this type must not
|
||||
/// displace.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub(crate) struct ParentLoss {
|
||||
/// Whether the tree state changed and the caller should re-announce.
|
||||
pub(crate) changed: bool,
|
||||
/// Whether the recovery switch armed a flap dampening episode.
|
||||
pub(crate) dampened: bool,
|
||||
}
|
||||
|
||||
/// Local spanning tree state for a node.
|
||||
///
|
||||
/// Contains this node's declaration, coordinates, and view of peers'
|
||||
@@ -347,6 +361,14 @@ impl TreeState {
|
||||
.set_flap_dampening(threshold, window_secs, dampening_secs);
|
||||
}
|
||||
|
||||
/// How long a dampening episode suppresses discretionary parent switching,
|
||||
/// after the configured value is clamped.
|
||||
///
|
||||
/// Crate-internal: it feeds a log field on the engagement warning.
|
||||
pub(crate) fn dampening_secs(&self) -> u64 {
|
||||
self.flap.dampening_secs()
|
||||
}
|
||||
|
||||
/// Check if flap dampening is currently active. `now_ms` is the injected
|
||||
/// monotonic time in milliseconds.
|
||||
pub fn is_flap_dampened(&self, now_ms: u64) -> bool {
|
||||
@@ -543,6 +565,22 @@ impl TreeState {
|
||||
now_secs: u64,
|
||||
now_ms: u64,
|
||||
) -> bool {
|
||||
self.recover(peer_costs, now_secs, now_ms).changed
|
||||
}
|
||||
|
||||
/// Handle loss of current parent, reporting whether the recovery switch
|
||||
/// itself armed a flap dampening episode.
|
||||
///
|
||||
/// Same recovery as [`TreeState::handle_parent_lost`], which delegates
|
||||
/// here. A caller holding a metrics handle uses this one so the
|
||||
/// engagement can be counted and logged; the published signature stays
|
||||
/// `bool`.
|
||||
pub(crate) fn recover(
|
||||
&mut self,
|
||||
peer_costs: &BTreeMap<NodeAddr, f64>,
|
||||
now_secs: u64,
|
||||
now_ms: u64,
|
||||
) -> ParentLoss {
|
||||
// Try to find an alternative parent. The veto is computed at the edge and
|
||||
// applied only to a discretionary result; a mandatory switch bypasses it.
|
||||
let suppressed = self.is_switch_suppressed(now_ms);
|
||||
@@ -553,16 +591,23 @@ impl TreeState {
|
||||
};
|
||||
if let Some(new_parent) = alt {
|
||||
let new_seq = self.my_declaration.sequence() + 1;
|
||||
self.set_parent(new_parent, new_seq, now_secs, now_ms);
|
||||
let dampened = self.set_parent(new_parent, new_seq, now_secs, now_ms);
|
||||
self.recompute_coords();
|
||||
return true;
|
||||
return ParentLoss {
|
||||
changed: true,
|
||||
dampened,
|
||||
};
|
||||
}
|
||||
|
||||
// No alternative: become own root
|
||||
// No alternative: become own root. This branch never calls
|
||||
// `set_parent`, so it cannot arm a dampening episode.
|
||||
let new_seq = self.my_declaration.sequence() + 1;
|
||||
self.my_declaration = ParentDeclaration::self_root(self.my_node_addr, new_seq, now_secs);
|
||||
self.recompute_coords();
|
||||
true
|
||||
ParentLoss {
|
||||
changed: true,
|
||||
dampened: false,
|
||||
}
|
||||
}
|
||||
|
||||
/// Mutable access to this node's declaration.
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
use alloc::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use super::util::{make_coords, make_costs, make_node_addr};
|
||||
use crate::NodeAddr;
|
||||
use crate::proto::stp::{ParentDeclaration, ParentEval, TreeState};
|
||||
|
||||
#[test]
|
||||
@@ -251,3 +252,193 @@ fn test_flap_dampening_same_parent_no_count() {
|
||||
// Should NOT be dampened since only the first was a real switch
|
||||
assert!(!state.is_flap_dampened(3000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flap_dampening_engages_a_second_time_after_first_episode_lapses() {
|
||||
// A lapsed episode must re-arm the mechanism: a second flap storm has to
|
||||
// engage dampening again, and must cost a fresh threshold of switches
|
||||
// rather than re-engaging on the first switch after the lapse. The
|
||||
// injected clock is advanced past the deadline instead of using a
|
||||
// zero-length episode, so the retirement is driven by a genuinely expired
|
||||
// episode.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node, 1000);
|
||||
state.set_flap_dampening(3, 60, 10);
|
||||
state.set_hold_down(0);
|
||||
|
||||
let peer_a = make_node_addr(1);
|
||||
let peer_b = make_node_addr(2);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[1, 0]),
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[2, 0]),
|
||||
);
|
||||
|
||||
// First episode: three switches inside the window reach the threshold.
|
||||
let first = state.set_parent(peer_a, 1, 1000, 1000);
|
||||
state.recompute_coords();
|
||||
let second = state.set_parent(peer_b, 2, 2000, 2000);
|
||||
state.recompute_coords();
|
||||
let third = state.set_parent(peer_a, 3, 3000, 3000);
|
||||
state.recompute_coords();
|
||||
|
||||
assert!(!first);
|
||||
assert!(!second);
|
||||
assert!(third, "first episode must engage at threshold");
|
||||
assert!(state.is_flap_dampened(3000));
|
||||
|
||||
// The episode was stamped at 3000 for 10s, so it has lapsed by 14000.
|
||||
assert!(!state.is_flap_dampened(14_000));
|
||||
|
||||
// Second episode: a fresh threshold of switches is required, so the first
|
||||
// two switches after the lapse must not re-engage.
|
||||
let fourth = state.set_parent(peer_b, 4, 4000, 14_000);
|
||||
state.recompute_coords();
|
||||
let fifth = state.set_parent(peer_a, 5, 5000, 15_000);
|
||||
state.recompute_coords();
|
||||
let sixth = state.set_parent(peer_b, 6, 6000, 16_000);
|
||||
state.recompute_coords();
|
||||
|
||||
assert!(!fourth, "a lapsed episode must not re-engage on one switch");
|
||||
assert!(
|
||||
!fifth,
|
||||
"a lapsed episode must not re-engage below threshold"
|
||||
);
|
||||
assert!(sixth, "a second episode must engage after the first lapses");
|
||||
assert!(state.is_flap_dampened(16_000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_flap_dampening_duration_at_u64_max_does_not_panic() {
|
||||
// A hostile node.tree.flap_dampening_secs must be clamped rather than
|
||||
// overflow the monotonic stamp when an episode engages. Unclamped, the
|
||||
// seconds-to-milliseconds conversion saturates at u64::MAX and the
|
||||
// deadline arithmetic then overflows on the switch that engages.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node, 1000);
|
||||
state.set_flap_dampening(3, 60, u64::MAX);
|
||||
state.set_hold_down(0);
|
||||
|
||||
let peer_a = make_node_addr(1);
|
||||
let peer_b = make_node_addr(2);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[1, 0]),
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[2, 0]),
|
||||
);
|
||||
|
||||
state.set_parent(peer_a, 1, 1000, 1000);
|
||||
state.recompute_coords();
|
||||
state.set_parent(peer_b, 2, 2000, 2000);
|
||||
state.recompute_coords();
|
||||
let dampened = state.set_parent(peer_a, 3, 3000, 3000);
|
||||
state.recompute_coords();
|
||||
|
||||
assert!(dampened, "the threshold switch must still engage dampening");
|
||||
assert!(state.is_flap_dampened(3000));
|
||||
// Clamped to a year, which is what the episode reports to its log field.
|
||||
assert_eq!(state.dampening_secs(), 365 * 24 * 60 * 60);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_loss_recovery_reports_the_engagement_that_arms_dampening() {
|
||||
// A parent-loss storm engages dampening on a mandatory recovery switch,
|
||||
// which bypasses the veto but still feeds the flap counter. The recovery
|
||||
// must report that engagement so the shell, which is the side holding a
|
||||
// metrics handle, can surface it.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node, 1000);
|
||||
state.set_flap_dampening(2, 60, 120);
|
||||
state.set_hold_down(0);
|
||||
|
||||
let peer_a = make_node_addr(1);
|
||||
let peer_b = make_node_addr(2);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[1, 0]),
|
||||
);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_b, root, 1, 1000),
|
||||
make_coords(&[2, 0]),
|
||||
);
|
||||
|
||||
// One switch short of the threshold.
|
||||
let first = state.set_parent(peer_a, 1, 1000, 1000);
|
||||
state.recompute_coords();
|
||||
assert!(!first, "one switch is below the threshold");
|
||||
|
||||
// Parent disappears; recovery picks peer_b and crosses the threshold.
|
||||
state.remove_peer(&peer_a);
|
||||
let outcome = state.recover(&BTreeMap::new(), 2000, 2000);
|
||||
|
||||
assert!(outcome.changed);
|
||||
assert_eq!(state.my_declaration().parent_id(), &peer_b);
|
||||
assert!(
|
||||
outcome.dampened,
|
||||
"parent-loss recovery must report the engagement it armed"
|
||||
);
|
||||
assert!(state.is_flap_dampened(2000));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_parent_loss_recovery_to_self_root_reports_no_engagement() {
|
||||
// The self-root fallthrough takes no parent switch, so it can never arm
|
||||
// an episode and must never report one.
|
||||
let my_node = make_node_addr(5);
|
||||
let mut state = TreeState::new(my_node, 1000);
|
||||
state.set_flap_dampening(1, 60, 120);
|
||||
state.set_hold_down(0);
|
||||
|
||||
let peer_a = make_node_addr(1);
|
||||
let root = make_node_addr(0);
|
||||
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer_a, root, 1, 1000),
|
||||
make_coords(&[1, 0]),
|
||||
);
|
||||
state.set_parent(peer_a, 1, 1000, 1000);
|
||||
state.recompute_coords();
|
||||
|
||||
state.remove_peer(&peer_a);
|
||||
let outcome = state.recover(&BTreeMap::new(), 2000, 2000);
|
||||
|
||||
assert!(outcome.changed);
|
||||
assert!(state.is_root());
|
||||
assert!(!outcome.dampened, "self-root recovery arms no episode");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_handle_parent_lost_keeps_its_published_bool_return() {
|
||||
// `TreeState` is published, so the return type of this entry point is part
|
||||
// of the API and the richer `ParentLoss` must not displace it. Binding the
|
||||
// method to an explicitly typed function pointer is the assertion: any
|
||||
// other return type fails to compile. Calling through the pointer keeps
|
||||
// the binding live.
|
||||
let published: fn(&mut TreeState, &BTreeMap<NodeAddr, f64>, u64, u64) -> bool =
|
||||
TreeState::handle_parent_lost;
|
||||
|
||||
let mut state = TreeState::new(make_node_addr(5), 1000);
|
||||
let peer = make_node_addr(1);
|
||||
state.update_peer(
|
||||
ParentDeclaration::new(peer, make_node_addr(0), 1, 1000),
|
||||
make_coords(&[1, 0]),
|
||||
);
|
||||
state.set_parent(peer, 1, 1000, 1000);
|
||||
state.recompute_coords();
|
||||
|
||||
state.remove_peer(&peer);
|
||||
assert!(published(&mut state, &BTreeMap::new(), 2000, 2000));
|
||||
assert!(state.is_root());
|
||||
}
|
||||
|
||||
@@ -103,6 +103,15 @@ pub const FIPS_OVERHEAD: u16 = 16 + 16 + 5 + 35 + 12 + 6 + 16; // 106 bytes
|
||||
/// ```
|
||||
pub const FIPS_IPV6_OVERHEAD: u16 = 77;
|
||||
|
||||
/// Smallest remote-supplied transport path MTU this node will act on.
|
||||
///
|
||||
/// Re-exported: the value is a protocol policy decision and is defined beside
|
||||
/// the path-MTU state machine that owns it, in
|
||||
/// [`crate::proto::mmp::MIN_ACTIONABLE_PATH_MTU`]. It is named from here
|
||||
/// because every site that applies it — the MSS clamp, the lookup response and
|
||||
/// the `MtuExceeded` signal — reaches it through this module.
|
||||
pub use crate::proto::mmp::MIN_ACTIONABLE_PATH_MTU;
|
||||
|
||||
/// Calculate the effective IPv6 MTU for FIPS-encapsulated traffic.
|
||||
///
|
||||
/// Given a transport MTU (e.g., UDP payload size), returns the maximum
|
||||
@@ -112,6 +121,21 @@ pub fn effective_ipv6_mtu(transport_mtu: u16) -> u16 {
|
||||
transport_mtu.saturating_sub(FIPS_IPV6_OVERHEAD)
|
||||
}
|
||||
|
||||
/// Largest TCP segment size a FIPS-encapsulated path of `transport_mtu`
|
||||
/// bytes on the wire admits: the effective inner IPv6 MTU less the 40-byte
|
||||
/// IPv6 header and the 20-byte TCP header.
|
||||
///
|
||||
/// Zero means the path has no room for even one payload byte, so no TCP
|
||||
/// segment fits and no clamp derived from it carries information. That is
|
||||
/// the one condition the SYN-time clamp treats as unusable regardless of
|
||||
/// where the MTU came from, and it is why the seed site warns; both read it
|
||||
/// from here so they cannot disagree about where the cliff is.
|
||||
pub fn mss_ceiling(transport_mtu: u16) -> u16 {
|
||||
effective_ipv6_mtu(transport_mtu)
|
||||
.saturating_sub(40)
|
||||
.saturating_sub(20)
|
||||
}
|
||||
|
||||
/// Check if we should send an ICMPv6 error for this packet.
|
||||
///
|
||||
/// Returns false if the packet is:
|
||||
|
||||
@@ -52,6 +52,12 @@ pub fn clamp_tcp_mss(ipv6_packet: &mut [u8], max_mss: u16) -> bool {
|
||||
return false;
|
||||
}
|
||||
|
||||
// A ceiling of zero carries no information and an MSS option of zero is
|
||||
// not a legal segment size. Refuse the clamp rather than write it.
|
||||
if max_mss == 0 {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Get TCP header start
|
||||
let tcp_start = 40;
|
||||
if ipv6_packet.len() < tcp_start + TCP_HEADER_MIN_LEN {
|
||||
@@ -236,6 +242,24 @@ mod tests {
|
||||
assert_eq!(mss, 1200);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clamp_tcp_mss_with_zero_ceiling_leaves_mss_option_untouched() {
|
||||
// A ceiling of zero reaches here only when something upstream
|
||||
// degenerated. Writing it would put an MSS of 0 in the SYN and wedge
|
||||
// the flow, so the clamp must refuse and report that it did nothing.
|
||||
let src = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
|
||||
let dst = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 2];
|
||||
let mut packet = make_tcp_syn_packet(src, dst, 1460);
|
||||
|
||||
let modified = clamp_tcp_mss(&mut packet, 0);
|
||||
|
||||
assert!(!modified, "a zero ceiling must not count as a clamp");
|
||||
|
||||
let tcp_start = 40;
|
||||
let mss = u16::from_be_bytes([packet[tcp_start + 22], packet[tcp_start + 23]]);
|
||||
assert_eq!(mss, 1460, "MSS option must be left exactly as it arrived");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_clamp_tcp_mss_leaves_small_mss_unchanged() {
|
||||
let src = [0xfd, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 1];
|
||||
|
||||
+106
-7
@@ -72,15 +72,13 @@ pub(crate) fn per_flow_max_mss(
|
||||
addr_bytes: &[u8],
|
||||
global_max_mss: u16,
|
||||
) -> u16 {
|
||||
use super::icmp::effective_ipv6_mtu;
|
||||
use super::icmp::mss_ceiling;
|
||||
|
||||
// RFC 8200 IPv6-minimum MTU (1280) → effective FIPS-encapsulated
|
||||
// payload (1203) → TCP segment after IPv6+TCP headers (1143).
|
||||
// Used as the conservative ceiling for empty-lookup destinations.
|
||||
const IPV6_MIN_MTU: u16 = 1280;
|
||||
let conservative_max_mss = effective_ipv6_mtu(IPV6_MIN_MTU)
|
||||
.saturating_sub(40)
|
||||
.saturating_sub(20);
|
||||
let conservative_max_mss = mss_ceiling(IPV6_MIN_MTU);
|
||||
let empty_lookup_ceiling = std::cmp::min(global_max_mss, conservative_max_mss);
|
||||
|
||||
if addr_bytes.len() != 16 {
|
||||
@@ -119,9 +117,39 @@ pub(crate) fn per_flow_max_mss(
|
||||
);
|
||||
return empty_lookup_ceiling;
|
||||
};
|
||||
let path_max_mss = effective_ipv6_mtu(path_mtu)
|
||||
.saturating_sub(40)
|
||||
.saturating_sub(20);
|
||||
let path_max_mss = mss_ceiling(path_mtu);
|
||||
// The actionable floor deliberately does not apply here. Every value a
|
||||
// remote party supplies is refused before it can reach this map, at the
|
||||
// path MTU state machine, the reactive `MtuExceeded` write and the
|
||||
// discovery response, so a small stored value is one the node derived
|
||||
// from its own outgoing link: a configured transport MTU, or the MTU a
|
||||
// BLE connection negotiated, which on that transport is routinely well
|
||||
// under the floor. Such a value is exact rather than suspect, and the
|
||||
// tight clamp it yields is the reason it is stored: discarding it would
|
||||
// advertise the conservative ceiling on a link that cannot carry it, and
|
||||
// a direct link has no forwarder to answer with `MtuExceeded`, so the
|
||||
// flow would stall with no feedback.
|
||||
//
|
||||
// What no provenance rescues is the arithmetic degenerating. At a stored
|
||||
// MTU of 137 or less not one payload byte fits alongside the IPv6 and TCP
|
||||
// headers, and `clamp_tcp_mss` refuses a ceiling of zero, which would
|
||||
// leave the SYN carrying the kernel-natural MSS instead. Fall back to the
|
||||
// conservative ceiling there; any positive result is by construction the
|
||||
// largest segment the stored MTU admits.
|
||||
//
|
||||
// `trace!`, not `warn!`, because this runs on every packet rather than
|
||||
// only on SYNs: one degenerate stored value would otherwise emit a WARN
|
||||
// per packet indefinitely and bury every other warning on the node. The
|
||||
// link promotion path warns once instead.
|
||||
if path_max_mss == 0 {
|
||||
trace!(
|
||||
fips_addr = %fips_addr,
|
||||
path_mtu,
|
||||
empty_lookup_ceiling,
|
||||
"per_flow_max_mss: stored path_mtu leaves no room for a TCP payload byte, using conservative ceiling"
|
||||
);
|
||||
return empty_lookup_ceiling;
|
||||
}
|
||||
let result = std::cmp::min(global_max_mss, path_max_mss);
|
||||
trace!(
|
||||
fips_addr = %fips_addr,
|
||||
@@ -1633,6 +1661,77 @@ mod tests {
|
||||
assert_eq!(per_flow_max_mss(&lookup, addr.as_bytes(), 1360), 1315);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_flow_stored_mtu_admitting_no_payload_byte_falls_back_to_conservative_ceiling() {
|
||||
// A stored MTU of 137 or less leaves nothing after the 77 bytes of
|
||||
// FIPS encapsulation and the 40 + 20 bytes of IPv6 and TCP header, so
|
||||
// the MSS arithmetic saturates to zero. Returning that zero would be
|
||||
// worse than the fallback: `clamp_tcp_mss` refuses a ceiling of zero,
|
||||
// so the SYN would go out at the kernel-natural MSS, unclamped.
|
||||
for stored in [0u16, 1, 100, 137] {
|
||||
let lookup = empty_lookup();
|
||||
let addr = fips_addr_with_node_byte(0x42);
|
||||
lookup.write().unwrap().insert(addr, stored);
|
||||
assert_eq!(
|
||||
per_flow_max_mss(&lookup, addr.as_bytes(), 1360),
|
||||
1143,
|
||||
"stored path_mtu {stored} admits no payload byte and must be ignored"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_flow_honors_a_locally_seeded_sub_floor_mtu_instead_of_loosening_to_the_ceiling() {
|
||||
// Only `seed_path_mtu_for_link_peer` can put a sub-floor value in this
|
||||
// map: every remote-supplied path MTU is refused at ingress, at the
|
||||
// path MTU state machine, the reactive `MtuExceeded` write and the
|
||||
// discovery response. A seeded value is therefore the node's own link
|
||||
// measurement, and BLE negotiates one per connection that lands in
|
||||
// this band routinely.
|
||||
//
|
||||
// Applying the remote-value floor here discarded it and advertised
|
||||
// 1143 instead, which a link this narrow cannot carry: every full-size
|
||||
// segment is refused by the transport, a direct link has no forwarder
|
||||
// to answer with `MtuExceeded`, and the flow stalls with no feedback.
|
||||
// The tight clamp is the whole reason the seed exists.
|
||||
//
|
||||
// 138 is the first MTU admitting a payload byte; 240 is a plausible
|
||||
// negotiated BLE value; 255 is one below the remote-value floor. The
|
||||
// whole table is evaluated before asserting, so a regression names
|
||||
// every band it broke rather than only the first.
|
||||
let want = [(138u16, 1u16), (240, 103), (255, 118)];
|
||||
let got: Vec<(u16, u16)> = want
|
||||
.iter()
|
||||
.map(|&(stored, _)| {
|
||||
let lookup = empty_lookup();
|
||||
let addr = fips_addr_with_node_byte(0x42);
|
||||
lookup.write().unwrap().insert(addr, stored);
|
||||
(stored, per_flow_max_mss(&lookup, addr.as_bytes(), 1360))
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
got,
|
||||
want.to_vec(),
|
||||
"each locally seeded path_mtu must clamp tight, not fall back to 1143"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_flow_stored_mtu_at_the_actionable_floor_is_still_honored() {
|
||||
// The smallest value the node will accept from a remote party still
|
||||
// clamps to its own arithmetic and nothing coarser: 256 - 77 - 40 - 20
|
||||
// = 119. Reintroducing the remote-value floor as a clamp-time guard
|
||||
// would leave this case passing, so it is pinned separately from the
|
||||
// sub-floor table above.
|
||||
let lookup = empty_lookup();
|
||||
let addr = fips_addr_with_node_byte(0x42);
|
||||
lookup
|
||||
.write()
|
||||
.unwrap()
|
||||
.insert(addr, super::super::icmp::MIN_ACTIONABLE_PATH_MTU);
|
||||
assert_eq!(per_flow_max_mss(&lookup, addr.as_bytes(), 1360), 119);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn per_flow_returns_conservative_ceiling_for_non_fips_addr() {
|
||||
// Non-fips IPv6 (e.g. fe80::/10 link-local) takes the empty-
|
||||
|
||||
Reference in New Issue
Block a user