Compare commits

..
Author SHA1 Message Date
Ian 9cbb31e15c Create the Windows data folder with its ACL instead of setting it after 2026-10-22 18:11:08 -04:00
ian-at-strix 91da9d68c9 Let an admin trust a held-back version of a new package (#36) 2026-10-22 17:57:31 -04:00
ian-at-strixandIan Graham Martinez e0b723a732 Keep a Windows manager tied to the firewall that runs it (#5)
On Windows the console sends Ctrl+C to both processes. The firewall
exited at once with 0xC000013A, handing the prompt back while a .cmd
shim's cmd.exe still asked "Terminate batch job (Y/N)?". It now ignores
Ctrl+C and Ctrl+Break while the manager runs and exits with its code.

A killed firewall also left the manager and its children running. The
firewall now joins a kill-on-close job before spawning, so whatever is
left dies with it.

An argument with a line break, which Rust refuses to pass to a batch
file, now says so instead of "batch file arguments are invalid".

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-22 17:42:27 -04:00
ian-at-strix fc02226cd6 Build the machine-wide binaries only with the machine-wide feature (#67) 2026-10-22 17:38:23 -04:00
ian-at-strix 5068b3578d Log the Windows service's connection failures at warn (#66) 2026-10-22 17:37:08 -04:00
ian-at-strix 8d781f3f98 Show a popup when pausing or resuming screening fails (#54)
The service now answers Pause and Continue only after rewriting the hosts
file, returning the write's error so the tray's ControlService call fails
and shows it. The tray's popup includes the error's source, which
windows-service's own message hides.
2026-10-22 17:33:56 -04:00
12 changed files with 185 additions and 28 deletions

No files matched your search

+8 -1
View File
@@ -38,14 +38,17 @@ path = "src/main.rs"
[[bin]]
name = "firewall-service"
path = "src/bin/firewall-service.rs"
required-features = ["machine-wide"]
[[bin]]
name = "firewall-tray"
path = "src/bin/firewall-tray.rs"
required-features = ["machine-wide"]
[[bin]]
name = "firewall-uninstall"
path = "src/bin/firewall-uninstall.rs"
required-features = ["machine-wide"]
[[example]]
name = "proxy-test"
@@ -87,6 +90,10 @@ path = "tests/tpm.rs"
name = "managers"
path = "tests/managers.rs"
[features]
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
machine-wide = []
[dependencies]
clap = "4.6.6"
directories = "6.0.0"
@@ -118,7 +125,7 @@ schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+30 -2
View File
@@ -1,10 +1,10 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{Map, Value};
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub const DEFAULT_PORT: u16 = 8080;
@@ -129,3 +129,31 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
}
}
}
/// Adds an `allow` entry to the file, leaving the rest of it as written.
pub fn allow(path: &Path, eco: &str, name: &str, version: Option<&str>) -> std::io::Result<()> {
let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let mut config = match std::fs::read_to_string(path) {
Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?,
Err(e) if e.kind() == ErrorKind::NotFound => json!({}),
Err(e) => return Err(e),
};
if !config.is_object() {
return Err(invalid("not an object".into()));
}
let entry = json!({ "name": name, "version": version });
let entries = &mut config["allow"][eco];
if entries.is_null() {
*entries = Value::Array(Vec::new());
}
let Some(entries) = entries.as_array_mut() else {
return Err(invalid(format!("allow.{eco} is not a list")));
};
if !entries.contains(&entry) {
entries.push(entry);
}
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
std::fs::write(path, serde_json::to_string_pretty(&config)?)
}
+21 -2
View File
@@ -4,7 +4,7 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::config::{self, FirewallConfig};
use crate::{blocks, mitm, telemetry};
use crate::Firewall;
use rama::bytes::Bytes;
@@ -22,6 +22,7 @@ use rama::net::apple::networkextension::{transparent_proxy_ffi, NwTcpStream, Tcp
use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service};
use serde::Deserialize;
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
@@ -59,7 +60,11 @@ impl TransparentProxyHandler for Handler {
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
other => format!("error: unknown message {other}"),
other => match other.strip_prefix("allow ").map(allow) {
Some(Ok(())) => "ok".to_owned(),
Some(Err(error)) => format!("error: {error}"),
None => format!("error: unknown message {other}"),
},
}))
}
@@ -118,6 +123,20 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
Ok(Handler { ca, hosts, service })
}
#[derive(Deserialize)]
struct AllowRequest {
eco: String,
name: String,
version: Option<String>,
}
/// Takes effect when the provider restarts, which rebuilds it from the config.
fn allow(request: &str) -> Result<(), BoxError> {
let request: AllowRequest = serde_json::from_str(request)?;
config::allow(CONFIG_FILE.as_ref(), &request.eco, &request.name, request.version.as_deref())?;
Ok(())
}
/// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
+23 -8
View File
@@ -11,6 +11,7 @@ use std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::*;
use windows::Win32::Storage::FileSystem::CreateDirectoryW;
use windows_registry::Type;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
@@ -65,25 +66,39 @@ pub fn load_or_create_ca(
Ok(ca)
}
/// Created on first use, its owner and ACL reset every time. One a user made first, or a link,
/// Created on first use with its ACL, which is reset every time. One a user made first, or a link,
/// is replaced: its owner could grant itself access again, and a link would redirect our writes.
pub(super) fn secure_data_dir() -> io::Result<PathBuf> {
let dir = data_dir();
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
let exists = match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => {
std::fs::remove_dir(&dir)?;
false
}
Ok(_) if !owned_by_admins(&dir)? => {
warn!(dir = %dir.display(), "replacing a data folder not created by an admin");
std::fs::remove_dir_all(&dir)?
std::fs::remove_dir_all(&dir)?;
false
}
_ => {}
}
std::fs::create_dir_all(&dir)?;
Ok(_) => true,
Err(_) => false,
};
let info = OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION;
let path = HSTRING::from(dir.as_path());
let acl = security_descriptor(DATA_DIR_ACL)?;
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
if exists {
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
} else {
let attributes = SECURITY_ATTRIBUTES {
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: acl.0,
bInheritHandle: false.into(),
};
// Never inherits ProgramData's ACL, which lets users add files; fails if it was recreated.
unsafe { CreateDirectoryW(&path, Some(&attributes))? };
}
Ok(dir)
}
+23 -8
View File
@@ -26,6 +26,7 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows::Win32::Foundation::ERROR_GEN_FAILURE;
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
@@ -73,8 +74,12 @@ fn run_until_stopped() -> Result<(), BoxError> {
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
// Answered once handled, so a failed pause fails the caller's ControlService.
let (reply, handled) = tokio::sync::oneshot::channel();
let _ = control_sender.send((control, reply));
handled
.blocking_recv()
.unwrap_or(ServiceControlHandlerResult::NoError)
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -89,16 +94,26 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some(control) = control_receiver.recv().await {
while let Some((control, reply)) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
let result = match write_hosts_entries(hosts) {
Ok(()) => {
set_status(&status, state, false)?;
ServiceControlHandlerResult::NoError
}
Err(error) => {
tracing::error!(%error, "could not rewrite the hosts file");
let code = error
.raw_os_error()
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
ServiceControlHandlerResult::Other(code)
}
};
let _ = reply.send(result);
}
Ok::<_, BoxError>(())
});
@@ -147,7 +162,7 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
resolver: resolver.clone(),
service: service.clone(),
};
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
let upstream = ConsumeErrLayer::trace_as_warning().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
}
+5 -1
View File
@@ -144,7 +144,11 @@ impl Tray {
}
fn show_error(error: BoxError) {
let text = HSTRING::from(error.to_string());
let text = match error.source() {
Some(source) => format!("{error}: {source}"),
None => error.to_string(),
};
let text = HSTRING::from(text);
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
+15
View File
@@ -57,3 +57,18 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
#[test]
fn allowing_keeps_the_rest_of_the_file() {
let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id()));
std::fs::write(&path, r#"{"fail_open": false}"#).expect("write");
for _ in 0..2 {
firewall::config::allow(&path, "npm", "left-pad", Some("1.3.0")).expect("allow");
}
let text = std::fs::read_to_string(&path).expect("read");
std::fs::remove_file(&path).ok();
let config: FirewallConfig = serde_json::from_str(&text).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.allow["npm"].len(), 1);
assert_eq!(config.allow["npm"][0].version.as_deref(), Some("1.3.0"));
}
+1
View File
@@ -7,6 +7,7 @@ ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
features = ["machine-wide"]
tap = "usestrix/homebrew-tap"
publish-jobs = ["homebrew", "./publish-crates"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
+29 -4
View File
@@ -1,4 +1,5 @@
import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let eco: String
@@ -11,22 +12,46 @@ struct BlockedPackage: Codable, Hashable, Identifiable {
var label: String { version.map { "\(name) \($0)" } ?? name }
/// The denylist and OSV only flag packages known to be malicious; the age check only holds back new ones.
var malicious: Bool { filter != "age" }
/// Malware is never allowed, and a new package only by version.
var trustable: Bool { !malicious && version != nil }
}
@MainActor
enum BlockAlert {
static func show(_ package: BlockedPackage) {
/// Returns whether the user chose to trust the package.
static func show(_ package: BlockedPackage) -> Bool {
let alert = NSAlert()
alert.addButton(withTitle: "OK")
if package.malicious {
alert.alertStyle = .critical
alert.messageText = "Blocked a malicious package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason). Do not install it."
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
alert.informativeText = "\(package.label) \(package.reason).\n\n"
+ "It was not installed. Don't install it from another source. "
+ "If you didn't expect this package, a dependency may be compromised; tell your security team."
} else {
alert.messageText = "Held back a new package"
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
if package.trustable { alert.addButton(withTitle: "Trust this version") }
}
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
return alert.runModal() == .alertSecondButtonReturn
}
}
enum AdminAuthorization {
/// The allowlist applies to every user of the Mac, so changing it takes an admin, as `sudo` would.
static func confirm() -> Bool {
var authorization: AuthorizationRef?
guard AuthorizationCreate(nil, nil, [], &authorization) == errAuthorizationSuccess,
let authorization else { return false }
defer { AuthorizationFree(authorization, []) }
return "system.preferences".withCString { name in
var item = AuthorizationItem(name: name, valueLength: 0, value: nil, flags: 0)
return withUnsafeMutablePointer(to: &item) { item in
var rights = AuthorizationRights(count: 1, items: item)
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights]
return AuthorizationCopyRights(authorization, &rights, nil, flags, nil) == errAuthorizationSuccess
}
}
}
}
+25 -1
View File
@@ -29,11 +29,13 @@ enum FirewallError: Error, LocalizedError {
case extensionUnavailable
case screeningDidNotStart(NEVPNStatus)
case extensionStopped(String)
case extensionRefused(String)
var errorDescription: String? {
switch self {
case .extensionUnavailable: return "The extension is not running"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .extensionStopped(reason):
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
}
@@ -201,6 +203,28 @@ final class FirewallModel: NSObject, ObservableObject {
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
/// Allowing a version writes it into the extension's config, which a restart then loads.
func trust(_ package: BlockedPackage) async {
guard package.trustable else { return }
guard AdminAuthorization.confirm() else {
log("Trust cancelled for \(package.label)")
return
}
do {
var request = ["eco": package.eco, "name": package.name]
request["version"] = package.version
let json = String(decoding: try JSONSerialization.data(withJSONObject: request), as: UTF8.self)
let reply = try await providerMessage("allow \(json)")
guard reply == "ok" else { throw FirewallError.extensionRefused(reply) }
blocked.removeAll { $0 == package }
log("Trusted \(package.label) (\(package.eco))")
} catch {
log("Could not trust \(package.label): \(error.localizedDescription)")
return
}
await restart()
}
/// The extension cannot message the app, so the app asks it for blocks.
private func watchBlocks() async {
while true {
@@ -208,7 +232,7 @@ final class FirewallModel: NSObject, ObservableObject {
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
BlockAlert.show(package)
if BlockAlert.show(package) { await trust(package) }
}
blocked = Array(blocked.suffix(10))
}
+4
View File
@@ -29,6 +29,10 @@ struct MenuBarView: View {
if !package.malicious {
Menu("Held back \(package.label) (\(package.eco))") {
Text(package.reason)
Button("Trust this version") {
Task { await model.trust(package) }
}
.disabled(model.busy || !package.trustable)
}
} else {
Menu("Blocked \(package.label) (\(package.eco))") {
+1 -1
View File
@@ -133,7 +133,7 @@ targets:
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)
cargo build --bin firewall-uninstall --features machine-wide --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"