Compare commits

..
Author SHA1 Message Date
Ian Graham Martinez e7958d6f21 Let the Windows service own its hosts-file block
The service writes the block once it listens. Pausing it removes the block
and continuing rewrites it; a stop, crash or shutdown leaves it, so a
restart fails closed and picks up a changed host list. Interactive users
may start, stop and pause the service, for the tray.
2026-10-12 23:51:25 -04:00
Ian Graham Martinez 07d2533208 Install and uninstall machine-wide screening on Windows
install copies the binary where only admins can replace it, trusts the CA,
sets the environment for clients that read no trust store, starts the
service, then points the hosts file at it. uninstall reverses each step,
the hosts file first.
2026-10-12 23:24:47 -04:00
Ian Graham Martinez feccd84f0f Screen redirected registry traffic in a Windows service
Each registry host gets its own loopback address, where a LocalSystem
service serves mitm_service, connecting upstream through a resolver that
ignores the hosts file. It trusts its CA machine-wide and serves the empty
CRL that schannel needs.
2026-10-12 23:23:52 -04:00
Ian Graham Martinez cb1f9f6faf Hold the CA key in the TPM on Windows
A non-exportable machine key in the TPM, or in software where there is
none, signs as the Secure Enclave key does on macOS.
2026-10-12 21:20:17 -04:00
Ian Graham Martinez a6c378c0d6 Sign an empty CRL and point leaves at it
schannel's revocation check, which cargo enables, fails closed on a leaf
without a reachable CRL. The CA gains cRLSign, which rcgen needs to sign
one, and leaves carry a distribution point when the platform serves it.
2026-10-12 19:59:29 -04:00
Ian Graham Martinez 959e71e07c List the screened hosts from the config
Building a Firewall needs a runtime, which an installer has no use for.
2026-10-12 19:59:28 -04:00
ian-at-strix b2e036f7c6 Run any command after firewall verbatim, -- included (#6)
bpaf always consumes `--`, so `firewall cargo run -- --release` handed
--release to cargo and our --config flags to the program. The CLI is now clap
with one trailing COMMAND argument, which keeps `--`.

Every manager's environment is set on every run, and cargo's and gem's flags
are added when the command is cargo or gem, so `firewall sh -c '…'` is
screened too. Since every command now gets HTTPS_PROXY, HTTPS to hosts that
aren't registries is tunnelled; registry hosts are still refused.
2026-10-12 16:55:27 -04:00
ian-at-strix 7f9e257a38 Merge pull request #3 from usestrix/fix/pipenv-trusted-host
Trust the PyPI hosts for pipenv's pip
2026-10-12 15:49:55 -04:00
Ian Graham MartinezandClaude Opus 5.5 098dea7cc0 Trust the PyPI hosts for pipenv's pip
pipenv installs through pip, which skips the plaintext index on an
untrusted host, so `firewall pipenv install six` fetched nothing and
failed with "No matching distribution found". Set PIP_TRUSTED_HOST as
the pip recipe already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:30:43 -04:00
24 changed files with 1281 additions and 131 deletions

No files matched your search

+326 -10
View File
@@ -45,6 +45,56 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -122,6 +172,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "asynk-strim"
version = "0.1.5"
@@ -210,12 +271,6 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "bpaf"
version = "0.9.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597e81e56404b7a3e26ef7b449f82a4b7a661ae76c3cac692b42d6bad06a0fbe"
[[package]]
name = "brotli"
version = "8.0.4"
@@ -313,6 +368,33 @@ dependencies = [
"libloading",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmake"
version = "0.1.58"
@@ -322,6 +404,12 @@ dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.8"
@@ -379,6 +467,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -419,6 +517,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
@@ -920,6 +1024,78 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-net"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"futures-channel",
"futures-io",
"futures-util",
"hickory-proto",
"idna",
"ipnet",
"jni",
"rand",
"thiserror",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-proto"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni",
"once_cell",
"prefix-trie",
"rand",
"ring",
"serde",
"thiserror",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981"
dependencies = [
"cfg-if",
"futures-util",
"hickory-net",
"hickory-proto",
"ipconfig",
"ipnet",
"jni",
"moka",
"ndk-context",
"once_cell",
"parking_lot",
"rand",
"resolv-conf",
"serde",
"smallvec",
"system-configuration",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1081,6 +1257,19 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1100,6 +1289,12 @@ dependencies = [
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1561,6 +1756,16 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl-macros"
@@ -1689,6 +1894,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prefix-trie"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
dependencies = [
"either",
"ipnet",
"num-traits",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2032,6 +2248,7 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2330,7 +2547,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation",
"core-foundation 0.10.1",
"csv",
"idna",
"ipnet",
@@ -2662,6 +2879,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "ring"
version = "0.17.14"
@@ -2797,7 +3020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3004,8 +3227,8 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "strix-firewall"
version = "0.1.0"
dependencies = [
"bpaf",
"core-foundation",
"clap",
"core-foundation 0.10.1",
"directories",
"percent-encoding",
"rama",
@@ -3022,8 +3245,18 @@ dependencies = [
"tracing",
"tracing-subscriber",
"which",
"windows-registry",
"windows-result",
"windows-service",
"windows-sys 0.61.2",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3086,6 +3319,27 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -3174,6 +3428,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -3427,12 +3687,31 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
@@ -3568,6 +3847,12 @@ dependencies = [
"libc",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "wildcard"
version = "0.3.0"
@@ -3614,6 +3899,17 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3623,6 +3919,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags 2.13.2",
"widestring",
"windows-sys 0.61.2",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
+16 -1
View File
@@ -53,7 +53,7 @@ name = "managers"
path = "tests/managers.rs"
[dependencies]
bpaf = "0.9.28"
clap = "4.6.6"
directories = "6.0.0"
percent-encoding = "2.3.2"
rama = { version = "0.4.0", features = ["boring", "http-full", "ring", "rustls"] }
@@ -75,6 +75,21 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
rama = { version = "0.4.0", features = ["dns-hickory"] }
tokio = { version = "1.53.1", features = ["net"] }
windows-registry = "0.6.1"
windows-result = "0.4.1"
windows-service = "0.8.0"
windows-sys = { version = "0.61.2", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_System_Services",
"Win32_UI_WindowsAndMessaging",
] }
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+29 -10
View File
@@ -1,15 +1,20 @@
use crate::http::{blocked, https_url, text};
use crate::{policies, telemetry};
use crate::{Firewall, FirewallConfig};
use rama::dns::client::DnsConnectorLayer;
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_LENGTH;
use rama::http::layer::decompression::DecompressionLayer;
use rama::http::layer::remove_header::{RemoveRequestHeaderLayer, RemoveResponseHeaderLayer};
use rama::http::layer::upgrade::{EagerHttpProxyConnector, UpgradeLayer};
use rama::http::server::HttpServer;
use rama::http::{Body, Method, Request, Response};
use rama::matcher::match_fn;
use rama::net::proxy::IoForwardService;
use rama::rt::Executor;
use rama::service::service_fn;
use rama::tcp::client::service::TcpConnector;
use rama::tcp::server::TcpListener;
use rama::{Layer, Service};
use std::convert::Infallible;
@@ -31,8 +36,26 @@ pub fn spawn(port: u16, config: FirewallConfig) -> std::io::Result<u16> {
Ok(listener) => listener,
Err(e) => return error!(error = %e, "forward proxy could not take over the socket"),
};
// Tunnels skip screening, so a registry's CONNECT falls through to `handle`, which refuses it.
let hosts = firewall.hosts();
let tunnel = UpgradeLayer::new(
exec.clone(),
match_fn(move |req: &Request| {
let host = req.uri().host().map(|host| host.to_str());
req.method() == Method::CONNECT
&& host.is_some_and(|host| !hosts.contains(&host.as_ref()))
}),
EagerHttpProxyConnector::new(
DnsConnectorLayer::new().into_layer(TcpConnector::new()),
IoForwardService::default(),
),
);
let server = HttpServer::auto(exec).service(
(RemoveResponseHeaderLayer::hop_by_hop(), RemoveRequestHeaderLayer::hop_by_hop())
(
RemoveResponseHeaderLayer::hop_by_hop(),
RemoveRequestHeaderLayer::hop_by_hop(),
tunnel,
)
.into_layer(service_fn(move |req: Request| {
let firewall = Arc::clone(&firewall);
async move { handle(req, &firewall).await }
@@ -66,16 +89,12 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}
info!(method = %req.method(), %url, "forwarding");
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten.
let tls = firewall.is_https_url(&url);
let index = tls && firewall.is_index_url(&url);
let index = firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts();
if tls {
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
}
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
let response = match client.serve(Request::from_parts(parts, body)).await {
+4 -2
View File
@@ -41,8 +41,10 @@ pub struct Manager {
pub static MANAGERS: &[&Manager] =
&[&NPM, &PIP, &PIP3, &CARGO, &GO, &GEM, &UV, &PDM, &PIPENV, &PNPM, &BUN];
pub fn variables(proxy: &str, manager: &Manager) -> Vec<(String, String)> {
manager.env.iter().map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
/// Every manager's variables, so whichever the command starts is pointed at the firewall.
pub fn variables(proxy: &str) -> Vec<(String, String)> {
let env = MANAGERS.iter().flat_map(|manager| manager.env);
env.map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
}
pub fn arguments(proxy: &str, manager: &Manager, typed: &[String]) -> Vec<String> {
+2
View File
@@ -9,6 +9,8 @@ pub static PIPENV: Manager = Manager {
("http_proxy", PROXY),
("https_proxy", PROXY),
("PIPENV_PYPI_MIRROR", PYPI_SIMPLE),
// pipenv installs through pip, which skips a plaintext index on a host it doesn't trust.
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
],
args: &[],
};
+19 -12
View File
@@ -9,30 +9,37 @@ use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Invocation {
pub manager: &'static Manager,
pub program: String,
pub args: Vec<String>,
}
impl Invocation {
pub fn new(manager: &'static Manager, args: Vec<String>) -> Self {
Invocation { manager, args }
pub fn new(program: String, args: Vec<String>) -> Self {
Invocation { program, args }
}
/// What the program is run with: the typed arguments plus any its manager needs.
pub fn arguments(&self, proxy: &str) -> Vec<String> {
let mut args = self.args.clone();
let Some(manager) = MANAGERS.iter().find(|manager| manager.command == self.program) else {
return args;
};
let ours = managers::arguments(proxy, manager, &args);
// Anything after `--` belongs to another program, so ours go before it.
let at = args.iter().position(|arg| arg == "--").unwrap_or(args.len());
args.splice(at..at, ours);
args
}
}
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let mut args = invocation.args.clone();
let ours = managers::arguments(&proxy, invocation.manager, &args);
// Anything after `--` belongs to another program, so ours go before it.
let at = args.iter().position(|arg| arg == "--").unwrap_or(args.len());
args.splice(at..at, ours);
let mut command = Command::new(resolve(invocation.manager.command)?);
command.args(args);
let mut command = Command::new(resolve(&invocation.program)?);
command.args(invocation.arguments(&proxy));
if let Some(dir) = dir {
command.current_dir(dir);
}
for (key, value) in managers::variables(&proxy, invocation.manager) {
for (key, value) in managers::variables(&proxy) {
command.env(key, value);
}
Ok(command.status()?.code().unwrap_or(1))
+12
View File
@@ -80,6 +80,18 @@ impl FirewallConfig {
pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port, path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
}
pub fn path() -> Option<PathBuf> {
+1 -13
View File
@@ -40,19 +40,7 @@ impl Firewall {
}
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.config.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
self.config.hosts()
}
pub fn is_index_url(&self, url: &str) -> bool {
+64 -47
View File
@@ -1,64 +1,81 @@
use bpaf::{any, choice, pure, OptionParser, Parser};
use firewall::aliasing::{self, Invocation, Manager, MANAGERS};
use clap::{Arg, Command};
use firewall::aliasing::{self, Invocation, MANAGERS};
use firewall::{config, proxy, telemetry, FirewallConfig};
use std::io::ErrorKind;
use std::path::Path;
use std::process::exit;
use tracing::{error, info};
enum SubCommand {
Serve,
Run(Invocation),
}
fn main() {
match options().run() {
SubCommand::Serve => {
firewall::init_logging("info");
serve()
}
SubCommand::Run(invocation) => {
firewall::init_logging("warn");
run(invocation)
}
let matches = options().get_matches();
if matches.subcommand_matches("serve").is_some() {
firewall::init_logging("info");
serve()
}
#[cfg(windows)]
windows(&matches);
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
run(Invocation::new(program, command.collect()))
}
fn options() -> OptionParser<SubCommand> {
fn options() -> Command {
let program = program_name();
let managers: Vec<_> = MANAGERS.iter().map(|manager| manager.command).collect();
let footer = format!(
"Run a package manager through {program} to screen what it installs:\n \
{program} npm install left-pad\n \n \
Alias it to screen every install without changing how you type:\n \
alias npm='{program} npm'"
"Run a command through {program} to screen what it installs:\n \
{program} npm install left-pad\n\n\
Alias it to screen every install without changing how you type:\n \
alias npm='{program} npm'\n\n\
Supported package managers: {}",
managers.join(", ")
);
let serve = pure(())
.map(|()| SubCommand::Serve)
.to_options()
.descr("Serve a local registry for package managers configured to use it")
.command("serve");
let mut commands: Vec<_> = MANAGERS
.iter()
.map(|manager| package(manager, &program).boxed())
.collect();
commands.push(serve.boxed());
choice(commands)
.to_options()
.descr("Screens package downloads before they reach your machine")
.footer(Box::leak(footer.into_boxed_str()) as &str)
.fallback_to_usage()
let command = Command::new("firewall")
.about("Screens package downloads before they reach your machine")
.after_help(footer)
.arg_required_else_help(true)
.args_conflicts_with_subcommands(true)
.subcommand(
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
.num_args(1..)
.trailing_var_arg(true)
.allow_hyphen_values(true),
);
#[cfg(windows)]
let command = command
.subcommand(Command::new("install").about("Screen every package download on this machine"))
.subcommand(Command::new("uninstall").about("Stop screening and remove the firewall"))
.subcommand(Command::new("service").hide(true));
command
}
fn package(manager: &'static Manager, program: &str) -> impl Parser<SubCommand> {
let name = manager.command;
let descr = format!("Run {name} with downloads screened: {program} {name} ARGS...");
any::<String, String, _>("ARGS", Some)
.many()
.map(move |args| SubCommand::Run(Invocation::new(manager, args)))
.to_options()
.descr(Box::leak(descr.into_boxed_str()) as &str)
.command(name)
/// Machine-wide screening through the hosts file and a service; needs an elevated shell.
#[cfg(windows)]
fn windows(matches: &clap::ArgMatches) {
use firewall::os::windows;
let (kind, result) = match matches.subcommand_name() {
// The service logs to a file; it has no console.
Some("service") => ("service", windows::service().map_err(Into::into)),
Some("install") => {
firewall::init_logging("info");
("install", windows::install())
}
Some("uninstall") => {
firewall::init_logging("info");
("uninstall", windows::uninstall())
}
_ => return,
};
match result {
Ok(()) => exit(0),
Err(e) => fail(kind, &format!("could not {kind}: {e}")),
}
}
fn serve() -> ! {
@@ -82,7 +99,7 @@ fn serve() -> ! {
}
fn run(invocation: Invocation) -> ! {
let command = invocation.manager.command;
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
Err(e) => fail("spawn", &format!("could not start the firewall: {e}")),
+29 -6
View File
@@ -1,12 +1,18 @@
use rama::error::BoxError;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyUsagePurpose, NameConstraints, SigningKey,
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
SerialNumber, SigningKey,
};
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
/// The CA's subject, by which a trust store entry is recognized as ours.
pub const COMMON_NAME: &str = "Strix Firewall";
pub const ORGANIZATION: &str = "Strix";
/// Held in the Secure Enclave on macOS, the TPM on Windows, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
pub struct CertificateAuthority {
@@ -42,16 +48,33 @@ impl CertificateAuthority {
}
}
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
/// Valid for a week; the caller re-signs it well before then.
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
let now = OffsetDateTime::now_utc();
let list = CertificateRevocationListParams {
this_update: now - Duration::hours(1),
next_update: now + Duration::days(7),
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
issuing_distribution_point: None,
revoked_certs: Vec::new(),
key_identifier_method: KeyIdMethod::Sha256,
};
let ca = params(hosts);
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
}
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::OrganizationName, "Strix");
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::OrganizationName, ORGANIZATION);
let mut params = CertificateParams::default();
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
// rcgen signs a CRL only for a CA that may.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts
.iter()
+12 -3
View File
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
use rama::tls::boring::core::x509::X509;
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
use rama::utils::collections::non_empty_vec;
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
@@ -14,12 +14,18 @@ pub struct ConstrainedCertIssuer {
ca_params: Arc<CertificateParams>,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
}
impl ConstrainedCertIssuer {
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
pub(super) fn new(
ca_crt: X509,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts }
Self { ca_crt, ca_params, signer, hosts, crl }
}
}
@@ -42,6 +48,9 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
subject.push(DnType::CommonName, common_name);
let mut leaf = CertificateParams::new(names)?;
leaf.distinguished_name = subject;
if let Some(crl) = &self.crl {
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
}
// The TLS server needs the leaf key in memory, so it can't be the platform key.
let leaf_key = KeyPair::generate()?;
+4 -2
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{CaSigner, CertificateAuthority};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
@@ -25,14 +25,16 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
type InterceptService =
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
pub fn mitm_service(
firewall: Arc<Firewall>,
ca: &CertificateAuthority,
signer: CaSigner,
crl: Option<String>,
exec: Executor,
) -> MitmService {
let hosts = firewall.hosts();
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
+1 -1
View File
@@ -108,7 +108,7 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
let signer: mitm::CaSigner = Arc::new(key);
let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let service = mitm::mitm_service(firewall, &ca, signer, exec);
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
Ok(Handler { ca, hosts, service })
}
+2
View File
@@ -1,2 +1,4 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
+106
View File
@@ -0,0 +1,106 @@
use super::*;
use crate::mitm::{CertificateAuthority, COMMON_NAME, ORGANIZATION};
use rama::tls::boring::core::nid::Nid;
use rama::tls::boring::core::x509::X509;
use rcgen::SigningKey;
use std::io;
use tracing::warn;
use windows_sys::Win32::Security::Cryptography::*;
use windows_sys::Win32::Security::*;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
const DATA_DIR_ACL: &str = "D:P(A;OICI;FA;;;SY)(A;OICI;FA;;;BA)(A;OICI;FRFX;;;BU)";
/// Trusted machine-wide so restarts keep it; replaced if the hosts change.
pub fn certificate_authority(
hosts: &[&str],
signer: &dyn SigningKey,
) -> Result<CertificateAuthority, BoxError> {
let stored = registry_get(REGISTRY_KEY, "ca")?;
let ca = match stored.and_then(|blob| CertificateAuthority::from_blob(&blob, hosts, signer)) {
Some(ca) => ca,
None => {
warn!(?hosts, "minting a new CA");
let ca = CertificateAuthority::generate(hosts, signer)?;
LOCAL_MACHINE.create(REGISTRY_KEY)?.set_string("ca", ca.to_blob(hosts))?;
ca
}
};
// Idempotent, and the registry value can outlive the certificate.
let der = ca.crt.to_der()?;
let store = RootStore::open()?;
let (encoding, flags) = (X509_ASN_ENCODING, CERT_STORE_ADD_REPLACE_EXISTING);
let len = der.len() as u32;
let added = unsafe {
CertAddEncodedCertificateToStore(store.0, encoding, der.as_ptr(), len, flags, ptr::null_mut())
};
if added == 0 {
return Err(io::Error::last_os_error().into());
}
distrust(Some(&der))?;
// For clients that read no trust store; screening still works for the rest if this fails.
if let Err(error) = data_dir_ready().and_then(|dir| std::fs::write(dir.join("ca.pem"), &ca.pem)) {
warn!(%error, "could not export the CA");
}
Ok(ca)
}
/// LocalMachine\Root, closed on drop.
struct RootStore(HCERTSTORE);
impl RootStore {
fn open() -> io::Result<Self> {
let flags = CERT_SYSTEM_STORE_LOCAL_MACHINE | CERT_STORE_OPEN_EXISTING_FLAG;
let name = wide("ROOT");
let store = unsafe { CertOpenStore(CERT_STORE_PROV_SYSTEM_W, 0, 0, flags, name.as_ptr().cast()) };
match store.is_null() {
true => Err(io::Error::last_os_error()),
false => Ok(RootStore(store)),
}
}
}
impl Drop for RootStore {
fn drop(&mut self) {
unsafe { CertCloseStore(self.0, 0) };
}
}
/// Created on first use, its ACL reset every time.
pub(super) fn data_dir_ready() -> io::Result<PathBuf> {
let dir = data_dir();
std::fs::create_dir_all(&dir)?;
let info = DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION;
let path = wide(&dir.display().to_string());
with_descriptor(DATA_DIR_ACL, |acl| unsafe { SetFileSecurityW(path.as_ptr(), info, acl) })?;
Ok(dir)
}
/// Drops every CA of ours from LocalMachine\Root but `keep`.
pub(super) fn distrust(keep: Option<&[u8]>) -> Result<(), BoxError> {
let store = RootStore::open()?;
let mut cert = ptr::null();
loop {
cert = unsafe { CertEnumCertificatesInStore(store.0, cert) };
let Some(context) = (unsafe { cert.as_ref() }) else { return Ok(()) };
let der = unsafe { std::slice::from_raw_parts(context.pbCertEncoded, context.cbCertEncoded as usize) };
if keep != Some(der) && is_ours(der) {
// Deleting frees the context, which enumeration still needs, so delete a copy.
if unsafe { CertDeleteCertificateFromStore(CertDuplicateCertificateContext(cert)) } == 0 {
let error = io::Error::last_os_error();
unsafe { CertFreeCertificateContext(cert) };
return Err(error.into());
}
}
}
}
fn is_ours(der: &[u8]) -> bool {
let Ok(crt) = X509::from_der(der) else { return false };
let name = |nid| {
let entry = crt.subject_name().entries_by_nid(nid).next()?;
Some(entry.data().as_utf8().ok()?.to_string())
};
name(Nid::COMMONNAME).as_deref() == Some(COMMON_NAME)
&& name(Nid::ORGANIZATIONNAME).as_deref() == Some(ORGANIZATION)
}
+82
View File
@@ -0,0 +1,82 @@
use super::ca::certificate_authority;
use super::tpm::TpmKey;
use super::uninstall::stop_service;
use super::*;
use crate::FirewallConfig;
use std::time::{Duration, Instant};
use tracing::info;
use windows_service::service::*;
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_sys::Win32::Security::DACL_SECURITY_INFORMATION;
use windows_sys::Win32::System::Services::SetServiceObjectSecurity;
/// The defaults, except that interactive users may also start, stop and pause it, as the tray does.
const SERVICE_ACL: &str = "D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)\
(A;;CCLCSWRPWPDTLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)";
/// Needs an elevated shell. The service points the hosts file at itself once it listens.
pub fn install() -> Result<(), BoxError> {
let hosts = FirewallConfig::default().hosts();
stop_service(false)?;
let (current, exe) = (std::env::current_exe()?, service_exe());
if current != exe {
std::fs::create_dir_all(exe.parent().expect("service_exe has a directory"))?;
std::fs::copy(current, &exe)?;
}
certificate_authority(&hosts, &TpmKey::load_or_create()?)?;
for (name, value) in environment() {
LOCAL_MACHINE.create(ENVIRONMENT_KEY)?.set_string(name, value)?;
}
broadcast_environment();
start_service()?;
info!(?hosts, "registry downloads are now screened");
Ok(())
}
fn start_service() -> Result<(), BoxError> {
let access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE;
let manager = ServiceManager::local_computer(None::<&str>, access)?;
let info = ServiceInfo {
name: SERVICE_NAME.into(),
display_name: "Strix Firewall".into(),
service_type: ServiceType::OWN_PROCESS,
start_type: ServiceStartType::AutoStart,
error_control: ServiceErrorControl::Normal,
executable_path: service_exe(),
launch_arguments: vec!["service".into()],
dependencies: vec![],
account_name: None,
account_password: None,
};
let access = ServiceAccess::CHANGE_CONFIG
| ServiceAccess::START
| ServiceAccess::QUERY_STATUS
| ServiceAccess::WRITE_DAC;
let service = match manager.open_service(SERVICE_NAME, access) {
Ok(service) => service.change_config(&info).map(|()| service)?,
Err(_) => manager.create_service(&info, access)?,
};
service.set_description("Screens package downloads for known malware")?;
with_descriptor(SERVICE_ACL, |acl| unsafe {
SetServiceObjectSecurity(service.raw_handle(), DACL_SECURITY_INFORMATION, acl)
})?;
// Registry hosts lead nowhere while it is down, so bring it back.
let restart = ServiceAction { action_type: ServiceActionType::Restart, delay: Duration::from_secs(5) };
service.update_failure_actions(ServiceFailureActions {
reset_period: ServiceFailureResetPeriod::After(Duration::from_secs(3600)),
reboot_msg: None,
command: None,
actions: Some(vec![restart; 3]),
})?;
service.set_failure_actions_on_non_crash_failures(true)?;
service.start::<&str>(&[])?;
let deadline = Instant::now() + Duration::from_secs(30);
loop {
match service.query_status()?.current_state {
ServiceState::Running => return Ok(()),
ServiceState::Stopped => return Err("the service stopped, see firewall.log".into()),
_ if Instant::now() > deadline => return Err("the service did not start".into()),
_ => std::thread::sleep(Duration::from_millis(250)),
}
}
}
+131
View File
@@ -0,0 +1,131 @@
//! The hosts file sends each registry host to its own loopback address, where a
//! LocalSystem service screens it as the macOS extension does.
mod ca;
mod install;
mod service;
mod tpm;
mod uninstall;
pub use install::install;
pub use service::run as service;
pub use tpm::TpmKey;
pub use uninstall::uninstall;
use rama::error::BoxError;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use std::ptr;
use windows_registry::LOCAL_MACHINE;
use windows_result::HRESULT;
use windows_sys::core::BOOL;
use windows_sys::Win32::Foundation::{LocalFree, ERROR_FILE_NOT_FOUND, LPARAM};
use windows_sys::Win32::Security::Authorization::*;
use windows_sys::Win32::Security::PSECURITY_DESCRIPTOR;
use windows_sys::Win32::UI::WindowsAndMessaging::{
SendMessageTimeoutW, HWND_BROADCAST, SMTO_ABORTIFHUNG, WM_SETTINGCHANGE,
};
const SERVICE_NAME: &str = "StrixFirewall";
/// Admin-only, so it holds the CA as the System keychain does on macOS.
const REGISTRY_KEY: &str = r"SOFTWARE\Strix Firewall";
const ENVIRONMENT_KEY: &str = r"SYSTEM\CurrentControlSet\Control\Session Manager\Environment";
const BEGIN: &str = "# BEGIN Strix Firewall";
const END: &str = "# END Strix Firewall";
/// Serves the CRL our leaves point at; schannel fails closed without one.
const CRL_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2);
/// Admin-only; holds the exported CA and the service log.
fn data_dir() -> PathBuf {
known_dir("ProgramData", r"C:\ProgramData").join("Strix Firewall")
}
/// The service binary, copied where only admins can replace it.
fn service_exe() -> PathBuf {
known_dir("ProgramFiles", r"C:\Program Files").join(r"Strix Firewall\firewall.exe")
}
fn hosts_file() -> PathBuf {
known_dir("SystemRoot", r"C:\Windows").join(r"System32\drivers\etc\hosts")
}
fn known_dir(var: &str, default: &str) -> PathBuf {
std::env::var_os(var).map_or_else(|| default.into(), PathBuf::from)
}
/// For clients that read no trust store. SSL_CERT_FILE would replace OpenSSL's bundle.
fn environment() -> [(&'static str, String); 2] {
let ca = data_dir().join("ca.pem");
[("NODE_EXTRA_CA_CERTS", ca.display().to_string()), ("UV_SYSTEM_CERTS", "1".into())]
}
/// From 127.0.0.3, leaving 127.0.0.1 to whatever else serves locally.
fn redirects(hosts: &[&'static str]) -> Vec<(Ipv4Addr, &'static str)> {
(3..).zip(hosts).map(|(i, host)| (Ipv4Addr::new(127, 0, 0, i), *host)).collect()
}
/// Points each host at its listener, or with none removes our block.
fn write_hosts(hosts: &[&'static str]) -> std::io::Result<()> {
let mut contents = without_block(&std::fs::read_to_string(hosts_file())?);
if !hosts.is_empty() {
if !contents.is_empty() && !contents.ends_with('\n') {
contents.push_str("\r\n");
}
contents += &format!("{BEGIN}\r\n");
for (address, host) in redirects(hosts) {
contents += &format!("{address} {host}\r\n");
}
contents += &format!("{END}\r\n");
}
std::fs::write(hosts_file(), contents)
}
/// `contents` without our block.
fn without_block(contents: &str) -> String {
let Some((before, rest)) = contents.split_once(BEGIN) else { return contents.into() };
let Some((_, after)) = rest.split_once(END) else { return contents.into() };
before.to_owned() + after.strip_prefix("\r\n").unwrap_or(after)
}
/// Parses `sddl` for `apply`, which returns zero on failure.
fn with_descriptor(sddl: &str, apply: impl FnOnce(PSECURITY_DESCRIPTOR) -> BOOL) -> std::io::Result<()> {
let (mut descriptor, sddl) = (ptr::null_mut(), wide(sddl));
let parsed = unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(sddl.as_ptr(), SDDL_REVISION_1, &mut descriptor, ptr::null_mut())
};
if parsed == 0 {
return Err(std::io::Error::last_os_error());
}
let applied = apply(descriptor);
let error = std::io::Error::last_os_error();
unsafe { LocalFree(descriptor) };
match applied {
0 => Err(error),
_ => Ok(()),
}
}
fn wide(s: &str) -> Vec<u16> {
s.encode_utf16().chain([0]).collect()
}
fn registry_get(key: &str, value: &str) -> Result<Option<String>, BoxError> {
found(LOCAL_MACHINE.open(key).and_then(|key| key.get_string(value)))
}
/// `None` for a missing key or value.
fn found<T>(result: windows_registry::Result<T>) -> Result<Option<T>, BoxError> {
match result {
Err(error) if error.code() == HRESULT::from_win32(ERROR_FILE_NOT_FOUND) => Ok(None),
result => Ok(Some(result?)),
}
}
/// So new processes pick up the machine environment without a sign-out.
fn broadcast_environment() {
let environment = wide("Environment");
let lparam = environment.as_ptr() as LPARAM;
unsafe {
SendMessageTimeoutW(HWND_BROADCAST, WM_SETTINGCHANGE, 0, lparam, SMTO_ABORTIFHUNG, 5000, ptr::null_mut())
};
}
+177
View File
@@ -0,0 +1,177 @@
use super::ca::{certificate_authority, data_dir_ready};
use super::tpm::TpmKey;
use super::{redirects, write_hosts, CRL_ADDRESS, SERVICE_NAME};
use crate::config::FirewallConfig;
use crate::http::{reply, text};
use crate::{mitm, telemetry, Firewall};
use rama::dns::client::hickory::resolver::config::ResolveHosts;
use rama::dns::client::hickory::{default_resolver_opts, HickoryDnsResolver};
use rama::dns::client::resolver::DnsAddressResolver;
use rama::dns::client::try_init_global_dns_resolver;
use rama::error::BoxError;
use rama::http::server::HttpServer;
use rama::http::Request;
use rama::io::BridgeIo;
use rama::layer::ConsumeErrLayer;
use rama::net::address::Domain;
use rama::rt::Executor;
use rama::service::service_fn;
use rama::tcp::server::TcpListener;
use rama::tcp::TcpStream;
use rama::{Layer, Service};
use std::convert::Infallible;
use std::ffi::OsString;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::{Arc, Mutex};
use std::time::Duration;
use windows_service::service::*;
use windows_service::service_control_handler::{self, ServiceControlHandlerResult, ServiceStatusHandle};
use windows_service::{define_windows_service, service_dispatcher};
define_windows_service!(ffi_service_main, service_main);
/// Called by the service control manager, never by hand.
pub fn run() -> windows_service::Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
/// stderr goes nowhere in a service, so events go to a file beside the CA.
fn service_main(_arguments: Vec<OsString>) {
let log = data_dir_ready()
.and_then(|dir| std::fs::File::options().create(true).append(true).open(dir.join("firewall.log")));
if let Ok(file) = log {
let filter = crate::log_filter("info");
let _ = tracing_subscriber::fmt().with_env_filter(filter).with_ansi(false).with_writer(Mutex::new(file)).try_init();
}
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
if let Err(error) = serve() {
tracing::error!(%error, "the service stopped");
}
}
fn serve() -> Result<(), BoxError> {
let (controls, mut received) = tokio::sync::mpsc::unbounded_channel();
let status = service_control_handler::register(SERVICE_NAME, move |control| match control {
ServiceControl::Stop | ServiceControl::Shutdown | ServiceControl::Pause | ServiceControl::Continue => {
let _ = controls.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})?;
report(&status, ServiceState::StartPending, false)?;
let served = crate::runtime()?.block_on(async {
let hosts = match listen(&status).await {
Ok(hosts) => hosts,
Err(error) => {
telemetry::send().await;
return Err(error);
}
};
// Only pausing lifts the block; a stop or crash keeps it, so traffic fails closed.
while let Some(control) = received.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts(hosts) {
Ok(()) => report(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
// A failed stop is what makes the service manager restart it.
report(&status, ServiceState::Stopped, served.is_err())?;
served
}
async fn listen(status: &ServiceStatusHandle) -> Result<Vec<&'static str>, BoxError> {
telemetry::heartbeat();
// Never the hosts file, which points back at us; the Firewall's lookups included.
let mut options = default_resolver_opts();
options.use_hosts_file = ResolveHosts::Never;
let resolver = HickoryDnsResolver::try_new_system_with_options(options)?;
let _ = try_init_global_dns_resolver(resolver.clone());
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = TpmKey::load_or_create().inspect_err(|_| telemetry::error("tpm"))?;
let signer: mitm::CaSigner = Arc::new(key);
let ca = certificate_authority(&hosts, &*signer).inspect_err(|_| telemetry::error("ca"))?;
let exec = Executor::default();
// An empty CRL for the leaves to point at, signed fresh for each fetch.
let (crl_hosts, crl_signer) = (hosts.clone(), Arc::clone(&signer));
let crl = HttpServer::auto(exec.clone()).service(service_fn(move |req: Request| {
let crl = match &*req.uri().request_target() {
"/ca.crl" => mitm::crl(&crl_hosts, &*crl_signer).map(|der| reply(200, "application/pkix-crl", der)),
_ => Ok(text(404, "No route.\n")),
};
async move { Ok::<_, Infallible>(crl.unwrap_or_else(|error| text(503, &format!("{error}\n")))) }
}));
tokio::spawn(bind(CRL_ADDRESS, 80, &exec)?.serve(crl));
let crl_url = format!("http://{CRL_ADDRESS}/ca.crl");
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in redirects(&hosts) {
for port in [80, 443] {
let upstream = Upstream { host, port, resolver: resolver.clone(), service: service.clone() };
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(bind(address, port, &exec)?.serve(upstream));
}
}
// Rewritten on every start, so a restart picks up a changed host list.
write_hosts(&hosts)?;
tracing::info!(?hosts, "screening registry traffic redirected by the hosts file");
report(status, ServiceState::Running, false)?;
Ok(hosts)
}
fn bind(address: Ipv4Addr, port: u16, exec: &Executor) -> std::io::Result<TcpListener> {
let bound = std::net::TcpListener::bind((address, port))?;
bound.set_nonblocking(true)?;
TcpListener::try_from_std_tcp_listener(bound, exec.clone())
}
/// Each listener is bound to one host's address, which is how it knows where to connect.
#[derive(Clone)]
struct Upstream {
host: &'static str,
port: u16,
resolver: HickoryDnsResolver,
service: mitm::MitmService,
}
impl Service<TcpStream> for Upstream {
type Output = ();
type Error = BoxError;
async fn serve(&self, ingress: TcpStream) -> Result<(), BoxError> {
let domain = Domain::from_static(self.host);
let address: IpAddr = match self.resolver.lookup_ipv4_first(domain.clone()).await {
Some(Ok(v4)) => v4.into(),
_ => self.resolver.lookup_ipv6_first(domain).await.ok_or(self.host)??.into(),
};
let egress = tokio::net::TcpStream::connect((address, self.port)).await?;
self.service.serve(BridgeIo(ingress, TcpStream::from(egress))).await
}
}
fn report(status: &ServiceStatusHandle, state: ServiceState, failed: bool) -> windows_service::Result<()> {
status.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: match state {
ServiceState::Running | ServiceState::Paused => {
ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN | ServiceControlAccept::PAUSE_CONTINUE
}
_ => ServiceControlAccept::empty(),
},
exit_code: if failed { ServiceExitCode::ServiceSpecific(1) } else { ServiceExitCode::Win32(0) },
checkpoint: 0,
wait_hint: Duration::from_secs(30),
process_id: None,
})
}
+172
View File
@@ -0,0 +1,172 @@
use super::wide;
use rama::error::BoxError;
use rama::tls::boring::core::bn::BigNum;
use rama::tls::boring::core::ecdsa::EcdsaSig;
use rama::tls::boring::core::sha::sha256;
use rcgen::{Error, PublicKeyData, SignatureAlgorithm, SigningKey, PKCS_ECDSA_P256_SHA256};
use std::io;
use std::ptr;
use tracing::warn;
use windows_sys::core::{HRESULT, PCWSTR};
use windows_sys::Win32::Foundation::NTE_BAD_KEYSET;
use windows_sys::Win32::Security::Cryptography::*;
const KEY_NAME: &str = "Strix Firewall CA";
/// The TPM, then software for machines without one.
const PROVIDERS: [PCWSTR; 2] = [MS_PLATFORM_CRYPTO_PROVIDER, MS_KEY_STORAGE_PROVIDER];
/// The private key never leaves the TPM, or, without one, a non-exportable machine key.
#[derive(Default)]
pub struct TpmKey {
key: usize,
provider: usize,
/// The uncompressed point, which is what rcgen writes into a SubjectPublicKeyInfo.
public: Vec<u8>,
}
// NCrypt handles may be used from any thread.
unsafe impl Send for TpmKey {}
unsafe impl Sync for TpmKey {}
impl TpmKey {
/// A machine key, so the service and an elevated installer share it.
pub fn load_or_create() -> Result<Self, BoxError> {
let flags = NCRYPT_MACHINE_KEY_FLAG;
if let Some(key) = PROVIDERS.into_iter().find_map(|p| open(p, KEY_NAME, flags).transpose()) {
return Ok(key?);
}
let key = create(false, KEY_NAME, flags).or_else(|error| {
warn!(%error, "no TPM key, falling back to a software key");
create(true, KEY_NAME, flags)
});
Ok(key?)
}
pub fn delete() -> Result<(), BoxError> {
for provider in PROVIDERS {
if let Some(key) = open(provider, KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
key.destroy()?;
}
}
Ok(())
}
/// Deletes the key itself, not just this handle to it.
fn destroy(mut self) -> io::Result<()> {
check(unsafe { NCryptDeleteKey(std::mem::take(&mut self.key), 0) })
}
}
impl Drop for TpmKey {
fn drop(&mut self) {
for handle in [self.key, self.provider].into_iter().filter(|&h| h != 0) {
unsafe { NCryptFreeObject(handle) };
}
}
}
impl PublicKeyData for TpmKey {
fn der_bytes(&self) -> &[u8] {
&self.public
}
fn algorithm(&self) -> &'static SignatureAlgorithm {
&PKCS_ECDSA_P256_SHA256
}
}
impl SigningKey for TpmKey {
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, Error> {
let digest = sha256(msg);
let (mut raw, mut len) = ([0u8; 64], 0);
let flags = NCRYPT_SILENT_FLAG;
check(unsafe {
NCryptSignHash(self.key, ptr::null(), digest.as_ptr(), 32, raw.as_mut_ptr(), 64, &mut len, flags)
})
.map_err(|_| Error::RemoteKeyError)?;
// NCrypt returns r || s; an X.509 signature holds DER SEQUENCE { r, s }.
let (r, s) = raw[..len as usize].split_at(len as usize / 2);
let sig = BigNum::from_slice(r)
.and_then(|r| EcdsaSig::from_private_components(r, BigNum::from_slice(s)?))
.and_then(|sig| sig.to_der());
sig.map_err(|_| Error::RemoteKeyError)
}
}
/// `None` if the provider is absent or holds no such key.
fn open(provider: PCWSTR, name: &str, flags: u32) -> io::Result<Option<TpmKey>> {
let mut key = TpmKey::default();
if check(unsafe { NCryptOpenStorageProvider(&mut key.provider, provider, 0) }).is_err() {
return Ok(None);
}
let name = wide(name);
match unsafe { NCryptOpenKey(key.provider, &mut key.key, name.as_ptr(), 0, flags | NCRYPT_SILENT_FLAG) } {
NTE_BAD_KEYSET => Ok(None),
status => {
check(status)?;
key.public = public_point(key.key)?;
Ok(Some(key))
}
}
}
fn create(software: bool, name: &str, flags: u32) -> io::Result<TpmKey> {
let mut key = TpmKey::default();
check(unsafe { NCryptOpenStorageProvider(&mut key.provider, PROVIDERS[software as usize], 0) })?;
let (algorithm, name) = (NCRYPT_ECDSA_P256_ALGORITHM, wide(name));
check(unsafe { NCryptCreatePersistedKey(key.provider, &mut key.key, algorithm, name.as_ptr(), 0, flags) })?;
// TPM keys cannot be exported at all; a software key must be told not to allow it.
if software {
let policy = 0u32.to_ne_bytes();
check(unsafe {
NCryptSetProperty(key.key, NCRYPT_EXPORT_POLICY_PROPERTY, policy.as_ptr(), 4, NCRYPT_PERSIST_FLAG)
})?;
}
check(unsafe { NCryptFinalizeKey(key.key, NCRYPT_SILENT_FLAG) })?;
key.public = public_point(key.key)?;
Ok(key)
}
/// 0x04 || X || Y, from a BCRYPT_ECCKEY_BLOB header followed by X and Y.
fn public_point(key: usize) -> io::Result<Vec<u8>> {
let (mut blob, mut len) = ([0u8; 8 + 64], 0);
let format = BCRYPT_ECCPUBLIC_BLOB;
check(unsafe { NCryptExportKey(key, 0, format, ptr::null(), blob.as_mut_ptr(), 72, &mut len, 0) })?;
if blob[..4] != BCRYPT_ECDSA_PUBLIC_P256_MAGIC.to_le_bytes() || len != 72 {
return Err(io::Error::other("key is not an ECDSA P-256 key"));
}
Ok([&[0x04], &blob[8..]].concat())
}
fn check(status: HRESULT) -> io::Result<()> {
match status {
0 => Ok(()),
status => Err(io::Error::from_raw_os_error(status)),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::mitm::CertificateAuthority;
/// Per-user keys; the TPM one is skipped on machines without a TPM.
#[test]
fn keys_sign_a_verifiable_ca() {
for software in [true, false] {
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
let key = match create(software, &name, 0) {
Ok(key) => key,
Err(error) if !software => return eprintln!("skipping, no TPM key: {error}"),
Err(error) => panic!("create a software key: {error}"),
};
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
let spki = key.subject_public_key_info();
key.destroy().expect("delete the test key");
let ca = ca.expect("mint a ca");
let public = ca.crt.public_key().expect("ca public key");
assert!(ca.crt.verify(&public).expect("verify the ca"));
assert_eq!(public.public_key_to_der().unwrap(), spki);
}
}
}
+62
View File
@@ -0,0 +1,62 @@
use super::ca::distrust;
use super::tpm::TpmKey;
use super::*;
use std::io::ErrorKind;
use std::time::{Duration, Instant};
use tracing::info;
use windows_service::service::{ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
use windows_sys::Win32::Foundation::*;
/// Reverses `install` in the opposite order, and does nothing for what is already gone.
pub fn uninstall() -> Result<(), BoxError> {
// Stop redirecting first, so nothing reaches the service as it goes.
write_hosts(&[])?;
stop_service(true)?;
for (name, value) in environment() {
if registry_get(ENVIRONMENT_KEY, name)? == Some(value) {
LOCAL_MACHINE.create(ENVIRONMENT_KEY)?.remove_value(name)?;
}
}
broadcast_environment();
distrust(None)?;
found(LOCAL_MACHINE.remove_tree(REGISTRY_KEY))?;
TpmKey::delete()?;
for dir in [data_dir(), service_exe().parent().expect("service_exe has a directory").into()] {
match std::fs::remove_dir_all(dir) {
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
_ => {}
}
}
info!("removed the firewall, its CA and its key");
Ok(())
}
/// Waits until it has stopped, so its binary can be replaced; `delete` also unregisters it.
pub(super) fn stop_service(delete: bool) -> Result<(), BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS | ServiceAccess::STOP | ServiceAccess::DELETE;
let is = |error: &windows_service::Error, code: u32| {
matches!(error, windows_service::Error::Winapi(e) if e.raw_os_error() == Some(code as i32))
};
let service = match manager.open_service(SERVICE_NAME, access) {
Err(error) if is(&error, ERROR_SERVICE_DOES_NOT_EXIST) => return Ok(()),
service => service?,
};
match service.stop() {
Err(error) if is(&error, ERROR_SERVICE_NOT_ACTIVE) => {}
stopped => _ = stopped?,
}
let deadline = Instant::now() + Duration::from_secs(30);
while service.query_status()?.current_state != ServiceState::Stopped {
if Instant::now() > deadline {
return Err("the service did not stop".into());
}
std::thread::sleep(Duration::from_millis(250));
}
match delete.then(|| service.delete()) {
Some(Err(error)) if !is(&error, ERROR_SERVICE_MARKED_FOR_DELETE) => Err(error.into()),
_ => Ok(()),
}
}
+1 -22
View File
@@ -1,9 +1,7 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::{aliasing, Firewall, FirewallConfig};
use firewall::Firewall;
use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target);
@@ -37,25 +35,6 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None);
}
#[test]
fn a_plain_http_upstream_is_not_upgraded() {
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = upstream.local_addr().expect("address");
std::thread::spawn(move || {
let (mut stream, _) = upstream.accept().expect("accept");
let _ = stream.read(&mut [0; 4096]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
});
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
}
#[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec();
+21 -1
View File
@@ -1,10 +1,19 @@
use firewall::aliasing::managers::*;
use firewall::aliasing::Invocation;
use firewall::Firewall;
#[test]
fn arguments_after_a_double_dash_reach_the_program() {
let typed = ["run", "--", "--release"].map(String::from).to_vec();
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000");
assert_eq!(args.first().map(String::as_str), Some("run"));
assert_eq!(args[args.len() - 2..], ["--", "--release"]);
}
#[test]
fn recipes_expand_into_the_environment() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let npm = variables("http://127.0.0.1:9000", &NPM);
let npm = variables("http://127.0.0.1:9000");
assert!(npm.contains(&var("npm_config_proxy", "http://127.0.0.1:9000")));
assert!(npm.contains(&var("npm_config_registry", "http://registry.npmjs.org/")));
let gem = arguments("http://127.0.0.1:9000", &GEM, &["install".to_string()]);
@@ -12,6 +21,17 @@ fn recipes_expand_into_the_environment() {
assert!(arguments("http://127.0.0.1:9000", &GEM, &["sources".to_string()]).is_empty());
}
#[test]
fn no_two_managers_set_a_variable_differently() {
let mut seen = std::collections::HashMap::new();
for manager in MANAGERS {
for (key, value) in manager.env {
let first = *seen.entry(key).or_insert(value);
assert_eq!(first, value, "{} sets {key} differently", manager.command);
}
}
}
#[tokio::test]
async fn every_overridden_registry_is_known() {
let hosts = Firewall::default().hosts();
+7
View File
@@ -43,3 +43,10 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
}
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
#[test]
fn ca_signs_an_empty_crl() {
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
}
+1 -1
View File
@@ -36,7 +36,7 @@ pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
fn run(probe: &Probe, port: u16) -> Outcome {
let args = probe.args.iter().map(|arg| arg.to_string()).collect();
let dir = probe.setup.and_then(|prepare| prepare());
match aliasing::run(port, &Invocation::new(probe.manager, args), dir.as_deref()) {
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref()) {
Ok(0) => Outcome::Ok,
Ok(_) => Outcome::Failed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,