Skip to content

Commit b832a0f

Browse files
committed
feat(ext-hdr): match §3.1/§3.2 first-4-byte selector on reflected header TLVs
Implement the draft-ietf-ippm-stamp-ext-hdr selector rule for the Reflected IPv6 Extension Header (Type 246) and Reflected Fixed Header (Type 247) TLVs. Previously the reflector ignored the request's first-4-byte pattern and concatenated every captured extension header ("Not yet implemented" per doc/architecture.md). Reflector (src/tlv/list/processing.rs): - Type 246: a non-zero first-4-byte selector picks the single captured extension-header record whose first 4 bytes match, disambiguating multiple headers of the same length; no match zero-fills and sets the U-flag. - Type 247: the selector acts as a validation gate after the length check — it must equal the received header's first 4 bytes, else U-flag. - A zero selector preserves the legacy copy-everything behavior. Sender (opt-in): --reflected-ipv6-ext-hdr-selector / --reflected-fixed-hdr-selector hex flags, validated in Configuration::validate() (requires the enabling flag, >=1 non-zero byte, family-size cap) and built via ReflectedIpv6ExtHdrTlv/ReflectedFixedHdrTlv:: request_with_selector. The selector matches stamp-suite's reflected representation, where byte 0 is the extension-header type (0x00 Hop-by-Hop, 0x3C Destination Options), not the on-wire Next Header pointer — documented in CLI help and architecture.md. No change to existing exchanges: the rule is dormant until a sender opts in.
1 parent 55a59cb commit b832a0f

8 files changed

Lines changed: 675 additions & 32 deletions

File tree

doc/architecture.md

Lines changed: 23 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -455,15 +455,29 @@ the datalink layer. Only the `pnet` backend can do this (see
455455
reflector), the reflector zero-fills the Value and sets the U-flag
456456
rather than silently truncating or zero-padding. This conformance check
457457
ships in stamp-suite as of this release.
458-
- **Not yet implemented:** the §3.1/§3.2 "non-zero first 4 bytes"
459-
disambiguation rule. When the sender pre-populates the first 4 bytes of
460-
a Type 246 TLV with header data to ask the reflector for a *specific*
461-
extension header (e.g. one of two same-length Hop-by-Hop options), the
462-
reflector is required to match against that pattern. Today we
463-
concatenate every captured extension header into the TLV Value
464-
regardless of the first-4-byte pattern. Tracked separately; safe today
465-
for senders that send a single TLV-instance per packet with the value
466-
field zeroed.
458+
- **§3.1/§3.2 "non-zero first 4 bytes" selector (implemented).** When the
459+
sender pre-populates the first 4 bytes of the request Value with a non-zero
460+
pattern, the reflector matches it against the captured header(s) before
461+
copying. For Type 246 this disambiguates multiple extension headers of the
462+
same length: the reflector walks the captured records (`[type][HdrExtLen][body]`,
463+
`(HdrExtLen+1)*8` bytes each) and copies **only** the record whose first 4
464+
bytes match the selector; if none matches it zero-fills and sets the U-flag.
465+
For Type 247 (one fixed header) it is a validation gate: the selector must
466+
equal the received header's first 4 bytes, else U-flag. A zero selector (the
467+
default) preserves the legacy behavior — Type 246 concatenates every captured
468+
header, Type 247 copies the fixed header. Senders opt in with
469+
`--reflected-ipv6-ext-hdr-selector <hex>` / `--reflected-fixed-hdr-selector <hex>`;
470+
this is dormant until a sender sets it, so no existing exchange changes.
471+
- **Selector byte-0 caveat.** stamp-suite's reflected representation stores
472+
each extension header's byte 0 as the header *type* (`0x00` Hop-by-Hop,
473+
`0x3C` Destination Options — the protocol number from the preceding Next
474+
Header field), with bytes 1..3 equal to the wire bytes. The reflector
475+
matches the selector against this reflected representation (the only
476+
internally-consistent choice, since that is what it copies back), so a
477+
stamp-suite selector's byte 0 is the header type, not the on-wire Next
478+
Header pointer. Fully wire-accurate matching would require reworking the
479+
capture format and is deferred (Types 246/247 use experimental codepoints
480+
that are already stamp-suite-specific).
467481

468482
## Prometheus Metrics
469483

doc/usage.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,7 +194,9 @@ The canonical reference is `stamp-suite --help` (this list is generated from the
194194
--reflected-control-length <LEN> Requested reply packet length, 0 = don't pad [default: 0]
195195
--reflected-control-interval-ns <NS> Inter-packet gap [default: 1_000_000]
196196
--reflected-fixed-hdr Request reflected IPv4/IPv6 fixed header (TLV 247, draft-ietf-ippm-stamp-ext-hdr §4)
197+
--reflected-fixed-hdr-selector <HEX> §3.2 selector: first bytes must match the received IP header, else U-flag (requires --reflected-fixed-hdr)
197198
--reflected-ipv6-ext-hdr Request reflected IPv6 extension headers (TLV 246, draft-ietf-ippm-stamp-ext-hdr §3)
199+
--reflected-ipv6-ext-hdr-selector <HEX> §3.1 selector: return only the matching extension header; byte 0 is the header type (00=Hop-by-Hop, 3c=Dest-Opts) (requires --reflected-ipv6-ext-hdr)
198200
--ber Enable BER TLVs (draft-gandhi-ippm-stamp-ber, Types 240/241/242)
199201
--ber-pattern <HEX> Padding bit pattern (default: ff00)
200202
--ber-padding-size <BYTES> Extra Padding length used with --ber [default: 64]

src/configuration.rs

Lines changed: 186 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -567,6 +567,25 @@ pub struct Configuration {
567567
/// the TLV with the U-flag set.
568568
#[clap(long)]
569569
pub reflected_ipv6_ext_hdr: bool,
570+
571+
/// Selector for the Type 246 request (draft-ietf-ippm-stamp-ext-hdr §3.1).
572+
/// Hex string (e.g. "3c000102"); its bytes are placed at the start of the
573+
/// request Value so the reflector returns only the matching extension
574+
/// header (disambiguating multiple same-length headers). Byte 0 is the
575+
/// header TYPE — `00` = Hop-by-Hop, `3c` = Destination Options — matching
576+
/// the reflected representation, not the on-wire Next Header pointer.
577+
/// Must contain at least one non-zero byte. Requires
578+
/// `--reflected-ipv6-ext-hdr`.
579+
#[clap(long, value_name = "HEX")]
580+
pub reflected_ipv6_ext_hdr_selector: Option<String>,
581+
582+
/// Selector for the Type 247 request (draft-ietf-ippm-stamp-ext-hdr §3.2).
583+
/// Hex string whose bytes must match the start of the received IP fixed
584+
/// header; on mismatch the reflector echoes the TLV with the U-flag set.
585+
/// At most 20 bytes (IPv4) or 40 bytes (IPv6) by the destination family,
586+
/// and at least one non-zero byte. Requires `--reflected-fixed-hdr`.
587+
#[clap(long, value_name = "HEX")]
588+
pub reflected_fixed_hdr_selector: Option<String>,
570589
}
571590

572591
impl Configuration {
@@ -742,6 +761,49 @@ impl Configuration {
742761
));
743762
}
744763

764+
// draft-ietf-ippm-stamp-ext-hdr §3.1/§3.2 header-reflection selectors.
765+
if let Some(sel) = &self.reflected_ipv6_ext_hdr_selector {
766+
if !self.reflected_ipv6_ext_hdr {
767+
return Err(ConfigurationError::InvalidConfiguration(
768+
"--reflected-ipv6-ext-hdr-selector requires --reflected-ipv6-ext-hdr"
769+
.to_string(),
770+
));
771+
}
772+
let bytes = decode_selector(sel).map_err(|e| {
773+
ConfigurationError::InvalidConfiguration(format!(
774+
"invalid --reflected-ipv6-ext-hdr-selector: {e}"
775+
))
776+
})?;
777+
if bytes.len() > MAX_IPV6_EXT_HDR_SELECTOR_BYTES {
778+
return Err(ConfigurationError::InvalidConfiguration(format!(
779+
"--reflected-ipv6-ext-hdr-selector is {} bytes; the maximum is {} \
780+
(one IPv6 extension header)",
781+
bytes.len(),
782+
MAX_IPV6_EXT_HDR_SELECTOR_BYTES
783+
)));
784+
}
785+
}
786+
if let Some(sel) = &self.reflected_fixed_hdr_selector {
787+
if !self.reflected_fixed_hdr {
788+
return Err(ConfigurationError::InvalidConfiguration(
789+
"--reflected-fixed-hdr-selector requires --reflected-fixed-hdr".to_string(),
790+
));
791+
}
792+
let bytes = decode_selector(sel).map_err(|e| {
793+
ConfigurationError::InvalidConfiguration(format!(
794+
"invalid --reflected-fixed-hdr-selector: {e}"
795+
))
796+
})?;
797+
let max = if self.remote_addr.is_ipv4() { 20 } else { 40 };
798+
if bytes.len() > max {
799+
return Err(ConfigurationError::InvalidConfiguration(format!(
800+
"--reflected-fixed-hdr-selector is {} bytes; the maximum for the \
801+
destination family is {max} (the IP fixed-header length)",
802+
bytes.len()
803+
)));
804+
}
805+
}
806+
745807
Ok(())
746808
}
747809

@@ -898,6 +960,8 @@ impl Configuration {
898960
merge!(reflected_control_min_interval_ns);
899961
merge!(reflected_fixed_hdr);
900962
merge!(reflected_ipv6_ext_hdr);
963+
merge_opt!(reflected_ipv6_ext_hdr_selector);
964+
merge_opt!(reflected_fixed_hdr_selector);
901965
}
902966
}
903967

@@ -992,6 +1056,8 @@ pub struct FileConfiguration {
9921056
pub reflected_control_min_interval_ns: Option<u32>,
9931057
pub reflected_fixed_hdr: Option<bool>,
9941058
pub reflected_ipv6_ext_hdr: Option<bool>,
1059+
pub reflected_ipv6_ext_hdr_selector: Option<String>,
1060+
pub reflected_fixed_hdr_selector: Option<String>,
9951061
}
9961062

9971063
/// JSON Schema (draft 2020-12) for the TOML config file accepted by
@@ -1079,7 +1145,9 @@ pub const CONFIG_JSON_SCHEMA: &str = r##"{
10791145
"reflected_control_max_size": { "type": "integer", "minimum": 0, "maximum": 65535 },
10801146
"reflected_control_min_interval_ns": { "type": "integer", "minimum": 0 },
10811147
"reflected_fixed_hdr": { "type": "boolean" },
1082-
"reflected_ipv6_ext_hdr": { "type": "boolean" }
1148+
"reflected_ipv6_ext_hdr": { "type": "boolean" },
1149+
"reflected_ipv6_ext_hdr_selector": { "type": "string", "pattern": "^[0-9a-fA-F]+$" },
1150+
"reflected_fixed_hdr_selector": { "type": "string", "pattern": "^[0-9a-fA-F]+$" }
10831151
}
10841152
}"##;
10851153

@@ -1089,6 +1157,29 @@ pub fn is_auth(mode: AuthMode) -> bool {
10891157
mode.is_authenticated()
10901158
}
10911159

1160+
/// Maximum length of a Type 246 selector: one full IPv6 extension header,
1161+
/// `(255 + 1) * 8` bytes (draft-ietf-ippm-stamp-ext-hdr §3.1).
1162+
pub(crate) const MAX_IPV6_EXT_HDR_SELECTOR_BYTES: usize = 2048;
1163+
1164+
/// Decodes a hex selector string (optional `0x` prefix) into bytes for the
1165+
/// draft-ietf-ippm-stamp-ext-hdr §3.1/§3.2 header-reflection request TLVs.
1166+
/// Requires non-empty input with at least one non-zero byte — an all-zero
1167+
/// selector would be indistinguishable from "no selector requested".
1168+
pub(crate) fn decode_selector(s: &str) -> Result<Vec<u8>, String> {
1169+
let trimmed = s
1170+
.strip_prefix("0x")
1171+
.or_else(|| s.strip_prefix("0X"))
1172+
.unwrap_or(s);
1173+
if trimmed.is_empty() {
1174+
return Err("empty selector".to_string());
1175+
}
1176+
let bytes = hex::decode(trimmed).map_err(|e| format!("invalid hex `{s}`: {e}"))?;
1177+
if bytes.iter().all(|&b| b == 0) {
1178+
return Err("selector must contain at least one non-zero byte".to_string());
1179+
}
1180+
Ok(bytes)
1181+
}
1182+
10921183
/// clap value_parser: parse a u16 from decimal or `0x`-prefixed hex, rejecting 0.
10931184
///
10941185
/// Accepts: `255`, `0xff`, `0XFF`, `0x00ab`. Rejects: `0`, `0x0`, empty, `ff`,
@@ -1167,6 +1258,100 @@ mod tests {
11671258
assert!(conf.is_err());
11681259
}
11691260

1261+
/// A known-good argument set that passes `validate()`, for tests that want
1262+
/// to isolate a single new validation rule.
1263+
fn base_valid_args() -> Vec<String> {
1264+
[
1265+
"test",
1266+
"--remote-addr",
1267+
"127.0.0.1",
1268+
"--local-addr",
1269+
"0.0.0.0",
1270+
"--remote-port",
1271+
"862",
1272+
"--local-port",
1273+
"862",
1274+
"--clock-source",
1275+
"NTP",
1276+
"--send-delay",
1277+
"1000",
1278+
"--count",
1279+
"1000",
1280+
"--timeout",
1281+
"5",
1282+
"--auth-mode",
1283+
"A",
1284+
"--is-reflector",
1285+
"--hmac-key",
1286+
"0123456789abcdef0123456789abcdef",
1287+
]
1288+
.iter()
1289+
.map(|s| (*s).to_string())
1290+
.collect()
1291+
}
1292+
1293+
#[test]
1294+
fn ext_hdr_selector_requires_enabling_flag() {
1295+
let mut args = base_valid_args();
1296+
args.extend([
1297+
"--reflected-ipv6-ext-hdr-selector".to_string(),
1298+
"3c000102".to_string(),
1299+
]);
1300+
let conf = Configuration::try_parse_from(args).unwrap();
1301+
assert!(conf.validate().is_err());
1302+
}
1303+
1304+
#[test]
1305+
fn ext_hdr_selector_with_flag_is_accepted() {
1306+
let mut args = base_valid_args();
1307+
args.extend([
1308+
"--reflected-ipv6-ext-hdr".to_string(),
1309+
"--reflected-ipv6-ext-hdr-selector".to_string(),
1310+
"3c000102".to_string(),
1311+
]);
1312+
let conf = Configuration::try_parse_from(args).unwrap();
1313+
assert!(conf.validate().is_ok());
1314+
assert_eq!(
1315+
conf.reflected_ipv6_ext_hdr_selector.as_deref(),
1316+
Some("3c000102")
1317+
);
1318+
}
1319+
1320+
#[test]
1321+
fn all_zero_ext_hdr_selector_is_rejected() {
1322+
let mut args = base_valid_args();
1323+
args.extend([
1324+
"--reflected-ipv6-ext-hdr".to_string(),
1325+
"--reflected-ipv6-ext-hdr-selector".to_string(),
1326+
"00000000".to_string(),
1327+
]);
1328+
let conf = Configuration::try_parse_from(args).unwrap();
1329+
assert!(conf.validate().is_err());
1330+
}
1331+
1332+
#[test]
1333+
fn fixed_hdr_selector_requires_enabling_flag() {
1334+
let mut args = base_valid_args();
1335+
args.extend([
1336+
"--reflected-fixed-hdr-selector".to_string(),
1337+
"45000054".to_string(),
1338+
]);
1339+
let conf = Configuration::try_parse_from(args).unwrap();
1340+
assert!(conf.validate().is_err());
1341+
}
1342+
1343+
#[test]
1344+
fn fixed_hdr_selector_too_long_for_ipv4_is_rejected() {
1345+
let mut args = base_valid_args();
1346+
args.extend([
1347+
"--reflected-fixed-hdr".to_string(),
1348+
"--reflected-fixed-hdr-selector".to_string(),
1349+
"01".repeat(21), // 21 bytes > 20-byte IPv4 fixed header
1350+
]);
1351+
let conf = Configuration::try_parse_from(args).unwrap();
1352+
assert!(conf.validate().is_err());
1353+
}
1354+
11701355
#[test]
11711356
fn test_control_plane_flags() {
11721357
let args = vec![

0 commit comments

Comments
 (0)