Compare commits

...
Author SHA1 Message Date
ian-at-strix 55a41286e1 Reject config values the firewall cannot use (#44) 2026-10-22 18:08:34 -04:00
ian-at-strix ca7a899d02 Screen mirrors listed in the config file (#58) 2026-10-22 18:02:08 -04:00
Ian 0c1b39c351 Make the platforms doc easier to read 2026-10-22 17:39:21 -04:00
Ian 34be4bb352 Document Intel Macs and how to change machine-wide settings 2026-10-22 17:35:31 -04:00
Ian 5839e240ab Document that cargo install installs only the command 2026-10-22 17:35:31 -04:00
Ian b0580ca7ad Document the Windows tray and who can pause screening 2026-10-22 16:49:04 -04:00
Ian Graham Martinez d84c34706f Document the supported platforms
Which OS versions, architectures and package managers each mode supports,
and what machine-wide screening on Windows needs and does not cover.
2026-10-22 16:49:04 -04:00
ian-at-strix 674f3bdcd3 Build the macOS app for Intel Macs too (#60) 2026-10-22 16:48:01 -04:00
ian-at-strix 2df02bf23f Restart the Windows service from the tray, to reload the config (#59) 2026-10-22 16:40:49 -04:00
ian-at-strix a3e5ab1802 Start the tray when the install finishes (#15)
The MSI's exit dialog starts it from the unelevated UI process, so it runs
as the signed-in user.
2026-10-22 16:28:09 -04:00
ian-at-strix da750007fc Show screening status in the Windows notification area (#14)
* Show screening status in the Windows notification area

firewall-tray.exe shows a tray icon: whether the service is screening,
paused or not running, with Pause/Resume and Open log. The MSI
starts it at every sign-in and closes it before replacing or removing it.

The icon is the Strix logo, white on a dark taskbar and black on a light
one like the macOS template image, and dimmed when not screening.
tray.png is assets/logo-transparent.png scaled and padded to 32x32:
sips -Z 32 logo-transparent.png --out a.png && sips -p 32 32 a.png --out tray.png

* Open the folder of dated service logs from the tray
2026-10-22 16:28:08 -04:00
ian-at-strix d9de2a8a94 Install machine-wide screening on Windows with an MSI (#11)
The MSI installs both binaries, registers and starts the service with a
DACL that lets interactive users start, stop and pause it, restarts it on
failure, and sets NODE_EXTRA_CA_CERTS and UV_SYSTEM_CERTS. Removing it runs
`firewall-service.exe uninstall` once the service is deleted, which clears
the hosts entries and removes the CA, its TPM key and the data folder but
the config.
2026-10-22 16:28:08 -04:00
ian-at-strix b3f47664df Forward plain-HTTP upstreams as asked (#1)
The wrapper's forward proxy upgraded every request to HTTPS and rewrote
links in index responses. Only an HTTPS registry is downgraded to reach
us, so only that should be upgraded back and rewritten; anything else
now goes out exactly as the client sent it, port included.
2026-10-22 16:11:31 -04:00
ian-at-strix bdcc2e93dc Move the macOS uninstall out of the CLI into its own binary (#56) 2026-10-22 15:19:16 -04:00
ian-at-strixandIan Graham Martinez 9e457c4d69 Screen redirected registry traffic in a Windows service (#10)
* 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.

* Explain the data folder ACL and the root store arguments

* Use schannel for the root store

* Free the parsed security descriptor on drop instead of through a closure

* Rotate the service log daily with tracing-appender

---------

Co-authored-by: Ian Graham Martinez <[email protected]>
2026-10-22 14:49:36 -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
39 changed files with 2183 additions and 150 deletions

No files matched your search

+473 -4
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"
@@ -250,6 +261,15 @@ dependencies = [
"hybrid-array",
]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]]
name = "borsh"
version = "1.8.1"
@@ -456,6 +476,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 +526,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"
@@ -642,6 +678,16 @@ dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "dispatch2"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
dependencies = [
"bitflags 2.13.2",
"objc2",
]
[[package]]
name = "displaydoc"
version = "0.2.7"
@@ -653,6 +699,12 @@ dependencies = [
"syn 3.0.6",
]
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]]
name = "either"
version = "1.18.0"
@@ -724,6 +776,15 @@ dependencies = [
"getrandom 0.4.3",
]
[[package]]
name = "fdeflate"
version = "0.3.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
dependencies = [
"simd-adler32",
]
[[package]]
name = "find-msvc-tools"
version = "0.1.13"
@@ -744,7 +805,7 @@ checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [
"crc32fast",
"libz-sys",
"miniz_oxide",
"miniz_oxide 0.9.1",
"zlib-rs",
]
@@ -997,6 +1058,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 +1291,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"
@@ -1340,6 +1486,16 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "keyboard-types"
version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
dependencies = [
"bitflags 2.13.2",
"serde",
]
[[package]]
name = "konst"
version = "0.2.20"
@@ -1483,6 +1639,16 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
[[package]]
name = "miniz_oxide"
version = "0.8.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
dependencies = [
"adler2",
"simd-adler32",
]
[[package]]
name = "miniz_oxide"
version = "0.9.1"
@@ -1524,6 +1690,24 @@ dependencies = [
"uuid",
]
[[package]]
name = "muda"
version = "0.21.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
dependencies = [
"crossbeam-channel",
"dpi",
"keyboard-types",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-foundation",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "multer"
version = "3.1.0"
@@ -1630,6 +1814,66 @@ dependencies = [
"autocfg",
]
[[package]]
name = "objc2"
version = "0.6.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
dependencies = [
"objc2-encode",
]
[[package]]
name = "objc2-app-kit"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
dependencies = [
"bitflags 2.13.2",
"objc2",
"objc2-core-foundation",
"objc2-foundation",
]
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [
"bitflags 2.13.2",
"dispatch2",
"objc2",
]
[[package]]
name = "objc2-core-graphics"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
dependencies = [
"bitflags 2.13.2",
"objc2-core-foundation",
]
[[package]]
name = "objc2-encode"
version = "4.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
[[package]]
name = "objc2-foundation"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
dependencies = [
"bitflags 2.13.2",
"block2",
"objc2",
"objc2-core-foundation",
]
[[package]]
name = "oid-registry"
version = "0.8.1"
@@ -1644,6 +1888,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"
@@ -1748,6 +1996,19 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
[[package]]
name = "png"
version = "0.18.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
dependencies = [
"bitflags 2.13.2",
"crc32fast",
"fdeflate",
"flate2",
"miniz_oxide 0.8.9",
]
[[package]]
name = "portable-atomic"
version = "1.15.0"
@@ -1778,6 +2039,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 +2393,7 @@ dependencies = [
"arc-swap",
"base64",
"bindgen",
"hickory-resolver",
"libc",
"moka",
"parking_lot",
@@ -2419,7 +2692,7 @@ dependencies = [
"base64",
"bitflags 2.13.2",
"const_format",
"core-foundation",
"core-foundation 0.10.1",
"csv",
"idna",
"ipnet",
@@ -2751,6 +3024,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 +3165,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 +3373,16 @@ name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"core-foundation",
"core-foundation 0.10.1",
"directories",
"muda",
"percent-encoding",
"png",
"rama",
"rand",
"rcgen",
"regex",
"schannel",
"security-framework",
"security-framework-sys",
"semver",
@@ -3109,8 +3391,13 @@ dependencies = [
"time",
"tokio",
"tracing",
"tracing-appender",
"tracing-subscriber",
"tray-icon",
"which",
"windows",
"windows-registry",
"windows-service",
]
[[package]]
@@ -3181,6 +3468,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 +3577,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"
@@ -3486,6 +3800,25 @@ dependencies = [
"tracing-serde",
]
[[package]]
name = "tray-icon"
version = "0.26.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
dependencies = [
"crossbeam-channel",
"muda",
"objc2",
"objc2-app-kit",
"objc2-core-foundation",
"objc2-core-graphics",
"objc2-foundation",
"once_cell",
"png",
"thiserror",
"windows-sys 0.61.2",
]
[[package]]
name = "try-lock"
version = "0.2.5"
@@ -3522,6 +3855,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 +4015,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 +4061,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 +4164,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 +4218,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"
+43
View File
@@ -3,6 +3,7 @@ name = "strix-firewall"
version = "0.1.0"
edition = "2021"
license = "Apache-2.0"
authors = ["Strix"]
description = "Screens package downloads for known malware before they reach your machine"
repository = "https://github.com/usestrix/firewall"
homepage = "https://github.com/usestrix/firewall"
@@ -10,6 +11,20 @@ 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 and tray.
[package.metadata.dist.binaries]
x86_64-pc-windows-msvc = ["firewall", "firewall-service", "firewall-tray"]
aarch64-apple-darwin = ["firewall"]
x86_64-apple-darwin = ["firewall"]
aarch64-unknown-linux-gnu = ["firewall"]
x86_64-unknown-linux-gnu = ["firewall"]
[package.metadata.wix]
upgrade-guid = "D944C2F6-BF55-4B21-A60E-1BBDB1A66293"
path-guid = "033B1050-23D0-4F5A-A0D3-F4757B4B67E0"
license = false
eula = false
[lib]
name = "firewall"
# The macOS extension links a staticlib, built on demand:
@@ -20,6 +35,18 @@ crate-type = ["lib"]
name = "firewall"
path = "src/main.rs"
[[bin]]
name = "firewall-service"
path = "src/bin/firewall-service.rs"
[[bin]]
name = "firewall-tray"
path = "src/bin/firewall-tray.rs"
[[bin]]
name = "firewall-uninstall"
path = "src/bin/firewall-uninstall.rs"
[[example]]
name = "proxy-test"
path = "tests/proxy-test/main.rs"
@@ -52,6 +79,10 @@ path = "tests/age.rs"
name = "mitm"
path = "tests/mitm.rs"
[[test]]
name = "tpm"
path = "tests/tpm.rs"
[[test]]
name = "managers"
path = "tests/managers.rs"
@@ -79,6 +110,18 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0"
security-framework-sys = "2.17.0"
[target.'cfg(windows)'.dependencies]
muda = { version = "0.21.2", default-features = false }
png = "0.18.1"
rama = { version = "0.4.0", features = ["dns-hickory"] }
schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Com", "Win32_System_Environment", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
[dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+10 -6
View File
@@ -82,19 +82,23 @@ 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));
}
info!(method = %req.method(), %url, "forwarding");
let index = firewall.is_index_url(&url);
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten.
let tls = firewall.is_https_url(&url);
let index = tls && firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts();
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
if tls {
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
}
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
let response = match client.serve(Request::from_parts(parts, body)).await {
+26
View File
@@ -0,0 +1,26 @@
//! Started by the Windows service manager. It logs to a file, having no console.
#[cfg(windows)]
fn main() {
// Run by the MSI as it removes the firewall.
if std::env::args().nth(1).as_deref() == Some("uninstall") {
firewall::init_logging("info");
if let Err(e) = firewall::os::windows::uninstall() {
fail("uninstall", &format!("could not uninstall: {e}"))
}
} else if let Err(e) = firewall::os::windows::run_service() {
fail("service", &format!("could not run the service: {e}"))
}
}
#[cfg(windows)]
fn fail(kind: &'static str, message: &str) -> ! {
tracing::error!("{message}");
firewall::telemetry::error(kind);
firewall::telemetry::flush();
std::process::exit(1)
}
/// The service is Windows-only.
#[cfg(not(windows))]
fn main() {}
+11
View File
@@ -0,0 +1,11 @@
//! The notification-area icon, started at sign-in. A GUI app, so no console opens with it.
#![windows_subsystem = "windows"]
#[cfg(windows)]
fn main() -> Result<(), rama::error::BoxError> {
firewall::os::windows::run_tray()
}
/// The tray is Windows-only.
#[cfg(not(windows))]
fn main() {}
+16
View File
@@ -0,0 +1,16 @@
//! Run as root by the macOS app's uninstall.
#[cfg(target_os = "macos")]
fn main() {
firewall::init_logging("info");
if let Err(e) = firewall::os::macos::uninstall() {
tracing::error!("could not uninstall: {e}");
firewall::telemetry::error("uninstall");
firewall::telemetry::flush();
std::process::exit(1)
}
}
/// Only macOS installs anything system-wide.
#[cfg(not(target_os = "macos"))]
fn main() {}
+89 -33
View File
@@ -1,8 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN};
use crate::policies::registries::{host_of, BUILT_IN};
use directories::ProjectDirs;
use rama::net::uri::Uri;
use serde::Deserialize;
use serde_json::{Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
@@ -10,7 +13,7 @@ pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FirewallConfig {
pub port: u16,
pub port: NonZeroU16,
/// Allow a package when a filter could not check it.
pub fail_open: bool,
/// 0 turns the age check off.
@@ -19,9 +22,8 @@ pub struct FirewallConfig {
/// 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>,
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
pub registries: HashMap<String, Vec<String>>,
}
#[derive(Debug, Deserialize)]
@@ -39,19 +41,19 @@ pub struct DenylistSource {
pub base: String,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: u64,
pub poll_secs: NonZeroU64,
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
port: DEFAULT_PORT,
port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: BUILT_IN.to_vec(),
registries: HashMap::new(),
}
}
}
@@ -62,40 +64,42 @@ impl Default for DenylistSource {
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,
poll_secs: NonZeroU64::new(10).unwrap(),
}
}
}
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)?;
let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
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 {
crate::local_url(self.port, path)
}
/// Without building a `Firewall`, which needs a runtime.
pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
crate::local_url(self.port.get(), path)
}
}
@@ -103,3 +107,55 @@ pub fn path() -> Option<PathBuf> {
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);
}
}
}
}
/// What serde's types cannot say.
fn check(config: &FirewallConfig) -> Result<(), String> {
// `Duration::hours` panics past this.
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
return Err(format!("{} hours is too long", config.min_age_hours));
}
for eco in config.allow.keys() {
if !BUILT_IN.iter().any(|r| r.eco == eco) {
return Err(format!("unknown ecosystem {eco:?} in allow"));
}
}
for url in [&config.denylist.base, &config.denylist.revision] {
if parse_url(url, &["http", "https"]).is_none() {
return Err(format!("{url:?} is not an http(s) URL"));
}
}
for (name, mirrors) in &config.registries {
if !BUILT_IN.iter().any(|r| r.name() == name) {
return Err(format!("no built-in registry is named {name}"));
}
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
}
}
Ok(())
}
/// No port or userinfo, as `host_of` is what gets intercepted.
fn is_mirror(url: &str) -> bool {
let Some(uri) = parse_url(url, &["https"]) else { return false };
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
}
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
let uri = Uri::parse(url).ok()?;
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
}
+32 -6
View File
@@ -9,6 +9,7 @@ use std::collections::HashMap;
pub struct Firewall {
pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>,
}
@@ -21,18 +22,19 @@ impl Default for Firewall {
impl Firewall {
pub fn new(config: FirewallConfig) -> Self {
let registries = with_mirrors(&config.registries);
let mut patterns = HashMap::new();
for registry in &config.registries {
for registry in &registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let ecosystems = registries.iter().map(|r| r.osv_name);
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 }
Firewall { config, registries, patterns, filters }
}
pub fn match_on<'a>(
@@ -40,15 +42,27 @@ impl Firewall {
url: &'a str,
key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> {
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
}
pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts()
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
}
pub fn is_index_url(&self, url: &str) -> bool {
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -59,3 +73,15 @@ impl Firewall {
&self.filters
}
}
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
let mut registries = BUILT_IN.to_vec();
for built_in in BUILT_IN {
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
let upstream = upstream.clone().leak();
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
}
}
registries
}
+2 -13
View File
@@ -12,10 +12,6 @@ fn main() {
firewall::init_logging("info");
serve()
}
if matches.subcommand_matches("uninstall").is_some() {
firewall::init_logging("info");
uninstall()
}
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
@@ -44,8 +40,6 @@ fn options() -> Command {
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
// Run as root by the macOS app's uninstall.
.subcommand(Command::new("uninstall").hide(true))
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
@@ -56,6 +50,7 @@ fn options() -> Command {
}
fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load();
let port = config.port;
print_setup(&config);
@@ -75,14 +70,8 @@ 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,
+4 -2
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 {
@@ -61,12 +61,14 @@ pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError>
}
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, COMMON_NAME);
name.push(DnType::OrganizationName, "Strix");
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, COMMON_NAME};
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));
+8 -1
View File
@@ -23,6 +23,7 @@ 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;
@@ -32,6 +33,7 @@ const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
#[derive(Clone)]
struct Handler {
@@ -97,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"))?;
+8 -5
View File
@@ -1,11 +1,14 @@
#[cfg(target_os = "macos")]
pub mod macos;
#[cfg(windows)]
pub mod windows;
#[cfg(target_os = "macos")]
pub use macos::uninstall;
pub use macos::system_config;
#[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
}
+135
View File
@@ -0,0 +1,135 @@
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 std::time::{SystemTime, UNIX_EPOCH};
use tracing::warn;
use windows::Win32::Security::Authorization::{GetNamedSecurityInfoW, SE_FILE_OBJECT};
use windows::Win32::Security::*;
use windows_registry::Type;
/// 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
);
/// 1970 in FILETIME, which counts 100 ns from 1601.
const FILETIME_UNIX_EPOCH: u128 = 116_444_736_000_000_000;
/// 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))?;
// Windows discards CRLs it cached before now, in case an old CA's is cached at our URL.
let now = SystemTime::now().duration_since(UNIX_EPOCH)?.as_nanos() / 100
+ FILETIME_UNIX_EPOCH;
let chain = r"SOFTWARE\Microsoft\Cryptography\OID\EncodingType 0\CertDllCreateCertificateChainEngine\Config";
let resync = (now as u64).to_le_bytes();
LOCAL_MACHINE.create(chain)?.set_bytes(
"ChainCacheResyncFiletime",
Type::Bytes,
&resync,
)?;
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)
}
+135
View File
@@ -0,0 +1,135 @@
//! 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;
mod tray;
mod uninstall;
pub use service::run as run_service;
pub use tpm::CaKey;
pub use tray::run as run_tray;
pub use uninstall::uninstall;
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,
})
}
+160
View File
@@ -0,0 +1,160 @@
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)?),
}
}
pub(super) fn delete_machine_key() -> Result<(), BoxError> {
if let Some(key) = open_key(KEY_NAME, NCRYPT_MACHINE_KEY_FLAG)? {
key.delete_from_tpm()?;
}
Ok(())
}
/// 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) })
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 KiB

+223
View File
@@ -0,0 +1,223 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
use super::*;
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
use std::io::Cursor;
use std::time::Duration;
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
use windows::core::{w, PCWSTR};
use windows::Win32::Foundation::*;
use windows::Win32::System::Threading::CreateMutexW;
use windows::Win32::UI::Shell::*;
use windows::Win32::UI::WindowsAndMessaging::*;
use windows_registry::CURRENT_USER;
use windows_service::service::{Service, ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
const LOGO: &[u8] = include_bytes!("tray.png");
/// Runs until Quit, once per session.
pub fn run() -> Result<(), BoxError> {
// Held until we exit.
let _mutex = unsafe { CreateMutexW(None, false, w!(r"Local\StrixFirewallTray"))? };
if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS {
return Ok(());
}
let status = MenuItem::new("", false, None);
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
let menu = Menu::with_items(&[
&status,
&PredefinedMenuItem::separator(),
&pause,
&MenuItem::with_id(MENU_RESTART, "Restart", true, None),
&MenuItem::with_id(MENU_LOG, "Open logs", true, None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
])?;
let (logo, logo_size) = logo_mask()?;
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
logo,
logo_size,
drawn: None,
status,
pause,
state: None,
};
tray.update();
unsafe { SetTimer(None, 0, 2000, None) };
let mut message = MSG::default();
while unsafe { GetMessageW(&mut message, None, 0, 0) }.0 > 0 {
unsafe { DispatchMessageW(&message) };
// WM_CLOSE, as the MSI sends, destroys the icon's window.
if !unsafe { IsWindow(Some(window)) }.as_bool() {
break;
}
// Ours, not the icon window's own hover timer.
if message.message == WM_TIMER && message.hwnd.is_invalid() {
tray.update();
}
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
// The menu opens on release, so this keeps it current.
if let TrayIconEvent::Click {
button_state: MouseButtonState::Down,
..
} = event
{
tray.update();
}
}
while let Ok(event) = MenuEvent::receiver().try_recv() {
if event.id == MENU_QUIT {
return Ok(());
}
tray.on_menu(event.id.as_ref());
}
}
Ok(())
}
struct Tray {
icon: TrayIcon,
/// The logo's alpha channel; it is square.
logo: Vec<u8>,
logo_size: u32,
/// Whether the taskbar was light and the service screening when the icon was last set.
drawn: Option<(bool, bool)>,
status: MenuItem,
pause: MenuItem,
/// As the menu last showed it.
state: Option<ServiceState>,
}
impl Tray {
fn update(&mut self) {
self.state = service_state();
let screening = self.state == Some(ServiceState::Running);
let light = light_taskbar();
if self.drawn != Some((light, screening)) {
let rgba = logo_rgba(&self.logo, light, screening);
if let Ok(icon) = Icon::from_rgba(rgba, self.logo_size, self.logo_size) {
let _ = self.icon.set_icon(Some(icon));
}
self.drawn = Some((light, screening));
}
let text = status_text(self.state);
let tip = format!("Strix Firewall: {text}");
let _ = self.icon.set_tooltip(Some(tip));
self.status.set_text(text);
let pause = if screening { "Pause" } else { "Resume" };
self.pause.set_text(pause);
self.pause.set_enabled(self.state.is_some());
}
fn on_menu(&self, id: &str) {
let state = self.state;
let screening = state == Some(ServiceState::Running);
let action: fn() -> Result<(), BoxError> = match id {
MENU_PAUSE if screening => || Ok(open_service()?.pause().map(drop)?),
MENU_PAUSE if state == Some(ServiceState::Paused) => {
|| Ok(open_service()?.resume().map(drop)?)
}
MENU_PAUSE => || Ok(open_service()?.start::<&str>(&[])?),
MENU_RESTART => {
// Off the UI thread, since stopping takes a while.
std::thread::spawn(|| restart().unwrap_or_else(show_error));
return;
}
MENU_LOG => {
let log = HSTRING::from(data_dir().as_path());
let none = PCWSTR::null();
unsafe { ShellExecuteW(None, w!("open"), &log, none, none, SW_SHOWNORMAL) };
return;
}
_ => return,
};
action().unwrap_or_else(show_error);
}
}
fn show_error(error: BoxError) {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?;
Some(service.query_status().ok()?.current_state)
}
fn light_taskbar() -> bool {
let personalize = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
let Ok(key) = CURRENT_USER.open(personalize) else {
return false;
};
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1)
}
fn status_text(state: Option<ServiceState>) -> &'static str {
match state {
Some(ServiceState::Running) => "Screening downloads",
Some(ServiceState::Paused) => "Paused",
_ => "Not running",
}
}
fn open_service() -> Result<Service, BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::PAUSE_CONTINUE;
Ok(manager.open_service(SERVICE_NAME, access)?)
}
/// Starting again re-reads the config.
fn restart() -> Result<(), BoxError> {
let service = open_service()?;
if service.query_status()?.current_state != ServiceState::Stopped {
service.stop()?;
}
for _ in 0..60 {
if service.query_status()?.current_state == ServiceState::Stopped {
return Ok(service.start::<&str>(&[])?);
}
std::thread::sleep(Duration::from_millis(500));
}
Err("The service did not stop.".into())
}
/// The alpha channel of the RGBA logo, and its width.
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
let mut rgba = vec![0; reader.output_buffer_size().ok_or("logo too large")?];
let info = reader.next_frame(&mut rgba)?;
let mut alpha = Vec::with_capacity(rgba.len() / 4);
for pixel in rgba.chunks(4) {
alpha.push(pixel[3]);
}
Ok((alpha, info.width))
}
/// White on a dark taskbar and black on a light one, as macOS draws a template image; dimmed
/// when not screening.
fn logo_rgba(mask: &[u8], light: bool, screening: bool) -> Vec<u8> {
let shade = if light { 0 } else { 255 };
let mut rgba = Vec::with_capacity(mask.len() * 4);
for &alpha in mask {
let alpha = if screening { alpha } else { alpha / 2 };
rgba.extend([shade, shade, shade, alpha]);
}
rgba
}
+41
View File
@@ -0,0 +1,41 @@
use super::ca::remove_our_cas_except;
use super::tpm::CaKey;
use super::*;
use std::io::ErrorKind;
use std::path::Path;
use tracing::info;
/// Run by the MSI after it deletes the service, which would restore the hosts entries. Does
/// nothing for what is already gone.
pub fn uninstall() -> Result<(), BoxError> {
write_hosts_entries(&[])?;
remove_our_cas_except(None)?;
none_if_missing(LOCAL_MACHINE.remove_tree(REGISTRY_KEY))?;
CaKey::delete_machine_key()?;
let entries = match std::fs::read_dir(data_dir()) {
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
entries => entries?.collect::<Result<Vec<_>, _>>()?,
};
for path in entries.into_iter().map(|entry| entry.path()) {
// The admin's config stays for a reinstall, as on macOS.
if path != config_file() {
remove_path(&path)?;
}
}
// Fails while the config is in it.
let _ = std::fs::remove_dir(data_dir());
info!("removed the CA, its key and the data folder");
Ok(())
}
/// A file or a folder, if it is there.
fn remove_path(path: &Path) -> std::io::Result<()> {
let removed = match path.is_dir() {
true => std::fs::remove_dir_all(path),
false => std::fs::remove_file(path),
};
match removed {
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
removed => removed,
}
}
+1 -1
View File
@@ -143,7 +143,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs);
let period = Duration::from_secs(source.poll_secs.get());
let mut seen = revision(&source, None).await;
loop {
tokio::time::sleep(period).await;
+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,
}
}
+7 -2
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
}
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Registry {
pub prefix: &'static str,
pub eco: &'static str,
@@ -52,7 +52,7 @@ pub struct Registry {
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
@@ -63,6 +63,11 @@ impl Registry {
pub fn host(&self) -> &'static str {
host_of(self.upstream)
}
/// What the config file calls it.
pub fn name(&self) -> &'static str {
self.prefix.trim_matches('/')
}
}
/// The host of an https:// URL, with the path dropped.
+6 -2
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;
@@ -15,10 +15,11 @@ use std::sync::Arc;
use tracing::{info, info_span, warn, Instrument};
pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?;
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
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();
+65 -2
View File
@@ -1,13 +1,13 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::Package;
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.port.get(), firewall::DEFAULT_PORT);
}
#[test]
@@ -15,12 +15,66 @@ fn a_misspelled_setting_is_an_error() {
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
}
#[test]
fn nonsense_values_are_errors() {
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
for bad in [
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
r#"{"denylist": {"poll_secs": 0}}"#,
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
r#"{"denylist": {"revision": "https://"}}"#,
r#"{"port": 0}"#,
r#"{"min_age_hours": 18446744073709551615}"#,
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
] {
std::fs::write(&file, bad).expect("write");
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
}
std::fs::remove_file(&file).ok();
}
#[test]
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 a_mirror_is_screened_like_the_registry_it_copies() {
let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
assert!(firewall.hosts().contains(&"npm.example.com"));
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
std::fs::remove_file(&unknown).ok();
assert!(loaded.is_err());
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
@@ -31,3 +85,12 @@ async fn an_allowed_version_skips_the_filters() {
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
#[tokio::test]
async fn failing_closed_blocks_what_could_not_be_checked() {
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
let blocked = screen(url, &firewall).await.unwrap_or_default();
assert!(blocked.contains("could not be checked"), "{blocked:?}");
}
+22 -1
View File
@@ -1,7 +1,9 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::Firewall;
use firewall::{aliasing, Firewall, FirewallConfig};
use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target);
@@ -35,6 +37,25 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None);
}
#[test]
fn a_plain_http_upstream_is_not_upgraded() {
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = upstream.local_addr().expect("address");
std::thread::spawn(move || {
let (mut stream, _) = upstream.accept().expect("accept");
let _ = stream.read(&mut [0; 4096]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
});
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
}
#[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec();
+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);
}
+193
View File
@@ -0,0 +1,193 @@
<?xml version='1.0' encoding='windows-1252'?>
<!--
Copyright (C) 2017 Christopher R. Field.
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
<?else ?>
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
<?endif ?>
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi' xmlns:util='http://schemas.microsoft.com/wix/UtilExtension'>
<Product
Id='*'
Name='strix-firewall'
UpgradeCode='D944C2F6-BF55-4B21-A60E-1BBDB1A66293'
Manufacturer='Strix'
Language='1033'
Codepage='1252'
Version='$(var.Version)'>
<Package Id='*'
Keywords='Installer'
Description='Screens package downloads for known malware before they reach your machine'
Manufacturer='Strix'
InstallerVersion='500'
Languages='1033'
Compressed='yes'
InstallScope='perMachine'
SummaryCodepage='1252'
/>
<MajorUpgrade
Schedule='afterInstallInitialize'
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
<Property Id='DiskPrompt' Value='strix-firewall Installation'/>
<Directory Id='TARGETDIR' Name='SourceDir'>
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
<Directory Id='APPLICATIONFOLDER' Name='Strix Firewall'>
<Component Id='Path' Guid='033B1050-23D0-4F5A-A0D3-F4757B4B67E0' KeyPath='yes'>
<Environment
Id='PATH'
Name='PATH'
Value='[APPLICATIONFOLDER]'
Permanent='no'
Part='last'
Action='set'
System='yes'/>
</Component>
<Component Id='binary0' Guid='*'>
<File
Id='exe0'
Name='firewall.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall.exe'
KeyPath='yes'/>
</Component>
<Component Id='binary1' Guid='*'>
<File
Id='exe1'
Name='firewall-service.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall-service.exe'
KeyPath='yes'/>
<ServiceInstall
Name='StrixFirewall'
DisplayName='Strix Firewall'
Description='Screens package downloads for known malware'
Type='ownProcess'
Start='auto'
ErrorControl='normal'
Vital='yes'>
<!-- The default, except that interactive users (IU) may also start (RP), stop (WP) and pause (DT) it, as the tray does. -->
<PermissionEx Sddl='D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)'/>
</ServiceInstall>
<!-- Waits, so a service that cannot start fails the install. -->
<ServiceControl Id='StrixFirewall' Name='StrixFirewall' Start='install' Stop='both' Remove='uninstall' Wait='yes'/>
<!-- For clients that read no trust store. SSL_CERT_FILE would replace OpenSSL's bundle. -->
<Environment Id='NODE_EXTRA_CA_CERTS' Name='NODE_EXTRA_CA_CERTS' Value='[CommonAppDataFolder]Strix Firewall\ca.pem' Action='set' System='yes'/>
<Environment Id='UV_SYSTEM_CERTS' Name='UV_SYSTEM_CERTS' Value='1' Action='set' System='yes'/>
</Component>
<Component Id='binary2' Guid='*'>
<File
Id='exe2'
Name='firewall-tray.exe'
DiskId='1'
Source='$(var.CargoTargetBinDir)\firewall-tray.exe'
KeyPath='yes'/>
<RegistryValue
Root='HKLM'
Key='SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
Name='Strix Firewall'
Type='string'
Value='"[#exe2]"'/>
</Component>
</Directory>
</Directory>
</Directory>
<Feature
Id='Binaries'
Title='Application'
Description='Installs the firewall and its service.'
Level='1'
ConfigurableDirectory='APPLICATIONFOLDER'
AllowAdvertise='no'
Display='expand'
Absent='disallow'>
<ComponentRef Id='binary0'/>
<ComponentRef Id='binary1'/>
<ComponentRef Id='binary2'/>
<Feature
Id='Environment'
Title='PATH Environment Variable'
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
Level='1'
Absent='allow'>
<ComponentRef Id='Path'/>
</Feature>
</Feature>
<!-- Removing the app must not leave the hosts file pointing at a service that is gone. -->
<CustomAction
Id='UninstallFirewall'
FileKey='exe1'
ExeCommand='uninstall'
Execute='deferred'
Impersonate='no'
Return='check'/>
<!--
Registry hosts lead nowhere while the service is down, so bring it back, also when it fails to
start. Through sc.exe, as MsiServiceConfigFailureActions cannot restart a service.
-->
<CustomAction
Id='SetFailureActions'
Directory='APPLICATIONFOLDER'
ExeCommand='"[System64Folder]sc.exe" failure StrixFirewall reset= 3600 actions= restart/5000/restart/5000/restart/5000'
Execute='deferred'
Impersonate='no'
Return='check'/>
<CustomAction
Id='SetFailureFlag'
Directory='APPLICATIONFOLDER'
ExeCommand='"[System64Folder]sc.exe" failureflag StrixFirewall 1'
Execute='deferred'
Impersonate='no'
Return='check'/>
<!-- From the exit dialog, which runs as the signed-in user rather than elevated. -->
<CustomAction
Id='StartTray'
Directory='APPLICATIONFOLDER'
ExeCommand='"[#exe2]"'
Return='asyncNoWait'/>
<!-- Before its file is replaced or removed. Trays WM_CLOSE does not reach, as in other sessions, are terminated. -->
<util:CloseApplication Id='CloseTray' Target='firewall-tray.exe' CloseMessage='yes' RebootPrompt='no' TerminateProcess='0'/>
<InstallExecuteSequence>
<!-- An upgrade removes the old version too, but should keep screening. -->
<Custom Action='UninstallFirewall' After='DeleteServices'>REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE</Custom>
<Custom Action='SetFailureActions' After='InstallServices'>NOT REMOVE~="ALL"</Custom>
<Custom Action='SetFailureFlag' After='SetFailureActions'>NOT REMOVE~="ALL"</Custom>
<!-- The default, before InstallFiles, is after RemoveFiles, which would find the tray still running. -->
<Custom Action='WixCloseApplications' Before='RemoveFiles'/>
</InstallExecuteSequence>
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
<Property Id='ARPHELPLINK' Value='https://github.com/usestrix/firewall'/>
<UI>
<UIRef Id='WixUI_FeatureTree'/>
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
<Publish Dialog='ExitDialog' Control='Finish' Event='DoAction' Value='StartTray' Order='1'>NOT Installed</Publish>
</UI>
</Product>
</Wix>
+3 -1
View File
@@ -4,7 +4,9 @@ members = ["cargo:core"]
[dist]
cargo-dist-version = "0.32.0"
ci = "github"
installers = ["shell", "powershell", "homebrew"]
installers = ["shell", "powershell", "homebrew", "msi"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
tap = "usestrix/homebrew-tap"
publish-jobs = ["homebrew", "./publish-crates"]
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
+66
View File
@@ -0,0 +1,66 @@
# Platforms
## Requirements
| | Command prefix | Machine-wide |
|---|---|---|
| macOS | 13+, Apple silicon or Intel | Apple silicon, or an Intel Mac with a T2 chip |
| Windows | 10 or 11, x86-64 | A TPM 2.0, and an admin to install |
| Linux | glibc, x86-64 or arm64 | Not supported |
There are no musl, 32-bit or Windows on Arm builds.
`cargo install strix-firewall` installs only `firewall`. Add `--features machine-wide` for the
machine-wide binaries, which otherwise come with the macOS app and the Windows MSI.
## What is screened
npm, pnpm, bun, pip, pip3, pipenv, pdm, uv, cargo, go and gem, downloading from:
| Ecosystem | Hosts |
|---|---|
| npm | registry.npmjs.org |
| PyPI | pypi.org, files.pythonhosted.org |
| crates.io | index.crates.io, static.crates.io, crates.io |
| RubyGems | rubygems.org |
| Go | proxy.golang.org |
Machine-wide screening also covers mirrors listed in the config file, under the registry they
copy (`npm`, `pypi`, `pypi-files`, `cargo/index`, `cargo-dl`, `crates-api`, `gem` or `go`):
`"registries": {"npm": ["https://npm.example.com/"]}`. A mirror must lay out its paths like that
registry. `firewall <manager>` still sends managers to the public registries.
## How machine-wide screening works
Traffic to the hosts above is decrypted with a local CA that can only sign for those hosts. Its
private key never leaves the Secure Enclave (macOS) or the TPM (Windows).
- **macOS:** a system extension, which you approve in System Settings.
- **Windows:** a service, with a hosts-file entry pointing each registry host at it.
- Without a TPM, the service can't start, so the install fails.
- Any signed-in user can pause screening from the notification-area icon, until they resume
it or the service restarts.
- Clients that use an HTTP proxy or their own DNS skip the hosts file and aren't screened.
Nor is WSL.
- Each client has to trust the CA:
| Client | Trusts it through |
|---|---|
| npm, pnpm, bun | `NODE_EXTRA_CA_CERTS`, set to `%ProgramData%\Strix Firewall\ca.pem` |
| uv | `UV_SYSTEM_CERTS`, which uses the Windows certificate store |
| pip, cargo | The Windows certificate store |
| gem, go | Untested |
Tested on Windows 11 and Apple silicon only.
## Changing settings
Machine-wide screening reads an admin-only JSON file when it starts. After editing it, restart
screening. An invalid file stops screening from starting.
| OS | File | Restart from |
|---|---|---|
| macOS | `/Library/Application Support/Firewall/config.json` | The menu bar's Restart |
| Windows | `%ProgramData%\Strix Firewall\config.json` | The notification-area icon's Restart |
The `firewall` command reads `config.json` from the user's config folder instead.
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 }
}
+19 -30
View File
@@ -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
}
+62 -26
View File
@@ -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
}
}
@@ -43,14 +45,15 @@ 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?
@@ -88,8 +91,8 @@ final class FirewallModel: NSObject, ObservableObject {
}
func install() async {
busy = true
defer { busy = false }
operations += 1
defer { operations -= 1 }
removed = false
guard CompetingExtensions.confirmInstall() else {
@@ -111,14 +114,34 @@ final class FirewallModel: NSObject, ObservableObject {
completed = true
log("Setup complete")
offerRestart()
}
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
private func offerRestart() {
let alert = NSAlert()
alert.messageText = "Restart to finish setting up"
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
alert.addButton(withTitle: "Restart")
alert.addButton(withTitle: "Later")
NSApp.activate(ignoringOtherApps: true)
guard alert.runModal() == .alertFirstButtonReturn else { return }
let osascript = Process()
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
try? osascript.run()
}
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
func uninstall() async {
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 {
@@ -151,16 +174,16 @@ final class FirewallModel: 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 {
@@ -168,6 +191,14 @@ final class FirewallModel: NSObject, ObservableObject {
}
}
func openApprovalSettings() {
var pane = "x-apple.systempreferences:com.apple.preference.security"
if #available(macOS 15, *) {
pane = "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension"
}
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String,
work: @MainActor () async throws -> String) async -> Bool {
@@ -249,10 +280,10 @@ final class FirewallModel: NSObject, ObservableObject {
return true
}
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
/// The extension wrote these as root, so the bundled firewall-uninstall 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"
let uninstaller = Bundle.main.path(forAuxiliaryExecutable: "firewall-uninstall") ?? ""
let script = "do shell script quoted form of \"\(uninstaller)\" with administrator privileges"
var error: NSDictionary?
NSAppleScript(source: script)?.executeAndReturnError(&error)
if let error {
@@ -268,14 +299,18 @@ final class FirewallModel: 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
@@ -391,7 +426,6 @@ final class FirewallModel: NSObject, ObservableObject {
}
private func log(_ line: String) {
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
logger.info("\(line, privacy: .public)")
}
}
@@ -405,12 +439,14 @@ extension FirewallModel: 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")
}
}
@@ -420,12 +456,12 @@ extension FirewallModel: 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)) }
}
}
+2 -1
View File
@@ -47,6 +47,7 @@ build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
@@ -55,7 +56,7 @@ sign() {
echo "==> Signing with $IDENTITY"
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall-uninstall"
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
+14 -5
View File
@@ -10,7 +10,7 @@ settings:
"CARGO_PROFILE[config=Debug]": debug
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE)
"LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
ARCHS: arm64
ARCHS: arm64 x86_64
"ONLY_ACTIVE_ARCH[config=Debug]": "YES"
SWIFT_VERSION: "5.0"
DEVELOPMENT_TEAM: ${TEAM}
@@ -97,6 +97,7 @@ targets:
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info:
path: App/Info.plist
properties:
@@ -117,14 +118,22 @@ targets:
- target: Extension
embed: true
codeSign: true
# Uninstall runs the CLI as root to remove what the extension left.
# Uninstall runs this as root to remove what the extension left.
postBuildScripts:
- name: Embed the CLI
- name: Embed firewall-uninstall
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/"
bins=
for arch in $ARCHS; do
case $arch in
arm64|arm64e) triple=aarch64-apple-darwin ;;
x86_64) triple=x86_64-apple-darwin ;;
esac
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
done
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"