The command line package is now `tsunagi` and builds the `tsng` executable, which the packages, the unit, the installers and the workflows install and run. The core library package becomes `tsunagi-core`, keeping the library name `tsunagi`, so no code changes `use tsunagi::...`. Directories follow (crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now `tsng ...`. Co-Authored-By: Claude <noreply@anthropic.com>
514 lines
18 KiB
Rust
514 lines
18 KiB
Rust
//! Managing the overlay interface on Windows, through Wintun and `netsh`.
|
|
//!
|
|
//! The shape is the same as the Linux provisioner: observe what is there,
|
|
//! [`plan_changes`](super::plan_changes) to decide what to do, apply the
|
|
//! difference. Only the two mechanisms differ.
|
|
//!
|
|
//! # Creating the interface
|
|
//!
|
|
//! The interface is a Wintun adapter, created through the `tun` dependency's
|
|
//! safe wrapper — [`open_tun`](super::super::tun) — because loading
|
|
//! `wintun.dll` is an `unsafe` call and this crate forbids `unsafe`. The
|
|
//! adapter is created, not made persistent: closing the last handle to it
|
|
//! removes it, so it goes away when the agent does, however the agent ends.
|
|
//! The reader and writer inside the device hold that handle, so the adapter
|
|
//! lives exactly as long as the device does.
|
|
//!
|
|
//! # Configuring it
|
|
//!
|
|
//! Addresses and MTU are applied with `netsh`. On Linux the same work is done
|
|
//! in process over a netlink socket; here there is no equivalent that this
|
|
//! crate may call, because the in-process route is the IP Helper API and that
|
|
//! is `unsafe`. `netsh` is the supported tool for the job, it is invoked by
|
|
//! absolute path from `%SystemRoot%` so nothing on `PATH` can stand in for it,
|
|
//! and every value handed to it is one this agent derived — an interface name
|
|
//! from the network id, an address it allocated — never anything a peer said.
|
|
//!
|
|
//! # Observing it
|
|
//!
|
|
//! The interface table is read through `netdev`, whose enumeration is a safe
|
|
//! wrapper over the same IP Helper API. An adapter is matched by the friendly
|
|
//! name it was created with. A match that carries a default gateway is treated
|
|
//! as foreign and left alone: a tsunagi overlay interface never has one, so a
|
|
//! gateway is the mark of a real device that happens to share the name.
|
|
//!
|
|
//! # What Windows cannot do that Linux can
|
|
//!
|
|
//! Wintun exposes no way to delete an adapter this process did not create, so
|
|
//! a leftover from a run that was killed is *reused* rather than replaced: its
|
|
//! addresses are flushed and the planned ones applied, which reaches the same
|
|
//! end state. A reused adapter is not removed on exit, only closed — that is a
|
|
//! Wintun limitation, and the common case of a clean start and a clean stop is
|
|
//! unaffected.
|
|
|
|
use std::net::{IpAddr, Ipv4Addr};
|
|
use std::sync::{Arc, Mutex};
|
|
|
|
use crate::BoxFuture;
|
|
use crate::overlay::OverlayError;
|
|
|
|
use super::super::config::Cidr;
|
|
use super::super::tun::{TunDevice, TunRequest, open_tun};
|
|
use super::privilege::{Privilege, probe_net_admin};
|
|
use super::{
|
|
InterfacePlan, InterfaceProvisioner, InterfaceState, LinkKind, Provisioned, plan_changes,
|
|
};
|
|
|
|
/// Manages the overlay interface with Wintun and `netsh`.
|
|
#[derive(Debug)]
|
|
pub struct WintunProvisioner {
|
|
/// Interfaces this process created, so they are adjusted rather than
|
|
/// replaced. See [`plan_changes`].
|
|
ours: Mutex<Vec<String>>,
|
|
/// Devices kept alive for as long as the interface should exist. Dropping
|
|
/// one closes the adapter, which removes it if this process created it.
|
|
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
|
}
|
|
|
|
impl WintunProvisioner {
|
|
/// Creates the provisioner, after checking the platform supports one.
|
|
///
|
|
/// Whether the process is actually elevated is not checked here — that
|
|
/// cannot be read without an `unsafe` call — so a missing privilege
|
|
/// surfaces at adapter creation with a precise message, the same way the
|
|
/// Linux path treats the open itself as the honest answer.
|
|
pub fn new() -> Result<Self, OverlayError> {
|
|
match probe_net_admin() {
|
|
Privilege::Available => {}
|
|
Privilege::Missing(reason) => return Err(OverlayError::Unavailable(reason)),
|
|
Privilege::Unsupported => {
|
|
return Err(OverlayError::Unavailable(
|
|
"interface management is not compiled in".to_string(),
|
|
));
|
|
}
|
|
}
|
|
Ok(Self {
|
|
ours: Mutex::new(Vec::new()),
|
|
held: Mutex::new(Vec::new()),
|
|
})
|
|
}
|
|
|
|
fn is_ours(&self, name: &str) -> bool {
|
|
lock(&self.ours)
|
|
.iter()
|
|
.any(|owned| owned.eq_ignore_ascii_case(name))
|
|
}
|
|
|
|
/// Opens the Wintun adapter, which is what creates it.
|
|
fn create_device(&self, plan: &InterfacePlan) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
|
let request = TunRequest::bare(plan.name.clone(), plan.mtu);
|
|
open_tun(&request)
|
|
}
|
|
}
|
|
|
|
impl InterfaceProvisioner for WintunProvisioner {
|
|
fn name(&self) -> &str {
|
|
"wintun"
|
|
}
|
|
|
|
fn reconcile<'a>(
|
|
&'a self,
|
|
plan: &'a InterfacePlan,
|
|
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
|
Box::pin(async move {
|
|
let current = observe(&plan.name).await;
|
|
let changes = plan_changes(¤t, plan, self.is_ours(&plan.name))?;
|
|
|
|
// Wintun cannot delete an adapter this process did not create, so a
|
|
// "delete then create" from `plan_changes` becomes "flush then
|
|
// reuse": clear the stale addresses off the interface that is
|
|
// there, and let the create step below reopen the same adapter.
|
|
// The end state — an adapter carrying exactly the plan's addresses
|
|
// — is identical.
|
|
let flush: Vec<Cidr> = if changes.delete_link {
|
|
tracing::info!(
|
|
interface = %plan.name,
|
|
"reusing an abandoned interface left by an earlier run"
|
|
);
|
|
current.addresses.clone()
|
|
} else {
|
|
Vec::new()
|
|
};
|
|
|
|
for cidr in flush.iter().chain(changes.remove.iter()) {
|
|
// Best effort: an address that is already gone is the outcome
|
|
// wanted, and refusing to start over one is not worth it.
|
|
if let Err(err) = address_del(&plan.name, *cidr).await {
|
|
tracing::debug!(interface = %plan.name, %cidr, %err, "could not remove address");
|
|
}
|
|
}
|
|
|
|
let device = if changes.create_link {
|
|
let device = self.create_device(plan)?;
|
|
lock(&self.ours).push(plan.name.clone());
|
|
lock(&self.held).push((plan.name.clone(), Arc::clone(&device)));
|
|
Some(device)
|
|
} else {
|
|
None
|
|
};
|
|
|
|
// A failure past this point leaves an interface that exists but
|
|
// cannot carry traffic. Undo the bookkeeping for one we just
|
|
// created so it is not mistaken for a working interface, and drop
|
|
// the device so the adapter closes.
|
|
let configured = configure(plan, &changes).await;
|
|
if let Err(err) = configured {
|
|
if changes.create_link {
|
|
lock(&self.held).retain(|(held, _)| held != &plan.name);
|
|
lock(&self.ours).retain(|owned| owned != &plan.name);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
Ok(Provisioned { changes, device })
|
|
})
|
|
}
|
|
|
|
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
|
Box::pin(async move {
|
|
// Dropping the device closes the adapter, which removes it if this
|
|
// process created it. A reused adapter is only closed, not removed;
|
|
// flushing its addresses first keeps no stale configuration behind.
|
|
let current = observe(name).await;
|
|
for cidr in ¤t.addresses {
|
|
if let Err(err) = address_del(name, *cidr).await {
|
|
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
|
}
|
|
}
|
|
lock(&self.held).retain(|(held, _)| !held.eq_ignore_ascii_case(name));
|
|
lock(&self.ours).retain(|owned| !owned.eq_ignore_ascii_case(name));
|
|
Ok(())
|
|
})
|
|
}
|
|
}
|
|
|
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
|
match mutex.lock() {
|
|
Ok(guard) => guard,
|
|
Err(poisoned) => poisoned.into_inner(),
|
|
}
|
|
}
|
|
|
|
/// `fe80::/10` and `169.254.0.0/16`, which Windows assigns on its own.
|
|
fn is_link_local(addr: IpAddr) -> bool {
|
|
match addr {
|
|
IpAddr::V4(addr) => addr.is_link_local(),
|
|
IpAddr::V6(addr) => (addr.segments()[0] & 0xffc0) == 0xfe80,
|
|
}
|
|
}
|
|
|
|
/// What the interface named `name` currently looks like.
|
|
///
|
|
/// Never fails: a name nothing is using reads as [`InterfaceState::absent`],
|
|
/// which is what the plan then acts on.
|
|
async fn observe(name: &str) -> InterfaceState {
|
|
let name = name.to_string();
|
|
let found = tokio::task::spawn_blocking(move || {
|
|
netdev::get_interfaces()
|
|
.into_iter()
|
|
.find(|iface| match &iface.friendly_name {
|
|
Some(friendly) => friendly.eq_ignore_ascii_case(&name),
|
|
None => false,
|
|
})
|
|
})
|
|
.await
|
|
.ok()
|
|
.flatten();
|
|
|
|
let Some(iface) = found else {
|
|
return InterfaceState::absent();
|
|
};
|
|
|
|
let mut addresses: Vec<Cidr> = Vec::new();
|
|
for net in &iface.ipv4 {
|
|
if let Ok(cidr) = Cidr::new(IpAddr::V4(net.addr()), net.prefix_len())
|
|
&& !is_link_local(cidr.addr)
|
|
{
|
|
addresses.push(cidr);
|
|
}
|
|
}
|
|
for net in &iface.ipv6 {
|
|
if let Ok(cidr) = Cidr::new(IpAddr::V6(net.addr()), net.prefix_len())
|
|
&& !is_link_local(cidr.addr)
|
|
{
|
|
addresses.push(cidr);
|
|
}
|
|
}
|
|
addresses.sort();
|
|
|
|
// A tsunagi overlay interface only ever carries the addresses it was given
|
|
// and no gateway. One that has a gateway is a real device sharing the name,
|
|
// so it is foreign and never touched.
|
|
let kind = if iface.gateway.is_some() {
|
|
LinkKind::Foreign("gatewayed".to_string())
|
|
} else {
|
|
LinkKind::Tun
|
|
};
|
|
|
|
InterfaceState {
|
|
kind,
|
|
// Wintun reports an adapter with a live session as up and an abandoned
|
|
// one as down, so "up" doubles as "some process is holding it".
|
|
attached: iface.is_up(),
|
|
up: iface.is_up(),
|
|
mtu: iface.mtu.unwrap_or(0),
|
|
addresses,
|
|
}
|
|
}
|
|
|
|
/// Applies MTU, link state and addresses.
|
|
async fn configure(plan: &InterfacePlan, changes: &super::Changes) -> Result<(), OverlayError> {
|
|
if let Some(mtu) = changes.set_mtu {
|
|
set_mtu(&plan.name, mtu).await?;
|
|
}
|
|
for cidr in &changes.add {
|
|
if let Err(err) = address_add(&plan.name, *cidr).await {
|
|
let err_str = err.to_string();
|
|
if !err_str.contains("already exists") && !err_str.contains("0x80071392") {
|
|
return Err(err);
|
|
}
|
|
}
|
|
}
|
|
if changes.bring_up {
|
|
// Best effort: a Wintun adapter with a session is up already, and a
|
|
// failure to nudge it is not a reason to fail the whole reconcile.
|
|
if let Err(err) = set_up(&plan.name).await {
|
|
tracing::debug!(interface = %plan.name, %err, "could not enable the interface");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The dotted netmask for an IPv4 prefix length.
|
|
fn ipv4_mask(prefix_len: u8) -> Ipv4Addr {
|
|
if prefix_len == 0 {
|
|
Ipv4Addr::UNSPECIFIED
|
|
} else {
|
|
Ipv4Addr::from(u32::MAX << (32 - u32::from(prefix_len)))
|
|
}
|
|
}
|
|
|
|
/// The `netsh` arguments that add an address to an interface.
|
|
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
match cidr.addr {
|
|
IpAddr::V4(addr) => vec![
|
|
"interface".into(),
|
|
"ipv4".into(),
|
|
"add".into(),
|
|
"address".into(),
|
|
format!("name={name}"),
|
|
format!("address={addr}"),
|
|
format!("mask={}", ipv4_mask(cidr.prefix_len)),
|
|
"store=active".into(),
|
|
],
|
|
IpAddr::V6(addr) => vec![
|
|
"interface".into(),
|
|
"ipv6".into(),
|
|
"add".into(),
|
|
"address".into(),
|
|
format!("interface={name}"),
|
|
format!("address={addr}/{}", cidr.prefix_len),
|
|
"store=active".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
/// The `netsh` arguments that remove an address from an interface.
|
|
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
match cidr.addr {
|
|
IpAddr::V4(addr) => vec![
|
|
"interface".into(),
|
|
"ipv4".into(),
|
|
"delete".into(),
|
|
"address".into(),
|
|
format!("name={name}"),
|
|
format!("address={addr}"),
|
|
"store=active".into(),
|
|
],
|
|
IpAddr::V6(addr) => vec![
|
|
"interface".into(),
|
|
"ipv6".into(),
|
|
"delete".into(),
|
|
"address".into(),
|
|
format!("interface={name}"),
|
|
format!("address={addr}"),
|
|
"store=active".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
/// The `netsh` arguments that set an interface's MTU, one call per family.
|
|
fn mtu_args(name: &str, family: &str, mtu: u32) -> Vec<String> {
|
|
vec![
|
|
"interface".into(),
|
|
family.into(),
|
|
"set".into(),
|
|
"subinterface".into(),
|
|
name.into(),
|
|
format!("mtu={mtu}"),
|
|
"store=active".into(),
|
|
]
|
|
}
|
|
|
|
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
netsh(address_add_args(name, cidr)).await
|
|
}
|
|
|
|
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
netsh(address_del_args(name, cidr)).await
|
|
}
|
|
|
|
/// Sets the MTU on both families.
|
|
///
|
|
/// A family that is turned off on the adapter cannot take an MTU, so this
|
|
/// fails only when *neither* would — one family carrying the plan's MTU is
|
|
/// enough, and refusing the whole interface because the other is disabled
|
|
/// would be wrong.
|
|
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
|
let v4 = netsh(mtu_args(name, "ipv4", mtu)).await;
|
|
let v6 = netsh(mtu_args(name, "ipv6", mtu)).await;
|
|
match (&v4, &v6) {
|
|
(Err(_), Err(_)) => v4,
|
|
_ => {
|
|
if let Err(err) = &v4 {
|
|
tracing::debug!(interface = %name, %err, "could not set the IPv4 MTU");
|
|
}
|
|
if let Err(err) = &v6 {
|
|
tracing::debug!(interface = %name, %err, "could not set the IPv6 MTU");
|
|
}
|
|
Ok(())
|
|
}
|
|
}
|
|
}
|
|
|
|
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
|
netsh(vec![
|
|
"interface".into(),
|
|
"set".into(),
|
|
"interface".into(),
|
|
format!("name={name}"),
|
|
"admin=enabled".into(),
|
|
])
|
|
.await
|
|
}
|
|
|
|
/// Runs `netsh` from `%SystemRoot%\System32`, so nothing on `PATH` can stand
|
|
/// in for it, and turns a non-zero exit into an error carrying what it said.
|
|
async fn netsh(args: Vec<String>) -> Result<(), OverlayError> {
|
|
let program = system32("netsh.exe");
|
|
let display = format!("netsh {}", args.join(" "));
|
|
let output = tokio::task::spawn_blocking(move || {
|
|
std::process::Command::new(program).args(&args).output()
|
|
})
|
|
.await
|
|
.map_err(|err| OverlayError::Other(format!("could not run {display}: {err}")))?
|
|
.map_err(|err| OverlayError::Unavailable(format!("could not run {display}: {err}")))?;
|
|
|
|
if output.status.success() {
|
|
return Ok(());
|
|
}
|
|
let message = {
|
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
|
let text = if stderr.trim().is_empty() {
|
|
String::from_utf8_lossy(&output.stdout).trim().to_string()
|
|
} else {
|
|
stderr.trim().to_string()
|
|
};
|
|
if text.is_empty() {
|
|
format!("exit code {}", output.status)
|
|
} else {
|
|
text
|
|
}
|
|
};
|
|
Err(OverlayError::Unavailable(format!(
|
|
"{display} failed: {message}"
|
|
)))
|
|
}
|
|
|
|
/// The absolute path to a program in `System32`.
|
|
fn system32(exe: &str) -> std::path::PathBuf {
|
|
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
|
|
std::path::Path::new(&root).join("System32").join(exe)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
use std::net::Ipv6Addr;
|
|
|
|
fn v4(last: u8, prefix: u8) -> Cidr {
|
|
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
|
}
|
|
|
|
fn v6(last: u16) -> Cidr {
|
|
Cidr::new(
|
|
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
|
64,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_prefix_length_becomes_the_right_dotted_mask() {
|
|
assert_eq!(ipv4_mask(24), Ipv4Addr::new(255, 255, 255, 0));
|
|
assert_eq!(ipv4_mask(16), Ipv4Addr::new(255, 255, 0, 0));
|
|
assert_eq!(ipv4_mask(32), Ipv4Addr::new(255, 255, 255, 255));
|
|
assert_eq!(ipv4_mask(0), Ipv4Addr::UNSPECIFIED);
|
|
}
|
|
|
|
#[test]
|
|
fn a_v4_address_is_added_by_name_with_a_dotted_mask() {
|
|
let args = address_add_args("tsun0", v4(69, 24));
|
|
assert!(args.contains(&"ipv4".to_string()));
|
|
assert!(args.contains(&"add".to_string()));
|
|
assert!(args.contains(&"name=tsun0".to_string()));
|
|
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
|
assert!(args.contains(&"mask=255.255.255.0".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn a_v6_address_is_added_by_interface_with_a_prefix() {
|
|
let args = address_add_args("tsun0", v6(1));
|
|
assert!(args.contains(&"ipv6".to_string()));
|
|
assert!(args.contains(&"interface=tsun0".to_string()));
|
|
assert!(args.contains(&"address=fd00::1/64".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_an_address_names_no_mask() {
|
|
let args = address_del_args("tsun0", v4(69, 24));
|
|
assert!(args.contains(&"delete".to_string()));
|
|
assert!(args.contains(&"address=10.13.37.69".to_string()));
|
|
assert!(!args.iter().any(|arg| arg.starts_with("mask=")));
|
|
}
|
|
|
|
#[test]
|
|
fn the_mtu_is_set_per_family_on_the_subinterface() {
|
|
let args = mtu_args("tsun0", "ipv4", 1280);
|
|
assert!(args.contains(&"subinterface".to_string()));
|
|
assert!(args.contains(&"tsun0".to_string()));
|
|
assert!(args.contains(&"mtu=1280".to_string()));
|
|
}
|
|
|
|
#[test]
|
|
fn link_local_addresses_are_not_ours_to_manage() {
|
|
assert!(is_link_local(IpAddr::V6(Ipv6Addr::new(
|
|
0xfe80, 0, 0, 0, 0, 0, 0, 1
|
|
))));
|
|
assert!(is_link_local(IpAddr::V4(Ipv4Addr::new(169, 254, 1, 1))));
|
|
assert!(!is_link_local(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 1))));
|
|
assert!(!is_link_local(IpAddr::V6(Ipv6Addr::new(
|
|
0xfd00, 0, 0, 0, 0, 0, 0, 1
|
|
))));
|
|
}
|
|
|
|
#[test]
|
|
fn system32_is_absolute_and_ends_with_the_program() {
|
|
let path = system32("netsh.exe");
|
|
assert!(path.is_absolute(), "{path:?}");
|
|
assert!(path.ends_with("netsh.exe"), "{path:?}");
|
|
}
|
|
}
|