macOS was TUN-less: the agent ran only with `--no-tun`, so tunnels formed but nothing reached the operating system. The provisioner, host rules and DNS publisher had Linux and Windows implementations and fell through to the unsupported/erroring paths on macOS. This adds the macOS half so `tsunagi up` carries real traffic there. The shared decision logic (plan_changes, rules_summary, Published) is untouched — only the per-platform mechanics are new. What it does on macOS (all root-only; macOS has no per-capability grant): - Interface: a `utun` device opened over the in-kernel utun control socket via the existing `tun` dependency (no kext, no Network Extension). The kernel assigns the `utunN` name, so a name derived from the network id is not a legal utun name; `open_tun` leaves the name unset on macOS and reads the real one back, and `Interface::start` adopts `device.name()` as the name everything downstream configures (host rules, reconfigure). A utun is torn down with the process, so a crash never leaks one. Addresses, MTU, up and the overlay subnet route are applied with `/sbin/ifconfig` and `/sbin/route` (absolute paths, args built only from locally-derived values), the same role netlink plays on Linux and netsh on Windows. Observation is `netdev` (getifaddrs). - DNS: a resolver-dir publisher writes `/etc/resolver/<network>` files pointing the system resolver at the overlay DNS server for each zone. The files carry a `port`, so the server needs no privileged socket, and a marker line, so the publisher only ever replaces or removes files it wrote and never clobbers a user's own. Removed on revert. - Broadcast host rules: the `255.255.255.255` route is installed with `route`. The inbound-UDP firewall allowance (a pf anchor) is deferred and reported as the incomplete half, so `status` says what to allow by hand; the route still goes in. Failure stays contained, exactly as on the other platforms. - Privilege: probe is optimistic (like Windows); the honest answer comes from the utun open, whose permission error now says to run with sudo or as a daemon. Deployment: - Default paths on macOS are fixed system locations rather than per-user Library dirs, so they do not depend on whose shell ran sudo: state `/var/db/tsunagi`, cache `/var/db/tsunagi/cache` (StoragePaths::user_default), control socket `/var/run/tsunagi/agent.sock` (default_control_socket_path, mirroring the Windows %ProgramData% branch). The local control socket already uses the shared `unix` adapter, so no IPC changes were needed — macOS is Unix. - dist/macos/ carries a root LaunchDaemon plist and install notes; README's deployment section now documents the macOS path instead of "not implemented". Decisions (agreed up front): utun auto-assign with the real name adopted; ifconfig/route rather than raw PF_ROUTE/ioctl (which would need unsafe); /etc/resolver files rather than scutil; route-now/firewall-deferred; root via sudo or a LaunchDaemon rather than Network Extension (which would require a signed sandboxed app bundle, not a Rust CLI). Checks: on macOS (Darwin 27), `cargo fmt --all --check`, `cargo clippy --locked --workspace --all-targets -D warnings`, and `cargo test --locked --workspace --all-targets` all pass (0 failures); the new macos-gated unit tests run under the standard workspace command via feature unification. Not yet exercised live: actual utun creation, ifconfig/route application and /etc/resolver writes need a root run, which has not been done in this session. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
532 lines
18 KiB
Rust
532 lines
18 KiB
Rust
//! Managing the overlay interface on macOS, through utun and `ifconfig`/`route`.
|
|
//!
|
|
//! The shape is the same as the Linux and Windows provisioners: observe what
|
|
//! is there, [`plan_changes`](super::plan_changes) to decide what to do, apply
|
|
//! the difference. Only the mechanisms differ.
|
|
//!
|
|
//! # Creating the interface
|
|
//!
|
|
//! The interface is a `utun` device, created through the `tun` dependency's
|
|
//! safe wrapper — [`open_tun`](super::super::tun) — over the in-kernel
|
|
//! `com.apple.net.utun_control` control socket. No kernel extension and no
|
|
//! Network Extension entitlement is involved; what it needs is root. The
|
|
//! device is not made persistent, so closing the last file descriptor removes
|
|
//! the interface — it goes away when the agent does, however the agent ends.
|
|
//!
|
|
//! # The kernel names the interface, not us
|
|
//!
|
|
//! Unlike Linux and Windows, a utun interface cannot be given an arbitrary
|
|
//! name: it is always `utunN`, and the unit number is assigned by the kernel.
|
|
//! So the name derived from the network id (`tsun…`) is only ever a *request*;
|
|
//! the real name is read back off the device after it is opened, and
|
|
//! everything downstream — the host rules, the resolver publication — uses the
|
|
//! real one. The agent adopts it as soon as the device is created (see
|
|
//! [`Interface`](crate::overlay::Interface)).
|
|
//!
|
|
//! # Configuring it
|
|
//!
|
|
//! Addresses, MTU and routes are applied with `ifconfig` and `route`. On Linux
|
|
//! the same work is done in process over a netlink socket; here there is no
|
|
//! equivalent this crate may call, because the in-process route is raw `ioctl`
|
|
//! and `PF_ROUTE` and that is `unsafe`. The tools are invoked by absolute path
|
|
//! from `/sbin`, so nothing on `PATH` can stand in for them, and every value
|
|
//! handed to them is one this agent derived — an interface name chosen by the
|
|
//! kernel, an address it allocated — never anything a peer said.
|
|
//!
|
|
//! # Observing it
|
|
//!
|
|
//! The interface table is read through `netdev`, a safe wrapper over
|
|
//! `getifaddrs`. An interface is matched by its real `utunN` name, which only
|
|
//! matters on reconfiguration: the first create always makes a fresh device.
|
|
|
|
use std::net::{IpAddr, Ipv4Addr, Ipv6Addr};
|
|
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 utun and `ifconfig`/`route`.
|
|
#[derive(Debug)]
|
|
pub struct UtunProvisioner {
|
|
/// Interfaces this process created, by their real `utunN` name, so a
|
|
/// reconfiguration adjusts them rather than trying to create them again.
|
|
ours: Mutex<Vec<String>>,
|
|
/// Devices kept alive for as long as the interface should exist. Dropping
|
|
/// one closes the last descriptor, which removes the utin interface.
|
|
held: Mutex<Vec<(String, Arc<dyn TunDevice>)>>,
|
|
}
|
|
|
|
impl UtunProvisioner {
|
|
/// Creates the provisioner, after checking the platform supports one.
|
|
///
|
|
/// Whether the process is actually root is not checked here — reading the
|
|
/// effective uid needs a libc call this crate forbids — so a missing
|
|
/// privilege surfaces at utun creation with a precise message, the same
|
|
/// way the Linux and Windows paths treat 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 == name)
|
|
}
|
|
|
|
/// Opens the utun device, which is what creates it. The kernel assigns the
|
|
/// name, so the returned device reports a `utunN` that is not `plan.name`.
|
|
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 UtunProvisioner {
|
|
fn name(&self) -> &str {
|
|
"utun"
|
|
}
|
|
|
|
fn reconcile<'a>(
|
|
&'a self,
|
|
plan: &'a InterfacePlan,
|
|
) -> BoxFuture<'a, Result<Provisioned, OverlayError>> {
|
|
Box::pin(async move {
|
|
// A reconfiguration: the plan carries the real `utunN` name the
|
|
// agent adopted after the first create, so the interface is there
|
|
// and only the addresses and MTU need bringing into line.
|
|
if self.is_ours(&plan.name) {
|
|
let current = observe(&plan.name).await;
|
|
let changes = plan_changes(¤t, plan, true)?;
|
|
configure(&plan.name, &changes).await?;
|
|
return Ok(Provisioned {
|
|
changes,
|
|
device: None,
|
|
});
|
|
}
|
|
|
|
// The first create. The kernel assigns the unit, so the device is
|
|
// opened first and its real name used for everything after.
|
|
let device = self.create_device(plan)?;
|
|
let real = device.name().to_string();
|
|
|
|
// A fresh interface starts with nothing on it, so the whole plan is
|
|
// applied. `plan_changes` against an absent interface is exactly
|
|
// that decision; its `create_link` flag is already satisfied by the
|
|
// open above, so `configure` ignores it and only applies the rest.
|
|
let changes = plan_changes(&InterfaceState::absent(), plan, false)?;
|
|
|
|
// A failure here leaves an interface that exists but cannot carry
|
|
// traffic. Drop the device so the utun closes, and report, rather
|
|
// than leave a trap behind.
|
|
if let Err(err) = configure(&real, &changes).await {
|
|
drop(device);
|
|
return Err(err);
|
|
}
|
|
|
|
lock(&self.ours).push(real.clone());
|
|
lock(&self.held).push((real, Arc::clone(&device)));
|
|
Ok(Provisioned {
|
|
changes,
|
|
device: Some(device),
|
|
})
|
|
})
|
|
}
|
|
|
|
fn remove<'a>(&'a self, name: &'a str) -> BoxFuture<'a, Result<(), OverlayError>> {
|
|
Box::pin(async move {
|
|
// Dropping the device closes the last descriptor, which removes the
|
|
// utun interface and the routes that were bound to it. There is
|
|
// nothing to flush: a utun never outlives the process that holds it.
|
|
lock(&self.held).retain(|(held, _)| held != name);
|
|
lock(&self.ours).retain(|owned| owned != 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 macOS 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| iface.name == name)
|
|
})
|
|
.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();
|
|
|
|
InterfaceState {
|
|
// Only ever reached for an interface this agent created (see
|
|
// `reconcile`), so it is ours and a TUN by construction.
|
|
kind: LinkKind::Tun,
|
|
attached: iface.is_up(),
|
|
up: iface.is_up(),
|
|
mtu: iface.mtu.unwrap_or(0),
|
|
addresses,
|
|
}
|
|
}
|
|
|
|
/// Applies MTU, link state, addresses and the on-link routes they imply.
|
|
async fn configure(name: &str, changes: &super::Changes) -> Result<(), OverlayError> {
|
|
if let Some(mtu) = changes.set_mtu {
|
|
set_mtu(name, mtu).await?;
|
|
}
|
|
for cidr in &changes.remove {
|
|
// Best effort: an address that is already gone is the outcome wanted.
|
|
if let Err(err) = address_del(name, *cidr).await {
|
|
tracing::debug!(interface = %name, %cidr, %err, "could not remove address");
|
|
}
|
|
if let Err(err) = route_del(name, *cidr).await {
|
|
tracing::debug!(interface = %name, %cidr, %err, "could not remove route");
|
|
}
|
|
}
|
|
for cidr in &changes.add {
|
|
address_add(name, *cidr).await?;
|
|
// The subnet route makes the whole overlay range reach the interface;
|
|
// a point-to-point utun does not install one from the address alone. A
|
|
// host address (/32 or /128) needs none. Best effort: a route that is
|
|
// already there is the outcome wanted.
|
|
if let Err(err) = route_add(name, *cidr).await {
|
|
tracing::debug!(interface = %name, %cidr, %err, "could not add route");
|
|
}
|
|
}
|
|
if changes.bring_up {
|
|
// Best effort: a utun with a descriptor open is up already, and a
|
|
// failure to nudge it is not a reason to fail the whole reconcile.
|
|
if let Err(err) = set_up(name).await {
|
|
tracing::debug!(interface = %name, %err, "could not enable the interface");
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The network address of a CIDR, with the host bits cleared.
|
|
fn network_of(cidr: Cidr) -> IpAddr {
|
|
match cidr.addr {
|
|
IpAddr::V4(addr) => {
|
|
let bits = u32::from(addr);
|
|
let mask = if cidr.prefix_len == 0 {
|
|
0
|
|
} else {
|
|
u32::MAX << (32 - u32::from(cidr.prefix_len))
|
|
};
|
|
IpAddr::V4(Ipv4Addr::from(bits & mask))
|
|
}
|
|
IpAddr::V6(addr) => {
|
|
let bits = u128::from(addr);
|
|
let mask = if cidr.prefix_len == 0 {
|
|
0
|
|
} else {
|
|
u128::MAX << (128 - u32::from(cidr.prefix_len))
|
|
};
|
|
IpAddr::V6(Ipv6Addr::from(bits & mask))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Whether a CIDR is a single host address, which needs no subnet route.
|
|
fn is_host_route(cidr: Cidr) -> bool {
|
|
match cidr.addr {
|
|
IpAddr::V4(_) => cidr.prefix_len >= 32,
|
|
IpAddr::V6(_) => cidr.prefix_len >= 128,
|
|
}
|
|
}
|
|
|
|
/// The `ifconfig` arguments that add an address to a utun interface.
|
|
///
|
|
/// A utun is point-to-point, so the IPv4 form names the address as its own
|
|
/// peer; `alias` lets several coexist on the one interface.
|
|
fn address_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
match cidr.addr {
|
|
IpAddr::V4(addr) => vec![
|
|
name.into(),
|
|
"inet".into(),
|
|
format!("{addr}/{}", cidr.prefix_len),
|
|
addr.to_string(),
|
|
"alias".into(),
|
|
],
|
|
IpAddr::V6(addr) => vec![
|
|
name.into(),
|
|
"inet6".into(),
|
|
format!("{addr}/{}", cidr.prefix_len),
|
|
"alias".into(),
|
|
],
|
|
}
|
|
}
|
|
|
|
/// The `ifconfig` arguments that remove an address from a utun interface.
|
|
fn address_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
let family = match cidr.addr {
|
|
IpAddr::V4(_) => "inet",
|
|
IpAddr::V6(_) => "inet6",
|
|
};
|
|
vec![
|
|
name.into(),
|
|
family.into(),
|
|
cidr.addr.to_string(),
|
|
"-alias".into(),
|
|
]
|
|
}
|
|
|
|
/// The `route` arguments that point a CIDR's subnet at the interface.
|
|
fn route_add_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
let family = match cidr.addr {
|
|
IpAddr::V4(_) => "-inet",
|
|
IpAddr::V6(_) => "-inet6",
|
|
};
|
|
vec![
|
|
"-q".into(),
|
|
"-n".into(),
|
|
"add".into(),
|
|
family.into(),
|
|
"-net".into(),
|
|
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
|
"-interface".into(),
|
|
name.into(),
|
|
]
|
|
}
|
|
|
|
/// The `route` arguments that remove that subnet route.
|
|
fn route_del_args(name: &str, cidr: Cidr) -> Vec<String> {
|
|
let family = match cidr.addr {
|
|
IpAddr::V4(_) => "-inet",
|
|
IpAddr::V6(_) => "-inet6",
|
|
};
|
|
vec![
|
|
"-q".into(),
|
|
"-n".into(),
|
|
"delete".into(),
|
|
family.into(),
|
|
"-net".into(),
|
|
format!("{}/{}", network_of(cidr), cidr.prefix_len),
|
|
"-interface".into(),
|
|
name.into(),
|
|
]
|
|
}
|
|
|
|
async fn address_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
ifconfig(address_add_args(name, cidr)).await
|
|
}
|
|
|
|
async fn address_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
ifconfig(address_del_args(name, cidr)).await
|
|
}
|
|
|
|
async fn route_add(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
if is_host_route(cidr) {
|
|
return Ok(());
|
|
}
|
|
route(route_add_args(name, cidr)).await
|
|
}
|
|
|
|
async fn route_del(name: &str, cidr: Cidr) -> Result<(), OverlayError> {
|
|
if is_host_route(cidr) {
|
|
return Ok(());
|
|
}
|
|
route(route_del_args(name, cidr)).await
|
|
}
|
|
|
|
async fn set_mtu(name: &str, mtu: u32) -> Result<(), OverlayError> {
|
|
ifconfig(vec![name.into(), "mtu".into(), mtu.to_string()]).await
|
|
}
|
|
|
|
async fn set_up(name: &str) -> Result<(), OverlayError> {
|
|
ifconfig(vec![name.into(), "up".into()]).await
|
|
}
|
|
|
|
/// Runs `/sbin/ifconfig`, turning a non-zero exit into an error with its text.
|
|
async fn ifconfig(args: Vec<String>) -> Result<(), OverlayError> {
|
|
run("/sbin/ifconfig", args).await
|
|
}
|
|
|
|
/// Runs `/sbin/route`, turning a non-zero exit into an error with its text.
|
|
async fn route(args: Vec<String>) -> Result<(), OverlayError> {
|
|
run("/sbin/route", args).await
|
|
}
|
|
|
|
/// Runs a system tool by absolute path, so nothing on `PATH` can stand in for
|
|
/// it, and turns a non-zero exit into an error carrying what it said.
|
|
async fn run(program: &'static str, args: Vec<String>) -> Result<(), OverlayError> {
|
|
let display = format!("{program} {}", 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}"
|
|
)))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
|
|
|
use super::*;
|
|
|
|
fn v4(last: u8, prefix: u8) -> Cidr {
|
|
Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, last)), prefix).unwrap()
|
|
}
|
|
|
|
fn v6(last: u16, prefix: u8) -> Cidr {
|
|
Cidr::new(
|
|
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, last)),
|
|
prefix,
|
|
)
|
|
.unwrap()
|
|
}
|
|
|
|
#[test]
|
|
fn a_v4_address_is_added_point_to_point_with_an_alias() {
|
|
let args = address_add_args("utun5", v4(69, 24));
|
|
assert_eq!(
|
|
args,
|
|
vec!["utun5", "inet", "10.13.37.69/24", "10.13.37.69", "alias"]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_v6_address_is_added_with_a_prefix_and_an_alias() {
|
|
let args = address_add_args("utun5", v6(1, 128));
|
|
assert_eq!(args, vec!["utun5", "inet6", "fd00::1/128", "alias"]);
|
|
}
|
|
|
|
#[test]
|
|
fn deleting_an_address_names_the_family_and_removes_the_alias() {
|
|
let args = address_del_args("utun5", v4(69, 24));
|
|
assert_eq!(args, vec!["utun5", "inet", "10.13.37.69", "-alias"]);
|
|
let args = address_del_args("utun5", v6(1, 128));
|
|
assert_eq!(args, vec!["utun5", "inet6", "fd00::1", "-alias"]);
|
|
}
|
|
|
|
#[test]
|
|
fn a_subnet_route_points_the_masked_network_at_the_interface() {
|
|
let args = route_add_args("utun5", v4(69, 24));
|
|
assert_eq!(
|
|
args,
|
|
vec![
|
|
"-q",
|
|
"-n",
|
|
"add",
|
|
"-inet",
|
|
"-net",
|
|
"10.13.37.0/24",
|
|
"-interface",
|
|
"utun5"
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn a_host_address_installs_no_subnet_route() {
|
|
assert!(is_host_route(v4(69, 32)));
|
|
assert!(is_host_route(v6(1, 128)));
|
|
assert!(!is_host_route(v4(69, 24)));
|
|
assert!(!is_host_route(v6(1, 64)));
|
|
}
|
|
|
|
#[test]
|
|
fn the_network_address_clears_the_host_bits() {
|
|
assert_eq!(
|
|
network_of(v4(69, 24)),
|
|
IpAddr::V4(Ipv4Addr::new(10, 13, 37, 0))
|
|
);
|
|
assert_eq!(
|
|
network_of(Cidr::new(IpAddr::V4(Ipv4Addr::new(10, 13, 37, 69)), 16).unwrap()),
|
|
IpAddr::V4(Ipv4Addr::new(10, 13, 0, 0))
|
|
);
|
|
assert_eq!(
|
|
network_of(v6(0x1234, 64)),
|
|
IpAddr::V6(Ipv6Addr::new(0xfd00, 0, 0, 0, 0, 0, 0, 0))
|
|
);
|
|
}
|
|
|
|
#[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
|
|
))));
|
|
}
|
|
}
|