diff --git a/README.md b/README.md index f816122..ae48a59 100644 --- a/README.md +++ b/README.md @@ -145,6 +145,37 @@ between IPv6 and IPv4 headers; nftables masquerade handles port mapping. Use `RouterPreset::MobileV6` or `.nat_v6(NatV6Mode::Nat64)` directly. See [docs/reference/ipv6.md](docs/reference/ipv6.md) for details. +### IPv6 link-local and provisioning modes + +Every IPv6-capable device/router interface now exposes a link-local address +through the handle snapshots: + +- `Device::default_iface().and_then(|i| i.ll6())` +- `Device::interfaces().iter().filter_map(|i| i.ll6())` +- `router.iface("ix").or_else(|| router.iface("wan")).and_then(|i| i.ll6())` +- `router.interfaces().iter().filter_map(|i| i.ll6())` + +Patchbay also supports explicit IPv6 provisioning and DAD modes via `LabOpts`: + +```rust +let lab = Lab::with_opts( + LabOpts::default() + .ipv6_provisioning_mode(Ipv6ProvisioningMode::Static) + .ipv6_dad_mode(Ipv6DadMode::Enabled), +).await?; +``` + +`Ipv6ProvisioningMode::Static` keeps route wiring deterministic. +`Ipv6ProvisioningMode::RaDriven` enables patchbay's RA/RS-driven path. +`Ipv6DadMode::Disabled` is the current default for deterministic test setup. + +In `RaDriven` mode, patchbay models Router Advertisement and Router +Solicitation behavior through structured events and route updates. It does +not emit raw ICMPv6 RA or RS packets on the virtual links. + +For full scope and known gaps, see [Book limitations](docs/limitations.md) +and [IPv6 reference](docs/reference/ipv6.md). + ### Firewalls Firewall presets control both inbound and outbound traffic: @@ -265,6 +296,12 @@ provide `spawn`, `run_sync`, `spawn_thread`, `spawn_command`, namespace. Handle methods return `Result` or `Option` when the underlying node has been removed from the lab. +For IPv6 diagnostics, use per-interface snapshots instead of only `ip6()`: + +- `DeviceIface::ip6()` for global/ULA address. +- `DeviceIface::ll6()` for `fe80::/10` link-local address. +- `RouterIface::ip6()` and `RouterIface::ll6()` for router-side interface state. + ## TOML configuration You can also load labs from TOML files via `Lab::load("lab.toml")`: @@ -302,15 +339,15 @@ patchbay run ./sims/my-sim.toml --open The UI provides five tabs: -- **Topology** — interactive graph of routers, devices, and links with a +- **Topology**: interactive graph of routers, devices, and links with a detail sidebar showing NAT, firewall, IPs, and counters. -- **Events** — table of lab lifecycle events (router added, device added, +- **Events**: table of lab lifecycle events (router added, device added, NAT changed, etc.) with relative/absolute timestamps. -- **Logs** — per-namespace tracing log viewer with JSON parsing, level +- **Logs**: per-namespace tracing log viewer with JSON parsing, level badges, and target filtering. Supports jump-to-log from the timeline. -- **Timeline** — grid of extracted `_events` per node over time, with +- **Timeline**: grid of extracted `_events` per node over time, with detail pane and jump-to-log. -- **Perf** — throughput results table (only for TOML runner sims). +- **Perf**: throughput results table (only for TOML runner sims). Each `Lab` instance writes to a timestamped subdirectory under the outdir. Multiple runs accumulate in the same outdir and appear in the run selector. diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index b3fd6c4..2df8fb2 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -1,6 +1,7 @@ # Summary [Introduction](introduction.md) +[Limitations](limitations.md) # Guide diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index 28678f5..4a74612 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -221,8 +221,8 @@ pollutes the host. ## Viewing results in the browser -patchbay can write structured output to disk — topology events, -per-namespace tracing logs, and extracted custom events — and serve them +patchbay can write structured output to disk, including topology events, +per-namespace tracing logs, and extracted custom events, and serve them in an interactive web UI. Set the `PATCHBAY_OUTDIR` environment variable to enable this: diff --git a/docs/guide/running-code.md b/docs/guide/running-code.md index 77cbf67..e76ddc0 100644 --- a/docs/guide/running-code.md +++ b/docs/guide/running-code.md @@ -211,6 +211,13 @@ Handle methods return `Result` or `Option` when the underlying node has been removed from the lab. If you hold a handle to a device that no longer exists, calls will return an error rather than panicking. +When debugging IPv6 behavior, inspect interface snapshots instead of only +top-level `ip6()` accessors: + +- `device.default_iface().and_then(|i| i.ip6())` for global/ULA IPv6. +- `device.default_iface().and_then(|i| i.ll6())` for link-local `fe80::/10`. +- `router.interfaces()` for `RouterIface` snapshots on `ix`/`wan` and bridge. + ## Cleanup When the `Lab` is dropped, it shuts down all async and sync workers, then diff --git a/docs/guide/topology.md b/docs/guide/topology.md index 9353964..f2e9bc8 100644 --- a/docs/guide/topology.md +++ b/docs/guide/topology.md @@ -120,8 +120,13 @@ You can read a device's assigned addresses through the handle: ```rust let v4: Option = server.ip(); let v6: Option = server.ip6(); +let ll: Option = server.default_iface().and_then(|i| i.ll6()); ``` +For router-side address inspection, use `router.interfaces()` or +`router.iface("ix")` / `router.iface("wan")` and read `ip6()` plus `ll6()` +from `RouterIface`. + ### Multi-homed devices A device can have multiple interfaces, each connected to a different diff --git a/docs/introduction.md b/docs/introduction.md index 0762dab..276e1fd 100644 --- a/docs/introduction.md +++ b/docs/introduction.md @@ -25,6 +25,10 @@ common network scenarios like WiFi handoff and VPN tunnels, the internals of NAT traversal and hole-punching as implemented in nftables, and the TOML simulation file format used by the patchbay runner. +The **Limitations** page documents known boundaries of the current model. +Read it before relying on packet-level control-plane behavior, operating +system specific network-stack quirks, or low-level timing fidelity. + A built-in devtools server (`patchbay serve`) provides an interactive web UI for inspecting lab runs: topology graphs, event timelines, per-namespace structured logs, and performance results. Set diff --git a/docs/limitations.md b/docs/limitations.md new file mode 100644 index 0000000..fe45e87 --- /dev/null +++ b/docs/limitations.md @@ -0,0 +1,104 @@ +# Limitations + +This page documents known limitations of patchbay. The goal is to help you +decide when patchbay is a good fit, and where you should expect differences +from production systems. + +## IPv6 limitations + +### RA and RS are modeled, not packet-emulated + +In `Ipv6ProvisioningMode::RaDriven`, patchbay models Router Advertisement +(RA) and Router Solicitation (RS) behavior through route updates and +structured tracing events. It does not currently send raw ICMPv6 RA or RS +packets on virtual links. + +Impact: + +- Application-level routing behavior is usually close to production. +- Packet-capture workflows that expect real RA/RS frames are not covered yet. + +### SLAAC behavior is partial + +Patchbay models default-route and address behavior needed for routing tests, +but it does not currently implement a full Stateless Address +Autoconfiguration (SLAAC) state machine with all timing transitions. + +Impact: + +- Connectivity and route-selection tests work well. +- Detailed host autoconfiguration timing studies are out of scope. + +### Neighbor Discovery timing is not fully emulated + +Neighbor Discovery (ND) address and router behavior is represented in route +and interface state, but exact kernel-level timing of ND probes, retries, and +expiration is not fully emulated. + +Impact: + +- Most application tests are unaffected. +- Low-level protocol timing analysis should use a dedicated packet-level setup. + +### DHCPv6 prefix delegation is not implemented + +Patchbay does not implement a DHCPv6 Prefix Delegation server or client flow. +Use static /64 allocation in topologies. + +Impact: + +- Residential-prefix churn workflows are not fully represented. +- Prefix-based routing and NAT64 scenarios still work with static setup. + +## General platform and model limitations + +### Linux-only execution model + +Patchbay uses Linux network namespaces, nftables, and tc. It models network +behavior through the Linux kernel network stack. + +Impact: + +- Native execution requires Linux. +- macOS and Windows host stacks are not emulated byte-for-byte. + +### Requires kernel features and host tooling + +Patchbay depends on user namespaces and network tools such as `nft` and `tc`. +If those capabilities are unavailable or restricted, labs cannot run. + +Impact: + +- Some CI and container environments need extra setup. +- Missing kernel features can block specific scenarios. + +### No wireless or cellular radio-layer simulation + +Patchbay models link effects with `tc` parameters such as latency, jitter, +loss, and rate limits. It does not model WiFi or cellular PHY/MAC behavior. + +Impact: + +- Good for transport and application resilience testing. +- Not suitable for radio scheduling or handover signaling research. + +### Dynamic routing protocols are not built in + +Patchbay focuses on static topology wiring, NAT, firewalling, and route +management through its API. It does not include built-in BGP, OSPF, or RIP +control-plane implementations. + +Impact: + +- You can still run routing daemons inside namespaces yourself. +- Protocol orchestration is user-managed, not first-class in patchbay. + +### Time and clock behavior are not virtualized + +Patchbay uses the host kernel clock and scheduler behavior. It does not +virtualize per-node clocks or provide deterministic virtual time. + +Impact: + +- Most integration tests work as expected. +- Time-sensitive distributed-system tests may need additional controls. diff --git a/docs/reference/ipv6.md b/docs/reference/ipv6.md index bdd024e..5ffa975 100644 --- a/docs/reference/ipv6.md +++ b/docs/reference/ipv6.md @@ -4,6 +4,23 @@ How IPv6 works in practice and how to simulate each scenario in patchbay. --- +## IPv6 Terms Used Here + +This page uses a few IPv6 terms that often appear as acronyms: + +- **GUA** means Global Unicast Address, an address that is globally routable. +- **ULA** means Unique Local Address, usually in `fd00::/8`, for local use. +- **Link-local address** means an address in `fe80::/10`, valid only on one link. +- **SLAAC** means Stateless Address Autoconfiguration, host-side address setup from router announcements. +- **RA** means Router Advertisement, a router message that tells hosts about prefixes and default routers. +- **RS** means Router Solicitation, a host message that asks routers for an advertisement. +- **DAD** means Duplicate Address Detection, kernel checks for address collisions before an address becomes preferred. + +When this document introduces a concept with an acronym, it also explains +what the concept does for routing and application behavior. + +--- + ## How ISPs Actually Deploy IPv6 ### Residential (FTTH, Cable, DSL) @@ -11,8 +28,8 @@ How IPv6 works in practice and how to simulate each scenario in patchbay. ISPs assign a **globally routable prefix** (typically /56 or /60) via DHCPv6-PD (Prefix Delegation). The CE (Customer Edge) router carves /64s from this prefix for each LAN segment. Devices get **public GUA addresses** -— no NAT involved. The security boundary is a **stateful firewall** on the -CE router that blocks unsolicited inbound connections (RFC 6092). +with no NAT involved. The security boundary is a **stateful firewall** +on the CE router that blocks unsolicited inbound connections (RFC 6092). IPv4 access is provided in parallel via dual-stack (separate IPv4 address with NAT44) or via DS-Lite / MAP-E / MAP-T (IPv4-in-IPv6 tunneling to the @@ -20,7 +37,7 @@ ISP's AFTR). **Key properties:** - Devices have globally routable IPv6 addresses -- No IPv6 NAT — the prefix is public +- No IPv6 NAT, the prefix is public - Stateful firewall blocks inbound, allows outbound + established - SLAAC for address assignment (not DHCPv6 address assignment) - Privacy extensions (RFC 4941) rotate source addresses @@ -30,7 +47,7 @@ ISP's AFTR). ### Mobile (4G/5G) Each device typically gets a **single /64** via RA (Router Advertisement). -The device is the only host on its /64. There is no home router — the +The device is the only host on its /64. There is no home router, and the carrier's gateway acts as the first hop. For IPv4 access, carriers use either: @@ -43,14 +60,14 @@ Some carriers (T-Mobile US, Jio) are IPv6-only with NAT64. Others **Key properties:** - One /64 per device (not shared) - NAT64/DNS64 for IPv4 access (no real IPv4 address) -- No firewall — carrier relies on per-device /64 isolation +- No firewall, carrier relies on per-device /64 isolation - 3GPP CGNAT for remaining IPv4 users ### Enterprise / Corporate Enterprises typically run dual-stack internally with PA (Provider Aggregatable) or PI (Provider Independent) space. Strict firewalls allow -only TCP 80/443 and UDP 53 outbound. All other ports are blocked — +only TCP 80/443 and UDP 53 outbound. All other ports are blocked, STUN/TURN on non-standard ports fails, must use TURN-over-TLS on 443. Some enterprises use ULA (fd00::/8) internally with NAT66 at the border, @@ -74,10 +91,10 @@ GUA addresses with a restrictive firewall. RFC 4193 ULA (fd00::/8) was designed for stable internal addressing, not as an IPv6 equivalent of RFC 1918. In practice: -- **No major ISP deploys NAT66** — it defeats the end-to-end principle +- **No major ISP deploys NAT66**, it defeats the end-to-end principle - Android **does not support NAT66** (no DHCPv6 client, only SLAAC) - ULA is used alongside GUA for stable internal addressing, never alone -- RFC 6296 NPTv6 (prefix translation) exists but is niche — mostly +- RFC 6296 NPTv6 (prefix translation) exists but is niche, mostly for multihoming, not general NAT If you need to simulate "NATted IPv6", use NPTv6 (`NatV6Mode::Nptv6`) @@ -126,14 +143,14 @@ blocks unsolicited inbound on both families. ```rust let home = lab.add_router("home").preset(RouterPreset::Home).build().await?; let laptop = lab.add_device("laptop").uplink(home.id()).build().await?; -// laptop.ip() → 10.0.x.x (private IPv4, NATted) -// laptop.ip6() → fd10:0:x::2 (ULA v6, firewalled) +// laptop.ip() -> 10.0.x.x (private IPv4, NATted) +// laptop.ip6() -> fd10:0:x::2 (ULA v6, firewalled) ``` ### Scenario 2: IPv6-Only Mobile with NAT64 A carrier network where devices only have IPv6. IPv4 destinations are -reached via NAT64 — a userspace SIIT translator on the router converts +reached via NAT64, a userspace SIIT translator on the router converts between IPv6 and IPv4 headers using the well-known prefix `64:ff9b::/96`. ```rust @@ -141,13 +158,13 @@ let carrier = lab.add_router("carrier") .preset(RouterPreset::MobileV6) .build().await?; let phone = lab.add_device("phone").uplink(carrier.id()).build().await?; -// phone.ip6() → 2001:db8:1:x::2 (public GUA) -// phone.ip() → None (no IPv4 on the device) +// phone.ip6() -> 2001:db8:1:x::2 (public GUA) +// phone.ip() -> None (no IPv4 on the device) // Reach an IPv4 server via NAT64: use patchbay::nat64::embed_v4_in_nat64; let nat64_addr = embed_v4_in_nat64(server_v4_ip); -// Connect to [64:ff9b::]:port — translated to IPv4 by the router +// Connect to [64:ff9b::]:port, translated to IPv4 by the router ``` The `MobileV6` preset configures: `IpSupport::V6Only` + `NatV6Mode::Nat64` @@ -164,7 +181,7 @@ let carrier = lab.add_router("carrier") ### Scenario 3: Corporate Firewall (Restrictive) Enterprise network that blocks everything except web traffic. STUN/ICE -fails — P2P apps must fall back to TURN-over-TLS on port 443. +fails, P2P apps must fall back to TURN-over-TLS on port 443. ```rust let corp = lab.add_router("corp").preset(RouterPreset::Corporate).build().await?; @@ -231,6 +248,61 @@ let charlie = lab.add_device("charlie").uplink(corp.id()).build().await?; | NAT64 | `NatV6Mode::Nat64` | Userspace SIIT + nftables masquerade | | DHCPv6-PD | *not planned* | Use static /64 allocation | +## Link-Local Addressing and Scope + +Patchbay assigns and exposes link-local IPv6 addresses on IPv6-capable +interfaces. + +- Device side: `DeviceIface::ll6()` +- Router side: `RouterIface::ll6()` +- Router snapshots: `Router::iface(name)` and `Router::interfaces()` + +Use `ip6()` when you need a global/ULA source or destination. Use `ll6()` +for neighbor/router-local checks and link-local route assertions. + +### Provisioning mode and DAD mode + +Configure IPv6 behavior at lab creation with `LabOpts`: + +```rust +let lab = Lab::with_opts( + LabOpts::default() + .ipv6_provisioning_mode(Ipv6ProvisioningMode::Static) + .ipv6_dad_mode(Ipv6DadMode::Enabled), +).await?; +``` + +- `Ipv6ProvisioningMode::Static`: patchbay installs routes during wiring. +- `Ipv6ProvisioningMode::RaDriven`: enables patchbay's RA/RS-driven provisioning path. +- `Ipv6DadMode::Disabled`: deterministic mode, current default. +- `Ipv6DadMode::Enabled`: kernel DAD behavior in namespaces. + +In `RaDriven` mode, patchbay emits structured RA/RS events and installs +link-local scoped default routes for default interfaces. This models host +routing behavior while keeping tests deterministic and introspectable. + +### Fidelity and Current Limitations + +Patchbay currently models RA and RS behavior at the control-plane level: + +- It updates routes and emits structured RA and RS events in tracing logs. +- It does not currently emit raw ICMPv6 RA or RS packets on virtual links. + +This means most application-level route and connectivity behavior is covered, +but some protocol-observer behavior is not yet modeled: + +- Full SLAAC state-machine behavior across all timers and transitions. +- Neighbor Discovery timing details, including exact probe/retransmit timing. +- Host temporary address rotation and privacy-address lifecycles. + +For the complete project-wide list, see [Limitations](../limitations.md). + +### Scoped default route behavior + +When an IPv6 default gateway is link-local (`fe80::/10`), route installation +must include interface scope. Patchbay uses scoped route installation for this +path, so default routing remains valid after interface changes. + --- ## Common Pitfalls @@ -244,7 +316,7 @@ neighbor discovery breaks and the router becomes unreachable. ### IPv6 Firewall Is Not Optional On IPv4, NAT implicitly blocks inbound connections (no port mapping = no -access). On IPv6 with public GUA addresses, there is **no NAT** — devices +access). On IPv6 with public GUA addresses, there is **no NAT**, devices are directly addressable. Without `Firewall::BlockInbound`, any host on the IX can connect to your devices. This matches reality: every CE router ships with an IPv6 stateful firewall enabled by default. diff --git a/docs/reference/toml-reference.md b/docs/reference/toml-reference.md index 6c1eef8..0a40d6f 100644 --- a/docs/reference/toml-reference.md +++ b/docs/reference/toml-reference.md @@ -142,7 +142,7 @@ value before the steps execute. This substitution happens at expansion time # In the group step: content = "cert_path = \"${${group.device}-cert.cert_pem_path}\"" # After group expansion (e.g. device="relay"): -# → cert_path = "${relay-cert.cert_pem_path}" +# -> cert_path = "${relay-cert.cert_pem_path}" # Then resolved at runtime as a capture reference. ``` @@ -176,10 +176,10 @@ Runs a command and waits for it to exit before moving to the next step. | Key | Type | Default | Description | |------------|---------|----------|-------------| | `cmd` | array | required | Command and arguments. Supports `${binary.}`, `$NETSIM_IP_`, `${id.capture}`. | -| `args` | array | — | Appended to the template's `cmd`. Does not replace it. | +| `args` | array | none | Appended to the template's `cmd`. Does not replace it. | | `parser` | string | `"text"` | Output parser. See [parsers](#parsers). | -| `captures` | table | — | Named captures. See [`[captures]`](#captures). | -| `results` | table | — | Normalized result fields. See [`[results]`](#results). | +| `captures` | table | none | Named captures. See [`[captures]`](#captures). | +| `results` | table | none | Normalized result fields. See [`[results]`](#results). | --- @@ -190,11 +190,11 @@ Starts a process in the background. A later `wait-for` step waits for it to exit | Key | Type | Default | Description | |---------------|---------|----------|-------------| | `cmd` | array | required | Command and arguments. | -| `args` | array | — | Appended to the template's `cmd`. | +| `args` | array | none | Appended to the template's `cmd`. | | `parser` | string | `"text"` | Output parser. See [parsers](#parsers). | -| `ready_after` | duration| — | How long to wait after spawning before the next step runs. Useful when a process needs startup time but doesn't print a ready signal. | -| `captures` | table | — | Named captures. See [`[captures]`](#captures). | -| `results` | table | — | Normalized result fields. Collected when the process exits. | +| `ready_after` | duration| none | How long to wait after spawning before the next step runs. Useful when a process needs startup time but doesn't print a ready signal. | +| `captures` | table | none | Named captures. See [`[captures]`](#captures). | +| `results` | table | none | Normalized result fields. Collected when the process exits. | --- @@ -263,7 +263,7 @@ written to `{work_dir}/certs/{id}/` and also stored as captures. | Key | Type | Default | Description | |----------|-----------------|-----------------------------|-------------| | `id` | string | required | Step ID, prefixes the output captures. | -| `device` | string | — | Device whose IP is automatically added to the Subject Alternative Names. | +| `device` | string | none | Device whose IP is automatically added to the Subject Alternative Names. | | `cn` | string | `"localhost"` | Certificate Common Name. | | `san` | array of strings| `[device_ip, "localhost"]` | SANs. `$NETSIM_IP_` variables are expanded. | @@ -369,9 +369,9 @@ pick = ".endpoint_id" | Key | Type | Default | Description | |---------|--------|------------|-------------| | `pipe` | string | `"stdout"` | Which output stream to read: `"stdout"` or `"stderr"`. | -| `regex` | string | — | Regex applied to the raw text line. Group 1 is captured if present, otherwise the full match. Works with all parsers. Cannot be combined with `pick`. | -| `match` | table | — | Key=value guards on a parsed JSON object. All keys must match. Requires `pick`. Only valid with `"ndjson"` or `"json"` parser. | -| `pick` | string | — | Dot-path into the parsed JSON value, e.g. `".endpoint_id"` or `".end.sum_received.bytes"`. Requires `"ndjson"` or `"json"` parser. Cannot be combined with `regex`. | +| `regex` | string | none | Regex applied to the raw text line. Group 1 is captured if present, otherwise the full match. Works with all parsers. Cannot be combined with `pick`. | +| `match` | table | none | Key=value guards on a parsed JSON object. All keys must match. Requires `pick`. Only valid with `"ndjson"` or `"json"` parser. | +| `pick` | string | none | Dot-path into the parsed JSON value, e.g. `".endpoint_id"` or `".end.sum_received.bytes"`. Requires `"ndjson"` or `"json"` parser. Cannot be combined with `regex`. | With `"ndjson"`, every matching line appends to the capture's history. With `"json"` or `regex`, the capture is set once from the final matched value. @@ -580,7 +580,7 @@ file = "iroh-defaults.toml" name = "iroh-1to1-nat" topology = "1to1-nat" -# Expands to: gen-certs → gen-file (relay config) → spawn relay +# Expands to: gen-certs -> gen-file (relay config) -> spawn relay [[step]] use = "relay-setup" vars = { device = "relay" } diff --git a/patchbay/src/config.rs b/patchbay/src/config.rs index b54b90f..972bd78 100644 --- a/patchbay/src/config.rs +++ b/patchbay/src/config.rs @@ -45,4 +45,10 @@ pub struct RouterConfig { /// IPv6 NAT mode. Defaults to `"none"`. #[serde(default)] pub nat_v6: NatV6Mode, + /// Optional override for RA emission in RA-driven provisioning mode. + pub ra_enabled: Option, + /// Optional RA interval in seconds. + pub ra_interval_secs: Option, + /// Optional RA lifetime in seconds. + pub ra_lifetime_secs: Option, } diff --git a/patchbay/src/core.rs b/patchbay/src/core.rs index 59dc033..8559042 100644 --- a/patchbay/src/core.rs +++ b/patchbay/src/core.rs @@ -2,7 +2,10 @@ use std::{ collections::HashMap, net::{IpAddr, Ipv4Addr, Ipv6Addr}, path::PathBuf, - sync::{atomic::AtomicU64, Arc}, + sync::{ + atomic::{AtomicBool, AtomicU64, Ordering}, + Arc, + }, }; use anyhow::{anyhow, bail, Context, Result}; @@ -11,10 +14,14 @@ use tokio_util::sync::CancellationToken; use tracing::{debug, instrument, Instrument as _}; use crate::{ - netlink::Netlink, netns, qdisc, ConntrackTimeouts, Firewall, IpSupport, LinkCondition, Nat, - NatConfig, NatFiltering, NatMapping, NatV6Mode, + netlink::Netlink, netns, qdisc, ConntrackTimeouts, Firewall, IpSupport, Ipv6DadMode, + Ipv6ProvisioningMode, LinkCondition, Nat, NatConfig, NatFiltering, NatMapping, NatV6Mode, }; +pub(crate) const RA_DEFAULT_ENABLED: bool = true; +pub(crate) const RA_DEFAULT_INTERVAL_SECS: u64 = 30; +pub(crate) const RA_DEFAULT_LIFETIME_SECS: u64 = 1800; + /// Defines static addressing and naming for one lab instance. #[derive(Clone, Debug)] pub(crate) struct CoreConfig { @@ -82,6 +89,12 @@ pub(crate) struct RouterConfig { pub block_icmp_frag_needed: bool, /// Firewall preset for the router's forward chain. pub firewall: Firewall, + /// Whether this router emits Router Advertisements in RA-driven mode. + pub ra_enabled: bool, + /// Router Advertisement interval in seconds. + pub ra_interval_secs: u64, + /// Router Advertisement lifetime in seconds. + pub ra_lifetime_secs: u64, } impl RouterConfig { @@ -124,6 +137,16 @@ pub(crate) struct ReplugIfaceSetup { pub root_ns: Arc, } +pub(crate) struct DeviceDefaultV6RouteTarget { + pub ns: Arc, + pub ifname: Arc, +} + +pub(crate) struct DownlinkV6Gateways { + pub global_v6: Option, + pub link_local_v6: Option, +} + /// One network interface on a device, connected to a router's downstream switch. #[derive(Clone, Debug)] pub(crate) struct DeviceIfaceData { @@ -135,6 +158,8 @@ pub(crate) struct DeviceIfaceData { pub ip: Option, /// Assigned IPv6 address. pub ip_v6: Option, + /// Assigned IPv6 link-local address. + pub ll_v6: Option, /// Optional link impairment applied via `tc netem`. pub impair: Option, /// Unique index used to name the root-namespace veth ends. @@ -156,6 +181,8 @@ pub(crate) struct DeviceData { pub default_via: Arc, /// Optional MTU for all interfaces. pub mtu: Option, + /// Optional per-device IPv6 provisioning override. + pub provisioning_mode: Option, /// Per-device operation lock — serializes multi-step mutations. pub op: Arc>, } @@ -203,6 +230,8 @@ pub(crate) struct RouterData { pub upstream_ip: Option, /// Router uplink IPv6 address. pub upstream_ip_v6: Option, + /// Router uplink IPv6 link-local address. + pub upstream_ll_v6: Option, /// Downstream switch identifier. pub downlink: Option, /// Downstream subnet CIDR. @@ -213,10 +242,74 @@ pub(crate) struct RouterData { pub downstream_cidr_v6: Option, /// Downstream IPv6 gateway address. pub downstream_gw_v6: Option, + /// Downstream bridge IPv6 link-local address. + pub downstream_ll_v6: Option, + /// Runtime RA settings consumed by the RA worker. + pub ra_runtime: Arc, /// Per-router operation lock — serializes multi-step mutations. pub op: Arc>, } +impl RouterData { + pub(crate) fn ra_default_enabled(&self) -> bool { + self.cfg.ra_enabled && self.cfg.ra_lifetime_secs > 0 + } + + pub(crate) fn active_downstream_ll_v6(&self) -> Option { + if self.ra_default_enabled() { + self.downstream_ll_v6 + } else { + None + } + } +} + +#[derive(Debug)] +pub(crate) struct RaRuntimeCfg { + enabled: AtomicBool, + interval_secs: AtomicU64, + lifetime_secs: AtomicU64, + changed: tokio::sync::Notify, +} + +impl RaRuntimeCfg { + pub(crate) fn new(enabled: bool, interval_secs: u64, lifetime_secs: u64) -> Self { + Self { + enabled: AtomicBool::new(enabled), + interval_secs: AtomicU64::new(interval_secs), + lifetime_secs: AtomicU64::new(lifetime_secs), + changed: tokio::sync::Notify::new(), + } + } + + pub(crate) fn load(&self) -> (bool, u64, u64) { + ( + self.enabled.load(Ordering::Relaxed), + self.interval_secs.load(Ordering::Relaxed), + self.lifetime_secs.load(Ordering::Relaxed), + ) + } + + pub(crate) fn set_enabled(&self, enabled: bool) { + self.enabled.store(enabled, Ordering::Relaxed); + self.changed.notify_waiters(); + } + + pub(crate) fn set_interval_secs(&self, secs: u64) { + self.interval_secs.store(secs.max(1), Ordering::Relaxed); + self.changed.notify_waiters(); + } + + pub(crate) fn set_lifetime_secs(&self, secs: u64) { + self.lifetime_secs.store(secs, Ordering::Relaxed); + self.changed.notify_waiters(); + } + + pub(crate) fn notified(&self) -> tokio::sync::futures::Notified<'_> { + self.changed.notified() + } +} + impl RouterData { /// Returns the WAN interface name: `"ix"` for IX-connected routers, `"wan"` for sub-routers. pub(crate) fn wan_ifname(&self, ix_sw: NodeId) -> &'static str { @@ -260,6 +353,8 @@ pub(crate) struct IfaceBuild { pub(crate) prefix_len: u8, pub(crate) gw_ip_v6: Option, pub(crate) dev_ip_v6: Option, + pub(crate) gw_ll_v6: Option, + pub(crate) dev_ll_v6: Option, pub(crate) prefix_len_v6: u8, pub(crate) impair: Option, pub(crate) ifname: Arc, @@ -460,6 +555,10 @@ pub(crate) struct LabInner { pub ns_to_name: std::sync::Mutex>, /// Resolved run output directory (e.g. `{base}/{ts}-{label}/`), if outdir was configured. pub run_dir: Option, + /// IPv6 duplicate address detection behavior. + pub ipv6_dad_mode: Ipv6DadMode, + /// IPv6 provisioning behavior. + pub ipv6_provisioning_mode: Ipv6ProvisioningMode, } impl Drop for LabInner { @@ -756,16 +855,26 @@ impl NetworkCore { mtu: None, block_icmp_frag_needed: false, firewall: Firewall::None, + ra_enabled: RA_DEFAULT_ENABLED, + ra_interval_secs: RA_DEFAULT_INTERVAL_SECS, + ra_lifetime_secs: RA_DEFAULT_LIFETIME_SECS, }, downlink_bridge, uplink: None, upstream_ip: None, upstream_ip_v6: None, + upstream_ll_v6: None, downlink: None, downstream_cidr: None, downstream_gw: None, downstream_cidr_v6: None, downstream_gw_v6: None, + downstream_ll_v6: None, + ra_runtime: Arc::new(RaRuntimeCfg::new( + RA_DEFAULT_ENABLED, + RA_DEFAULT_INTERVAL_SECS, + RA_DEFAULT_LIFETIME_SECS, + )), op: Arc::new(tokio::sync::Mutex::new(())), }, ); @@ -791,6 +900,7 @@ impl NetworkCore { interfaces: vec![], default_via: "".into(), mtu: None, + provisioning_mode: None, op: Arc::new(tokio::sync::Mutex::new(())), }, ); @@ -846,6 +956,7 @@ impl NetworkCore { uplink: downlink, ip: assigned, ip_v6: assigned_v6, + ll_v6: assigned_v6.map(|_| link_local_from_seed(idx)), impair, idx, }); @@ -885,9 +996,12 @@ impl NetworkCore { let gw_router = sw .owner_router .ok_or_else(|| anyhow!("switch missing owner"))?; + let gw_router_data = self + .router(gw_router) + .ok_or_else(|| anyhow!("gateway router missing"))?; let gw_br = sw.bridge.clone().unwrap_or_else(|| "br-lan".into()); - let gw_ns = self.router(gw_router).unwrap().ns.clone(); - + let gw_ns = gw_router_data.ns.clone(); + let gw_ll_v6 = gw_router_data.active_downstream_ll_v6(); let iface_build = IfaceBuild { dev_ns, gw_ns, @@ -897,6 +1011,8 @@ impl NetworkCore { prefix_len: sw.cidr.map(|c| c.prefix_len()).unwrap_or(24), gw_ip_v6: sw.gw_v6, dev_ip_v6: iface.ip_v6, + gw_ll_v6, + dev_ll_v6: iface.ll_v6, prefix_len_v6: sw.cidr_v6.map(|c| c.prefix_len()).unwrap_or(64), impair, ifname: ifname.into(), @@ -970,6 +1086,8 @@ impl NetworkCore { prefix_len, gw_ip_v6: sw.gw_v6, dev_ip_v6: new_ip_v6, + gw_ll_v6: target_router.active_downstream_ll_v6(), + dev_ll_v6: new_ip_v6.map(|_| link_local_from_seed(old_idx)), prefix_len_v6: sw.cidr_v6.map(|c| c.prefix_len()).unwrap_or(64), impair, ifname: ifname.into(), @@ -1004,6 +1122,7 @@ impl NetworkCore { iface.uplink = new_uplink; iface.ip = new_ip; iface.ip_v6 = new_ip_v6; + iface.ll_v6 = new_ip_v6.map(|_| link_local_from_seed(iface.idx)); } Ok(()) } @@ -1031,6 +1150,65 @@ impl NetworkCore { .ok_or_else(|| anyhow!("switch missing gateway ip")) } + /// Returns IPv6 default-router candidates for a router downstream switch. + pub(crate) fn router_downlink_gw6_for_switch(&self, sw: NodeId) -> Result { + let switch = self + .switches + .get(&sw) + .ok_or_else(|| anyhow!("switch missing"))?; + let link_local_v6 = switch + .owner_router + .and_then(|rid| self.routers.get(&rid)) + .and_then(|r| r.downstream_ll_v6); + Ok(DownlinkV6Gateways { + global_v6: switch.gw_v6, + link_local_v6, + }) + } + + pub(crate) fn router_default_v6_targets( + &self, + router: NodeId, + default_mode: Ipv6ProvisioningMode, + ) -> Result> { + let downlink = self + .router(router) + .ok_or_else(|| anyhow!("router removed"))? + .downlink + .ok_or_else(|| anyhow!("router has no downlink"))?; + + let mut out = Vec::new(); + for dev in self.devices.values() { + let Some(iface) = dev.iface(&dev.default_via) else { + continue; + }; + let mode = dev.provisioning_mode.unwrap_or(default_mode); + if mode != Ipv6ProvisioningMode::RaDriven { + continue; + } + if iface.uplink == downlink && iface.ip_v6.is_some() { + out.push(DeviceDefaultV6RouteTarget { + ns: dev.ns.clone(), + ifname: iface.ifname.clone(), + }); + } + } + Ok(out) + } + + /// Returns whether RA-driven default-route learning is active for this switch. + pub(crate) fn ra_default_enabled_for_switch(&self, sw: NodeId) -> Result { + let switch = self + .switches + .get(&sw) + .ok_or_else(|| anyhow!("switch missing"))?; + let router = switch + .owner_router + .and_then(|rid| self.routers.get(&rid)) + .ok_or_else(|| anyhow!("switch missing owner router"))?; + Ok(router.ra_default_enabled()) + } + /// Adds a switch node and returns its identifier. pub(crate) fn add_switch( &mut self, @@ -1074,6 +1252,7 @@ impl NetworkCore { router_entry.uplink = Some(sw); router_entry.upstream_ip = ip; router_entry.upstream_ip_v6 = ip_v6; + router_entry.upstream_ll_v6 = ip_v6.map(|_| link_local_from_seed(seed2(router.0, sw.0))); Ok(()) } @@ -1172,6 +1351,8 @@ impl NetworkCore { router_entry.downstream_gw = gw; router_entry.downstream_cidr_v6 = cidr_v6; router_entry.downstream_gw_v6 = gw_v6; + router_entry.downstream_ll_v6 = + cidr_v6.map(|_| link_local_from_seed(seed3(router.0, sw.0, 0xA5A5))); Ok((cidr, gw)) } @@ -1771,9 +1952,10 @@ where pub(crate) async fn setup_root_ns_async( cfg: &CoreConfig, netns: &Arc, + dad_mode: Ipv6DadMode, ) -> Result<()> { let root_ns = cfg.root_ns.clone(); - create_named_netns(netns, &root_ns, None, None)?; + create_named_netns(netns, &root_ns, None, None, dad_mode)?; netns.run_closure_in(&root_ns, || { set_sysctl_root("net/ipv4/ip_forward", "1")?; @@ -1832,6 +2014,12 @@ pub(crate) struct RouterSetupData { pub parent_route_v4: Option<(Arc, Ipv4Addr, u8, Ipv4Addr)>, // (parent_ns, net, prefix, via) /// Cancellation token for long-running background tasks (NAT64 translator). pub cancel: CancellationToken, + /// IPv6 DAD behavior for created namespaces. + pub dad_mode: Ipv6DadMode, + /// IPv6 provisioning behavior. + pub provisioning_mode: Ipv6ProvisioningMode, + /// Whether RA worker should run for this router. + pub ra_enabled: bool, } /// Sets up a single router's namespaces, links, and NAT. No lock held. @@ -1845,7 +2033,7 @@ pub(crate) async fn setup_router_async( debug!(name = %router.name, ns = %router.ns, "router: setup"); let log_prefix = format!("{}.{}", crate::consts::KIND_ROUTER, router.name); - create_named_netns(netns, &router.ns, None, Some(log_prefix))?; + create_named_netns(netns, &router.ns, None, Some(log_prefix), data.dad_mode)?; let uplink = router .uplink @@ -1899,6 +2087,9 @@ pub(crate) async fn setup_router_async( (d.router.upstream_ip_v6, d.ix_cidr_v6_prefix, d.ix_gw_v6) { h.add_addr6(&ns_if, ip6, prefix6).await?; + if let Some(ll6) = d.router.upstream_ll_v6 { + h.add_addr6(&ns_if, ll6, 64).await?; + } h.add_default_route_v6(gw6).await?; } Ok(()) @@ -2019,7 +2210,14 @@ pub(crate) async fn setup_router_async( d.upstream_gw_v6, ) { h.add_addr6(&wan_if, ip6, prefix6).await?; - h.add_default_route_v6(g6).await?; + if let Some(ll6) = d.router.upstream_ll_v6 { + h.add_addr6(&wan_if, ll6, 64).await?; + } + if g6.is_unicast_link_local() { + h.add_default_route_v6_scoped(&wan_if, g6).await?; + } else { + h.add_default_route_v6(g6).await?; + } } Ok(()) } @@ -2056,6 +2254,7 @@ pub(crate) async fn setup_router_async( // Create downlink bridge. if let Some((br, v4_addr)) = &data.downlink_bridge { let downlink_v6 = data.downlink_bridge_v6; + let downlink_ll_v6 = data.router.downstream_ll_v6; let v4_addr = *v4_addr; nl_run(netns, &router.ns, { let br = br.clone(); @@ -2070,6 +2269,9 @@ pub(crate) async fn setup_router_async( if let Some((gw_v6, prefix_v6)) = downlink_v6 { h.add_addr6(&br, gw_v6, prefix_v6).await?; } + if let Some(ll6) = downlink_ll_v6 { + h.add_addr6(&br, ll6, 64).await?; + } Ok(()) } }) @@ -2149,9 +2351,98 @@ pub(crate) async fn setup_router_async( setup_nat64(netns, &router.ns, fw_wan, &data.cancel).await?; } + // RA worker scaffold for RA-driven mode. + if data.provisioning_mode == Ipv6ProvisioningMode::RaDriven + && data.ra_enabled + && router.cfg.ip_support.has_v6() + { + spawn_ra_worker( + netns, + &router.ns, + data.cancel.clone(), + RaWorkerCfg { + ra_runtime: Arc::clone(&router.ra_runtime), + router_name: router.name.to_string(), + iface: router.downlink_bridge.to_string(), + src_ll: router.downstream_ll_v6, + }, + )?; + } + Ok(()) } +fn spawn_ra_worker( + netns: &Arc, + ns: &str, + cancel: CancellationToken, + cfg: RaWorkerCfg, +) -> Result<()> { + let rt = netns.rt_handle_for(ns)?; + let ns = ns.to_string(); + rt.spawn(async move { + let load_runtime = || cfg.ra_runtime.load(); + + let emit_ra = |interval_secs: u64, lifetime_secs: u64| { + if let Some(src) = cfg.src_ll { + tracing::info!( + target: "patchbay::_events::RouterAdvertisement", + ns = %ns, + router = %cfg.router_name, + iface = %cfg.iface, + src = %src, + lifetime_secs, + interval_secs, + "router advertisement" + ); + } else { + tracing::warn!( + ns = %ns, + router = %cfg.router_name, + "ra-worker: missing link-local source address" + ); + } + }; + + let (enabled, interval_secs, lifetime_secs) = load_runtime(); + if enabled { + emit_ra(interval_secs, lifetime_secs); + } + + loop { + let (_, interval_secs, _) = load_runtime(); + let changed = cfg.ra_runtime.notified(); + tokio::pin!(changed); + tokio::select! { + _ = cancel.cancelled() => break, + _ = &mut changed => { + tracing::trace!(ns = %ns, "ra-worker: runtime config changed"); + let (enabled, interval_secs, lifetime_secs) = load_runtime(); + if enabled { + emit_ra(interval_secs, lifetime_secs); + } + } + _ = tokio::time::sleep(tokio::time::Duration::from_secs(interval_secs)) => { + tracing::trace!(ns = %ns, interval_secs, "ra-worker: tick"); + let (enabled, interval_secs, lifetime_secs) = load_runtime(); + if enabled { + emit_ra(interval_secs, lifetime_secs); + } + } + } + } + tracing::trace!(ns = %ns, "ra-worker: stopped"); + }); + Ok(()) +} + +struct RaWorkerCfg { + ra_runtime: Arc, + router_name: String, + iface: String, + src_ll: Option, +} + /// Sets up NAT64 in the router namespace: /// 1. Creates TUN device `nat64` /// 2. Assigns the NAT64 IPv4 pool address @@ -2329,21 +2620,76 @@ pub(crate) async fn remove_firewall(netns: &netns::NetnsManager, ns: &str) -> Re } /// Sets up a single device's namespace and wires all interfaces. No lock held. -#[instrument(name = "device", skip_all, fields(id = dev.id.0))] +pub(crate) struct DeviceSetupData { + pub prefix: Arc, + pub root_ns: Arc, + pub dev: DeviceData, + pub ifaces: Vec, + pub dns_overlay: Option, + pub dad_mode: Ipv6DadMode, + pub provisioning_mode: Ipv6ProvisioningMode, +} + +/// Sets up a single device's namespace and wires all interfaces. No lock held. +#[instrument(name = "device", skip_all)] pub(crate) async fn setup_device_async( netns: &Arc, - prefix: &str, - root_ns: &str, - dev: &DeviceData, - ifaces: Vec, - dns_overlay: Option, + data: DeviceSetupData, ) -> Result<()> { - debug!(name = %dev.name, ns = %dev.ns, "device: setup"); + let DeviceSetupData { + prefix, + root_ns, + dev, + ifaces, + dns_overlay, + dad_mode, + provisioning_mode, + } = data; + let rs_ifaces: Vec<(Arc, Option)> = + if provisioning_mode == Ipv6ProvisioningMode::RaDriven { + ifaces + .iter() + .filter(|iface| iface.is_default && iface.dev_ip_v6.is_some()) + .map(|iface| (iface.ifname.clone(), iface.gw_ll_v6)) + .collect() + } else { + Vec::new() + }; + debug!(id = dev.id.0, name = %dev.name, ns = %dev.ns, "device: setup"); let log_prefix = format!("{}.{}", crate::consts::KIND_DEVICE, dev.name); - create_named_netns(netns, &dev.ns, dns_overlay, Some(log_prefix))?; + create_named_netns(netns, &dev.ns, dns_overlay, Some(log_prefix), dad_mode)?; for iface in ifaces { - wire_iface_async(netns, prefix, root_ns, iface).await?; + wire_iface_async(netns, &prefix, &root_ns, iface).await?; + } + + for (ifname, router_ll) in rs_ifaces { + let ns = dev.ns.clone(); + let ns_for_log = ns.clone(); + let dev_name = dev.name.to_string(); + let iface = ifname.to_string(); + nl_run(netns, &ns, move |_h: Netlink| async move { + let router_ll = router_ll.map(|ll| ll.to_string()); + tracing::info!( + target: "patchbay::_events::RouterSolicitation", + ns = %ns_for_log, + device = %dev_name, + iface = %iface, + dst = "ff02::2", + router_ll = router_ll.as_deref(), + "router solicitation" + ); + if router_ll.is_none() { + tracing::trace!( + ns = %ns_for_log, + device = %dev_name, + iface = %iface, + "router solicitation emitted without router_ll" + ); + } + Ok(()) + }) + .await?; } // Apply MTU on all device interfaces if configured. @@ -2390,7 +2736,7 @@ pub(crate) async fn wire_iface_async( }) .await?; - // DAD already disabled by create_named_netns. + // DAD mode was configured by create_named_netns before interfaces were created. nl_run(netns, &dev.dev_ns, { let d = dev.clone(); let root_dev = root_dev.clone(); @@ -2408,9 +2754,14 @@ pub(crate) async fn wire_iface_async( } if let Some(ip6) = d.dev_ip_v6 { h.add_addr6(&d.ifname, ip6, d.prefix_len_v6).await?; + if let Some(ll6) = d.dev_ll_v6 { + h.add_addr6(&d.ifname, ll6, 64).await?; + } if d.is_default { if let Some(gw6) = d.gw_ip_v6 { h.add_default_route_v6(gw6).await?; + } else if let Some(gw_ll6) = d.gw_ll_v6 { + h.add_default_route_v6_scoped(&d.ifname, gw_ll6).await?; } } } @@ -2446,29 +2797,58 @@ fn add_host(cidr: Ipv4Net, host: u8) -> Result { Ok(Ipv4Addr::new(octets[0], octets[1], octets[2], host)) } +fn splitmix64(mut x: u64) -> u64 { + x = x.wrapping_add(0x9E3779B97F4A7C15); + x = (x ^ (x >> 30)).wrapping_mul(0xBF58476D1CE4E5B9); + x = (x ^ (x >> 27)).wrapping_mul(0x94D049BB133111EB); + x ^ (x >> 31) +} + +fn seed2(a: u64, b: u64) -> u64 { + splitmix64(a.rotate_left(7) ^ b.rotate_right(3) ^ 0xC2B2AE3D27D4EB4F) +} + +fn seed3(a: u64, b: u64, c: u64) -> u64 { + splitmix64(seed2(a, b) ^ c.rotate_left(17)) +} + +fn link_local_from_seed(seed: u64) -> Ipv6Addr { + let mixed = splitmix64(seed); + let mut iid = mixed.to_be_bytes(); + iid[0] |= 0x02; + let a = u16::from_be_bytes([iid[0], iid[1]]); + let b = u16::from_be_bytes([iid[2], iid[3]]); + let c = u16::from_be_bytes([iid[4], iid[5]]); + let d = u16::from_be_bytes([iid[6], iid[7]]); + Ipv6Addr::new(0xfe80, 0, 0, 0, a, b, c, d) +} + // ───────────────────────────────────────────── // Netns + process helpers // ───────────────────────────────────────────── -/// Creates a namespace with optional DNS overlay and disables IPv6 DAD. +/// Creates a namespace with optional DNS overlay and applies IPv6 DAD mode. /// -/// IPv6 DAD is disabled immediately so interfaces moved in will inherit -/// `dad_transmits=0` and addresses go straight to "valid" state. +/// When `dad_mode` is disabled, this sets `accept_dad=0` and +/// `dad_transmits=0` before interfaces are moved in. pub(crate) fn create_named_netns( netns: &netns::NetnsManager, name: &str, dns_overlay: Option, log_prefix: Option, + dad_mode: Ipv6DadMode, ) -> Result<()> { netns.create_netns(name, dns_overlay, log_prefix)?; - // Disable DAD before any interfaces are created or moved in. - netns.run_closure_in(name, || { - set_sysctl_root("net/ipv6/conf/all/accept_dad", "0").ok(); - set_sysctl_root("net/ipv6/conf/default/accept_dad", "0").ok(); - set_sysctl_root("net/ipv6/conf/all/dad_transmits", "0").ok(); - set_sysctl_root("net/ipv6/conf/default/dad_transmits", "0").ok(); - Ok(()) - })?; + if dad_mode == Ipv6DadMode::Disabled { + // Disable DAD before any interfaces are created or moved in. + netns.run_closure_in(name, || { + set_sysctl_root("net/ipv6/conf/all/accept_dad", "0").ok(); + set_sysctl_root("net/ipv6/conf/default/accept_dad", "0").ok(); + set_sysctl_root("net/ipv6/conf/all/dad_transmits", "0").ok(); + set_sysctl_root("net/ipv6/conf/default/dad_transmits", "0").ok(); + Ok(()) + })?; + } Ok(()) } diff --git a/patchbay/src/event.rs b/patchbay/src/event.rs index 1adf1a0..44a3124 100644 --- a/patchbay/src/event.rs +++ b/patchbay/src/event.rs @@ -266,6 +266,8 @@ pub struct IfaceSnapshot { pub ip: Option, /// IPv6 address. pub ip_v6: Option, + /// IPv6 link-local address. + pub ll_v6: Option, /// Link condition. pub link_condition: Option, } @@ -352,6 +354,8 @@ pub struct RouterState { pub uplink_ip: Option, /// WAN IPv6 address. pub uplink_ip_v6: Option, + /// WAN IPv6 link-local address. + pub uplink_ll_v6: Option, /// LAN IPv4 CIDR. pub downstream_cidr: Option, /// LAN IPv4 gateway. @@ -360,6 +364,8 @@ pub struct RouterState { pub downstream_cidr_v6: Option, /// LAN IPv6 gateway. pub downstream_gw_v6: Option, + /// LAN IPv6 link-local address. + pub downstream_ll_v6: Option, /// Downstream bridge name. pub downstream_bridge: String, /// Downlink condition (applies to all downstream traffic). @@ -423,10 +429,12 @@ impl RouterState { upstream: upstream_name, uplink_ip: r.upstream_ip, uplink_ip_v6: r.upstream_ip_v6, + uplink_ll_v6: r.upstream_ll_v6, downstream_cidr: r.downstream_cidr, downstream_gw: r.downstream_gw, downstream_cidr_v6: r.downstream_cidr_v6, downstream_gw_v6: r.downstream_gw_v6, + downstream_ll_v6: r.downstream_ll_v6, downstream_bridge, downlink_condition: None, devices: Vec::new(), @@ -456,6 +464,7 @@ impl DeviceState { router: router_name, ip: iface.ip, ip_v6: iface.ip_v6, + ll_v6: iface.ll_v6, link_condition: iface.impair, } }) @@ -782,10 +791,12 @@ mod tests { upstream: None, uplink_ip: Some(Ipv4Addr::new(198, 18, 0, 2)), uplink_ip_v6: None, + uplink_ll_v6: None, downstream_cidr: Some("10.0.1.0/24".parse().unwrap()), downstream_gw: Some(Ipv4Addr::new(10, 0, 1, 1)), downstream_cidr_v6: None, downstream_gw_v6: None, + downstream_ll_v6: None, downstream_bridge: "br-1".into(), downlink_condition: None, devices: Vec::new(), @@ -803,6 +814,7 @@ mod tests { router: "r1".into(), ip: Some(Ipv4Addr::new(10, 0, 1, 2)), ip_v6: None, + ll_v6: None, link_condition: None, }], counters: BTreeMap::new(), @@ -868,10 +880,12 @@ mod tests { upstream: None, uplink_ip: Some(Ipv4Addr::new(198, 18, 0, 2)), uplink_ip_v6: None, + uplink_ll_v6: None, downstream_cidr: Some("10.0.1.0/24".parse().unwrap()), downstream_gw: Some(Ipv4Addr::new(10, 0, 1, 1)), downstream_cidr_v6: None, downstream_gw_v6: None, + downstream_ll_v6: None, downstream_bridge: "br-2".into(), downlink_condition: None, devices: Vec::new(), @@ -893,6 +907,7 @@ mod tests { router: "r1".into(), ip: Some(Ipv4Addr::new(10, 0, 1, 2)), ip_v6: None, + ll_v6: None, link_condition: None, }], counters: BTreeMap::new(), diff --git a/patchbay/src/handles.rs b/patchbay/src/handles.rs index 8d2c14a..7ad1060 100644 --- a/patchbay/src/handles.rs +++ b/patchbay/src/handles.rs @@ -33,11 +33,89 @@ use crate::{ }, event::{IfaceSnapshot, LabEventKind}, firewall::Firewall, - lab::{Lab, LinkCondition, ObservedAddr}, + lab::{Ipv6ProvisioningMode, Lab, LinkCondition, ObservedAddr}, nat::{IpSupport, Nat, NatV6Mode}, netlink::Netlink, }; +async fn emit_router_solicitation( + netns: &Arc, + ns: String, + device: String, + iface: String, + router_ll: Option, +) -> Result<()> { + let ns_for_log = ns.clone(); + core::nl_run(netns, &ns, move |_h: Netlink| async move { + match router_ll { + Some(router_ll) => { + tracing::info!( + target: "patchbay::_events::RouterSolicitation", + ns = %ns_for_log, + device = %device, + iface = %iface, + dst = "ff02::2", + router_ll = %router_ll, + "router solicitation" + ); + } + None => { + tracing::info!( + target: "patchbay::_events::RouterSolicitation", + ns = %ns_for_log, + device = %device, + iface = %iface, + dst = "ff02::2", + "router solicitation" + ); + } + } + Ok(()) + }) + .await +} + +async fn reconcile_radriven_default_v6_routes( + lab: &Arc, + router: NodeId, + install_ll: Option, +) -> Result<()> { + let targets = { + let inner = lab.core.lock().unwrap(); + inner.router_default_v6_targets(router, lab.ipv6_provisioning_mode)? + }; + for t in targets { + let ifname = t.ifname.to_string(); + core::nl_run(&lab.netns, &t.ns, move |nl: Netlink| async move { + if let Some(ll) = install_ll { + nl.replace_default_route_v6_scoped(&ifname, ll).await?; + } else { + nl.clear_default_route_v6().await?; + } + Ok(()) + }) + .await?; + } + Ok(()) +} + +fn select_default_v6_gateway( + provisioning: Ipv6ProvisioningMode, + ra_default_enabled: bool, + gw_v6: Option, + gw_ll_v6: Option, +) -> Option { + if provisioning == Ipv6ProvisioningMode::RaDriven { + if ra_default_enabled { + gw_ll_v6.or(gw_v6) + } else { + None + } + } else { + gw_v6.or(gw_ll_v6) + } +} + // ───────────────────────────────────────────── // Device / Router / DeviceIface handles // ───────────────────────────────────────────── @@ -51,6 +129,7 @@ pub struct DeviceIface { ifname: String, ip: Option, ip_v6: Option, + ll_v6: Option, impair: Option, } @@ -70,6 +149,11 @@ impl DeviceIface { self.ip_v6 } + /// Returns the assigned IPv6 link-local address, if any. + pub fn ll6(&self) -> Option { + self.ll_v6 + } + /// Returns the impairment profile, if any. pub fn impair(&self) -> Option { self.impair @@ -92,6 +176,37 @@ pub struct Device { lab: Arc, } +/// Owned snapshot of a single router network interface. +#[derive(Clone, Debug)] +pub struct RouterIface { + ifname: String, + ip: Option, + ip_v6: Option, + ll_v6: Option, +} + +impl RouterIface { + /// Returns the interface name. + pub fn name(&self) -> &str { + &self.ifname + } + + /// Returns the assigned IPv4 address, if any. + pub fn ip(&self) -> Option { + self.ip + } + + /// Returns the assigned IPv6 address, if any. + pub fn ip6(&self) -> Option { + self.ip_v6 + } + + /// Returns the assigned IPv6 link-local address, if any. + pub fn ll6(&self) -> Option { + self.ll_v6 + } +} + impl Clone for Device { fn clone(&self) -> Self { Self { @@ -187,6 +302,7 @@ impl Device { ifname: iface.ifname.to_string(), ip: iface.ip, ip_v6: iface.ip_v6, + ll_v6: iface.ll_v6, impair: iface.impair, }) } @@ -200,6 +316,7 @@ impl Device { ifname: iface.ifname.to_string(), ip: iface.ip, ip_v6: iface.ip_v6, + ll_v6: iface.ll_v6, impair: iface.impair, } }) @@ -220,11 +337,22 @@ impl Device { ifname: iface.ifname.to_string(), ip: iface.ip, ip_v6: iface.ip_v6, + ll_v6: iface.ll_v6, impair: iface.impair, }) .collect() } + fn provisioning_mode(&self) -> Result { + let inner = self.lab.core.lock().unwrap(); + let dev = inner + .device(self.id) + .ok_or_else(|| anyhow!("device removed"))?; + Ok(dev + .provisioning_mode + .unwrap_or(self.lab.ipv6_provisioning_mode)) + } + // ── Dynamic operations ────────────────────────────────────────────── /// Brings an interface administratively down. @@ -274,16 +402,47 @@ impl Device { }) .await?; if is_default_via { - let gw_ip = self - .lab - .core - .lock() - .unwrap() - .router_downlink_gw_for_switch(uplink)?; + let provisioning = self.provisioning_mode()?; + let (gw_ip, gw_v6, gw_ll_v6, ra_default_enabled) = { + let inner = self.lab.core.lock().unwrap(); + let gw_ip = inner.router_downlink_gw_for_switch(uplink)?; + let gw_v6 = inner.router_downlink_gw6_for_switch(uplink)?; + let ra_default_enabled = inner.ra_default_enabled_for_switch(uplink)?; + ( + gw_ip, + gw_v6.global_v6, + gw_v6.link_local_v6, + ra_default_enabled, + ) + }; core::nl_run(&self.lab.netns, &ns, move |nl: Netlink| async move { - nl.replace_default_route_v4(&ifname_owned, gw_ip).await + nl.replace_default_route_v4(&ifname_owned, gw_ip).await?; + let primary_v6 = + select_default_v6_gateway(provisioning, ra_default_enabled, gw_v6, gw_ll_v6); + if let Some(gw6) = primary_v6 { + if gw6.is_unicast_link_local() { + nl.replace_default_route_v6_scoped(&ifname_owned, gw6) + .await?; + } else { + nl.replace_default_route_v6(&ifname_owned, gw6).await?; + } + } else { + nl.clear_default_route_v6().await?; + } + Ok(()) }) .await?; + if provisioning == Ipv6ProvisioningMode::RaDriven { + let rs_router_ll = if ra_default_enabled { gw_ll_v6 } else { None }; + emit_router_solicitation( + &self.lab.netns, + ns.to_string(), + self.name.to_string(), + ifname.to_string(), + rs_router_ll, + ) + .await?; + } } self.lab.emit(LabEventKind::LinkUp { device: self.name.to_string(), @@ -307,7 +466,8 @@ impl Device { .with_device(self.id, |d| Arc::clone(&d.op)) .ok_or_else(|| anyhow!("device removed"))?; let _guard = op.lock().await; - let (ns, impair, gw_ip) = { + let provisioning = self.provisioning_mode()?; + let (ns, impair, gw_ip, gw_v6, gw_ll_v6, ra_default_enabled) = { let inner = self.lab.core.lock().unwrap(); let dev = inner .device(self.id) @@ -316,13 +476,45 @@ impl Device { .iface(to) .ok_or_else(|| anyhow!("interface '{}' not found", to))?; let gw_ip = inner.router_downlink_gw_for_switch(iface.uplink)?; - (dev.ns.clone(), iface.impair, gw_ip) + let gw_v6 = inner.router_downlink_gw6_for_switch(iface.uplink)?; + let ra_default_enabled = inner.ra_default_enabled_for_switch(iface.uplink)?; + ( + dev.ns.clone(), + iface.impair, + gw_ip, + gw_v6.global_v6, + gw_v6.link_local_v6, + ra_default_enabled, + ) }; let to_owned = to.to_string(); core::nl_run(&self.lab.netns, &ns, move |nl: Netlink| async move { - nl.replace_default_route_v4(&to_owned, gw_ip).await + nl.replace_default_route_v4(&to_owned, gw_ip).await?; + let primary_v6 = + select_default_v6_gateway(provisioning, ra_default_enabled, gw_v6, gw_ll_v6); + if let Some(gw6) = primary_v6 { + if gw6.is_unicast_link_local() { + nl.replace_default_route_v6_scoped(&to_owned, gw6).await?; + } else { + nl.replace_default_route_v6(&to_owned, gw6).await?; + } + } else { + nl.clear_default_route_v6().await?; + } + Ok(()) }) .await?; + if provisioning == Ipv6ProvisioningMode::RaDriven { + let rs_router_ll = if ra_default_enabled { gw_ll_v6 } else { None }; + emit_router_solicitation( + &self.lab.netns, + ns.to_string(), + self.name.to_string(), + to.to_string(), + rs_router_ll, + ) + .await?; + } apply_or_remove_impair(&self.lab.netns, &ns, to, impair).await; self.lab .core @@ -533,12 +725,15 @@ impl Device { let _guard = op.lock().await; // Phase 1: Lock → register iface + allocate IP → unlock - let setup = self + let mut setup = self .lab .core .lock() .unwrap() .prepare_add_iface(self.id, ifname, router, impair)?; + if self.provisioning_mode()? == Ipv6ProvisioningMode::RaDriven { + setup.iface_build.gw_ip_v6 = None; + } // Phase 2: Wire the interface (veth pair, IPs, bridge attachment). let netns = &self.lab.netns; @@ -565,6 +760,7 @@ impl Device { let dev = inner.device(self.id); let iface_ip = dev.and_then(|d| d.iface(ifname)).and_then(|i| i.ip); let iface_ip_v6 = dev.and_then(|d| d.iface(ifname)).and_then(|i| i.ip_v6); + let iface_ll_v6 = dev.and_then(|d| d.iface(ifname)).and_then(|i| i.ll_v6); drop(inner); self.lab.emit(LabEventKind::InterfaceAdded { device: self.name.to_string(), @@ -573,6 +769,7 @@ impl Device { router: router_name, ip: iface_ip, ip_v6: iface_ip_v6, + ll_v6: iface_ll_v6, link_condition: impair, }, }); @@ -637,12 +834,15 @@ impl Device { use crate::core; // Phase 1: Lock → extract data + allocate from new router's pool → unlock - let setup = self + let mut setup = self .lab .core .lock() .unwrap() .prepare_replug_iface(self.id, ifname, to_router)?; + if self.provisioning_mode()? == Ipv6ProvisioningMode::RaDriven { + setup.iface_build.gw_ip_v6 = None; + } // Phase 2: Delete old veth pair. let dev_ns = setup.iface_build.dev_ns.clone(); @@ -849,6 +1049,38 @@ impl Router { Some(run_dir.join(filename)) } + /// Returns a snapshot of the named router interface, if it exists. + pub fn iface(&self, name: &str) -> Option { + self.interfaces() + .into_iter() + .find(|iface| iface.name() == name) + } + + /// Returns snapshots of all router-facing interfaces. + /// + /// Currently includes WAN (`ix` or `wan`) and downstream bridge interface. + pub fn interfaces(&self) -> Vec { + let core = self.lab.core.lock().unwrap(); + let Some(router) = core.router(self.id) else { + return vec![]; + }; + let mut out = Vec::new(); + let wan_if = router.wan_ifname(core.ix_sw()); + out.push(RouterIface { + ifname: wan_if.to_string(), + ip: router.upstream_ip, + ip_v6: router.upstream_ip_v6, + ll_v6: router.upstream_ll_v6, + }); + out.push(RouterIface { + ifname: router.downlink_bridge.to_string(), + ip: router.downstream_gw, + ip_v6: router.downstream_gw_v6, + ll_v6: router.downstream_ll_v6, + }); + out + } + /// Returns the region label, if set. /// /// Returns `None` if the router has been removed or no region is assigned. @@ -931,8 +1163,112 @@ impl Router { self.lab.with_router(self.id, |r| r.cfg.nat_v6) } + /// Returns whether RA emission is enabled for this router, if present. + pub fn ra_enabled(&self) -> Option { + self.lab.with_router(self.id, |r| r.cfg.ra_enabled) + } + + /// Returns RA emission interval in seconds for this router, if present. + pub fn ra_interval_secs(&self) -> Option { + self.lab.with_router(self.id, |r| r.cfg.ra_interval_secs) + } + + /// Returns RA lifetime in seconds for this router, if present. + pub fn ra_lifetime_secs(&self) -> Option { + self.lab.with_router(self.id, |r| r.cfg.ra_lifetime_secs) + } + // ── Dynamic operations ────────────────────────────────────────────── + /// Updates the RA enabled flag at runtime. + /// + /// This affects subsequent RA-driven route refresh operations and any + /// future device wiring. Existing RA worker task lifecycle is not changed. + /// RA behavior in patchbay is currently modeled through structured events + /// and route updates, not raw ICMPv6 control packets. + pub async fn set_ra_enabled(&self, enabled: bool) -> Result<()> { + let op = self + .lab + .with_router(self.id, |r| Arc::clone(&r.op)) + .ok_or_else(|| anyhow!("router removed"))?; + let _guard = op.lock().await; + let install_ll = { + let mut inner = self.lab.core.lock().unwrap(); + let router = inner + .router_mut(self.id) + .ok_or_else(|| anyhow!("router removed"))?; + router.cfg.ra_enabled = enabled; + router.ra_runtime.set_enabled(enabled); + let ll = router.downstream_ll_v6; + if self.lab.ipv6_provisioning_mode == Ipv6ProvisioningMode::RaDriven + && router.cfg.ra_enabled + && router.cfg.ra_lifetime_secs > 0 + { + ll + } else { + None + } + }; + if self.lab.ipv6_provisioning_mode == Ipv6ProvisioningMode::RaDriven { + reconcile_radriven_default_v6_routes(&self.lab, self.id, install_ll).await?; + } + Ok(()) + } + + /// Updates RA interval in seconds at runtime. + /// + /// Value is clamped to at least one second. + /// Existing RA worker task lifecycle is not changed. + /// This interval controls modeled RA event cadence. + pub async fn set_ra_interval_secs(&self, secs: u64) -> Result<()> { + let op = self + .lab + .with_router(self.id, |r| Arc::clone(&r.op)) + .ok_or_else(|| anyhow!("router removed"))?; + let _guard = op.lock().await; + let mut inner = self.lab.core.lock().unwrap(); + let router = inner + .router_mut(self.id) + .ok_or_else(|| anyhow!("router removed"))?; + router.cfg.ra_interval_secs = secs.max(1); + router.ra_runtime.set_interval_secs(secs); + Ok(()) + } + + /// Updates RA lifetime in seconds at runtime. + /// + /// A value of `0` represents default-router withdrawal semantics. + /// Existing RA worker task lifecycle is not changed. + /// This affects modeled route withdrawal in RA-driven mode. + pub async fn set_ra_lifetime_secs(&self, secs: u64) -> Result<()> { + let op = self + .lab + .with_router(self.id, |r| Arc::clone(&r.op)) + .ok_or_else(|| anyhow!("router removed"))?; + let _guard = op.lock().await; + let install_ll = { + let mut inner = self.lab.core.lock().unwrap(); + let router = inner + .router_mut(self.id) + .ok_or_else(|| anyhow!("router removed"))?; + router.cfg.ra_lifetime_secs = secs; + router.ra_runtime.set_lifetime_secs(secs); + let ll = router.downstream_ll_v6; + if self.lab.ipv6_provisioning_mode == Ipv6ProvisioningMode::RaDriven + && router.cfg.ra_enabled + && router.cfg.ra_lifetime_secs > 0 + { + ll + } else { + None + } + }; + if self.lab.ipv6_provisioning_mode == Ipv6ProvisioningMode::RaDriven { + reconcile_radriven_default_v6_routes(&self.lab, self.id, install_ll).await?; + } + Ok(()) + } + /// Replaces NAT rules on this router at runtime. /// /// Flushes the `ip nat` and `ip filter` nftables tables, then re-applies diff --git a/patchbay/src/lab.rs b/patchbay/src/lab.rs index b89c339..22f81bc 100644 --- a/patchbay/src/lab.rs +++ b/patchbay/src/lab.rs @@ -20,7 +20,8 @@ pub use crate::qdisc::LinkLimits; use crate::{ core::{ self, apply_or_remove_impair, setup_device_async, setup_root_ns_async, setup_router_async, - CoreConfig, DownstreamPool, IfaceBuild, LabInner, NetworkCore, NodeId, RouterSetupData, + CoreConfig, DeviceSetupData, DownstreamPool, IfaceBuild, LabInner, NetworkCore, NodeId, + RouterSetupData, RA_DEFAULT_ENABLED, RA_DEFAULT_INTERVAL_SECS, RA_DEFAULT_LIFETIME_SECS, }, event::{DeviceState, LabEvent, LabEventKind, RouterState}, netlink::Netlink, @@ -51,7 +52,7 @@ fn region_base(idx: u8) -> Ipv4Addr { pub use crate::{ firewall::{Firewall, FirewallConfig, FirewallConfigBuilder}, - handles::{Device, DeviceIface, Ix, Router}, + handles::{Device, DeviceIface, Ix, Router, RouterIface}, nat::{ ConntrackTimeouts, IpSupport, Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, NatV6Mode, @@ -290,6 +291,60 @@ pub struct Lab { pub struct LabOpts { outdir: Option, label: Option, + ipv6_dad_mode: Ipv6DadMode, + ipv6_provisioning_mode: Ipv6ProvisioningMode, +} + +/// Controls IPv6 duplicate address detection behavior in created namespaces. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Ipv6DadMode { + /// Keep kernel default behavior, DAD enabled. + Enabled, + /// Disable DAD for deterministic fast tests. + #[default] + Disabled, +} + +/// Controls how IPv6 routes are provisioned for hosts and routers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Ipv6ProvisioningMode { + /// Install routes directly from patchbay wiring logic. + #[default] + Static, + /// + /// This mode follows RA and RS semantics for route installation and + /// emits structured RA and RS events into patchbay logs. It does not + /// emit raw ICMPv6 Router Advertisement or Router Solicitation packets. + RaDriven, +} + +/// Deployment-oriented IPv6 behavior profile for a lab. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Ipv6Profile { + /// Deterministic test profile: DAD off and static v6 route wiring. + LabDeterministic, + /// Production-like baseline: DAD on and RA/RS-driven route learning. + ProductionLike, + /// Consumer home baseline: DAD on and RA/RS-driven route learning. + ConsumerHome, + /// Mobile carrier baseline: DAD on and RA/RS-driven route learning. + MobileCarrier, + /// Enterprise baseline: DAD on and RA/RS-driven route learning. + Enterprise, +} + +impl Ipv6Profile { + fn modes(self) -> (Ipv6DadMode, Ipv6ProvisioningMode) { + match self { + Self::LabDeterministic => (Ipv6DadMode::Disabled, Ipv6ProvisioningMode::Static), + Self::ProductionLike | Self::ConsumerHome | Self::MobileCarrier | Self::Enterprise => { + (Ipv6DadMode::Enabled, Ipv6ProvisioningMode::RaDriven) + } + } + } } impl LabOpts { @@ -313,6 +368,26 @@ impl LabOpts { } self } + + /// Sets IPv6 duplicate address detection behavior. + pub fn ipv6_dad_mode(mut self, mode: Ipv6DadMode) -> Self { + self.ipv6_dad_mode = mode; + self + } + + /// Sets IPv6 provisioning behavior. + pub fn ipv6_provisioning_mode(mut self, mode: Ipv6ProvisioningMode) -> Self { + self.ipv6_provisioning_mode = mode; + self + } + + /// Applies a deployment profile that sets both DAD and v6 provisioning mode. + pub fn ipv6_profile(mut self, profile: Ipv6Profile) -> Self { + let (dad, provisioning) = profile.modes(); + self.ipv6_dad_mode = dad; + self.ipv6_provisioning_mode = provisioning; + self + } } impl Lab { @@ -389,11 +464,13 @@ impl Lab { label: label.clone(), ns_to_name: std::sync::Mutex::new(HashMap::new()), run_dir: run_dir.clone(), + ipv6_dad_mode: opts.ipv6_dad_mode, + ipv6_provisioning_mode: opts.ipv6_provisioning_mode, }), }; // Initialize root namespace and IX bridge eagerly — no lazy-init race. let cfg = lab.inner.core.lock().unwrap().cfg.clone(); - setup_root_ns_async(&cfg, &netns) + setup_root_ns_async(&cfg, &netns, opts.ipv6_dad_mode) .await .context("failed to set up root namespace")?; @@ -498,6 +575,15 @@ impl Lab { .nat(rcfg.nat) .ip_support(rcfg.ip_support) .nat_v6(rcfg.nat_v6); + if let Some(enabled) = rcfg.ra_enabled { + rb = rb.ra_enabled(enabled); + } + if let Some(interval) = rcfg.ra_interval_secs { + rb = rb.ra_interval_secs(interval); + } + if let Some(lifetime) = rcfg.ra_lifetime_secs { + rb = rb.ra_lifetime_secs(lifetime); + } // TODO: support region assignment from TOML config via add_region. if let Some(u) = upstream { rb = rb.upstream(u); @@ -669,6 +755,9 @@ impl Lab { mtu: None, block_icmp_frag_needed: false, firewall: Firewall::None, + ra_enabled: RA_DEFAULT_ENABLED, + ra_interval_secs: RA_DEFAULT_INTERVAL_SECS, + ra_lifetime_secs: RA_DEFAULT_LIFETIME_SECS, result: Ok(()), } } @@ -686,6 +775,7 @@ impl Lab { lab_span, id: NodeId(u64::MAX), mtu: None, + provisioning_mode: None, result: Err(anyhow!("device '{}' already exists", name)), }; } @@ -695,6 +785,7 @@ impl Lab { lab_span, id, mtu: None, + provisioning_mode: None, result: Ok(()), } } @@ -817,6 +908,7 @@ impl Lab { let sw = inner.switch(sw_id)?; Some((sw.gw_v6?, sw.cidr_v6?.prefix_len())) }); + let ra_enabled = router.cfg.ra_enabled; let setup_data = RouterSetupData { router, @@ -841,6 +933,9 @@ impl Lab { parent_route_v6: None, parent_route_v4: None, cancel: self.inner.cancel.clone(), + dad_mode: self.inner.ipv6_dad_mode, + provisioning_mode: self.inner.ipv6_provisioning_mode, + ra_enabled, }; (id, setup_data, idx) @@ -1487,6 +1582,17 @@ impl RouterPreset { _ => DownstreamPool::Public, } } + + /// Returns the recommended lab-level IPv6 profile for this router preset. + pub fn recommended_ipv6_profile(self) -> Ipv6Profile { + match self { + Self::Home => Ipv6Profile::ConsumerHome, + Self::Mobile | Self::MobileV6 => Ipv6Profile::MobileCarrier, + Self::Corporate | Self::Cloud | Self::Datacenter => Ipv6Profile::Enterprise, + // These presets are primarily v4-focused and do not benefit from RA-driven v6 provisioning. + Self::Hotel | Self::IspV4 => Ipv6Profile::LabDeterministic, + } + } } // ───────────────────────────────────────────── @@ -1509,6 +1615,9 @@ pub struct RouterBuilder { mtu: Option, block_icmp_frag_needed: bool, firewall: Firewall, + ra_enabled: bool, + ra_interval_secs: u64, + ra_lifetime_secs: u64, result: Result<()>, } @@ -1535,6 +1644,9 @@ impl RouterBuilder { mtu: None, block_icmp_frag_needed: false, firewall: Firewall::None, + ra_enabled: RA_DEFAULT_ENABLED, + ra_interval_secs: RA_DEFAULT_INTERVAL_SECS, + ra_lifetime_secs: RA_DEFAULT_LIFETIME_SECS, result: Err(err), } } @@ -1671,6 +1783,38 @@ impl RouterBuilder { self } + /// Enables or disables router advertisement emission in RA-driven mode. + /// + /// In the current implementation, this controls structured RA events and + /// default-route behavior, not raw ICMPv6 packet emission. + pub fn ra_enabled(mut self, enabled: bool) -> Self { + if self.result.is_ok() { + self.ra_enabled = enabled; + } + self + } + + /// Sets the RA interval in seconds, clamped to at least 1 second. + /// + /// This interval drives patchbay's RA event cadence in RA-driven mode. + pub fn ra_interval_secs(mut self, secs: u64) -> Self { + if self.result.is_ok() { + self.ra_interval_secs = secs.max(1); + } + self + } + + /// Sets Router Advertisement lifetime in seconds. + /// + /// A value of `0` advertises default-router withdrawal semantics in + /// patchbay's RA-driven route model. + pub fn ra_lifetime_secs(mut self, secs: u64) -> Self { + if self.result.is_ok() { + self.ra_lifetime_secs = secs; + } + self + } + /// Overrides the downstream subnet instead of auto-allocating from the pool. /// /// The gateway address is the `.1` host of the given CIDR. Device addresses @@ -1708,6 +1852,12 @@ impl RouterBuilder { r.cfg.mtu = self.mtu; r.cfg.block_icmp_frag_needed = self.block_icmp_frag_needed; r.cfg.firewall = self.firewall.clone(); + r.cfg.ra_enabled = self.ra_enabled; + r.cfg.ra_interval_secs = self.ra_interval_secs.max(1); + r.cfg.ra_lifetime_secs = self.ra_lifetime_secs; + r.ra_runtime.set_enabled(self.ra_enabled); + r.ra_runtime.set_interval_secs(self.ra_interval_secs); + r.ra_runtime.set_lifetime_secs(self.ra_lifetime_secs); } let has_v4 = self.ip_support.has_v4(); let has_v6 = self.ip_support.has_v6(); @@ -1896,6 +2046,7 @@ impl RouterBuilder { }; let has_v6 = router.cfg.ip_support.has_v6(); + let ra_enabled = router.cfg.ra_enabled; let setup_data = RouterSetupData { router, root_ns: cfg.root_ns.clone(), @@ -1923,6 +2074,9 @@ impl RouterBuilder { parent_route_v6, parent_route_v4, cancel: self.inner.cancel.clone(), + dad_mode: self.inner.ipv6_dad_mode, + provisioning_mode: self.inner.ipv6_provisioning_mode, + ra_enabled, }; (id, setup_data) @@ -1988,6 +2142,7 @@ pub struct DeviceBuilder { lab_span: tracing::Span, id: NodeId, mtu: Option, + provisioning_mode: Option, result: Result<()>, } @@ -2000,6 +2155,14 @@ impl DeviceBuilder { self } + /// Overrides IPv6 provisioning mode for this device only. + pub fn ipv6_provisioning_mode(mut self, mode: Ipv6ProvisioningMode) -> Self { + if self.result.is_ok() { + self.provisioning_mode = Some(mode); + } + self + } + /// Attach `ifname` inside the device namespace to `router`'s downstream switch. pub fn iface(mut self, ifname: &str, router: NodeId, impair: Option) -> Self { if self.result.is_ok() { @@ -2054,16 +2217,20 @@ impl DeviceBuilder { self.result?; // Phase 1: Lock → extract snapshot + DNS overlay → unlock. - let (dev, ifaces, prefix, root_ns, dns_overlay) = { + let (dev, ifaces, prefix, root_ns, dns_overlay, provisioning_mode) = { let mut inner = self.inner.core.lock().unwrap(); // Apply builder-level config before snapshot. if let Some(d) = inner.device_mut(self.id) { d.mtu = self.mtu; + d.provisioning_mode = self.provisioning_mode; } let dev = inner .device(self.id) .ok_or_else(|| anyhow!("unknown device id"))? .clone(); + let provisioning_mode = dev + .provisioning_mode + .unwrap_or(self.inner.ipv6_provisioning_mode); let mut iface_data = Vec::new(); for iface in &dev.interfaces { @@ -2083,6 +2250,18 @@ impl DeviceBuilder { })?; let gw_br = sw.bridge.clone().unwrap_or_else(|| "br-lan".into()); let gw_ns = inner.router(gw_router).unwrap().ns.clone(); + let gw_ip_v6 = if provisioning_mode == Ipv6ProvisioningMode::RaDriven { + None + } else { + sw.gw_v6 + }; + let gw_ll_v6 = inner.router(gw_router).and_then(|r| { + if provisioning_mode == Ipv6ProvisioningMode::RaDriven { + r.active_downstream_ll_v6() + } else { + r.downstream_ll_v6 + } + }); iface_data.push(IfaceBuild { dev_ns: dev.ns.clone(), gw_ns, @@ -2090,8 +2269,10 @@ impl DeviceBuilder { gw_br, dev_ip: iface.ip, prefix_len: sw.cidr.map(|c| c.prefix_len()).unwrap_or(24), - gw_ip_v6: sw.gw_v6, + gw_ip_v6, dev_ip_v6: iface.ip_v6, + gw_ll_v6, + dev_ll_v6: iface.ll_v6, prefix_len_v6: sw.cidr_v6.map(|c| c.prefix_len()).unwrap_or(64), impair: iface.impair, ifname: iface.ifname.clone(), @@ -2109,7 +2290,7 @@ impl DeviceBuilder { let prefix = inner.cfg.prefix.clone(); let root_ns = inner.cfg.root_ns.clone(); - (dev, iface_data, prefix, root_ns, overlay) + (dev, iface_data, prefix, root_ns, overlay, provisioning_mode) }; // lock released // Phase 2: Async network setup (no lock held). @@ -2117,7 +2298,19 @@ impl DeviceBuilder { // get /etc/hosts and /etc/resolv.conf bind-mounted at startup. let netns = &self.inner.netns; async { - setup_device_async(netns, &prefix, &root_ns, &dev, ifaces, Some(dns_overlay)).await + setup_device_async( + netns, + DeviceSetupData { + prefix, + root_ns, + dev: dev.clone(), + ifaces, + dns_overlay: Some(dns_overlay), + dad_mode: self.inner.ipv6_dad_mode, + provisioning_mode, + }, + ) + .await } .instrument(self.lab_span.clone()) .await?; diff --git a/patchbay/src/lib.rs b/patchbay/src/lib.rs index 08613c0..b6dc826 100644 --- a/patchbay/src/lib.rs +++ b/patchbay/src/lib.rs @@ -102,9 +102,10 @@ pub use firewall::PortPolicy; pub use ipnet::Ipv4Net; pub use lab::{ ConntrackTimeouts, DefaultRegions, Device, DeviceBuilder, DeviceIface, Firewall, - FirewallConfig, FirewallConfigBuilder, IpSupport, Ix, Lab, LabOpts, LinkCondition, LinkLimits, - Nat, NatConfig, NatConfigBuilder, NatFiltering, NatMapping, NatV6Mode, ObservedAddr, Region, - RegionLink, Router, RouterBuilder, RouterPreset, + FirewallConfig, FirewallConfigBuilder, IpSupport, Ipv6DadMode, Ipv6Profile, + Ipv6ProvisioningMode, Ix, Lab, LabOpts, LinkCondition, LinkLimits, Nat, NatConfig, + NatConfigBuilder, NatFiltering, NatMapping, NatV6Mode, ObservedAddr, Region, RegionLink, + Router, RouterBuilder, RouterIface, RouterPreset, }; pub use crate::{ diff --git a/patchbay/src/netlink.rs b/patchbay/src/netlink.rs index 46018ea..708c346 100644 --- a/patchbay/src/netlink.rs +++ b/patchbay/src/netlink.rs @@ -272,6 +272,77 @@ impl Netlink { Ok(()) } + pub(crate) async fn replace_default_route_v6(&self, ifname: &str, via: Ipv6Addr) -> Result<()> { + trace!(ifname = %ifname, via = %via, "replace default route v6"); + let ifindex = self.link_index(ifname).await?; + + self.delete_default_routes_v6().await?; + + let msg = RouteMessageBuilder::::new() + .output_interface(ifindex) + .gateway(via) + .build(); + self.handle.route().add(msg).execute().await?; + Ok(()) + } + + pub(crate) async fn add_default_route_v6_scoped( + &self, + ifname: &str, + via: Ipv6Addr, + ) -> Result<()> { + trace!(ifname = %ifname, via = %via, "add default route v6 scoped"); + let ifindex = self.link_index(ifname).await?; + let msg = RouteMessageBuilder::::new() + .output_interface(ifindex) + .gateway(via) + .build(); + if let Err(err) = self.handle.route().add(msg).execute().await { + if is_eexist(&err) { + return Ok(()); + } + return Err(err.into()); + } + Ok(()) + } + + pub(crate) async fn replace_default_route_v6_scoped( + &self, + ifname: &str, + via: Ipv6Addr, + ) -> Result<()> { + trace!(ifname = %ifname, via = %via, "replace default route v6 scoped"); + let ifindex = self.link_index(ifname).await?; + + self.delete_default_routes_v6().await?; + + let msg = RouteMessageBuilder::::new() + .output_interface(ifindex) + .gateway(via) + .build(); + self.handle.route().add(msg).execute().await?; + Ok(()) + } + + pub(crate) async fn clear_default_route_v6(&self) -> Result<()> { + trace!("clear default route v6"); + self.delete_default_routes_v6().await + } + + async fn delete_default_routes_v6(&self) -> Result<()> { + let mut routes = self + .handle + .route() + .get(RouteMessageBuilder::::new().build()) + .execute(); + while let Some(route) = routes.try_next().await? { + if route.header.destination_prefix_length == 0 { + let _ = self.handle.route().del(route).execute().await; + } + } + Ok(()) + } + pub(crate) async fn add_route_v6( &self, dst: Ipv6Addr, diff --git a/patchbay/src/tests/preset.rs b/patchbay/src/tests/preset.rs index 689072f..6bd5709 100644 --- a/patchbay/src/tests/preset.rs +++ b/patchbay/src/tests/preset.rs @@ -201,6 +201,32 @@ async fn preset_override() -> Result<()> { Ok(()) } +/// Router presets expose sane default IPv6 deployment profile recommendations. +#[tokio::test(flavor = "current_thread")] +#[traced_test] +async fn preset_recommended_ipv6_profiles() -> Result<()> { + check_caps()?; + + assert_eq!( + RouterPreset::Home.recommended_ipv6_profile(), + Ipv6Profile::ConsumerHome + ); + assert_eq!( + RouterPreset::Mobile.recommended_ipv6_profile(), + Ipv6Profile::MobileCarrier + ); + assert_eq!( + RouterPreset::Corporate.recommended_ipv6_profile(), + Ipv6Profile::Enterprise + ); + assert_eq!( + RouterPreset::Hotel.recommended_ipv6_profile(), + Ipv6Profile::LabDeterministic + ); + + Ok(()) +} + /// Public GUA v6 pool gives addresses from 2001:db8:1::/48, not ULA fd10::/48. #[tokio::test(flavor = "current_thread")] #[traced_test] diff --git a/plans/PLAN.md b/plans/PLAN.md index f8dc0d9..4469d6c 100644 --- a/plans/PLAN.md +++ b/plans/PLAN.md @@ -12,6 +12,7 @@ ## Completed +- [ipv6-linklocal.md](ipv6-linklocal.md) - priority 5; link-local/scoped routing parity, RA runtime controls, profile mapping, and docs finalized - [devtools-ui.md](devtools-ui.md) - priority 5; all phases complete, unified UI, per-namespace tracing, event system, axum server - [real-world-conditions.md](real-world-conditions.md) - priority 5; all 6 phases complete (NAT, impairment, MTU/removal/IP, hairpin, region routing, firewall) - [region-routing.md](region-routing.md) - priority 5; per-region router namespaces, 198.18.0.0/15 address space, inter-region veths, break/restore diff --git a/plans/ipv6-linklocal.md b/plans/ipv6-linklocal.md new file mode 100644 index 0000000..fcc60cb --- /dev/null +++ b/plans/ipv6-linklocal.md @@ -0,0 +1,716 @@ +# IPv6 Link-Local Parity Plan + +## TODO + +- [x] Write plan +- [x] Phase 0: Define target behavior and compatibility boundaries +- [x] Phase 1: Kernel behavior parity for link-local addresses and routes +- [x] Phase 2: Router Advertisement and Router Solicitation behavior +- [x] Phase 2.3a: Add per-device IPv6 provisioning override for gradual migration +- [x] Phase 3: Public API support for link-local and scope handling +- [x] Phase 4: Real-world presets for consumer and production-like IPv6 +- [x] Phase 4.1: Add lab IPv6 deployment profiles that map to DAD and provisioning defaults +- [x] Phase 4.2a: Wire router preset defaults to profile recommendations +- [x] Phase 4.2b: Document preset-to-profile recommendations in user-facing docs +- [x] Phase 5: Tests and validation matrix +- [x] Phase 5.1: Add RA worker lifecycle coverage for router namespace removal +- [x] Phase 5.2: Add profile-behavior test for static vs RA-driven default-route semantics +- [x] Phase 5.3a: Verify RA reconciliation only targets RA-driven devices when mixed with static overrides +- [x] Phase 5.3: Close remaining matrix gaps from this plan +- [x] Final review + +## Goal + +Make patchbay's IPv6 link-local behavior match production and consumer deployments as closely as practical: + +- Every IPv6-capable interface has a usable link-local address. +- Default router behavior uses link-local next hops from RA/ND semantics. +- Scope-aware APIs and routing behavior work for `fe80::/10` correctly. +- Consumer CPE behavior and host behavior follow modern RFC expectations. + +## Completion Update (2026-03-04) + +- Core link-local, scoped routing, RA runtime controls, profile wiring, and per-device provisioning overrides are implemented. +- Dedicated `ipv6_ll.rs` was retired after review feedback; coverage was folded back into stable test modules (`ipv6.rs`, `preset.rs`, `route.rs`, `lifecycle.rs`) to avoid fragile file-polling tracing tests. +- Preset-to-profile behavior is documented in user-facing docs (`README.md`, `docs/reference/ipv6.md`). +- IPv6 fidelity boundaries are now documented explicitly in API docs, README, book reference docs, and a new book limitations page (`docs/limitations.md`), including current gaps around packet-level RA/RS, full SLAAC state machines, and ND timing. + +## Real-World Deployment Baselines + +This section defines the target behavior we want to emulate first. These are the reference deployment classes for parity work. + +### 1. Mobile carrier access (4G/5G, handset as host) + +- The host receives RAs from a carrier router. Default router is link-local. +- The host has at least one LLA and often one or more temporary/stable global addresses. +- IPv4 fallback is typically NAT64/464XLAT. This does not change the need for correct LLA default-router behavior. +- A single interface commonly owns the active default route, with rapid route refresh during mobility events. + +Patchbay parity target: + +- RS on interface up, RA-driven default route via `fe80::/10` next hop. +- Route replacement behavior that handles carrier-style churn. +- Optional NAT64 remains independent from LLA mechanics. + +### 2. Home router with IPv6 support (consumer CPE) + +- CPE advertises one or more /64 LAN prefixes via RA. +- Router source address for RA is link-local on that LAN interface. +- Hosts choose default route from RA and maintain Neighbor Cache entries to the router LLA. +- Stateful firewall on CPE controls inbound behavior. This is separate from ND/RA correctness. + +Patchbay parity target: + +- Router LAN interfaces send RAs with configurable lifetime and preference. +- Hosts install and remove default routes based on RA timers. +- Prefix and default-router behavior follows RFC 4861/4862/5942 semantics. + +### 3. Linux laptop host behavior + +- Uses kernel SLAAC and RFC 4861 ND behavior. +- Link-local sockets and routes require interface scope correctness. +- DAD is normally enabled; addresses can remain tentative briefly after link up. + +Patchbay parity target: + +- Default mode keeps DAD enabled. +- Tests can assert tentative -> preferred transition when needed. +- Scope-aware APIs prevent accidental use of ambiguous LLAs. + +### 4. macOS laptop host behavior + +- Uses RA-driven default route and SLAAC with temporary address rotation. +- Strongly depends on scoped address handling for user-space link-local sockets. +- Route selection prefers valid default routers and can switch after lifetime expiry. + +Patchbay parity target: + +- Route lifetime and preference updates are modeled. +- Link-local route installation and socket examples work with explicit scope. +- The simulator models Linux-kernel-observable behavior and policy, not a byte-for-byte macOS network stack implementation. + +### 5. Windows laptop host behavior + +- Uses RA and SLAAC by default, including temporary addresses and stable behavior per interface. +- Scope zone index is required for link-local destinations in many user-space APIs. +- Multiple interfaces can produce multiple candidate default routes. + +Patchbay parity target: + +- Zone-aware examples and tests are part of docs and helper utilities. +- Multi-interface device tests validate deterministic `default_via` behavior when multiple LLAs exist. +- The simulator models Linux-kernel-observable behavior and policy, not a byte-for-byte Windows network stack implementation. + +### 6. Cross-platform baseline rules we should match + +- Every IPv6-enabled interface has an LLA. +- RA source is link-local. +- Default routers are represented as scoped LLA next hops. +- Route and socket operations requiring scope fail fast when scope is missing. +- DAD is on by default, off only in explicit deterministic test mode. + +## Research Basis + +Primary references used for this plan: + +- RFC 4861 (Neighbor Discovery): RA source must be link-local; default routers are learned from RA. + - https://www.rfc-editor.org/rfc/rfc4861 +- RFC 4862 (SLAAC): link-local creation lifecycle and DAD requirements. + - https://www.rfc-editor.org/rfc/rfc4862 +- RFC 4007 (Scoped addressing): link-local requires zone index / scope handling. + - https://www.rfc-editor.org/rfc/rfc4007 +- RFC 5942 (Subnet model): hosts should only treat explicit on-link prefixes as on-link. + - https://www.rfc-editor.org/rfc/rfc5942 +- RFC 4191 (Router preferences and RIO): default-router preference and route information semantics. + - https://www.rfc-editor.org/rfc/rfc4191 +- RFC 7084 (IPv6 CE requirements) plus updates RFC 9096 and RFC 9818: consumer router requirements. + - https://www.rfc-editor.org/rfc/rfc7084 + - https://www.rfc-editor.org/rfc/rfc9096 + - https://www.rfc-editor.org/rfc/rfc9818 +- Linux man pages: `ipv6(7)`, `ip-route(8)`, `ip-address(8)` for scope-id, route installation, and DAD state. + - https://man7.org/linux/man-pages/man7/ipv6.7.html + - https://man7.org/linux/man-pages/man8/ip-route.8.html + - https://man7.org/linux/man-pages/man8/ip-address.8.html + +## Current Gaps (as of today) + +Observed in current codebase: + +- IPv6 addresses are assigned explicitly from patchbay pools, not from link-local lifecycle. +- IPv6 default route helper uses only gateway address and does not explicitly model interface scope for link-local next hops. +- DAD is globally disabled in namespaces, which diverges from production defaults where DAD is usually enabled. +- Public handles expose global/ULA-like `ip6()` but do not expose link-local per interface. +- No explicit RA/RS simulation path for host default-router learning from link-local router addresses. +- Devtools and event payloads do not surface LLA or default-router source details. +- Presets do not distinguish static provisioning from RA-driven provisioning. +- The plan text previously implied OS-specific emulation that patchbay cannot provide inside Linux network namespaces. + +## Phase 0: Behavior Contract + +Define the exact behavior profile to avoid ambiguous implementation: + +1. Add an internal design note under `docs/reference/ipv6.md` describing two modes: + - `production_like` (default target): DAD enabled, LLAs present, RA/RS path active where configured. + - `deterministic_test` (compat mode): existing deterministic static assignment semantics where needed for old tests. +2. Define a policy profile matrix (`consumer_home`, `mobile_carrier`, `enterprise_strict`, `lab_deterministic`) that maps to expected observable behavior for tests and docs. +3. Decide where strict realism is mandatory versus opt-in. +4. List non-goals for first iteration, for example full DHCPv6-PD server stack in core. +5. Define migration rules for existing tests that currently assume immediate non-tentative IPv6 addresses. +6. Explicitly state that patchbay does not emulate non-Linux host stacks. It emulates deployment behavior and routing/address policy visible at the wire and netlink levels. + +Acceptance: + +- The project has one written contract for link-local behavior and migration strategy. +- The contract explicitly maps behavior expectations to at least home CPE, mobile carrier, and laptop hosts. + +## Phase 1: Kernel Parity for LLA + Routes + +### 1.1 Interface link-local visibility + +1. Extend interface state model to carry an optional link-local IPv6 address (`ll6`) independently from global/ULA `ip6`. +2. Add explicit `AddrState` metadata for IPv6 addresses where available (`tentative`, `preferred`, `deprecated`, `dadfailed`). +3. Add helper methods on handles, for example `DeviceIface::ll6()` and router-side iface accessors. +4. Ensure `ll6` can be discovered from netlink after interface bring-up, and cache refresh hooks exist after replug/reconfigure. + +### 1.2 Route installation with scoped next hops + +1. Add netlink methods for IPv6 default routes that can bind both next hop and output interface (scope-safe for link-local). +2. For link-local next hop default routes, always install route with explicit device context. +3. Keep existing global next hop path for non-link-local gateways. +4. Add idempotent `replace_default_route_v6_scoped` helper for multi-uplink devices. +5. Ensure route query helpers return interface index/name together with gateway to avoid scope loss. + +### 1.3 DAD behavior control + +1. Stop globally forcing `accept_dad=0` by default for production-like mode. +2. Add explicit option to disable DAD only for deterministic test mode. +3. Surface address state transitions (`tentative`, `dadfailed`) where useful for debugging. +4. Add bounded waiting helper for tests that need a preferred IPv6 address before connect. + +Acceptance: + +- In a dual-stack lab, each IPv6-capable interface reports `ll6`. +- Default route via link-local next hop works and resolves only with explicit interface scope. +- DAD behavior is configurable and defaults to production-like mode. +- Replug and route-switch operations preserve valid scoped default route behavior. + +## Phase 2: RA/RS and Default Router Learning + +### 2.1 Router behavior + +1. Add RA emission capability for router interfaces that should advertise prefixes. +2. Ensure RA source address is the router interface link-local address. +3. Support Router Lifetime and RFC 4191 preference fields. +4. Optionally support Route Information Option for local-only communication patterns. +5. Support configurable RA intervals and immediate unsolicited RA on topology changes. +6. Implement RA emission as long-lived per-interface tasks owned by the namespace async worker, not on Tokio runtime threads outside netns. +7. Define deterministic shutdown and restart semantics for those tasks during router removal, replug, or topology reset. + +### 2.2 Host behavior + +1. Add RS sending on host iface bring-up when RA mode is enabled. +2. Populate default router from received RA using router link-local source. +3. Maintain prefix list and on-link behavior consistent with RFC 5942 rules. +4. Handle RA lifetime expiration and default-router withdrawal. +5. Respect RIO when no default route is present (local communications-only scenarios). +6. Implement host RS/default-router learning in netns worker tasks to preserve setns correctness and avoid cross-thread scope bugs. + +### 2.3 Compatibility mode + +1. Keep static route assignment path for tests that depend on fixed setup. +2. Allow per-lab toggle: static provisioning versus RA-driven provisioning. +3. Add per-device override for targeted migration of complex tests. + +Acceptance: + +- Host default route can be learned from RA and points to a link-local router address on the correct interface. +- RA disable/enable and Router Lifetime changes produce expected default-router list behavior. +- Static mode remains deterministic and preserves legacy test stability. +- RA/RS tasks are started and stopped cleanly with namespace lifecycle events. + +## Phase 3: Public API and CLI Ergonomics + +1. Add explicit getters: + - `DeviceIface::ll6()` + - `RouterIface::ll6()` +2. Add getters for default-router metadata per interface, including scoped next hop. +3. Add utility constructors for scoped socket addresses in examples and helpers. +4. Add convenience method for creating scoped textual addresses for diagnostics. +5. Update event/devtools payloads to include link-local and scope metadata where relevant. +6. Document route and socket caveats for link-local usage in tests (`sin6_scope_id` / iface binding requirements). +7. Add migration notes for downstream users currently calling `ip6()` and assuming it is sufficient for routing. + +Acceptance: + +- Users can retrieve and use link-local addresses safely from the public API. +- Devtools can display link-local addresses distinctly from global/ULA addresses. +- Downstream tests can choose between global `ip6()` and scoped `ll6()` explicitly. + +## Planned Rust API Changes + +This section summarizes the expected public API surface changes, with compatibility notes. + +### New getters and metadata + +1. `DeviceIface::ll6() -> Option` + - Returns the interface link-local IPv6 address when IPv6 is enabled. +2. `Router::iface(name: &str) -> Option` + - Returns an owned snapshot handle for a router interface. +3. `Router::interfaces() -> Vec` + - Returns all router interfaces as owned snapshots. +4. `RouterIface::ll6() -> Option` + - Exposes router-side LLAs for diagnostics and RA/default-router assertions. +5. `DeviceIface::default_router_v6() -> Option` + - Returns current default-router next hop including interface scope metadata. +6. Optional address state accessors, for example: + - `DeviceIface::ip6_state() -> Option` + - `DeviceIface::ll6_state() -> Option` + - `RouterIface::ll6_state() -> Option` + +### New supporting types + +1. `ScopedIpv6NextHop` + - Proposed fields: `{ addr: Ipv6Addr, ifname: Arc, ifindex: u32 }` + - Purpose: represent link-local next hops safely without losing scope. +2. `AddrState` + - Proposed variants: `Tentative`, `Preferred`, `Deprecated`, `DadFailed`, `Unknown` + - Purpose: expose kernel IPv6 address lifecycle where available. +3. Optional provisioning mode enum, for example `Ipv6ProvisioningMode` + - `Static`, `RaDriven` + - Purpose: make behavior explicit at lab or profile level. + +### Builder and configuration changes + +1. Add RA/provisioning knobs on router and possibly lab builders, for example: + - `RouterBuilder::ra_enabled(bool)` + - `RouterBuilder::ra_lifetime(Duration)` + - `RouterBuilder::ra_preference(RouterPreference)` +2. Add compatibility and determinism controls: + - `LabOpts::ipv6_dad_mode(...)` or equivalent + - `LabOpts::ipv6_provisioning_mode(...)` or equivalent +3. Keep existing APIs functional: + - `Device::ip6()` remains available for global/ULA address use cases. + - Existing static provisioning paths remain valid in deterministic mode. +4. Keep `LabOpts` as the entry point for global defaults. This matches existing builder style and remains feasible with additive fields. + +### Netlink/internal API additions + +1. Scoped default-route helpers for link-local gateways. +2. Route query helpers that return interface metadata with gateway. +3. Interface refresh helpers to update cached `ll6` and address states after replug/reconfigure. +4. RA/RS task registration APIs on netns workers for lifecycle-safe startup and teardown. + +### Events and devtools schema impact + +1. Extend interface-related event payloads with optional link-local fields. +2. Include default-router source/scope info where relevant. +3. Keep fields additive to preserve backward compatibility for consumers that ignore new keys. + +### Compatibility policy + +1. Existing callers using `ip6()` continue to work. +2. New APIs are additive in first rollout. +3. Behavior changes that can affect timing or routing default to explicit opt-in until migration is complete. + +## Phase 4: Real-world Presets and Defaults + +Align presets with consumer behavior expectations from RFC 7084 family: + +1. Home/consumer preset: + - RA enabled on LAN + - link-local router identity stable + - realistic default firewall posture remains intact + - default-route learning is RA-driven for hosts +2. Datacenter/internal preset: + - support optional link-local-only infrastructure links where appropriate + - keep loopback/global addresses for management scenarios +3. Mobile-like preset: + - preserve existing NAT64 and v6-only semantics while ensuring link-local correctness on access links +4. Policy profile toggles: + - `consumer_home`, `mobile_carrier`, `enterprise_strict`, `lab_deterministic` knobs for timing and address-selection policy where practical. + +Acceptance: + +- Presets express explicit link-local policy and behavior. +- Example topologies in docs cover at least one mobile, one home, and one laptop profile scenario. +- Docs explicitly state profiles are deployment-policy emulation, not OS-kernel emulation. + +## Phase 5: Test Matrix + +Add focused tests, separate from existing IPv6 tests. + +Test module location: + +- Link-local and RA/RS coverage is implemented in existing stable test modules: + - `patchbay/src/tests/ipv6.rs` + - `patchbay/src/tests/preset.rs` + - `patchbay/src/tests/route.rs` + - `patchbay/src/tests/lifecycle.rs` + - `patchbay/src/tests/iface.rs` + +Core tests: + +1. `link_local_presence_on_all_ipv6_ifaces` + - Verifies every IPv6-capable interface gets a non-empty `fe80::/10` address and that API getters return it. +2. `default_route_via_link_local_requires_dev_scope` + - Verifies a default route using link-local next hop is only installed/usable with explicit interface scope. +3. `ra_source_is_link_local` + - Verifies outbound RA packets use the router interface LLA as source, never a global/ULA source. +4. `host_learns_default_router_from_ra_link_local` + - Verifies host installs default route from received RA and next hop is scoped LLA of advertising router. +5. `dad_enabled_production_mode` + - Verifies production-like mode enables DAD and observed address state transitions from tentative to preferred. +6. `dad_disabled_deterministic_mode` + - Verifies deterministic mode disables DAD and addresses become usable immediately for stable tests. +7. `link_local_socket_scope_required` (expected failure without scope, success with scope) + - Verifies application-level connect/send to LLA fails without scope id and succeeds with correct scope. +8. `router_lifetime_zero_withdraws_default_router` + - Verifies RA with lifetime 0 removes default-router entry and default route from host routing table. +9. `rio_local_routes_without_default_router` + - Verifies RIO routes can exist and be used when no default route is advertised. +10. `multi_uplink_prefers_router_preference_and_metric` + - Verifies host/router selection across multiple candidate defaults follows preference/metric policy. +11. `replug_iface_preserves_or_relearns_scoped_default_route` + - Verifies replugging an interface preserves valid scoped route or re-learns it cleanly through RA/RS. +12. `devtools_shows_ll6_and_router_scope` + - Verifies event stream and devtools payloads include LLA and scope metadata and UI renders them. +13. `home_profile_ra_refresh_after_router_restart` + - Verifies consumer-home profile recovers default-router and prefix state after router restart. +14. `mobile_profile_fast_default_router_reselection` + - Verifies mobile-like profile handles default-router churn quickly and converges to a usable route. +15. `ra_task_lifecycle_matches_namespace_lifecycle` + - Verifies RA worker tasks are created, restarted, and terminated correctly with namespace/router lifecycle transitions. +16. `router_iface_api_exposes_ll6_consistently` + - Verifies new `RouterIface` snapshots and getters stay consistent with netlink-observed interface state. + - Status: implemented. + +Additional exhaustiveness tests: + +17. `static_mode_does_not_run_ra_rs_tasks` + - Verifies static provisioning mode does not start RA/RS workers and still provides deterministic routing. +18. `ra_disabled_router_emits_no_ra` + - Verifies per-router RA disable truly suppresses advertisements even when global profile enables RA. +19. `multiple_prefixes_from_ra_install_expected_addresses` + - Verifies host behavior when router advertises multiple prefixes, including address and route selection expectations. +20. `default_router_preference_changes_reorder_selection` + - Verifies RFC 4191 preference updates change default-router choice without requiring interface bounce. +21. `iface_remove_cleans_scoped_default_route` + - Verifies removing an interface removes stale scoped link-local default routes and cached router metadata. +22. `iface_add_relearns_ll6_and_default_router` + - Verifies hot-added interfaces discover LLA and default router correctly via RS/RA path. +23. `rebooted_router_new_ll6_replaces_old_neighbor_and_route` + - Verifies host recovers cleanly when router LLA changes across restart and old next hop becomes invalid. +24. `dual_uplink_failover_preserves_connectivity_with_ll_next_hop` + - Verifies failover when primary uplink/router disappears and secondary scoped default route takes over. +25. `nonscoped_ll_connect_fails_with_clear_error` + - Verifies downstream-facing helper APIs surface a clear error for link-local socket usage without scope. +26. `devtools_payload_backward_compatible_when_ll6_missing` + - Verifies additive schema behavior when older runs or v4-only interfaces lack LLA fields. + +Implemented across the current test suite: + +- `link_local_presence_on_all_ipv6_ifaces` +- `router_iface_api_exposes_ll6_consistently` +- `dad_disabled_deterministic_mode` +- `radriven_default_route_uses_scoped_ll_and_switches_iface` +- `radriven_link_up_restores_scoped_ll_default_route` +- `radriven_ra_worker_respects_router_enable_flag` +- `ra_source_is_link_local` +- `host_learns_default_router_from_ra_link_local` +- `router_lifetime_zero_withdraws_default_router` +- `static_override_runtime_default_route_switch_uses_global_v6` +- `static_mode_does_not_run_ra_rs_tasks` + +Implemented control-plane scaffolding so far: + +- Router-level RA controls: `ra_enabled(bool)` and `ra_interval_secs(u64)`. +- Router-level RA lifetime control: `ra_lifetime_secs(u64)`, including zero-lifetime withdrawal semantics for RA-driven default routing. +- TOML support for router RA controls: `ra_enabled`, `ra_interval_secs`, `ra_lifetime_secs`. +- RA worker now honors per-router enable flag and configured interval. +- RA worker emits `RouterAdvertisement` events with `src`, `interval_secs`, and `lifetime_secs`. +- RA-driven host control path emits `RouterSolicitation` tracing events on initial default-iface setup, link-up restore, and explicit default-route switches. + +Validation commands before completion: + +- `cargo make format` +- `cargo clippy -p patchbay --tests --fix --allow-dirty` +- `cargo check -p patchbay --tests` +- `cargo nextest run -p patchbay` +- `cd ui && npm run build` (if devtools payload/UI changes are included) + +## Rollout Strategy + +1. Land data model + route primitives first, behind compatibility guards. +2. Land RA/RS path as opt-in. +3. Land `RouterIface` and scoped-next-hop APIs as additive public changes. +4. Ship API/docs/devtools visibility updates so users can debug new behavior. +5. Switch presets to production-like defaults after test parity is proven. +6. Remove old behavior only after migration window and test updates are complete. + +## Risks and Mitigations + +- Test flakiness from DAD timing: + - Mitigation: deterministic mode, bounded retries, explicit waiting for non-tentative state. +- Behavior drift across kernels: + - Mitigation: netlink-level assertions in tests, avoid shell-only checks. +- Backward compatibility breaks in existing tests: + - Mitigation: per-lab toggle and staged migration. +- Scope-handling regressions in downstream apps: + - Mitigation: helper APIs, docs, and compile-time type hints where possible. +- RA timing sensitivity in CI: + - Mitigation: controlled RA timers in tests and explicit timeouts. +- Background-task lifecycle bugs for RA/RS workers: + - Mitigation: explicit ownership model in netns workers, teardown tests, and tracing instrumentation for task state. + +## Deliverables + +- Updated IPv6/link-local core behavior with scope-safe routing. +- RA/RS-capable provisioning path. +- Router interface public API (`RouterIface`) with LLA observability. +- Public API and devtools support for link-local observability. +- New docs and a dedicated link-local test suite. +- Production-like example scenarios for home, mobile, Linux, macOS, and Windows host behavior. + +## Implementation Notes for Patchbay Architecture + +This section maps planned work to current patchbay modules so implementation can start directly. + +1. `patchbay/src/core.rs` + - Extend interface and router state with `ll6` and optional address-state fields. + - Replace unconditional DAD disable with mode-driven behavior. + - Integrate scoped default-route installation paths. + - Add RA/RS task lifecycle hooks at router/device setup and teardown points. +2. `patchbay/src/netlink.rs` + - Add scoped IPv6 route helpers that bind gateway and output interface. + - Add query helpers that return default route plus interface metadata. + - Add address query helpers to read interface LLAs and flags. +3. `patchbay/src/handles.rs` + - Add `RouterIface` value type analogous to `DeviceIface`. + - Add `Router::iface` and `Router::interfaces` methods. + - Add LLA/state/default-router getters to device and router iface snapshots. +4. `patchbay/src/lab.rs` + - Extend `LabOpts` with IPv6 provisioning and DAD mode defaults. + - Add builder options for RA behavior and profile selection. +5. `patchbay/src/event.rs` and `patchbay-server` + - Add optional LLA and scoped-router fields in serialized events/state for devtools. +6. `ui/` + - Display per-interface LLAs and scoped default-router details in node views. + +## Feasibility Notes from Additional Research + +1. Scoped route installation is feasible with Linux route semantics and current rtnetlink primitives. + - Linux route model supports explicit `via + dev` behavior for scoped next hops. + - Current patchbay already has device-route helpers and can be extended with scoped default-route helpers. +2. RA source constraints are straightforward to enforce. + - RFC 4861 requires router advertisements to use link-local source addresses. + - This requirement fits patchbay's per-interface router model once LLAs are observable. +3. DAD realism is feasible but requires controlled timing in tests. + - Linux default behavior includes tentative states; deterministic test mode must remain available. +4. Full non-Linux host emulation is not feasible in-scope. + - Patchbay runs Linux netns, so policy-profile emulation is the practical and correct target. + +## Execution Checklist (PR-by-PR) + +This is the concrete implementation order to reduce risk and keep each change reviewable. + +### PR 1: Data model and scoped route primitives + +Scope: + +- Add core/interface fields for link-local visibility and route scope metadata. +- Add netlink helpers needed for scoped IPv6 default routes. +- Keep behavior unchanged by default, no RA/RS yet. + +Files: + +1. `patchbay/src/core.rs` + - Add `ll6` and optional address-state fields in interface structs. + - Add default-router scoped metadata fields (internal only for now). +2. `patchbay/src/netlink.rs` + - Add scoped IPv6 default route add/replace helpers. + - Add route query helper that returns gateway + device info. + - Add helper(s) to query interface link-local IPv6 addresses. +3. `patchbay/src/handles.rs` + - Add `DeviceIface::ll6()` and placeholder state getters if data exists. + +Checks: + +- `cargo check -p patchbay --tests` +- Add/adjust unit tests for netlink helper behavior where feasible. + +Exit criteria: + +- Link-local can be discovered from netlink and exposed on device iface snapshots. +- Scoped default-route helper compiles and is callable from core. + +### PR 2: RouterIface public API and snapshot plumbing + +Scope: + +- Introduce `RouterIface` as a value-type snapshot API. +- Wire `Router::iface` and `Router::interfaces`. + +Files: + +1. `patchbay/src/handles.rs` + - Add `RouterIface` struct and getters (`name`, `ip`, `ip6`, `ll6`, optional state). + - Add `Router::iface(name)` and `Router::interfaces()`. +2. `patchbay/src/core.rs` + - Ensure router interface snapshots can include new fields. +3. `patchbay/src/lib.rs` + - Re-export `RouterIface` if needed. + +Checks: + +- `cargo check -p patchbay --tests` +- Add API-level tests for snapshot consistency. + +Exit criteria: + +- Downstream code can fetch router-side LLAs through stable public APIs. + +### PR 3: LabOpts and builder knobs for provisioning and DAD modes + +Scope: + +- Extend `LabOpts` and router builder knobs without changing defaults yet. +- Add explicit policy profile config structs/enums. + +Files: + +1. `patchbay/src/lab.rs` + - Extend `LabOpts` with IPv6 provisioning/DAD defaults. + - Add profile and RA config options on builders. +2. `patchbay/src/config.rs` + - Add TOML mapping for new knobs where relevant. +3. `patchbay/src/lib.rs` + - Re-export new public enums/types. + +Checks: + +- `cargo check -p patchbay --tests` +- Add config parsing tests for new options. + +Exit criteria: + +- New knobs are accepted and serialized/loaded, with old behavior preserved by default. + +### PR 4: DAD mode implementation and scoped default-route usage in core wiring + +Scope: + +- Replace unconditional DAD disable with mode-driven behavior. +- Switch IPv6 default-route install paths to scoped helpers for link-local gateways. + +Files: + +1. `patchbay/src/core.rs` + - Gate DAD sysctl handling on mode. + - Update iface/router setup paths to call scoped route helpers where needed. +2. `patchbay/src/netlink.rs` + - Finalize any missing error handling and idempotency behavior. + +Checks: + +- `cargo check -p patchbay --tests` +- Add tests: + - `dad_enabled_production_mode` + - `dad_disabled_deterministic_mode` + - `default_route_via_link_local_requires_dev_scope` + +Exit criteria: + +- DAD behavior is mode-dependent. +- Link-local default routes are scope-safe. + +### PR 5: RA/RS engine on netns workers + +Scope: + +- Implement RA sender tasks on router interfaces and RS/default-router learning on hosts. +- Add lifecycle management hooks for start/stop/restart. + +Files: + +1. `patchbay/src/core.rs` + - Add RA/RS task registration and teardown integration points. +2. `patchbay/src/netns.rs` + - Add APIs needed to own long-lived namespace tasks safely. +3. `patchbay/src/lab.rs` + - Trigger RA/RS initialization from topology/build lifecycle. + +Checks: + +- `cargo check -p patchbay --tests` +- Add initial RA/RS tests in stable test modules: + - `ra_source_is_link_local` + - `host_learns_default_router_from_ra_link_local` + - `router_lifetime_zero_withdraws_default_router` + - `ra_task_lifecycle_matches_namespace_lifecycle` + +Exit criteria: + +- RA/RS works in opt-in mode and survives lifecycle churn. + +### PR 6: Events/devtools observability and UI support + +Scope: + +- Surface `ll6` and scoped default-router metadata in events/state. +- Render metadata in devtools UI. +- Add schema/back-compat tests in event/UI test modules as needed. + +Files: + +1. `patchbay/src/event.rs` + - Add optional fields for LLA/scope metadata. +2. `patchbay-server/src/lib.rs` + - Ensure serialization path includes new fields. +3. `ui/src/` (details + topology panes) + - Display per-interface LLAs and scoped router info. + +Checks: + +- `cargo check -p patchbay-server` +- `cd ui && npm run build` +- Add/adjust UI tests if applicable. + +Exit criteria: + +- Devtools shows LLA and scope metadata without breaking existing views. +- Additive payload behavior is verified when LLA data is absent. + +### PR 7: Presets, policy profiles, docs, and migration finalization + +Scope: + +- Wire policy profiles to presets. +- Update docs and examples. +- Decide default switch timing. + +Files: + +1. `patchbay/src/lab.rs` / preset definitions + - Map presets to profile behavior. +2. `docs/reference/ipv6.md` + - Add behavior contract and migration notes. +3. `docs/guide/*` as needed + - Update examples to use `ll6`/scoped routes where relevant. +4. `plans/PLAN.md` + - Move this plan through Partial -> Completed when done. + +Checks: + +- Full mandatory workflow from AGENTS.md: + - `cargo make format` + - `cargo clippy -p patchbay --tests --fix --allow-dirty` + - `cargo check -p patchbay --tests` + - `cargo nextest run -p patchbay` + - `cargo check` (workspace) + - `cd ui && npm run test:e2e` if UI changed +- All Phase 5 behaviors are covered by the current test suite and passing. + +Exit criteria: + +- Profiles and presets are documented, test-backed, and ready for default rollout decision. +- Coverage is maintained in the existing long-lived test modules to reduce flakiness and duplication.