mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7ba37450b9 |
No files matched your search
Binary file not shown.
|
Before Width: | Height: | Size: 3.3 KiB |
Generated
+12
-72
@@ -935,7 +935,7 @@ checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1331,9 +1331,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "js-sys"
|
||||
version = "0.3.106"
|
||||
version = "0.3.105"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
|
||||
checksum = "ce57d20d1ea864ce2ac172ab472d409214f4fd359f0b2a2775abdf522e2af99e"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"futures-util",
|
||||
@@ -1500,7 +1500,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
"wasi",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
@@ -1630,24 +1630,6 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-system-configuration"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7216bd11cbda54ccabcab84d523dc93b858ec75ecfb3a7d89513fa22464da396"
|
||||
dependencies = [
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -3129,7 +3111,6 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
"which",
|
||||
"whoami",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3607,15 +3588,6 @@ version = "0.11.1+wasi-snapshot-preview1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
|
||||
|
||||
[[package]]
|
||||
name = "wasi"
|
||||
version = "0.14.7+wasi-0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "883478de20367e224c0090af9cf5f9fa85bed63a95c1abf3afc5c083ebc06e8c"
|
||||
dependencies = [
|
||||
"wasip2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasip2"
|
||||
version = "1.0.4+wasi-0.2.12"
|
||||
@@ -3625,20 +3597,11 @@ dependencies = [
|
||||
"wit-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasite"
|
||||
version = "1.0.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "66fe902b4a6b8028a753d5424909b764ccf79b7a209eac9bf97e59cda9f71a42"
|
||||
dependencies = [
|
||||
"wasi 0.14.7+wasi-0.2.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen"
|
||||
version = "0.2.129"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
|
||||
checksum = "aecb87a33d3b0c5e3b7aa46336eaf486cffafbd281b195e4c8b80d50df2351bf"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"once_cell",
|
||||
@@ -3649,9 +3612,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro"
|
||||
version = "0.2.129"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
|
||||
checksum = "a690d511e3c1a8b3a55e33511e3c2c00c78415cd23650f32b808627f5696b9ed"
|
||||
dependencies = [
|
||||
"quote",
|
||||
"wasm-bindgen-macro-support",
|
||||
@@ -3659,9 +3622,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-macro-support"
|
||||
version = "0.2.129"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
|
||||
checksum = "411e4887f0071ef2d2164a9d5fdf2d20efbef78fccd3a78b0c10a1dc5295e48a"
|
||||
dependencies = [
|
||||
"bumpalo",
|
||||
"proc-macro2",
|
||||
@@ -3672,23 +3635,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "wasm-bindgen-shared"
|
||||
version = "0.2.129"
|
||||
version = "0.2.128"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
|
||||
checksum = "81941cd78d0c92026c33e5e01312845a4cb1e9af3407f9134b100dd03144103e"
|
||||
dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "web-sys"
|
||||
version = "0.3.106"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "88261b9deccee56594c11a3460c462c41f58d148598fe70ad77070126a68aba4"
|
||||
dependencies = [
|
||||
"js-sys",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
@@ -3716,19 +3669,6 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "whoami"
|
||||
version = "2.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "626c4bac6755d76ffc12cb01b2eac751db1996b9e0041de9aa02c8c211ddc82c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"libredox",
|
||||
"objc2-system-configuration",
|
||||
"wasite",
|
||||
"web-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wildcard"
|
||||
version = "0.3.0"
|
||||
|
||||
@@ -72,7 +72,6 @@ tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
which = "8"
|
||||
whoami = "2.1.3"
|
||||
|
||||
[target.'cfg(target_vendor = "apple")'.dependencies]
|
||||
core-foundation = "0.10.1"
|
||||
|
||||
@@ -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));
|
||||
|
||||
+24
-56
@@ -1,12 +1,12 @@
|
||||
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;
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
@@ -20,8 +20,6 @@ pub struct FirewallConfig {
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Reports installs to, and takes blocks from, a Strix workspace.
|
||||
pub platform: Option<PlatformConfig>,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
@@ -35,25 +33,24 @@ pub struct AllowedPackage {
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct PlatformConfig {
|
||||
pub api_key: String,
|
||||
/// Where a self-hosted Strix lives.
|
||||
#[serde(default = "hosted")]
|
||||
pub url: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct DenylistSource {
|
||||
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
|
||||
pub base: String,
|
||||
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
|
||||
pub token: Option<String>,
|
||||
/// Polled for a new commit, which re-fetches every list in use.
|
||||
pub revision: String,
|
||||
pub poll_secs: u64,
|
||||
}
|
||||
|
||||
impl DenylistSource {
|
||||
pub fn token(&self) -> Option<String> {
|
||||
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FirewallConfig {
|
||||
fn default() -> Self {
|
||||
FirewallConfig {
|
||||
@@ -63,7 +60,6 @@ impl Default for FirewallConfig {
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
platform: None,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
}
|
||||
}
|
||||
@@ -72,8 +68,9 @@ 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(),
|
||||
token: None,
|
||||
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
}
|
||||
@@ -81,31 +78,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 {
|
||||
@@ -125,23 +109,7 @@ impl FirewallConfig {
|
||||
}
|
||||
}
|
||||
|
||||
fn hosted() -> String {
|
||||
"https://app.usestrix.com".into()
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -27,7 +27,7 @@ impl Firewall {
|
||||
patterns.insert(registry.prefix, compiled.collect());
|
||||
}
|
||||
let ecosystems = config.registries.iter().map(|r| r.osv_name);
|
||||
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
|
||||
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
|
||||
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
|
||||
@@ -4,7 +4,6 @@ pub mod firewall;
|
||||
pub mod http;
|
||||
pub mod mitm;
|
||||
pub mod os;
|
||||
mod platform;
|
||||
pub mod policies;
|
||||
pub mod proxy;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -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,
|
||||
|
||||
+1
-3
@@ -60,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,17 +21,15 @@ 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;
|
||||
|
||||
const BUNDLE_ID: &str = "com.usestrix.firewall";
|
||||
const BUNDLE_ID: &str = "ai.strix.firewall";
|
||||
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.
|
||||
/// Must match `macos/LaunchAgents/ai.strix.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"))?;
|
||||
@@ -118,7 +113,7 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
}
|
||||
|
||||
/// stderr is invisible in a system extension, so events go to the unified log:
|
||||
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
|
||||
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
|
||||
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
|
||||
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
|
||||
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
|
||||
|
||||
@@ -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";
|
||||
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.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,16 +1,2 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
#[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,73 +0,0 @@
|
||||
use crate::config::PlatformConfig;
|
||||
use crate::policies::Package;
|
||||
use crate::FirewallConfig;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::{AUTHORIZATION, CONTENT_TYPE};
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::{LazyLock, Mutex};
|
||||
use std::time::Duration;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::OffsetDateTime;
|
||||
use tracing::{debug, warn};
|
||||
|
||||
const TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// The most events the platform takes in one request.
|
||||
const BATCH: usize = 500;
|
||||
|
||||
/// On when a readable config names a Strix API key.
|
||||
static PLATFORM: LazyLock<Option<Platform>> = LazyLock::new(|| {
|
||||
let config = FirewallConfig::load().ok()?.platform?;
|
||||
Some(Platform { config, events: Mutex::default() })
|
||||
});
|
||||
|
||||
struct Platform {
|
||||
config: PlatformConfig,
|
||||
events: Mutex<Vec<Value>>,
|
||||
}
|
||||
|
||||
pub fn on() -> bool {
|
||||
PLATFORM.is_some()
|
||||
}
|
||||
|
||||
pub fn record(eco: &str, pkg: &Package, reason: Option<&str>) {
|
||||
let Some(platform) = &*PLATFORM else { return };
|
||||
let event = json!({
|
||||
"ecosystem": eco,
|
||||
"name": pkg.name,
|
||||
"version": pkg.version,
|
||||
"blocked": reason.is_some(),
|
||||
"reason": reason,
|
||||
"occurred_at": OffsetDateTime::now_utc().format(&Rfc3339).ok(),
|
||||
});
|
||||
platform.events.lock().expect("platform lock").push(event);
|
||||
}
|
||||
|
||||
/// Events in a batch the platform did not take are dropped, not retried.
|
||||
pub async fn send() {
|
||||
let Some(platform) = &*PLATFORM else { return };
|
||||
let events = std::mem::take(&mut *platform.events.lock().expect("platform lock"));
|
||||
let url = format!("{}/api/v1/firewall/events", platform.config.url);
|
||||
let client = EasyHttpWebClient::default();
|
||||
for batch in events.chunks(BATCH) {
|
||||
let body = json!({
|
||||
"device": whoami::hostname().ok(),
|
||||
"user": whoami::username().ok(),
|
||||
"os": std::env::consts::OS,
|
||||
"firewall_version": env!("CARGO_PKG_VERSION"),
|
||||
"events": batch,
|
||||
});
|
||||
let request = client
|
||||
.post(&url)
|
||||
.header(CONTENT_TYPE, "application/json")
|
||||
.header(AUTHORIZATION, format!("Bearer {}", platform.config.api_key));
|
||||
match tokio::time::timeout(TIMEOUT, request.body(body.to_string()).send()).await {
|
||||
Ok(Ok(response)) if response.status().is_success() => {
|
||||
debug!(events = batch.len(), "installs reported")
|
||||
}
|
||||
Ok(Ok(response)) => warn!(status = %response.status(), %url, "platform refused installs"),
|
||||
Ok(Err(error)) => warn!(%error, %url, "could not report installs"),
|
||||
Err(_) => warn!(%url, "reporting installs timed out"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ use crate::config::DenylistSource;
|
||||
use crate::policies::registries::Registry;
|
||||
use crate::policies::Package;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::http::{BodyExtractExt, StatusCode};
|
||||
use semver::Version;
|
||||
@@ -37,15 +37,20 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
|
||||
|
||||
pub struct Denylist {
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
lists: Arc<Lists>,
|
||||
watch: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Denylist {
|
||||
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
|
||||
pub fn new(
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
ecosystems: impl Iterator<Item = &'static str>,
|
||||
) -> Self {
|
||||
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
|
||||
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, lists, watch }
|
||||
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, token, lists, watch }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,7 +69,7 @@ impl Filter for Denylist {
|
||||
Box::pin(async move {
|
||||
let eco = registry.osv_name;
|
||||
let load = || async {
|
||||
RwLock::new(Arc::new(load(&self.source, eco).await))
|
||||
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
|
||||
};
|
||||
let list = self.lists[eco].get_or_init(load).await;
|
||||
let list = Arc::clone(&list.read().expect("denylist lock"));
|
||||
@@ -142,12 +147,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
|
||||
}
|
||||
|
||||
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
|
||||
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
|
||||
let period = Duration::from_secs(source.poll_secs);
|
||||
let mut seen = revision(&source, None).await;
|
||||
let mut seen = revision(&source, token.as_deref(), None).await;
|
||||
loop {
|
||||
tokio::time::sleep(period).await;
|
||||
let changed = revision(&source, seen.as_deref()).await;
|
||||
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
|
||||
if let Some(current) = &changed {
|
||||
info!(revision = %current, "denylist source changed");
|
||||
seen = changed.clone();
|
||||
@@ -158,7 +163,7 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
if changed.is_none() && !failed {
|
||||
continue;
|
||||
}
|
||||
if let Ok(packages) = load(&source, eco).await {
|
||||
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
|
||||
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
|
||||
}
|
||||
}
|
||||
@@ -166,9 +171,16 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
}
|
||||
|
||||
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
|
||||
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
|
||||
async fn revision(
|
||||
source: &DenylistSource,
|
||||
token: Option<&str>,
|
||||
seen: Option<&str>,
|
||||
) -> Option<String> {
|
||||
let client = EasyHttpWebClient::default();
|
||||
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(seen) = seen {
|
||||
request = request.header(IF_NONE_MATCH, seen);
|
||||
}
|
||||
@@ -185,8 +197,8 @@ async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String>
|
||||
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
|
||||
}
|
||||
|
||||
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, &eco.to_lowercase()).await;
|
||||
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, token, &eco.to_lowercase()).await;
|
||||
match &list {
|
||||
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
|
||||
Err(error) => warn!(%eco, %error, "denylist unavailable"),
|
||||
@@ -194,10 +206,13 @@ async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
|
||||
list
|
||||
}
|
||||
|
||||
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
|
||||
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
|
||||
let url = format!("{}/{eco}", source.base);
|
||||
let client = EasyHttpWebClient::default();
|
||||
let request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
let mut request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("{url} replied {}", response.status()));
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
+10
-31
@@ -1,12 +1,10 @@
|
||||
use crate::platform;
|
||||
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;
|
||||
@@ -31,47 +29,35 @@ 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>) {
|
||||
if !on() {
|
||||
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>) {
|
||||
platform::record(eco, pkg, reason);
|
||||
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 }));
|
||||
}
|
||||
|
||||
pub fn heartbeat() {
|
||||
if !on() {
|
||||
if TELEMETRY.is_none() {
|
||||
return;
|
||||
}
|
||||
tokio::spawn(async {
|
||||
@@ -83,7 +69,7 @@ pub fn heartbeat() {
|
||||
}
|
||||
|
||||
pub fn flush() {
|
||||
if !on() {
|
||||
if TELEMETRY.is_none() {
|
||||
return;
|
||||
}
|
||||
match Runtime::try_new() {
|
||||
@@ -92,15 +78,9 @@ pub fn flush() {
|
||||
}
|
||||
}
|
||||
|
||||
/// PostHog, or the Strix platform when the config names one.
|
||||
fn on() -> bool {
|
||||
TELEMETRY.is_some() || platform::on()
|
||||
}
|
||||
|
||||
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();
|
||||
@@ -110,7 +90,6 @@ fn record(event: &str, mut properties: Value) {
|
||||
}
|
||||
|
||||
pub async fn send() {
|
||||
platform::send().await;
|
||||
let Some(telemetry) = &*TELEMETRY else { return };
|
||||
let batch = std::mem::take(&mut *telemetry.events.lock().expect("telemetry lock"));
|
||||
if batch.is_empty() {
|
||||
|
||||
+2
-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]
|
||||
@@ -8,6 +8,7 @@ fn a_file_only_has_to_set_what_it_changes() {
|
||||
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
|
||||
assert!(!config.fail_open);
|
||||
assert_eq!(config.port, firewall::DEFAULT_PORT);
|
||||
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -21,23 +22,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 +32,3 @@ async fn an_allowed_version_skips_the_filters() {
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failing_closed_blocks_what_could_not_be_checked() {
|
||||
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
|
||||
let blocked = screen(url, &firewall).await.unwrap_or_default();
|
||||
assert!(blocked.contains("could not be checked"), "{blocked:?}");
|
||||
}
|
||||
+2
-2
@@ -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 }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "com.usestrix.firewall"
|
||||
static let extensionBundleID = "com.usestrix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "ai.strix.firewall"
|
||||
static let extensionBundleID = "ai.strix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
@@ -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.
+1
-1
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>com.usestrix.firewall.env</string>
|
||||
<string>ai.strix.firewall.env</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/launchctl</string>
|
||||
+25
-45
@@ -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.
|
||||
#
|
||||
# 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 because the granted NetworkExtension
|
||||
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -12,41 +10,27 @@ 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
|
||||
APP_ID=ai.strix.firewall
|
||||
EXT_ID=ai.strix.firewall.extension
|
||||
VERSION=0.1
|
||||
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
|
||||
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
|
||||
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
|
||||
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
|
||||
APP=build/Build/Products/Debug/Installer.app
|
||||
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
|
||||
PKG_ROOT=build/pkg-root
|
||||
|
||||
main() {
|
||||
build
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
}
|
||||
|
||||
build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
@@ -55,44 +39,40 @@ 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 \
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
--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 LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
|
||||
cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
|
||||
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
|
||||
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
|
||||
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
|
||||
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
|
||||
build/Firewall.pkg >/dev/null
|
||||
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
|
||||
}
|
||||
|
||||
notarize() {
|
||||
echo "==> Notarizing build/Firewall.pkg"
|
||||
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple build/Firewall.pkg
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
|
||||
# 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
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
user=$(stat -f %Su /dev/console)
|
||||
[ "$user" = root ] && exit 0
|
||||
uid=$(id -u "$user")
|
||||
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
|
||||
agent=/Library/LaunchAgents/ai.strix.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
|
||||
+14
-26
@@ -1,6 +1,6 @@
|
||||
name: Firewall
|
||||
options:
|
||||
bundleIdPrefix: com.usestrix.firewall
|
||||
bundleIdPrefix: ai.strix.firewall
|
||||
deploymentTarget: { macOS: "13.0" }
|
||||
createIntermediateGroups: true
|
||||
|
||||
@@ -33,8 +33,8 @@ targets:
|
||||
sources: [Extension]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
|
||||
PRODUCT_NAME: com.usestrix.firewall.extension
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
|
||||
PRODUCT_NAME: ai.strix.firewall.extension
|
||||
PRODUCT_MODULE_NAME: Extension
|
||||
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
|
||||
info:
|
||||
@@ -44,7 +44,7 @@ targets:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
NetworkExtension:
|
||||
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
|
||||
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
|
||||
NEProviderClasses:
|
||||
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
|
||||
entitlements:
|
||||
@@ -52,13 +52,13 @@ targets:
|
||||
properties:
|
||||
# Xcode injects these from the profile; we sign by hand, and NE's IPC
|
||||
# check rejects a caller without them.
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
|
||||
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.networking.networkextension:
|
||||
- ${NE_PROVIDER}
|
||||
- app-proxy-provider
|
||||
# NE requires NEMachServiceName to start with one of these.
|
||||
com.apple.security.application-groups:
|
||||
- ${TEAM}.com.usestrix.firewall
|
||||
- ${TEAM}.ai.strix.firewall
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.network.server: true
|
||||
dependencies:
|
||||
@@ -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
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
|
||||
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.application-identifier: ${TEAM}.ai.strix.firewall
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.system-extension.install: true
|
||||
com.apple.developer.networking.networkextension:
|
||||
- ${NE_PROVIDER}
|
||||
- app-proxy-provider
|
||||
com.apple.security.network.client: true
|
||||
dependencies:
|
||||
- target: Extension
|
||||
embed: true
|
||||
codeSign: true
|
||||
# Uninstall runs the CLI as root to remove what the extension left.
|
||||
postBuildScripts:
|
||||
- name: Embed the CLI
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
unset CC CXX CFLAGS CXXFLAGS
|
||||
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
|
||||
cd "$SRCROOT/../core"
|
||||
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"
|
||||
Reference in new issue
Block a user