Split the command line by level

`up` now says which level each setting belongs to, and `--help` shows the
two sections. System: how the agent reaches peers, the one interface it
owns, the address range, the resolver. Transport: which protocols carry
packets and what they take.

`--wireguard` is gone. `--protocol` takes a list and defaults to
`wg-quic`, which is what the protocol is now called — WireGuard's
cryptography in QUIC datagrams, so the name says what is on the wire
rather than what the implementation borrows. `--protocol none` runs the
control plane alone.

Protocol settings moved to `-o key=value`, or `-o protocol:key=value`
when several are selected. Each protocol declares its own settings and
their help, so `tsunagi protocols` can list them without the agent
knowing anything about any protocol, and a setting nobody takes is
refused rather than dropped — a dropped setting looks exactly like one
that did not work. What the user asked for is checked before anything
that could fail on its own, so a misspelled protocol is not buried under
a privilege error.

`--wg-prefix` and `--wg-mtu` became `--interface` and `--mtu`: they were
never the protocol's, and the interface they describe belongs to the
agent. `--transport` became `--reach`, because "transport" now means the
protocol level and using the word for iroh's path policy as well would
be a collision of meaning rather than a shortage of words.

The plugin gave up the last things that were not its own: the interface
name it carried in its own state, and the check that this agent's
address is really on an interface. Both are the agent's, and the check
is now the agent's too, still said once per address rather than every
round.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
tsunagi
2026-09-21 19:44:21 +01:00
co-authored by Claude Opus 5
parent 7c1be332e3
commit ff7e235414
6 changed files with 386 additions and 154 deletions
+42 -7
View File
@@ -73,13 +73,13 @@ cargo build --release
./target/release/tsunagi id secret generate # prints tsn1...; share it privately ./target/release/tsunagi id secret generate # prints tsn1...; share it privately
./target/release/tsunagi status # this device, the agent, and this host ./target/release/tsunagi status # this device, the agent, and this host
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard ./target/release/tsunagi up --network lab --secret "$SECRET"
``` ```
It prints its endpoint id and then waits. On the second machine, pass that id: It prints its endpoint id and then waits. On the second machine, pass that id:
```bash ```bash
./target/release/tsunagi up --network lab --secret "$SECRET" --wireguard \ ./target/release/tsunagi up --network lab --secret "$SECRET" \
--peer <endpoint-id-from-the-first-machine> --peer <endpoint-id-from-the-first-machine>
``` ```
@@ -134,8 +134,8 @@ id and collides with essentially nothing.
what they find, so `--ipv4-range` only matters for whoever starts the network: what they find, so `--ipv4-range` only matters for whoever starts the network:
```bash ```bash
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range 10.44.0.0/16 tsunagi up --network lab --secret "$SECRET" --ipv4-range 10.44.0.0/16
tsunagi up --network lab --secret "$SECRET" --wireguard --ipv4-range none # IPv6 only tsunagi up --network lab --secret "$SECRET" --ipv4-range none # IPv6 only
``` ```
An address is claimed with a record signed by that member's persistent device An address is claimed with a record signed by that member's persistent device
@@ -149,13 +149,48 @@ Because the address is allocated at run time rather than derived, it is not
known until the agent has started and agreed with its peers. The agent then known until the agent has started and agreed with its peers. The agent then
assigns it to the interface itself. assigns it to the interface itself.
## Levels
The command line is split the way the design is. A bare `tsunagi up` needs
only a network name and a secret; everything else sits under the level it
belongs to, which `--help` shows as two sections:
* **System** — what the agent itself does: how it reaches peers (`--reach`),
the one overlay interface it owns (`--interface`, `--mtu`, `--no-tun`), the
address range (`--ipv4-range`), and the local resolver (`--dns`).
* **Transport** — which protocols carry packets (`--protocol`, a list) and
their own settings (`-o key=value`, or `-o protocol:key=value`).
```bash
tsunagi protocols # what this build can carry packets with
```
```text
wg-quic (wire version 4)
what WireGuard's cryptography carried in iroh's QUIC datagrams
-o keepalive=SECONDS keeps a tunnel and its link warm through a NAT
-o mtu=BYTES largest packet a tunnel will carry, at least 576
```
Each protocol declares its own settings, so the agent can list them without
knowing anything about the protocol, and a setting no selected protocol takes
is refused rather than ignored. `--protocol none` runs the control plane by
itself.
A pair of peers uses a protocol they both have **at the same wire version**.
That is not the software version: two peers on different builds carry traffic
for each other for as long as the bytes between them have not changed. A peer
with nothing in common keeps its control plane — messages and signed state
still flow — and simply has no data plane, which `tsunagi status` shows as a
session with no agreed protocol.
## Names ## Names
`--dns` serves a local DNS zone for the network's members, so they can be `--dns` serves a local DNS zone for the network's members, so they can be
reached by name instead of by address: reached by name instead of by address:
```bash ```bash
tsunagi up --network lab --secret "$SECRET" --wireguard --dns tsunagi up --network lab --secret "$SECRET" --dns
dig @10.13.37.69 -p 5354 music.lab dig @10.13.37.69 -p 5354 music.lab
``` ```
@@ -407,7 +442,7 @@ explicitly.
Two different lookups are involved, and only one of them is this project's: Two different lookups are involved, and only one of them is this project's:
**1. Resolving one endpoint's address — iroh's, and it works today.** **1. Resolving one endpoint's address — iroh's, and it works today.**
With `--transport relay` or `--transport direct`, iroh publishes a signed With `--reach relay` or `--reach direct`, iroh publishes a signed
record of this endpoint's addresses, keyed by its endpoint id, to the public record of this endpoint's addresses, keyed by its endpoint id, to the public
service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`, service run by Number 0 — "n0", the company behind iroh — at `dns.iroh.link`,
over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached: over pkarr and DNS, and resolves other endpoints the same way. That is why `--peer <endpoint-id>` works with no address attached:
@@ -426,7 +461,7 @@ What this means in practice:
- With `relay` or `direct`, **your endpoint id and IP addresses are published - With `relay` or `direct`, **your endpoint id and IP addresses are published
to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is to a public third-party service** (Number 0's, unless you change it). They are not secret, and the network secret is
never published, but an observer of that service learns that your endpoint never published, but an observer of that service learns that your endpoint
exists and where it is. `--transport local` publishes nothing. exists and where it is. `--reach local` publishes nothing.
- A relay, when one is needed, sees the volume and timing of your traffic — not - A relay, when one is needed, sees the volume and timing of your traffic — not
its contents. The default relays are Number 0's, in the US, EU and its contents. The default relays are Number 0's, in the US, EU and
Asia-Pacific. Asia-Pacific.
+238 -62
View File
@@ -50,6 +50,8 @@ enum Command {
Up(Box<UpArgs>), Up(Box<UpArgs>),
/// Reports this device, what the agent is doing, and what this host can do. /// Reports this device, what the agent is doing, and what this host can do.
Status(StatusArgs), Status(StatusArgs),
/// Shows the protocols this build can carry packets with.
Protocols,
} }
#[derive(Debug, Args)] #[derive(Debug, Args)]
@@ -160,7 +162,7 @@ impl PathArgs {
/// behind iroh — at `dns.iroh.link`, and resolve peers through it. That is /// behind iroh — at `dns.iroh.link`, and resolve peers through it. That is
/// what makes `--peer <endpoint-id>` work without an address. /// what makes `--peer <endpoint-id>` work without an address.
#[derive(Debug, Clone, Copy, ValueEnum)] #[derive(Debug, Clone, Copy, ValueEnum)]
enum Transport { enum Reach {
/// Loopback and the local network only. Publishes nothing. /// Loopback and the local network only. Publishes nothing.
Local, Local,
/// Public address lookup, direct paths only, no relays. /// Public address lookup, direct paths only, no relays.
@@ -170,12 +172,12 @@ enum Transport {
Relay, Relay,
} }
impl From<Transport> for TransportPolicy { impl From<Reach> for TransportPolicy {
fn from(value: Transport) -> Self { fn from(value: Reach) -> Self {
match value { match value {
Transport::Local => TransportPolicy::LocalOnly, Reach::Local => TransportPolicy::LocalOnly,
Transport::Direct => TransportPolicy::DirectOnly, Reach::Direct => TransportPolicy::DirectOnly,
Transport::Relay => TransportPolicy::N0Defaults, Reach::Relay => TransportPolicy::N0Defaults,
} }
} }
} }
@@ -203,44 +205,70 @@ struct UpArgs {
secret_file: Option<PathBuf>, secret_file: Option<PathBuf>,
/// Hostname to announce. Defaults to the machine's. /// Hostname to announce. Defaults to the machine's.
#[arg(long)] #[arg(long, help_heading = "System")]
hostname: Option<String>, hostname: Option<String>,
/// How much external connectivity to use. /// How much of iroh's reachability to use.
#[arg(long, value_enum, default_value_t = Transport::Relay)] ///
transport: Transport, /// About how the *control plane* finds peers, not about which protocol
/// carries packets — that is `--protocol`.
#[arg(long, value_enum, default_value_t = Reach::Relay, help_heading = "System")]
reach: Reach,
/// A peer to contact, as `<endpoint-id>` or `<endpoint-id>@<ip:port>,...`. /// A peer to contact, as `<endpoint-id>` or `<endpoint-id>@<ip:port>,...`.
/// ///
/// One agent needs to know another to begin with. Repeat for several. /// One agent needs to know another to begin with. Repeat for several.
#[arg(long = "peer", value_name = "PEER")] #[arg(long = "peer", value_name = "PEER", help_heading = "System")]
peers: Vec<String>, peers: Vec<String>,
/// Local address to bind. Repeat for several; defaults to iroh's choice. /// Local address to bind. Repeat for several; defaults to iroh's choice.
#[arg(long = "bind", value_name = "ADDR")] #[arg(long = "bind", value_name = "ADDR", help_heading = "System")]
binds: Vec<SocketAddr>, binds: Vec<SocketAddr>,
/// Run the WireGuard data plane. /// Name of the overlay interface. One agent has one, whatever carries it.
#[arg(long)] #[arg(
wireguard: bool, long,
default_value = "tsun0",
value_name = "NAME",
help_heading = "System"
)]
interface: String,
/// Largest packet the overlay carries, at least 576.
#[arg(long, value_name = "BYTES", help_heading = "System")]
mtu: Option<u32>,
/// Do not create a real network interface. /// Do not create a real network interface.
/// ///
/// The WireGuard tunnels still run and handshake, so the mesh can be /// Tunnels still run and handshake, so a mesh can be verified with no
/// verified with no privileges; traffic just does not reach the /// privileges; traffic just does not reach the operating system.
/// operating system. #[arg(long, help_heading = "System")]
#[arg(long)]
no_tun: bool, no_tun: bool,
/// Interface name prefix for the WireGuard data plane. /// Protocols to carry packets with, best first.
#[arg(long, default_value = "tsun")]
wg_prefix: String,
/// Interface MTU for the WireGuard data plane.
/// ///
/// Must be at least 1280, the minimum IPv6 requires. /// A pair of peers uses one they both have at the same wire version. A
#[arg(long)] /// peer with none in common keeps its control plane and gets no data
wg_mtu: Option<u32>, /// plane. `none` runs the control plane alone.
#[arg(
long = "protocol",
value_name = "LIST",
value_delimiter = ',',
default_value = "wg-quic",
help_heading = "Transport"
)]
protocols: Vec<String>,
/// A protocol setting, as `key=value` or `protocol:key=value`.
///
/// Repeat for several. `tsunagi protocols` lists what each one takes.
#[arg(
short = 'o',
long = "protocol-option",
value_name = "KEY=VALUE",
help_heading = "Transport"
)]
protocol_options: Vec<String>,
/// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4. /// IPv4 overlay range, as `address/prefix`, or `none` to disable IPv4.
/// ///
@@ -249,22 +277,22 @@ struct UpArgs {
/// agent adopts what it finds. Addresses are allocated from it and /// agent adopts what it finds. Addresses are allocated from it and
/// recorded in signed state, so each member keeps its own across /// recorded in signed state, so each member keeps its own across
/// restarts and long absences. /// restarts and long absences.
#[arg(long, value_name = "CIDR")] #[arg(long, value_name = "CIDR", help_heading = "System")]
ipv4_range: Option<String>, ipv4_range: Option<String>,
/// Serve a local DNS zone for this network's members. /// Serve a local DNS zone for this network's members.
/// ///
/// Members resolve as `<hostname>.<zone>`, from signed state, so a /// Members resolve as `<hostname>.<zone>`, from signed state, so a
/// member that is switched off still resolves. IPv4 only. /// member that is switched off still resolves. IPv4 only.
#[arg(long)] #[arg(long, help_heading = "System")]
dns: bool, dns: bool,
/// The zone to answer for. Defaults to the network name. /// The zone to answer for. Defaults to the network name.
#[arg(long, value_name = "NAME")] #[arg(long, value_name = "NAME", help_heading = "System")]
dns_zone: Option<String>, dns_zone: Option<String>,
/// Port for the local DNS server. /// Port for the local DNS server.
#[arg(long, default_value_t = 5354)] #[arg(long, default_value_t = 5354, help_heading = "System")]
dns_port: u16, dns_port: u16,
/// How often to print a status summary, in seconds. Zero disables it. /// How often to print a status summary, in seconds. Zero disables it.
@@ -353,6 +381,7 @@ async fn run(command: Command) -> Result<(), Box<dyn std::error::Error>> {
Command::Id(args) => id(args).await, Command::Id(args) => id(args).await,
Command::Up(args) => up(*args).await, Command::Up(args) => up(*args).await,
Command::Status(args) => status(args).await, Command::Status(args) => status(args).await,
Command::Protocols => show_protocols(),
} }
} }
@@ -539,7 +568,6 @@ fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
/// Starts the DNS service for one network and keeps it in step with state. /// Starts the DNS service for one network and keeps it in step with state.
fn spawn_dns( fn spawn_dns(
agent: Agent, agent: Agent,
wireguard: Option<Arc<WireguardPlugin>>,
network: NetworkId, network: NetworkId,
zone: tsunagi::dns::ZoneName, zone: tsunagi::dns::ZoneName,
port: u16, port: u16,
@@ -599,10 +627,11 @@ fn spawn_dns(
.iter() .iter()
.find(|member| member.endpoint_id == own) .find(|member| member.endpoint_id == own)
.and_then(|member| member.overlay_address_v4); .and_then(|member| member.overlay_address_v4);
let interface = wireguard // The interface belongs to the agent, so the resolver
.as_ref() // setting attaches to that one and not to a protocol's.
.and_then(|plugin| plugin.overview(network)) let interface = agent
.map(|view| view.interface) .overlay()
.map(|overlay| overlay.interface)
.filter(|name| !name.is_empty()); .filter(|name| !name.is_empty());
let wanted = listen_addresses(overlay, port); let wanted = listen_addresses(overlay, port);
if attempted != wanted { if attempted != wanted {
@@ -727,6 +756,110 @@ fn update(state: &Arc<std::sync::Mutex<DnsState>>, edit: impl FnOnce(&mut DnsSta
} }
} }
/// What a protocol is called, what it speaks, and what it takes.
///
/// A registry rather than a lookup on the plugins themselves, because
/// `tsunagi protocols` has to answer before anything is constructed, and
/// because this is the list `--protocol` resolves against.
struct ProtocolSpec {
/// The name on the wire, which is what peers compare.
name: &'static str,
/// The wire version. Not the software version: two peers on different
/// builds carry traffic for each other as long as this matches.
version: u16,
/// One line about what it is.
summary: &'static str,
/// The settings it accepts.
options: &'static [tsunagi::dataplane::ProtocolOption],
}
/// Every protocol this build has.
const PROTOCOLS: &[ProtocolSpec] = &[ProtocolSpec {
name: tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL,
version: tsunagi::dataplane::wireguard::ANNOUNCEMENT_VERSION,
summary: "WireGuard's cryptography carried in iroh's QUIC datagrams, so it \
crosses NAT and survives where plain WireGuard is blocked",
options: WireguardPlugin::OPTIONS,
}];
/// One `-o` setting, and the protocol it was aimed at.
struct Setting {
/// `Some` when written as `protocol:key=value`.
protocol: Option<String>,
key: String,
value: String,
}
/// Parses `-o` settings, which are `key=value` or `protocol:key=value`.
fn parse_settings(raw: &[String]) -> Result<Vec<Setting>, Box<dyn std::error::Error>> {
raw.iter()
.map(|entry| {
let (left, value) = entry
.split_once('=')
.ok_or_else(|| format!("`{entry}` is not a setting; write it as key=value"))?;
let (protocol, key) = match left.split_once(':') {
Some((protocol, key)) => (Some(protocol.to_string()), key),
None => (None, left),
};
if key.is_empty() {
return Err(format!("`{entry}` has no key").into());
}
Ok(Setting {
protocol,
key: key.to_string(),
value: value.to_string(),
})
})
.collect()
}
/// The settings meant for one protocol, refusing any that fit nowhere.
fn settings_for(spec: &ProtocolSpec, settings: &[Setting]) -> Vec<(String, String)> {
let mut taken = Vec::new();
for setting in settings {
let aimed_here = match &setting.protocol {
Some(name) => name == spec.name,
// Unqualified settings go to whichever protocol declares the
// key. With one selected that is the obvious reading; with
// several, write `protocol:key=value`.
None => spec.options.iter().any(|option| option.key == setting.key),
};
if aimed_here {
taken.push((setting.key.clone(), setting.value.clone()));
}
}
taken
}
/// Shows the protocols this build has, and what each one takes.
fn show_protocols() -> Result<(), Box<dyn std::error::Error>> {
use report::{Health, Report, Row, Section};
let mut out = Report::new();
for spec in PROTOCOLS {
let mut section = Section::new(format!("{} (wire version {})", spec.name, spec.version));
section.push(Row::new(Health::Info, "what", spec.summary));
if spec.options.is_empty() {
section.push(Row::new(Health::Info, "settings", "none"));
}
for option in spec.options {
section.push(
Row::new(
Health::Info,
format!("-o {}={}", option.key, option.value),
option.help,
)
.with_note(match option.default {
Some(default) => format!("default {default}"),
None => "no default".to_string(),
}),
);
}
out.push(section);
}
print_report("tsunagi protocols", &out)
}
/// Serves the local control socket from the running agent. /// Serves the local control socket from the running agent.
/// ///
/// A struct rather than a closure because this end both answers questions and /// A struct rather than a closure because this end both answers questions and
@@ -1872,7 +2005,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
let mut config = AgentConfig::new(paths.clone()) let mut config = AgentConfig::new(paths.clone())
.with_overlay_ipv4_range(ipv4_range) .with_overlay_ipv4_range(ipv4_range)
.with_transport(args.transport.into()) .with_transport(args.reach.into())
.with_discovery(discovery) .with_discovery(discovery)
.with_discovery_interval(Duration::from_secs(5)); .with_discovery_interval(Duration::from_secs(5));
if let Some(hostname) = &args.hostname { if let Some(hostname) = &args.hostname {
@@ -1882,31 +2015,77 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
config = config.with_bind_addrs(args.binds.clone()); config = config.with_bind_addrs(args.binds.clone());
} }
// The data plane is optional and never required for the control plane. // What the user asked for is checked first, before anything that could
let wireguard = if args.wireguard { // fail for a reason of its own: a misspelled protocol or setting is
// The interface belongs to the agent, not to the protocol: one // their mistake to see, not something to bury under a privilege error.
// agent, one interface, and every protocol carries traffic for the let wanted: Vec<&ProtocolSpec> = {
// same addresses on it. let mut wanted = Vec::new();
for name in args
.protocols
.iter()
.filter(|name| !name.eq_ignore_ascii_case("none"))
{
let Some(spec) = PROTOCOLS.iter().find(|spec| spec.name == name.as_str()) else {
let known: Vec<&str> = PROTOCOLS.iter().map(|spec| spec.name).collect();
return Err(format!(
"this build has no protocol called `{name}`; it has {}. \
Run `tsunagi protocols` to see what each one takes.",
known.join(", ")
)
.into());
};
wanted.push(spec);
}
wanted
};
let settings = parse_settings(&args.protocol_options)?;
// A setting nobody takes is a mistake, not a preference: one that was
// silently dropped looks exactly like one that did not work.
for setting in &settings {
if !wanted
.iter()
.any(|spec| !settings_for(spec, std::slice::from_ref(setting)).is_empty())
{
return Err(format!(
"no selected protocol takes `{}`; run `tsunagi protocols` to see what they do",
setting.key
)
.into());
}
}
// The interface belongs to the agent, so it is configured once whatever
// was selected to carry traffic over it.
let mut wireguard = None;
if !wanted.is_empty() {
let tun_factory: Arc<dyn TunFactory> = if args.no_tun { let tun_factory: Arc<dyn TunFactory> = if args.no_tun {
Arc::new(MemoryTunFactory::new()) Arc::new(MemoryTunFactory::new())
} else { } else {
system_tun_factory()? system_tun_factory()?
}; };
let mtu = args let mtu = args
.wg_mtu .mtu
.unwrap_or(tsunagi::dataplane::wireguard::DEFAULT_MTU); .unwrap_or(tsunagi::dataplane::wireguard::DEFAULT_MTU);
config = config.with_interface(tun_factory, args.wg_prefix.clone(), mtu); config = config.with_interface(tun_factory, args.interface.clone(), mtu);
}
let mut wg = WireguardConfig::new(paths.state_dir.join("wireguard")); for spec in &wanted {
if let Some(mtu) = args.wg_mtu { let options = settings_for(spec, &settings);
wg = wg.with_mtu(mtu); match spec.name {
tsunagi::dataplane::wireguard::WIREGUARD_PROTOCOL => {
let mut wg = WireguardConfig::new(paths.state_dir.join("wg-quic"));
if let Some(mtu) = args.mtu {
wg = wg.with_mtu(mtu);
}
let wg = WireguardPlugin::configure(wg, &options)?;
let plugin = WireguardPlugin::open(wg).await?;
config = config.with_plugin(plugin.clone() as Arc<dyn IpPlugin>);
wireguard = Some(plugin);
}
other => return Err(format!("`{other}` is listed but not built in").into()),
} }
let plugin = WireguardPlugin::open(wg).await?; }
config = config.with_plugin(plugin.clone() as Arc<dyn IpPlugin>);
Some(plugin)
} else {
None
};
let agent = Agent::spawn(config).await?; let agent = Agent::spawn(config).await?;
// From here on every exit goes through `agent.shutdown()`, so the endpoint // From here on every exit goes through `agent.shutdown()`, so the endpoint
@@ -1943,13 +2122,7 @@ async fn up(args: UpArgs) -> Result<(), Box<dyn std::error::Error>> {
tracing::warn!("{warning}"); tracing::warn!("{warning}");
} }
println!(" dns zone {}", zone.as_str()); println!(" dns zone {}", zone.as_str());
Some(spawn_dns( Some(spawn_dns(agent.clone(), network, zone, args.dns_port))
agent.clone(),
wireguard.clone(),
network,
zone,
args.dns_port,
))
} }
Err(err) => { Err(err) => {
agent.shutdown().await; agent.shutdown().await;
@@ -2298,8 +2471,11 @@ async fn print_status(agent: &Agent, network: NetworkId, wireguard: Option<&Wire
&& let Some(view) = plugin.overview(network) && let Some(view) = plugin.overview(network)
{ {
println!( println!(
"wireguard: {} on {}/{} mtu {}, {}/{} tunnel(s) established", "{}: {} on {}/{} mtu {}, {}/{} tunnel(s) established",
view.interface, plugin.protocol_id(),
agent
.overlay()
.map_or_else(|| "no interface".to_string(), |overlay| overlay.interface),
view.overlay_address_v4 view.overlay_address_v4
.map_or_else(|| "no address yet".to_string(), |addr| addr.to_string()), .map_or_else(|| "no address yet".to_string(), |addr| addr.to_string()),
view.ipv4_range.map_or(0, |range| range.prefix_len), view.ipv4_range.map_or(0, |range| range.prefix_len),
+1 -1
View File
@@ -92,7 +92,7 @@ fn start(zone: &str, port: u16) -> Running {
.arg("--cache-dir") .arg("--cache-dir")
.arg(dir.path().join("cache")) .arg(dir.path().join("cache"))
// No real interface and no internet: this is about the wiring. // No real interface and no internet: this is about the wiring.
.args(["--transport", "local", "--no-tun", "--wireguard", "--dns"]) .args(["--reach", "local", "--no-tun", "--dns"])
.args(["--dns-zone", zone]) .args(["--dns-zone", zone])
.args(["--dns-port", &port.to_string()]) .args(["--dns-port", &port.to_string()])
.args(["--log", "error", "--status-interval", "0"]) .args(["--log", "error", "--status-interval", "0"])
+43 -3
View File
@@ -213,6 +213,9 @@ struct Runtime {
/// Peers already told about a protocol version that cannot match, so it /// Peers already told about a protocol version that cannot match, so it
/// is said once rather than on every announcement. /// is said once rather than on every announcement.
reported_mismatch: HashSet<(EndpointId, String)>, reported_mismatch: HashSet<(EndpointId, String)>,
/// The address last reported as absent from the interface, so it is said
/// once rather than for ever.
reported_missing: Option<std::net::Ipv4Addr>,
/// Signed records, merged from every replica we have talked to. /// Signed records, merged from every replica we have talked to.
state: StateSet, state: StateSet,
/// Snapshots received while dispatching, handled on the next loop pass. /// Snapshots received while dispatching, handled on the next loop pass.
@@ -246,6 +249,7 @@ impl Runtime {
link_results_tx, link_results_tx,
link_results_rx, link_results_rx,
reported_mismatch: HashSet::new(), reported_mismatch: HashSet::new(),
reported_missing: None,
state: StateSet::new(), state: StateSet::new(),
pending_state: Vec::new(), pending_state: Vec::new(),
own_version: 0, own_version: 0,
@@ -878,14 +882,50 @@ impl Runtime {
}); });
return; return;
} }
if let Some(interface) = &self.params.interface let Some(interface) = self.params.interface.clone() else {
&& let Err(err) = interface.sync_addresses().await return;
{ };
if let Err(err) = interface.sync_addresses().await {
self.emit(Event::PluginError { self.emit(Event::PluginError {
network: self.network_id, network: self.network_id,
protocol: "overlay".into(), protocol: "overlay".into(),
reason: err.to_string(), reason: err.to_string(),
}); });
return;
}
// The agent assigns this address itself, so finding it absent means
// the assignment did not take — something outside removed it, or the
// provisioner reported a success it did not achieve. Left unsaid it
// looks like a broken network: packets would leave with the wrong
// source and every peer would drop them. Checked rather than
// assumed, because the assumption is exactly the kind that has been
// wrong here before. Said once per address, not every round.
let missing = match local {
Some(address) if !crate::overlay::address_is_local(std::net::IpAddr::V4(address)) => {
let already = self.reported_missing == Some(address);
self.reported_missing = Some(address);
(!already).then_some(address)
}
_ => {
self.reported_missing = None;
None
}
};
if let Some(address) = missing {
self.emit(Event::PluginError {
network: self.network_id,
protocol: "overlay".into(),
reason: format!(
"this agent was allocated {address}/{} but the address is not on any \
interface, so the overlay cannot work: packets would leave with the \
wrong source and every peer would drop them. It should have been \
assigned to `{}` automatically; check whether something else removed \
it.",
range.prefix_len,
interface.name()
),
});
} }
} }
@@ -25,7 +25,7 @@
//! A failure here is reported and retried. It never stops the control plane. //! A failure here is reported and retried. It never stops the control plane.
use std::collections::{BTreeSet, HashMap}; use std::collections::{BTreeSet, HashMap};
use std::net::{IpAddr, Ipv4Addr}; use std::net::Ipv4Addr;
use std::path::PathBuf; use std::path::PathBuf;
use std::sync::{Arc, Mutex, OnceLock}; use std::sync::{Arc, Mutex, OnceLock};
use std::time::Duration; use std::time::Duration;
@@ -44,11 +44,15 @@ use super::announcement::{ValidatedAnnouncement, WgAnnouncement};
use super::device::{PeerSummary, WireguardDevice}; use super::device::{PeerSummary, WireguardDevice};
use super::keys::{WgPublicKey, WgSecretKey}; use super::keys::{WgPublicKey, WgSecretKey};
use super::store::WgKeyStore; use super::store::WgKeyStore;
use crate::overlay::config::{DEFAULT_INTERFACE_PREFIX, interface_name};
use crate::state::Ipv4Range; use crate::state::Ipv4Range;
/// The protocol identifier this plugin announces. /// The protocol identifier this plugin announces.
pub const WIREGUARD_PROTOCOL: &str = "wireguard"; /// The protocol id of this plugin.
///
/// `wg-quic`, because that is what it is: WireGuard's cryptography carried
/// in QUIC datagrams. The name is on the wire, so it is a protocol name and
/// not a description of the implementation.
pub const WIREGUARD_PROTOCOL: &str = "wg-quic";
/// Smallest interface MTU the overlay accepts. /// Smallest interface MTU the overlay accepts.
/// ///
@@ -77,14 +81,12 @@ pub const WIREGUARD_OVERHEAD: u32 = 32;
pub struct WireguardConfig { pub struct WireguardConfig {
/// Directory for the plugin's own key store. Separate from agent state. /// Directory for the plugin's own key store. Separate from agent state.
pub state_dir: PathBuf, pub state_dir: PathBuf,
/// Prefix of the interface names this plugin creates.
///
/// Two agents on one host in the same network need different prefixes,
/// because the rest of the name is derived from the network id.
pub interface_prefix: String,
/// WireGuard keepalive, which keeps tunnels and their links warm. /// WireGuard keepalive, which keeps tunnels and their links warm.
pub keepalive: Option<u16>, pub keepalive: Option<u16>,
/// Interface MTU. See [`DEFAULT_MTU`]. /// The largest packet a tunnel will carry.
///
/// Not the interface MTU, which belongs to the agent: this is what this
/// protocol refuses to encrypt because it would not fit one datagram.
pub mtu: u32, pub mtu: u32,
/// How long to coalesce changes before reconciling. /// How long to coalesce changes before reconciling.
pub reconcile_debounce: Duration, pub reconcile_debounce: Duration,
@@ -98,7 +100,6 @@ impl WireguardConfig {
pub fn new(state_dir: impl Into<PathBuf>) -> Self { pub fn new(state_dir: impl Into<PathBuf>) -> Self {
Self { Self {
state_dir: state_dir.into(), state_dir: state_dir.into(),
interface_prefix: DEFAULT_INTERFACE_PREFIX.to_string(),
keepalive: Some(25), keepalive: Some(25),
mtu: DEFAULT_MTU, mtu: DEFAULT_MTU,
reconcile_debounce: Duration::from_millis(200), reconcile_debounce: Duration::from_millis(200),
@@ -106,12 +107,6 @@ impl WireguardConfig {
} }
} }
/// Sets the interface name prefix.
pub fn with_interface_prefix(mut self, prefix: impl Into<String>) -> Self {
self.interface_prefix = prefix.into();
self
}
/// Sets the interface MTU. /// Sets the interface MTU.
/// ///
/// Validated when the plugin is opened; see [`MIN_MTU`]. /// Validated when the plugin is opened; see [`MIN_MTU`].
@@ -138,9 +133,7 @@ impl WireguardConfig {
pub struct NetworkOverview { pub struct NetworkOverview {
/// The network. /// The network.
pub network: NetworkId, pub network: NetworkId,
/// Packet interface this plugin created for it. /// The largest packet a tunnel in this network will carry.
pub interface: String,
/// Interface MTU.
pub mtu: u32, pub mtu: u32,
/// This agent's WireGuard public key in this network. /// This agent's WireGuard public key in this network.
pub public_key: WgPublicKey, pub public_key: WgPublicKey,
@@ -186,7 +179,6 @@ impl PeerOverview {
#[derive(Debug)] #[derive(Debug)]
struct NetworkState { struct NetworkState {
key: WgSecretKey, key: WgSecretKey,
interface: String,
device: Option<Arc<WireguardDevice>>, device: Option<Arc<WireguardDevice>>,
announcements: HashMap<EndpointId, ValidatedAnnouncement>, announcements: HashMap<EndpointId, ValidatedAnnouncement>,
links: HashMap<EndpointId, SharedLink>, links: HashMap<EndpointId, SharedLink>,
@@ -194,8 +186,6 @@ struct NetworkState {
allocations: HashMap<EndpointId, Ipv4Addr>, allocations: HashMap<EndpointId, Ipv4Addr>,
/// The range those allocations came from. /// The range those allocations came from.
ipv4_range: Option<Ipv4Range>, ipv4_range: Option<Ipv4Range>,
/// The address last reported as missing, so it is said once, not forever.
reported_missing_v4: Option<Ipv4Addr>,
} }
#[derive(Debug, Default)] #[derive(Debug, Default)]
@@ -245,22 +235,60 @@ pub struct WireguardPlugin {
impl WireguardPlugin { impl WireguardPlugin {
/// The settings this protocol accepts. /// The settings this protocol accepts.
pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = pub const OPTIONS: &'static [crate::dataplane::ProtocolOption] = &[
&[crate::dataplane::ProtocolOption { crate::dataplane::ProtocolOption {
key: "keepalive", key: "keepalive",
value: "SECONDS", value: "SECONDS",
help: "persistent keepalive interval; 0 turns it off", help: "keeps a tunnel and its link warm through a NAT; 0 turns it off",
default: Some("25"), default: Some("25"),
}]; },
crate::dataplane::ProtocolOption {
key: "mtu",
value: "BYTES",
help: "largest packet a tunnel will carry, at least 576",
default: Some("1280"),
},
];
/// Applies `key=value` settings to a configuration.
///
/// An unknown key is refused rather than ignored: a setting that was
/// silently dropped looks exactly like one that did not work.
pub fn configure(
mut config: WireguardConfig,
options: &[(String, String)],
) -> Result<WireguardConfig, PluginError> {
for (key, value) in options {
match key.as_str() {
"keepalive" => {
let seconds: u16 = value.parse().map_err(|_| {
PluginError::Other(format!("keepalive={value} is not a number of seconds"))
})?;
config.keepalive = (seconds > 0).then_some(seconds);
}
"mtu" => {
let mtu: u32 = value.parse().map_err(|_| {
PluginError::Other(format!("mtu={value} is not a number of bytes"))
})?;
config = config.with_mtu(mtu);
}
other => {
let known: Vec<&str> = Self::OPTIONS.iter().map(|spec| spec.key).collect();
return Err(PluginError::Other(format!(
"`{other}` is not a setting of {WIREGUARD_PROTOCOL}; it takes {}",
known.join(", ")
)));
}
}
}
Ok(config)
}
/// Opens the plugin's key store and starts its reconciliation task. /// Opens the plugin's key store and starts its reconciliation task.
/// ///
/// Must be called from inside a tokio runtime; the plugin starts no /// Must be called from inside a tokio runtime; the plugin starts no
/// runtime of its own. /// runtime of its own.
pub async fn open(config: WireguardConfig) -> Result<Arc<Self>, PluginError> { pub async fn open(config: WireguardConfig) -> Result<Arc<Self>, PluginError> {
// Validate the prefix once, here, rather than failing per network.
interface_name(&config.interface_prefix, NetworkId::from_bytes([0u8; 32]))?;
if config.mtu < MIN_MTU { if config.mtu < MIN_MTU {
return Err(PluginError::Other(format!( return Err(PluginError::Other(format!(
"an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \ "an MTU of {} is below the {MIN_MTU} bytes every IPv4 host must be able \
@@ -325,7 +353,6 @@ impl WireguardPlugin {
Some(NetworkOverview { Some(NetworkOverview {
network, network,
interface: state.interface.clone(),
mtu: self.worker.config.mtu, mtu: self.worker.config.mtu,
public_key: state.key.public(), public_key: state.key.public(),
overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(), overlay_address_v4: state.allocations.get(&self.worker.local_id()).copied(),
@@ -399,7 +426,6 @@ impl Worker {
return Ok(false); return Ok(false);
} }
let name = interface_name(&self.config.interface_prefix, network)?;
let worker = Arc::clone(self); let worker = Arc::clone(self);
let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network)) let key = tokio::task::spawn_blocking(move || worker.store.load_or_create(network))
.await .await
@@ -409,13 +435,11 @@ impl Worker {
let mut shared = self.lock_shared(); let mut shared = self.lock_shared();
shared.networks.entry(network).or_insert(NetworkState { shared.networks.entry(network).or_insert(NetworkState {
key, key,
interface: name,
device: None, device: None,
announcements: HashMap::new(), announcements: HashMap::new(),
links: HashMap::new(), links: HashMap::new(),
allocations: HashMap::new(), allocations: HashMap::new(),
ipv4_range: None, ipv4_range: None,
reported_missing_v4: None,
}); });
} }
@@ -471,10 +495,6 @@ impl Worker {
}; };
let allocations = state.allocations.clone(); let allocations = state.allocations.clone();
let own_v4 = state.allocations.get(&self.local_id()).copied();
let interface = state.device.as_ref().map(|_| state.interface.clone());
let range = state.ipv4_range;
let state_reported = state.reported_missing_v4;
let mut wanted: Vec<WgPublicKey> = Vec::new(); let mut wanted: Vec<WgPublicKey> = Vec::new();
let mut too_small: Vec<(usize, usize)> = Vec::new(); let mut too_small: Vec<(usize, usize)> = Vec::new();
for (endpoint_id, announcement) in &state.announcements { for (endpoint_id, announcement) in &state.announcements {
@@ -515,53 +535,15 @@ impl Worker {
} }
device.retain_peers(&wanted); device.retain_peers(&wanted);
// The agent assigns this address itself, so finding it absent means
// the assignment did not take — something outside removed it, or the
// provisioner reported a success it did not achieve. Left unsaid it
// looks like a broken network: the kernel would send packets with the
// wrong source address and every peer would drop them. So it is
// checked rather than assumed, because the assumption is exactly the
// kind that has been wrong here before.
let missing_v4 = match (own_v4, interface.as_deref(), range) {
(Some(address), Some(interface), Some(range))
if !crate::overlay::tun::address_is_local(IpAddr::V4(address)) =>
{
let already = state_reported == Some(address);
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = Some(address);
}
(!already).then_some((address, interface.to_string(), range))
}
_ => {
if let Some(state) = shared.networks.get_mut(&network) {
state.reported_missing_v4 = None;
}
None
}
};
drop(shared); drop(shared);
if let Some((address, interface, range)) = missing_v4 {
self.report(
network,
format!(
"this agent was allocated {address}/{} but the address is not on any \
interface, so IPv4 cannot work: packets would leave with the wrong \
source and every peer would drop them. It should have been assigned \
to `{interface}` automatically; check whether something else removed \
it.",
range.prefix_len
),
);
}
for (available, needed) in too_small { for (available, needed) in too_small {
self.report( self.report(
network, network,
format!( format!(
"this path carries only {available} byte datagrams but a {} byte MTU needs \ "this path carries only {available} byte datagrams but a {} byte MTU needs \
{needed}; packets larger than {} bytes will be dropped. Lower the MTU only \ {needed}; packets larger than {} bytes will be dropped. The floor is \
if you can stay at or above {MIN_MTU}, which IPv6 requires.", {MIN_MTU} bytes, what every IPv4 host must be able to reassemble.",
self.config.mtu, self.config.mtu,
available.saturating_sub(WIREGUARD_OVERHEAD as usize) available.saturating_sub(WIREGUARD_OVERHEAD as usize)
), ),
+3 -4
View File
@@ -474,10 +474,10 @@ async fn an_allocated_address_missing_from_the_host_is_reported() {
reason.contains(&allocated.to_string()), reason.contains(&allocated.to_string()),
"unexpected: {reason}" "unexpected: {reason}"
); );
// The agent assigns the address itself, so the report says which // The agent owns the interface, so it is the agent that notices and the
// interface should have had it rather than a command to run. // report names the interface the address should have been on.
assert!( assert!(
reason.contains(&a.plugin.overview(network_id).unwrap().interface), reason.contains(&a.agent.overlay().unwrap().interface),
"must name the interface: {reason}" "must name the interface: {reason}"
); );
@@ -815,7 +815,6 @@ async fn restarting_keeps_the_wireguard_identity_and_overlay_address() {
}) })
.await; .await;
assert_eq!(after.public_key, before.public_key); assert_eq!(after.public_key, before.public_key);
assert_eq!(after.interface, before.interface);
// The tunnel comes back on its own. // The tunnel comes back on its own.
wait_until("the tunnel is re-established", || async { wait_until("the tunnel is re-established", || async {