mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b37f93936e | ||
|
|
bcd26b91b0 | ||
|
|
70e66280f6 | ||
|
|
1879e9061e | ||
|
|
dcee91ef04 | ||
|
|
b1713d4576 | ||
|
|
902a80b06c | ||
|
|
18d8a0dd93 | ||
|
|
d551b115c4 | ||
|
|
f88f6a9d8b | ||
|
|
1b43b9ba16 | ||
|
|
59bb6de147 | ||
|
|
39729cec84 | ||
|
|
b4e2359a5a | ||
|
|
ba690a22e2 | ||
|
|
a2e6d3f72f | ||
|
|
ac26cd704e |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
+5
-1
@@ -44,6 +44,10 @@ path = "tests/denylist.rs"
|
||||
name = "osv"
|
||||
path = "tests/osv.rs"
|
||||
|
||||
[[test]]
|
||||
name = "age"
|
||||
path = "tests/age.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mitm"
|
||||
path = "tests/mitm.rs"
|
||||
@@ -63,7 +67,7 @@ regex = "1.13.1"
|
||||
semver = "1.0.28"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
time = { version = "0.3.47", features = ["formatting"] }
|
||||
time = { version = "0.3.47", features = ["formatting", "parsing"] }
|
||||
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
|
||||
+18
-13
@@ -1,11 +1,11 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
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)]
|
||||
@@ -13,37 +13,43 @@ pub struct FirewallConfig {
|
||||
pub port: u16,
|
||||
/// Allow a package when a filter could not check it.
|
||||
pub fail_open: bool,
|
||||
/// 0 turns the age check off.
|
||||
pub min_age_hours: u64,
|
||||
pub denylist: DenylistSource,
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AllowedPackage {
|
||||
pub name: String,
|
||||
/// Every version when absent.
|
||||
pub version: Option<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 {
|
||||
port: DEFAULT_PORT,
|
||||
fail_open: true,
|
||||
min_age_hours: 0,
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
}
|
||||
@@ -53,9 +59,8 @@ impl Default for FirewallConfig {
|
||||
impl Default for DenylistSource {
|
||||
fn default() -> Self {
|
||||
DenylistSource {
|
||||
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
|
||||
token: None,
|
||||
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
|
||||
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
}
|
||||
@@ -95,6 +100,6 @@ impl FirewallConfig {
|
||||
}
|
||||
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
|
||||
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
|
||||
Some(dirs.config_dir().join("config.json"))
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::policies::filters::age::Age;
|
||||
use crate::policies::filters::denylist::Denylist;
|
||||
use crate::policies::filters::osv::Osv;
|
||||
use crate::policies::filters::Filter;
|
||||
@@ -26,8 +27,11 @@ impl Firewall {
|
||||
patterns.insert(registry.prefix, compiled.collect());
|
||||
}
|
||||
let ecosystems = config.registries.iter().map(|r| r.osv_name);
|
||||
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
|
||||
let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
|
||||
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
}
|
||||
Firewall { config, patterns, filters }
|
||||
}
|
||||
|
||||
|
||||
@@ -12,6 +12,10 @@ fn main() {
|
||||
firewall::init_logging("info");
|
||||
serve()
|
||||
}
|
||||
if matches.subcommand_matches("uninstall").is_some() {
|
||||
firewall::init_logging("info");
|
||||
uninstall()
|
||||
}
|
||||
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
|
||||
let program = command.next().unwrap();
|
||||
firewall::init_logging("warn");
|
||||
@@ -40,6 +44,8 @@ fn options() -> Command {
|
||||
Command::new("serve")
|
||||
.about("Serve a local registry for package managers configured to use it"),
|
||||
)
|
||||
// Run as root by the macOS app's uninstall.
|
||||
.subcommand(Command::new("uninstall").hide(true))
|
||||
.arg(
|
||||
Arg::new("COMMAND")
|
||||
.help("A command to run with its package downloads screened")
|
||||
@@ -69,6 +75,13 @@ fn serve() -> ! {
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall() -> ! {
|
||||
match firewall::os::uninstall() {
|
||||
Ok(()) => exit(0),
|
||||
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
|
||||
+25
-4
@@ -1,10 +1,12 @@
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
|
||||
KeyUsagePurpose, NameConstraints, SigningKey,
|
||||
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
|
||||
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
|
||||
SerialNumber, SigningKey,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
/// Held in the Secure Enclave on macOS, in memory elsewhere.
|
||||
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
|
||||
@@ -42,16 +44,35 @@ impl CertificateAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
|
||||
/// Signed fresh for each fetch; a week's validity outlasts any verifier's cache.
|
||||
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let list = CertificateRevocationListParams {
|
||||
this_update: now - Duration::hours(1),
|
||||
next_update: now + Duration::days(7),
|
||||
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs: Vec::new(),
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
let ca = params(hosts);
|
||||
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
|
||||
}
|
||||
|
||||
pub const COMMON_NAME: &str = "Strix Firewall";
|
||||
|
||||
/// Also used by the issuer, whose issuer name must match the CA's subject.
|
||||
pub fn params(hosts: &[&str]) -> CertificateParams {
|
||||
let mut name = DistinguishedName::new();
|
||||
name.push(DnType::CommonName, "Strix Firewall");
|
||||
name.push(DnType::CommonName, COMMON_NAME);
|
||||
name.push(DnType::OrganizationName, "Strix");
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.distinguished_name = name;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
|
||||
// rcgen signs a CRL only for a CA that may.
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.name_constraints = Some(NameConstraints {
|
||||
permitted_subtrees: hosts
|
||||
.iter()
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
|
||||
use rama::utils::collections::non_empty_vec;
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
|
||||
@@ -14,12 +14,18 @@ pub struct ConstrainedCertIssuer {
|
||||
ca_params: Arc<CertificateParams>,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
}
|
||||
|
||||
impl ConstrainedCertIssuer {
|
||||
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
|
||||
pub(super) fn new(
|
||||
ca_crt: X509,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
) -> Self {
|
||||
let ca_params = Arc::new(params(&hosts));
|
||||
Self { ca_crt, ca_params, signer, hosts }
|
||||
Self { ca_crt, ca_params, signer, hosts, crl }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +48,9 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
|
||||
subject.push(DnType::CommonName, common_name);
|
||||
let mut leaf = CertificateParams::new(names)?;
|
||||
leaf.distinguished_name = subject;
|
||||
if let Some(crl) = &self.crl {
|
||||
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
|
||||
}
|
||||
|
||||
// The TLS server needs the leaf key in memory, so it can't be the platform key.
|
||||
let leaf_key = KeyPair::generate()?;
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{CaSigner, CertificateAuthority};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
@@ -25,14 +25,16 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
|
||||
type InterceptService =
|
||||
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
|
||||
|
||||
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
|
||||
pub fn mitm_service(
|
||||
firewall: Arc<Firewall>,
|
||||
ca: &CertificateAuthority,
|
||||
signer: CaSigner,
|
||||
crl: Option<String>,
|
||||
exec: Executor,
|
||||
) -> MitmService {
|
||||
let hosts = firewall.hosts();
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
|
||||
|
||||
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
|
||||
|
||||
|
||||
@@ -2,6 +2,8 @@ mod enclave;
|
||||
mod install;
|
||||
mod uninstall;
|
||||
|
||||
pub use uninstall::uninstall;
|
||||
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::{mitm, telemetry};
|
||||
use crate::Firewall;
|
||||
@@ -25,10 +27,10 @@ use std::sync::Arc;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
const BUNDLE_ID: &str = "ai.strix.firewall";
|
||||
const BUNDLE_ID: &str = "com.usestrix.firewall";
|
||||
const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
|
||||
const KEYCHAIN_ACCOUNT: &str = "ca";
|
||||
/// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
|
||||
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
|
||||
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -49,15 +51,11 @@ impl TransparentProxyHandler for Handler {
|
||||
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
|
||||
}
|
||||
|
||||
/// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
|
||||
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
|
||||
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
|
||||
let message = String::from_utf8_lossy(&message).into_owned();
|
||||
Some(Bytes::from(match message.as_str() {
|
||||
"ca" => self.ca.pem.clone(),
|
||||
"uninstall" => match uninstall::certificate_authority(&self.ca) {
|
||||
Ok(()) => "ok".to_owned(),
|
||||
Err(error) => format!("error: {error}"),
|
||||
},
|
||||
other => format!("error: unknown message {other}"),
|
||||
}))
|
||||
}
|
||||
@@ -108,12 +106,12 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
let signer: mitm::CaSigner = Arc::new(key);
|
||||
let ca = install::certificate_authority(&hosts, &*signer);
|
||||
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, exec);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, service })
|
||||
}
|
||||
|
||||
/// stderr is invisible in a system extension, so events go to the unified log:
|
||||
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
|
||||
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
|
||||
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
|
||||
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
|
||||
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
|
||||
|
||||
@@ -1,26 +1,47 @@
|
||||
use super::enclave::EnclaveKey;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use crate::mitm::CertificateAuthority;
|
||||
use crate::mitm::COMMON_NAME;
|
||||
use rama::error::BoxError;
|
||||
use rama::net::apple::networkextension::system_keychain;
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework_sys::base::errSecItemNotFound;
|
||||
use std::io::ErrorKind;
|
||||
use tracing::info;
|
||||
|
||||
/// Installed by the pkg.
|
||||
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
|
||||
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
|
||||
|
||||
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
|
||||
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
|
||||
pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
|
||||
system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
/// Removes what only root can: every CA the extension minted, its enclave key and exported
|
||||
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those.
|
||||
pub fn uninstall() -> Result<(), BoxError> {
|
||||
let deleted = ItemSearchOptions::new()
|
||||
.class(ItemClass::certificate())
|
||||
.keychains(&[SecKeychain::open("/Library/Keychains/System.keychain")?])
|
||||
.label(COMMON_NAME)
|
||||
.limit(Limit::All)
|
||||
.delete();
|
||||
let mut removed = match deleted {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.code() == errSecItemNotFound => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
if system_keychain::load_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?.is_some() {
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
removed = true;
|
||||
}
|
||||
EnclaveKey::delete()?;
|
||||
for path in [CA_FILE, ENV_AGENT] {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
|
||||
_ => {}
|
||||
Ok(()) => removed = true,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
info!("removed the CA from the system keychain");
|
||||
if removed {
|
||||
info!("removed the CA from the system keychain");
|
||||
} else {
|
||||
info!("nothing to uninstall");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,2 +1,11 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::uninstall;
|
||||
|
||||
/// Elsewhere nothing is installed system-wide.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn uninstall() -> Result<(), rama::error::BoxError> {
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::{Filter, Verdict, VerdictFuture, AGENT};
|
||||
use crate::policies::registries::{Published, Registry};
|
||||
use crate::policies::Package;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::USER_AGENT;
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::http::{BodyExtractExt, StatusCode};
|
||||
use serde_json::Value;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
pub struct Age {
|
||||
min: Duration,
|
||||
}
|
||||
|
||||
impl Age {
|
||||
pub fn new(hours: u64) -> Self {
|
||||
Age { min: Duration::hours(hours as i64) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter for Age {
|
||||
fn name(&self) -> &'static str {
|
||||
"age"
|
||||
}
|
||||
|
||||
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
|
||||
let Some(published) = ®istry.published else { return Verdict::Allow };
|
||||
let published_at = match published_at(published, &pkg.name, version).await {
|
||||
Ok(at) => at,
|
||||
Err(e) => return Verdict::Unknown(e),
|
||||
};
|
||||
|
||||
let age = OffsetDateTime::now_utc() - published_at;
|
||||
if age >= self.min {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
|
||||
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn published_at(
|
||||
published: &Published,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Result<OffsetDateTime, String> {
|
||||
let name = match published.escape {
|
||||
Some(escape) => escape(name),
|
||||
None => name.to_string(),
|
||||
};
|
||||
let url = published.url.replace("{name}", &name).replace("{version}", version);
|
||||
let pointer = published.pointer.replace("{version}", version);
|
||||
|
||||
let client = EasyHttpWebClient::default();
|
||||
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
|
||||
let response = response.map_err(|e| e.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("{url} replied {}", response.status()));
|
||||
}
|
||||
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
|
||||
let body: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
|
||||
|
||||
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
|
||||
return Err(format!("{url} gave no publish time"));
|
||||
};
|
||||
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::{Filter, Verdict, VerdictFuture};
|
||||
use super::{Filter, Verdict, VerdictFuture, AGENT};
|
||||
use crate::config::DenylistSource;
|
||||
use crate::policies::registries::Registry;
|
||||
use crate::policies::Package;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::http::{BodyExtractExt, StatusCode};
|
||||
use semver::Version;
|
||||
@@ -14,9 +14,6 @@ use tokio::sync::OnceCell;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// The GitHub API turns away requests without a user agent.
|
||||
const AGENT: &str = "strix-firewall";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum AffectedVersions {
|
||||
All,
|
||||
@@ -40,20 +37,15 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
|
||||
|
||||
pub struct Denylist {
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
lists: Arc<Lists>,
|
||||
watch: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Denylist {
|
||||
pub fn new(
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
ecosystems: impl Iterator<Item = &'static str>,
|
||||
) -> Self {
|
||||
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
|
||||
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
|
||||
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, token, lists, watch }
|
||||
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, lists, watch }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +64,7 @@ impl Filter for Denylist {
|
||||
Box::pin(async move {
|
||||
let eco = registry.osv_name;
|
||||
let load = || async {
|
||||
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
|
||||
RwLock::new(Arc::new(load(&self.source, eco).await))
|
||||
};
|
||||
let list = self.lists[eco].get_or_init(load).await;
|
||||
let list = Arc::clone(&list.read().expect("denylist lock"));
|
||||
@@ -150,12 +142,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
|
||||
}
|
||||
|
||||
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
|
||||
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
|
||||
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
let period = Duration::from_secs(source.poll_secs);
|
||||
let mut seen = revision(&source, token.as_deref(), None).await;
|
||||
let mut seen = revision(&source, None).await;
|
||||
loop {
|
||||
tokio::time::sleep(period).await;
|
||||
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
|
||||
let changed = revision(&source, seen.as_deref()).await;
|
||||
if let Some(current) = &changed {
|
||||
info!(revision = %current, "denylist source changed");
|
||||
seen = changed.clone();
|
||||
@@ -166,7 +158,7 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
|
||||
if changed.is_none() && !failed {
|
||||
continue;
|
||||
}
|
||||
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
|
||||
if let Ok(packages) = load(&source, eco).await {
|
||||
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
|
||||
}
|
||||
}
|
||||
@@ -174,16 +166,9 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
|
||||
}
|
||||
|
||||
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
|
||||
async fn revision(
|
||||
source: &DenylistSource,
|
||||
token: Option<&str>,
|
||||
seen: Option<&str>,
|
||||
) -> Option<String> {
|
||||
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
|
||||
let client = EasyHttpWebClient::default();
|
||||
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(seen) = seen {
|
||||
request = request.header(IF_NONE_MATCH, seen);
|
||||
}
|
||||
@@ -200,8 +185,8 @@ async fn revision(
|
||||
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
|
||||
}
|
||||
|
||||
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, token, &eco.to_lowercase()).await;
|
||||
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, &eco.to_lowercase()).await;
|
||||
match &list {
|
||||
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
|
||||
Err(error) => warn!(%eco, %error, "denylist unavailable"),
|
||||
@@ -209,13 +194,10 @@ async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -
|
||||
list
|
||||
}
|
||||
|
||||
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
|
||||
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
|
||||
let url = format!("{}/{eco}", source.base);
|
||||
let client = EasyHttpWebClient::default();
|
||||
let mut request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
let request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("{url} replied {}", response.status()));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod age;
|
||||
pub mod denylist;
|
||||
pub mod osv;
|
||||
|
||||
@@ -6,6 +7,9 @@ use crate::Firewall;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Some registry APIs turn away requests without a user agent.
|
||||
const AGENT: &str = "strix-firewall";
|
||||
|
||||
pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
|
||||
|
||||
/// `Unknown` means the filter could not check the package.
|
||||
@@ -26,6 +30,9 @@ pub async fn consult(
|
||||
registry: &'static Registry,
|
||||
pkg: &Package,
|
||||
) -> Option<(&'static str, Verdict)> {
|
||||
if allowed(firewall, registry, pkg) {
|
||||
return None;
|
||||
}
|
||||
let mut unsure = None;
|
||||
for filter in firewall.filters() {
|
||||
match filter.check(registry, pkg).await {
|
||||
@@ -37,3 +44,18 @@ pub async fn consult(
|
||||
}
|
||||
unsure
|
||||
}
|
||||
|
||||
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
|
||||
let Some(entries) = firewall.config.allow.get(registry.eco) else {
|
||||
return false;
|
||||
};
|
||||
entries.iter().any(|entry| {
|
||||
let name_matches = match registry.normalize {
|
||||
Some(normalize) => normalize(&entry.name) == pkg.name,
|
||||
None => entry.name == pkg.name,
|
||||
};
|
||||
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
|
||||
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
|
||||
name_matches && (version.is_none() || version == pkg.version.as_deref())
|
||||
})
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static CARGO_INDEX: Registry = Registry {
|
||||
prefix: "/cargo/index/",
|
||||
@@ -16,6 +16,7 @@ pub static CARGO_INDEX: Registry = Registry {
|
||||
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
|
||||
Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
|
||||
],
|
||||
published: None,
|
||||
};
|
||||
|
||||
pub static CARGO_DL: Registry = Registry {
|
||||
@@ -26,6 +27,11 @@ pub static CARGO_DL: Registry = Registry {
|
||||
index: false,
|
||||
normalize: None,
|
||||
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: None }],
|
||||
published: Some(Published {
|
||||
url: "https://crates.io/api/v1/crates/{name}/{version}",
|
||||
pointer: "/version/created_at",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
|
||||
/// `cargo add` resolves versions here, named by config.json's "api" field.
|
||||
@@ -37,4 +43,5 @@ pub static CRATES_API: Registry = Registry {
|
||||
index: false,
|
||||
normalize: None,
|
||||
patterns: &[],
|
||||
published: None,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static GEM: Registry = Registry {
|
||||
prefix: "/gem/",
|
||||
@@ -16,4 +16,9 @@ pub static GEM: Registry = Registry {
|
||||
// The compact index, which names no version.
|
||||
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None },
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://rubygems.org/api/v2/rubygems/{name}/versions/{version}.json",
|
||||
pointer: "/created_at",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static GO: Registry = Registry {
|
||||
prefix: "/go/",
|
||||
@@ -13,6 +13,11 @@ pub static GO: Registry = Registry {
|
||||
// The version list and the latest lookup, which name no version.
|
||||
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None },
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://proxy.golang.org/{name}/@v/v{version}.info",
|
||||
pointer: "/Time",
|
||||
escape: Some(escape),
|
||||
}),
|
||||
};
|
||||
|
||||
/// The proxy protocol writes every capital as !<lowercase>; advisories do not.
|
||||
@@ -27,3 +32,14 @@ fn unescape(path: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape(path: &str) -> String {
|
||||
let mut out = String::with_capacity(path.len());
|
||||
for c in path.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('!');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -48,6 +48,15 @@ pub struct Registry {
|
||||
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
|
||||
pub normalize: Option<fn(&str) -> String>,
|
||||
pub patterns: &'static [Pattern],
|
||||
pub published: Option<Published>,
|
||||
}
|
||||
|
||||
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
|
||||
#[derive(Debug)]
|
||||
pub struct Published {
|
||||
pub url: &'static str,
|
||||
pub pointer: &'static str,
|
||||
pub escape: Option<fn(&str) -> String>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static NPM: Registry = Registry {
|
||||
prefix: "/npm/",
|
||||
@@ -18,4 +18,9 @@ pub static NPM: Registry = Registry {
|
||||
rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://registry.npmjs.org/{name}",
|
||||
pointer: "/time/{version}",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub static PYPI: Registry = Registry {
|
||||
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
|
||||
Pattern { re: "", rewrite: Some(FILES) },
|
||||
],
|
||||
published: None,
|
||||
};
|
||||
|
||||
/// The directories are hash shards, but the filename names the distribution.
|
||||
@@ -35,6 +36,11 @@ pub static PYPI_FILES: Registry = Registry {
|
||||
rewrite: None,
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://pypi.org/pypi/{name}/{version}/json",
|
||||
pointer: "/urls/0/upload_time_iso_8601",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
|
||||
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use firewall::policies::screen;
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
use rama::rt::blocking::Runtime;
|
||||
|
||||
const OLD_DOWNLOADS: &[&str] = &[
|
||||
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
|
||||
"https://files.pythonhosted.org/packages/aa/bb/requests-2.31.0-py3-none-any.whl",
|
||||
"https://static.crates.io/crates/serde/1.0.200",
|
||||
"https://rubygems.org/gems/rake-13.0.6.gem",
|
||||
"https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/v1.3.2.zip",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "queries every registry"]
|
||||
fn every_registry_reports_when_a_version_was_published() {
|
||||
let runtime = Runtime::try_new().expect("runtime");
|
||||
runtime.block_on(async {
|
||||
let firewall =
|
||||
|hours| Firewall::new(FirewallConfig { min_age_hours: hours, ..Default::default() });
|
||||
let (lenient, strict) = (firewall(24), firewall(24 * 365 * 100));
|
||||
for url in OLD_DOWNLOADS {
|
||||
assert_eq!(screen(url, &lenient).await, None, "{url}");
|
||||
let blocked = screen(url, &strict).await.unwrap_or_default();
|
||||
assert!(blocked.contains("hour minimum"), "{url}: {blocked:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
+14
-2
@@ -1,12 +1,13 @@
|
||||
use firewall::config::path;
|
||||
use firewall::FirewallConfig;
|
||||
use firewall::policies::filters::consult;
|
||||
use firewall::policies::Package;
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
|
||||
#[test]
|
||||
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]
|
||||
@@ -19,3 +20,14 @@ fn the_config_lives_under_the_config_directory() {
|
||||
let path = path().expect("a home directory");
|
||||
assert!(path.ends_with("config.json"), "{}", path.display());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// An unreachable denylist would otherwise leave the package unchecked.
|
||||
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
|
||||
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
}
|
||||
@@ -43,3 +43,10 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
|
||||
|
||||
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
|
||||
}
|
||||
|
||||
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
+2
-2
@@ -3,5 +3,5 @@ Firewall.xcodeproj
|
||||
.env
|
||||
Extension/Extension.entitlements
|
||||
Extension/Info.plist
|
||||
Installer/Installer.entitlements
|
||||
Installer/Info.plist
|
||||
App/App.entitlements
|
||||
App/Info.plist
|
||||
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct InstallerApp: App {
|
||||
@StateObject private var model = InstallerModel()
|
||||
struct FirewallApp: App {
|
||||
@StateObject private var model = FirewallModel()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
@@ -31,7 +31,7 @@ enum SetupWindow {
|
||||
|
||||
/// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
|
||||
private struct MenuBarLabel: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var raised = false
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "com.usestrix.firewall"
|
||||
static let extensionBundleID = "com.usestrix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
@@ -23,22 +23,23 @@ enum StepStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum InstallerError: Error, LocalizedError {
|
||||
enum FirewallError: Error, LocalizedError {
|
||||
case extensionUnavailable
|
||||
case extensionRefused(String)
|
||||
case screeningDidNotStart(NEVPNStatus)
|
||||
case extensionStopped(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .extensionUnavailable: return "The extension is not running"
|
||||
case let .extensionRefused(reply): return "The extension replied \(reply)"
|
||||
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
|
||||
case let .extensionStopped(reason):
|
||||
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InstallerModel: NSObject, ObservableObject {
|
||||
final class FirewallModel: NSObject, ObservableObject {
|
||||
@Published private(set) var extensionStep = StepStatus.pending
|
||||
@Published private(set) var screeningStep = StepStatus.pending
|
||||
@Published private(set) var trustStep = StepStatus.pending
|
||||
@@ -124,9 +125,9 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
log("Uninstall cancelled")
|
||||
return
|
||||
}
|
||||
// The extension owns the private key, so ask it before tearing down the session that carries the request.
|
||||
let strandedKey = await removeStoredCA()
|
||||
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
|
||||
await removeConfiguration()
|
||||
let strandedKey = removeStoredCA()
|
||||
await deactivate()
|
||||
if let strandedKey { trustStep = .failed(strandedKey) }
|
||||
|
||||
@@ -167,7 +168,7 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
|
||||
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
self[keyPath: step] = .running(running)
|
||||
@@ -245,17 +246,18 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Only root can delete the key, so the extension does it; returns what to report if it survived.
|
||||
private func removeStoredCA() async -> String? {
|
||||
do {
|
||||
let reply = try await providerMessage("uninstall")
|
||||
guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
|
||||
log("Removed the CA, its exported copy and the env agent")
|
||||
return nil
|
||||
} catch {
|
||||
log("Could not remove the CA: \(error.localizedDescription)")
|
||||
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
|
||||
private func removeStoredCA() -> String? {
|
||||
let cli = Bundle.main.path(forAuxiliaryExecutable: "firewall") ?? ""
|
||||
let script = "do shell script quoted form of \"\(cli)\" & \" uninstall\" with administrator privileges"
|
||||
var error: NSDictionary?
|
||||
NSAppleScript(source: script)?.executeAndReturnError(&error)
|
||||
if let error {
|
||||
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)")
|
||||
return "The CA private key is still in the System keychain"
|
||||
}
|
||||
log("Removed the CA, its exported copy and the env agent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - System extension
|
||||
@@ -295,16 +297,22 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
try await manager.loadFromPreferences()
|
||||
watched = manager
|
||||
|
||||
screeningStep = .running("Starting the extension")
|
||||
try manager.connection.startVPNTunnel()
|
||||
guard try await wait(for: .connected, on: manager.connection) else {
|
||||
throw InstallerError.screeningDidNotStart(manager.connection.status)
|
||||
do {
|
||||
try await manager.connection.fetchLastDisconnectError()
|
||||
} catch {
|
||||
throw FirewallError.extensionStopped(error.localizedDescription)
|
||||
}
|
||||
throw FirewallError.screeningDidNotStart(manager.connection.status)
|
||||
}
|
||||
return "Screening package downloads"
|
||||
}
|
||||
|
||||
private func restartScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else {
|
||||
throw InstallerError.extensionUnavailable
|
||||
throw FirewallError.extensionUnavailable
|
||||
}
|
||||
manager.connection.stopVPNTunnel()
|
||||
_ = try await wait(for: .disconnected, on: manager.connection)
|
||||
@@ -312,7 +320,7 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func stopScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
|
||||
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
|
||||
manager.isEnabled = false
|
||||
try await manager.saveToPreferences()
|
||||
manager.connection.stopVPNTunnel()
|
||||
@@ -320,9 +328,13 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
return "Screening disabled"
|
||||
}
|
||||
|
||||
/// Gives up early once a start has failed, which shows as connecting then disconnected.
|
||||
private func wait(for status: NEVPNStatus, on connection: NEVPNConnection) async throws -> Bool {
|
||||
var connecting = false
|
||||
for _ in 0 ..< 60 {
|
||||
if connection.status == status { return true }
|
||||
if connection.status == .connecting { connecting = true }
|
||||
if connecting && connection.status == .disconnected { return false }
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
return false
|
||||
@@ -358,13 +370,13 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
private func providerMessage(_ message: String) async throws -> String {
|
||||
guard let session = watched?.connection as? NETunnelProviderSession else {
|
||||
throw InstallerError.extensionUnavailable
|
||||
throw FirewallError.extensionUnavailable
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session.sendProviderMessage(Data(message.utf8)) { response in
|
||||
guard let response, let reply = String(data: response, encoding: .utf8) else {
|
||||
continuation.resume(throwing: InstallerError.extensionUnavailable)
|
||||
continuation.resume(throwing: FirewallError.extensionUnavailable)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: reply)
|
||||
@@ -381,7 +393,7 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest,
|
||||
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
||||
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
@@ -9,7 +9,7 @@ struct MenuBarView: View {
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Installer") {
|
||||
Button("Setup") {
|
||||
SetupWindow.open(openWindow)
|
||||
}
|
||||
|
||||
File renamed without changes.
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "ai.strix.firewall"
|
||||
static let extensionBundleID = "ai.strix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.strix.firewall.env</string>
|
||||
<string>com.usestrix.firewall.env</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/launchctl</string>
|
||||
+48
-20
@@ -1,8 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
|
||||
# Builds and signs StrixFirewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
|
||||
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
|
||||
# makes a Debug build and skips notarization.
|
||||
#
|
||||
# Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
|
||||
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
|
||||
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing
|
||||
# because macOS refuses to activate a Developer ID extension that is not notarized.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -10,13 +12,27 @@ cd "$(dirname "$0")"
|
||||
: "${TEAM:?set TEAM in macos/.env}"
|
||||
export TEAM
|
||||
|
||||
APP_ID=ai.strix.firewall
|
||||
EXT_ID=ai.strix.firewall.extension
|
||||
# Identities are matched as a substring of the certificate name; set them if more than one matches.
|
||||
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension.
|
||||
if [ "${1:-}" = debug ]; then
|
||||
CONFIGURATION=Debug
|
||||
IDENTITY=${DEBUG_IDENTITY:-Apple Development}
|
||||
NE_PROVIDER=app-proxy-provider
|
||||
else
|
||||
CONFIGURATION=Release
|
||||
IDENTITY=${IDENTITY:-Developer ID Application}
|
||||
NE_PROVIDER=app-proxy-provider-systemextension
|
||||
: "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
|
||||
fi
|
||||
export NE_PROVIDER
|
||||
|
||||
APP_ID=com.usestrix.firewall
|
||||
EXT_ID=com.usestrix.firewall.extension
|
||||
VERSION=0.1
|
||||
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
|
||||
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
|
||||
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
|
||||
APP=build/Build/Products/Debug/Installer.app
|
||||
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
|
||||
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
|
||||
PKG_ROOT=build/pkg-root
|
||||
|
||||
@@ -25,12 +41,13 @@ main() {
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
}
|
||||
|
||||
build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
@@ -39,18 +56,19 @@ sign() {
|
||||
echo "==> Signing with $IDENTITY"
|
||||
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
|
||||
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
|
||||
codesign --verify --deep --strict "$APP"
|
||||
}
|
||||
|
||||
# The extension can only be activated from /Applications.
|
||||
install_app() {
|
||||
echo "==> Installing /Applications/Firewall.app"
|
||||
rm -rf /Applications/Firewall.app
|
||||
cp -R "$APP" /Applications/Firewall.app
|
||||
echo "==> Installing /Applications/StrixFirewall.app"
|
||||
rm -rf /Applications/StrixFirewall.app
|
||||
cp -R "$APP" /Applications/StrixFirewall.app
|
||||
}
|
||||
|
||||
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
|
||||
@@ -58,21 +76,31 @@ package() {
|
||||
echo "==> Packaging build/Firewall.pkg"
|
||||
rm -rf "$PKG_ROOT"
|
||||
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
|
||||
cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app"
|
||||
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
|
||||
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
|
||||
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
|
||||
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
|
||||
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
|
||||
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
|
||||
build/Firewall.pkg >/dev/null
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
|
||||
notarize() {
|
||||
echo "==> Notarizing build/Firewall.pkg"
|
||||
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple build/Firewall.pkg
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
|
||||
profile_for() {
|
||||
local want=$TEAM.$1 newest= newest_time=0 id t
|
||||
local want=$TEAM.$1 newest= newest_time=0 profile id t
|
||||
for p in "$PROFILES"/*.provisionprofile; do
|
||||
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
|
||||
profile=$(security cms -D -i "$p")
|
||||
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
|
||||
[ "$id" = "$want" ] || continue
|
||||
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
|
||||
grep -q "\"$NE_PROVIDER\"" || continue
|
||||
t=$(stat -f %m "$p")
|
||||
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
|
||||
done
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
user=$(stat -f %Su /dev/console)
|
||||
[ "$user" = root ] && exit 0
|
||||
uid=$(id -u "$user")
|
||||
agent=/Library/LaunchAgents/ai.strix.firewall.env.plist
|
||||
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
|
||||
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
|
||||
launchctl bootstrap "gui/$uid" "$agent"
|
||||
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
|
||||
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install
|
||||
exit 0
|
||||
+25
-14
@@ -1,6 +1,6 @@
|
||||
name: Firewall
|
||||
options:
|
||||
bundleIdPrefix: ai.strix.firewall
|
||||
bundleIdPrefix: com.usestrix.firewall
|
||||
deploymentTarget: { macOS: "13.0" }
|
||||
createIntermediateGroups: true
|
||||
|
||||
@@ -33,8 +33,8 @@ targets:
|
||||
sources: [Extension]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
|
||||
PRODUCT_NAME: ai.strix.firewall.extension
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
|
||||
PRODUCT_NAME: com.usestrix.firewall.extension
|
||||
PRODUCT_MODULE_NAME: Extension
|
||||
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
|
||||
info:
|
||||
@@ -44,7 +44,7 @@ targets:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
NetworkExtension:
|
||||
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
|
||||
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
|
||||
NEProviderClasses:
|
||||
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
|
||||
entitlements:
|
||||
@@ -52,13 +52,13 @@ targets:
|
||||
properties:
|
||||
# Xcode injects these from the profile; we sign by hand, and NE's IPC
|
||||
# check rejects a caller without them.
|
||||
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.networking.networkextension:
|
||||
- app-proxy-provider
|
||||
- ${NE_PROVIDER}
|
||||
# NE requires NEMachServiceName to start with one of these.
|
||||
com.apple.security.application-groups:
|
||||
- ${TEAM}.ai.strix.firewall
|
||||
- ${TEAM}.com.usestrix.firewall
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.network.server: true
|
||||
dependencies:
|
||||
@@ -87,33 +87,44 @@ targets:
|
||||
$([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
done
|
||||
|
||||
Installer:
|
||||
StrixFirewall:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- Installer
|
||||
- App
|
||||
- path: ../assets/logo-transparent.png
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
|
||||
info:
|
||||
path: Installer/Info.plist
|
||||
path: App/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
LSUIElement: true
|
||||
entitlements:
|
||||
path: Installer/Installer.entitlements
|
||||
path: App/App.entitlements
|
||||
properties:
|
||||
com.apple.application-identifier: ${TEAM}.ai.strix.firewall
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.system-extension.install: true
|
||||
com.apple.developer.networking.networkextension:
|
||||
- app-proxy-provider
|
||||
- ${NE_PROVIDER}
|
||||
com.apple.security.network.client: true
|
||||
dependencies:
|
||||
- target: Extension
|
||||
embed: true
|
||||
codeSign: true
|
||||
# Uninstall runs the CLI as root to remove what the extension left.
|
||||
postBuildScripts:
|
||||
- name: Embed the CLI
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
unset CC CXX CFLAGS CXXFLAGS
|
||||
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
|
||||
cd "$SRCROOT/../core"
|
||||
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"
|
||||
Reference in new issue
Block a user