Bring the macOS stack up: utun interface, resolver files, system paths
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>
This commit is contained in:
1 parent
533921059a
commit
c3a6201ee2
16 files changed
+1349
-22
No files matched your search
@@ -478,6 +478,7 @@ The original IP packet, including its DF bit, is preserved. See
|
|||||||
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
|
| `setcap cap_net_admin+p` | ordinary user, one capability | recommended: nothing to prepare, nothing left behind. Lost on every rebuild or copy of the binary. |
|
||||||
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
|
| systemd service | `User=`, `AmbientCapabilities=CAP_NET_ADMIN` | the same, for an installed service |
|
||||||
| `sudo tsunagi up` | root | everything works, nothing is isolated |
|
| `sudo tsunagi up` | root | everything works, nothing is isolated |
|
||||||
|
| macOS root LaunchDaemon | root | the native macOS deployment; see below |
|
||||||
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
|
| `--no-tun` | ordinary user, no capabilities | tunnels run and handshake, traffic never reaches the OS |
|
||||||
|
|
||||||
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
|
On Windows, put `wintun.dll` beside the executable and start `tsunagi up`
|
||||||
@@ -489,9 +490,24 @@ of claiming the agent is absent or trying to edit its locked state.
|
|||||||
Creating a TUN without sufficient privileges also explains how to restart
|
Creating a TUN without sufficient privileges also explains how to restart
|
||||||
the agent with the required permissions.
|
the agent with the required permissions.
|
||||||
|
|
||||||
**Not implemented yet.** macOS has no provisioner; `--no-tun` is the way to
|
On macOS the agent creates a `utun` interface, configures it with `ifconfig`
|
||||||
run it. The control plane and tunnels are unaffected. The decision logic
|
and `route`, and points the system resolver at the overlay by writing
|
||||||
that says *what* to change is shared and tested on every platform.
|
`/etc/resolver/<network>` files (which carry a port, so the DNS server needs no
|
||||||
|
privileged socket). All of this needs **root**: macOS has no per-capability
|
||||||
|
grant like Linux, and a `utun` plus `/etc/resolver` are root-only. The kernel
|
||||||
|
assigns the interface its `utunN` name, so the name derived from the network id
|
||||||
|
is only a request and the real one is adopted once the interface is up.
|
||||||
|
|
||||||
|
Run it as root — `sudo tsunagi up` for a quick start, or install it as a root
|
||||||
|
**LaunchDaemon** for the native always-on deployment. As a daemon the agent runs
|
||||||
|
unprivileged commands (`join`, `status`, `dns`, `network`) over its control
|
||||||
|
socket like any other platform; the socket lives at `/var/run/tsunagi/agent.sock`
|
||||||
|
and the stores at `/var/db/tsunagi`, both fixed system paths so they do not
|
||||||
|
depend on whose shell started `sudo`. A ready-to-edit plist and install notes
|
||||||
|
are in [dist/macos/](dist/macos/). The broadcast route is installed; the
|
||||||
|
inbound-UDP firewall allowance (a `pf` anchor) is not yet, so `status` reports
|
||||||
|
that half as incomplete and names what to allow by hand. The decision logic that
|
||||||
|
says *what* to change is shared and tested on every platform.
|
||||||
|
|
||||||
## Checks
|
## Checks
|
||||||
|
|
||||||
|
|||||||
@@ -958,7 +958,11 @@ fn dns_publisher() -> Arc<dyn tsunagi::dns::DnsPublisher> {
|
|||||||
{
|
{
|
||||||
Arc::new(tsunagi::dns::publish::NrptPublisher::new())
|
Arc::new(tsunagi::dns::publish::NrptPublisher::new())
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
Arc::new(tsunagi::dns::publish::ResolverDirPublisher::new())
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
|
Arc::new(tsunagi::dns::publish::UnsupportedPublisher::new())
|
||||||
}
|
}
|
||||||
@@ -4253,11 +4257,24 @@ fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error
|
|||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The utun interface, created and configured by the agent and removed when it
|
||||||
|
/// exits, the same as the Linux and Windows ones. The kernel assigns the utun
|
||||||
|
/// unit, so the interface reports a `utunN` name the agent adopts.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
||||||
|
use tsunagi::overlay::{MacosHostRules, ManagedTunFactory, UtunProvisioner};
|
||||||
|
let provisioner = UtunProvisioner::new()?;
|
||||||
|
Ok(Arc::new(
|
||||||
|
ManagedTunFactory::new(Arc::new(provisioner))
|
||||||
|
.with_host_rules(Arc::new(MacosHostRules::new())),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
/// There is no provisioner for this platform yet.
|
/// There is no provisioner for this platform yet.
|
||||||
///
|
///
|
||||||
/// Refused here rather than at the first packet, and with the one thing that
|
/// Refused here rather than at the first packet, and with the one thing that
|
||||||
/// does work on every platform named.
|
/// does work on every platform named.
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||||
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
fn system_tun_factory() -> Result<Arc<dyn TunFactory>, Box<dyn std::error::Error>> {
|
||||||
Err(format!(
|
Err(format!(
|
||||||
"managing the overlay interface is not implemented on {} yet. \
|
"managing the overlay interface is not implemented on {} yet. \
|
||||||
|
|||||||
@@ -12,9 +12,13 @@ use std::time::Duration;
|
|||||||
|
|
||||||
use crate::dataplane::SharedPlugin;
|
use crate::dataplane::SharedPlugin;
|
||||||
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
use crate::discovery::{MainlineDiscovery, NetworkDiscovery};
|
||||||
use crate::error::{Error, Result};
|
use crate::error::Result;
|
||||||
|
|
||||||
/// Qualifier/organisation/application triple used for platform directories.
|
/// Qualifier/organisation/application triple used for platform directories.
|
||||||
|
///
|
||||||
|
/// Unused on macOS, where the default paths are fixed system locations rather
|
||||||
|
/// than per-user directories derived from this.
|
||||||
|
#[cfg_attr(target_os = "macos", allow(dead_code))]
|
||||||
const APP_NAME: &str = "tsunagi";
|
const APP_NAME: &str = "tsunagi";
|
||||||
|
|
||||||
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
/// Rendezvous slots per network; this bounds a discovery sample, not membership.
|
||||||
@@ -74,17 +78,34 @@ impl StoragePaths {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The per-user platform directories.
|
/// The default platform directories.
|
||||||
///
|
///
|
||||||
/// A future system service can supply its own paths instead.
|
/// Per user on Linux and Windows. On macOS the agent runs as a root
|
||||||
|
/// LaunchDaemon (creating a utun interface needs root, and `$HOME` under
|
||||||
|
/// `sudo` points at the wrong place), so the stores live in the system
|
||||||
|
/// locations instead — `/var/db/tsunagi` for the mandatory state and a
|
||||||
|
/// disposable cache beside it. A caller that wants somewhere else passes
|
||||||
|
/// explicit paths to [`StoragePaths::new`] or [`StoragePaths::under`].
|
||||||
pub fn user_default() -> Result<Self> {
|
pub fn user_default() -> Result<Self> {
|
||||||
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
#[cfg(target_os = "macos")]
|
||||||
Error::Storage("no valid home directory for platform config paths".into())
|
{
|
||||||
})?;
|
Ok(Self {
|
||||||
Ok(Self {
|
state_dir: PathBuf::from("/var/db/tsunagi"),
|
||||||
state_dir: dirs.data_dir().to_path_buf(),
|
cache_dir: PathBuf::from("/var/db/tsunagi/cache"),
|
||||||
cache_dir: dirs.cache_dir().to_path_buf(),
|
})
|
||||||
})
|
}
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
{
|
||||||
|
let dirs = directories::ProjectDirs::from("", "", APP_NAME).ok_or_else(|| {
|
||||||
|
crate::error::Error::Storage(
|
||||||
|
"no valid home directory for platform config paths".into(),
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
Ok(Self {
|
||||||
|
state_dir: dirs.data_dir().to_path_buf(),
|
||||||
|
cache_dir: dirs.cache_dir().to_path_buf(),
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path of the mandatory state database.
|
/// Path of the mandatory state database.
|
||||||
|
|||||||
@@ -0,0 +1,333 @@
|
|||||||
|
//! Telling the macOS resolver to send some questions here, through
|
||||||
|
//! `/etc/resolver`.
|
||||||
|
//!
|
||||||
|
//! macOS reads one file per domain out of `/etc/resolver`: a file named after
|
||||||
|
//! a domain, containing the `nameserver` and `port` to send that domain's
|
||||||
|
//! questions to, makes the system resolver a *scoped* resolver for exactly
|
||||||
|
//! that suffix and nothing else (see `resolver(5)`). That is the same contract
|
||||||
|
//! the systemd-resolved and NRPT publishers arrange on the other platforms:
|
||||||
|
//! route these suffixes to this server, and claim nothing else.
|
||||||
|
//!
|
||||||
|
//! # Why a port, and so no privileged socket
|
||||||
|
//!
|
||||||
|
//! A resolver file carries a `port`, so the server does not have to sit on 53
|
||||||
|
//! and the agent needs nothing like `CAP_NET_BIND_SERVICE`. It can serve on a
|
||||||
|
//! high port and still be reached for its suffixes.
|
||||||
|
//!
|
||||||
|
//! # Tagged, reversible, and never clobbering
|
||||||
|
//!
|
||||||
|
//! Every file this publisher writes begins with a marker line, so it only ever
|
||||||
|
//! replaces or removes files it wrote itself: a pre-existing `/etc/resolver`
|
||||||
|
//! entry a user set up by hand is left untouched. The files are removed when
|
||||||
|
//! the resolver is turned off and on shutdown; a crash leaves them behind, and
|
||||||
|
//! the next apply for the same domains overwrites them.
|
||||||
|
//!
|
||||||
|
//! # Privilege
|
||||||
|
//!
|
||||||
|
//! Writing under `/etc/resolver` needs root. A permission error is reported as
|
||||||
|
//! a refusal — the kind of failure a person has to act on — rather than a
|
||||||
|
//! transient one, so the agent does not retry it at the ordinary pace.
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::net::IpAddr;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
use std::sync::Mutex;
|
||||||
|
|
||||||
|
use crate::BoxFuture;
|
||||||
|
|
||||||
|
use super::{DnsPublisher, PublishError, Published};
|
||||||
|
|
||||||
|
/// The directory macOS reads scoped resolvers from.
|
||||||
|
const RESOLVER_DIR: &str = "/etc/resolver";
|
||||||
|
|
||||||
|
/// The first line of every file this publisher writes, so it recognises its
|
||||||
|
/// own and never touches a file a user created.
|
||||||
|
const MARKER: &str = "# managed by tsunagi — do not edit";
|
||||||
|
|
||||||
|
/// Writes and removes `/etc/resolver` files for the overlay zones.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct ResolverDirPublisher {
|
||||||
|
/// The resolver directory. A field rather than a constant so the tests can
|
||||||
|
/// point it at a temporary directory and never touch the real one.
|
||||||
|
dir: PathBuf,
|
||||||
|
/// The domain files written by the last apply, so a later apply can remove
|
||||||
|
/// the ones no longer wanted and `revert` can remove them all.
|
||||||
|
written: Mutex<BTreeSet<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ResolverDirPublisher {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::new()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResolverDirPublisher {
|
||||||
|
/// A publisher writing to the real `/etc/resolver`.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::in_dir(RESOLVER_DIR)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A publisher writing to a specific directory. For the tests.
|
||||||
|
fn in_dir(dir: impl Into<PathBuf>) -> Self {
|
||||||
|
Self {
|
||||||
|
dir: dir.into(),
|
||||||
|
written: Mutex::new(BTreeSet::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply_inner(&self, published: &Published) -> Result<(), PublishError> {
|
||||||
|
let desired = desired_files(published)?;
|
||||||
|
|
||||||
|
// The directory may not exist yet; creating it needs root.
|
||||||
|
std::fs::create_dir_all(&self.dir).map_err(|err| classify(&self.dir, err))?;
|
||||||
|
|
||||||
|
// Remove the files from a previous apply that are no longer wanted.
|
||||||
|
let previous = lock(&self.written).clone();
|
||||||
|
for name in previous.difference(&keys(&desired)) {
|
||||||
|
remove_if_ours(&self.dir.join(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (name, body) in &desired {
|
||||||
|
let path = self.dir.join(name);
|
||||||
|
// Only ever overwrite a file that is already ours, or a name that
|
||||||
|
// is free. A resolver a user set up by hand is left in place and
|
||||||
|
// reported, never clobbered.
|
||||||
|
if path.exists() && !is_ours(&path) {
|
||||||
|
return Err(PublishError::Failed(format!(
|
||||||
|
"{} already exists and was not created by tsunagi; \
|
||||||
|
remove it or rename the network to publish its resolver",
|
||||||
|
path.display()
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
std::fs::write(&path, body).map_err(|err| classify(&path, err))?;
|
||||||
|
}
|
||||||
|
|
||||||
|
*lock(&self.written) = keys(&desired);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revert_inner(&self) {
|
||||||
|
let written = std::mem::take(&mut *lock(&self.written));
|
||||||
|
for name in &written {
|
||||||
|
remove_if_ours(&self.dir.join(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DnsPublisher for ResolverDirPublisher {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"resolver-dir"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply<'a>(&'a self, published: &'a Published) -> BoxFuture<'a, Result<(), PublishError>> {
|
||||||
|
// A handful of small file writes, short enough to run in place rather
|
||||||
|
// than hop to the blocking pool.
|
||||||
|
Box::pin(async move { self.apply_inner(published) })
|
||||||
|
}
|
||||||
|
|
||||||
|
fn revert(&self) -> BoxFuture<'_, Result<(), PublishError>> {
|
||||||
|
Box::pin(async move {
|
||||||
|
self.revert_inner();
|
||||||
|
Ok(())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The resolver files a publication implies: a map of file name to contents.
|
||||||
|
fn desired_files(published: &Published) -> Result<Vec<(String, String)>, PublishError> {
|
||||||
|
if published.servers.is_empty() {
|
||||||
|
return Err(PublishError::Failed(
|
||||||
|
"no server address to point the resolver at".to_string(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let body = file_contents(published);
|
||||||
|
let mut files = Vec::new();
|
||||||
|
for domain in &published.domains {
|
||||||
|
let name = resolver_file_name(domain)?;
|
||||||
|
files.push((name, body.clone()));
|
||||||
|
}
|
||||||
|
Ok(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The contents of one resolver file: the marker, every server, one port.
|
||||||
|
fn file_contents(published: &Published) -> String {
|
||||||
|
let mut out = String::from(MARKER);
|
||||||
|
out.push('\n');
|
||||||
|
for server in &published.servers {
|
||||||
|
let ip = match server.ip() {
|
||||||
|
IpAddr::V4(v4) => v4.to_string(),
|
||||||
|
IpAddr::V6(v6) => v6.to_string(),
|
||||||
|
};
|
||||||
|
out.push_str("nameserver ");
|
||||||
|
out.push_str(&ip);
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
// One port for the file; every server listens on the same one. The `port`
|
||||||
|
// directive is what lets the server stay off 53.
|
||||||
|
if let Some(first) = published.servers.first() {
|
||||||
|
out.push_str("port ");
|
||||||
|
out.push_str(&first.port().to_string());
|
||||||
|
out.push('\n');
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The file name for a domain, rejecting anything that is not a plain label
|
||||||
|
/// path component: a resolver file name must never escape the directory.
|
||||||
|
fn resolver_file_name(domain: &str) -> Result<String, PublishError> {
|
||||||
|
let trimmed = domain.trim_end_matches('.');
|
||||||
|
if trimmed.is_empty()
|
||||||
|
|| trimmed.contains('/')
|
||||||
|
|| trimmed.contains('\\')
|
||||||
|
|| trimmed.contains(std::path::MAIN_SEPARATOR)
|
||||||
|
|| trimmed == "."
|
||||||
|
|| trimmed == ".."
|
||||||
|
|| trimmed.starts_with('.')
|
||||||
|
{
|
||||||
|
return Err(PublishError::Failed(format!(
|
||||||
|
"cannot make a resolver file name for the domain {domain:?}"
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
Ok(trimmed.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether a file is one this publisher wrote, by its marker first line.
|
||||||
|
fn is_ours(path: &Path) -> bool {
|
||||||
|
match std::fs::read_to_string(path) {
|
||||||
|
Ok(text) => text.lines().next() == Some(MARKER),
|
||||||
|
Err(_) => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes a file, but only if it is one of ours. Best effort.
|
||||||
|
fn remove_if_ours(path: &Path) {
|
||||||
|
if is_ours(path)
|
||||||
|
&& let Err(err) = std::fs::remove_file(path)
|
||||||
|
&& err.kind() != std::io::ErrorKind::NotFound
|
||||||
|
{
|
||||||
|
tracing::debug!(path = %path.display(), %err, "cannot remove the resolver file");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The set of file names in a desired-files list.
|
||||||
|
fn keys(files: &[(String, String)]) -> BTreeSet<String> {
|
||||||
|
files.iter().map(|(name, _)| name.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Turns a filesystem error into the right kind of publish error: a permission
|
||||||
|
/// problem is a refusal a person must act on, anything else is a plain failure.
|
||||||
|
fn classify(path: &Path, err: std::io::Error) -> PublishError {
|
||||||
|
if err.kind() == std::io::ErrorKind::PermissionDenied {
|
||||||
|
PublishError::Refused(format!(
|
||||||
|
"writing {} needs root; start the agent with `sudo` or as a root LaunchDaemon",
|
||||||
|
path.display()
|
||||||
|
))
|
||||||
|
} else {
|
||||||
|
PublishError::Failed(format!("cannot write {}: {err}", path.display()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock<T>(mutex: &Mutex<T>) -> std::sync::MutexGuard<'_, T> {
|
||||||
|
match mutex.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(poisoned) => poisoned.into_inner(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use std::net::SocketAddr;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn published(domains: &[&str]) -> Published {
|
||||||
|
Published {
|
||||||
|
interface: "utun5".into(),
|
||||||
|
servers: vec![
|
||||||
|
"10.13.37.1:5353".parse::<SocketAddr>().unwrap(),
|
||||||
|
"[fd00::1]:5353".parse::<SocketAddr>().unwrap(),
|
||||||
|
],
|
||||||
|
domains: domains.iter().map(|d| d.to_string()).collect(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_file_lists_every_server_and_one_port_under_the_marker() {
|
||||||
|
let body = file_contents(&published(&["mynet."]));
|
||||||
|
let mut lines = body.lines();
|
||||||
|
assert_eq!(lines.next(), Some(MARKER));
|
||||||
|
assert_eq!(lines.next(), Some("nameserver 10.13.37.1"));
|
||||||
|
assert_eq!(lines.next(), Some("nameserver fd00::1"));
|
||||||
|
assert_eq!(lines.next(), Some("port 5353"));
|
||||||
|
assert_eq!(lines.next(), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_domain_becomes_a_plain_file_name_without_its_trailing_dot() {
|
||||||
|
assert_eq!(resolver_file_name("mynet.").unwrap(), "mynet");
|
||||||
|
assert_eq!(resolver_file_name("my.net").unwrap(), "my.net");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_domain_that_would_escape_the_directory_is_refused() {
|
||||||
|
assert!(resolver_file_name("../etc/passwd").is_err());
|
||||||
|
assert!(resolver_file_name("a/b").is_err());
|
||||||
|
assert!(resolver_file_name("").is_err());
|
||||||
|
assert!(resolver_file_name(".").is_err());
|
||||||
|
assert!(resolver_file_name(".hidden").is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn apply_writes_a_file_per_domain_and_revert_removes_them() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||||
|
|
||||||
|
publisher
|
||||||
|
.apply(&published(&["mynet.", "other."]))
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert!(dir.path().join("mynet").is_file());
|
||||||
|
assert!(dir.path().join("other").is_file());
|
||||||
|
|
||||||
|
publisher.revert().await.unwrap();
|
||||||
|
assert!(!dir.path().join("mynet").exists());
|
||||||
|
assert!(!dir.path().join("other").exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn re_applying_removes_a_domain_that_is_no_longer_wanted() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||||
|
|
||||||
|
publisher.apply(&published(&["a.", "b."])).await.unwrap();
|
||||||
|
publisher.apply(&published(&["a."])).await.unwrap();
|
||||||
|
|
||||||
|
assert!(dir.path().join("a").is_file());
|
||||||
|
assert!(
|
||||||
|
!dir.path().join("b").exists(),
|
||||||
|
"the dropped domain's file is removed"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn a_file_a_user_created_is_never_clobbered_or_removed() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let foreign = dir.path().join("mynet");
|
||||||
|
std::fs::write(&foreign, "nameserver 9.9.9.9\n").unwrap();
|
||||||
|
|
||||||
|
let publisher = ResolverDirPublisher::in_dir(dir.path());
|
||||||
|
let err = publisher.apply(&published(&["mynet."])).await.unwrap_err();
|
||||||
|
assert!(matches!(err, PublishError::Failed(_)), "{err:?}");
|
||||||
|
// Untouched.
|
||||||
|
assert_eq!(
|
||||||
|
std::fs::read_to_string(&foreign).unwrap(),
|
||||||
|
"nameserver 9.9.9.9\n"
|
||||||
|
);
|
||||||
|
|
||||||
|
// And revert leaves a foreign file alone even if it is in our set.
|
||||||
|
publisher.revert().await.unwrap();
|
||||||
|
assert!(foreign.is_file());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,11 @@ mod windows;
|
|||||||
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
#[cfg(all(feature = "dns-publish", target_os = "windows"))]
|
||||||
pub use windows::NrptPublisher;
|
pub use windows::NrptPublisher;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(all(feature = "dns-publish", target_os = "macos"))]
|
||||||
|
pub use macos::ResolverDirPublisher;
|
||||||
|
|
||||||
mod unsupported;
|
mod unsupported;
|
||||||
pub use unsupported::UnsupportedPublisher;
|
pub use unsupported::UnsupportedPublisher;
|
||||||
|
|
||||||
|
|||||||
@@ -87,7 +87,10 @@ pub fn control_socket_path(state_dir: &Path) -> PathBuf {
|
|||||||
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
|
/// On Windows, the system-wide `%ProgramData%\tsunagi\agent.sock` is used so that
|
||||||
/// any local user, tray application, or CLI tool can manage the running agent
|
/// any local user, tray application, or CLI tool can manage the running agent
|
||||||
/// without needing to know which user account or service started it.
|
/// without needing to know which user account or service started it.
|
||||||
/// On Unix, this derives from the default per-user state directory.
|
/// On macOS the agent runs as a root LaunchDaemon, so a fixed system path under
|
||||||
|
/// `/var/run` is used for the same reason, and it stays short enough for the
|
||||||
|
/// Unix socket path limit. On other Unix it derives from the default per-user
|
||||||
|
/// state directory.
|
||||||
pub fn default_control_socket_path() -> PathBuf {
|
pub fn default_control_socket_path() -> PathBuf {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
@@ -99,7 +102,11 @@ pub fn default_control_socket_path() -> PathBuf {
|
|||||||
}
|
}
|
||||||
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
|
PathBuf::from(r"C:\ProgramData\tsunagi\agent.sock")
|
||||||
}
|
}
|
||||||
#[cfg(not(windows))]
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
PathBuf::from("/var/run/tsunagi/agent.sock")
|
||||||
|
}
|
||||||
|
#[cfg(all(unix, not(target_os = "macos")))]
|
||||||
{
|
{
|
||||||
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
if let Ok(paths) = crate::config::StoragePaths::user_default() {
|
||||||
control_socket_path(&paths.state_dir)
|
control_socket_path(&paths.state_dir)
|
||||||
|
|||||||
@@ -0,0 +1,189 @@
|
|||||||
|
//! Broadcast host rules on macOS: a `route` entry; the firewall is deferred.
|
||||||
|
//!
|
||||||
|
//! The limited-broadcast route is installed with `/sbin/route`, so a game's
|
||||||
|
//! LAN discovery packet leaves through the overlay interface. It belongs to
|
||||||
|
//! the interface and is removed when broadcast is turned off, the network
|
||||||
|
//! leaves, or the agent stops — and, because a utun is torn down with the
|
||||||
|
//! process, it also goes away on its own if the agent is killed.
|
||||||
|
//!
|
||||||
|
//! The inbound-UDP firewall allowance is **not** implemented here. On macOS
|
||||||
|
//! that means a `pf` anchor, which is a good deal more machinery; until it is
|
||||||
|
//! written the firewall half reports itself as missing with what to allow by
|
||||||
|
//! hand, and the route is still installed. Failure is contained: neither half
|
||||||
|
//! can stop the agent, and `status` shows exactly which is in place.
|
||||||
|
//!
|
||||||
|
//! `route` is a program, not a system call, so it is run by absolute path from
|
||||||
|
//! `/sbin` rather than off `PATH`, and its arguments are built from values
|
||||||
|
//! this agent computed — the interface name the kernel assigned, nothing a
|
||||||
|
//! peer said.
|
||||||
|
|
||||||
|
use crate::BoxFuture;
|
||||||
|
|
||||||
|
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome};
|
||||||
|
|
||||||
|
/// The limited-broadcast destination routed through the overlay interface.
|
||||||
|
const LIMITED_BROADCAST: &str = "255.255.255.255";
|
||||||
|
|
||||||
|
/// The `route` arguments that direct the limited broadcast out an interface.
|
||||||
|
fn route_add_args(interface: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-q".into(),
|
||||||
|
"-n".into(),
|
||||||
|
"add".into(),
|
||||||
|
"-host".into(),
|
||||||
|
LIMITED_BROADCAST.into(),
|
||||||
|
"-interface".into(),
|
||||||
|
interface.into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `route` arguments that remove it again.
|
||||||
|
fn route_del_args(interface: &str) -> Vec<String> {
|
||||||
|
vec![
|
||||||
|
"-q".into(),
|
||||||
|
"-n".into(),
|
||||||
|
"delete".into(),
|
||||||
|
"-host".into(),
|
||||||
|
LIMITED_BROADCAST.into(),
|
||||||
|
"-interface".into(),
|
||||||
|
interface.into(),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What to do by hand while the `pf` allowance is unimplemented.
|
||||||
|
fn firewall_todo(plan: &BroadcastRulesPlan) -> String {
|
||||||
|
format!(
|
||||||
|
"the inbound-UDP firewall allowance is not implemented on macOS yet; if the host \
|
||||||
|
firewall drops discovery replies, allow inbound UDP from {} on `{}` with a pf rule \
|
||||||
|
yourself",
|
||||||
|
plan.range, plan.interface
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The macOS implementation: `route` for the route, no firewall yet.
|
||||||
|
#[derive(Debug, Default)]
|
||||||
|
pub struct MacosHostRules;
|
||||||
|
|
||||||
|
impl MacosHostRules {
|
||||||
|
/// Creates the host rules.
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Installs the route, replacing one this agent added earlier so the call
|
||||||
|
/// can be repeated. The delete is best effort: a route that is not there
|
||||||
|
/// is the state the add then reaches anyway.
|
||||||
|
async fn set_route(&self, interface: String) -> RuleOutcome {
|
||||||
|
let _ = run(route_del_args(&interface)).await;
|
||||||
|
match run(route_add_args(&interface)).await {
|
||||||
|
Ok(()) => RuleOutcome::Applied,
|
||||||
|
Err(reason) => RuleOutcome::Failed(reason),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl BroadcastHostRules for MacosHostRules {
|
||||||
|
fn name(&self) -> &str {
|
||||||
|
"route"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
|
||||||
|
Box::pin(async move {
|
||||||
|
let route = self.set_route(plan.interface.clone()).await;
|
||||||
|
BroadcastRulesReport {
|
||||||
|
plan: plan.clone(),
|
||||||
|
route,
|
||||||
|
firewall: RuleOutcome::Failed(firewall_todo(plan)),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
|
||||||
|
Box::pin(async move {
|
||||||
|
if let Err(reason) = run(route_del_args(interface)).await {
|
||||||
|
tracing::debug!(interface = %interface, %reason, "cannot remove the broadcast route");
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs `/sbin/route`, turning a non-zero exit into the message it printed.
|
||||||
|
async fn run(args: Vec<String>) -> Result<(), String> {
|
||||||
|
let display = format!("route {}", args.join(" "));
|
||||||
|
let output = tokio::task::spawn_blocking(move || {
|
||||||
|
std::process::Command::new("/sbin/route")
|
||||||
|
.args(&args)
|
||||||
|
.output()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.map_err(|err| format!("could not run {display}: {err}"))?
|
||||||
|
.map_err(|err| format!("could not run {display}: {err}"))?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let text = stderr.trim();
|
||||||
|
if text.is_empty() {
|
||||||
|
Err(format!("{display} failed with {}", output.status))
|
||||||
|
} else {
|
||||||
|
Err(format!("{display} failed: {text}"))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
|
||||||
|
|
||||||
|
use std::net::Ipv4Addr;
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn plan() -> BroadcastRulesPlan {
|
||||||
|
BroadcastRulesPlan {
|
||||||
|
interface: "utun5".into(),
|
||||||
|
source: Ipv4Addr::new(10, 13, 37, 142),
|
||||||
|
range: "10.13.37.0/24".parse().unwrap(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_route_is_the_limited_broadcast_out_the_interface() {
|
||||||
|
assert_eq!(
|
||||||
|
route_add_args("utun5"),
|
||||||
|
vec![
|
||||||
|
"-q",
|
||||||
|
"-n",
|
||||||
|
"add",
|
||||||
|
"-host",
|
||||||
|
"255.255.255.255",
|
||||||
|
"-interface",
|
||||||
|
"utun5"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
route_del_args("utun5"),
|
||||||
|
vec![
|
||||||
|
"-q",
|
||||||
|
"-n",
|
||||||
|
"delete",
|
||||||
|
"-host",
|
||||||
|
"255.255.255.255",
|
||||||
|
"-interface",
|
||||||
|
"utun5"
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_firewall_todo_names_the_range_and_interface() {
|
||||||
|
let todo = firewall_todo(&plan());
|
||||||
|
assert!(todo.contains("10.13.37.0/24"), "{todo}");
|
||||||
|
assert!(todo.contains("utun5"), "{todo}");
|
||||||
|
assert!(todo.contains("pf"), "{todo}");
|
||||||
|
// Deferred, so the firewall half is always reported as not in place;
|
||||||
|
// the route runs for real and so is left to an on-host check. Running
|
||||||
|
// `route` here would need root and would change the host routing
|
||||||
|
// table, which the default test suite must never do.
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -56,6 +56,11 @@ mod windows;
|
|||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use windows::WindowsHostRules;
|
pub use windows::WindowsHostRules;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use macos::MacosHostRules;
|
||||||
|
|
||||||
/// What the host should be told for one interface.
|
/// What the host should be told for one interface.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub struct BroadcastRulesPlan {
|
pub struct BroadcastRulesPlan {
|
||||||
|
|||||||
@@ -172,6 +172,13 @@ impl Interface {
|
|||||||
let name = name.into();
|
let name = name.into();
|
||||||
let host_rules = factory.host_rules();
|
let host_rules = factory.host_rules();
|
||||||
let device = factory.create(TunRequest::bare(name.clone(), mtu)).await?;
|
let device = factory.create(TunRequest::bare(name.clone(), mtu)).await?;
|
||||||
|
// The name the operating system actually gave the interface. On Linux
|
||||||
|
// and Windows that is the one we asked for; on macOS the kernel assigns
|
||||||
|
// the utun unit, so the real name differs and everything that
|
||||||
|
// configures the host for this interface afterwards — the broadcast
|
||||||
|
// route and firewall, the reconfigure on an address change — has to use
|
||||||
|
// the real one, not the requested `tsun…`.
|
||||||
|
let name = device.name().to_string();
|
||||||
let tally = Arc::new(Tally::default());
|
let tally = Arc::new(Tally::default());
|
||||||
|
|
||||||
let task = {
|
let task = {
|
||||||
|
|||||||
@@ -64,10 +64,15 @@ pub use tun::{MemoryTun, MemoryTunFactory, TunDevice, TunFactory, TunRequest, ad
|
|||||||
|
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
pub use hostrules::LinuxHostRules;
|
pub use hostrules::LinuxHostRules;
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use hostrules::MacosHostRules;
|
||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use hostrules::WindowsHostRules;
|
pub use hostrules::WindowsHostRules;
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
pub use provision::NetlinkProvisioner;
|
pub use provision::NetlinkProvisioner;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use provision::UtunProvisioner;
|
||||||
|
|
||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use provision::WintunProvisioner;
|
pub use provision::WintunProvisioner;
|
||||||
@@ -0,0 +1,531 @@
|
|||||||
|
//! 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
|
||||||
|
))));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,11 +20,13 @@
|
|||||||
//! pure and tested on every platform, and only the execution is behind
|
//! pure and tested on every platform, and only the execution is behind
|
||||||
//! [`InterfaceProvisioner`].
|
//! [`InterfaceProvisioner`].
|
||||||
//!
|
//!
|
||||||
//! Four implementations:
|
//! Five implementations:
|
||||||
//!
|
//!
|
||||||
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
//! * `NetlinkProvisioner` on Linux, which needs `CAP_NET_ADMIN`.
|
||||||
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
//! * `WintunProvisioner` on Windows, a Wintun adapter configured with `netsh`,
|
||||||
//! which needs an elevated process.
|
//! which needs an elevated process.
|
||||||
|
//! * `UtunProvisioner` on macOS, a utun interface configured with `ifconfig`
|
||||||
|
//! and `route`, which needs root.
|
||||||
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
//! * [`MockProvisioner`], an in-memory host used by the tests.
|
||||||
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
//! * [`UnsupportedProvisioner`] elsewhere, which fails with an explanation
|
||||||
//! and a pointer at the manual route rather than pretending to work.
|
//! and a pointer at the manual route rather than pretending to work.
|
||||||
@@ -60,6 +62,11 @@ mod windows;
|
|||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use windows::WintunProvisioner;
|
pub use windows::WintunProvisioner;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
mod macos;
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use macos::UtunProvisioner;
|
||||||
|
|
||||||
mod privilege;
|
mod privilege;
|
||||||
pub use privilege::{Privilege, probe_net_admin};
|
pub use privilege::{Privilege, probe_net_admin};
|
||||||
|
|
||||||
|
|||||||
@@ -63,15 +63,20 @@ pub use linux_impl::{NetAdmin, probe_net_admin};
|
|||||||
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
#[cfg(all(feature = "tun-device", target_os = "windows"))]
|
||||||
pub use windows_impl::probe_net_admin;
|
pub use windows_impl::probe_net_admin;
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
pub use macos_impl::probe_net_admin;
|
||||||
|
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
all(feature = "tun-device", target_os = "linux"),
|
all(feature = "tun-device", target_os = "linux"),
|
||||||
all(feature = "tun-device", target_os = "windows")
|
all(feature = "tun-device", target_os = "windows"),
|
||||||
|
all(feature = "tun-device", target_os = "macos")
|
||||||
)))]
|
)))]
|
||||||
pub use other_impl::probe_net_admin;
|
pub use other_impl::probe_net_admin;
|
||||||
|
|
||||||
#[cfg(not(any(
|
#[cfg(not(any(
|
||||||
all(feature = "tun-device", target_os = "linux"),
|
all(feature = "tun-device", target_os = "linux"),
|
||||||
all(feature = "tun-device", target_os = "windows")
|
all(feature = "tun-device", target_os = "windows"),
|
||||||
|
all(feature = "tun-device", target_os = "macos")
|
||||||
)))]
|
)))]
|
||||||
mod other_impl {
|
mod other_impl {
|
||||||
use super::Privilege;
|
use super::Privilege;
|
||||||
@@ -101,6 +106,25 @@ mod windows_impl {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(all(feature = "tun-device", target_os = "macos"))]
|
||||||
|
mod macos_impl {
|
||||||
|
use super::Privilege;
|
||||||
|
|
||||||
|
/// Whether this process can configure interfaces.
|
||||||
|
///
|
||||||
|
/// macOS has no capability to hold and lower the way Linux does: creating
|
||||||
|
/// a utun interface simply needs the process to be root. Reading the
|
||||||
|
/// effective uid means a libc call this crate forbids, so the probe is
|
||||||
|
/// deliberately optimistic — it reports that the platform can manage
|
||||||
|
/// interfaces — and the real check is left to the utun open, which fails
|
||||||
|
/// with a precise "run with sudo" message when the process is not root.
|
||||||
|
/// This matches how the Linux path treats the open itself as the honest
|
||||||
|
/// answer.
|
||||||
|
pub fn probe_net_admin() -> Privilege {
|
||||||
|
Privilege::Available
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
#[cfg(all(feature = "tun-device", target_os = "linux"))]
|
||||||
mod linux_impl {
|
mod linux_impl {
|
||||||
use caps::{CapSet, Capability};
|
use caps::{CapSet, Capability};
|
||||||
|
|||||||
@@ -417,7 +417,15 @@ mod system {
|
|||||||
/// across this call, and such a guard must not span an `await` because
|
/// across this call, and such a guard must not span an `await` because
|
||||||
/// Linux capabilities are per thread.
|
/// Linux capabilities are per thread.
|
||||||
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
pub(crate) fn open_tun(request: &TunRequest) -> Result<Arc<dyn TunDevice>, OverlayError> {
|
||||||
|
// macOS mutates neither field below (it neither names the interface nor
|
||||||
|
// sets a platform config), so `mut` is unused only there.
|
||||||
|
#[cfg_attr(target_os = "macos", allow(unused_mut))]
|
||||||
let mut config = tun::Configuration::default();
|
let mut config = tun::Configuration::default();
|
||||||
|
// macOS names its tunnels `utunN` and assigns the unit number itself.
|
||||||
|
// A name derived from the network id (`tsun…`) is not a legal utun
|
||||||
|
// name, so there the kernel is left to choose and the real name is read
|
||||||
|
// back below. Linux and Windows take the requested name verbatim.
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
config.tun_name(&request.name);
|
config.tun_name(&request.name);
|
||||||
#[cfg(target_os = "linux")]
|
#[cfg(target_os = "linux")]
|
||||||
config.platform_config(|platform| {
|
config.platform_config(|platform| {
|
||||||
@@ -435,9 +443,20 @@ mod system {
|
|||||||
|
|
||||||
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
let device = tun::create_as_async(&config).map_err(|err| open_error(&request.name, err))?;
|
||||||
|
|
||||||
|
// The interface name the operating system actually gave us: the
|
||||||
|
// requested one everywhere except macOS, where the kernel chose the
|
||||||
|
// utun unit and so the real name has to be read back off the device.
|
||||||
|
#[cfg(target_os = "macos")]
|
||||||
|
let name = {
|
||||||
|
use tun::AbstractDevice;
|
||||||
|
device.tun_name().unwrap_or_else(|_| request.name.clone())
|
||||||
|
};
|
||||||
|
#[cfg(not(target_os = "macos"))]
|
||||||
|
let name = request.name.clone();
|
||||||
|
|
||||||
let (reader, writer) = tokio::io::split(device);
|
let (reader, writer) = tokio::io::split(device);
|
||||||
Ok(Arc::new(SystemTun {
|
Ok(Arc::new(SystemTun {
|
||||||
name: request.name.clone(),
|
name,
|
||||||
mtu: request.mtu,
|
mtu: request.mtu,
|
||||||
reader: Mutex::new(reader),
|
reader: Mutex::new(reader),
|
||||||
writer: Mutex::new(writer),
|
writer: Mutex::new(writer),
|
||||||
@@ -468,6 +487,9 @@ mod system {
|
|||||||
} else if cfg!(target_os = "linux") {
|
} else if cfg!(target_os = "linux") {
|
||||||
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
"Start `tsunagi up` as root or grant CAP_NET_ADMIN with \
|
||||||
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
|
`sudo setcap cap_net_admin,cap_net_bind_service+p /path/to/tsunagi`."
|
||||||
|
} else if cfg!(target_os = "macos") {
|
||||||
|
"Creating a utun interface needs root on macOS; start `tsunagi up` \
|
||||||
|
with `sudo`, or install it as a root LaunchDaemon."
|
||||||
} else {
|
} else {
|
||||||
"Start `tsunagi up` with the privileges required to create a TUN interface."
|
"Start `tsunagi up` with the privileges required to create a TUN interface."
|
||||||
};
|
};
|
||||||
@@ -496,7 +518,12 @@ mod system {
|
|||||||
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
run as Administrator with the DLL beside the executable, or run with `--no-tun` \
|
||||||
to keep the tunnels off the operating system."
|
to keep the tunnels off the operating system."
|
||||||
}
|
}
|
||||||
#[cfg(not(any(target_os = "linux", target_os = "windows")))]
|
#[cfg(target_os = "macos")]
|
||||||
|
{
|
||||||
|
"Creating a utun interface needs root; start the agent with `sudo` or as a root \
|
||||||
|
LaunchDaemon, or run with `--no-tun` to keep the tunnels off the operating system."
|
||||||
|
}
|
||||||
|
#[cfg(not(any(target_os = "linux", target_os = "windows", target_os = "macos")))]
|
||||||
{
|
{
|
||||||
"Run with `--no-tun` to keep the tunnels off the operating system."
|
"Run with `--no-tun` to keep the tunnels off the operating system."
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+82
@@ -0,0 +1,82 @@
|
|||||||
|
# Running tsunagi on macOS
|
||||||
|
|
||||||
|
The agent needs **root** on macOS: creating a `utun` interface and writing
|
||||||
|
`/etc/resolver` are both root-only, and macOS has no per-capability grant like
|
||||||
|
Linux. There are two ways to run it.
|
||||||
|
|
||||||
|
## Quick start — `sudo`
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo tsunagi up
|
||||||
|
```
|
||||||
|
|
||||||
|
In another terminal, the control commands talk to it over the system control
|
||||||
|
socket and do **not** need `sudo`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
tsunagi status
|
||||||
|
tsunagi join <network-name> <tsn1…secret>
|
||||||
|
```
|
||||||
|
|
||||||
|
The agent uses fixed system paths, so the commands find it regardless of whose
|
||||||
|
shell started `sudo`:
|
||||||
|
|
||||||
|
| what | path |
|
||||||
|
|---|---|
|
||||||
|
| mandatory state | `/var/db/tsunagi` |
|
||||||
|
| disposable cache | `/var/db/tsunagi/cache` |
|
||||||
|
| control socket | `/var/run/tsunagi/agent.sock` |
|
||||||
|
|
||||||
|
## Native deployment — root LaunchDaemon
|
||||||
|
|
||||||
|
For an always-on agent that starts at boot, install the bundled
|
||||||
|
[`cy.hexor.tsunagi.plist`](cy.hexor.tsunagi.plist).
|
||||||
|
|
||||||
|
```sh
|
||||||
|
# Put the binary where the plist expects it (edit the plist for another path).
|
||||||
|
sudo install -m 0755 target/release/tsunagi /usr/local/bin/tsunagi
|
||||||
|
|
||||||
|
# Install the daemon. launchd refuses a daemon plist that is not owned by
|
||||||
|
# root:wheel or that is group/world-writable.
|
||||||
|
sudo install -m 0644 -o root -g wheel \
|
||||||
|
dist/macos/cy.hexor.tsunagi.plist \
|
||||||
|
/Library/LaunchDaemons/cy.hexor.tsunagi.plist
|
||||||
|
|
||||||
|
# Load and start it.
|
||||||
|
sudo launchctl bootstrap system /Library/LaunchDaemons/cy.hexor.tsunagi.plist
|
||||||
|
sudo launchctl enable system/cy.hexor.tsunagi
|
||||||
|
```
|
||||||
|
|
||||||
|
Manage it afterwards with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
sudo launchctl kickstart -k system/cy.hexor.tsunagi # restart
|
||||||
|
sudo launchctl bootout system/cy.hexor.tsunagi # stop and unload
|
||||||
|
tail -f /var/log/tsunagi.log # logs
|
||||||
|
```
|
||||||
|
|
||||||
|
The control commands (`status`, `join`, `network`, `dns`) work the same against
|
||||||
|
the daemon as against a `sudo tsunagi up`, over the same socket.
|
||||||
|
|
||||||
|
## What the agent does to the system
|
||||||
|
|
||||||
|
Everything it installs is tagged to its own interface, computed from local
|
||||||
|
state, and reversible — it is removed when a network leaves, broadcast is turned
|
||||||
|
off, or the agent stops:
|
||||||
|
|
||||||
|
- a **`utun` interface**, configured with `ifconfig` (addresses, MTU, up) and a
|
||||||
|
subnet `route` for the overlay range. The kernel assigns the `utunN` name; the
|
||||||
|
agent adopts whatever it is given. A `utun` is torn down with the process, so
|
||||||
|
a crash never leaves one behind.
|
||||||
|
- `/etc/resolver/<network>` files pointing the system resolver at the overlay
|
||||||
|
DNS server for each network's zone, with a `port` so the server stays off 53.
|
||||||
|
Each file carries a marker line; the agent only ever replaces or removes files
|
||||||
|
it wrote, never one you created by hand.
|
||||||
|
- while broadcast is on, a `255.255.255.255` **route** through the overlay
|
||||||
|
interface. The matching inbound-UDP firewall allowance is **not** implemented
|
||||||
|
yet (it needs a `pf` anchor); `tsunagi status` reports that half as incomplete
|
||||||
|
and names what to allow by hand if your host firewall drops discovery replies.
|
||||||
|
|
||||||
|
Nothing taken from a remote peer ever becomes a path, a command argument or an
|
||||||
|
OS setting: the interface name comes from the kernel, addresses and ranges from
|
||||||
|
signed state the agent already verified.
|
||||||
Vendored
+51
@@ -0,0 +1,51 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||||
|
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<!--
|
||||||
|
LaunchDaemon for the tsunagi agent.
|
||||||
|
|
||||||
|
A daemon, so it runs as root (creating a utun interface and writing
|
||||||
|
/etc/resolver both need it) and comes up at boot before anyone logs in.
|
||||||
|
Install it at /Library/LaunchDaemons/cy.hexor.tsunagi.plist, owned by
|
||||||
|
root:wheel with mode 0644 — launchd refuses a daemon plist that is
|
||||||
|
group- or world-writable. See README.md in this directory.
|
||||||
|
|
||||||
|
Edit the binary path if tsunagi is not at /usr/local/bin/tsunagi.
|
||||||
|
-->
|
||||||
|
<plist version="1.0">
|
||||||
|
<dict>
|
||||||
|
<key>Label</key>
|
||||||
|
<string>cy.hexor.tsunagi</string>
|
||||||
|
|
||||||
|
<!-- `up` starts the agent and keeps it in the foreground, which is what a
|
||||||
|
launchd daemon expects: launchd owns the lifecycle, so the process
|
||||||
|
must not daemonise itself. -->
|
||||||
|
<key>ProgramArguments</key>
|
||||||
|
<array>
|
||||||
|
<string>/usr/local/bin/tsunagi</string>
|
||||||
|
<string>up</string>
|
||||||
|
</array>
|
||||||
|
|
||||||
|
<!-- Start at boot and bring it back if it ever exits. -->
|
||||||
|
<key>RunAtLoad</key>
|
||||||
|
<true/>
|
||||||
|
<key>KeepAlive</key>
|
||||||
|
<true/>
|
||||||
|
|
||||||
|
<!-- Back off rather than spin if it fails to start repeatedly. -->
|
||||||
|
<key>ThrottleInterval</key>
|
||||||
|
<integer>10</integer>
|
||||||
|
|
||||||
|
<!-- Logs. launchd creates these as root; rotate them with newsyslog if you
|
||||||
|
keep the daemon around. -->
|
||||||
|
<key>StandardOutPath</key>
|
||||||
|
<string>/var/log/tsunagi.log</string>
|
||||||
|
<key>StandardErrorPath</key>
|
||||||
|
<string>/var/log/tsunagi.log</string>
|
||||||
|
|
||||||
|
<!-- The agent uses the fixed macOS system paths by default:
|
||||||
|
state /var/db/tsunagi
|
||||||
|
socket /var/run/tsunagi/agent.sock
|
||||||
|
so nothing here has to point at a user's home directory. -->
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
Reference in new issue
Block a user