mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
afa0b9a2ec | ||
|
|
620ed57c9a | ||
|
|
7ed69632a2 | ||
|
|
ccf59a1d0c |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Generated
-96
@@ -3111,7 +3111,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"which",
|
||||
"windows",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3710,89 +3709,12 @@ version = "0.4.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
|
||||
|
||||
[[package]]
|
||||
name = "windows"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
|
||||
dependencies = [
|
||||
"windows-collections",
|
||||
"windows-core",
|
||||
"windows-future",
|
||||
"windows-numerics",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-collections"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-future"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
"windows-threading",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-numerics"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
|
||||
dependencies = [
|
||||
"windows-core",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
@@ -3802,15 +3724,6 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.52.0"
|
||||
@@ -3845,15 +3758,6 @@ dependencies = [
|
||||
"windows_x86_64_msvc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-threading"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows_aarch64_gnullvm"
|
||||
version = "0.52.6"
|
||||
|
||||
@@ -52,10 +52,6 @@ path = "tests/age.rs"
|
||||
name = "mitm"
|
||||
path = "tests/mitm.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tpm"
|
||||
path = "tests/tpm.rs"
|
||||
|
||||
[[test]]
|
||||
name = "managers"
|
||||
path = "tests/managers.rs"
|
||||
@@ -83,9 +79,6 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
|
||||
security-framework = "3.7.0"
|
||||
security-framework-sys = "2.17.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security_Cryptography"] }
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
|
||||
};
|
||||
|
||||
let reason = policies::screen(&url, firewall).await;
|
||||
telemetry::screened(firewall, &url, reason.as_deref());
|
||||
telemetry::screened(firewall, &url, reason.is_some());
|
||||
if let Some(reason) = reason {
|
||||
warn!(method = %req.method(), %url, %reason, "blocked");
|
||||
return Ok(blocked(&reason));
|
||||
|
||||
+14
-40
@@ -1,7 +1,6 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::PathBuf;
|
||||
@@ -60,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,
|
||||
}
|
||||
@@ -69,31 +68,18 @@ impl Default for DenylistSource {
|
||||
}
|
||||
|
||||
impl FirewallConfig {
|
||||
/// A missing file means defaults; a bad one is an error, so the policy is never quietly relaxed.
|
||||
pub fn load() -> std::io::Result<Self> {
|
||||
let mut paths: Vec<PathBuf> = path().into_iter().collect();
|
||||
paths.extend(crate::os::system_config());
|
||||
Self::load_from(&paths)
|
||||
}
|
||||
|
||||
/// Later files win key by key, but allow lists add up.
|
||||
/// A bad file is an error, so the policy is never quietly relaxed.
|
||||
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> {
|
||||
let mut merged = Map::new();
|
||||
for path in paths {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
|
||||
};
|
||||
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
|
||||
FirewallConfig::deserialize(&layer).map_err(invalid)?;
|
||||
let Value::Object(layer) = layer else {
|
||||
unreachable!("a config parses only from an object");
|
||||
};
|
||||
merge(&mut merged, layer);
|
||||
}
|
||||
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
|
||||
let Some(path) = path() else {
|
||||
return Ok(Self::default());
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
|
||||
};
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
|
||||
}
|
||||
|
||||
pub fn local_url(&self, path: &str) -> String {
|
||||
@@ -114,18 +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"))
|
||||
}
|
||||
|
||||
fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
|
||||
for (key, value) in top {
|
||||
match (base.get_mut(&key), value) {
|
||||
(Some(Value::Object(base)), Value::Object(top)) => merge(base, top),
|
||||
(Some(Value::Array(base)), Value::Array(top)) => base.extend(top),
|
||||
(_, value) => {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
@@ -56,7 +50,6 @@ fn options() -> Command {
|
||||
}
|
||||
|
||||
fn serve() -> ! {
|
||||
telemetry::set_mode("proxy");
|
||||
let config = load();
|
||||
let port = config.port;
|
||||
print_setup(&config);
|
||||
@@ -76,15 +69,7 @@ fn serve() -> ! {
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall() -> ! {
|
||||
match firewall::os::uninstall() {
|
||||
Ok(()) => exit(0),
|
||||
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
telemetry::set_mode("wrapper");
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
Ok(port) => port,
|
||||
|
||||
+2
-11
@@ -1,5 +1,4 @@
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::sha::sha256;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
|
||||
@@ -9,7 +8,7 @@ use rcgen::{
|
||||
use std::sync::Arc;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
/// Held in the Secure Enclave on macOS, the TPM on Windows, in memory elsewhere.
|
||||
/// Held in the Secure Enclave on macOS, in memory elsewhere.
|
||||
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
|
||||
|
||||
pub struct CertificateAuthority {
|
||||
@@ -40,12 +39,6 @@ impl CertificateAuthority {
|
||||
(held == signer.subject_public_key_info()).then_some(ca)
|
||||
}
|
||||
|
||||
/// Unique to this CA, so a verifier's cached CRL from an earlier one never applies to it.
|
||||
pub fn crl_path(&self) -> String {
|
||||
let digest = sha256(self.pem.as_bytes());
|
||||
format!("/{}.crl", digest[..8].iter().map(|b| format!("{b:02x}")).collect::<String>())
|
||||
}
|
||||
|
||||
fn new(pem: String) -> Result<Self, BoxError> {
|
||||
Ok(Self { crt: X509::from_pem(pem.as_bytes())?, pem })
|
||||
}
|
||||
@@ -67,12 +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";
|
||||
|
||||
/// 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();
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
|
||||
@@ -37,7 +37,7 @@ where
|
||||
async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
|
||||
if let Some(url) = https_url(&req) {
|
||||
let reason = policies::screen(&url, &self.firewall).await;
|
||||
telemetry::screened(&self.firewall, &url, reason.as_deref());
|
||||
telemetry::screened(&self.firewall, &url, reason.is_some());
|
||||
if let Some(reason) = reason {
|
||||
warn!(%url, "blocked by firewall: {reason}");
|
||||
return Ok(blocked(&reason));
|
||||
|
||||
@@ -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;
|
||||
@@ -23,7 +21,6 @@ use rama::rt::Executor;
|
||||
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
|
||||
use rama::{Layer, Service};
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
@@ -33,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 CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Handler {
|
||||
@@ -53,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}"),
|
||||
}))
|
||||
}
|
||||
@@ -99,14 +99,9 @@ impl TransparentProxyHandlerFactory for Factory {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn system_config() -> Option<PathBuf> {
|
||||
Some(CONFIG_FILE.into())
|
||||
}
|
||||
|
||||
fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
telemetry::set_mode("extension");
|
||||
telemetry::heartbeat();
|
||||
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?;
|
||||
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
let hosts = firewall.hosts();
|
||||
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
|
||||
|
||||
@@ -1,45 +1,26 @@
|
||||
use super::enclave::EnclaveKey;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use crate::mitm::COMMON_NAME;
|
||||
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("/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()),
|
||||
};
|
||||
// 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,18 +1,2 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::{system_config, uninstall};
|
||||
|
||||
/// Elsewhere nothing is installed system-wide.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn uninstall() -> Result<(), rama::error::BoxError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn system_config() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
mod tpm;
|
||||
|
||||
pub use tpm::CaKey;
|
||||
@@ -1,153 +0,0 @@
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::bn::BigNum;
|
||||
use rama::tls::boring::core::ecdsa::EcdsaSig;
|
||||
use rama::tls::boring::core::sha::sha256;
|
||||
use rcgen::{Error, PublicKeyData, SignatureAlgorithm, SigningKey, PKCS_ECDSA_P256_SHA256};
|
||||
use windows::core::{Owned, Result as WinResult, HSTRING};
|
||||
use windows::Win32::Foundation::{NTE_BAD_KEY, NTE_BAD_KEYSET};
|
||||
use windows::Win32::Security::Cryptography::*;
|
||||
|
||||
const KEY_NAME: &str = "Strix Firewall CA";
|
||||
|
||||
/// A P-256 key held by the TPM. Signing asks the TPM to sign, so the private key never leaves it.
|
||||
pub struct CaKey {
|
||||
// Fields drop in order, so the key handle closes before its provider's.
|
||||
key_handle: Owned<NCRYPT_KEY_HANDLE>,
|
||||
_provider_handle: Owned<NCRYPT_PROV_HANDLE>,
|
||||
/// 0x04 || X || Y, the form rcgen writes into a certificate's SubjectPublicKeyInfo.
|
||||
public_point: Vec<u8>,
|
||||
}
|
||||
|
||||
impl CaKey {
|
||||
/// A machine key, so the service and an elevated installer share it.
|
||||
pub fn load_or_create() -> Result<Self, BoxError> {
|
||||
match open_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
|
||||
Some(key) => Ok(key),
|
||||
None => Ok(create_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)?),
|
||||
}
|
||||
}
|
||||
|
||||
/// A per-user key under a random name, which needs no elevation; `delete_from_tpm` it after.
|
||||
pub fn create_test_key() -> WinResult<Self> {
|
||||
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
|
||||
create_key(&name, NCRYPT_FLAGS(0))
|
||||
}
|
||||
|
||||
/// Deletes the key from the TPM, not just this handle to it.
|
||||
pub fn delete_from_tpm(mut self) -> WinResult<()> {
|
||||
// NCryptDeleteKey also frees the handle, so the Owned mustn't free it again.
|
||||
let key_handle = std::mem::take(&mut *self.key_handle);
|
||||
unsafe { NCryptDeleteKey(key_handle, 0) }
|
||||
}
|
||||
|
||||
/// Takes both handles and reads the key's public point once, for rcgen.
|
||||
fn from_handles(
|
||||
provider_handle: Owned<NCRYPT_PROV_HANDLE>,
|
||||
key_handle: Owned<NCRYPT_KEY_HANDLE>,
|
||||
) -> WinResult<Self> {
|
||||
// A BCRYPT_ECCKEY_BLOB header, then X and Y.
|
||||
const HEADER_LEN: usize = size_of::<BCRYPT_ECCKEY_BLOB>();
|
||||
let mut blob = [0u8; HEADER_LEN + 64];
|
||||
let mut blob_len = 0;
|
||||
unsafe {
|
||||
NCryptExportKey(
|
||||
*key_handle,
|
||||
None,
|
||||
BCRYPT_ECCPUBLIC_BLOB,
|
||||
None,
|
||||
Some(&mut blob),
|
||||
&mut blob_len,
|
||||
NCRYPT_FLAGS(0),
|
||||
)?
|
||||
};
|
||||
let (header, x_and_y) = blob.split_at(HEADER_LEN);
|
||||
let is_p256 = header[..4] == BCRYPT_ECDSA_PUBLIC_P256_MAGIC.to_le_bytes();
|
||||
if !is_p256 || blob_len as usize != blob.len() {
|
||||
return Err(windows::core::Error::new(NTE_BAD_KEY, "key is not an ECDSA P-256 key"));
|
||||
}
|
||||
let public_point = [&[0x04], x_and_y].concat();
|
||||
Ok(CaKey { key_handle, _provider_handle: provider_handle, public_point })
|
||||
}
|
||||
}
|
||||
|
||||
impl PublicKeyData for CaKey {
|
||||
fn der_bytes(&self) -> &[u8] {
|
||||
&self.public_point
|
||||
}
|
||||
|
||||
fn algorithm(&self) -> &'static SignatureAlgorithm {
|
||||
&PKCS_ECDSA_P256_SHA256
|
||||
}
|
||||
}
|
||||
|
||||
impl SigningKey for CaKey {
|
||||
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, Error> {
|
||||
let mut r_and_s = [0u8; 64];
|
||||
let mut signature_len = 0;
|
||||
let signed = unsafe {
|
||||
NCryptSignHash(
|
||||
*self.key_handle,
|
||||
None,
|
||||
&sha256(msg),
|
||||
Some(&mut r_and_s),
|
||||
&mut signature_len,
|
||||
NCRYPT_SILENT_FLAG,
|
||||
)
|
||||
};
|
||||
signed.map_err(|_| Error::RemoteKeyError)?;
|
||||
// NCrypt returns r || s, each half the length; X.509 wants DER SEQUENCE { r, s }.
|
||||
let (r, s) = r_and_s[..signature_len as usize].split_at(signature_len as usize / 2);
|
||||
let der = BigNum::from_slice(r)
|
||||
.and_then(|r| EcdsaSig::from_private_components(r, BigNum::from_slice(s)?))
|
||||
.and_then(|signature| signature.to_der());
|
||||
der.map_err(|_| Error::RemoteKeyError)
|
||||
}
|
||||
}
|
||||
|
||||
/// `None` if the TPM holds no key by that name.
|
||||
/// `scope` is `NCRYPT_MACHINE_KEY_FLAG`, or empty for the current user's keys.
|
||||
fn open_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<Option<CaKey>> {
|
||||
let provider_handle = open_tpm_provider()?;
|
||||
let mut key_handle = NCRYPT_KEY_HANDLE::default();
|
||||
let opened = unsafe {
|
||||
NCryptOpenKey(
|
||||
*provider_handle,
|
||||
&mut key_handle,
|
||||
&HSTRING::from(name),
|
||||
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
|
||||
scope | NCRYPT_SILENT_FLAG,
|
||||
)
|
||||
};
|
||||
match opened {
|
||||
Err(error) if error.code() == NTE_BAD_KEYSET => Ok(None),
|
||||
Err(error) => Err(error),
|
||||
Ok(()) => CaKey::from_handles(provider_handle, unsafe { Owned::new(key_handle) }).map(Some),
|
||||
}
|
||||
}
|
||||
|
||||
/// Generates the key inside the TPM, which never lets it out.
|
||||
fn create_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<CaKey> {
|
||||
let provider_handle = open_tpm_provider()?;
|
||||
let mut key_handle = NCRYPT_KEY_HANDLE::default();
|
||||
unsafe {
|
||||
NCryptCreatePersistedKey(
|
||||
*provider_handle,
|
||||
&mut key_handle,
|
||||
NCRYPT_ECDSA_P256_ALGORITHM,
|
||||
&HSTRING::from(name),
|
||||
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
|
||||
scope,
|
||||
)?
|
||||
};
|
||||
let key_handle = unsafe { Owned::new(key_handle) };
|
||||
// Until finalized the key is only a handle; this generates and stores it.
|
||||
unsafe { NCryptFinalizeKey(*key_handle, NCRYPT_SILENT_FLAG)? };
|
||||
CaKey::from_handles(provider_handle, key_handle)
|
||||
}
|
||||
|
||||
/// The Microsoft Platform Crypto Provider, which keeps its keys in the TPM.
|
||||
fn open_tpm_provider() -> WinResult<Owned<NCRYPT_PROV_HANDLE>> {
|
||||
let mut provider_handle = NCRYPT_PROV_HANDLE::default();
|
||||
unsafe { NCryptOpenStorageProvider(&mut provider_handle, MS_PLATFORM_CRYPTO_PROVIDER, 0)? };
|
||||
Ok(unsafe { Owned::new(provider_handle) })
|
||||
}
|
||||
@@ -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,6 +1,6 @@
|
||||
use crate::http::{blocked, reply, text};
|
||||
use crate::policies::{self, Check, Decision};
|
||||
use crate::{telemetry, Firewall, FirewallConfig};
|
||||
use crate::{Firewall, FirewallConfig};
|
||||
use rama::http::body::util::BodyExt;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::CONTENT_TYPE;
|
||||
@@ -19,7 +19,6 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
|
||||
bound.set_nonblocking(true)?;
|
||||
crate::runtime()?.block_on(async move {
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
telemetry::heartbeat();
|
||||
let exec = Executor::default();
|
||||
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
|
||||
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
|
||||
@@ -45,7 +44,6 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
}
|
||||
Decision::Block { eco, pkg, filter, reason } => {
|
||||
let reason = format!("{} {reason}", pkg.label());
|
||||
telemetry::screened_package(eco, &pkg, Some(&reason));
|
||||
warn!(%eco, %filter, "blocked by firewall: {reason}");
|
||||
blocked(&reason)
|
||||
}
|
||||
@@ -53,11 +51,9 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
match &check {
|
||||
Check::NoPackage => {}
|
||||
Check::Allowed(pkg) => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
|
||||
}
|
||||
Check::Unverified { pkg, filter, error } => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
|
||||
}
|
||||
}
|
||||
|
||||
+7
-20
@@ -1,11 +1,10 @@
|
||||
use crate::policies::Package;
|
||||
use crate::{Firewall, FirewallConfig};
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::CONTENT_TYPE;
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::rt::blocking::Runtime;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{LazyLock, Mutex, OnceLock};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
@@ -30,40 +29,29 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
|
||||
})
|
||||
});
|
||||
|
||||
/// How the firewall is running: "extension", "wrapper" or "proxy".
|
||||
static MODE: OnceLock<&'static str> = OnceLock::new();
|
||||
|
||||
struct Telemetry {
|
||||
key: &'static str,
|
||||
session: String,
|
||||
events: Mutex<Vec<Value>>,
|
||||
}
|
||||
|
||||
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
|
||||
pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
|
||||
if TELEMETRY.is_none() {
|
||||
return;
|
||||
}
|
||||
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
|
||||
return;
|
||||
};
|
||||
if let Some(pkg) = firewall.resolve(registry, rest).pkg {
|
||||
screened_package(registry.eco, &pkg, reason);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn screened_package(eco: &str, pkg: &Package, reason: Option<&str>) {
|
||||
let mut properties = json!({ "eco": eco, "blocked": reason.is_some() });
|
||||
if let Some(reason) = reason {
|
||||
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
|
||||
return;
|
||||
};
|
||||
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
|
||||
if blocked {
|
||||
properties["package"] = pkg.label().into();
|
||||
properties["reason"] = reason.into();
|
||||
}
|
||||
record("package_screened", properties);
|
||||
}
|
||||
|
||||
pub fn set_mode(mode: &'static str) {
|
||||
MODE.set(mode).ok();
|
||||
}
|
||||
|
||||
pub fn error(kind: &'static str) {
|
||||
record("error", json!({ "kind": kind }));
|
||||
}
|
||||
@@ -93,7 +81,6 @@ pub fn flush() {
|
||||
fn record(event: &str, mut properties: Value) {
|
||||
let Some(telemetry) = &*TELEMETRY else { return };
|
||||
properties["distinct_id"] = telemetry.session.clone().into();
|
||||
properties["mode"] = MODE.get().copied().into();
|
||||
properties["os"] = std::env::consts::OS.into();
|
||||
properties["arch"] = std::env::consts::ARCH.into();
|
||||
properties["$process_person_profile"] = false.into();
|
||||
|
||||
+1
-27
@@ -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]
|
||||
@@ -21,23 +21,6 @@ fn the_config_lives_under_the_config_directory() {
|
||||
assert!(path.ends_with("config.json"), "{}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let user = dir.join("user.json");
|
||||
let system = dir.join("system.json");
|
||||
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
|
||||
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
|
||||
let missing = dir.join("missing.json");
|
||||
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(config.min_age_hours, 24);
|
||||
assert!(!config.fail_open);
|
||||
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// An unreachable denylist would otherwise leave the package unchecked.
|
||||
@@ -48,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:?}");
|
||||
}
|
||||
@@ -50,12 +50,3 @@ fn ca_signs_an_empty_crl() {
|
||||
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
|
||||
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
|
||||
}
|
||||
|
||||
/// A re-minted CA must not inherit the old one's CRL from a verifier's cache.
|
||||
#[test]
|
||||
fn each_ca_names_its_own_crl() {
|
||||
let signer = signer();
|
||||
let npm = mitm::CertificateAuthority::generate(&["registry.npmjs.org"], &signer).expect("mint a ca");
|
||||
let pypi = mitm::CertificateAuthority::generate(&["pypi.org"], &signer).expect("mint a ca");
|
||||
assert_ne!(npm.crl_path(), pypi.crl_path());
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
#![cfg(windows)]
|
||||
|
||||
use firewall::mitm::CertificateAuthority;
|
||||
use firewall::os::windows::CaKey;
|
||||
use rcgen::PublicKeyData;
|
||||
|
||||
/// Skipped on machines without a TPM.
|
||||
#[test]
|
||||
fn key_signs_a_verifiable_ca() {
|
||||
let key = match CaKey::create_test_key() {
|
||||
Ok(key) => key,
|
||||
Err(error) => return eprintln!("skipping, no TPM key: {error}"),
|
||||
};
|
||||
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
|
||||
let spki = key.subject_public_key_info();
|
||||
key.delete_from_tpm().expect("delete the test key");
|
||||
let ca = ca.expect("mint a ca");
|
||||
let public = ca.crt.public_key().expect("ca public key");
|
||||
assert!(ca.crt.verify(&public).expect("verify the ca"));
|
||||
assert_eq!(public.public_key_to_der().unwrap(), spki);
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
@@ -1,24 +0,0 @@
|
||||
{
|
||||
"fill" : {
|
||||
"solid" : "srgb:0.07000,0.07000,0.07000,1.00000"
|
||||
},
|
||||
"groups" : [
|
||||
{
|
||||
"layers" : [
|
||||
{
|
||||
"image-name" : "logo.png",
|
||||
"name" : "logo",
|
||||
"position" : {
|
||||
"scale" : 1.8,
|
||||
"translation-in-points" : [0, 0]
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"supported-platforms" : {
|
||||
"squares" : [
|
||||
"macOS"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
File renamed without changes.
@@ -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
|
||||
}
|
||||
File renamed without changes.
@@ -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 {
|
||||
@@ -114,43 +110,23 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
|
||||
completed = true
|
||||
log("Setup complete")
|
||||
offerRestart()
|
||||
}
|
||||
|
||||
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
|
||||
private func offerRestart() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Restart to finish setting up"
|
||||
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
|
||||
alert.addButton(withTitle: "Restart")
|
||||
alert.addButton(withTitle: "Later")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let osascript = Process()
|
||||
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
|
||||
try? osascript.run()
|
||||
}
|
||||
|
||||
/// 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) }
|
||||
|
||||
@@ -174,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 {
|
||||
@@ -191,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)
|
||||
@@ -243,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)")
|
||||
@@ -280,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
|
||||
@@ -299,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
|
||||
@@ -335,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)
|
||||
@@ -358,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()
|
||||
@@ -366,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
|
||||
@@ -408,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)
|
||||
@@ -426,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 {
|
||||
@@ -439,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -456,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)
|
||||
}
|
||||
|
||||
File renamed without changes.
+21
-31
@@ -1,10 +1,8 @@
|
||||
#!/bin/bash
|
||||
# Builds and signs StrixFirewall.app 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.
|
||||
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
|
||||
# Set NOTARY_PROFILE in macos/.env to a notarytool keychain profile to notarize the package.
|
||||
#
|
||||
# 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,41 +10,30 @@ 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
|
||||
|
||||
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/Release/Installer.app
|
||||
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
|
||||
PKG_ROOT=build/pkg-root
|
||||
|
||||
main() {
|
||||
build
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
[ -z "${NOTARY_PROFILE:-}" ] || notarize
|
||||
}
|
||||
|
||||
build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Release \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
@@ -55,20 +42,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 +77,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
|
||||
+6
-18
@@ -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/"
|
||||
Reference in new issue
Block a user