Compare commits

..
Author SHA1 Message Date
Ian 13b0995b05 Forward plain-HTTP upstreams as asked
The wrapper's forward proxy upgraded every request to HTTPS and rewrote
links in index responses. Only an HTTPS registry is downgraded to reach
us, so only that should be upgraded back and rewritten; anything else
now goes out exactly as the client sent it, port included.
2026-10-09 16:53:43 -04:00
24 changed files with 131 additions and 1281 deletions

No files matched your search

+10 -326
View File
@@ -45,56 +45,6 @@ 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"
@@ -172,17 +122,6 @@ 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"
@@ -271,6 +210,12 @@ 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"
@@ -368,33 +313,6 @@ 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"
@@ -404,12 +322,6 @@ 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"
@@ -467,16 +379,6 @@ 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"
@@ -517,12 +419,6 @@ 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"
@@ -1024,78 +920,6 @@ 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"
@@ -1257,19 +1081,6 @@ 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"
@@ -1289,12 +1100,6 @@ 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"
@@ -1756,16 +1561,6 @@ 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"
@@ -1894,17 +1689,6 @@ 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"
@@ -2248,7 +2032,6 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2547,7 +2330,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation 0.10.1",
"core-foundation",
"csv",
"idna",
"ipnet",
@@ -2879,12 +2662,6 @@ 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"
@@ -3020,7 +2797,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.10.1",
"core-foundation",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3227,8 +3004,8 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"core-foundation 0.10.1",
"bpaf",
"core-foundation",
"directories",
"percent-encoding",
"rama",
@@ -3245,18 +3022,8 @@ 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"
@@ -3319,27 +3086,6 @@ 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"
@@ -3428,12 +3174,6 @@ 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"
@@ -3687,31 +3427,12 @@ 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"
@@ -3847,12 +3568,6 @@ 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"
@@ -3899,17 +3614,6 @@ 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"
@@ -3919,26 +3623,6 @@ 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"
+1 -16
View File
@@ -53,7 +53,7 @@ name = "managers"
path = "tests/managers.rs"
[dependencies]
clap = "4.6.6"
bpaf = "0.9.28"
directories = "6.0.0"
percent-encoding = "2.3.2"
rama = { version = "0.4.0", features = ["boring", "http-full", "ring", "rustls"] }
@@ -75,21 +75,6 @@ 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"] }
+10 -29
View File
@@ -1,20 +1,15 @@
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;
@@ -36,26 +31,8 @@ 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(),
tunnel,
)
(RemoveResponseHeaderLayer::hop_by_hop(), RemoveRequestHeaderLayer::hop_by_hop())
.into_layer(service_fn(move |req: Request| {
let firewall = Arc::clone(&firewall);
async move { handle(req, &firewall).await }
@@ -89,12 +66,16 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}
info!(method = %req.method(), %url, "forwarding");
let index = firewall.is_index_url(&url);
// 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 (mut parts, body) = req.into_parts();
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
if tls {
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 {
+2 -4
View File
@@ -41,10 +41,8 @@ pub struct Manager {
pub static MANAGERS: &[&Manager] =
&[&NPM, &PIP, &PIP3, &CARGO, &GO, &GEM, &UV, &PDM, &PIPENV, &PNPM, &BUN];
/// 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 variables(proxy: &str, manager: &Manager) -> Vec<(String, String)> {
manager.env.iter().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,8 +9,6 @@ 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: &[],
};
+12 -19
View File
@@ -9,37 +9,30 @@ use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Invocation {
pub program: String,
pub manager: &'static Manager,
pub args: Vec<String>,
}
impl Invocation {
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 new(manager: &'static Manager, args: Vec<String>) -> Self {
Invocation { manager, args }
}
}
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let mut command = Command::new(resolve(&invocation.program)?);
command.args(invocation.arguments(&proxy));
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);
if let Some(dir) = dir {
command.current_dir(dir);
}
for (key, value) in managers::variables(&proxy) {
for (key, value) in managers::variables(&proxy, invocation.manager) {
command.env(key, value);
}
Ok(command.status()?.code().unwrap_or(1))
-12
View File
@@ -80,18 +80,6 @@ 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> {
+13 -1
View File
@@ -40,7 +40,19 @@ impl Firewall {
}
pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts()
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)
}
pub fn is_index_url(&self, url: &str) -> bool {
+47 -64
View File
@@ -1,81 +1,64 @@
use clap::{Arg, Command};
use firewall::aliasing::{self, Invocation, MANAGERS};
use bpaf::{any, choice, pure, OptionParser, Parser};
use firewall::aliasing::{self, Invocation, Manager, 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() {
let matches = options().get_matches();
if matches.subcommand_matches("serve").is_some() {
firewall::init_logging("info");
serve()
match options().run() {
SubCommand::Serve => {
firewall::init_logging("info");
serve()
}
SubCommand::Run(invocation) => {
firewall::init_logging("warn");
run(invocation)
}
}
#[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() -> Command {
fn options() -> OptionParser<SubCommand> {
let program = program_name();
let managers: Vec<_> = MANAGERS.iter().map(|manager| manager.command).collect();
let footer = format!(
"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(", ")
"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'"
);
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
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()
}
/// 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 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)
}
fn serve() -> ! {
@@ -99,7 +82,7 @@ fn serve() -> ! {
}
fn run(invocation: Invocation) -> ! {
let command = &invocation.program;
let command = invocation.manager.command;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
Err(e) => fail("spawn", &format!("could not start the firewall: {e}")),
+6 -29
View File
@@ -1,18 +1,12 @@
use rama::error::BoxError;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
SerialNumber, SigningKey,
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyUsagePurpose, NameConstraints, SigningKey,
};
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// 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.
/// Held in the Secure Enclave on macOS, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
pub struct CertificateAuthority {
@@ -48,33 +42,16 @@ 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, COMMON_NAME);
name.push(DnType::OrganizationName, ORGANIZATION);
name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default();
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
// rcgen signs a CRL only for a CA that may.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts
.iter()
+3 -12
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, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
@@ -14,18 +14,12 @@ 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>,
crl: Option<String>,
) -> Self {
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts, crl }
Self { ca_crt, ca_params, signer, hosts }
}
}
@@ -48,9 +42,6 @@ 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()?;
+2 -4
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
pub use ca::{CaSigner, CertificateAuthority};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
@@ -25,16 +25,14 @@ 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(), crl);
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
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, None, exec);
let service = mitm::mitm_service(firewall, &ca, signer, exec);
Ok(Handler { ca, hosts, service })
}
-2
View File
@@ -1,4 +1,2 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
-106
View File
@@ -1,106 +0,0 @@
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
@@ -1,82 +0,0 @@
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
@@ -1,131 +0,0 @@
//! 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
@@ -1,177 +0,0 @@
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
@@ -1,172 +0,0 @@
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
@@ -1,62 +0,0 @@
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(()),
}
}
+22 -1
View File
@@ -1,7 +1,9 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::Firewall;
use firewall::{aliasing, Firewall, FirewallConfig};
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);
@@ -35,6 +37,25 @@ 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();
+1 -21
View File
@@ -1,19 +1,10 @@
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");
let npm = variables("http://127.0.0.1:9000", &NPM);
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()]);
@@ -21,17 +12,6 @@ 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,10 +43,3 @@ 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.command.into(), args), dir.as_deref()) {
match aliasing::run(port, &Invocation::new(probe.manager, args), dir.as_deref()) {
Ok(0) => Outcome::Ok,
Ok(_) => Outcome::Failed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,