use std::{
ffi::CString,
io,
net::{IpAddr, Ipv4Addr, Ipv6Addr},
os::fd::{AsRawFd, FromRawFd, OwnedFd, RawFd},
process::Command,
};
use anyhow::{Context, bail};
use route_socket::{Gateway, RouteSocket, RouteSpec};
mod discovery;
mod dns;
mod firewall;
mod ipv6_presence;
mod route_socket;
const TUN_V4: &str = "100.64.0.1";
const TUN_V6: &str = "2001:db8:6765::1";
const TUN_V6_PREFIX: &str = "64";
const TUN_MTU: &str = "16384";
const V4_SPLIT: [Ipv4Addr; 2] = [Ipv4Addr::UNSPECIFIED, Ipv4Addr::new(128, 0, 0, 0)];
const V6_SPLIT: [Ipv6Addr; 2] = [
Ipv6Addr::UNSPECIFIED,
Ipv6Addr::new(0x8000, 0, 0, 0, 0, 0, 0, 0),
];
const SPLIT_PREFIXLEN: u8 = 1;
const SENTINEL_DNS_V4: &str = "1.1.1.1";
const SENTINEL_DNS_V6: &str = "2606:4700:4700::1111";
const PF_SYSTEM: libc::c_int = 32;
const SYSPROTO_CONTROL: libc::c_int = 2;
const AF_SYSTEM: u8 = 32;
const AF_SYS_CONTROL: u16 = 2;
const UTUN_OPT_IFNAME: libc::c_int = 2;
const CTLIOCGINFO: libc::c_ulong = 0xC064_4E03;
const UTUN_CONTROL_NAME: &[u8] = b"com.apple.net.utun_control";
#[repr(C)]
struct CtlInfo {
ctl_id: u32,
ctl_name: [libc::c_char; 96],
}
#[repr(C)]
struct SockaddrCtl {
sc_len: u8,
sc_family: u8,
ss_sysaddr: u16,
sc_id: u32,
sc_unit: u32,
sc_reserved: [u32; 5],
}
#[derive(Clone, PartialEq, Eq)]
pub(super) struct PhysIface {
if4: u32,
if6: u32,
v4_gw: (String, Ipv4Addr),
v6_gw: Option<(String, Ipv6Addr)>,
v4_service_id: String,
}
pub(super) struct VpnHandle {
tun: OwnedFd,
ifname: String,
phys: PhysIface,
dns_backup: dns::DnsBackup,
pf_state: Option<firewall::PfState>,
v6_presence_path: Option<String>,
}
impl VpnHandle {
pub(super) fn tun_fd(&self) -> RawFd {
self.tun.as_raw_fd()
}
pub(super) fn bind_indices(&self) -> (u32, u32) {
(self.phys.if4, self.phys.if6)
}
pub(super) fn phys_dns(&self) -> Vec<std::net::IpAddr> {
let mut seen = std::collections::HashSet::new();
let mut out = Vec::new();
for (_, prior) in &self.dns_backup {
for s in prior.iter().flatten() {
if let Ok(ip) = s.parse::<std::net::IpAddr>() {
if seen.insert(ip) {
out.push(ip);
}
}
}
}
out
}
fn cleanup(&mut self) {
if let Some(state) = self.pf_state.take() {
firewall::teardown(state);
}
if let Some(path) = self.v6_presence_path.take() {
ipv6_presence::remove(&path);
}
dns::restore(&self.dns_backup);
del_scoped_default(&self.phys);
delete_split_routes();
let (ifn, gw) = &self.phys.v4_gw;
if let Err(error) = restore_default_route(*gw) {
tracing::warn!(
%error,
gateway = %gw,
interface = %ifn,
"could not restore physical default route after VPN teardown"
);
}
}
}
fn restore_default_route(gw: Ipv4Addr) -> anyhow::Result<()> {
let mut rs = RouteSocket::new().context("opening routing socket")?;
let probe = RouteSpec {
dest: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
ifscope: None,
};
if let Some(info) = rs.get(&probe)? {
match iface_name(info.ifindex) {
Some(name) if !is_tunnel_iface(&name) => return Ok(()),
_ => {
rs.delete(&probe)?;
}
}
}
tracing::warn!(gateway = %gw, "physical default route missing after VPN teardown; restoring it");
rs.add(&RouteSpec {
dest: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V4(gw)),
ifscope: None,
})
.context("re-adding physical default route")
}
pub(super) fn cleanup(mut handle: VpnHandle) {
handle.cleanup();
}
pub(super) fn physical_iface() -> anyhow::Result<PhysIface> {
let v4 = discovery::primary_v4()?;
let if4 = iface_index(&v4.ifname)?;
let (if6, v6_gw) = match discovery::primary_v6() {
Ok(v6) => (
iface_index(&v6.ifname).unwrap_or(if4),
Some((v6.ifname, v6.router)),
),
Err(_) => (if4, None),
};
Ok(PhysIface {
if4,
if6,
v4_gw: (v4.ifname, v4.router),
v6_gw,
v4_service_id: v4.service_id,
})
}
pub(super) fn setup(phys: PhysIface, uid: u32, allow_lan: bool) -> anyhow::Result<VpnHandle> {
let (tun, ifname) = create_utun().context("creating utun device")?;
let mut handle = scopeguard::guard(
VpnHandle {
tun,
ifname,
phys,
dns_backup: Vec::new(),
pf_state: None,
v6_presence_path: None,
},
|mut handle| handle.cleanup(),
);
handle.bring_up(uid, allow_lan)?;
Ok(scopeguard::ScopeGuard::into_inner(handle))
}
impl VpnHandle {
fn bring_up(&mut self, uid: u32, allow_lan: bool) -> anyhow::Result<()> {
let ifname = self.ifname.clone();
run(
"ifconfig",
&[
ifname.as_str(),
"inet",
TUN_V4,
TUN_V4,
"mtu",
TUN_MTU,
"up",
],
)
.context("configuring utun IPv4")?;
let _ = run(
"ifconfig",
&[
ifname.as_str(),
"inet6",
TUN_V6,
"prefixlen",
TUN_V6_PREFIX,
"up",
],
);
self.pf_state =
Some(firewall::apply(&ifname, uid, allow_lan).context("applying PF kill switch")?);
upsert_split_routes(&ifname)?;
add_scoped_default(&self.phys).context("installing interface-scoped default route")?;
self.dns_backup = dns::set_sentinel(SENTINEL_DNS_V4, SENTINEL_DNS_V6);
self.v6_presence_path = ipv6_presence::publish(&self.phys.v4_service_id, &ifname, TUN_V6);
Ok(())
}
}
pub(super) fn reconcile(handle: &mut VpnHandle, uid: u32, allow_lan: bool) -> anyhow::Result<()> {
let physical = match physical_iface() {
Ok(p) => p,
Err(e) => {
tracing::debug!("keeping last-known physical interface; rediscovery failed: {e:#}");
handle.phys.clone()
}
};
let ifname = handle.ifname.clone();
firewall::apply(&ifname, uid, allow_lan).context("reasserting PF kill switch")?;
run(
"ifconfig",
&[
ifname.as_str(),
"inet",
TUN_V4,
TUN_V4,
"mtu",
TUN_MTU,
"up",
],
)
.context("reasserting utun IPv4")?;
let _ = run(
"ifconfig",
&[
ifname.as_str(),
"inet6",
TUN_V6,
"prefixlen",
TUN_V6_PREFIX,
"up",
],
);
upsert_split_routes(&ifname)?;
add_scoped_default(&physical).context("reasserting interface-scoped default route")?;
if handle.phys != physical {
del_scoped_default(&handle.phys);
}
handle.phys = physical;
dns::reassert_sentinel(SENTINEL_DNS_V4, SENTINEL_DNS_V6);
if let Some(old) = handle.v6_presence_path.take()
&& !old.contains(&handle.phys.v4_service_id)
{
ipv6_presence::remove(&old);
}
handle.v6_presence_path = ipv6_presence::publish(&handle.phys.v4_service_id, &ifname, TUN_V6);
Ok(())
}
fn upsert_split_routes(ifname: &str) -> anyhow::Result<()> {
let idx = iface_index(ifname)? as u16;
let mut rs = RouteSocket::new().context("opening routing socket")?;
for net in V4_SPLIT {
rs.add(&RouteSpec {
dest: IpAddr::V4(net),
prefixlen: Some(SPLIT_PREFIXLEN),
gateway: Gateway::Interface(idx),
ifscope: None,
})
.with_context(|| format!("installing split route {net}/1 via {ifname}"))?;
}
for net in V6_SPLIT {
if let Err(error) = rs.add(&RouteSpec {
dest: IpAddr::V6(net),
prefixlen: Some(SPLIT_PREFIXLEN),
gateway: Gateway::Interface(idx),
ifscope: None,
}) {
tracing::warn!(
%net,
ifname,
%error,
"IPv6 split route failed; IPv6 will not be tunneled"
);
}
}
Ok(())
}
fn delete_split_routes() {
let Ok(mut rs) = RouteSocket::new() else {
return;
};
for net in V4_SPLIT {
let _ = rs.delete(&RouteSpec {
dest: IpAddr::V4(net),
prefixlen: Some(SPLIT_PREFIXLEN),
gateway: Gateway::Interface(0),
ifscope: None,
});
}
for net in V6_SPLIT {
let _ = rs.delete(&RouteSpec {
dest: IpAddr::V6(net),
prefixlen: Some(SPLIT_PREFIXLEN),
gateway: Gateway::Interface(0),
ifscope: None,
});
}
}
fn create_utun() -> anyhow::Result<(OwnedFd, String)> {
let fd = unsafe { libc::socket(PF_SYSTEM, libc::SOCK_DGRAM, SYSPROTO_CONTROL) };
if fd < 0 {
return Err(io::Error::last_os_error()).context("socket(PF_SYSTEM)");
}
let owned = unsafe { OwnedFd::from_raw_fd(fd) };
let mut info = CtlInfo {
ctl_id: 0,
ctl_name: [0; 96],
};
for (i, b) in UTUN_CONTROL_NAME.iter().enumerate() {
info.ctl_name[i] = *b as libc::c_char;
}
if unsafe { libc::ioctl(owned.as_raw_fd(), CTLIOCGINFO, &mut info as *mut CtlInfo) } < 0 {
return Err(io::Error::last_os_error()).context("ioctl(CTLIOCGINFO)");
}
let sc = SockaddrCtl {
sc_len: std::mem::size_of::<SockaddrCtl>() as u8,
sc_family: AF_SYSTEM,
ss_sysaddr: AF_SYS_CONTROL,
sc_id: info.ctl_id,
sc_unit: 0,
sc_reserved: [0; 5],
};
let rc = unsafe {
libc::connect(
owned.as_raw_fd(),
&sc as *const SockaddrCtl as *const libc::sockaddr,
std::mem::size_of::<SockaddrCtl>() as libc::socklen_t,
)
};
if rc < 0 {
return Err(io::Error::last_os_error()).context("connect(utun control)");
}
let mut name_buf = [0u8; 64];
let mut name_len = name_buf.len() as libc::socklen_t;
let rc = unsafe {
libc::getsockopt(
owned.as_raw_fd(),
SYSPROTO_CONTROL,
UTUN_OPT_IFNAME,
name_buf.as_mut_ptr() as *mut libc::c_void,
&mut name_len,
)
};
if rc < 0 {
return Err(io::Error::last_os_error()).context("getsockopt(UTUN_OPT_IFNAME)");
}
let end = name_buf
.iter()
.position(|&b| b == 0)
.unwrap_or(name_buf.len());
let ifname = String::from_utf8_lossy(&name_buf[..end]).into_owned();
unsafe {
libc::fcntl(owned.as_raw_fd(), libc::F_SETFD, libc::FD_CLOEXEC);
}
Ok((owned, ifname))
}
pub(self) fn is_tunnel_iface(name: &str) -> bool {
name.starts_with("utun") || name.starts_with("ipsec")
}
fn iface_index(name: &str) -> anyhow::Result<u32> {
let c = CString::new(name)?;
let idx = unsafe { libc::if_nametoindex(c.as_ptr()) };
if idx == 0 {
bail!("if_nametoindex({name}) failed");
}
Ok(idx)
}
fn iface_name(index: u16) -> Option<String> {
let mut buf = [0u8; libc::IFNAMSIZ];
let ret =
unsafe { libc::if_indextoname(u32::from(index), buf.as_mut_ptr() as *mut libc::c_char) };
if ret.is_null() {
return None;
}
let end = buf.iter().position(|&b| b == 0).unwrap_or(buf.len());
Some(String::from_utf8_lossy(&buf[..end]).into_owned())
}
fn kame_scoped(gw: Ipv6Addr, ifindex: u16) -> Ipv6Addr {
if (gw.segments()[0] & 0xffc0) == 0xfe80 {
let mut o = gw.octets();
o[2..4].copy_from_slice(&ifindex.to_be_bytes());
Ipv6Addr::from(o)
} else {
gw
}
}
fn add_scoped_default(phys: &PhysIface) -> anyhow::Result<()> {
let mut rs = RouteSocket::new().context("opening routing socket")?;
let (ifn, gw) = &phys.v4_gw;
let idx = iface_index(ifn)? as u16;
rs.add(&RouteSpec {
dest: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V4(*gw)),
ifscope: Some(idx),
})
.with_context(|| format!("scoped default via {gw} on {ifn}"))?;
if let Some((ifn6, gw6)) = &phys.v6_gw
&& let Ok(idx6) = iface_index(ifn6)
{
let _ = rs.add(&RouteSpec {
dest: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V6(kame_scoped(*gw6, idx6 as u16))),
ifscope: Some(idx6 as u16),
});
}
Ok(())
}
fn del_scoped_default(phys: &PhysIface) {
let Ok(mut rs) = RouteSocket::new() else {
return;
};
if let Ok(idx) = iface_index(&phys.v4_gw.0) {
let _ = rs.delete(&RouteSpec {
dest: IpAddr::V4(Ipv4Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V4(phys.v4_gw.1)),
ifscope: Some(idx as u16),
});
}
if let Some((ifn6, gw6)) = &phys.v6_gw
&& let Ok(idx6) = iface_index(ifn6)
{
let _ = rs.delete(&RouteSpec {
dest: IpAddr::V6(Ipv6Addr::UNSPECIFIED),
prefixlen: Some(0),
gateway: Gateway::Ip(IpAddr::V6(kame_scoped(*gw6, idx6 as u16))),
ifscope: Some(idx6 as u16),
});
}
}
#[derive(Clone)]
pub(super) struct NetworkSnapshot {
phys: PhysIface,
}
pub(super) fn network_snapshot(handle: &VpnHandle) -> NetworkSnapshot {
NetworkSnapshot {
phys: handle.phys.clone(),
}
}
pub(super) fn network_check(snapshot: &NetworkSnapshot) -> super::NetworkAction {
let cur = match physical_iface() {
Ok(c) => c,
Err(_) => return super::NetworkAction::Healthy,
};
if cur.if4 != snapshot.phys.if4 || cur.v4_gw != snapshot.phys.v4_gw {
return super::NetworkAction::Reconcile;
}
if !scoped_default_ok(&snapshot.phys) {
return super::NetworkAction::Reconcile;
}
super::NetworkAction::Healthy
}
fn scoped_default_ok(phys: &PhysIface) -> bool {
let (ifn, gw) = &phys.v4_gw;
let Ok(idx) = iface_index(ifn) else {
return false;
};
let Ok(mut rs) = RouteSocket::new() else {
return false;
};
match rs.get(&RouteSpec {
dest: IpAddr::V4(Ipv4Addr::new(1, 1, 1, 1)),
prefixlen: None,
gateway: Gateway::Ip(IpAddr::V4(Ipv4Addr::UNSPECIFIED)),
ifscope: Some(idx as u16),
}) {
Ok(Some(info)) => info.gateway == Some(IpAddr::V4(*gw)) && u32::from(info.ifindex) == idx,
_ => false,
}
}
const PF_ROUTE: libc::c_int = 17;
pub(super) fn route_change_loop(mut on_change: impl FnMut()) {
let fd = unsafe { libc::socket(PF_ROUTE, libc::SOCK_RAW, 0) };
if fd < 0 {
tracing::warn!("could not open PF_ROUTE socket; relying on periodic VPN checks only");
return;
}
let fd = unsafe { OwnedFd::from_raw_fd(fd) };
let mut buf = [0u8; 4096];
loop {
let n = unsafe {
libc::read(
fd.as_raw_fd(),
buf.as_mut_ptr() as *mut libc::c_void,
buf.len(),
)
};
if n <= 0 {
if n < 0 && std::io::Error::last_os_error().kind() == io::ErrorKind::Interrupted {
continue;
}
return;
}
on_change();
}
}
pub(super) fn cleanup_stale() {
firewall::teardown_stale();
delete_split_routes();
dns::cleanup_stale(SENTINEL_DNS_V4, SENTINEL_DNS_V6);
ipv6_presence::cleanup_stale(TUN_V6);
}
fn run(cmd: &str, args: &[&str]) -> anyhow::Result<()> {
cmd_output(cmd, args).map(|_| ())
}
fn cmd_output(cmd: &str, args: &[&str]) -> anyhow::Result<String> {
let out = Command::new(cmd)
.args(args)
.output()
.with_context(|| format!("spawning {cmd}"))?;
if !out.status.success() {
bail!(
"`{cmd} {}` failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).into_owned())
}
#[cfg(test)]
mod tests {
use super::{is_tunnel_iface, kame_scoped};
#[test]
fn tunnel_ifaces_are_never_physical() {
assert!(is_tunnel_iface("utun0"));
assert!(is_tunnel_iface("utun4"));
assert!(is_tunnel_iface("ipsec0"));
assert!(!is_tunnel_iface("en0"));
assert!(!is_tunnel_iface("en1"));
assert!(!is_tunnel_iface("bridge0"));
}
#[test]
fn kame_scoping_embeds_index_for_link_local_only() {
let ll: std::net::Ipv6Addr = "fe80::1".parse().unwrap();
let scoped = kame_scoped(ll, 17);
assert_eq!(scoped.octets()[2..4], 17u16.to_be_bytes());
let global: std::net::Ipv6Addr = "2606:4700::1".parse().unwrap();
assert_eq!(kame_scoped(global, 17), global);
}
}