Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a427dac50f |
No files matched your search
@@ -62,7 +62,7 @@ directories = "6.0.0"
|
||||
percent-encoding = "2.3.2"
|
||||
rama = { version = "0.4.0", features = ["boring", "http-full", "ring", "rustls"] }
|
||||
rand = "0.10.2"
|
||||
rcgen = { version = "0.14.10", features = ["x509-parser"] }
|
||||
rcgen = "0.14.10"
|
||||
regex = "1.13.1"
|
||||
semver = "1.0.28"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
@@ -76,7 +76,7 @@ which = "8"
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
core-foundation = "0.10.1"
|
||||
rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
|
||||
security-framework = { version = "3.7.0", features = ["macos-12"] }
|
||||
security-framework = "3.7.0"
|
||||
security-framework-sys = "2.17.0"
|
||||
|
||||
[dev-dependencies]
|
||||
|
||||
@@ -59,8 +59,8 @@ impl Default for FirewallConfig {
|
||||
impl Default for DenylistSource {
|
||||
fn default() -> Self {
|
||||
DenylistSource {
|
||||
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
|
||||
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
}
|
||||
@@ -100,6 +100,6 @@ impl FirewallConfig {
|
||||
}
|
||||
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
|
||||
let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
|
||||
Some(dirs.config_dir().join("config.json"))
|
||||
}
|
||||
@@ -12,10 +12,6 @@ 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");
|
||||
@@ -44,8 +40,6 @@ 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")
|
||||
@@ -75,13 +69,6 @@ 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()) {
|
||||
|
||||
@@ -39,8 +39,7 @@ impl CertificateAuthority {
|
||||
(held == signer.subject_public_key_info()).then_some(ca)
|
||||
}
|
||||
|
||||
/// The first certificate is the CA; any after it are its chain, for clients that read no keychain.
|
||||
pub fn new(pem: String) -> Result<Self, BoxError> {
|
||||
fn new(pem: String) -> Result<Self, BoxError> {
|
||||
Ok(Self { crt: X509::from_pem(pem.as_bytes())?, pem })
|
||||
}
|
||||
}
|
||||
@@ -61,13 +60,10 @@ 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";
|
||||
/// Of a CA an MDM enrolls instead, which must not match `COMMON_NAME`: uninstall deletes those.
|
||||
pub const MANAGED_COMMON_NAME: &str = "Strix Firewall Managed";
|
||||
|
||||
fn params(hosts: &[&str]) -> CertificateParams {
|
||||
/// 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, COMMON_NAME);
|
||||
name.push(DnType::CommonName, "Strix Firewall");
|
||||
name.push(DnType::OrganizationName, "Strix");
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
|
||||
@@ -1,15 +1,17 @@
|
||||
use super::ca::CaSigner;
|
||||
use super::ca::{params, CaSigner};
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::pkey::PKey;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
|
||||
use rama::utils::collections::non_empty_vec;
|
||||
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
|
||||
#[derive(Clone)]
|
||||
pub struct ConstrainedCertIssuer {
|
||||
ca_crt: X509,
|
||||
ca_params: Arc<CertificateParams>,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
@@ -22,7 +24,8 @@ impl ConstrainedCertIssuer {
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
) -> Self {
|
||||
Self { ca_crt, signer, hosts, crl }
|
||||
let ca_params = Arc::new(params(&hosts));
|
||||
Self { ca_crt, ca_params, signer, hosts, crl }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,8 +54,7 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
|
||||
|
||||
// The TLS server needs the leaf key in memory, so it can't be the platform key.
|
||||
let leaf_key = KeyPair::generate()?;
|
||||
let ca_der = self.ca_crt.to_der()?;
|
||||
let issuer = Issuer::from_ca_cert_der(&ca_der.into(), &*self.signer)?;
|
||||
let issuer = Issuer::from_params(&self.ca_params, &*self.signer);
|
||||
let crt = X509::from_der(leaf.signed_by(&leaf_key, &issuer)?.der())?;
|
||||
let key = PKey::private_key_from_pem(leaf_key.serialize_pem().as_bytes())?;
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, MANAGED_COMMON_NAME};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
|
||||
@@ -33,17 +33,10 @@ impl EnclaveKey {
|
||||
Some(blob) => load(&blob)?,
|
||||
None => create()?,
|
||||
};
|
||||
Self::new(key)
|
||||
}
|
||||
|
||||
pub fn new(key: SecKey) -> Result<Self, BoxError> {
|
||||
let public = key
|
||||
.public_key()
|
||||
.and_then(|public| public.external_representation())
|
||||
.ok_or("secure enclave key has no exportable public half")?;
|
||||
if public.len() != 65 {
|
||||
return Err("the CA key must be P-256".into());
|
||||
}
|
||||
Ok(EnclaveKey { key, public: public.to_vec() })
|
||||
}
|
||||
|
||||
|
||||
@@ -1,53 +1,11 @@
|
||||
use super::enclave::EnclaveKey;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
|
||||
use crate::mitm::{CertificateAuthority, MANAGED_COMMON_NAME};
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use crate::mitm::CertificateAuthority;
|
||||
use rama::error::BoxError;
|
||||
use rama::net::apple::networkextension::system_keychain;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rcgen::SigningKey;
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Limit, Reference, SearchResult};
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework::policy::SecPolicy;
|
||||
use security_framework::trust::SecTrust;
|
||||
use security_framework_sys::base::errSecItemNotFound;
|
||||
use std::path::Path;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// A CA an MDM enrolled over ACME. The MDM trusts its issuer, so the user is never asked to.
|
||||
pub fn managed_certificate_authority(
|
||||
) -> Result<Option<(EnclaveKey, CertificateAuthority)>, BoxError> {
|
||||
let found = ItemSearchOptions::new()
|
||||
.class(ItemClass::identity())
|
||||
.keychains(&[SecKeychain::open(SYSTEM_KEYCHAIN)?])
|
||||
.load_refs(true)
|
||||
.limit(Limit::All)
|
||||
.search();
|
||||
let results = match found {
|
||||
Ok(results) => results,
|
||||
Err(error) if error.code() == errSecItemNotFound => return Ok(None),
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
for result in results {
|
||||
let SearchResult::Ref(Reference::Identity(identity)) = result else { continue };
|
||||
let crt = identity.certificate()?;
|
||||
if crt.subject_summary() != MANAGED_COMMON_NAME {
|
||||
continue;
|
||||
}
|
||||
let key = EnclaveKey::new(identity.private_key()?)?;
|
||||
let trust = SecTrust::create_with_certificates(&[crt], &[SecPolicy::create_x509()])?;
|
||||
trust.evaluate_with_error().map_err(|e| format!("the managed CA is not trusted: {e}"))?;
|
||||
let mut pem = String::new();
|
||||
for crt in trust.chain() {
|
||||
pem.push_str(std::str::from_utf8(&X509::from_der(&crt.to_der())?.to_pem()?)?);
|
||||
}
|
||||
let ca = CertificateAuthority::new(pem)?;
|
||||
info!("using the CA enrolled by MDM");
|
||||
export_pem(&ca);
|
||||
return Ok(Some((key, ca)));
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Kept in the System keychain so trust survives restarts; replaced if the hosts change.
|
||||
pub fn certificate_authority(
|
||||
hosts: &[&str],
|
||||
|
||||
@@ -2,8 +2,6 @@ mod enclave;
|
||||
mod install;
|
||||
mod uninstall;
|
||||
|
||||
pub use uninstall::uninstall;
|
||||
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::{mitm, telemetry};
|
||||
use crate::Firewall;
|
||||
@@ -32,7 +30,6 @@ const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
|
||||
const KEYCHAIN_ACCOUNT: &str = "ca";
|
||||
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
|
||||
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
|
||||
const SYSTEM_KEYCHAIN: &str = "/Library/Keychains/System.keychain";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Handler {
|
||||
@@ -52,11 +49,15 @@ impl TransparentProxyHandler for Handler {
|
||||
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
|
||||
}
|
||||
|
||||
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
|
||||
/// Setup requests from the installer. 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}"),
|
||||
}))
|
||||
}
|
||||
@@ -103,18 +104,10 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
let hosts = firewall.hosts();
|
||||
let managed = install::managed_certificate_authority().inspect_err(|_| telemetry::error("ca"))?;
|
||||
let (key, ca) = match managed {
|
||||
Some(managed) => managed,
|
||||
None => {
|
||||
let key = enclave::EnclaveKey::load_or_create()
|
||||
.inspect_err(|_| telemetry::error("enclave"))?;
|
||||
let ca = install::certificate_authority(&hosts, &key);
|
||||
(key, ca.inspect_err(|_| telemetry::error("ca"))?)
|
||||
}
|
||||
};
|
||||
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
|
||||
let signer: mitm::CaSigner = Arc::new(key);
|
||||
let ca = Arc::new(ca);
|
||||
let ca = install::certificate_authority(&hosts, &*signer);
|
||||
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, service })
|
||||
}
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
use super::enclave::EnclaveKey;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE, SYSTEM_KEYCHAIN};
|
||||
use crate::mitm::COMMON_NAME;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use crate::mitm::CertificateAuthority;
|
||||
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: 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(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()),
|
||||
};
|
||||
// Reading a secret's data is limited to the extension, so delete without checking it exists.
|
||||
/// 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)?;
|
||||
EnclaveKey::delete()?;
|
||||
for path in [CA_FILE, ENV_AGENT] {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => removed = true,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if removed {
|
||||
info!("removed the CA from the system keychain");
|
||||
} else {
|
||||
info!("nothing to uninstall");
|
||||
}
|
||||
info!("removed the CA from the system keychain");
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,11 +1,2 @@
|
||||
#[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(())
|
||||
}
|
||||
@@ -3,5 +3,5 @@ Firewall.xcodeproj
|
||||
.env
|
||||
Extension/Extension.entitlements
|
||||
Extension/Info.plist
|
||||
App/App.entitlements
|
||||
App/Info.plist
|
||||
Installer/Installer.entitlements
|
||||
Installer/Info.plist
|
||||
@@ -1,68 +0,0 @@
|
||||
{
|
||||
"images": [
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "1x",
|
||||
"filename": "icon_16.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "16x16",
|
||||
"scale": "2x",
|
||||
"filename": "icon_32.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "1x",
|
||||
"filename": "icon_32.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "32x32",
|
||||
"scale": "2x",
|
||||
"filename": "icon_64.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "1x",
|
||||
"filename": "icon_128.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "128x128",
|
||||
"scale": "2x",
|
||||
"filename": "icon_256.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "1x",
|
||||
"filename": "icon_256.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "256x256",
|
||||
"scale": "2x",
|
||||
"filename": "icon_512.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "1x",
|
||||
"filename": "icon_512.png"
|
||||
},
|
||||
{
|
||||
"idiom": "mac",
|
||||
"size": "512x512",
|
||||
"scale": "2x",
|
||||
"filename": "icon_1024.png"
|
||||
}
|
||||
],
|
||||
"info": {
|
||||
"author": "xcode",
|
||||
"version": 1
|
||||
}
|
||||
}
|
||||
|
Before Width: | Height: | Size: 39 KiB |
|
Before Width: | Height: | Size: 3.8 KiB |
|
Before Width: | Height: | Size: 1.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 16 KiB |
|
Before Width: | Height: | Size: 2.3 KiB |
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var model: FirewallModel
|
||||
@ObservedObject var model: InstallerModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -10,9 +10,11 @@ struct ContentView: View {
|
||||
Divider()
|
||||
steps
|
||||
Divider()
|
||||
transcript
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 540)
|
||||
.frame(width: 540, height: 560)
|
||||
.task { await model.refresh() }
|
||||
.onAppear(perform: recenterIfOffscreen)
|
||||
.onChange(of: model.completed) { done in
|
||||
@@ -36,11 +38,9 @@ struct ContentView: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(nsImage: NSImage(named: "logo-transparent")!)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Firewall").font(.title2.weight(.semibold))
|
||||
Text("Screens package downloads before they reach your machine.")
|
||||
@@ -70,6 +70,27 @@ struct ContentView: View {
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var transcript: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.transcript.indices, id: \.self) { line in
|
||||
Text(model.transcript[line])
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(line)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.onChange(of: model.transcript.count) { count in
|
||||
proxy.scrollTo(count - 1)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if model.busy { ProgressView().controlSize(.small) }
|
||||
@@ -84,11 +105,8 @@ struct ContentView: View {
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
if model.awaitingApproval {
|
||||
Button("Open System Settings") { model.openApprovalSettings() }
|
||||
}
|
||||
Button("Uninstall") { Task { await model.uninstall() } }
|
||||
.disabled(model.busy && !model.awaitingApproval)
|
||||
.disabled(model.busy)
|
||||
Button("Install") { Task { await model.install() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(model.busy || model.completed)
|
||||
@@ -111,10 +129,9 @@ private struct StepRow: View {
|
||||
Text(title).font(.body.weight(.medium))
|
||||
Text(note).font(.caption).foregroundStyle(.secondary)
|
||||
Text(status.detail)
|
||||
.font(font)
|
||||
.font(.caption)
|
||||
.foregroundStyle(tint)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -129,7 +146,7 @@ private struct StepRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
|
||||
case .running, .needsAction:
|
||||
case .running:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
@@ -138,15 +155,9 @@ private struct StepRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var font: Font {
|
||||
if case .needsAction = status { return .system(size: 14) }
|
||||
return .caption
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch status {
|
||||
case .pending, .running: return .secondary
|
||||
case .needsAction: return .orange
|
||||
case .done: return .green
|
||||
case .failed: return .orange
|
||||
}
|
||||
@@ -4,5 +4,4 @@ enum FirewallIDs {
|
||||
static let appBundleID = "com.usestrix.firewall"
|
||||
static let extensionBundleID = "com.usestrix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
static let managedCaCommonName = "Strix Firewall Managed"
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct FirewallApp: App {
|
||||
@StateObject private var model = FirewallModel()
|
||||
struct InstallerApp: App {
|
||||
@StateObject private var model = InstallerModel()
|
||||
|
||||
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: FirewallModel
|
||||
@ObservedObject var model: InstallerModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var raised = false
|
||||
|
||||
@@ -7,15 +7,13 @@ import SystemExtensions
|
||||
enum StepStatus: Equatable {
|
||||
case pending
|
||||
case running(String)
|
||||
/// Waiting on the user to do something outside the app.
|
||||
case needsAction(String)
|
||||
case done(String)
|
||||
case failed(String)
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .pending: return "Not started"
|
||||
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
|
||||
case let .running(text), let .done(text), let .failed(text): return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,35 +23,33 @@ enum StepStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum FirewallError: Error, LocalizedError {
|
||||
enum InstallerError: 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 FirewallModel: NSObject, ObservableObject {
|
||||
final class InstallerModel: NSObject, ObservableObject {
|
||||
@Published private(set) var extensionStep = StepStatus.pending
|
||||
@Published private(set) var screeningStep = StepStatus.pending
|
||||
@Published private(set) var trustStep = StepStatus.pending
|
||||
@Published private var operations = 0
|
||||
var busy: Bool { operations > 0 }
|
||||
@Published private(set) var awaitingApproval = false
|
||||
@Published private(set) var busy = false
|
||||
@Published private(set) var completed = false
|
||||
@Published private(set) var removed = false
|
||||
@Published private(set) var transcript: [String] = []
|
||||
@Published private(set) var screening = false
|
||||
|
||||
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
|
||||
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
|
||||
private var pendingRequest: CheckedContinuation<Void, Error>?
|
||||
private var statusObserver: NSObjectProtocol?
|
||||
// NE only posts status notifications for connections that are still referenced.
|
||||
@Published private var watched: NETransparentProxyManager?
|
||||
@@ -91,8 +87,8 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func install() async {
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
removed = false
|
||||
|
||||
guard CompetingExtensions.confirmInstall() else {
|
||||
@@ -118,23 +114,19 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
|
||||
func uninstall() async {
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
completed = false
|
||||
removed = false
|
||||
// Stop waiting on an activation the user never approved; the deactivation below withdraws it.
|
||||
if let pending = pendingRequest {
|
||||
finishRequest(pending.request, .failure(CocoaError(.userCancelled)))
|
||||
}
|
||||
|
||||
// Install needs admin, so uninstall does too: untrusting the CA prompts for it, and a cancel stops here.
|
||||
guard await untrustAuthorities() else {
|
||||
log("Uninstall cancelled")
|
||||
return
|
||||
}
|
||||
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
|
||||
// The extension owns the private key, so ask it before tearing down the session that carries the request.
|
||||
let strandedKey = await removeStoredCA()
|
||||
await removeConfiguration()
|
||||
let strandedKey = removeStoredCA()
|
||||
await deactivate()
|
||||
if let strandedKey { trustStep = .failed(strandedKey) }
|
||||
|
||||
@@ -158,16 +150,16 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
|
||||
func restart() async {
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return }
|
||||
// A restart can mint a new CA when the screened hosts change.
|
||||
_ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority)
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
if enabled {
|
||||
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
|
||||
} else {
|
||||
@@ -175,15 +167,7 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
func openApprovalSettings() {
|
||||
var pane = "x-apple.systempreferences:com.apple.preference.security"
|
||||
if #available(macOS 15, *) {
|
||||
pane = "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension"
|
||||
}
|
||||
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
|
||||
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
self[keyPath: step] = .running(running)
|
||||
@@ -227,9 +211,6 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main))
|
||||
extensionStep = .pending
|
||||
log("System extension deactivated, macOS finishes removing it on the next reboot")
|
||||
} catch OSSystemExtensionError.extensionNotFound {
|
||||
extensionStep = .pending
|
||||
log("No system extension to deactivate")
|
||||
} catch {
|
||||
extensionStep = .failed(error.localizedDescription)
|
||||
log("Deactivation failed: \(error.localizedDescription)")
|
||||
@@ -264,18 +245,17 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// 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)")
|
||||
/// 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)")
|
||||
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
|
||||
@@ -283,18 +263,14 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
private func submit(_ request: OSSystemExtensionRequest) async throws {
|
||||
request.delegate = self
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
pendingRequest = (request, continuation)
|
||||
pendingRequest = continuation
|
||||
OSSystemExtensionManager.shared.submitRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
/// Callbacks for an abandoned request can still arrive, so only the pending one is finished.
|
||||
private func finishRequest(_ request: OSSystemExtensionRequest, _ result: Result<Void, Error>) {
|
||||
guard let pending = pendingRequest, pending.request === request else { return }
|
||||
private func finishRequest(_ result: Result<Void, Error>) {
|
||||
pendingRequest?.resume(with: result)
|
||||
pendingRequest = nil
|
||||
awaitingApproval = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
pending.continuation.resume(with: result)
|
||||
}
|
||||
|
||||
// MARK: - Network extension
|
||||
@@ -319,22 +295,16 @@ final class FirewallModel: 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 {
|
||||
do {
|
||||
try await manager.connection.fetchLastDisconnectError()
|
||||
} catch {
|
||||
throw FirewallError.extensionStopped(error.localizedDescription)
|
||||
}
|
||||
throw FirewallError.screeningDidNotStart(manager.connection.status)
|
||||
throw InstallerError.screeningDidNotStart(manager.connection.status)
|
||||
}
|
||||
return "Screening package downloads"
|
||||
}
|
||||
|
||||
private func restartScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else {
|
||||
throw FirewallError.extensionUnavailable
|
||||
throw InstallerError.extensionUnavailable
|
||||
}
|
||||
manager.connection.stopVPNTunnel()
|
||||
_ = try await wait(for: .disconnected, on: manager.connection)
|
||||
@@ -342,7 +312,7 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func stopScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
|
||||
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
|
||||
manager.isEnabled = false
|
||||
try await manager.saveToPreferences()
|
||||
manager.connection.stopVPNTunnel()
|
||||
@@ -350,13 +320,9 @@ final class FirewallModel: 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
|
||||
@@ -392,13 +358,13 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
private func providerMessage(_ message: String) async throws -> String {
|
||||
guard let session = watched?.connection as? NETunnelProviderSession else {
|
||||
throw FirewallError.extensionUnavailable
|
||||
throw InstallerError.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: FirewallError.extensionUnavailable)
|
||||
continuation.resume(throwing: InstallerError.extensionUnavailable)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: reply)
|
||||
@@ -410,11 +376,12 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func log(_ line: String) {
|
||||
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
|
||||
logger.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest,
|
||||
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
||||
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
@@ -423,14 +390,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
|
||||
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
Task { @MainActor in
|
||||
// macOS shows its own alert that opens System Settings; this says what to do there.
|
||||
var instruction = "In System Settings › Privacy & Security, click Allow for StrixFirewall"
|
||||
if #available(macOS 15, *) {
|
||||
instruction = "In System Settings › General › Login Items & Extensions, click Network Extensions and turn on StrixFirewall"
|
||||
}
|
||||
extensionStep = .needsAction(instruction)
|
||||
awaitingApproval = true
|
||||
extensionStep = .running("Approve Firewall in System Settings › General › Login Items & Extensions")
|
||||
log("Waiting for user approval")
|
||||
// Only MDM can pre-approve a system extension, so take them to the switch.
|
||||
if let pane = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension") {
|
||||
NSWorkspace.shared.open(pane)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -440,12 +405,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
if result == .willCompleteAfterReboot {
|
||||
log("Extension will finish installing after a reboot")
|
||||
}
|
||||
finishRequest(request, .success(()))
|
||||
finishRequest(.success(()))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
Task { @MainActor in finishRequest(request, .failure(error)) }
|
||||
Task { @MainActor in finishRequest(.failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@ObservedObject var model: FirewallModel
|
||||
@ObservedObject var model: InstallerModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
@@ -9,7 +9,7 @@ struct MenuBarView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Setup") {
|
||||
Button("Installer") {
|
||||
SetupWindow.open(openWindow)
|
||||
}
|
||||
|
||||
@@ -34,11 +34,7 @@ enum TrustStore {
|
||||
return certificate
|
||||
}
|
||||
|
||||
/// The extension only uses a managed CA after checking that it chains to a root the MDM trusts.
|
||||
static func isTrusted(_ certificate: SecCertificate) -> Bool {
|
||||
var name: CFString?
|
||||
if SecCertificateCopyCommonName(certificate, &name) == errSecSuccess,
|
||||
name as String? == FirewallIDs.managedCaCommonName { return true }
|
||||
var settings: CFArray?
|
||||
return SecTrustSettingsCopyTrustSettings(certificate, .admin, &settings) == errSecSuccess
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
# Deploying with MDM
|
||||
|
||||
On a managed Mac the firewall can use a CA your MDM enrolls instead of minting its own, so users
|
||||
need no admin rights and see no trust prompt. Each Mac still has its own key in its Secure Enclave;
|
||||
your PKI signs its certificate.
|
||||
|
||||
Push one device-channel profile with:
|
||||
|
||||
1. **Certificate** (`com.apple.security.root`): your root CA.
|
||||
2. **ACME Certificate** (`com.apple.security.acme`), macOS 14 or later:
|
||||
- `KeyType` `ECSECPrimeRandom`, `KeySize` `256`, `HardwareBound` `true`, `Attest` `true`
|
||||
- `Subject` `[[["CN", "Strix Firewall Managed"]]]`; the extension finds the identity by this name
|
||||
- `AllowAllAppsAccess` `true`, so the extension can sign with the key
|
||||
3. **System Extensions**: allow `com.usestrix.firewall.extension` for the Strix team ID.
|
||||
|
||||
Your ACME server must issue the enrolled certificate as a CA, not a leaf:
|
||||
|
||||
- basic constraints `CA:TRUE, pathlen:0` and key usage `keyCertSign`
|
||||
- critical name constraints permitting only the screened hosts: `registry.npmjs.org`, `pypi.org`,
|
||||
`files.pythonhosted.org`, `index.crates.io`, `static.crates.io`, `crates.io`,
|
||||
`proxy.golang.org`, `rubygems.org`
|
||||
|
||||
The extension checks the certificate chains to a trusted root before using it. Install the profile
|
||||
before the firewall: without a managed CA the extension mints its own, which needs an admin to trust.
|
||||
@@ -1,10 +1,9 @@
|
||||
#!/bin/bash
|
||||
# Builds and signs StrixFirewall.app and packages it as build/Firewall.pkg.
|
||||
# Builds and signs Firewall.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. Debug builds use Apple Development signing
|
||||
# because macOS refuses to activate a Developer ID extension that is not notarized.
|
||||
# Xcode builds the bundles unsigned; they are signed here with Developer ID.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -12,33 +11,26 @@ cd "$(dirname "$0")"
|
||||
: "${TEAM:?set TEAM in macos/.env}"
|
||||
export TEAM
|
||||
|
||||
# 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
|
||||
CONFIGURATION=Release
|
||||
if [ "${1:-}" = debug ]; then CONFIGURATION=Debug; fi
|
||||
[ $CONFIGURATION = Debug ] || : "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
|
||||
|
||||
APP_ID=com.usestrix.firewall
|
||||
EXT_ID=com.usestrix.firewall.extension
|
||||
VERSION=0.1
|
||||
# Matched as a substring of the certificate name; set these if more than one certificate matches.
|
||||
IDENTITY=${IDENTITY:-Developer ID Application}
|
||||
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
|
||||
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
|
||||
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
|
||||
APP=build/Build/Products/$CONFIGURATION/Installer.app
|
||||
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
|
||||
PKG_ROOT=build/pkg-root
|
||||
|
||||
main() {
|
||||
build
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
}
|
||||
@@ -46,7 +38,7 @@ main() {
|
||||
build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration $CONFIGURATION \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
@@ -55,20 +47,26 @@ 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 App/App.entitlements -s "$IDENTITY" "$APP"
|
||||
--entitlements Installer/Installer.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
|
||||
}
|
||||
|
||||
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
|
||||
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/StrixFirewall.app"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
|
||||
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
|
||||
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
|
||||
@@ -84,15 +82,12 @@ notarize() {
|
||||
xcrun stapler staple build/Firewall.pkg
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
|
||||
profile_for() {
|
||||
local want=$TEAM.$1 newest= newest_time=0 profile id t
|
||||
local want=$TEAM.$1 newest= newest_time=0 id t
|
||||
for p in "$PROFILES"/*.provisionprofile; do
|
||||
profile=$(security cms -D -i "$p")
|
||||
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
|
||||
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
|
||||
[ "$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
|
||||
|
||||
@@ -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/StrixFirewall.app --args --install
|
||||
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
|
||||
exit 0
|
||||
@@ -55,7 +55,7 @@ targets:
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.networking.networkextension:
|
||||
- ${NE_PROVIDER}
|
||||
- app-proxy-provider-systemextension
|
||||
# NE requires NEMachServiceName to start with one of these.
|
||||
com.apple.security.application-groups:
|
||||
- ${TEAM}.com.usestrix.firewall
|
||||
@@ -87,45 +87,33 @@ targets:
|
||||
$([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
done
|
||||
|
||||
StrixFirewall:
|
||||
Installer:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- App
|
||||
- Installer
|
||||
- path: ../assets/logo-transparent.png
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: App/Info.plist
|
||||
path: Installer/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: App/App.entitlements
|
||||
path: Installer/Installer.entitlements
|
||||
properties:
|
||||
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:
|
||||
- ${NE_PROVIDER}
|
||||
- app-proxy-provider-systemextension
|
||||
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/"
|
||||