Compare commits

...
Author SHA1 Message Date
Ian 0f863acd94 Reject config values the firewall cannot use 2026-10-22 18:02:11 -04:00
ian-at-strix ca7a899d02 Screen mirrors listed in the config file (#58) 2026-10-22 18:02:08 -04:00
Ian 0c1b39c351 Make the platforms doc easier to read 2026-10-22 17:39:21 -04:00
Ian 34be4bb352 Document Intel Macs and how to change machine-wide settings 2026-10-22 17:35:31 -04:00
Ian 5839e240ab Document that cargo install installs only the command 2026-10-22 17:35:31 -04:00
Ian b0580ca7ad Document the Windows tray and who can pause screening 2026-10-22 16:49:04 -04:00
Ian Graham Martinez d84c34706f Document the supported platforms
Which OS versions, architectures and package managers each mode supports,
and what machine-wide screening on Windows needs and does not cover.
2026-10-22 16:49:04 -04:00
7 changed files with 194 additions and 35 deletions

No files matched your search

+53 -23
View File
@@ -1,9 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN}; use crate::policies::registries::{host_of, BUILT_IN};
use directories::ProjectDirs; use directories::ProjectDirs;
use rama::net::uri::Uri;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{Map, Value}; use serde_json::{Map, Value};
use std::collections::HashMap; use std::collections::HashMap;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64};
use std::path::PathBuf; use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080; pub const DEFAULT_PORT: u16 = 8080;
@@ -11,7 +13,7 @@ pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct FirewallConfig { pub struct FirewallConfig {
pub port: u16, pub port: NonZeroU16,
/// Allow a package when a filter could not check it. /// Allow a package when a filter could not check it.
pub fail_open: bool, pub fail_open: bool,
/// 0 turns the age check off. /// 0 turns the age check off.
@@ -20,9 +22,8 @@ pub struct FirewallConfig {
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`. /// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>, pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool, pub telemetry: bool,
/// Not describable in the file yet, so this is always the built-in table. /// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
#[serde(skip)] pub registries: HashMap<String, Vec<String>>,
pub registries: Vec<&'static Registry>,
} }
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
@@ -40,19 +41,19 @@ pub struct DenylistSource {
pub base: String, pub base: String,
/// Polled for a new commit, which re-fetches every list in use. /// Polled for a new commit, which re-fetches every list in use.
pub revision: String, pub revision: String,
pub poll_secs: u64, pub poll_secs: NonZeroU64,
} }
impl Default for FirewallConfig { impl Default for FirewallConfig {
fn default() -> Self { fn default() -> Self {
FirewallConfig { FirewallConfig {
port: DEFAULT_PORT, port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
fail_open: true, fail_open: true,
min_age_hours: 0, min_age_hours: 0,
denylist: DenylistSource::default(), denylist: DenylistSource::default(),
allow: HashMap::new(), allow: HashMap::new(),
telemetry: true, telemetry: true,
registries: BUILT_IN.to_vec(), registries: HashMap::new(),
} }
} }
} }
@@ -63,7 +64,7 @@ impl Default for DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(), base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main" revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
.into(), .into(),
poll_secs: 10, poll_secs: NonZeroU64::new(10).unwrap(),
} }
} }
} }
@@ -87,7 +88,8 @@ impl FirewallConfig {
}; };
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())); let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?; let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
FirewallConfig::deserialize(&layer).map_err(invalid)?; let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
let Value::Object(layer) = layer else { let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object"); unreachable!("a config parses only from an object");
}; };
@@ -97,19 +99,7 @@ impl FirewallConfig {
} }
pub fn local_url(&self, path: &str) -> String { pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port, path) crate::local_url(self.port.get(), 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
} }
} }
@@ -129,3 +119,43 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
} }
} }
} }
/// What serde's types cannot say.
fn check(config: &FirewallConfig) -> Result<(), String> {
// `Duration::hours` panics past this.
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
return Err(format!("{} hours is too long", config.min_age_hours));
}
for eco in config.allow.keys() {
if !BUILT_IN.iter().any(|r| r.eco == eco) {
return Err(format!("unknown ecosystem {eco:?} in allow"));
}
}
for url in [&config.denylist.base, &config.denylist.revision] {
if parse_url(url, &["http", "https"]).is_none() {
return Err(format!("{url:?} is not an http(s) URL"));
}
}
for (name, mirrors) in &config.registries {
if !BUILT_IN.iter().any(|r| r.name() == name) {
return Err(format!("no built-in registry is named {name}"));
}
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
}
}
Ok(())
}
/// No port or userinfo, as `host_of` is what gets intercepted.
fn is_mirror(url: &str) -> bool {
let Some(uri) = parse_url(url, &["https"]) else { return false };
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
}
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
let uri = Uri::parse(url).ok()?;
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
}
+28 -7
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
pub struct Firewall { pub struct Firewall {
pub config: FirewallConfig, pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>, patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>, filters: Vec<Box<dyn Filter>>,
} }
@@ -21,18 +22,19 @@ impl Default for Firewall {
impl Firewall { impl Firewall {
pub fn new(config: FirewallConfig) -> Self { pub fn new(config: FirewallConfig) -> Self {
let registries = with_mirrors(&config.registries);
let mut patterns = HashMap::new(); let mut patterns = HashMap::new();
for registry in &config.registries { for registry in &registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re)); let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect()); patterns.insert(registry.prefix, compiled.collect());
} }
let ecosystems = config.registries.iter().map(|r| r.osv_name); let ecosystems = registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems); let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)]; let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 { if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours))); filters.push(Box::new(Age::new(config.min_age_hours)));
} }
Firewall { config, patterns, filters } Firewall { config, registries, patterns, filters }
} }
pub fn match_on<'a>( pub fn match_on<'a>(
@@ -40,20 +42,27 @@ impl Firewall {
url: &'a str, url: &'a str,
key: fn(&Registry) -> &'static str, key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> { ) -> Option<(&'static Registry, &'a str)> {
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest))) self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
} }
pub fn hosts(&self) -> Vec<&'static str> { pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts() 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 is_https_url(&self, url: &str) -> bool { pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url); let host = host_of(url);
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host) self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
} }
pub fn is_index_url(&self, url: &str) -> bool { pub fn is_index_url(&self, url: &str) -> bool {
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url)) self.registries.iter().any(|r| r.index && r.host() == host_of(url))
} }
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved { pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -64,3 +73,15 @@ impl Firewall {
&self.filters &self.filters
} }
} }
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
let mut registries = BUILT_IN.to_vec();
for built_in in BUILT_IN {
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
let upstream = upstream.clone().leak();
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
}
}
registries
}
+1 -1
View File
@@ -143,7 +143,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick. /// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) { async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs); let period = Duration::from_secs(source.poll_secs.get());
let mut seen = revision(&source, None).await; let mut seen = revision(&source, None).await;
loop { loop {
tokio::time::sleep(period).await; tokio::time::sleep(period).await;
+7 -2
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
} }
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`. /// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Registry { pub struct Registry {
pub prefix: &'static str, pub prefix: &'static str,
pub eco: &'static str, pub eco: &'static str,
@@ -52,7 +52,7 @@ pub struct Registry {
} }
/// A URL to fetch and a JSON pointer to the timestamp in its reply. /// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)] #[derive(Debug, Clone)]
pub struct Published { pub struct Published {
pub url: &'static str, pub url: &'static str,
pub pointer: &'static str, pub pointer: &'static str,
@@ -63,6 +63,11 @@ impl Registry {
pub fn host(&self) -> &'static str { pub fn host(&self) -> &'static str {
host_of(self.upstream) host_of(self.upstream)
} }
/// What the config file calls it.
pub fn name(&self) -> &'static str {
self.prefix.trim_matches('/')
}
} }
/// The host of an https:// URL, with the path dropped. /// The host of an https:// URL, with the path dropped.
+1 -1
View File
@@ -15,7 +15,7 @@ use std::sync::Arc;
use tracing::{info, info_span, warn, Instrument}; use tracing::{info, info_span, warn, Instrument};
pub fn serve(config: FirewallConfig) -> std::io::Result<()> { pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?; let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
bound.set_nonblocking(true)?; bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move { crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config)); let firewall = Arc::new(Firewall::new(config));
+38 -1
View File
@@ -7,7 +7,7 @@ use firewall::{Firewall, FirewallConfig};
fn a_file_only_has_to_set_what_it_changes() { fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses"); let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open); assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT); assert_eq!(config.port.get(), firewall::DEFAULT_PORT);
} }
#[test] #[test]
@@ -15,6 +15,28 @@ fn a_misspelled_setting_is_an_error() {
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err()); assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
} }
#[test]
fn nonsense_values_are_errors() {
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
for bad in [
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
r#"{"denylist": {"poll_secs": 0}}"#,
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
r#"{"denylist": {"revision": "https://"}}"#,
r#"{"port": 0}"#,
r#"{"min_age_hours": 18446744073709551615}"#,
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
] {
std::fs::write(&file, bad).expect("write");
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
}
std::fs::remove_file(&file).ok();
}
#[test] #[test]
fn the_config_lives_under_the_config_directory() { fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory"); let path = path().expect("a home directory");
@@ -38,6 +60,21 @@ fn a_later_file_wins_and_allow_lists_add_up() {
assert_eq!(names, ["a", "b"]); assert_eq!(names, ["a", "b"]);
} }
#[tokio::test]
async fn a_mirror_is_screened_like_the_registry_it_copies() {
let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
assert!(firewall.hosts().contains(&"npm.example.com"));
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
std::fs::remove_file(&unknown).ok();
assert!(loaded.is_err());
}
#[tokio::test] #[tokio::test]
async fn an_allowed_version_skips_the_filters() { async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked. // An unreachable denylist would otherwise leave the package unchecked.
+66
View File
@@ -0,0 +1,66 @@
# Platforms
## Requirements
| | Command prefix | Machine-wide |
|---|---|---|
| macOS | 13+, Apple silicon or Intel | Apple silicon, or an Intel Mac with a T2 chip |
| Windows | 10 or 11, x86-64 | A TPM 2.0, and an admin to install |
| Linux | glibc, x86-64 or arm64 | Not supported |
There are no musl, 32-bit or Windows on Arm builds.
`cargo install strix-firewall` installs only `firewall`. Add `--features machine-wide` for the
machine-wide binaries, which otherwise come with the macOS app and the Windows MSI.
## What is screened
npm, pnpm, bun, pip, pip3, pipenv, pdm, uv, cargo, go and gem, downloading from:
| Ecosystem | Hosts |
|---|---|
| npm | registry.npmjs.org |
| PyPI | pypi.org, files.pythonhosted.org |
| crates.io | index.crates.io, static.crates.io, crates.io |
| RubyGems | rubygems.org |
| Go | proxy.golang.org |
Machine-wide screening also covers mirrors listed in the config file, under the registry they
copy (`npm`, `pypi`, `pypi-files`, `cargo/index`, `cargo-dl`, `crates-api`, `gem` or `go`):
`"registries": {"npm": ["https://npm.example.com/"]}`. A mirror must lay out its paths like that
registry. `firewall <manager>` still sends managers to the public registries.
## How machine-wide screening works
Traffic to the hosts above is decrypted with a local CA that can only sign for those hosts. Its
private key never leaves the Secure Enclave (macOS) or the TPM (Windows).
- **macOS:** a system extension, which you approve in System Settings.
- **Windows:** a service, with a hosts-file entry pointing each registry host at it.
- Without a TPM, the service can't start, so the install fails.
- Any signed-in user can pause screening from the notification-area icon, until they resume
it or the service restarts.
- Clients that use an HTTP proxy or their own DNS skip the hosts file and aren't screened.
Nor is WSL.
- Each client has to trust the CA:
| Client | Trusts it through |
|---|---|
| npm, pnpm, bun | `NODE_EXTRA_CA_CERTS`, set to `%ProgramData%\Strix Firewall\ca.pem` |
| uv | `UV_SYSTEM_CERTS`, which uses the Windows certificate store |
| pip, cargo | The Windows certificate store |
| gem, go | Untested |
Tested on Windows 11 and Apple silicon only.
## Changing settings
Machine-wide screening reads an admin-only JSON file when it starts. After editing it, restart
screening. An invalid file stops screening from starting.
| OS | File | Restart from |
|---|---|---|
| macOS | `/Library/Application Support/Firewall/config.json` | The menu bar's Restart |
| Windows | `%ProgramData%\Strix Firewall\config.json` | The notification-area icon's Restart |
The `firewall` command reads `config.json` from the user's config folder instead.