mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 13:31:32 -10:00
Compare commits
8
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5814a3088 | ||
|
|
0c1b39c351 | ||
|
|
34be4bb352 | ||
|
|
5839e240ab | ||
|
|
b0580ca7ad | ||
|
|
d84c34706f | ||
|
|
674f3bdcd3 | ||
|
|
2df02bf23f |
No files matched your search
+16
-18
@@ -1,4 +1,4 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use crate::policies::registries::BUILT_IN;
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
@@ -20,9 +20,8 @@ pub struct FirewallConfig {
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
|
||||
pub registries: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -52,7 +51,7 @@ impl Default for FirewallConfig {
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
registries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -87,7 +86,8 @@ impl FirewallConfig {
|
||||
};
|
||||
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
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 {
|
||||
unreachable!("a config parses only from an object");
|
||||
};
|
||||
@@ -99,18 +99,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> {
|
||||
@@ -129,3 +117,13 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// What serde's types cannot say.
|
||||
fn check(config: &FirewallConfig) -> Result<(), String> {
|
||||
for name in config.registries.keys() {
|
||||
if !BUILT_IN.iter().any(|r| r.name() == name) {
|
||||
return Err(format!("no built-in registry is named {name}"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+28
-7
@@ -9,6 +9,7 @@ use std::collections::HashMap;
|
||||
|
||||
pub struct Firewall {
|
||||
pub config: FirewallConfig,
|
||||
registries: Vec<&'static Registry>,
|
||||
patterns: HashMap<&'static str, Vec<Regex>>,
|
||||
filters: Vec<Box<dyn Filter>>,
|
||||
}
|
||||
@@ -21,18 +22,19 @@ impl Default for Firewall {
|
||||
|
||||
impl Firewall {
|
||||
pub fn new(config: FirewallConfig) -> Self {
|
||||
let registries = with_mirrors(&config.registries);
|
||||
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));
|
||||
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 mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
}
|
||||
Firewall { config, patterns, filters }
|
||||
Firewall { config, registries, patterns, filters }
|
||||
}
|
||||
|
||||
pub fn match_on<'a>(
|
||||
@@ -40,20 +42,27 @@ impl Firewall {
|
||||
url: &'a str,
|
||||
key: fn(&Registry) -> &'static 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> {
|
||||
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 {
|
||||
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 {
|
||||
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 {
|
||||
@@ -64,3 +73,15 @@ impl Firewall {
|
||||
&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
|
||||
}
|
||||
@@ -3,6 +3,7 @@
|
||||
use super::*;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::*;
|
||||
@@ -14,6 +15,7 @@ use windows_service::service::{Service, ServiceAccess, ServiceState};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
const MENU_PAUSE: &str = "pause";
|
||||
const MENU_RESTART: &str = "restart";
|
||||
const MENU_LOG: &str = "log";
|
||||
const MENU_QUIT: &str = "quit";
|
||||
const LOGO: &[u8] = include_bytes!("tray.png");
|
||||
@@ -31,6 +33,7 @@ pub fn run() -> Result<(), BoxError> {
|
||||
&status,
|
||||
&PredefinedMenuItem::separator(),
|
||||
&pause,
|
||||
&MenuItem::with_id(MENU_RESTART, "Restart", true, None),
|
||||
&MenuItem::with_id(MENU_LOG, "Open logs", true, None),
|
||||
&PredefinedMenuItem::separator(),
|
||||
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
|
||||
@@ -123,6 +126,11 @@ impl Tray {
|
||||
|| Ok(open_service()?.resume().map(drop)?)
|
||||
}
|
||||
MENU_PAUSE => || Ok(open_service()?.start::<&str>(&[])?),
|
||||
MENU_RESTART => {
|
||||
// Off the UI thread, since stopping takes a while.
|
||||
std::thread::spawn(|| restart().unwrap_or_else(show_error));
|
||||
return;
|
||||
}
|
||||
MENU_LOG => {
|
||||
let log = HSTRING::from(data_dir().as_path());
|
||||
let none = PCWSTR::null();
|
||||
@@ -131,13 +139,15 @@ impl Tray {
|
||||
}
|
||||
_ => return,
|
||||
};
|
||||
if let Err(error) = action() {
|
||||
let text = HSTRING::from(error.to_string());
|
||||
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
|
||||
}
|
||||
action().unwrap_or_else(show_error);
|
||||
}
|
||||
}
|
||||
|
||||
fn show_error(error: BoxError) {
|
||||
let text = HSTRING::from(error.to_string());
|
||||
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
|
||||
}
|
||||
|
||||
/// `None` when the service is not installed.
|
||||
fn service_state() -> Option<ServiceState> {
|
||||
let manager =
|
||||
@@ -166,10 +176,28 @@ fn status_text(state: Option<ServiceState>) -> &'static str {
|
||||
|
||||
fn open_service() -> Result<Service, BoxError> {
|
||||
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
|
||||
let access = ServiceAccess::START | ServiceAccess::PAUSE_CONTINUE;
|
||||
let access = ServiceAccess::QUERY_STATUS
|
||||
| ServiceAccess::START
|
||||
| ServiceAccess::STOP
|
||||
| ServiceAccess::PAUSE_CONTINUE;
|
||||
Ok(manager.open_service(SERVICE_NAME, access)?)
|
||||
}
|
||||
|
||||
/// Starting again re-reads the config.
|
||||
fn restart() -> Result<(), BoxError> {
|
||||
let service = open_service()?;
|
||||
if service.query_status()?.current_state != ServiceState::Stopped {
|
||||
service.stop()?;
|
||||
}
|
||||
for _ in 0..60 {
|
||||
if service.query_status()?.current_state == ServiceState::Stopped {
|
||||
return Ok(service.start::<&str>(&[])?);
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(500));
|
||||
}
|
||||
Err("The service did not stop.".into())
|
||||
}
|
||||
|
||||
/// The alpha channel of the RGBA logo, and its width.
|
||||
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
|
||||
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct Pattern {
|
||||
}
|
||||
|
||||
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registry {
|
||||
pub prefix: &'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.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Published {
|
||||
pub url: &'static str,
|
||||
pub pointer: &'static str,
|
||||
@@ -63,6 +63,11 @@ impl Registry {
|
||||
pub fn host(&self) -> &'static str {
|
||||
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.
|
||||
|
||||
@@ -38,6 +38,21 @@ fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
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]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// 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