mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b74d671e38 | ||
|
|
a6bce0fabc | ||
|
|
cc3b76cdeb | ||
|
|
c079ca5835 | ||
|
|
71da167858 | ||
|
|
97d96a307c | ||
|
|
2ffe4b6b02 | ||
|
|
b212bb92ee | ||
|
|
b1713d4576 | ||
|
|
902a80b06c | ||
|
|
18d8a0dd93 | ||
|
|
d551b115c4 | ||
|
|
f88f6a9d8b | ||
|
|
1b43b9ba16 | ||
|
|
59bb6de147 | ||
|
|
39729cec84 | ||
|
|
b4e2359a5a |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
+3
-13
@@ -6,7 +6,6 @@ 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)]
|
||||
@@ -38,19 +37,11 @@ pub struct AllowedPackage {
|
||||
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 {
|
||||
@@ -68,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,
|
||||
}
|
||||
@@ -110,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"))
|
||||
}
|
||||
@@ -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(), config.denylist.token(), ecosystems);
|
||||
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)));
|
||||
|
||||
@@ -12,6 +12,10 @@ fn main() {
|
||||
firewall::init_logging("info");
|
||||
serve()
|
||||
}
|
||||
if matches.subcommand_matches("uninstall").is_some() {
|
||||
firewall::init_logging("info");
|
||||
uninstall()
|
||||
}
|
||||
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
|
||||
let program = command.next().unwrap();
|
||||
firewall::init_logging("warn");
|
||||
@@ -40,6 +44,8 @@ fn options() -> Command {
|
||||
Command::new("serve")
|
||||
.about("Serve a local registry for package managers configured to use it"),
|
||||
)
|
||||
// Run as root by the macOS app's uninstall.
|
||||
.subcommand(Command::new("uninstall").hide(true))
|
||||
.arg(
|
||||
Arg::new("COMMAND")
|
||||
.help("A command to run with its package downloads screened")
|
||||
@@ -69,6 +75,13 @@ fn serve() -> ! {
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall() -> ! {
|
||||
match firewall::os::uninstall() {
|
||||
Ok(()) => exit(0),
|
||||
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
|
||||
+3
-1
@@ -60,10 +60,12 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
|
||||
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
|
||||
}
|
||||
|
||||
pub const COMMON_NAME: &str = "Strix Firewall";
|
||||
|
||||
/// Also used by the issuer, whose issuer name must match the CA's subject.
|
||||
pub fn params(hosts: &[&str]) -> CertificateParams {
|
||||
let mut name = DistinguishedName::new();
|
||||
name.push(DnType::CommonName, "Strix Firewall");
|
||||
name.push(DnType::CommonName, COMMON_NAME);
|
||||
name.push(DnType::OrganizationName, "Strix");
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
|
||||
@@ -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}"),
|
||||
}))
|
||||
}
|
||||
@@ -113,7 +111,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 == "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,45 @@
|
||||
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()?)?;
|
||||
/// 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.
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
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(())
|
||||
}
|
||||
@@ -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::{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;
|
||||
@@ -37,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 }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -69,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"));
|
||||
@@ -147,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();
|
||||
@@ -163,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));
|
||||
}
|
||||
}
|
||||
@@ -171,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);
|
||||
}
|
||||
@@ -197,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"),
|
||||
@@ -206,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()));
|
||||
|
||||
@@ -8,7 +8,6 @@ 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]
|
||||
|
||||
+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 {
|
||||
@@ -10,11 +10,9 @@ struct ContentView: View {
|
||||
Divider()
|
||||
steps
|
||||
Divider()
|
||||
transcript
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 540, height: 560)
|
||||
.frame(width: 540)
|
||||
.task { await model.refresh() }
|
||||
.onAppear(perform: recenterIfOffscreen)
|
||||
.onChange(of: model.completed) { done in
|
||||
@@ -38,9 +36,11 @@ struct ContentView: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Image(nsImage: NSImage(named: "logo-transparent")!)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Firewall").font(.title2.weight(.semibold))
|
||||
Text("Screens package downloads before they reach your machine.")
|
||||
@@ -70,27 +70,6 @@ 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) }
|
||||
@@ -105,8 +84,11 @@ struct ContentView: View {
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
if model.awaitingApproval {
|
||||
Button("Open System Settings") { model.openApprovalSettings() }
|
||||
}
|
||||
Button("Uninstall") { Task { await model.uninstall() } }
|
||||
.disabled(model.busy)
|
||||
.disabled(model.busy && !model.awaitingApproval)
|
||||
Button("Install") { Task { await model.install() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(model.busy || model.completed)
|
||||
@@ -129,9 +111,10 @@ private struct StepRow: View {
|
||||
Text(title).font(.body.weight(.medium))
|
||||
Text(note).font(.caption).foregroundStyle(.secondary)
|
||||
Text(status.detail)
|
||||
.font(.caption)
|
||||
.font(font)
|
||||
.foregroundStyle(tint)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -146,7 +129,7 @@ private struct StepRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
|
||||
case .running:
|
||||
case .running, .needsAction:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
@@ -155,9 +138,15 @@ 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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -7,13 +7,15 @@ 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 .done(text), let .failed(text): return text
|
||||
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,33 +25,35 @@ 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
|
||||
@Published private(set) var busy = false
|
||||
@Published private var operations = 0
|
||||
var busy: Bool { operations > 0 }
|
||||
@Published private(set) var awaitingApproval = 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: CheckedContinuation<Void, Error>?
|
||||
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
|
||||
private var statusObserver: NSObjectProtocol?
|
||||
// NE only posts status notifications for connections that are still referenced.
|
||||
@Published private var watched: NETransparentProxyManager?
|
||||
@@ -87,8 +91,8 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func install() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
removed = false
|
||||
|
||||
guard CompetingExtensions.confirmInstall() else {
|
||||
@@ -114,19 +118,23 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
|
||||
func uninstall() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
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
|
||||
}
|
||||
// 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) }
|
||||
|
||||
@@ -150,16 +158,16 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
|
||||
func restart() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
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 {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
if enabled {
|
||||
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
|
||||
} else {
|
||||
@@ -167,7 +175,15 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
|
||||
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>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
self[keyPath: step] = .running(running)
|
||||
@@ -211,6 +227,9 @@ final class InstallerModel: 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)")
|
||||
@@ -245,17 +264,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
|
||||
@@ -263,14 +283,18 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
private func submit(_ request: OSSystemExtensionRequest) async throws {
|
||||
request.delegate = self
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
pendingRequest = continuation
|
||||
pendingRequest = (request, continuation)
|
||||
OSSystemExtensionManager.shared.submitRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRequest(_ result: Result<Void, Error>) {
|
||||
pendingRequest?.resume(with: result)
|
||||
/// 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 }
|
||||
pendingRequest = nil
|
||||
awaitingApproval = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
pending.continuation.resume(with: result)
|
||||
}
|
||||
|
||||
// MARK: - Network extension
|
||||
@@ -295,16 +319,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 +342,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 +350,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 +392,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)
|
||||
@@ -376,12 +410,11 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func log(_ line: String) {
|
||||
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
|
||||
logger.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest,
|
||||
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
||||
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
@@ -390,12 +423,14 @@ extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
|
||||
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
Task { @MainActor in
|
||||
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)
|
||||
// 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
|
||||
log("Waiting for user approval")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,12 +440,12 @@ extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
if result == .willCompleteAfterReboot {
|
||||
log("Extension will finish installing after a reboot")
|
||||
}
|
||||
finishRequest(.success(()))
|
||||
finishRequest(request, .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
Task { @MainActor in finishRequest(.failure(error)) }
|
||||
Task { @MainActor in finishRequest(request, .failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
+45
-25
@@ -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 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,27 +12,41 @@ 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
|
||||
|
||||
main() {
|
||||
build
|
||||
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,40 +55,44 @@ 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
|
||||
}
|
||||
|
||||
# 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/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