Compare commits

..
Author SHA1 Message Date
Ian 5c50344062 Rotate the service log daily with tracing-appender 2026-10-22 14:37:48 -04:00
Ian 9c85bd84e5 Free the parsed security descriptor on drop instead of through a closure 2026-10-22 14:17:11 -04:00
Ian 78172519f0 Use schannel for the root store 2026-10-22 14:03:26 -04:00
Ian 902afd031d Explain the data folder ACL and the root store arguments 2026-10-22 13:52:02 -04:00
Ian Graham Martinez 19d29f1ea6 Screen redirected registry traffic in a Windows service
Each registry host gets its own loopback address, where a LocalSystem
service serves mitm_service, connecting upstream through a resolver that
ignores the hosts file. It trusts its CA machine-wide and serves the empty
CRL that schannel needs.

The service writes the hosts-file block once it listens. Pausing it removes
the block and continuing rewrites it; a stop, crash or shutdown leaves it,
so a restart fails closed and picks up a changed host list.
2026-10-22 11:48:15 -04:00
ian-at-strixandIan Graham Martinez 7cafa16cc4 Hold the CA key in the TPM on Windows (#9)
* Hold the CA key in the TPM on Windows

A non-exportable machine key in the TPM, or in software where there is
none, signs as the Secure Enclave key does on macOS.

* Require the TPM for the Windows CA key

A failure to open the TPM fell back to a software key, which minted a new CA
and kept it for good. Fail instead, as macOS does without a Secure Enclave.

* Use the windows crate for the TPM key

---------

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-22 10:53:51 -04:00
ian-at-strix 38d9a4500e Layer a system config over the user's, and have the extension read only the system one (#43) 2026-10-21 20:07:55 -04:00
ian-at-strix d03c4cb6e3 Make the app icon an Icon Composer icon so macOS 26 doesn't put it in a grey bubble (#42) 2026-10-21 18:15:02 -04:00
ian-at-strix 8f20758ba0 Offer a restart once setup finishes (#35)
* Offer a restart once setup finishes

* Describe the restart without naming one variable
2026-10-21 18:07:41 -04:00
ian-at-strix 577e0b7eb0 Screening events from the local proxy, and the mode each event came from (#41) 2026-10-21 17:55:21 -04:00
ian-at-strix f94d3bf211 Include why a package was blocked in the screening event (#39) 2026-10-21 17:50:04 -04:00
ian-at-strix 753e21dd19 Block what could not be checked when screening fails closed (#20) 2026-10-21 17:48:29 -04:00
ian-at-strix 7c128de056 Give the app the Strix logo as its icon (#34) 2026-10-21 16:45:38 -04:00
ian-at-strix 64e938bf7a Guide the user to the extension approval switch (#33)
* Guide the user to the extension approval switch

* Allow uninstalling while the extension waits for approval

* Show the approval instruction in larger orange text

* Leave opening System Settings to the macOS alert

* Shrink the approval instruction slightly

* Hide the setup log

* Add a button that opens System Settings while approval is pending
2026-10-21 16:44:27 -04:00
ian-at-strix b212bb92ee Uninstall through the bundled CLI (#24)
* Remove the CA from the app, not the extension

* Uninstall through the bundled CLI

* Log uninstall only when something was removed

* Rename the Installer app to StrixFirewall (#28)

* Rename the Installer app to Firewall

* Name the app Strix Firewall

* Name the app StrixFirewall

* Show why the extension failed to start (#31)

* Delete the CA secret without reading it

* Treat a missing extension as already deactivated

* Leave installing the app to the package
2026-10-21 16:01:25 -04:00
ian-at-strix b1713d4576 Store config under com.usestrix.firewall (#30) 2026-10-21 15:27:59 -04:00
ian-at-strix 902a80b06c Sign debug builds with Apple Development (#27) 2026-10-21 15:11:12 -04:00
ian-at-strix 18d8a0dd93 Read the denylist from usestrix/firewall-blocklist (#26) 2026-10-21 14:06:18 -04:00
ian-at-strix d551b115c4 Add a debug build that skips notarization (#25) 2026-10-21 14:02:23 -04:00
ian-at-strix f88f6a9d8b Sign with Developer ID and notarize the package (#22)
* Sign with Developer ID and notarize the package

* Name signing identities by type

* Say how signing identities are matched

* Notarize only when NOTARY_PROFILE is set
2026-10-21 13:01:23 -04:00
Ian 1b43b9ba16 logo 2026-10-21 12:46:53 -04:00
ian-at-strix 59bb6de147 Use the com.usestrix.firewall bundle ID (#21) 2026-10-21 12:08:29 -04:00
ian-at-strix 39729cec84 Drop the GitHub token from denylist fetches (#19) 2026-10-21 12:02:53 -04:00
ian-at-strix b4e2359a5a Let the config allow packages, optionally by version (#16) 2026-10-21 11:38:36 -04:00
ian-at-strix ba690a22e2 Block versions published under a minimum age (#17)
* Block versions published under a minimum age

* Trim comments and tidy the age filter
2026-10-21 11:09:32 -04:00
ian-at-strixandIan Graham Martinez a2e6d3f72f Sign an empty CRL and point leaves at it (#8)
schannel's revocation check, which cargo enables, fails closed on a leaf
without a reachable CRL. The CA gains cRLSign, which rcgen needs to sign
one, and leaves carry a distribution point when the platform serves it.

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-13 11:11:25 -04:00
ian-at-strixandIan Graham Martinez ac26cd704e List the screened hosts from the config (#7)
Building a Firewall needs a runtime, which an installer has no use for.

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-13 11:11:25 -04:00
49 changed files with 1651 additions and 234 deletions

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+305 -3
View File
@@ -172,6 +172,17 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "async-trait"
version = "0.1.92"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
"syn 3.0.6",
]
[[package]]
name = "asynk-strim"
version = "0.1.5"
@@ -456,6 +467,16 @@ dependencies = [
"unicode-xid",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -496,6 +517,12 @@ dependencies = [
"cfg-if",
]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]]
name = "crossbeam-channel"
version = "0.5.17"
@@ -997,6 +1024,78 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hickory-net"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c480823ed7c2c5d0f09c41020cb6b7c28029ce60ec42dc942158dcf22f8e0a4d"
dependencies = [
"async-trait",
"cfg-if",
"data-encoding",
"futures-channel",
"futures-io",
"futures-util",
"hickory-proto",
"idna",
"ipnet",
"jni",
"rand",
"thiserror",
"tinyvec",
"tokio",
"tracing",
"url",
]
[[package]]
name = "hickory-proto"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "12b92608f679a6fa515dd1d15c1ff89443026e391200a2c840c7afcba482893d"
dependencies = [
"data-encoding",
"idna",
"ipnet",
"jni",
"once_cell",
"prefix-trie",
"rand",
"ring",
"serde",
"thiserror",
"tinyvec",
"tracing",
"url",
]
[[package]]
name = "hickory-resolver"
version = "0.26.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f3da5255c95d5a716857d54b5b8f4e8d67c3484d3beaaaae2ce25063b3ba981"
dependencies = [
"cfg-if",
"futures-util",
"hickory-net",
"hickory-proto",
"ipconfig",
"ipnet",
"jni",
"moka",
"ndk-context",
"once_cell",
"parking_lot",
"rand",
"resolv-conf",
"serde",
"smallvec",
"system-configuration",
"thiserror",
"tokio",
"tracing",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1158,6 +1257,19 @@ dependencies = [
"hashbrown 0.17.1",
]
[[package]]
name = "ipconfig"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4d40460c0ce33d6ce4b0630ad68ff63d6661961c48b6dba35e5a4d81cfb48222"
dependencies = [
"socket2",
"widestring",
"windows-registry",
"windows-result",
"windows-sys 0.61.2",
]
[[package]]
name = "ipnet"
version = "2.12.2"
@@ -1644,6 +1756,10 @@ name = "once_cell"
version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]]
name = "once_cell_polyfill"
@@ -1778,6 +1894,17 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
[[package]]
name = "prefix-trie"
version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cf6e3177f0684016a5c209b00882e15f8bdd3f3bb48f0491df10cd102d0c6e7"
dependencies = [
"either",
"ipnet",
"num-traits",
]
[[package]]
name = "prettyplease"
version = "0.2.37"
@@ -2121,6 +2248,7 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2419,7 +2547,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation",
"core-foundation 0.10.1",
"csv",
"idna",
"ipnet",
@@ -2751,6 +2879,12 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]]
name = "ring"
version = "0.17.14"
@@ -2886,7 +3020,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [
"bitflags 2.13.2",
"core-foundation",
"core-foundation 0.10.1",
"core-foundation-sys",
"libc",
"security-framework-sys",
@@ -3094,13 +3228,14 @@ name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"core-foundation",
"core-foundation 0.10.1",
"directories",
"percent-encoding",
"rama",
"rand",
"rcgen",
"regex",
"schannel",
"security-framework",
"security-framework-sys",
"semver",
@@ -3109,8 +3244,12 @@ dependencies = [
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"which",
"windows",
"windows-registry",
"windows-service",
]
[[package]]
@@ -3181,6 +3320,27 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "system-configuration"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b"
dependencies = [
"bitflags 2.13.2",
"core-foundation 0.9.4",
"system-configuration-sys",
]
[[package]]
name = "system-configuration-sys"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "tagptr"
version = "0.2.0"
@@ -3269,6 +3429,12 @@ dependencies = [
"zerovec",
]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
version = "1.53.1"
@@ -3522,6 +3688,19 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1"
[[package]]
name = "url"
version = "2.5.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed"
dependencies = [
"form_urlencoded",
"idna",
"percent-encoding",
"serde",
"serde_derive",
]
[[package]]
name = "utf8_iter"
version = "1.0.4"
@@ -3669,6 +3848,12 @@ dependencies = [
"libc",
]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]]
name = "wildcard"
version = "0.3.0"
@@ -3709,12 +3894,100 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [
"windows-collections",
"windows-core",
"windows-future",
"windows-numerics",
]
[[package]]
name = "windows-collections"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [
"windows-core",
]
[[package]]
name = "windows-core"
version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [
"windows-implement",
"windows-interface",
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-future"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [
"windows-core",
"windows-link",
"windows-threading",
]
[[package]]
name = "windows-implement"
version = "0.60.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-interface"
version = "0.59.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.119",
]
[[package]]
name = "windows-link"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
[[package]]
name = "windows-numerics"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [
"windows-core",
"windows-link",
]
[[package]]
name = "windows-registry"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720"
dependencies = [
"windows-link",
"windows-result",
"windows-strings",
]
[[package]]
name = "windows-result"
version = "0.4.1"
@@ -3724,6 +3997,26 @@ dependencies = [
"windows-link",
]
[[package]]
name = "windows-service"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "857224b3b211c6f3616921f081ee54721ee3ad2ace2fac6a6337e032f7b4dcf2"
dependencies = [
"bitflags 2.13.2",
"widestring",
"windows-sys 0.61.2",
]
[[package]]
name = "windows-strings"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
dependencies = [
"windows-link",
]
[[package]]
name = "windows-sys"
version = "0.52.0"
@@ -3758,6 +4051,15 @@ dependencies = [
"windows_x86_64_msvc",
]
[[package]]
name = "windows-threading"
version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37"
dependencies = [
"windows-link",
]
[[package]]
name = "windows_aarch64_gnullvm"
version = "0.52.6"
+38 -1
View File
@@ -10,6 +10,14 @@ homepage = "https://github.com/usestrix/firewall"
# as an integration test; new tests/*.rs need an explicit [[test]] entry.
autotests = false
# Only Windows ships the service.
[package.metadata.dist.binaries]
x86_64-pc-windows-msvc = ["firewall", "firewall-service"]
aarch64-apple-darwin = ["firewall"]
x86_64-apple-darwin = ["firewall"]
aarch64-unknown-linux-gnu = ["firewall"]
x86_64-unknown-linux-gnu = ["firewall"]
[lib]
name = "firewall"
# The macOS extension links a staticlib, built on demand:
@@ -20,6 +28,10 @@ crate-type = ["lib"]
name = "firewall"
path = "src/main.rs"
[[bin]]
name = "firewall-service"
path = "src/bin/firewall-service.rs"
[[example]]
name = "proxy-test"
path = "tests/proxy-test/main.rs"
@@ -44,10 +56,18 @@ path = "tests/denylist.rs"
name = "osv"
path = "tests/osv.rs"
[[test]]
name = "age"
path = "tests/age.rs"
[[test]]
name = "mitm"
path = "tests/mitm.rs"
[[test]]
name = "tpm"
path = "tests/tpm.rs"
[[test]]
name = "managers"
path = "tests/managers.rs"
@@ -63,7 +83,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"] }
@@ -75,6 +95,23 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
rama = { version = "0.4.0", features = ["dns-hickory"] }
schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
windows = { version = "0.62", features = [
"Win32_Foundation",
"Win32_Security",
"Win32_Security_Authorization",
"Win32_Security_Cryptography",
"Win32_System_Com",
"Win32_System_Environment",
"Win32_UI_Shell",
] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+1 -1
View File
@@ -82,7 +82,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
};
let reason = policies::screen(&url, firewall).await;
telemetry::screened(firewall, &url, reason.is_some());
telemetry::screened(firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(method = %req.method(), %url, %reason, "blocked");
return Ok(blocked(&reason));
+15
View File
@@ -0,0 +1,15 @@
//! Started by the Windows service manager. It logs to a file, having no console.
#[cfg(windows)]
fn main() {
if let Err(e) = firewall::os::windows::run_service() {
tracing::error!("could not run the service: {e}");
firewall::telemetry::error("service");
firewall::telemetry::flush();
std::process::exit(1)
}
}
/// The service is Windows-only.
#[cfg(not(windows))]
fn main() {}
+55 -24
View File
@@ -1,11 +1,12 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -13,37 +14,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 +60,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,
}
@@ -63,18 +69,31 @@ impl Default for DenylistSource {
}
impl FirewallConfig {
/// A missing file means defaults; a bad one is an error, so the policy is never quietly relaxed.
pub fn load() -> std::io::Result<Self> {
let Some(path) = path() else {
return Ok(Self::default());
};
let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
serde_json::from_str(&text)
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
let mut paths: Vec<PathBuf> = path().into_iter().collect();
paths.extend(crate::os::system_config());
Self::load_from(&paths)
}
/// Later files win key by key, but allow lists add up.
/// A bad file is an error, so the policy is never quietly relaxed.
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> {
let mut merged = Map::new();
for path in paths {
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
FirewallConfig::deserialize(&layer).map_err(invalid)?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
merge(&mut merged, layer);
}
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
}
pub fn local_url(&self, path: &str) -> String {
@@ -95,6 +114,18 @@ 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"))
}
fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
for (key, value) in top {
match (base.get_mut(&key), value) {
(Some(Value::Object(base)), Value::Object(top)) => merge(base, top),
(Some(Value::Array(base)), Value::Array(top)) => base.extend(top),
(_, value) => {
base.insert(key, value);
}
}
}
}
+6 -2
View File
@@ -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 }
}
+15
View File
@@ -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")
@@ -50,6 +56,7 @@ fn options() -> Command {
}
fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load();
let port = config.port;
print_setup(&config);
@@ -69,7 +76,15 @@ fn serve() -> ! {
}
}
fn uninstall() -> ! {
match firewall::os::uninstall() {
Ok(()) => exit(0),
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
}
}
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
+7 -3
View File
@@ -8,7 +8,7 @@ use rcgen::{
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
/// Held in the Secure Enclave on macOS, the TPM on Windows, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
pub struct CertificateAuthority {
@@ -60,11 +60,15 @@ 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";
/// With `COMMON_NAME`, how a trust store entry is recognized as ours.
pub const ORGANIZATION: &str = "Strix";
/// 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::OrganizationName, "Strix");
name.push(DnType::CommonName, COMMON_NAME);
name.push(DnType::OrganizationName, ORGANIZATION);
let mut params = CertificateParams::default();
params.distinguished_name = name;
+1 -1
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority};
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
+1 -1
View File
@@ -37,7 +37,7 @@ where
async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
if let Some(url) = https_url(&req) {
let reason = policies::screen(&url, &self.firewall).await;
telemetry::screened(&self.firewall, &url, reason.is_some());
telemetry::screened(&self.firewall, &url, reason.as_deref());
if let Some(reason) = reason {
warn!(%url, "blocked by firewall: {reason}");
return Ok(blocked(&reason));
+14 -9
View File
@@ -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;
@@ -21,15 +23,17 @@ use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service};
use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
const BUNDLE_ID: &str = "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";
const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
#[derive(Clone)]
struct Handler {
@@ -49,15 +53,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}"),
}))
}
@@ -99,9 +99,14 @@ impl TransparentProxyHandlerFactory for Factory {
}
}
pub fn system_config() -> Option<PathBuf> {
Some(CONFIG_FILE.into())
}
fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat();
let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
@@ -113,7 +118,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}")));
+28 -9
View File
@@ -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(())
}
+18
View File
@@ -1,2 +1,20 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
#[cfg(target_os = "macos")]
pub use macos::{system_config, uninstall};
#[cfg(windows)]
pub use windows::system_config;
/// Elsewhere nothing is installed system-wide.
#[cfg(not(target_os = "macos"))]
pub fn uninstall() -> Result<(), rama::error::BoxError> {
Ok(())
}
#[cfg(not(any(target_os = "macos", windows)))]
pub fn system_config() -> Option<std::path::PathBuf> {
None
}
+120
View File
@@ -0,0 +1,120 @@
use super::*;
use crate::mitm::{CertificateAuthority, COMMON_NAME, ORGANIZATION};
use rama::tls::boring::core::nid::Nid;
use rama::tls::boring::core::x509::X509;
use rcgen::SigningKey;
use schannel::cert_context::CertContext;
use schannel::cert_store::{CertAdd, CertStore};
use std::io;
use std::path::Path;
use tracing::warn;
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::*;
/// SYSTEM and admins write, users only read, so no one else can plant a CA for node.
const DATA_DIR_ACL: &str = concat!(
"O:BA", // owner: Administrators
"D:P", // protected: ignore ProgramData's rules, which let any user create files
"(A;OICI;FA;;;SY)", // allow SYSTEM full control, inherited by files (OI) and subfolders (CI)
"(A;OICI;FA;;;BA)", // allow Administrators full control
"(A;OICI;FRFX;;;BU)", // allow Users read and execute only
);
/// Trusted machine-wide so restarts keep it; replaced if the hosts change.
pub fn load_or_create_ca(
hosts: &[&str],
signer: &dyn SigningKey,
) -> Result<CertificateAuthority, BoxError> {
let stored = read_registry_string(REGISTRY_KEY, "ca")?;
let ca = match stored.and_then(|blob| CertificateAuthority::from_blob(&blob, hosts, signer)) {
Some(ca) => ca,
None => {
warn!(?hosts, "minting a new CA");
let ca = CertificateAuthority::generate(hosts, signer)?;
LOCAL_MACHINE
.create(REGISTRY_KEY)?
.set_string("ca", ca.to_blob(hosts))?;
ca
}
};
// Idempotent, and the registry value can outlive the certificate.
let der = ca.crt.to_der()?;
let mut store = CertStore::open_local_machine("Root")?;
store.add_cert(&CertContext::new(&der)?, CertAdd::ReplaceExisting)?;
remove_our_cas_except(Some(&der))?;
// For clients that read no trust store; screening still works for the rest if this fails.
let exported = secure_data_dir().and_then(|dir| std::fs::write(dir.join("ca.pem"), &ca.pem));
if let Err(error) = exported {
warn!(%error, "could not export the CA");
}
Ok(ca)
}
/// Created on first use, its owner and ACL reset every time. One a user made first, or a link,
/// is replaced: its owner could grant itself access again, and a link would redirect our writes.
pub(super) fn secure_data_dir() -> io::Result<PathBuf> {
let dir = data_dir();
match std::fs::symlink_metadata(&dir) {
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
Ok(_) if !owned_by_admins(&dir)? => {
warn!(dir = %dir.display(), "replacing a data folder not created by an admin");
std::fs::remove_dir_all(&dir)?
}
_ => {}
}
std::fs::create_dir_all(&dir)?;
let info = OWNER_SECURITY_INFORMATION
| DACL_SECURITY_INFORMATION
| PROTECTED_DACL_SECURITY_INFORMATION;
let path = HSTRING::from(dir.as_path());
let acl = security_descriptor(DATA_DIR_ACL)?;
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
Ok(dir)
}
/// By SYSTEM or the Administrators group, as what an elevated process creates is.
fn owned_by_admins(path: &Path) -> windows::core::Result<bool> {
let (mut owner, mut descriptor) = (PSID::default(), PSECURITY_DESCRIPTOR::default());
unsafe {
GetNamedSecurityInfoW(
&HSTRING::from(path),
SE_FILE_OBJECT,
OWNER_SECURITY_INFORMATION,
Some(&mut owner),
None,
None,
None,
&mut descriptor,
)
.ok()?
};
let admins = [WinBuiltinAdministratorsSid, WinLocalSystemSid];
let owned = admins
.into_iter()
.any(|sid| unsafe { IsWellKnownSid(owner, sid) }.as_bool());
// `owner` points into the descriptor, so it is freed last.
unsafe { LocalFree(Some(HLOCAL(descriptor.0))) };
Ok(owned)
}
/// Drops every CA of ours from LocalMachine\Root but `keep`.
pub(super) fn remove_our_cas_except(keep: Option<&[u8]>) -> Result<(), BoxError> {
for cert in CertStore::open_local_machine("Root")?.certs() {
if keep != Some(cert.to_der()) && is_our_ca(cert.to_der()) {
cert.delete()?;
}
}
Ok(())
}
fn is_our_ca(der: &[u8]) -> bool {
let Ok(crt) = X509::from_der(der) else {
return false;
};
let name = |nid| {
let entry = crt.subject_name().entries_by_nid(nid).next()?;
Some(entry.data().as_utf8().ok()?.to_string())
};
name(Nid::COMMONNAME).as_deref() == Some(COMMON_NAME)
&& name(Nid::ORGANIZATIONNAME).as_deref() == Some(ORGANIZATION)
}
+131
View File
@@ -0,0 +1,131 @@
//! The hosts file sends each registry host to its own loopback address, where a
//! LocalSystem service screens it as the macOS extension does.
mod ca;
mod service;
mod tpm;
pub use service::run as run_service;
pub use tpm::CaKey;
use rama::error::BoxError;
use std::net::Ipv4Addr;
use std::path::PathBuf;
use windows::core::{Owned, GUID, HSTRING};
use windows::Win32::Foundation::{LocalFree, ERROR_FILE_NOT_FOUND, HLOCAL};
use windows::Win32::Security::Authorization::*;
use windows::Win32::Security::PSECURITY_DESCRIPTOR;
use windows::Win32::System::Com::CoTaskMemFree;
use windows::Win32::System::Environment::ExpandEnvironmentStringsW;
use windows::Win32::UI::Shell::*;
use windows_registry::LOCAL_MACHINE;
const SERVICE_NAME: &str = "StrixFirewall";
/// Admin-only, so it holds the CA as the System keychain does on macOS.
const REGISTRY_KEY: &str = r"SOFTWARE\Strix Firewall";
/// Serves the CRL our leaves point at; schannel fails closed without one.
const CRL_ADDRESS: Ipv4Addr = Ipv4Addr::new(127, 0, 0, 2);
/// Admin-only; holds the exported CA, the service log and the config.
fn data_dir() -> PathBuf {
known_folder(&FOLDERID_ProgramData).join("Strix Firewall")
}
/// Admin-only, so users cannot relax what the service screens.
fn config_file() -> PathBuf {
data_dir().join("config.json")
}
pub fn system_config() -> Option<PathBuf> {
Some(config_file())
}
/// In the folder Windows reads it from, normally %SystemRoot%\System32\drivers\etc.
fn hosts_file() -> PathBuf {
let key = r"SYSTEM\CurrentControlSet\Services\Tcpip\Parameters";
let configured = LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_hstring("DataBasePath"));
let dir = match configured {
Ok(dir) => expand_environment(&dir),
Err(_) => known_folder(&FOLDERID_System).join(r"drivers\etc"),
};
dir.join("hosts")
}
/// Windows always has its known folders.
fn known_folder(id: &GUID) -> PathBuf {
let path = unsafe { SHGetKnownFolderPath(id, KF_FLAG_DEFAULT, None) }.expect("a known folder");
let dir = PathBuf::from(unsafe { path.to_hstring() }.to_os_string());
unsafe { CoTaskMemFree(Some(path.0 as *const _)) };
dir
}
fn expand_environment(path: &HSTRING) -> PathBuf {
let len = unsafe { ExpandEnvironmentStringsW(path, None) };
let mut expanded = vec![0; len as usize];
let len = unsafe { ExpandEnvironmentStringsW(path, Some(&mut expanded)) };
// Less the terminating null.
PathBuf::from(String::from_utf16_lossy(
&expanded[..(len as usize).saturating_sub(1)],
))
}
/// From 127.0.0.3, leaving 127.0.0.1 to whatever else serves locally.
fn loopback_addresses(hosts: &[&'static str]) -> Vec<(Ipv4Addr, &'static str)> {
(3..)
.zip(hosts)
.map(|(i, host)| (Ipv4Addr::new(127, 0, 0, i), *host))
.collect()
}
/// Points each host at its listener, replacing the lines we wrote before; with none, only removes
/// them. Bytes, as the file needn't be UTF-8, and every other line is kept as it was.
fn write_hosts_entries(hosts: &[&'static str]) -> std::io::Result<()> {
const TAG: &str = " # Strix Firewall";
let old = std::fs::read(hosts_file())?;
let ours = |line: &&[u8]| line.trim_ascii_end().ends_with(TAG.as_bytes());
let lines = old.split_inclusive(|&byte| byte == b'\n');
let mut new: Vec<u8> = lines
.filter(|line| !ours(line))
.flatten()
.copied()
.collect();
if !new.is_empty() && !new.ends_with(b"\n") {
new.extend_from_slice(b"\r\n");
}
for (address, host) in loopback_addresses(hosts) {
new.extend_from_slice(format!("{address} {host}{TAG}\r\n").as_bytes());
}
std::fs::write(hosts_file(), new)
}
/// Parses `sddl`; freed on drop.
fn security_descriptor(sddl: &str) -> windows::core::Result<Owned<HLOCAL>> {
let mut descriptor = PSECURITY_DESCRIPTOR::default();
unsafe {
ConvertStringSecurityDescriptorToSecurityDescriptorW(
&HSTRING::from(sddl),
SDDL_REVISION_1,
&mut descriptor,
None,
)?
};
Ok(unsafe { Owned::new(HLOCAL(descriptor.0)) })
}
fn read_registry_string(key: &str, value: &str) -> Result<Option<String>, BoxError> {
none_if_missing(
LOCAL_MACHINE
.open(key)
.and_then(|key| key.get_string(value)),
)
}
/// `None` for a missing key or value.
fn none_if_missing<T>(result: windows_registry::Result<T>) -> Result<Option<T>, BoxError> {
match result {
Err(error) if error.code() == ERROR_FILE_NOT_FOUND.to_hresult() => Ok(None),
result => Ok(Some(result?)),
}
}
+234
View File
@@ -0,0 +1,234 @@
use super::ca::{load_or_create_ca, secure_data_dir};
use super::tpm::CaKey;
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME};
use crate::config::FirewallConfig;
use crate::http::{reply, text};
use crate::{mitm, telemetry, Firewall};
use rama::dns::client::hickory::resolver::config::ResolveHosts;
use rama::dns::client::hickory::{default_resolver_opts, HickoryDnsResolver};
use rama::dns::client::resolver::DnsAddressResolver;
use rama::dns::client::try_init_global_dns_resolver;
use rama::error::BoxError;
use rama::http::server::HttpServer;
use rama::http::{Request, Response};
use rama::io::BridgeIo;
use rama::layer::ConsumeErrLayer;
use rama::net::address::Domain;
use rama::rt::Executor;
use rama::service::service_fn;
use rama::tcp::server::TcpListener;
use rama::tcp::TcpStream;
use rama::{Layer, Service};
use rcgen::SigningKey;
use std::convert::Infallible;
use std::ffi::OsString;
use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
};
use windows_service::{define_windows_service, service_dispatcher};
const CRL_PATH: &str = "/ca.crl";
define_windows_service!(ffi_service_main, service_main);
/// Called by the service control manager, never by hand.
pub fn run() -> windows_service::Result<()> {
service_dispatcher::start(SERVICE_NAME, ffi_service_main)
}
/// stderr goes nowhere in a service, so events go to a file beside the CA.
fn service_main(_arguments: Vec<OsString>) {
let log = secure_data_dir().ok().and_then(|dir| {
RollingFileAppender::builder()
.rotation(Rotation::DAILY)
.filename_prefix("firewall")
.filename_suffix("log")
.max_log_files(7)
.build(dir)
.ok()
});
if let Some(appender) = log {
let filter = crate::log_filter("info");
let _ = tracing_subscriber::fmt()
.with_env_filter(filter)
.with_ansi(false)
.with_writer(appender)
.try_init();
}
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
if let Err(error) = run_until_stopped() {
tracing::error!(%error, "the service stopped");
}
}
fn run_until_stopped() -> Result<(), BoxError> {
let (control_sender, mut control_receiver) = tokio::sync::mpsc::unbounded_channel();
let status = service_control_handler::register(SERVICE_NAME, move |control| match control {
ServiceControl::Stop
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
})?;
set_status(&status, ServiceState::StartPending, false)?;
let served = crate::runtime()?.block_on(async {
let hosts = match start_screening(&status).await {
Ok(hosts) => hosts,
Err(error) => {
telemetry::send().await;
return Err(error);
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some(control) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
// A failed stop is what makes the service manager restart it.
set_status(&status, ServiceState::Stopped, served.is_err())?;
served
}
async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static str>, BoxError> {
telemetry::set_mode("service");
telemetry::heartbeat();
// Never the hosts file, which points back at us; the Firewall's lookups included.
let mut options = default_resolver_opts();
options.use_hosts_file = ResolveHosts::Never;
let resolver = HickoryDnsResolver::try_new_system_with_options(options)?;
let _ = try_init_global_dns_resolver(resolver.clone());
let config =
FirewallConfig::load_from(&[config_file()]).inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts();
let key = CaKey::load_or_create().inspect_err(|_| telemetry::error("tpm"))?;
let signer: mitm::CaSigner = Arc::new(key);
let ca = load_or_create_ca(&hosts, &*signer).inspect_err(|_| telemetry::error("ca"))?;
let exec = Executor::default();
// An empty CRL for the leaves to point at, signed fresh for each fetch.
let crl_url = format!("http://{CRL_ADDRESS}{CRL_PATH}");
let (crl_hosts, crl_signer) = (hosts.clone(), Arc::clone(&signer));
let crl = service_fn(move |req: Request| {
let response = if req.uri().request_target() == CRL_PATH {
crl_response(&crl_hosts, &*crl_signer)
} else {
text(404, "No route.\n")
};
async move { Ok::<_, Infallible>(response) }
});
let crl = HttpServer::auto(exec.clone()).service(crl);
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
for (address, host) in loopback_addresses(&hosts) {
for port in [80, 443] {
let upstream = RedirectedHost {
host,
port,
resolver: resolver.clone(),
service: service.clone(),
};
let upstream = ConsumeErrLayer::trace_as_debug().into_layer(upstream);
tokio::spawn(listen_on(address, port, &exec)?.serve(upstream));
}
}
// Rewritten on every start, so a restart picks up a changed host list.
write_hosts_entries(&hosts)?;
tracing::info!(?hosts, "screening redirected registry traffic");
set_status(status, ServiceState::Running, false)?;
Ok(hosts)
}
fn crl_response(hosts: &[&str], signer: &dyn SigningKey) -> Response {
match mitm::crl(hosts, signer) {
Ok(der) => reply(200, "application/pkix-crl", der),
Err(error) => text(503, &format!("{error}\n")),
}
}
fn listen_on(address: Ipv4Addr, port: u16, exec: &Executor) -> std::io::Result<TcpListener> {
let bound = std::net::TcpListener::bind((address, port))?;
bound.set_nonblocking(true)?;
TcpListener::try_from_std_tcp_listener(bound, exec.clone())
}
/// Each listener is bound to one host's address, which is how it knows where to connect.
#[derive(Clone)]
struct RedirectedHost {
host: &'static str,
port: u16,
resolver: HickoryDnsResolver,
service: mitm::MitmService,
}
impl Service<TcpStream> for RedirectedHost {
type Output = ();
type Error = BoxError;
async fn serve(&self, ingress: TcpStream) -> Result<(), BoxError> {
let address = self.resolve().await?;
let egress = tokio::net::TcpStream::connect((address, self.port)).await?;
let streams = BridgeIo(ingress, TcpStream::from(egress));
self.service.serve(streams).await
}
}
impl RedirectedHost {
/// IPv4 if it has one, else IPv6.
async fn resolve(&self) -> Result<IpAddr, BoxError> {
let domain = Domain::from_static(self.host);
if let Some(Ok(v4)) = self.resolver.lookup_ipv4_first(domain.clone()).await {
return Ok(v4.into());
}
match self.resolver.lookup_ipv6_first(domain).await {
Some(v6) => Ok(v6?.into()),
None => Err(format!("{} has no address", self.host).into()),
}
}
}
fn set_status(
status: &ServiceStatusHandle,
state: ServiceState,
failed: bool,
) -> windows_service::Result<()> {
status.set_service_status(ServiceStatus {
service_type: ServiceType::OWN_PROCESS,
current_state: state,
controls_accepted: match state {
ServiceState::Running | ServiceState::Paused => {
ServiceControlAccept::STOP
| ServiceControlAccept::SHUTDOWN
| ServiceControlAccept::PAUSE_CONTINUE
}
_ => ServiceControlAccept::empty(),
},
exit_code: if failed {
ServiceExitCode::ServiceSpecific(1)
} else {
ServiceExitCode::Win32(0)
},
checkpoint: 0,
wait_hint: Duration::from_secs(30),
process_id: None,
})
}
+153
View File
@@ -0,0 +1,153 @@
use rama::error::BoxError;
use rama::tls::boring::core::bn::BigNum;
use rama::tls::boring::core::ecdsa::EcdsaSig;
use rama::tls::boring::core::sha::sha256;
use rcgen::{Error, PublicKeyData, SignatureAlgorithm, SigningKey, PKCS_ECDSA_P256_SHA256};
use windows::core::{Owned, Result as WinResult, HSTRING};
use windows::Win32::Foundation::{NTE_BAD_KEY, NTE_BAD_KEYSET};
use windows::Win32::Security::Cryptography::*;
const KEY_NAME: &str = "Strix Firewall CA";
/// A P-256 key held by the TPM. Signing asks the TPM to sign, so the private key never leaves it.
pub struct CaKey {
// Fields drop in order, so the key handle closes before its provider's.
key_handle: Owned<NCRYPT_KEY_HANDLE>,
_provider_handle: Owned<NCRYPT_PROV_HANDLE>,
/// 0x04 || X || Y, the form rcgen writes into a certificate's SubjectPublicKeyInfo.
public_point: Vec<u8>,
}
impl CaKey {
/// A machine key, so the service and an elevated installer share it.
pub fn load_or_create() -> Result<Self, BoxError> {
match open_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
Some(key) => Ok(key),
None => Ok(create_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)?),
}
}
/// A per-user key under a random name, which needs no elevation; `delete_from_tpm` it after.
pub fn create_test_key() -> WinResult<Self> {
let name = format!("Strix Firewall test {:016x}", rand::random::<u64>());
create_key(&name, NCRYPT_FLAGS(0))
}
/// Deletes the key from the TPM, not just this handle to it.
pub fn delete_from_tpm(mut self) -> WinResult<()> {
// NCryptDeleteKey also frees the handle, so the Owned mustn't free it again.
let key_handle = std::mem::take(&mut *self.key_handle);
unsafe { NCryptDeleteKey(key_handle, 0) }
}
/// Takes both handles and reads the key's public point once, for rcgen.
fn from_handles(
provider_handle: Owned<NCRYPT_PROV_HANDLE>,
key_handle: Owned<NCRYPT_KEY_HANDLE>,
) -> WinResult<Self> {
// A BCRYPT_ECCKEY_BLOB header, then X and Y.
const HEADER_LEN: usize = size_of::<BCRYPT_ECCKEY_BLOB>();
let mut blob = [0u8; HEADER_LEN + 64];
let mut blob_len = 0;
unsafe {
NCryptExportKey(
*key_handle,
None,
BCRYPT_ECCPUBLIC_BLOB,
None,
Some(&mut blob),
&mut blob_len,
NCRYPT_FLAGS(0),
)?
};
let (header, x_and_y) = blob.split_at(HEADER_LEN);
let is_p256 = header[..4] == BCRYPT_ECDSA_PUBLIC_P256_MAGIC.to_le_bytes();
if !is_p256 || blob_len as usize != blob.len() {
return Err(windows::core::Error::new(NTE_BAD_KEY, "key is not an ECDSA P-256 key"));
}
let public_point = [&[0x04], x_and_y].concat();
Ok(CaKey { key_handle, _provider_handle: provider_handle, public_point })
}
}
impl PublicKeyData for CaKey {
fn der_bytes(&self) -> &[u8] {
&self.public_point
}
fn algorithm(&self) -> &'static SignatureAlgorithm {
&PKCS_ECDSA_P256_SHA256
}
}
impl SigningKey for CaKey {
fn sign(&self, msg: &[u8]) -> Result<Vec<u8>, Error> {
let mut r_and_s = [0u8; 64];
let mut signature_len = 0;
let signed = unsafe {
NCryptSignHash(
*self.key_handle,
None,
&sha256(msg),
Some(&mut r_and_s),
&mut signature_len,
NCRYPT_SILENT_FLAG,
)
};
signed.map_err(|_| Error::RemoteKeyError)?;
// NCrypt returns r || s, each half the length; X.509 wants DER SEQUENCE { r, s }.
let (r, s) = r_and_s[..signature_len as usize].split_at(signature_len as usize / 2);
let der = BigNum::from_slice(r)
.and_then(|r| EcdsaSig::from_private_components(r, BigNum::from_slice(s)?))
.and_then(|signature| signature.to_der());
der.map_err(|_| Error::RemoteKeyError)
}
}
/// `None` if the TPM holds no key by that name.
/// `scope` is `NCRYPT_MACHINE_KEY_FLAG`, or empty for the current user's keys.
fn open_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<Option<CaKey>> {
let provider_handle = open_tpm_provider()?;
let mut key_handle = NCRYPT_KEY_HANDLE::default();
let opened = unsafe {
NCryptOpenKey(
*provider_handle,
&mut key_handle,
&HSTRING::from(name),
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
scope | NCRYPT_SILENT_FLAG,
)
};
match opened {
Err(error) if error.code() == NTE_BAD_KEYSET => Ok(None),
Err(error) => Err(error),
Ok(()) => CaKey::from_handles(provider_handle, unsafe { Owned::new(key_handle) }).map(Some),
}
}
/// Generates the key inside the TPM, which never lets it out.
fn create_key(name: &str, scope: NCRYPT_FLAGS) -> WinResult<CaKey> {
let provider_handle = open_tpm_provider()?;
let mut key_handle = NCRYPT_KEY_HANDLE::default();
unsafe {
NCryptCreatePersistedKey(
*provider_handle,
&mut key_handle,
NCRYPT_ECDSA_P256_ALGORITHM,
&HSTRING::from(name),
CERT_KEY_SPEC::default(), // legacy, unused by the TPM provider
scope,
)?
};
let key_handle = unsafe { Owned::new(key_handle) };
// Until finalized the key is only a handle; this generates and stores it.
unsafe { NCryptFinalizeKey(*key_handle, NCRYPT_SILENT_FLAG)? };
CaKey::from_handles(provider_handle, key_handle)
}
/// The Microsoft Platform Crypto Provider, which keeps its keys in the TPM.
fn open_tpm_provider() -> WinResult<Owned<NCRYPT_PROV_HANDLE>> {
let mut provider_handle = NCRYPT_PROV_HANDLE::default();
unsafe { NCryptOpenStorageProvider(&mut provider_handle, MS_PLATFORM_CRYPTO_PROVIDER, 0)? };
Ok(unsafe { Owned::new(provider_handle) })
}
+72
View File
@@ -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) = &registry.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}"))
}
+15 -33
View File
@@ -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()));
+22
View File
@@ -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())
})
}
+3
View File
@@ -72,6 +72,9 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
Some((_, Verdict::Deny(reason))) => {
Some(format!("{} {reason}", pkg.label()))
}
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
}
_ => None,
}
}
+8 -1
View File
@@ -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,
};
+6 -1
View File
@@ -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,
}),
};
+17 -1
View File
@@ -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
}
+9
View File
@@ -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 {
+6 -1
View File
@@ -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,
}),
};
+7 -1
View File
@@ -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.
+5 -1
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, reply, text};
use crate::policies::{self, Check, Decision};
use crate::{Firewall, FirewallConfig};
use crate::{telemetry, Firewall, FirewallConfig};
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
@@ -19,6 +19,7 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config));
telemetry::heartbeat();
let exec = Executor::default();
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
@@ -44,6 +45,7 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
}
Decision::Block { eco, pkg, filter, reason } => {
let reason = format!("{} {reason}", pkg.label());
telemetry::screened_package(eco, &pkg, Some(&reason));
warn!(%eco, %filter, "blocked by firewall: {reason}");
blocked(&reason)
}
@@ -51,9 +53,11 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
match &check {
Check::NoPackage => {}
Check::Allowed(pkg) => {
telemetry::screened_package(target.eco, pkg, None);
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
}
Check::Unverified { pkg, filter, error } => {
telemetry::screened_package(target.eco, pkg, None);
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
}
}
+20 -7
View File
@@ -1,10 +1,11 @@
use crate::policies::Package;
use crate::{Firewall, FirewallConfig};
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE;
use rama::http::service::client::HttpClientExt;
use rama::rt::blocking::Runtime;
use serde_json::{json, Value};
use std::sync::{LazyLock, Mutex};
use std::sync::{LazyLock, Mutex, OnceLock};
use std::time::Duration;
use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime;
@@ -29,29 +30,40 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
})
});
/// How the firewall is running: "extension", "service", "wrapper" or "proxy".
static MODE: OnceLock<&'static str> = OnceLock::new();
struct Telemetry {
key: &'static str,
session: String,
events: Mutex<Vec<Value>>,
}
pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) {
if TELEMETRY.is_none() {
return;
}
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
return;
};
let Some(pkg) = firewall.resolve(registry, rest).pkg else {
return;
};
let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
if blocked {
if let Some(pkg) = firewall.resolve(registry, rest).pkg {
screened_package(registry.eco, &pkg, reason);
}
}
pub fn screened_package(eco: &str, pkg: &Package, reason: Option<&str>) {
let mut properties = json!({ "eco": eco, "blocked": reason.is_some() });
if let Some(reason) = reason {
properties["package"] = pkg.label().into();
properties["reason"] = reason.into();
}
record("package_screened", properties);
}
pub fn set_mode(mode: &'static str) {
MODE.set(mode).ok();
}
pub fn error(kind: &'static str) {
record("error", json!({ "kind": kind }));
}
@@ -81,6 +93,7 @@ pub fn flush() {
fn record(event: &str, mut properties: Value) {
let Some(telemetry) = &*TELEMETRY else { return };
properties["distinct_id"] = telemetry.session.clone().into();
properties["mode"] = MODE.get().copied().into();
properties["os"] = std::env::consts::OS.into();
properties["arch"] = std::env::consts::ARCH.into();
properties["$process_person_profile"] = false.into();
+27
View File
@@ -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:?}");
}
});
}
+40 -2
View File
@@ -1,12 +1,13 @@
use firewall::config::path;
use firewall::FirewallConfig;
use firewall::policies::filters::consult;
use firewall::policies::{screen, 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,40 @@ fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory");
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[test]
fn a_later_file_wins_and_allow_lists_add_up() {
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let user = dir.join("user.json");
let system = dir.join("system.json");
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
let missing = dir.join("missing.json");
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(config.min_age_hours, 24);
assert!(!config.fail_open);
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
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());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
+21
View File
@@ -0,0 +1,21 @@
#![cfg(windows)]
use firewall::mitm::CertificateAuthority;
use firewall::os::windows::CaKey;
use rcgen::PublicKeyData;
/// Skipped on machines without a TPM.
#[test]
fn key_signs_a_verifiable_ca() {
let key = match CaKey::create_test_key() {
Ok(key) => key,
Err(error) => return eprintln!("skipping, no TPM key: {error}"),
};
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
let spki = key.subject_public_key_info();
key.delete_from_tpm().expect("delete the test key");
let ca = ca.expect("mint a ca");
let public = ca.crt.public_key().expect("ca public key");
assert!(ca.crt.verify(&public).expect("verify the ca"));
assert_eq!(public.public_key_to_der().unwrap(), spki);
}
+2 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

+24
View File
@@ -0,0 +1,24 @@
{
"fill" : {
"solid" : "srgb:0.07000,0.07000,0.07000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "logo.png",
"name" : "logo",
"position" : {
"scale" : 1.8,
"translation-in-points" : [0, 0]
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
+3
View File
@@ -0,0 +1,3 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
@@ -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
+7
View File
@@ -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 {
@@ -110,23 +114,43 @@ final class InstallerModel: NSObject, ObservableObject {
completed = true
log("Setup complete")
offerRestart()
}
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
private func offerRestart() {
let alert = NSAlert()
alert.messageText = "Restart to finish setting up"
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
alert.addButton(withTitle: "Restart")
alert.addButton(withTitle: "Later")
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
let osascript = Process()
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
try? osascript.run()
}
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
func uninstall() async {
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 +174,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 +191,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 +243,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 +280,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 +299,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 +335,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 +358,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 +366,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 +408,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 +426,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 +439,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 +456,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.
-7
View File
@@ -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"
}
@@ -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
View File
@@ -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
+2 -2
View File
@@ -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
+26 -14
View File
@@ -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,45 @@ 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
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
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/"