Skip to content

Commit 1a6fc60

Browse files
committed
feat: added TLV Types 246/247 behind the pnet backend, with nix U-flag fallback, and updated README and CHANGELOG.
1 parent 4ce741c commit 1a6fc60

14 files changed

Lines changed: 842 additions & 76 deletions

File tree

CHANGELOG.md

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,64 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [Unreleased]
9+
10+
### Added
11+
12+
- **draft-ietf-ippm-stamp-ext-hdr Reflected Fixed / IPv6 Extension Header Data
13+
TLVs (Types 247 / 246)**: let the sender ask the reflector to echo the raw
14+
bytes of the received IP fixed header (Type 247 — 20 B IPv4 or 40 B IPv6)
15+
and IPv6 Hop-by-Hop / Destination Options extension headers (Type 246),
16+
which allows end-to-end diagnostics of DSCP remarking, TTL decrement,
17+
Flow Label rewriting, and in-path tampering with IPv6 options.
18+
- New typed TLVs `ReflectedFixedHdrTlv` and `ReflectedIpv6ExtHdrTlv`
19+
implementing `TypedTlv`, plus `TlvType::ReflectedFixedHdr` /
20+
`ReflectedIpv6ExtHdr` enum variants and length validation that treats
21+
the Value as variable-length (empty = sender request, populated =
22+
reflector response).
23+
- New `CapturedHeaders` struct threaded through `ProcessingContext` so the
24+
reflector's TLV processing can see the raw IP-layer bytes captured at
25+
receive time.
26+
- New `TlvList::process_reflected_headers()` invoked from
27+
`apply_semantic_tlv_processing` — copies captured bytes into the TLV or
28+
sets the U-flag when the backend cannot observe the IP layer.
29+
- `pnet` backend populates `CapturedHeaders` from `Ipv4Packet` / `Ipv6Packet`
30+
and walks Hop-by-Hop (NextHeader=0) / Destination Options (NextHeader=60)
31+
in wire format (NextHeader byte + HdrExtLen byte + body, per RFC 8200);
32+
non-options headers (Routing, Fragment, ESP, AH) stop the walk, matching
33+
the draft's scope. For IPv4 only the fixed 20-byte header is copied —
34+
IPv4 options are intentionally dropped.
35+
- `nix` backend passes `captured_headers: None` unconditionally; the
36+
reflector echoes the TLV empty with the U-flag set per RFC 8972 §4.2
37+
and emits a one-time log warning suggesting a rebuild with
38+
`--features ttl-pnet` if header reflection is genuinely required. An
39+
empty Value on an IPv4 packet or an IPv6 packet without extension
40+
headers is legitimate and does **not** set the U-flag.
41+
- Sender CLI: `--reflected-fixed-hdr`, `--reflected-ipv6-ext-hdr` (plus
42+
matching `FileConfiguration` TOML fields). Sender attaches empty-request
43+
TLVs; reflector fills them on the pnet backend or U-flags them on nix.
44+
45+
### Changed
46+
47+
- **Shared `build_local_addresses` between backends**: the interface-address
48+
enumeration used for Destination Node Address TLV matching (RFC 9503 §4)
49+
was duplicated in `receiver/nix.rs` and `receiver/pnet.rs` with platform-
50+
specific implementations. Consolidated into a single
51+
`receiver::build_local_addresses()` with `cfg(unix)` / `cfg(not(unix))`
52+
internals — `nix::ifaddrs::getifaddrs` on Unix, `pnet::datalink::interfaces`
53+
on Windows. Removes ~60 lines of near-duplicate code and a class of
54+
drift bugs.
55+
56+
### Documentation
57+
58+
- README gained a **Receiver Backends** section explaining the two capture
59+
paths (nix UDP socket + cmsg vs pnet datalink capture), why `nix` stays
60+
the default on Linux/macOS (unprivileged execution, no libpcap runtime
61+
dependency, kernel-side UDP demultiplex, firewall integration, kernel-
62+
handled checksums / fragmentation / ARP, socket observability), and what
63+
the tradeoff is (TLV 246/247 only on the pnet backend; two capture loops
64+
to maintain).
65+
866
## [0.6.0] - 2026-04-22
967

1068
### Added

README.md

Lines changed: 131 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ stamp-suite is a Rust implementation of the Simple Two-Way Active Measurement Pr
1919
- RFC 9534 Micro-session ID TLV for LAG per-member-link measurement
2020
- Reflected Test Packet Control TLV (draft-ietf-ippm-asymmetrical-pkts-14, Type 12) for asymmetrical reply measurement
2121
- Bit Error Rate TLVs (draft-gandhi-ippm-stamp-ber-05, Types 240/241/242) for residual BER measurement against a known padding pattern
22+
- Reflected Fixed/IPv6 Extension Header Data TLVs (draft-ietf-ippm-stamp-ext-hdr, Types 247/246) for header-transparency diagnostics — feature-gated to the pnet backend, gracefully U-flagged on the default nix backend
2223
- Class of Service (CoS) TLV support with DSCP/ECN measurement (RFC 8972 §5.2)
2324
- Location, Timestamp Info, Direct Measurement, Access Report, and Follow-Up Telemetry TLVs
2425
- HMAC authentication support
@@ -78,6 +79,90 @@ nix develop
7879
| `metrics` | Enable Prometheus metrics endpoint |
7980
| `snmp` | Enable SNMP AgentX sub-agent (Unix only) |
8081

82+
### Receiver Backends
83+
84+
The reflector has two receive-path backends. They differ in how they extract
85+
per-packet metadata (TTL, DSCP/ECN, destination address, raw IP headers), not in
86+
STAMP protocol behaviour — both go through the same `process_stamp_packet`
87+
pipeline.
88+
89+
**`nix` backend (default on Linux and macOS)**
90+
91+
Binds a normal `tokio::net::UdpSocket` and attaches `IP_RECVTTL`,
92+
`IP_RECVTOS`, and `IP_PKTINFO` (with IPv6 equivalents) so the kernel hands
93+
per-packet metadata through `recvmsg` control messages. The kernel performs UDP
94+
demultiplexing and checksum validation; userspace only sees traffic destined
95+
for the bound port.
96+
97+
**`pnet` backend (default on Windows, opt-in elsewhere)**
98+
99+
Captures frames at the datalink layer via libpcap / Npcap, parses Ethernet /
100+
IPv4 / IPv6 / UDP manually, and sends replies through a separate `UdpSocket`.
101+
Sees full IP headers, including IPv6 extension headers.
102+
103+
#### Why nix is the default where it works
104+
105+
Picking `pnet` everywhere would simplify the codebase slightly (one capture
106+
loop instead of two), but it would regress every Linux/macOS deployment on
107+
several independent axes:
108+
109+
1. **Privileges.** The `nix` backend runs as an unprivileged user — it binds a
110+
UDP port and that is it. The `pnet` backend needs `CAP_NET_RAW` (or
111+
`setcap cap_net_raw=eip` on the binary, or plain root). That matters for
112+
container images, systemd hardening, CI runners, SaaS deployments, and
113+
anywhere security policy limits capabilities.
114+
115+
2. **Runtime dependencies.** `nix` only needs libc. `pnet` links against
116+
libpcap on Unix and Npcap on Windows; these must be installed out-of-band
117+
before the binary will start. A statically-linked `nix` build drops into
118+
minimal containers and immutable OS images without extra packaging work.
119+
120+
3. **Kernel packet filtering.** With `nix`, the kernel demultiplexes UDP by
121+
destination port before userspace wakes up — stamp-suite only ever sees
122+
its own traffic. With `pnet`, every frame on the interface reaches
123+
userspace; we then discard everything that is not UDP to our port. On a
124+
10 Gb/s link carrying unrelated traffic this wastes CPU and causes
125+
scheduling jitter that pollutes delay measurements.
126+
127+
4. **Interaction with the host firewall.** `nix` goes through the normal
128+
socket path, so `iptables` / `nftables` INPUT rules and per-socket
129+
accounting behave exactly as the operator expects. `pnet` bypasses INPUT
130+
on receive and can bypass OUTPUT on raw send, so host-level policy is
131+
silently skipped.
132+
133+
5. **Kernel does the heavy lifting.** `nix` lets the kernel handle UDP
134+
checksum, fragmentation, path MTU discovery, ICMP unreachable, ARP /
135+
NDP for the next hop, and routing-table changes. A pnet-everywhere
136+
design would have to re-implement or work around each of these.
137+
138+
6. **Observability.** The `nix` backend has a real socket visible to
139+
`ss -u`, `netstat`, `lsof`, tracing tools, and systemd socket accounting.
140+
The pnet backend has none of these handles.
141+
142+
#### Tradeoff
143+
144+
Keeping two backends has a real cost: packet-processing changes that touch
145+
the capture path have to be mirrored in both files. We mitigate this by
146+
keeping all STAMP-level logic (TLV parsing, HMAC, Return Path handling,
147+
session tracking, counter updates) in `receiver/mod.rs` — the two backends
148+
differ only in how they capture packets and whether they send over an async
149+
tokio socket or a blocking std socket.
150+
151+
The other consequence is that a handful of features that genuinely require
152+
raw IP-header visibility — currently just the Reflected Fixed Header Data
153+
(Type 247) and Reflected IPv6 Extension Header Data (Type 246) TLVs from
154+
draft-ietf-ippm-stamp-ext-hdr — are only populated on the pnet backend.
155+
On the nix backend the reflector echoes the TLV with the U-flag set per
156+
RFC 8972 §4.2, and logs a one-time warning suggesting a rebuild with
157+
`--features ttl-pnet` if header reflection is actually needed. This
158+
follows the draft's own "may be unsupported by the reflector" semantics,
159+
so the sender sees a spec-compliant response either way.
160+
161+
If you specifically need TLV 246/247 reflection, or you want to craft
162+
outgoing packets with non-default IPv6 extension headers, build with
163+
`--no-default-features --features ttl-pnet` and accept the tradeoffs
164+
above.
165+
81166
## Usage
82167

83168
### Session-Reflector (Server)
@@ -418,8 +503,10 @@ The implementation supports RFC 8972 TLV (Type-Length-Value) extensions, which a
418503
| 240 | BER Bit Pattern in Padding | Repeated bit pattern carried alongside Extra Padding (draft-gandhi-ippm-stamp-ber-05) | Experimental |
419504
| 241 | BER Bit Error Count | u32 error-bit count, computed by reflector | Experimental |
420505
| 242 | BER Max Bit Error Burst Size | u32 longest consecutive error run, computed by reflector | Experimental |
506+
| 246 | Reflected IPv6 Extension Header Data | Reflects received IPv6 Hop-by-Hop / Destination Options headers (draft-ietf-ippm-stamp-ext-hdr) | Experimental (pnet backend only) |
507+
| 247 | Reflected Fixed Header Data | Reflects the raw 20-byte IPv4 or 40-byte IPv6 fixed header (draft-ietf-ippm-stamp-ext-hdr) | Experimental (pnet backend only) |
421508

422-
**Status**: Full = structured parsing, validation, and reflector field population. Experimental = implements an active IETF draft; wire format and type numbers for BER (240/241/242) are TBD in the draft (experimental-range picks) while Reflected Control (Type 12) is IANA-assigned. SR-MPLS/SRv6 forwarding is echoed with U-flag (actual segment routing is out of scope for userspace UDP).
509+
**Status**: Full = structured parsing, validation, and reflector field population. Experimental = implements an active IETF draft; wire format and type numbers for BER (240/241/242) and ext-hdr reflection (246/247) are TBD in the draft (experimental-range picks) while Reflected Control (Type 12) is IANA-assigned. SR-MPLS/SRv6 forwarding is echoed with U-flag (actual segment routing is out of scope for userspace UDP). Types 246/247 require raw IP-header visibility which the default `nix` UDP-socket backend cannot provide; on that backend they are echoed with the U-flag set and a one-time warning is logged — see [Receiver Backends](#receiver-backends) for why the default remains `nix`.
423510

424511
### TLV Handling Modes
425512

@@ -606,6 +693,49 @@ stamp-suite --remote-addr 192.168.1.100 --ber --ber-pattern aa55 --ber-padding-s
606693

607694
The reflector XORs the received padding against the expected pattern (from the Bit Pattern TLV, or 0xFF00 if absent), counts error bits and the longest consecutive error run across byte boundaries, and writes the results into Types 241 and 242. Per draft §3, duplicate BER TLVs or a missing companion Extra Padding TLV cause the reflector to set the U-flag on all BER TLVs and skip the computation.
608695

696+
### Reflected Fixed / IPv6 Extension Header Data TLVs (draft-ietf-ippm-stamp-ext-hdr)
697+
698+
Two experimental TLVs let the sender ask the reflector to echo the bytes of the
699+
received IP headers — useful for diagnosing DSCP remarking, TTL decrement,
700+
Flow Label rewriting, or tampering with IPv6 Hop-by-Hop / Destination Options
701+
by intermediate routers.
702+
703+
| Type | Name | Content |
704+
|------|------|---------|
705+
| 246 | Reflected IPv6 Extension Header Data | Concatenated Hop-by-Hop (NextHeader 0) and Destination Options (NextHeader 60) extension headers, each prefixed with its NextHeader byte and HdrExtLen byte as they appeared on the wire |
706+
| 247 | Reflected Fixed Header Data | Raw 20-byte IPv4 or 40-byte IPv6 fixed header as received |
707+
708+
Type numbers are TBD in the draft; this implementation uses 246/247 from RFC
709+
8972's experimental range.
710+
711+
```bash
712+
# Ask the reflector to reflect the IPv4/IPv6 fixed header
713+
stamp-suite --remote-addr 192.168.1.100 --reflected-fixed-hdr
714+
715+
# IPv6 test with reflected extension headers
716+
stamp-suite --remote-addr 2001:db8::1 --reflected-ipv6-ext-hdr
717+
```
718+
719+
**Backend requirement:** these TLVs require the reflector to copy raw IP-header
720+
bytes into the response, which is only possible when the reflector captures at
721+
the datalink layer. Only the `pnet` backend can do this (see
722+
[Receiver Backends](#receiver-backends)).
723+
724+
- On the **pnet** backend (Windows default, or `--features ttl-pnet` on Unix):
725+
the reflector populates the TLV Value with the captured bytes. For IPv4
726+
packets the TLV is truncated to the fixed 20-byte header, so IPv4 options
727+
are not reflected.
728+
- On the **nix** backend (Linux/macOS default): the kernel hides raw IP
729+
headers from the application, so the reflector has nothing to copy. The
730+
TLVs are echoed with an empty Value and the U-flag set per RFC 8972 §4.2,
731+
and a one-time warning is logged telling the operator to rebuild with
732+
`--features ttl-pnet` if header reflection is required. The sender sees a
733+
protocol-compliant response either way.
734+
- A sender-requested Type 246 TLV on an IPv4 packet, or on an IPv6 packet
735+
without any extension headers, legitimately produces an empty Value — this
736+
is **not** the same as the U-flag case and is treated as a valid "no data"
737+
response.
738+
609739
## Prometheus Metrics
610740

611741
When built with `--features metrics`, the reflector can expose Prometheus metrics:

src/configuration.rs

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -326,6 +326,22 @@ pub struct Configuration {
326326
/// Ignored unless `--reflected-control-count` > 1.
327327
#[clap(long, default_value_t = 1_000_000)]
328328
pub reflected_control_interval_ns: u32,
329+
330+
/// Request that the reflector copy the received IP fixed header
331+
/// (IPv4: 20 bytes, IPv6: 40 bytes) back via TLV Type 247
332+
/// (draft-ietf-ippm-stamp-ext-hdr §4). Reflectors built with the
333+
/// `ttl-nix` backend cannot observe the IP header and will echo the
334+
/// TLV with the U-flag set.
335+
#[clap(long)]
336+
pub reflected_fixed_hdr: bool,
337+
338+
/// Request that the reflector copy IPv6 Hop-by-Hop and Destination
339+
/// Options extension headers back via TLV Type 246
340+
/// (draft-ietf-ippm-stamp-ext-hdr §3). Reflectors built with the
341+
/// `ttl-nix` backend cannot observe extension headers and will echo
342+
/// the TLV with the U-flag set.
343+
#[clap(long)]
344+
pub reflected_ipv6_ext_hdr: bool,
329345
}
330346

331347
impl Configuration {
@@ -602,6 +618,8 @@ impl Configuration {
602618
merge!(reflected_control_count);
603619
merge!(reflected_control_length);
604620
merge!(reflected_control_interval_ns);
621+
merge!(reflected_fixed_hdr);
622+
merge!(reflected_ipv6_ext_hdr);
605623
}
606624
}
607625

@@ -678,6 +696,8 @@ pub struct FileConfiguration {
678696
pub reflected_control_count: Option<u16>,
679697
pub reflected_control_length: Option<u16>,
680698
pub reflected_control_interval_ns: Option<u32>,
699+
pub reflected_fixed_hdr: Option<bool>,
700+
pub reflected_ipv6_ext_hdr: Option<bool>,
681701
}
682702

683703
/// Checks if authenticated mode is enabled.

src/receiver/mod.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,61 @@ use crate::{
6868
},
6969
};
7070

71+
/// Returns the list of local IP addresses used for Destination Node Address
72+
/// TLV matching (RFC 9503 §4).
73+
///
74+
/// When `bind_addr` is a wildcard (`0.0.0.0` or `::`), enumerates every
75+
/// interface address on the system. Otherwise returns just `bind_addr`.
76+
///
77+
/// Interface enumeration uses the `nix` crate on Unix and `pnet::datalink` on
78+
/// Windows — both produce the same logical output.
79+
pub fn build_local_addresses(bind_addr: std::net::IpAddr) -> Vec<std::net::IpAddr> {
80+
let is_wildcard = match bind_addr {
81+
std::net::IpAddr::V4(v4) => v4.is_unspecified(),
82+
std::net::IpAddr::V6(v6) => v6.is_unspecified(),
83+
};
84+
if !is_wildcard {
85+
return vec![bind_addr];
86+
}
87+
88+
let addrs = enumerate_interface_addresses();
89+
if addrs.is_empty() {
90+
log::warn!(
91+
"Could not enumerate local addresses; Destination Node Address matching may fail"
92+
);
93+
vec![bind_addr]
94+
} else {
95+
addrs
96+
}
97+
}
98+
99+
#[cfg(unix)]
100+
fn enumerate_interface_addresses() -> Vec<std::net::IpAddr> {
101+
let mut addrs = Vec::new();
102+
if let Ok(ifaddrs) = ::nix::ifaddrs::getifaddrs() {
103+
for ifaddr in ifaddrs {
104+
if let Some(addr) = ifaddr.address {
105+
if let Some(v4) = addr.as_sockaddr_in() {
106+
addrs.push(std::net::IpAddr::V4(v4.ip()));
107+
} else if let Some(v6) = addr.as_sockaddr_in6() {
108+
addrs.push(std::net::IpAddr::V6(v6.ip()));
109+
}
110+
}
111+
}
112+
}
113+
addrs
114+
}
115+
116+
#[cfg(not(unix))]
117+
fn enumerate_interface_addresses() -> Vec<std::net::IpAddr> {
118+
// Windows has no `getifaddrs`; fall back to pnet's datalink enumeration.
119+
// pnet is always a build dependency on Windows (default ttl-pnet backend).
120+
pnet::datalink::interfaces()
121+
.into_iter()
122+
.flat_map(|iface| iface.ips.into_iter().map(|n| n.ip()))
123+
.collect()
124+
}
125+
71126
/// Loads the HMAC key from configuration (hex string or file).
72127
pub fn load_hmac_key(conf: &Configuration) -> Option<HmacKey> {
73128
if let Some(ref hex_key) = conf.hmac_key {
@@ -494,6 +549,28 @@ pub struct ProcessingContext<'a> {
494549
pub sender_port: u16,
495550
/// Reflector member link ID for Micro-session ID TLV (RFC 9534 §3.2).
496551
pub reflector_member_link_id: Option<u16>,
552+
/// Raw bytes of the received IP fixed header and IPv6 extension headers,
553+
/// for draft-ietf-ippm-stamp-ext-hdr Reflected Fixed/Ext Header TLVs
554+
/// (Types 247/246). `None` on backends that cannot observe the IP layer
555+
/// (UDP-socket `nix` backend): the reflector then echoes the TLV with the
556+
/// U-flag set.
557+
pub captured_headers: Option<&'a CapturedHeaders>,
558+
}
559+
560+
/// Raw IP-layer bytes captured at receive time for reflecting back to the
561+
/// sender via TLV Types 246 and 247 (draft-ietf-ippm-stamp-ext-hdr).
562+
///
563+
/// Populated only by backends that capture at the datalink layer (pnet).
564+
/// UDP-socket backends (nix) cannot observe these bytes and leave the
565+
/// struct unset; the reflector sets the U-flag on any 246/247 request.
566+
#[derive(Debug, Clone, Default)]
567+
pub struct CapturedHeaders {
568+
/// Raw IP fixed header (20 bytes for IPv4, 40 bytes for IPv6).
569+
pub fixed_header: Vec<u8>,
570+
/// Concatenated IPv6 Hop-by-Hop (NextHeader 0) and Destination Options
571+
/// (NextHeader 60) extension headers, each prefixed with its NextHeader
572+
/// byte and HdrLen byte, exactly as received on the wire.
573+
pub ipv6_ext_headers: Vec<u8>,
497574
}
498575

499576
/// Processes a STAMP packet and returns the response.
@@ -960,6 +1037,22 @@ fn apply_semantic_tlv_processing(
9601037
// compute Bit Error Count and Max Burst against the companion Extra Padding.
9611038
tlvs.process_ber();
9621039

1040+
// Process Reflected Fixed / IPv6 Extension Header TLVs
1041+
// (draft-ietf-ippm-stamp-ext-hdr §§3–4). If the backend captured raw IP
1042+
// bytes, copy them into the TLV Value; otherwise set the U-flag per
1043+
// RFC 8972 §4.2 and echo an empty TLV. A nix UDP-socket backend hands us
1044+
// `captured_headers = None`, so this correctly advertises "unsupported"
1045+
// to senders that requested header reflection.
1046+
let (captured_fixed, captured_ext): (Option<&[u8]>, Option<&[u8]>) = match ctx.captured_headers
1047+
{
1048+
Some(h) => (
1049+
Some(h.fixed_header.as_slice()),
1050+
Some(h.ipv6_ext_headers.as_slice()),
1051+
),
1052+
None => (None, None),
1053+
};
1054+
tlvs.process_reflected_headers(captured_fixed, captured_ext);
1055+
9631056
// Process Reflected Test Packet Control TLV (draft-ietf-ippm-asymmetrical-pkts §3).
9641057
// We don't honour the requested per-packet length in this implementation — if the
9651058
// sender asks for a specific length we set the C flag on the echoed TLV to indicate
@@ -1284,6 +1377,7 @@ mod tests {
12841377
local_addresses: &[],
12851378
sender_port: 0,
12861379
reflector_member_link_id: None,
1380+
captured_headers: None,
12871381
}
12881382
}
12891383

0 commit comments

Comments
 (0)