Compare commits

..
Author SHA1 Message Date
Ian 1bba5f6a0b Build the macOS app for Intel Macs too 2026-10-22 16:40:39 -04:00
17 changed files with 32 additions and 392 deletions

No files matched your search

+1 -8
View File
@@ -38,17 +38,14 @@ 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 +87,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"
@@ -125,7 +118,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_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_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+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"),
)
}
-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)?)
}
-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 -23
View File
@@ -11,7 +11,6 @@ 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.
@@ -66,39 +65,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)
}
+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));
}
}
+5 -37
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,19 +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 = 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 =
@@ -180,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()?;
+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
View File
@@ -7,7 +7,6 @@ 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"]
-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 -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 --features machine-wide --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
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"