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
17 changed files with 126 additions and 59 deletions

No files matched your search

+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;
+3 -5
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;
@@ -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}"),
}))
}
+30 -9
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/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(())
}
+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
File renamed without changes.
@@ -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.
+9 -8
View File
@@ -1,5 +1,5 @@
#!/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.
#
@@ -32,7 +32,7 @@ VERSION=0.1
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/$CONFIGURATION/Installer.app
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
@@ -47,7 +47,7 @@ main() {
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration $CONFIGURATION \
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
}
@@ -56,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 -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
--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.
@@ -75,7 +76,7 @@ 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 -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
+1 -1
View File
@@ -6,5 +6,5 @@ uid=$(id -u "$user")
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
+15 -4
View File
@@ -87,25 +87,25 @@ 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: 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}.com.usestrix.firewall
com.apple.developer.team-identifier: ${TEAM}
@@ -117,3 +117,14 @@ targets:
- 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/"