mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
7
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0f863acd94 | ||
|
|
ca7a899d02 | ||
|
|
0c1b39c351 | ||
|
|
34be4bb352 | ||
|
|
5839e240ab | ||
|
|
b0580ca7ad | ||
|
|
d84c34706f |
No files matched your search
+53
-23
@@ -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
@@ -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 ®istries {
|
||||||
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
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
Reference in new issue
Block a user