Compare commits

..
Author SHA1 Message Date
Ian 219765e4ff Open the folder of dated service logs from the tray 2026-10-22 16:20:11 -04:00
Ian 8b0d734f02 Show screening status in the Windows notification area
firewall-tray.exe shows a tray icon: whether the service is screening,
paused or not running, with Pause/Resume and Open log. The MSI
starts it at every sign-in and closes it before replacing or removing it.

The icon is the Strix logo, white on a dark taskbar and black on a light
one like the macOS template image, and dimmed when not screening.
tray.png is assets/logo-transparent.png scaled and padded to 32x32:
sips -Z 32 logo-transparent.png --out a.png && sips -p 32 32 a.png --out tray.png
2026-10-22 16:20:08 -04:00
Ian 10fdd0f272 Install machine-wide screening on Windows with an MSI
The MSI installs both binaries, registers and starts the service with a
DACL that lets interactive users start, stop and pause it, restarts it on
failure, and sets NODE_EXTRA_CA_CERTS and UV_SYSTEM_CERTS. Removing it runs
`firewall-service.exe uninstall` once the service is deleted, which clears
the hosts entries and removes the CA, its TPM key and the data folder but
the config.
2026-10-22 15:23:40 -04:00
11 changed files with 40 additions and 219 deletions

No files matched your search

+5 -9
View File
@@ -89,16 +89,12 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}
info!(method = %req.method(), %url, "forwarding");
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten.
let tls = firewall.is_https_url(&url);
let index = tls && firewall.is_index_url(&url);
let index = firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts();
if tls {
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
}
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
let response = match client.serve(Request::from_parts(parts, body)).await {
+18 -16
View File
@@ -1,4 +1,4 @@
use crate::policies::registries::BUILT_IN;
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{Map, Value};
@@ -20,8 +20,9 @@ pub struct FirewallConfig {
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
pub registries: HashMap<String, Vec<String>>,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
#[derive(Debug, Deserialize)]
@@ -51,7 +52,7 @@ impl Default for FirewallConfig {
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: HashMap::new(),
registries: BUILT_IN.to_vec(),
}
}
}
@@ -86,8 +87,7 @@ impl FirewallConfig {
};
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
FirewallConfig::deserialize(&layer).map_err(invalid)?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
@@ -99,6 +99,18 @@ impl FirewallConfig {
pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port, path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
}
pub fn path() -> Option<PathBuf> {
@@ -117,13 +129,3 @@ 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(())
}
+6 -32
View File
@@ -9,7 +9,6 @@ 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>>,
}
@@ -22,19 +21,18 @@ 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 &registries {
for registry in &config.registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = registries.iter().map(|r| r.osv_name);
let ecosystems = config.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, registries, patterns, filters }
Firewall { config, patterns, filters }
}
pub fn match_on<'a>(
@@ -42,27 +40,15 @@ impl Firewall {
url: &'a str,
key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> {
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
}
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 is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
self.config.hosts()
}
pub fn is_index_url(&self, url: &str) -> bool {
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -73,15 +59,3 @@ 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
}
+5 -33
View File
@@ -3,7 +3,6 @@
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::*;
@@ -15,7 +14,6 @@ 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");
@@ -33,7 +31,6 @@ 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),
@@ -126,11 +123,6 @@ 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();
@@ -139,15 +131,13 @@ impl Tray {
}
_ => return,
};
action().unwrap_or_else(show_error);
if let Err(error) = action() {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
}
}
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 =
@@ -176,28 +166,10 @@ 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::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::PAUSE_CONTINUE;
let access = ServiceAccess::START | 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()?;
+2 -7
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
}
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug, Clone)]
#[derive(Debug)]
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, Clone)]
#[derive(Debug)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
@@ -63,11 +63,6 @@ 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.
-15
View File
@@ -38,21 +38,6 @@ 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.
+1 -22
View File
@@ -1,9 +1,7 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::{aliasing, Firewall, FirewallConfig};
use firewall::Firewall;
use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target);
@@ -37,25 +35,6 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None);
}
#[test]
fn a_plain_http_upstream_is_not_upgraded() {
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = upstream.local_addr().expect("address");
std::thread::spawn(move || {
let (mut stream, _) = upstream.accept().expect("accept");
let _ = stream.read(&mut [0; 4096]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
});
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
}
#[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec();
-7
View File
@@ -162,12 +162,6 @@
Execute='deferred'
Impersonate='no'
Return='check'/>
<!-- From the exit dialog, which runs as the signed-in user rather than elevated. -->
<CustomAction
Id='StartTray'
Directory='APPLICATIONFOLDER'
ExeCommand='"[#exe2]"'
Return='asyncNoWait'/>
<!-- Before its file is replaced or removed. Trays WM_CLOSE does not reach, as in other sessions, are terminated. -->
<util:CloseApplication Id='CloseTray' Target='firewall-tray.exe' CloseMessage='yes' RebootPrompt='no' TerminateProcess='0'/>
<InstallExecuteSequence>
@@ -186,7 +180,6 @@
<UIRef Id='WixUI_FeatureTree'/>
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
<Publish Dialog='ExitDialog' Control='Finish' Event='DoAction' Value='StartTray' Order='1'>NOT Installed</Publish>
</UI>
</Product>
-66
View File
@@ -1,66 +0,0 @@
# 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.
-1
View File
@@ -47,7 +47,6 @@ build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+3 -11
View File
@@ -10,7 +10,7 @@ settings:
"CARGO_PROFILE[config=Debug]": debug
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE)
"LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
ARCHS: arm64 x86_64
ARCHS: arm64
"ONLY_ACTIVE_ARCH[config=Debug]": "YES"
SWIFT_VERSION: "5.0"
DEVELOPMENT_TEAM: ${TEAM}
@@ -127,13 +127,5 @@ targets:
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
bins=
for arch in $ARCHS; do
case $arch in
arm64|arm64e) triple=aarch64-apple-darwin ;;
x86_64) triple=x86_64-apple-darwin ;;
esac
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
done
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
cargo build --bin firewall-uninstall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall-uninstall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"