Compare commits

..
Author SHA1 Message Date
Ian b37f93936e Show why the extension failed to start 2026-10-21 15:40:40 -04:00
ian-at-strix bcd26b91b0 Rename the Installer app to StrixFirewall (#28)
* Rename the Installer app to Firewall

* Name the app Strix Firewall

* Name the app StrixFirewall
2026-10-21 15:28:19 -04:00
Ian 70e66280f6 Log uninstall only when something was removed 2026-10-21 15:28:19 -04:00
Ian 1879e9061e Uninstall through the bundled CLI 2026-10-21 15:28:19 -04:00
Ian dcee91ef04 Remove the CA from the app, not the extension 2026-10-21 15:28:19 -04:00
ian-at-strix b1713d4576 Store config under com.usestrix.firewall (#30) 2026-10-21 15:27:59 -04:00
ian-at-strix 902a80b06c Sign debug builds with Apple Development (#27) 2026-10-21 15:11:12 -04:00
ian-at-strix 18d8a0dd93 Read the denylist from usestrix/firewall-blocklist (#26) 2026-10-21 14:06:18 -04:00
ian-at-strix d551b115c4 Add a debug build that skips notarization (#25) 2026-10-21 14:02:23 -04:00
ian-at-strix f88f6a9d8b Sign with Developer ID and notarize the package (#22)
* Sign with Developer ID and notarize the package

* Name signing identities by type

* Say how signing identities are matched

* Notarize only when NOTARY_PROFILE is set
2026-10-21 13:01:23 -04:00
Ian 1b43b9ba16 logo 2026-10-21 12:46:53 -04:00
ian-at-strix 59bb6de147 Use the com.usestrix.firewall bundle ID (#21) 2026-10-21 12:08:29 -04:00
ian-at-strix 39729cec84 Drop the GitHub token from denylist fetches (#19) 2026-10-21 12:02:53 -04:00
25 changed files with 207 additions and 151 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+3 -13
View File
@@ -6,7 +6,6 @@ use std::io::{Error, ErrorKind};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -38,19 +37,11 @@ pub struct AllowedPackage {
pub struct DenylistSource {
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
pub base: String,
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
pub token: Option<String>,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: u64,
}
impl DenylistSource {
pub fn token(&self) -> Option<String> {
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
}
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
@@ -68,9 +59,8 @@ impl Default for FirewallConfig {
impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
token: None,
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
.into(),
poll_secs: 10,
}
@@ -110,6 +100,6 @@ impl FirewallConfig {
}
pub fn path() -> Option<PathBuf> {
let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
Some(dirs.config_dir().join("config.json"))
}
+1 -1
View File
@@ -27,7 +27,7 @@ impl Firewall {
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 {
filters.push(Box::new(Age::new(config.min_age_hours)));
+13
View File
@@ -12,6 +12,10 @@ fn main() {
firewall::init_logging("info");
serve()
}
if matches.subcommand_matches("uninstall").is_some() {
firewall::init_logging("info");
uninstall()
}
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
@@ -40,6 +44,8 @@ fn options() -> Command {
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
// Run as root by the macOS app's uninstall.
.subcommand(Command::new("uninstall").hide(true))
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
@@ -69,6 +75,13 @@ fn serve() -> ! {
}
}
fn uninstall() -> ! {
match firewall::os::uninstall() {
Ok(()) => exit(0),
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
}
}
fn run(invocation: Invocation) -> ! {
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
+3 -1
View File
@@ -60,10 +60,12 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
}
pub const COMMON_NAME: &str = "Strix Firewall";
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default();
+1 -1
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+6 -8
View File
@@ -2,6 +2,8 @@ mod enclave;
mod install;
mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig;
use crate::{mitm, telemetry};
use crate::Firewall;
@@ -25,10 +27,10 @@ use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
const BUNDLE_ID: &str = "ai.strix.firewall";
const BUNDLE_ID: &str = "com.usestrix.firewall";
const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
#[derive(Clone)]
@@ -49,15 +51,11 @@ impl TransparentProxyHandler for Handler {
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
}
/// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(),
"uninstall" => match uninstall::certificate_authority(&self.ca) {
Ok(()) => "ok".to_owned(),
Err(error) => format!("error: {error}"),
},
other => format!("error: unknown message {other}"),
}))
}
@@ -113,7 +111,7 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
}
/// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
+31 -10
View File
@@ -1,26 +1,47 @@
use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::CertificateAuthority;
use crate::mitm::COMMON_NAME;
use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain;
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
use security_framework::os::macos::keychain::SecKeychain;
use security_framework_sys::base::errSecItemNotFound;
use std::io::ErrorKind;
use tracing::info;
/// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
/// Removes what only root can: every CA the extension minted, its enclave key and exported
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those.
pub fn uninstall() -> Result<(), BoxError> {
let deleted = ItemSearchOptions::new()
.class(ItemClass::certificate())
.keychains(&[SecKeychain::open("/Library/Keychains/System.keychain")?])
.label(COMMON_NAME)
.limit(Limit::All)
.delete();
let mut removed = match deleted {
Ok(()) => true,
Err(error) if error.code() == errSecItemNotFound => false,
Err(error) => return Err(error.into()),
};
if system_keychain::load_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?.is_some() {
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
removed = true;
}
EnclaveKey::delete()?;
for path in [CA_FILE, ENV_AGENT] {
match std::fs::remove_file(path) {
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
_ => {}
Ok(()) => removed = true,
Err(error) if error.kind() == ErrorKind::NotFound => {}
Err(error) => return Err(error.into()),
}
}
info!("removed the CA from the system keychain");
if removed {
info!("removed the CA from the system keychain");
} else {
info!("nothing to uninstall");
}
Ok(())
}
+9
View File
@@ -1,2 +1,11 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
+14 -29
View File
@@ -3,7 +3,7 @@ use crate::config::DenylistSource;
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use semver::Version;
@@ -37,20 +37,15 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
pub struct Denylist {
source: DenylistSource,
token: Option<String>,
lists: Arc<Lists>,
watch: JoinHandle<()>,
}
impl Denylist {
pub fn new(
source: DenylistSource,
token: Option<String>,
ecosystems: impl Iterator<Item = &'static str>,
) -> Self {
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
Denylist { source, token, lists, watch }
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
Denylist { source, lists, watch }
}
}
@@ -69,7 +64,7 @@ impl Filter for Denylist {
Box::pin(async move {
let eco = registry.osv_name;
let load = || async {
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
RwLock::new(Arc::new(load(&self.source, eco).await))
};
let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock"));
@@ -147,12 +142,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
}
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, token.as_deref(), None).await;
let mut seen = revision(&source, None).await;
loop {
tokio::time::sleep(period).await;
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
let changed = revision(&source, seen.as_deref()).await;
if let Some(current) = &changed {
info!(revision = %current, "denylist source changed");
seen = changed.clone();
@@ -163,7 +158,7 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
if changed.is_none() && !failed {
continue;
}
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
if let Ok(packages) = load(&source, eco).await {
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
}
}
@@ -171,16 +166,9 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
}
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
async fn revision(
source: &DenylistSource,
token: Option<&str>,
seen: Option<&str>,
) -> Option<String> {
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
let client = EasyHttpWebClient::default();
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
if let Some(seen) = seen {
request = request.header(IF_NONE_MATCH, seen);
}
@@ -197,8 +185,8 @@ async fn revision(
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
}
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, token, &eco.to_lowercase()).await;
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, &eco.to_lowercase()).await;
match &list {
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
Err(error) => warn!(%eco, %error, "denylist unavailable"),
@@ -206,13 +194,10 @@ async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -
list
}
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
let url = format!("{}/{eco}", source.base);
let client = EasyHttpWebClient::default();
let mut request = client.get(&url).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let request = client.get(&url).header(USER_AGENT, AGENT);
let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
-3
View File
@@ -72,9 +72,6 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
}
_ => None,
}
}
+1 -11
View File
@@ -1,6 +1,6 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::{screen, Package};
use firewall::policies::Package;
use firewall::{Firewall, FirewallConfig};
#[test]
@@ -8,7 +8,6 @@ fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT);
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
}
#[test]
@@ -32,12 +31,3 @@ async fn an_allowed_version_skips_the_filters() {
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
+2 -2
View File
@@ -3,5 +3,5 @@ Firewall.xcodeproj
.env
Extension/Extension.entitlements
Extension/Info.plist
Installer/Installer.entitlements
Installer/Info.plist
App/App.entitlements
App/Info.plist
@@ -1,7 +1,7 @@
import SwiftUI
struct ContentView: View {
@ObservedObject var model: InstallerModel
@ObservedObject var model: FirewallModel
@Environment(\.dismiss) private var dismiss
var body: some View {
@@ -1,8 +1,8 @@
import SwiftUI
@main
struct InstallerApp: App {
@StateObject private var model = InstallerModel()
struct FirewallApp: App {
@StateObject private var model = FirewallModel()
var body: some Scene {
MenuBarExtra {
@@ -31,7 +31,7 @@ enum SetupWindow {
/// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
private struct MenuBarLabel: View {
@ObservedObject var model: InstallerModel
@ObservedObject var model: FirewallModel
@Environment(\.openWindow) private var openWindow
@State private var raised = false
+7
View File
@@ -0,0 +1,7 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "com.usestrix.firewall"
static let extensionBundleID = "com.usestrix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
@@ -23,22 +23,23 @@ enum StepStatus: Equatable {
}
}
enum InstallerError: Error, LocalizedError {
enum FirewallError: Error, LocalizedError {
case extensionUnavailable
case extensionRefused(String)
case screeningDidNotStart(NEVPNStatus)
case extensionStopped(String)
var errorDescription: String? {
switch self {
case .extensionUnavailable: return "The extension is not running"
case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
case let .extensionStopped(reason):
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
}
}
}
@MainActor
final class InstallerModel: NSObject, ObservableObject {
final class FirewallModel: NSObject, ObservableObject {
@Published private(set) var extensionStep = StepStatus.pending
@Published private(set) var screeningStep = StepStatus.pending
@Published private(set) var trustStep = StepStatus.pending
@@ -124,9 +125,9 @@ final class InstallerModel: NSObject, ObservableObject {
log("Uninstall cancelled")
return
}
// The extension owns the private key, so ask it before tearing down the session that carries the request.
let strandedKey = await removeStoredCA()
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
await removeConfiguration()
let strandedKey = removeStoredCA()
await deactivate()
if let strandedKey { trustStep = .failed(strandedKey) }
@@ -167,7 +168,7 @@ final class InstallerModel: NSObject, ObservableObject {
}
}
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
self[keyPath: step] = .running(running)
@@ -245,17 +246,18 @@ final class InstallerModel: NSObject, ObservableObject {
return true
}
/// Only root can delete the key, so the extension does it; returns what to report if it survived.
private func removeStoredCA() async -> String? {
do {
let reply = try await providerMessage("uninstall")
guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
log("Removed the CA, its exported copy and the env agent")
return nil
} catch {
log("Could not remove the CA: \(error.localizedDescription)")
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
private func removeStoredCA() -> String? {
let cli = Bundle.main.path(forAuxiliaryExecutable: "firewall") ?? ""
let script = "do shell script quoted form of \"\(cli)\" & \" uninstall\" with administrator privileges"
var error: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&error)
if let error {
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)")
return "The CA private key is still in the System keychain"
}
log("Removed the CA, its exported copy and the env agent")
return nil
}
// MARK: - System extension
@@ -295,16 +297,22 @@ final class InstallerModel: NSObject, ObservableObject {
try await manager.loadFromPreferences()
watched = manager
screeningStep = .running("Starting the extension")
try manager.connection.startVPNTunnel()
guard try await wait(for: .connected, on: manager.connection) else {
throw InstallerError.screeningDidNotStart(manager.connection.status)
do {
try await manager.connection.fetchLastDisconnectError()
} catch {
throw FirewallError.extensionStopped(error.localizedDescription)
}
throw FirewallError.screeningDidNotStart(manager.connection.status)
}
return "Screening package downloads"
}
private func restartScreening() async throws -> String {
guard let manager = try await loadManager() else {
throw InstallerError.extensionUnavailable
throw FirewallError.extensionUnavailable
}
manager.connection.stopVPNTunnel()
_ = try await wait(for: .disconnected, on: manager.connection)
@@ -312,7 +320,7 @@ final class InstallerModel: NSObject, ObservableObject {
}
private func stopScreening() async throws -> String {
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
manager.isEnabled = false
try await manager.saveToPreferences()
manager.connection.stopVPNTunnel()
@@ -320,9 +328,13 @@ final class InstallerModel: NSObject, ObservableObject {
return "Screening disabled"
}
/// Gives up early once a start has failed, which shows as connecting then disconnected.
private func wait(for status: NEVPNStatus, on connection: NEVPNConnection) async throws -> Bool {
var connecting = false
for _ in 0 ..< 60 {
if connection.status == status { return true }
if connection.status == .connecting { connecting = true }
if connecting && connection.status == .disconnected { return false }
try await Task.sleep(nanoseconds: 250_000_000)
}
return false
@@ -358,13 +370,13 @@ final class InstallerModel: NSObject, ObservableObject {
private func providerMessage(_ message: String) async throws -> String {
guard let session = watched?.connection as? NETunnelProviderSession else {
throw InstallerError.extensionUnavailable
throw FirewallError.extensionUnavailable
}
return try await withCheckedThrowingContinuation { continuation in
do {
try session.sendProviderMessage(Data(message.utf8)) { response in
guard let response, let reply = String(data: response, encoding: .utf8) else {
continuation.resume(throwing: InstallerError.extensionUnavailable)
continuation.resume(throwing: FirewallError.extensionUnavailable)
return
}
continuation.resume(returning: reply)
@@ -381,7 +393,7 @@ final class InstallerModel: NSObject, ObservableObject {
}
}
extension InstallerModel: OSSystemExtensionRequestDelegate {
extension FirewallModel: OSSystemExtensionRequestDelegate {
nonisolated func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
@@ -1,7 +1,7 @@
import SwiftUI
struct MenuBarView: View {
@ObservedObject var model: InstallerModel
@ObservedObject var model: FirewallModel
@Environment(\.openWindow) private var openWindow
var body: some View {
@@ -9,7 +9,7 @@ struct MenuBarView: View {
Divider()
Button("Installer") {
Button("Setup") {
SetupWindow.open(openWindow)
}
File renamed without changes.
-7
View File
@@ -1,7 +0,0 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "ai.strix.firewall"
static let extensionBundleID = "ai.strix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>ai.strix.firewall.env</string>
<string>com.usestrix.firewall.env</string>
<key>ProgramArguments</key>
<array>
<string>/bin/launchctl</string>
+48 -20
View File
@@ -1,8 +1,10 @@
#!/bin/bash
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# Builds and signs StrixFirewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
# makes a Debug build and skips notarization.
#
# Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing
# because macOS refuses to activate a Developer ID extension that is not notarized.
set -euo pipefail
cd "$(dirname "$0")"
@@ -10,13 +12,27 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}"
export TEAM
APP_ID=ai.strix.firewall
EXT_ID=ai.strix.firewall.extension
# Identities are matched as a substring of the certificate name; set them if more than one matches.
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension.
if [ "${1:-}" = debug ]; then
CONFIGURATION=Debug
IDENTITY=${DEBUG_IDENTITY:-Apple Development}
NE_PROVIDER=app-proxy-provider
else
CONFIGURATION=Release
IDENTITY=${IDENTITY:-Developer ID Application}
NE_PROVIDER=app-proxy-provider-systemextension
: "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
fi
export NE_PROVIDER
APP_ID=com.usestrix.firewall
EXT_ID=com.usestrix.firewall.extension
VERSION=0.1
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/Debug/Installer.app
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
@@ -25,12 +41,13 @@ main() {
sign
install_app
package
[ $CONFIGURATION = Debug ] || notarize
}
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
@@ -39,18 +56,19 @@ sign() {
echo "==> Signing with $IDENTITY"
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
codesign --force --options runtime --timestamp=none \
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp=none \
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --force --options runtime --timestamp \
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$APP"
}
# The extension can only be activated from /Applications.
install_app() {
echo "==> Installing /Applications/Firewall.app"
rm -rf /Applications/Firewall.app
cp -R "$APP" /Applications/Firewall.app
echo "==> Installing /Applications/StrixFirewall.app"
rm -rf /Applications/StrixFirewall.app
cp -R "$APP" /Applications/StrixFirewall.app
}
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
@@ -58,21 +76,31 @@ package() {
echo "==> Packaging build/Firewall.pkg"
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
build/Firewall.pkg >/dev/null
}
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
notarize() {
echo "==> Notarizing build/Firewall.pkg"
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple build/Firewall.pkg
}
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
profile_for() {
local want=$TEAM.$1 newest= newest_time=0 id t
local want=$TEAM.$1 newest= newest_time=0 profile id t
for p in "$PROFILES"/*.provisionprofile; do
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
profile=$(security cms -D -i "$p")
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
[ "$id" = "$want" ] || continue
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
grep -q "\"$NE_PROVIDER\"" || continue
t=$(stat -f %m "$p")
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
done
+2 -2
View File
@@ -3,8 +3,8 @@
user=$(stat -f %Su /dev/console)
[ "$user" = root ] && exit 0
uid=$(id -u "$user")
agent=/Library/LaunchAgents/ai.strix.firewall.env.plist
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
launchctl bootstrap "gui/$uid" "$agent"
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install
exit 0
+25 -14
View File
@@ -1,6 +1,6 @@
name: Firewall
options:
bundleIdPrefix: ai.strix.firewall
bundleIdPrefix: com.usestrix.firewall
deploymentTarget: { macOS: "13.0" }
createIntermediateGroups: true
@@ -33,8 +33,8 @@ targets:
sources: [Extension]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
PRODUCT_NAME: ai.strix.firewall.extension
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
PRODUCT_NAME: com.usestrix.firewall.extension
PRODUCT_MODULE_NAME: Extension
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
info:
@@ -44,7 +44,7 @@ targets:
CFBundleShortVersionString: "0.1"
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension:
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
NEProviderClasses:
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
entitlements:
@@ -52,13 +52,13 @@ targets:
properties:
# Xcode injects these from the profile; we sign by hand, and NE's IPC
# check rejects a caller without them.
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.networking.networkextension:
- app-proxy-provider
- ${NE_PROVIDER}
# NE requires NEMachServiceName to start with one of these.
com.apple.security.application-groups:
- ${TEAM}.ai.strix.firewall
- ${TEAM}.com.usestrix.firewall
com.apple.security.network.client: true
com.apple.security.network.server: true
dependencies:
@@ -87,33 +87,44 @@ targets:
$([ "$CONFIGURATION" = Debug ] || echo --release)
done
Installer:
StrixFirewall:
type: application
platform: macOS
sources:
- Installer
- App
- path: ../assets/logo-transparent.png
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
info:
path: Installer/Info.plist
path: App/Info.plist
properties:
CFBundleShortVersionString: "0.1"
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true
entitlements:
path: Installer/Installer.entitlements
path: App/App.entitlements
properties:
com.apple.application-identifier: ${TEAM}.ai.strix.firewall
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.system-extension.install: true
com.apple.developer.networking.networkextension:
- app-proxy-provider
- ${NE_PROVIDER}
com.apple.security.network.client: true
dependencies:
- target: Extension
embed: true
codeSign: true
# Uninstall runs the CLI as root to remove what the extension left.
postBuildScripts:
- name: Embed the CLI
basedOnDependencyAnalysis: false
script: |
export PATH="$HOME/.cargo/bin:$PATH"
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"