Compare commits

..
Author SHA1 Message Date
Ian 4fc0d64771 Move the macOS uninstall out of the CLI into its own binary 2026-10-22 15:09:36 -04:00
29 changed files with 52 additions and 1103 deletions

No files matched your search

+1 -168
View File
@@ -261,15 +261,6 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.8.1"
@@ -678,16 +669,6 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -699,12 +680,6 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.18.0"
@@ -776,15 +751,6 @@ dependencies = [
"getrandom 0.4.3",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
@@ -805,7 +771,7 @@ checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide 0.9.1",
"miniz_oxide",
"zlib-rs",
]
@@ -1486,16 +1452,6 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -1639,16 +1595,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1690,24 +1636,6 @@ dependencies = [
"uuid",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1814,66 +1742,6 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -1996,19 +1864,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -3375,9 +3230,7 @@ dependencies = [
"clap",
"core-foundation 0.10.1",
"directories",
"muda",
"percent-encoding",
"png",
"rama",
"rand",
"rcgen",
@@ -3393,7 +3246,6 @@ dependencies = [
"tracing",
"tracing-appender",
"tracing-subscriber",
"tray-icon",
"which",
"windows",
"windows-registry",
@@ -3800,25 +3652,6 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "tray-icon"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
dependencies = [
"crossbeam-channel",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
+11 -24
View File
@@ -3,7 +3,6 @@ name = "strix-firewall"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
authors = ["Strix"]
description = "Screens package downloads for known malware before they reach your machine"
repository = "https://github.com/usestrix/firewall"
homepage = "https://github.com/usestrix/firewall"
@@ -11,20 +10,14 @@ homepage = "https://github.com/usestrix/firewall"
# as an integration test; new tests/*.rs need an explicit [[test]] entry.
autotests = false
# Only Windows ships the service and tray.
# Only Windows ships the service.
[package.metadata.dist.binaries]
x86_64-pc-windows-msvc = ["firewall", "firewall-service", "firewall-tray"]
x86_64-pc-windows-msvc = ["firewall", "firewall-service"]
aarch64-apple-darwin = ["firewall"]
x86_64-apple-darwin = ["firewall"]
aarch64-unknown-linux-gnu = ["firewall"]
x86_64-unknown-linux-gnu = ["firewall"]
[package.metadata.wix]
upgrade-guid = "D944C2F6-BF55-4B21-A60E-1BBDB1A66293"
path-guid = "033B1050-23D0-4F5A-A0D3-F4757B4B67E0"
license = false
eula = false
[lib]
name = "firewall"
# The macOS extension links a staticlib, built on demand:
@@ -38,17 +31,10 @@ 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"
@@ -90,10 +76,6 @@ 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,14 +100,19 @@ security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
muda = { version = "0.21.2", default-features = false }
png = "0.18.1"
rama = { version = "0.4.0", features = ["dns-hickory"] }
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_Storage_FileSystem", "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_System_Com",
"Win32_System_Environment",
"Win32_UI_Shell",
] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+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 {
+2 -10
View File
@@ -1,7 +1,5 @@
pub mod forward;
pub mod managers;
#[cfg(windows)]
mod windows;
pub use forward::spawn;
pub use managers::*;
@@ -36,8 +34,7 @@ impl Invocation {
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let program = resolve(&invocation.program)?;
let mut command = Command::new(&program);
let mut command = Command::new(resolve(&invocation.program)?);
command.args(invocation.arguments(&proxy));
if let Some(dir) = dir {
command.current_dir(dir);
@@ -45,12 +42,7 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
for (key, value) in managers::variables(&proxy) {
command.env(key, value);
}
#[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))
Ok(command.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
@@ -1,64 +0,0 @@
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"),
)
}
+5 -16
View File
@@ -2,25 +2,14 @@
#[cfg(windows)]
fn main() {
// Run by the MSI as it removes the firewall.
if std::env::args().nth(1).as_deref() == Some("uninstall") {
firewall::init_logging("info");
if let Err(e) = firewall::os::windows::uninstall() {
fail("uninstall", &format!("could not uninstall: {e}"))
}
} else if let Err(e) = firewall::os::windows::run_service() {
fail("service", &format!("could not run the service: {e}"))
if let Err(e) = firewall::os::windows::run_service() {
tracing::error!("could not run the service: {e}");
firewall::telemetry::error("service");
firewall::telemetry::flush();
std::process::exit(1)
}
}
#[cfg(windows)]
fn fail(kind: &'static str, message: &str) -> ! {
tracing::error!("{message}");
firewall::telemetry::error(kind);
firewall::telemetry::flush();
std::process::exit(1)
}
/// The service is Windows-only.
#[cfg(not(windows))]
fn main() {}
-11
View File
@@ -1,11 +0,0 @@
//! The notification-area icon, started at sign-in. A GUI app, so no console opens with it.
#![windows_subsystem = "windows"]
#[cfg(windows)]
fn main() -> Result<(), rama::error::BoxError> {
firewall::os::windows::run_tray()
}
/// The tray is Windows-only.
#[cfg(not(windows))]
fn main() {}
-36
View File
@@ -1,36 +0,0 @@
use crate::policies::Package;
use serde::Serialize;
use std::sync::Mutex;
/// Bounded, so blocks pile up harmlessly while nothing collects them.
const KEPT: usize = 50;
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new());
#[derive(Serialize)]
pub struct BlockedPackage {
pub eco: &'static str,
pub name: String,
pub version: Option<String>,
pub filter: &'static str,
pub reason: String,
}
pub fn record(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) {
let mut recent = RECENT.lock().unwrap_or_else(|e| e.into_inner());
if recent.len() == KEPT {
recent.remove(0);
}
recent.push(BlockedPackage {
eco,
name: pkg.name.clone(),
version: pkg.version.clone(),
filter,
reason: reason.to_owned(),
});
}
/// Hands each block over once.
pub fn take() -> Vec<BlockedPackage> {
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner()))
}
+3 -31
View File
@@ -1,10 +1,10 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{json, Map, Value};
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
@@ -48,7 +48,7 @@ impl Default for FirewallConfig {
FirewallConfig {
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 24,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
@@ -129,31 +129,3 @@ 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)?)
}
-5
View File
@@ -47,11 +47,6 @@ impl Firewall {
self.config.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)
}
pub fn is_index_url(&self, url: &str) -> bool {
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
-1
View File
@@ -1,5 +1,4 @@
pub mod aliasing;
pub mod blocks;
pub mod config;
pub mod firewall;
pub mod http;
+3 -23
View File
@@ -4,8 +4,8 @@ mod uninstall;
pub use uninstall::uninstall;
use crate::config::{self, FirewallConfig};
use crate::{blocks, mitm, telemetry};
use crate::config::FirewallConfig;
use crate::{mitm, telemetry};
use crate::Firewall;
use rama::bytes::Bytes;
use rama::error::BoxError;
@@ -22,7 +22,6 @@ 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,12 +58,7 @@ impl TransparentProxyHandler for Handler {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
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}"),
},
other => format!("error: unknown message {other}"),
}))
}
@@ -123,20 +117,6 @@ 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 {
+8 -38
View File
@@ -7,12 +7,9 @@ use schannel::cert_context::CertContext;
use schannel::cert_store::{CertAdd, CertStore};
use std::io;
use std::path::Path;
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.
const DATA_DIR_ACL: &str = concat!(
@@ -23,9 +20,6 @@ const DATA_DIR_ACL: &str = concat!(
"(A;OICI;FRFX;;;BU)", // allow Users read and execute only
);
/// 1970 in FILETIME, which counts 100 ns from 1601.
const FILETIME_UNIX_EPOCH: u128 = 116_444_736_000_000_000;
/// Trusted machine-wide so restarts keep it; replaced if the hosts change.
pub fn load_or_create_ca(
hosts: &[&str],
@@ -40,16 +34,6 @@ pub fn load_or_create_ca(
LOCAL_MACHINE
.create(REGISTRY_KEY)?
.set_string("ca", ca.to_blob(hosts))?;
// Windows discards CRLs it cached before now, in case an old CA's is cached at our URL.
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() / 100
+ FILETIME_UNIX_EPOCH;
let chain = r"SOFTWARE\Microsoft\Cryptography\OID\EncodingType 0\CertDllCreateCertificateChainEngine\Config";
let resync = (now as u64).to_le_bytes();
LOCAL_MACHINE.create(chain)?.set_bytes(
"ChainCacheResyncFiletime",
Type::Bytes,
&resync,
)?;
ca
}
};
@@ -66,39 +50,25 @@ pub fn load_or_create_ca(
Ok(ca)
}
/// Created on first use with its ACL, which is reset every time. One a user made first, or a link,
/// Created on first use, its owner and ACL 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();
let exists = match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => {
std::fs::remove_dir(&dir)?;
false
}
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
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)?;
false
std::fs::remove_dir_all(&dir)?
}
Ok(_) => true,
Err(_) => false,
};
_ => {}
}
std::fs::create_dir_all(&dir)?;
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)?;
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))? };
}
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
Ok(dir)
}
-4
View File
@@ -4,13 +4,9 @@
mod ca;
mod service;
mod tpm;
mod tray;
mod uninstall;
pub use service::run as run_service;
pub use tpm::CaKey;
pub use tray::run as run_tray;
pub use uninstall::uninstall;
use rama::error::BoxError;
use std::net::Ipv4Addr;
+8 -23
View File
@@ -26,7 +26,6 @@ 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,
@@ -74,12 +73,8 @@ fn run_until_stopped() -> Result<(), BoxError> {
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
// 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)
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -94,26 +89,16 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some((control, reply)) = control_receiver.recv().await {
while let Some(control) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
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);
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
@@ -162,7 +147,7 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
resolver: resolver.clone(),
service: service.clone(),
};
let upstream = ConsumeErrLayer::trace_as_warning().into_layer(upstream);
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
}
-7
View File
@@ -27,13 +27,6 @@ impl CaKey {
}
}
pub(super) fn delete_machine_key() -> Result<(), BoxError> {
if let Some(key) = open_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
key.delete_from_tpm()?;
}
Ok(())
}
/// A per-user key under a random name, which needs no elevation; `delete_from_tpm` it after.
pub fn create_test_key() -> WinResult<Self> {
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

-227
View File
@@ -1,227 +0,0 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
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::*;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*;
use windows_registry::CURRENT_USER;
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");
/// Runs until Quit, once per session.
pub fn run() -> Result<(), BoxError> {
// Held until we exit.
let _mutex = unsafe { CreateMutexW(None, false, w!(r"Local\StrixFirewallTray"))? };
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
return Ok(());
}
let status = MenuItem::new("", false, None);
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
let menu = Menu::with_items(&[
&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),
])?;
let (logo, logo_size) = logo_mask()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
logo,
logo_size,
drawn: None,
status,
pause,
state: None,
};
tray.update();
unsafe { SetTimer(None, 0, 2000, None) };
let mut message = MSG::default();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe { DispatchMessageW(&message) };
// WM_CLOSE, as the MSI sends, destroys the icon's window.
if !unsafe { IsWindow(Some(window)) }.as_bool() {
break;
}
// Ours, not the icon window's own hover timer.
if message.message == WM_TIMER && message.hwnd.is_invalid() {
tray.update();
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
// The menu opens on release, so this keeps it current.
if let TrayIconEvent::Click {
button_state: MouseButtonState::Down,
..
} = event
{
tray.update();
}
}
while let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == MENU_QUIT {
return Ok(());
}
tray.on_menu(event.id.as_ref());
}
}
Ok(())
}
struct Tray {
icon: TrayIcon,
/// The logo's alpha channel; it is square.
logo: Vec<u8>,
logo_size: u32,
/// Whether the taskbar was light and the service screening when the icon was last set.
drawn: Option<(bool, bool)>,
status: MenuItem,
pause: MenuItem,
/// As the menu last showed it.
state: Option<ServiceState>,
}
impl Tray {
fn update(&mut self) {
self.state = service_state();
let screening = self.state == Some(ServiceState::Running);
let light = light_taskbar();
if self.drawn != Some((light, screening)) {
let rgba = logo_rgba(&self.logo, light, screening);
if let Ok(icon) = Icon::from_rgba(rgba, self.logo_size, self.logo_size) {
let _ = self.icon.set_icon(Some(icon));
}
self.drawn = Some((light, screening));
}
let text = status_text(self.state);
let tip = format!("Strix Firewall: {text}");
let _ = self.icon.set_tooltip(Some(tip));
self.status.set_text(text);
let pause = if screening { "Pause" } else { "Resume" };
self.pause.set_text(pause);
self.pause.set_enabled(self.state.is_some());
}
fn on_menu(&self, id: &str) {
let state = self.state;
let screening = state == Some(ServiceState::Running);
let action: fn() -> Result<(), BoxError> = match id {
MENU_PAUSE if screening => || Ok(open_service()?.pause().map(drop)?),
MENU_PAUSE if state == Some(ServiceState::Paused) => {
|| 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();
unsafe { ShellExecuteW(None, w!("open"), &log, none, none, SW_SHOWNORMAL) };
return;
}
_ => return,
};
action().unwrap_or_else(show_error);
}
}
fn show_error(error: BoxError) {
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) };
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?;
Some(service.query_status().ok()?.current_state)
}
fn light_taskbar() -> bool {
let personalize = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
let Ok(key) = CURRENT_USER.open(personalize) else {
return false;
};
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1)
}
fn status_text(state: Option<ServiceState>) -> &'static str {
match state {
Some(ServiceState::Running) => "Screening downloads",
Some(ServiceState::Paused) => "Paused",
_ => "Not running",
}
}
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;
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()?;
let mut rgba = vec![0; reader.output_buffer_size().ok_or("logo too large")?];
let info = reader.next_frame(&mut rgba)?;
let mut alpha = Vec::with_capacity(rgba.len() / 4);
for pixel in rgba.chunks(4) {
alpha.push(pixel[3]);
}
Ok((alpha, info.width))
}
/// White on a dark taskbar and black on a light one, as macOS draws a template image; dimmed
/// when not screening.
fn logo_rgba(mask: &[u8], light: bool, screening: bool) -> Vec<u8> {
let shade = if light { 0 } else { 255 };
let mut rgba = Vec::with_capacity(mask.len() * 4);
for &alpha in mask {
let alpha = if screening { alpha } else { alpha / 2 };
rgba.extend([shade, shade, shade, alpha]);
}
rgba
}
-41
View File
@@ -1,41 +0,0 @@
use super::ca::remove_our_cas_except;
use super::tpm::CaKey;
use super::*;
use std::io::ErrorKind;
use std::path::Path;
use tracing::info;
/// Run by the MSI after it deletes the service, which would restore the hosts entries. Does
/// nothing for what is already gone.
pub fn uninstall() -> Result<(), BoxError> {
write_hosts_entries(&[])?;
remove_our_cas_except(None)?;
none_if_missing(LOCAL_MACHINE.remove_tree(REGISTRY_KEY))?;
CaKey::delete_machine_key()?;
let entries = match std::fs::read_dir(data_dir()) {
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
entries => entries?.collect::<Result<Vec<_>, _>>()?,
};
for path in entries.into_iter().map(|entry| entry.path()) {
// The admin's config stays for a reinstall, as on macOS.
if path != config_file() {
remove_path(&path)?;
}
}
// Fails while the config is in it.
let _ = std::fs::remove_dir(data_dir());
info!("removed the CA, its key and the data folder");
Ok(())
}
/// A file or a folder, if it is there.
fn remove_path(path: &Path) -> std::io::Result<()> {
let removed = match path.is_dir() {
true => std::fs::remove_dir_all(path),
false => std::fs::remove_file(path),
};
match removed {
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
removed => removed,
}
}
+1 -2
View File
@@ -69,8 +69,7 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
};
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
match consult(firewall, registry, &pkg).await {
Some((filter, Verdict::Deny(reason))) => {
crate::blocks::record(registry.eco, &pkg, filter, &reason);
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
-15
View File
@@ -57,18 +57,3 @@ 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 -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();
-193
View File
@@ -1,193 +0,0 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi' xmlns:util='http://schemas.microsoft.com/wix/UtilExtension'>
<Product
Id='*'
Name='strix-firewall'
UpgradeCode='D944C2F6-BF55-4B21-A60E-1BBDB1A66293'
Manufacturer='Strix'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Screens package downloads for known malware before they reach your machine'
Manufacturer='Strix'
InstallerVersion='500'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='strix-firewall Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='Strix Firewall'>
<Component Id='Path' Guid='033B1050-23D0-4F5A-A0D3-F4757B4B67E0' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[APPLICATIONFOLDER]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='firewall.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall.exe'
KeyPath='yes'/>
</Component>
<Component Id='binary1' Guid='*'>
<File
Id='exe1'
Name='firewall-service.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall-service.exe'
KeyPath='yes'/>
<ServiceInstall
Name='StrixFirewall'
DisplayName='Strix Firewall'
Description='Screens package downloads for known malware'
Type='ownProcess'
Start='auto'
ErrorControl='normal'
Vital='yes'>
<!-- The default, except that interactive users (IU) may also start (RP), stop (WP) and pause (DT) it, as the tray does. -->
<PermissionEx Sddl='D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)'/>
</ServiceInstall>
<!-- Waits, so a service that cannot start fails the install. -->
<ServiceControl Id='StrixFirewall' Name='StrixFirewall' Start='install' Stop='both' Remove='uninstall' Wait='yes'/>
<!-- For clients that read no trust store. SSL_CERT_FILE would replace OpenSSL's bundle. -->
<Environment Id='NODE_EXTRA_CA_CERTS' Name='NODE_EXTRA_CA_CERTS' Value='[CommonAppDataFolder]Strix Firewall\ca.pem' Action='set' System='yes'/>
<Environment Id='UV_SYSTEM_CERTS' Name='UV_SYSTEM_CERTS' Value='1' Action='set' System='yes'/>
</Component>
<Component Id='binary2' Guid='*'>
<File
Id='exe2'
Name='firewall-tray.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall-tray.exe'
KeyPath='yes'/>
<RegistryValue
Root='HKLM'
Key='SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
Name='Strix Firewall'
Type='string'
Value='"[#exe2]"'/>
</Component>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs the firewall and its service.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<ComponentRef Id='binary0'/>
<ComponentRef Id='binary1'/>
<ComponentRef Id='binary2'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<!-- Removing the app must not leave the hosts file pointing at a service that is gone. -->
<CustomAction
Id='UninstallFirewall'
FileKey='exe1'
ExeCommand='uninstall'
Execute='deferred'
Impersonate='no'
Return='check'/>
<!--
Registry hosts lead nowhere while the service is down, so bring it back, also when it fails to
start. Through sc.exe, as MsiServiceConfigFailureActions cannot restart a service.
-->
<CustomAction
Id='SetFailureActions'
Directory='APPLICATIONFOLDER'
ExeCommand='"[System64Folder]sc.exe" failure StrixFirewall reset= 3600 actions= restart/5000/restart/5000/restart/5000'
Execute='deferred'
Impersonate='no'
Return='check'/>
<CustomAction
Id='SetFailureFlag'
Directory='APPLICATIONFOLDER'
ExeCommand='"[System64Folder]sc.exe" failureflag StrixFirewall 1'
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>
<!-- An upgrade removes the old version too, but should keep screening. -->
<Custom Action='UninstallFirewall' After='DeleteServices'>REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action='SetFailureActions' After='InstallServices'>NOT REMOVE~="ALL"</Custom>
<Custom Action='SetFailureFlag' After='SetFailureActions'>NOT REMOVE~="ALL"</Custom>
<!-- The default, before InstallFiles, is after RemoveFiles, which would find the tray still running. -->
<Custom Action='WixCloseApplications' Before='RemoveFiles'/>
</InstallExecuteSequence>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<Property Id='ARPHELPLINK' Value='https://github.com/usestrix/firewall'/>
<UI>
<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>
</Wix>
+1 -4
View File
@@ -4,10 +4,7 @@ members = ["cargo:core"]
[dist]
cargo-dist-version = "0.32.0"
ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
features = ["machine-wide"]
installers = ["shell", "powershell", "homebrew"]
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"]
-57
View File
@@ -1,57 +0,0 @@
import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let eco: String
let name: String
let version: String?
let filter: String
let reason: String
var id: String { "\(eco)/\(name)@\(version ?? "")" }
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 {
/// 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 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)
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
}
}
}
}
-41
View File
@@ -29,13 +29,11 @@ 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)\"'"
}
@@ -53,7 +51,6 @@ final class FirewallModel: NSObject, ObservableObject {
@Published private(set) var completed = false
@Published private(set) var removed = false
@Published private(set) var screening = false
@Published private(set) var blocked: [BlockedPackage] = []
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
@@ -75,7 +72,6 @@ final class FirewallModel: NSObject, ObservableObject {
await refresh()
if CommandLine.arguments.contains("--install") { await install() }
}
Task { await watchBlocks() }
}
// MARK: - Flows
@@ -203,43 +199,6 @@ 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 {
if screening, let reply = try? await providerMessage("blocks"),
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
for package in packages where !blocked.contains(package) {
blocked.append(package)
if BlockAlert.show(package) { await trust(package) }
}
blocked = Array(blocked.suffix(10))
}
try? await Task.sleep(nanoseconds: 2_000_000_000)
}
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
-19
View File
@@ -22,24 +22,5 @@ struct MenuBarView: View {
Task { await model.restart() }
}
.disabled(model.busy)
if !model.blocked.isEmpty {
Divider()
ForEach(model.blocked) { package in
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))") {
Text("Malicious: \(package.reason)")
}
}
}
}
}
}
-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 --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"
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/"