Files
tsunagi/crates/tsunagi-core/src/overlay/hostrules/windows.rs
T
Aleksandr BogomiakovandClaude 9966b50407 Rename the CLI package to tsunagi and its executable to tsng
The command line package is now `tsunagi` and builds the `tsng` executable,
which the packages, the unit, the installers and the workflows install and
run. The core library package becomes `tsunagi-core`, keeping the library name
`tsunagi`, so no code changes `use tsunagi::...`. Directories follow
(crates/tsunagi-core, crates/tsunagi). Every command shown to a person is now
`tsng ...`.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-10-03 02:14:43 +01:00

273 lines
10 KiB
Rust

//! Broadcast host rules on Windows: a route and a Defender Firewall rule.
//!
//! Both are written with the `NetTCPIP` and `NetSecurity` PowerShell modules,
//! run by absolute path from `%SystemRoot%` like the DNS publisher does: the
//! in-process alternatives are `unsafe` IP Helper calls this crate forbids.
//!
//! * The route is `255.255.255.255/32` on the overlay interface, on-link, with
//! metric 1, written to the active store only so it cannot outlive a reboot.
//! Windows picks the interface's own address as the source of a packet sent
//! along it; with several networks on one interface that is whichever
//! address Windows treats as primary, which this cannot choose.
//! * The firewall rule allows inbound UDP on that interface alias from the
//! overlay range only, in every profile. Wintun adapters are usually
//! classified as a public network, where inbound UDP is otherwise dropped.
//! Its display name is the tag `tsunagi:<interface>`, which is how it is
//! found again, replaced and removed.
//!
//! Every value in a script is one this agent produced: the interface name is
//! derived from the network id and the range comes from signed state. They are
//! still single-quoted and escaped, so nothing could be read as PowerShell
//! rather than as data. Changing routes and firewall rules needs an elevated
//! process, which is reported as its own cause.
//!
//! This module is compiled everywhere so its script building is tested
//! everywhere. It could not be run against a real Windows host while it was
//! written.
#![cfg_attr(not(target_os = "windows"), allow(dead_code))]
use crate::BoxFuture;
use super::{BroadcastHostRules, BroadcastRulesPlan, BroadcastRulesReport, RuleOutcome, tag_for};
/// A PowerShell single-quoted literal, with any embedded quote doubled.
fn ps_literal(value: &str) -> String {
format!("'{}'", value.replace('\'', "''"))
}
/// The step that makes the route match the plan.
///
/// A leftover route (from a crashed run, or for an old interface index) is
/// removed first so the result is exactly one route.
fn route_script(plan: &BroadcastRulesPlan) -> String {
let alias = ps_literal(&plan.interface);
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
-PolicyStore ActiveStore -ErrorAction SilentlyContinue | \
Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue\n\
New-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias $alias \
-NextHop '0.0.0.0' -RouteMetric 1 -PolicyStore ActiveStore | Out-Null\n"
)
}
/// The step that makes the firewall hold exactly one rule for the interface.
fn firewall_script(plan: &BroadcastRulesPlan) -> String {
let alias = ps_literal(&plan.interface);
let tag = ps_literal(&plan.tag());
let range = ps_literal(&plan.range.to_string());
format!(
"$ErrorActionPreference = 'Stop'\n\
$alias = {alias}\n\
$tag = {tag}\n\
Get-NetFirewallRule -DisplayName $tag -ErrorAction SilentlyContinue | \
Remove-NetFirewallRule -ErrorAction SilentlyContinue\n\
New-NetFirewallRule -DisplayName $tag -Direction Inbound -Action Allow -Protocol UDP \
-InterfaceAlias $alias -RemoteAddress {range} -Profile Any | Out-Null\n"
)
}
/// The script that removes both, ignoring what is not there.
fn clear_script(interface: &str) -> String {
let alias = ps_literal(interface);
let tag = ps_literal(&tag_for(interface));
format!(
"$ErrorActionPreference = 'SilentlyContinue'\n\
Get-NetRoute -DestinationPrefix '255.255.255.255/32' -InterfaceAlias {alias} \
-PolicyStore ActiveStore | Remove-NetRoute -Confirm:$false\n\
Get-NetFirewallRule -DisplayName {tag} | Remove-NetFirewallRule\n\
exit 0\n"
)
}
/// Turns what PowerShell said into a cause a person can act on.
fn classify(what: &str, text: &str) -> String {
let lower = text.to_ascii_lowercase();
if lower.contains("access is denied")
|| lower.contains("requires elevation")
|| lower.contains("run as administrator")
|| lower.contains("permissiondenied")
{
format!("{what}: Windows refused; run tsng from an elevated (administrator) process")
} else if lower.contains("is not recognized") || lower.contains("commandnotfound") {
format!("{what}: the NetTCPIP/NetSecurity PowerShell modules are not available: {text}")
} else if text.is_empty() {
format!("{what}: failed without saying why")
} else {
format!("{what}: {text}")
}
}
/// The absolute path to Windows PowerShell, so nothing on `PATH` can stand in
/// for it.
fn powershell_path() -> std::path::PathBuf {
let root = std::env::var_os("SystemRoot").unwrap_or_else(|| r"C:\Windows".into());
std::path::Path::new(&root)
.join("System32")
.join("WindowsPowerShell")
.join("v1.0")
.join("powershell.exe")
}
/// Runs a script and returns what it said on failure.
fn run_powershell(script: &str) -> Result<(), String> {
let output = std::process::Command::new(powershell_path())
.args([
"-NoProfile",
"-NonInteractive",
"-ExecutionPolicy",
"Bypass",
"-Command",
script,
])
.output()
.map_err(|err| format!("could not run PowerShell: {err}"))?;
if output.status.success() {
return Ok(());
}
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()
};
Err(text)
}
async fn step(what: &'static str, script: String) -> RuleOutcome {
match tokio::task::spawn_blocking(move || run_powershell(&script)).await {
Ok(Ok(())) => RuleOutcome::Applied,
Ok(Err(text)) => RuleOutcome::Failed(classify(what, &text)),
Err(err) => RuleOutcome::Failed(format!("{what}: the task failed: {err}")),
}
}
/// The Windows implementation, through PowerShell.
#[derive(Debug, Default)]
pub struct WindowsHostRules;
impl WindowsHostRules {
/// Creates it. Nothing is run until [`BroadcastHostRules::apply`].
pub fn new() -> Self {
Self
}
}
impl BroadcastHostRules for WindowsHostRules {
fn name(&self) -> &str {
"windows-netsecurity"
}
fn apply<'a>(&'a self, plan: &'a BroadcastRulesPlan) -> BoxFuture<'a, BroadcastRulesReport> {
Box::pin(async move {
let route = step("route", route_script(plan)).await;
let firewall = step("firewall", firewall_script(plan)).await;
BroadcastRulesReport {
plan: plan.clone(),
route,
firewall,
}
})
}
fn clear<'a>(&'a self, interface: &'a str) -> BoxFuture<'a, ()> {
Box::pin(async move {
if let RuleOutcome::Failed(reason) = step("clear", clear_script(interface)).await {
tracing::warn!(
interface,
"cannot remove the broadcast host rules: {reason}"
);
}
})
}
}
#[cfg(test)]
mod tests {
#![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
use super::*;
use std::net::Ipv4Addr;
fn plan() -> BroadcastRulesPlan {
BroadcastRulesPlan {
interface: "tsun0".into(),
source: Ipv4Addr::new(10, 13, 37, 142),
range: "10.13.37.0/24".parse().unwrap(),
}
}
#[test]
fn a_quote_in_a_value_cannot_escape_the_literal() {
assert_eq!(ps_literal("a'b"), "'a''b'");
let mut hostile = plan();
hostile.interface = "x'; Remove-Item C:\\ #".into();
let script = route_script(&hostile);
assert!(
script.contains("$alias = 'x''; Remove-Item C:\\ #'"),
"{script}"
);
assert!(firewall_script(&hostile).contains("'x''; Remove-Item C:\\ #'"));
}
#[test]
fn the_route_is_on_link_metric_one_in_the_active_store_and_replaces_its_own() {
let script = route_script(&plan());
assert!(script.contains("'255.255.255.255/32'"));
assert!(script.contains("-NextHop '0.0.0.0'"));
assert!(script.contains("-RouteMetric 1"));
assert!(script.contains("-PolicyStore ActiveStore"));
assert!(
script.find("Remove-NetRoute").unwrap() < script.find("New-NetRoute").unwrap(),
"a stale route goes before the new one:\n{script}"
);
}
#[test]
fn the_firewall_rule_is_udp_only_from_the_overlay_range_on_this_interface() {
let script = firewall_script(&plan());
assert!(script.contains("-Protocol UDP"));
assert!(script.contains("-Direction Inbound -Action Allow"));
assert!(script.contains("-InterfaceAlias $alias"));
assert!(script.contains("-RemoteAddress '10.13.37.0/24'"));
assert!(script.contains("$tag = 'tsunagi:tsun0'"));
assert!(!script.contains("-Protocol Any") && !script.contains("-Protocol TCP"));
assert!(
script.find("Remove-NetFirewallRule").unwrap()
< script.find("New-NetFirewallRule").unwrap(),
"{script}"
);
}
#[test]
fn clearing_touches_only_our_route_and_our_tagged_rule() {
let script = clear_script("tsun0");
assert!(script.contains("-InterfaceAlias 'tsun0'"));
assert!(script.contains("Get-NetFirewallRule -DisplayName 'tsunagi:tsun0'"));
assert!(script.contains("exit 0"));
}
#[test]
fn a_refusal_names_elevation_and_a_missing_module_names_the_module() {
assert!(classify("route", "Access is denied").contains("elevated"));
assert!(
classify(
"firewall",
"The term 'New-NetFirewallRule' is not recognized"
)
.contains("modules are not available")
);
assert_eq!(classify("route", "boom"), "route: boom");
assert!(classify("route", "").contains("without saying why"));
}
#[test]
fn powershell_is_found_by_absolute_path() {
let path = powershell_path();
assert!(path.ends_with("powershell.exe"));
assert!(path.to_string_lossy().contains("System32"), "{path:?}");
}
}