Compare commits

...
Author SHA1 Message Date
Ian e9b8176b7e Let an allow entry lift only the age check, never the malware checks 2026-10-22 17:51:08 -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
9 changed files with 96 additions and 14 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_Environment", "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_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"
+10 -2
View File
@@ -1,5 +1,7 @@
pub mod forward;
pub mod managers;
#[cfg(windows)]
mod windows;
pub use forward::spawn;
pub use managers::*;
@@ -34,7 +36,8 @@ impl Invocation {
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let mut command = Command::new(resolve(&invocation.program)?);
let program = resolve(&invocation.program)?;
let mut command = Command::new(&program);
command.args(invocation.arguments(&proxy));
if let Some(dir) = dir {
command.current_dir(dir);
@@ -42,7 +45,12 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
for (key, value) in managers::variables(&proxy) {
command.env(key, value);
}
Ok(command.status()?.code().unwrap_or(1))
#[cfg(windows)]
windows::prepare();
let status = command.status();
#[cfg(windows)]
let status = status.map_err(|error| windows::explain(error, &program));
Ok(status?.code().unwrap_or(1))
}
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
+64
View File
@@ -0,0 +1,64 @@
use std::io::{Error, ErrorKind};
use std::path::Path;
use std::sync::Once;
use tracing::debug;
use windows::core::{BOOL, PCWSTR};
use windows::Win32::System::Console::{SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT};
use windows::Win32::System::JobObjects::{
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
};
use windows::Win32::System::Threading::GetCurrentProcess;
/// Called before spawning, so the manager behaves as it would run from the shell directly.
/// Once per process: a caller may run several managers.
pub fn prepare() {
static PREPARED: Once = Once::new();
PREPARED.call_once(|| {
// The console sends Ctrl+C to the manager too. Exiting first would hand the prompt back
// while a .cmd shim still asks "Terminate batch job (Y/N)?", so wait for its exit instead.
// A handler, unlike ignoring the signal outright, is not inherited by the manager.
let _ = unsafe { SetConsoleCtrlHandler(Some(ignore_interrupt), true) };
if let Err(error) = kill_children_on_exit() {
debug!(%error, "the manager may outlive the firewall");
}
});
}
unsafe extern "system" fn ignore_interrupt(event: u32) -> BOOL {
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT).into()
}
/// Joins a job that kills what is left in it once the firewall exits, even if killed itself.
/// The manager inherits the job when spawned, as do its own children.
fn kill_children_on_exit() -> windows::core::Result<()> {
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
// Never closed: the handle closing as the firewall exits is what kills the rest.
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
unsafe {
SetInformationJobObject(
job,
JobObjectExtendedLimitInformation,
(&raw const limits).cast(),
size_of_val(&limits) as u32,
)?
};
unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }
}
/// Rust refuses to pass a line break to a batch file, where it would cut the command short.
pub fn explain(error: Error, program: &Path) -> Error {
let batch = program
.extension()
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
if !batch || error.kind() != ErrorKind::InvalidInput {
return error;
}
let name = program.file_name().unwrap_or(program.as_os_str()).to_string_lossy();
Error::new(
ErrorKind::InvalidInput,
format!("{name} is a batch file, which cannot be passed an argument with a line break"),
)
}
+1 -1
View File
@@ -17,7 +17,7 @@ pub struct FirewallConfig {
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
/// Packages exempt from the age check, 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.
+1 -1
View File
@@ -162,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 -3
View File
@@ -25,16 +25,18 @@ pub trait Filter: Send + Sync {
}
/// Asks every filter. A `Deny` wins; failing that, the first filter that could not tell.
/// An allowed package skips only the age check, so malware is never let through.
pub async fn consult(
firewall: &Firewall,
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
return None;
}
let allowed = allowed(firewall, registry, pkg);
let mut unsure = None;
for filter in firewall.filters() {
if allowed && filter.name() == "age" {
continue;
}
match filter.check(registry, pkg).await {
Verdict::Allow => continue,
Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))),
+5 -5
View File
@@ -39,14 +39,14 @@ fn a_later_file_wins_and_allow_lists_add_up() {
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
async fn an_allowed_version_is_still_checked_for_malware() {
// The unreachable denylist shows that it still runs.
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
let pkg = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
let verdict = consult(&firewall, registry, &pkg).await;
assert_eq!(verdict.map(|(filter, _)| filter), Some("denylist"));
}
#[tokio::test]
+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"]
+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"