mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
38
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9cbb31e15c | ||
|
|
91da9d68c9 | ||
|
|
e0b723a732 | ||
|
|
fc02226cd6 | ||
|
|
5068b3578d | ||
|
|
8d781f3f98 | ||
|
|
e521cbdefa | ||
|
|
674f3bdcd3 | ||
|
|
2df02bf23f | ||
|
|
a3e5ab1802 | ||
|
|
da750007fc | ||
|
|
d9de2a8a94 | ||
|
|
b3f47664df | ||
|
|
bdcc2e93dc | ||
|
|
9e457c4d69 | ||
|
|
7cafa16cc4 | ||
|
|
38d9a4500e | ||
|
|
d03c4cb6e3 | ||
|
|
8f20758ba0 | ||
|
|
577e0b7eb0 | ||
|
|
f94d3bf211 | ||
|
|
753e21dd19 | ||
|
|
7c128de056 | ||
|
|
64e938bf7a | ||
|
|
b212bb92ee | ||
|
|
b1713d4576 | ||
|
|
902a80b06c | ||
|
|
18d8a0dd93 | ||
|
|
d551b115c4 | ||
|
|
f88f6a9d8b | ||
|
|
1b43b9ba16 | ||
|
|
59bb6de147 | ||
|
|
39729cec84 | ||
|
|
b4e2359a5a | ||
|
|
ba690a22e2 | ||
|
|
a2e6d3f72f | ||
|
|
ac26cd704e | ||
|
|
f500e4a42a |
No files matched your search
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
Generated
+473
-4
@@ -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"
|
||||
|
||||
+55
-1
@@ -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,21 @@ crate-type = ["lib"]
|
||||
name = "firewall"
|
||||
path = "src/main.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-service"
|
||||
path = "src/bin/firewall-service.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-tray"
|
||||
path = "src/bin/firewall-tray.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-uninstall"
|
||||
path = "src/bin/firewall-uninstall.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[example]]
|
||||
name = "proxy-test"
|
||||
path = "tests/proxy-test/main.rs"
|
||||
@@ -44,14 +74,26 @@ path = "tests/denylist.rs"
|
||||
name = "osv"
|
||||
path = "tests/osv.rs"
|
||||
|
||||
[[test]]
|
||||
name = "age"
|
||||
path = "tests/age.rs"
|
||||
|
||||
[[test]]
|
||||
name = "mitm"
|
||||
path = "tests/mitm.rs"
|
||||
|
||||
[[test]]
|
||||
name = "tpm"
|
||||
path = "tests/tpm.rs"
|
||||
|
||||
[[test]]
|
||||
name = "managers"
|
||||
path = "tests/managers.rs"
|
||||
|
||||
[features]
|
||||
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
|
||||
machine-wide = []
|
||||
|
||||
[dependencies]
|
||||
clap = "4.6.6"
|
||||
directories = "6.0.0"
|
||||
@@ -63,7 +105,7 @@ regex = "1.13.1"
|
||||
semver = "1.0.28"
|
||||
serde = { version = "1.0.229", features = ["derive"] }
|
||||
serde_json = "1.0.151"
|
||||
time = { version = "0.3.47", features = ["formatting"] }
|
||||
time = { version = "0.3.47", features = ["formatting", "parsing"] }
|
||||
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
|
||||
tracing = "0.1.44"
|
||||
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
|
||||
@@ -75,6 +117,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_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "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"] }
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
pub mod forward;
|
||||
pub mod managers;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
pub use forward::spawn;
|
||||
pub use managers::*;
|
||||
@@ -34,7 +36,8 @@ impl Invocation {
|
||||
|
||||
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
|
||||
let proxy = crate::local_url(port, "");
|
||||
let mut command = Command::new(resolve(&invocation.program)?);
|
||||
let program = resolve(&invocation.program)?;
|
||||
let mut command = Command::new(&program);
|
||||
command.args(invocation.arguments(&proxy));
|
||||
if let Some(dir) = dir {
|
||||
command.current_dir(dir);
|
||||
@@ -42,7 +45,12 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
|
||||
for (key, value) in managers::variables(&proxy) {
|
||||
command.env(key, value);
|
||||
}
|
||||
Ok(command.status()?.code().unwrap_or(1))
|
||||
#[cfg(windows)]
|
||||
windows::prepare();
|
||||
let status = command.status();
|
||||
#[cfg(windows)]
|
||||
let status = status.map_err(|error| windows::explain(error, &program));
|
||||
Ok(status?.code().unwrap_or(1))
|
||||
}
|
||||
|
||||
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::Path;
|
||||
use std::sync::Once;
|
||||
use tracing::debug;
|
||||
use windows::core::{BOOL, PCWSTR};
|
||||
use windows::Win32::System::Console::{SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT};
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||
|
||||
/// Called before spawning, so the manager behaves as it would run from the shell directly.
|
||||
/// Once per process: a caller may run several managers.
|
||||
pub fn prepare() {
|
||||
static PREPARED: Once = Once::new();
|
||||
PREPARED.call_once(|| {
|
||||
// The console sends Ctrl+C to the manager too. Exiting first would hand the prompt back
|
||||
// while a .cmd shim still asks "Terminate batch job (Y/N)?", so wait for its exit instead.
|
||||
// A handler, unlike ignoring the signal outright, is not inherited by the manager.
|
||||
let _ = unsafe { SetConsoleCtrlHandler(Some(ignore_interrupt), true) };
|
||||
if let Err(error) = kill_children_on_exit() {
|
||||
debug!(%error, "the manager may outlive the firewall");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsafe extern "system" fn ignore_interrupt(event: u32) -> BOOL {
|
||||
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT).into()
|
||||
}
|
||||
|
||||
/// Joins a job that kills what is left in it once the firewall exits, even if killed itself.
|
||||
/// The manager inherits the job when spawned, as do its own children.
|
||||
fn kill_children_on_exit() -> windows::core::Result<()> {
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
// Never closed: the handle closing as the firewall exits is what kills the rest.
|
||||
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
|
||||
unsafe {
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&raw const limits).cast(),
|
||||
size_of_val(&limits) as u32,
|
||||
)?
|
||||
};
|
||||
unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }
|
||||
}
|
||||
|
||||
/// Rust refuses to pass a line break to a batch file, where it would cut the command short.
|
||||
pub fn explain(error: Error, program: &Path) -> Error {
|
||||
let batch = program
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
|
||||
if !batch || error.kind() != ErrorKind::InvalidInput {
|
||||
return error;
|
||||
}
|
||||
let name = program.file_name().unwrap_or(program.as_os_str()).to_string_lossy();
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("{name} is a batch file, which cannot be passed an argument with a line break"),
|
||||
)
|
||||
}
|
||||
@@ -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() {}
|
||||
@@ -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() {}
|
||||
@@ -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() {}
|
||||
@@ -0,0 +1,36 @@
|
||||
use crate::policies::Package;
|
||||
use serde::Serialize;
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Bounded, so blocks pile up harmlessly while nothing collects them.
|
||||
const KEPT: usize = 50;
|
||||
|
||||
static RECENT: Mutex<Vec<BlockedPackage>> = Mutex::new(Vec::new());
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct BlockedPackage {
|
||||
pub eco: &'static str,
|
||||
pub name: String,
|
||||
pub version: Option<String>,
|
||||
pub filter: &'static str,
|
||||
pub reason: String,
|
||||
}
|
||||
|
||||
pub fn record(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) {
|
||||
let mut recent = RECENT.lock().unwrap_or_else(|e| e.into_inner());
|
||||
if recent.len() == KEPT {
|
||||
recent.remove(0);
|
||||
}
|
||||
recent.push(BlockedPackage {
|
||||
eco,
|
||||
name: pkg.name.clone(),
|
||||
version: pkg.version.clone(),
|
||||
filter,
|
||||
reason: reason.to_owned(),
|
||||
});
|
||||
}
|
||||
|
||||
/// Hands each block over once.
|
||||
pub fn take() -> Vec<BlockedPackage> {
|
||||
std::mem::take(&mut *RECENT.lock().unwrap_or_else(|e| e.into_inner()))
|
||||
}
|
||||
+96
-25
@@ -1,11 +1,12 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::PathBuf;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
@@ -13,37 +14,43 @@ pub struct FirewallConfig {
|
||||
pub port: u16,
|
||||
/// Allow a package when a filter could not check it.
|
||||
pub fail_open: bool,
|
||||
/// 0 turns the age check off.
|
||||
pub min_age_hours: u64,
|
||||
pub denylist: DenylistSource,
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AllowedPackage {
|
||||
pub name: String,
|
||||
/// Every version when absent.
|
||||
pub version: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct DenylistSource {
|
||||
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
|
||||
pub base: String,
|
||||
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
|
||||
pub token: Option<String>,
|
||||
/// Polled for a new commit, which re-fetches every list in use.
|
||||
pub revision: String,
|
||||
pub poll_secs: u64,
|
||||
}
|
||||
|
||||
impl DenylistSource {
|
||||
pub fn token(&self) -> Option<String> {
|
||||
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for FirewallConfig {
|
||||
fn default() -> Self {
|
||||
FirewallConfig {
|
||||
port: DEFAULT_PORT,
|
||||
fail_open: true,
|
||||
min_age_hours: 24,
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
}
|
||||
@@ -53,9 +60,8 @@ impl Default for FirewallConfig {
|
||||
impl Default for DenylistSource {
|
||||
fn default() -> Self {
|
||||
DenylistSource {
|
||||
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
|
||||
token: None,
|
||||
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
|
||||
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
}
|
||||
@@ -63,26 +69,91 @@ impl Default for DenylistSource {
|
||||
}
|
||||
|
||||
impl FirewallConfig {
|
||||
/// A missing file means defaults; a bad one is an error, so the policy is never quietly relaxed.
|
||||
pub fn load() -> std::io::Result<Self> {
|
||||
let Some(path) = path() else {
|
||||
return Ok(Self::default());
|
||||
};
|
||||
let text = match std::fs::read_to_string(&path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
|
||||
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
|
||||
};
|
||||
serde_json::from_str(&text)
|
||||
.map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
|
||||
let mut paths: Vec<PathBuf> = path().into_iter().collect();
|
||||
paths.extend(crate::os::system_config());
|
||||
Self::load_from(&paths)
|
||||
}
|
||||
|
||||
/// Later files win key by key, but allow lists add up.
|
||||
/// A bad file is an error, so the policy is never quietly relaxed.
|
||||
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> {
|
||||
let mut merged = Map::new();
|
||||
for path in paths {
|
||||
let text = match std::fs::read_to_string(path) {
|
||||
Ok(text) => text,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => continue,
|
||||
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
|
||||
};
|
||||
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
|
||||
FirewallConfig::deserialize(&layer).map_err(invalid)?;
|
||||
let Value::Object(layer) = layer else {
|
||||
unreachable!("a config parses only from an object");
|
||||
};
|
||||
merge(&mut merged, layer);
|
||||
}
|
||||
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
|
||||
}
|
||||
|
||||
pub fn local_url(&self, path: &str) -> String {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
pub fn path() -> Option<PathBuf> {
|
||||
let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
|
||||
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?;
|
||||
Some(dirs.config_dir().join("config.json"))
|
||||
}
|
||||
|
||||
fn merge(base: &mut Map<String, Value>, top: Map<String, Value>) {
|
||||
for (key, value) in top {
|
||||
match (base.get_mut(&key), value) {
|
||||
(Some(Value::Object(base)), Value::Object(top)) => merge(base, top),
|
||||
(Some(Value::Array(base)), Value::Array(top)) => base.extend(top),
|
||||
(_, value) => {
|
||||
base.insert(key, value);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Adds an `allow` entry to the file, leaving the rest of it as written.
|
||||
pub fn allow(path: &Path, eco: &str, name: &str, version: Option<&str>) -> std::io::Result<()> {
|
||||
let invalid = |e: String| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
let mut config = match std::fs::read_to_string(path) {
|
||||
Ok(text) => serde_json::from_str(&text).map_err(|e| invalid(e.to_string()))?,
|
||||
Err(e) if e.kind() == ErrorKind::NotFound => json!({}),
|
||||
Err(e) => return Err(e),
|
||||
};
|
||||
if !config.is_object() {
|
||||
return Err(invalid("not an object".into()));
|
||||
}
|
||||
let entry = json!({ "name": name, "version": version });
|
||||
let entries = &mut config["allow"][eco];
|
||||
if entries.is_null() {
|
||||
*entries = Value::Array(Vec::new());
|
||||
}
|
||||
let Some(entries) = entries.as_array_mut() else {
|
||||
return Err(invalid(format!("allow.{eco} is not a list")));
|
||||
};
|
||||
if !entries.contains(&entry) {
|
||||
entries.push(entry);
|
||||
}
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
std::fs::write(path, serde_json::to_string_pretty(&config)?)
|
||||
}
|
||||
+12
-10
@@ -1,4 +1,5 @@
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::policies::filters::age::Age;
|
||||
use crate::policies::filters::denylist::Denylist;
|
||||
use crate::policies::filters::osv::Osv;
|
||||
use crate::policies::filters::Filter;
|
||||
@@ -26,8 +27,11 @@ impl Firewall {
|
||||
patterns.insert(registry.prefix, compiled.collect());
|
||||
}
|
||||
let ecosystems = config.registries.iter().map(|r| r.osv_name);
|
||||
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
|
||||
let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
|
||||
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
}
|
||||
Firewall { config, patterns, filters }
|
||||
}
|
||||
|
||||
@@ -40,14 +44,12 @@ impl Firewall {
|
||||
}
|
||||
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.config.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
self.config.hosts()
|
||||
}
|
||||
|
||||
pub fn is_https_url(&self, url: &str) -> bool {
|
||||
let host = host_of(url);
|
||||
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
|
||||
}
|
||||
|
||||
pub fn is_index_url(&self, url: &str) -> bool {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod aliasing;
|
||||
pub mod blocks;
|
||||
pub mod config;
|
||||
pub mod firewall;
|
||||
pub mod http;
|
||||
|
||||
+30
-4
@@ -25,11 +25,13 @@ fn options() -> Command {
|
||||
"Run a command through {program} to screen what it installs:\n \
|
||||
{program} npm install left-pad\n\n\
|
||||
Alias it to screen every install without changing how you type:\n \
|
||||
alias npm='{program} npm'\n\n\
|
||||
{}\n\n\
|
||||
Supported package managers: {}",
|
||||
alias(&program),
|
||||
managers.join(", ")
|
||||
);
|
||||
Command::new("firewall")
|
||||
.bin_name(&program)
|
||||
.about("Screens package downloads before they reach your machine")
|
||||
.after_help(footer)
|
||||
.arg_required_else_help(true)
|
||||
@@ -48,6 +50,7 @@ fn options() -> Command {
|
||||
}
|
||||
|
||||
fn serve() -> ! {
|
||||
telemetry::set_mode("proxy");
|
||||
let config = load();
|
||||
let port = config.port;
|
||||
print_setup(&config);
|
||||
@@ -68,6 +71,7 @@ fn serve() -> ! {
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
telemetry::set_mode("wrapper");
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
Ok(port) => port,
|
||||
@@ -91,12 +95,32 @@ fn print_setup(config: &FirewallConfig) {
|
||||
let base = config.local_url("");
|
||||
println!("Firewall listening on {base}\n");
|
||||
println!("Point your package managers at it:");
|
||||
println!(" npm npm_config_registry={base}/npm/");
|
||||
println!(" pip PIP_INDEX_URL={base}/pypi/simple/");
|
||||
println!(" npm {}", variable("npm_config_registry", &format!("{base}/npm/")));
|
||||
println!(" pip {}", variable("PIP_INDEX_URL", &format!("{base}/pypi/simple/")));
|
||||
println!(" cargo [source.crates-io] replace-with = \"firewall\"");
|
||||
println!(" [source.firewall] registry = \"sparse+{base}/cargo/index/\"\n");
|
||||
}
|
||||
|
||||
fn alias(program: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
// Set-Alias cannot carry arguments, so a function stands in.
|
||||
format!(
|
||||
"PowerShell: function npm {{ {program} npm @args }}\n \
|
||||
cmd: doskey npm={program} npm $*"
|
||||
)
|
||||
} else {
|
||||
format!("alias npm='{program} npm'")
|
||||
}
|
||||
}
|
||||
|
||||
fn variable(key: &str, value: &str) -> String {
|
||||
if cfg!(windows) {
|
||||
format!("$env:{key} = \"{value}\"")
|
||||
} else {
|
||||
format!("{key}={value}")
|
||||
}
|
||||
}
|
||||
|
||||
fn load() -> FirewallConfig {
|
||||
match FirewallConfig::load() {
|
||||
Ok(config) => config,
|
||||
@@ -113,7 +137,9 @@ fn fail(kind: &'static str, message: &str) -> ! {
|
||||
|
||||
fn program_name() -> String {
|
||||
let arg = std::env::args_os().next().unwrap_or_default();
|
||||
match Path::new(&arg).file_name() {
|
||||
// Without `.exe` on Windows, which is how it is typed.
|
||||
let path = Path::new(&arg);
|
||||
match if cfg!(windows) { path.file_stem() } else { path.file_name() } {
|
||||
Some(name) => name.to_string_lossy().into_owned(),
|
||||
None => "firewall".into(),
|
||||
}
|
||||
|
||||
+29
-6
@@ -1,12 +1,14 @@
|
||||
use rama::error::BoxError;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rcgen::{
|
||||
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
|
||||
KeyUsagePurpose, NameConstraints, SigningKey,
|
||||
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
|
||||
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
|
||||
SerialNumber, SigningKey,
|
||||
};
|
||||
use std::sync::Arc;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
/// Held in the Secure Enclave on macOS, in memory elsewhere.
|
||||
/// 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 {
|
||||
@@ -42,16 +44,37 @@ impl CertificateAuthority {
|
||||
}
|
||||
}
|
||||
|
||||
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
|
||||
/// Signed fresh for each fetch; a week's validity outlasts any verifier's cache.
|
||||
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
|
||||
let now = OffsetDateTime::now_utc();
|
||||
let list = CertificateRevocationListParams {
|
||||
this_update: now - Duration::hours(1),
|
||||
next_update: now + Duration::days(7),
|
||||
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
|
||||
issuing_distribution_point: None,
|
||||
revoked_certs: Vec::new(),
|
||||
key_identifier_method: KeyIdMethod::Sha256,
|
||||
};
|
||||
let ca = params(hosts);
|
||||
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
|
||||
}
|
||||
|
||||
pub const COMMON_NAME: &str = "Strix Firewall";
|
||||
/// With `COMMON_NAME`, how a trust store entry is recognized as ours.
|
||||
pub const ORGANIZATION: &str = "Strix";
|
||||
|
||||
/// Also used by the issuer, whose issuer name must match the CA's subject.
|
||||
pub fn params(hosts: &[&str]) -> CertificateParams {
|
||||
let mut name = DistinguishedName::new();
|
||||
name.push(DnType::CommonName, "Strix Firewall");
|
||||
name.push(DnType::OrganizationName, "Strix");
|
||||
name.push(DnType::CommonName, COMMON_NAME);
|
||||
name.push(DnType::OrganizationName, ORGANIZATION);
|
||||
|
||||
let mut params = CertificateParams::default();
|
||||
params.distinguished_name = name;
|
||||
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
|
||||
// rcgen signs a CRL only for a CA that may.
|
||||
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
|
||||
params.name_constraints = Some(NameConstraints {
|
||||
permitted_subtrees: hosts
|
||||
.iter()
|
||||
|
||||
+12
-3
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
|
||||
use rama::tls::boring::core::x509::X509;
|
||||
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
|
||||
use rama::utils::collections::non_empty_vec;
|
||||
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
|
||||
use std::sync::Arc;
|
||||
|
||||
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
|
||||
@@ -14,12 +14,18 @@ pub struct ConstrainedCertIssuer {
|
||||
ca_params: Arc<CertificateParams>,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
}
|
||||
|
||||
impl ConstrainedCertIssuer {
|
||||
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
|
||||
pub(super) fn new(
|
||||
ca_crt: X509,
|
||||
signer: CaSigner,
|
||||
hosts: Vec<&'static str>,
|
||||
crl: Option<String>,
|
||||
) -> Self {
|
||||
let ca_params = Arc::new(params(&hosts));
|
||||
Self { ca_crt, ca_params, signer, hosts }
|
||||
Self { ca_crt, ca_params, signer, hosts, crl }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,6 +48,9 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
|
||||
subject.push(DnType::CommonName, common_name);
|
||||
let mut leaf = CertificateParams::new(names)?;
|
||||
leaf.distinguished_name = subject;
|
||||
if let Some(crl) = &self.crl {
|
||||
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
|
||||
}
|
||||
|
||||
// The TLS server needs the leaf key in memory, so it can't be the platform key.
|
||||
let leaf_key = KeyPair::generate()?;
|
||||
|
||||
@@ -3,7 +3,7 @@ mod issuer;
|
||||
mod route;
|
||||
mod screen;
|
||||
|
||||
pub use ca::{CaSigner, CertificateAuthority};
|
||||
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION};
|
||||
|
||||
use crate::Firewall;
|
||||
use issuer::ConstrainedCertIssuer;
|
||||
@@ -25,14 +25,16 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
|
||||
type InterceptService =
|
||||
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
|
||||
|
||||
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
|
||||
pub fn mitm_service(
|
||||
firewall: Arc<Firewall>,
|
||||
ca: &CertificateAuthority,
|
||||
signer: CaSigner,
|
||||
crl: Option<String>,
|
||||
exec: Executor,
|
||||
) -> MitmService {
|
||||
let hosts = firewall.hosts();
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
|
||||
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
|
||||
|
||||
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
+37
-12
@@ -2,8 +2,10 @@ mod enclave;
|
||||
mod install;
|
||||
mod uninstall;
|
||||
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::{mitm, telemetry};
|
||||
pub use uninstall::uninstall;
|
||||
|
||||
use crate::config::{self, FirewallConfig};
|
||||
use crate::{blocks, mitm, telemetry};
|
||||
use crate::Firewall;
|
||||
use rama::bytes::Bytes;
|
||||
use rama::error::BoxError;
|
||||
@@ -20,16 +22,19 @@ use rama::net::apple::networkextension::{transparent_proxy_ffi, NwTcpStream, Tcp
|
||||
use rama::rt::Executor;
|
||||
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
|
||||
use rama::{Layer, Service};
|
||||
use serde::Deserialize;
|
||||
use std::convert::Infallible;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
use tracing_subscriber::layer::SubscriberExt;
|
||||
use tracing_subscriber::util::SubscriberInitExt;
|
||||
|
||||
const BUNDLE_ID: &str = "ai.strix.firewall";
|
||||
const BUNDLE_ID: &str = "com.usestrix.firewall";
|
||||
const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
|
||||
const KEYCHAIN_ACCOUNT: &str = "ca";
|
||||
/// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
|
||||
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
|
||||
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
|
||||
const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Handler {
|
||||
@@ -49,16 +54,17 @@ impl TransparentProxyHandler for Handler {
|
||||
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
|
||||
}
|
||||
|
||||
/// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
|
||||
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply.
|
||||
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
|
||||
let message = String::from_utf8_lossy(&message).into_owned();
|
||||
Some(Bytes::from(match message.as_str() {
|
||||
"ca" => self.ca.pem.clone(),
|
||||
"uninstall" => match uninstall::certificate_authority(&self.ca) {
|
||||
Ok(()) => "ok".to_owned(),
|
||||
Err(error) => format!("error: {error}"),
|
||||
"blocks" => serde_json::to_string(&blocks::take()).expect("blocks serialize"),
|
||||
other => match other.strip_prefix("allow ").map(allow) {
|
||||
Some(Ok(())) => "ok".to_owned(),
|
||||
Some(Err(error)) => format!("error: {error}"),
|
||||
None => format!("error: unknown message {other}"),
|
||||
},
|
||||
other => format!("error: unknown message {other}"),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -99,21 +105,40 @@ 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"))?;
|
||||
let signer: mitm::CaSigner = Arc::new(key);
|
||||
let ca = install::certificate_authority(&hosts, &*signer);
|
||||
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, exec);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, service })
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AllowRequest {
|
||||
eco: String,
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
}
|
||||
|
||||
/// Takes effect when the provider restarts, which rebuilds it from the config.
|
||||
fn allow(request: &str) -> Result<(), BoxError> {
|
||||
let request: AllowRequest = serde_json::from_str(request)?;
|
||||
config::allow(CONFIG_FILE.as_ref(), &request.eco, &request.name, request.version.as_deref())?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// stderr is invisible in a system extension, so events go to the unified log:
|
||||
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
|
||||
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
|
||||
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
|
||||
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
|
||||
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
|
||||
|
||||
@@ -1,26 +1,45 @@
|
||||
use super::enclave::EnclaveKey;
|
||||
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
|
||||
use crate::mitm::CertificateAuthority;
|
||||
use crate::mitm::COMMON_NAME;
|
||||
use rama::error::BoxError;
|
||||
use rama::net::apple::networkextension::system_keychain;
|
||||
use security_framework::item::{ItemClass, ItemSearchOptions, Limit};
|
||||
use security_framework::os::macos::keychain::SecKeychain;
|
||||
use security_framework_sys::base::errSecItemNotFound;
|
||||
use std::io::ErrorKind;
|
||||
use tracing::info;
|
||||
|
||||
/// Installed by the pkg.
|
||||
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
|
||||
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
|
||||
|
||||
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
|
||||
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
|
||||
pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
|
||||
system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
|
||||
/// Removes what only root can: every CA the extension minted, its enclave key and exported
|
||||
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those.
|
||||
pub fn uninstall() -> Result<(), BoxError> {
|
||||
let deleted = ItemSearchOptions::new()
|
||||
.class(ItemClass::certificate())
|
||||
.keychains(&[SecKeychain::open("/Library/Keychains/System.keychain")?])
|
||||
.label(COMMON_NAME)
|
||||
.limit(Limit::All)
|
||||
.delete();
|
||||
let mut removed = match deleted {
|
||||
Ok(()) => true,
|
||||
Err(error) if error.code() == errSecItemNotFound => false,
|
||||
Err(error) => return Err(error.into()),
|
||||
};
|
||||
// Reading a secret's data is limited to the extension, so delete without checking it exists.
|
||||
system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
|
||||
EnclaveKey::delete()?;
|
||||
for path in [CA_FILE, ENV_AGENT] {
|
||||
match std::fs::remove_file(path) {
|
||||
Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
|
||||
_ => {}
|
||||
Ok(()) => removed = true,
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => {}
|
||||
Err(error) => return Err(error.into()),
|
||||
}
|
||||
}
|
||||
info!("removed the CA from the system keychain");
|
||||
if removed {
|
||||
info!("removed the CA from the system keychain");
|
||||
} else {
|
||||
info!("nothing to uninstall");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,2 +1,14 @@
|
||||
#[cfg(target_os = "macos")]
|
||||
pub mod macos;
|
||||
#[cfg(windows)]
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::system_config;
|
||||
#[cfg(windows)]
|
||||
pub use windows::system_config;
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
pub fn system_config() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
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::Win32::Storage::FileSystem::CreateDirectoryW;
|
||||
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 with its ACL, which is 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();
|
||||
let exists = match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
std::fs::remove_dir(&dir)?;
|
||||
false
|
||||
}
|
||||
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)?;
|
||||
false
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
};
|
||||
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)?;
|
||||
if exists {
|
||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||
} else {
|
||||
let attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: acl.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
// Never inherits ProgramData's ACL, which lets users add files; fails if it was recreated.
|
||||
unsafe { CreateDirectoryW(&path, Some(&attributes))? };
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -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?)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
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::Win32::Foundation::ERROR_GEN_FAILURE;
|
||||
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 => {
|
||||
// Answered once handled, so a failed pause fails the caller's ControlService.
|
||||
let (reply, handled) = tokio::sync::oneshot::channel();
|
||||
let _ = control_sender.send((control, reply));
|
||||
handled
|
||||
.blocking_recv()
|
||||
.unwrap_or(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, reply)) = control_receiver.recv().await {
|
||||
let (hosts, state) = match control {
|
||||
ServiceControl::Pause => (&[][..], ServiceState::Paused),
|
||||
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
|
||||
_ => break,
|
||||
};
|
||||
let result = match write_hosts_entries(hosts) {
|
||||
Ok(()) => {
|
||||
set_status(&status, state, false)?;
|
||||
ServiceControlHandlerResult::NoError
|
||||
}
|
||||
Err(error) => {
|
||||
tracing::error!(%error, "could not rewrite the hosts file");
|
||||
let code = error
|
||||
.raw_os_error()
|
||||
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
|
||||
ServiceControlHandlerResult::Other(code)
|
||||
}
|
||||
};
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
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_warning().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,
|
||||
})
|
||||
}
|
||||
@@ -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 |
@@ -0,0 +1,227 @@
|
||||
//! 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 = match error.source() {
|
||||
Some(source) => format!("{error}: {source}"),
|
||||
None => error.to_string(),
|
||||
};
|
||||
let text = HSTRING::from(text);
|
||||
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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
use super::{Filter, Verdict, VerdictFuture, AGENT};
|
||||
use crate::policies::registries::{Published, Registry};
|
||||
use crate::policies::Package;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::USER_AGENT;
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::http::{BodyExtractExt, StatusCode};
|
||||
use serde_json::Value;
|
||||
use time::format_description::well_known::Rfc3339;
|
||||
use time::{Duration, OffsetDateTime};
|
||||
|
||||
pub struct Age {
|
||||
min: Duration,
|
||||
}
|
||||
|
||||
impl Age {
|
||||
pub fn new(hours: u64) -> Self {
|
||||
Age { min: Duration::hours(hours as i64) }
|
||||
}
|
||||
}
|
||||
|
||||
impl Filter for Age {
|
||||
fn name(&self) -> &'static str {
|
||||
"age"
|
||||
}
|
||||
|
||||
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
|
||||
let Some(published) = ®istry.published else { return Verdict::Allow };
|
||||
let published_at = match published_at(published, &pkg.name, version).await {
|
||||
Ok(at) => at,
|
||||
Err(e) => return Verdict::Unknown(e),
|
||||
};
|
||||
|
||||
let age = OffsetDateTime::now_utc() - published_at;
|
||||
if age >= self.min {
|
||||
return Verdict::Allow;
|
||||
}
|
||||
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
|
||||
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
async fn published_at(
|
||||
published: &Published,
|
||||
name: &str,
|
||||
version: &str,
|
||||
) -> Result<OffsetDateTime, String> {
|
||||
let name = match published.escape {
|
||||
Some(escape) => escape(name),
|
||||
None => name.to_string(),
|
||||
};
|
||||
let url = published.url.replace("{name}", &name).replace("{version}", version);
|
||||
let pointer = published.pointer.replace("{version}", version);
|
||||
|
||||
let client = EasyHttpWebClient::default();
|
||||
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
|
||||
let response = response.map_err(|e| e.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("{url} replied {}", response.status()));
|
||||
}
|
||||
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
|
||||
let body: Value =
|
||||
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
|
||||
|
||||
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
|
||||
return Err(format!("{url} gave no publish time"));
|
||||
};
|
||||
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
|
||||
}
|
||||
@@ -1,9 +1,9 @@
|
||||
use super::{Filter, Verdict, VerdictFuture};
|
||||
use super::{Filter, Verdict, VerdictFuture, AGENT};
|
||||
use crate::config::DenylistSource;
|
||||
use crate::policies::registries::Registry;
|
||||
use crate::policies::Package;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
|
||||
use rama::http::service::client::HttpClientExt;
|
||||
use rama::http::{BodyExtractExt, StatusCode};
|
||||
use semver::Version;
|
||||
@@ -14,9 +14,6 @@ use tokio::sync::OnceCell;
|
||||
use tokio::task::JoinHandle;
|
||||
use tracing::{info, warn};
|
||||
|
||||
/// The GitHub API turns away requests without a user agent.
|
||||
const AGENT: &str = "strix-firewall";
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum AffectedVersions {
|
||||
All,
|
||||
@@ -40,20 +37,15 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
|
||||
|
||||
pub struct Denylist {
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
lists: Arc<Lists>,
|
||||
watch: JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl Denylist {
|
||||
pub fn new(
|
||||
source: DenylistSource,
|
||||
token: Option<String>,
|
||||
ecosystems: impl Iterator<Item = &'static str>,
|
||||
) -> Self {
|
||||
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
|
||||
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
|
||||
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, token, lists, watch }
|
||||
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
|
||||
Denylist { source, lists, watch }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +64,7 @@ impl Filter for Denylist {
|
||||
Box::pin(async move {
|
||||
let eco = registry.osv_name;
|
||||
let load = || async {
|
||||
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
|
||||
RwLock::new(Arc::new(load(&self.source, eco).await))
|
||||
};
|
||||
let list = self.lists[eco].get_or_init(load).await;
|
||||
let list = Arc::clone(&list.read().expect("denylist lock"));
|
||||
@@ -150,12 +142,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
|
||||
}
|
||||
|
||||
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
|
||||
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
|
||||
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
let period = Duration::from_secs(source.poll_secs);
|
||||
let mut seen = revision(&source, token.as_deref(), None).await;
|
||||
let mut seen = revision(&source, None).await;
|
||||
loop {
|
||||
tokio::time::sleep(period).await;
|
||||
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
|
||||
let changed = revision(&source, seen.as_deref()).await;
|
||||
if let Some(current) = &changed {
|
||||
info!(revision = %current, "denylist source changed");
|
||||
seen = changed.clone();
|
||||
@@ -166,7 +158,7 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
|
||||
if changed.is_none() && !failed {
|
||||
continue;
|
||||
}
|
||||
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
|
||||
if let Ok(packages) = load(&source, eco).await {
|
||||
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
|
||||
}
|
||||
}
|
||||
@@ -174,16 +166,9 @@ async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>)
|
||||
}
|
||||
|
||||
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
|
||||
async fn revision(
|
||||
source: &DenylistSource,
|
||||
token: Option<&str>,
|
||||
seen: Option<&str>,
|
||||
) -> Option<String> {
|
||||
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
|
||||
let client = EasyHttpWebClient::default();
|
||||
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
if let Some(seen) = seen {
|
||||
request = request.header(IF_NONE_MATCH, seen);
|
||||
}
|
||||
@@ -200,8 +185,8 @@ async fn revision(
|
||||
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
|
||||
}
|
||||
|
||||
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, token, &eco.to_lowercase()).await;
|
||||
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
|
||||
let list = fetch(source, &eco.to_lowercase()).await;
|
||||
match &list {
|
||||
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
|
||||
Err(error) => warn!(%eco, %error, "denylist unavailable"),
|
||||
@@ -209,13 +194,10 @@ async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -
|
||||
list
|
||||
}
|
||||
|
||||
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
|
||||
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
|
||||
let url = format!("{}/{eco}", source.base);
|
||||
let client = EasyHttpWebClient::default();
|
||||
let mut request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
if let Some(token) = token {
|
||||
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
|
||||
}
|
||||
let request = client.get(&url).header(USER_AGENT, AGENT);
|
||||
let response = request.send().await.map_err(|e| e.to_string())?;
|
||||
if response.status() != StatusCode::OK {
|
||||
return Err(format!("{url} replied {}", response.status()));
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
pub mod age;
|
||||
pub mod denylist;
|
||||
pub mod osv;
|
||||
|
||||
@@ -6,6 +7,9 @@ use crate::Firewall;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
|
||||
/// Some registry APIs turn away requests without a user agent.
|
||||
const AGENT: &str = "strix-firewall";
|
||||
|
||||
pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
|
||||
|
||||
/// `Unknown` means the filter could not check the package.
|
||||
@@ -26,6 +30,9 @@ pub async fn consult(
|
||||
registry: &'static Registry,
|
||||
pkg: &Package,
|
||||
) -> Option<(&'static str, Verdict)> {
|
||||
if allowed(firewall, registry, pkg) {
|
||||
return None;
|
||||
}
|
||||
let mut unsure = None;
|
||||
for filter in firewall.filters() {
|
||||
match filter.check(registry, pkg).await {
|
||||
@@ -37,3 +44,18 @@ pub async fn consult(
|
||||
}
|
||||
unsure
|
||||
}
|
||||
|
||||
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
|
||||
let Some(entries) = firewall.config.allow.get(registry.eco) else {
|
||||
return false;
|
||||
};
|
||||
entries.iter().any(|entry| {
|
||||
let name_matches = match registry.normalize {
|
||||
Some(normalize) => normalize(&entry.name) == pkg.name,
|
||||
None => entry.name == pkg.name,
|
||||
};
|
||||
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
|
||||
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
|
||||
name_matches && (version.is_none() || version == pkg.version.as_deref())
|
||||
})
|
||||
}
|
||||
@@ -69,9 +69,13 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
|
||||
};
|
||||
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
|
||||
match consult(firewall, registry, &pkg).await {
|
||||
Some((_, Verdict::Deny(reason))) => {
|
||||
Some((filter, Verdict::Deny(reason))) => {
|
||||
crate::blocks::record(registry.eco, &pkg, filter, &reason);
|
||||
Some(format!("{} {reason}", pkg.label()))
|
||||
}
|
||||
Some((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
|
||||
Some(format!("{} could not be checked by {filter} ({e})", pkg.label()))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static CARGO_INDEX: Registry = Registry {
|
||||
prefix: "/cargo/index/",
|
||||
@@ -16,6 +16,7 @@ pub static CARGO_INDEX: Registry = Registry {
|
||||
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
|
||||
Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
|
||||
],
|
||||
published: None,
|
||||
};
|
||||
|
||||
pub static CARGO_DL: Registry = Registry {
|
||||
@@ -26,6 +27,11 @@ pub static CARGO_DL: Registry = Registry {
|
||||
index: false,
|
||||
normalize: None,
|
||||
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: None }],
|
||||
published: Some(Published {
|
||||
url: "https://crates.io/api/v1/crates/{name}/{version}",
|
||||
pointer: "/version/created_at",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
|
||||
/// `cargo add` resolves versions here, named by config.json's "api" field.
|
||||
@@ -37,4 +43,5 @@ pub static CRATES_API: Registry = Registry {
|
||||
index: false,
|
||||
normalize: None,
|
||||
patterns: &[],
|
||||
published: None,
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static GEM: Registry = Registry {
|
||||
prefix: "/gem/",
|
||||
@@ -16,4 +16,9 @@ pub static GEM: Registry = Registry {
|
||||
// The compact index, which names no version.
|
||||
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None },
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://rubygems.org/api/v2/rubygems/{name}/versions/{version}.json",
|
||||
pointer: "/created_at",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static GO: Registry = Registry {
|
||||
prefix: "/go/",
|
||||
@@ -13,6 +13,11 @@ pub static GO: Registry = Registry {
|
||||
// The version list and the latest lookup, which name no version.
|
||||
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None },
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://proxy.golang.org/{name}/@v/v{version}.info",
|
||||
pointer: "/Time",
|
||||
escape: Some(escape),
|
||||
}),
|
||||
};
|
||||
|
||||
/// The proxy protocol writes every capital as !<lowercase>; advisories do not.
|
||||
@@ -27,3 +32,14 @@ fn unescape(path: &str) -> String {
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape(path: &str) -> String {
|
||||
let mut out = String::with_capacity(path.len());
|
||||
for c in path.chars() {
|
||||
if c.is_ascii_uppercase() {
|
||||
out.push('!');
|
||||
}
|
||||
out.push(c.to_ascii_lowercase());
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -48,6 +48,15 @@ pub struct Registry {
|
||||
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
|
||||
pub normalize: Option<fn(&str) -> String>,
|
||||
pub patterns: &'static [Pattern],
|
||||
pub published: Option<Published>,
|
||||
}
|
||||
|
||||
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
|
||||
#[derive(Debug)]
|
||||
pub struct Published {
|
||||
pub url: &'static str,
|
||||
pub pointer: &'static str,
|
||||
pub escape: Option<fn(&str) -> String>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
pub static NPM: Registry = Registry {
|
||||
prefix: "/npm/",
|
||||
@@ -18,4 +18,9 @@ pub static NPM: Registry = Registry {
|
||||
rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://registry.npmjs.org/{name}",
|
||||
pointer: "/time/{version}",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Pattern, Registry};
|
||||
use super::{Pattern, Published, Registry};
|
||||
|
||||
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
|
||||
|
||||
@@ -13,6 +13,7 @@ pub static PYPI: Registry = Registry {
|
||||
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
|
||||
Pattern { re: "", rewrite: Some(FILES) },
|
||||
],
|
||||
published: None,
|
||||
};
|
||||
|
||||
/// The directories are hash shards, but the filename names the distribution.
|
||||
@@ -35,6 +36,11 @@ pub static PYPI_FILES: Registry = Registry {
|
||||
rewrite: None,
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://pypi.org/pypi/{name}/{version}/json",
|
||||
pointer: "/urls/0/upload_time_iso_8601",
|
||||
escape: None,
|
||||
}),
|
||||
};
|
||||
|
||||
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::http::{blocked, reply, text};
|
||||
use crate::policies::{self, Check, Decision};
|
||||
use crate::{Firewall, FirewallConfig};
|
||||
use crate::{telemetry, Firewall, FirewallConfig};
|
||||
use rama::http::body::util::BodyExt;
|
||||
use rama::http::client::EasyHttpWebClient;
|
||||
use rama::http::header::CONTENT_TYPE;
|
||||
@@ -19,6 +19,7 @@ pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
|
||||
bound.set_nonblocking(true)?;
|
||||
crate::runtime()?.block_on(async move {
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
telemetry::heartbeat();
|
||||
let exec = Executor::default();
|
||||
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
|
||||
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
|
||||
@@ -44,6 +45,7 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
}
|
||||
Decision::Block { eco, pkg, filter, reason } => {
|
||||
let reason = format!("{} {reason}", pkg.label());
|
||||
telemetry::screened_package(eco, &pkg, Some(&reason));
|
||||
warn!(%eco, %filter, "blocked by firewall: {reason}");
|
||||
blocked(&reason)
|
||||
}
|
||||
@@ -51,9 +53,11 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
|
||||
match &check {
|
||||
Check::NoPackage => {}
|
||||
Check::Allowed(pkg) => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
|
||||
}
|
||||
Check::Unverified { pkg, filter, error } => {
|
||||
telemetry::screened_package(target.eco, pkg, None);
|
||||
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
|
||||
}
|
||||
}
|
||||
|
||||
+20
-7
@@ -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();
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
use firewall::policies::screen;
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
use rama::rt::blocking::Runtime;
|
||||
|
||||
const OLD_DOWNLOADS: &[&str] = &[
|
||||
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
|
||||
"https://files.pythonhosted.org/packages/aa/bb/requests-2.31.0-py3-none-any.whl",
|
||||
"https://static.crates.io/crates/serde/1.0.200",
|
||||
"https://rubygems.org/gems/rake-13.0.6.gem",
|
||||
"https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/v1.3.2.zip",
|
||||
];
|
||||
|
||||
#[test]
|
||||
#[ignore = "queries every registry"]
|
||||
fn every_registry_reports_when_a_version_was_published() {
|
||||
let runtime = Runtime::try_new().expect("runtime");
|
||||
runtime.block_on(async {
|
||||
let firewall =
|
||||
|hours| Firewall::new(FirewallConfig { min_age_hours: hours, ..Default::default() });
|
||||
let (lenient, strict) = (firewall(24), firewall(24 * 365 * 100));
|
||||
for url in OLD_DOWNLOADS {
|
||||
assert_eq!(screen(url, &lenient).await, None, "{url}");
|
||||
let blocked = screen(url, &strict).await.unwrap_or_default();
|
||||
assert!(blocked.contains("hour minimum"), "{url}: {blocked:?}");
|
||||
}
|
||||
});
|
||||
}
|
||||
+55
-2
@@ -1,12 +1,13 @@
|
||||
use firewall::config::path;
|
||||
use firewall::FirewallConfig;
|
||||
use firewall::policies::filters::consult;
|
||||
use firewall::policies::{screen, Package};
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
|
||||
#[test]
|
||||
fn a_file_only_has_to_set_what_it_changes() {
|
||||
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
|
||||
assert!(!config.fail_open);
|
||||
assert_eq!(config.port, firewall::DEFAULT_PORT);
|
||||
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -19,3 +20,55 @@ fn the_config_lives_under_the_config_directory() {
|
||||
let path = path().expect("a home directory");
|
||||
assert!(path.ends_with("config.json"), "{}", path.display());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).expect("temp dir");
|
||||
let user = dir.join("user.json");
|
||||
let system = dir.join("system.json");
|
||||
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
|
||||
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
|
||||
let missing = dir.join("missing.json");
|
||||
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
assert_eq!(config.min_age_hours, 24);
|
||||
assert!(!config.fail_open);
|
||||
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
|
||||
assert_eq!(names, ["a", "b"]);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// An unreachable denylist would otherwise leave the package unchecked.
|
||||
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
|
||||
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn failing_closed_blocks_what_could_not_be_checked() {
|
||||
let config = r#"{"fail_open": false, "denylist": {"base": "http://127.0.0.1:1"}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let url = "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz";
|
||||
let blocked = screen(url, &firewall).await.unwrap_or_default();
|
||||
assert!(blocked.contains("could not be checked"), "{blocked:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn allowing_keeps_the_rest_of_the_file() {
|
||||
let path = std::env::temp_dir().join(format!("firewall-allow-{}.json", std::process::id()));
|
||||
std::fs::write(&path, r#"{"fail_open": false}"#).expect("write");
|
||||
for _ in 0..2 {
|
||||
firewall::config::allow(&path, "npm", "left-pad", Some("1.3.0")).expect("allow");
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).expect("read");
|
||||
std::fs::remove_file(&path).ok();
|
||||
let config: FirewallConfig = serde_json::from_str(&text).expect("parses");
|
||||
assert!(!config.fail_open);
|
||||
assert_eq!(config.allow["npm"].len(), 1);
|
||||
assert_eq!(config.allow["npm"][0].version.as_deref(), Some("1.3.0"));
|
||||
}
|
||||
+22
-1
@@ -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();
|
||||
|
||||
@@ -43,3 +43,10 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
|
||||
|
||||
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
|
||||
}
|
||||
|
||||
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
|
||||
#[test]
|
||||
fn ca_signs_an_empty_crl() {
|
||||
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
|
||||
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
+4
-1
@@ -4,7 +4,10 @@ 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"]
|
||||
features = ["machine-wide"]
|
||||
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"]
|
||||
+2
-2
@@ -3,5 +3,5 @@ Firewall.xcodeproj
|
||||
.env
|
||||
Extension/Extension.entitlements
|
||||
Extension/Info.plist
|
||||
Installer/Installer.entitlements
|
||||
Installer/Info.plist
|
||||
App/App.entitlements
|
||||
App/Info.plist
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
@@ -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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import AppKit
|
||||
import Security
|
||||
|
||||
struct BlockedPackage: Codable, Hashable, Identifiable {
|
||||
let eco: String
|
||||
let name: String
|
||||
let version: String?
|
||||
let filter: String
|
||||
let reason: String
|
||||
|
||||
var id: String { "\(eco)/\(name)@\(version ?? "")" }
|
||||
var label: String { version.map { "\(name) \($0)" } ?? name }
|
||||
/// The denylist and OSV only flag packages known to be malicious; the age check only holds back new ones.
|
||||
var malicious: Bool { filter != "age" }
|
||||
/// Malware is never allowed, and a new package only by version.
|
||||
var trustable: Bool { !malicious && version != nil }
|
||||
}
|
||||
|
||||
@MainActor
|
||||
enum BlockAlert {
|
||||
/// Returns whether the user chose to trust the package.
|
||||
static func show(_ package: BlockedPackage) -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.addButton(withTitle: "OK")
|
||||
if package.malicious {
|
||||
alert.alertStyle = .critical
|
||||
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
|
||||
alert.informativeText = "\(package.label) \(package.reason).\n\n"
|
||||
+ "It was not installed. Don't install it from another source. "
|
||||
+ "If you didn't expect this package, a dependency may be compromised; tell your security team."
|
||||
} else {
|
||||
alert.messageText = "Held back a new package"
|
||||
alert.informativeText = "\(package.label) (\(package.eco)) \(package.reason)."
|
||||
if package.trustable { alert.addButton(withTitle: "Trust this version") }
|
||||
}
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
return alert.runModal() == .alertSecondButtonReturn
|
||||
}
|
||||
}
|
||||
|
||||
enum AdminAuthorization {
|
||||
/// The allowlist applies to every user of the Mac, so changing it takes an admin, as `sudo` would.
|
||||
static func confirm() -> Bool {
|
||||
var authorization: AuthorizationRef?
|
||||
guard AuthorizationCreate(nil, nil, [], &authorization) == errAuthorizationSuccess,
|
||||
let authorization else { return false }
|
||||
defer { AuthorizationFree(authorization, []) }
|
||||
return "system.preferences".withCString { name in
|
||||
var item = AuthorizationItem(name: name, valueLength: 0, value: nil, flags: 0)
|
||||
return withUnsafeMutablePointer(to: &item) { item in
|
||||
var rights = AuthorizationRights(count: 1, items: item)
|
||||
let flags: AuthorizationFlags = [.interactionAllowed, .extendRights]
|
||||
return AuthorizationCopyRights(authorization, &rights, nil, flags, nil) == errAuthorizationSuccess
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,7 +1,7 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ContentView: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.dismiss) private var dismiss
|
||||
|
||||
var body: some View {
|
||||
@@ -10,11 +10,9 @@ struct ContentView: View {
|
||||
Divider()
|
||||
steps
|
||||
Divider()
|
||||
transcript
|
||||
Divider()
|
||||
footer
|
||||
}
|
||||
.frame(width: 540, height: 560)
|
||||
.frame(width: 540)
|
||||
.task { await model.refresh() }
|
||||
.onAppear(perform: recenterIfOffscreen)
|
||||
.onChange(of: model.completed) { done in
|
||||
@@ -38,9 +36,11 @@ struct ContentView: View {
|
||||
|
||||
private var header: some View {
|
||||
HStack(spacing: 14) {
|
||||
Image(systemName: "shield.lefthalf.filled")
|
||||
.font(.system(size: 34, weight: .light))
|
||||
.foregroundStyle(.tint)
|
||||
Image(nsImage: NSImage(named: "logo-transparent")!)
|
||||
.renderingMode(.template)
|
||||
.resizable()
|
||||
.scaledToFit()
|
||||
.frame(height: 40)
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
Text("Firewall").font(.title2.weight(.semibold))
|
||||
Text("Screens package downloads before they reach your machine.")
|
||||
@@ -70,27 +70,6 @@ struct ContentView: View {
|
||||
.padding(20)
|
||||
}
|
||||
|
||||
private var transcript: some View {
|
||||
ScrollViewReader { proxy in
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
ForEach(model.transcript.indices, id: \.self) { line in
|
||||
Text(model.transcript[line])
|
||||
.font(.system(.caption, design: .monospaced))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.id(line)
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
}
|
||||
.onChange(of: model.transcript.count) { count in
|
||||
proxy.scrollTo(count - 1)
|
||||
}
|
||||
}
|
||||
.frame(maxHeight: .infinity)
|
||||
}
|
||||
|
||||
private var footer: some View {
|
||||
HStack {
|
||||
if model.busy { ProgressView().controlSize(.small) }
|
||||
@@ -105,8 +84,11 @@ struct ContentView: View {
|
||||
.font(.callout)
|
||||
}
|
||||
Spacer()
|
||||
if model.awaitingApproval {
|
||||
Button("Open System Settings") { model.openApprovalSettings() }
|
||||
}
|
||||
Button("Uninstall") { Task { await model.uninstall() } }
|
||||
.disabled(model.busy)
|
||||
.disabled(model.busy && !model.awaitingApproval)
|
||||
Button("Install") { Task { await model.install() } }
|
||||
.keyboardShortcut(.defaultAction)
|
||||
.disabled(model.busy || model.completed)
|
||||
@@ -129,9 +111,10 @@ private struct StepRow: View {
|
||||
Text(title).font(.body.weight(.medium))
|
||||
Text(note).font(.caption).foregroundStyle(.secondary)
|
||||
Text(status.detail)
|
||||
.font(.caption)
|
||||
.font(font)
|
||||
.foregroundStyle(tint)
|
||||
.textSelection(.enabled)
|
||||
.fixedSize(horizontal: false, vertical: true)
|
||||
}
|
||||
Spacer()
|
||||
}
|
||||
@@ -146,7 +129,7 @@ private struct StepRow: View {
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 20, height: 20)
|
||||
.overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
|
||||
case .running:
|
||||
case .running, .needsAction:
|
||||
ProgressView().controlSize(.small)
|
||||
case .done:
|
||||
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
|
||||
@@ -155,9 +138,15 @@ private struct StepRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
private var font: Font {
|
||||
if case .needsAction = status { return .system(size: 14) }
|
||||
return .caption
|
||||
}
|
||||
|
||||
private var tint: Color {
|
||||
switch status {
|
||||
case .pending, .running: return .secondary
|
||||
case .needsAction: return .orange
|
||||
case .done: return .green
|
||||
case .failed: return .orange
|
||||
}
|
||||
@@ -1,8 +1,8 @@
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct InstallerApp: App {
|
||||
@StateObject private var model = InstallerModel()
|
||||
struct FirewallApp: App {
|
||||
@StateObject private var model = FirewallModel()
|
||||
|
||||
var body: some Scene {
|
||||
MenuBarExtra {
|
||||
@@ -31,7 +31,7 @@ enum SetupWindow {
|
||||
|
||||
/// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
|
||||
private struct MenuBarLabel: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
@State private var raised = false
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "com.usestrix.firewall"
|
||||
static let extensionBundleID = "com.usestrix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
@@ -7,13 +7,15 @@ import SystemExtensions
|
||||
enum StepStatus: Equatable {
|
||||
case pending
|
||||
case running(String)
|
||||
/// Waiting on the user to do something outside the app.
|
||||
case needsAction(String)
|
||||
case done(String)
|
||||
case failed(String)
|
||||
|
||||
var detail: String {
|
||||
switch self {
|
||||
case .pending: return "Not started"
|
||||
case let .running(text), let .done(text), let .failed(text): return text
|
||||
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,33 +25,38 @@ enum StepStatus: Equatable {
|
||||
}
|
||||
}
|
||||
|
||||
enum InstallerError: Error, LocalizedError {
|
||||
enum FirewallError: Error, LocalizedError {
|
||||
case extensionUnavailable
|
||||
case extensionRefused(String)
|
||||
case screeningDidNotStart(NEVPNStatus)
|
||||
case extensionStopped(String)
|
||||
case extensionRefused(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .extensionUnavailable: return "The extension is not running"
|
||||
case let .extensionRefused(reply): return "The extension replied \(reply)"
|
||||
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
|
||||
case let .extensionRefused(reply): return "The extension replied \(reply)"
|
||||
case let .extensionStopped(reason):
|
||||
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class InstallerModel: NSObject, ObservableObject {
|
||||
final class FirewallModel: NSObject, ObservableObject {
|
||||
@Published private(set) var extensionStep = StepStatus.pending
|
||||
@Published private(set) var screeningStep = StepStatus.pending
|
||||
@Published private(set) var trustStep = StepStatus.pending
|
||||
@Published private(set) var busy = false
|
||||
@Published private var operations = 0
|
||||
var busy: Bool { operations > 0 }
|
||||
@Published private(set) var awaitingApproval = false
|
||||
@Published private(set) var completed = false
|
||||
@Published private(set) var removed = false
|
||||
@Published private(set) var transcript: [String] = []
|
||||
@Published private(set) var screening = false
|
||||
@Published private(set) var blocked: [BlockedPackage] = []
|
||||
|
||||
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?
|
||||
@@ -68,6 +75,7 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
await refresh()
|
||||
if CommandLine.arguments.contains("--install") { await install() }
|
||||
}
|
||||
Task { await watchBlocks() }
|
||||
}
|
||||
|
||||
// MARK: - Flows
|
||||
@@ -87,8 +95,8 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
func install() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
removed = false
|
||||
|
||||
guard CompetingExtensions.confirmInstall() else {
|
||||
@@ -110,23 +118,43 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
completed = true
|
||||
log("Setup complete")
|
||||
offerRestart()
|
||||
}
|
||||
|
||||
/// Apps opened before setup, terminals included, only get the environment the env agent sets once reopened.
|
||||
private func offerRestart() {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Restart to finish setting up"
|
||||
alert.informativeText = "Apps that were already open, such as terminals, can't install packages until they're reopened. Restarting reopens them all."
|
||||
alert.addButton(withTitle: "Restart")
|
||||
alert.addButton(withTitle: "Later")
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
guard alert.runModal() == .alertFirstButtonReturn else { return }
|
||||
let osascript = Process()
|
||||
osascript.executableURL = URL(fileURLWithPath: "/usr/bin/osascript")
|
||||
osascript.arguments = ["-e", "tell application \"loginwindow\" to «event aevtrest»"]
|
||||
try? osascript.run()
|
||||
}
|
||||
|
||||
/// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
|
||||
func uninstall() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
completed = false
|
||||
removed = false
|
||||
// Stop waiting on an activation the user never approved; the deactivation below withdraws it.
|
||||
if let pending = pendingRequest {
|
||||
finishRequest(pending.request, .failure(CocoaError(.userCancelled)))
|
||||
}
|
||||
|
||||
// Install needs admin, so uninstall does too: untrusting the CA prompts for it, and a cancel stops here.
|
||||
guard await untrustAuthorities() else {
|
||||
log("Uninstall cancelled")
|
||||
return
|
||||
}
|
||||
// The extension owns the private key, so ask it before tearing down the session that carries the request.
|
||||
let strandedKey = await removeStoredCA()
|
||||
// Stop screening first so the extension cannot mint a fresh CA after it is removed.
|
||||
await removeConfiguration()
|
||||
let strandedKey = removeStoredCA()
|
||||
await deactivate()
|
||||
if let strandedKey { trustStep = .failed(strandedKey) }
|
||||
|
||||
@@ -150,16 +178,16 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
|
||||
func restart() async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return }
|
||||
// A restart can mint a new CA when the screened hosts change.
|
||||
_ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority)
|
||||
}
|
||||
|
||||
func setEnabled(_ enabled: Bool) async {
|
||||
busy = true
|
||||
defer { busy = false }
|
||||
operations += 1
|
||||
defer { operations -= 1 }
|
||||
if enabled {
|
||||
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
|
||||
} else {
|
||||
@@ -167,7 +195,52 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
|
||||
func openApprovalSettings() {
|
||||
var pane = "x-apple.systempreferences:com.apple.preference.security"
|
||||
if #available(macOS 15, *) {
|
||||
pane = "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension"
|
||||
}
|
||||
if let url = URL(string: pane) { NSWorkspace.shared.open(url) }
|
||||
}
|
||||
|
||||
/// Allowing a version writes it into the extension's config, which a restart then loads.
|
||||
func trust(_ package: BlockedPackage) async {
|
||||
guard package.trustable else { return }
|
||||
guard AdminAuthorization.confirm() else {
|
||||
log("Trust cancelled for \(package.label)")
|
||||
return
|
||||
}
|
||||
do {
|
||||
var request = ["eco": package.eco, "name": package.name]
|
||||
request["version"] = package.version
|
||||
let json = String(decoding: try JSONSerialization.data(withJSONObject: request), as: UTF8.self)
|
||||
let reply = try await providerMessage("allow \(json)")
|
||||
guard reply == "ok" else { throw FirewallError.extensionRefused(reply) }
|
||||
blocked.removeAll { $0 == package }
|
||||
log("Trusted \(package.label) (\(package.eco))")
|
||||
} catch {
|
||||
log("Could not trust \(package.label): \(error.localizedDescription)")
|
||||
return
|
||||
}
|
||||
await restart()
|
||||
}
|
||||
|
||||
/// The extension cannot message the app, so the app asks it for blocks.
|
||||
private func watchBlocks() async {
|
||||
while true {
|
||||
if screening, let reply = try? await providerMessage("blocks"),
|
||||
let packages = try? JSONDecoder().decode([BlockedPackage].self, from: Data(reply.utf8)) {
|
||||
for package in packages where !blocked.contains(package) {
|
||||
blocked.append(package)
|
||||
if BlockAlert.show(package) { await trust(package) }
|
||||
}
|
||||
blocked = Array(blocked.suffix(10))
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 2_000_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
self[keyPath: step] = .running(running)
|
||||
@@ -211,6 +284,9 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main))
|
||||
extensionStep = .pending
|
||||
log("System extension deactivated, macOS finishes removing it on the next reboot")
|
||||
} catch OSSystemExtensionError.extensionNotFound {
|
||||
extensionStep = .pending
|
||||
log("No system extension to deactivate")
|
||||
} catch {
|
||||
extensionStep = .failed(error.localizedDescription)
|
||||
log("Deactivation failed: \(error.localizedDescription)")
|
||||
@@ -245,17 +321,18 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// Only root can delete the key, so the extension does it; returns what to report if it survived.
|
||||
private func removeStoredCA() async -> String? {
|
||||
do {
|
||||
let reply = try await providerMessage("uninstall")
|
||||
guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
|
||||
log("Removed the CA, its exported copy and the env agent")
|
||||
return nil
|
||||
} catch {
|
||||
log("Could not remove the CA: \(error.localizedDescription)")
|
||||
/// The extension wrote these as root, so the bundled firewall-uninstall removes them behind an admin prompt; returns what to report if it failed.
|
||||
private func removeStoredCA() -> String? {
|
||||
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 {
|
||||
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)")
|
||||
return "The CA private key is still in the System keychain"
|
||||
}
|
||||
log("Removed the CA, its exported copy and the env agent")
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - System extension
|
||||
@@ -263,14 +340,18 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
private func submit(_ request: OSSystemExtensionRequest) async throws {
|
||||
request.delegate = self
|
||||
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
|
||||
pendingRequest = continuation
|
||||
pendingRequest = (request, continuation)
|
||||
OSSystemExtensionManager.shared.submitRequest(request)
|
||||
}
|
||||
}
|
||||
|
||||
private func finishRequest(_ result: Result<Void, Error>) {
|
||||
pendingRequest?.resume(with: result)
|
||||
/// Callbacks for an abandoned request can still arrive, so only the pending one is finished.
|
||||
private func finishRequest(_ request: OSSystemExtensionRequest, _ result: Result<Void, Error>) {
|
||||
guard let pending = pendingRequest, pending.request === request else { return }
|
||||
pendingRequest = nil
|
||||
awaitingApproval = false
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
pending.continuation.resume(with: result)
|
||||
}
|
||||
|
||||
// MARK: - Network extension
|
||||
@@ -295,16 +376,22 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
try await manager.loadFromPreferences()
|
||||
watched = manager
|
||||
|
||||
screeningStep = .running("Starting the extension")
|
||||
try manager.connection.startVPNTunnel()
|
||||
guard try await wait(for: .connected, on: manager.connection) else {
|
||||
throw InstallerError.screeningDidNotStart(manager.connection.status)
|
||||
do {
|
||||
try await manager.connection.fetchLastDisconnectError()
|
||||
} catch {
|
||||
throw FirewallError.extensionStopped(error.localizedDescription)
|
||||
}
|
||||
throw FirewallError.screeningDidNotStart(manager.connection.status)
|
||||
}
|
||||
return "Screening package downloads"
|
||||
}
|
||||
|
||||
private func restartScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else {
|
||||
throw InstallerError.extensionUnavailable
|
||||
throw FirewallError.extensionUnavailable
|
||||
}
|
||||
manager.connection.stopVPNTunnel()
|
||||
_ = try await wait(for: .disconnected, on: manager.connection)
|
||||
@@ -312,7 +399,7 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func stopScreening() async throws -> String {
|
||||
guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
|
||||
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable }
|
||||
manager.isEnabled = false
|
||||
try await manager.saveToPreferences()
|
||||
manager.connection.stopVPNTunnel()
|
||||
@@ -320,9 +407,13 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
return "Screening disabled"
|
||||
}
|
||||
|
||||
/// Gives up early once a start has failed, which shows as connecting then disconnected.
|
||||
private func wait(for status: NEVPNStatus, on connection: NEVPNConnection) async throws -> Bool {
|
||||
var connecting = false
|
||||
for _ in 0 ..< 60 {
|
||||
if connection.status == status { return true }
|
||||
if connection.status == .connecting { connecting = true }
|
||||
if connecting && connection.status == .disconnected { return false }
|
||||
try await Task.sleep(nanoseconds: 250_000_000)
|
||||
}
|
||||
return false
|
||||
@@ -358,13 +449,13 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
|
||||
private func providerMessage(_ message: String) async throws -> String {
|
||||
guard let session = watched?.connection as? NETunnelProviderSession else {
|
||||
throw InstallerError.extensionUnavailable
|
||||
throw FirewallError.extensionUnavailable
|
||||
}
|
||||
return try await withCheckedThrowingContinuation { continuation in
|
||||
do {
|
||||
try session.sendProviderMessage(Data(message.utf8)) { response in
|
||||
guard let response, let reply = String(data: response, encoding: .utf8) else {
|
||||
continuation.resume(throwing: InstallerError.extensionUnavailable)
|
||||
continuation.resume(throwing: FirewallError.extensionUnavailable)
|
||||
return
|
||||
}
|
||||
continuation.resume(returning: reply)
|
||||
@@ -376,12 +467,11 @@ final class InstallerModel: NSObject, ObservableObject {
|
||||
}
|
||||
|
||||
private func log(_ line: String) {
|
||||
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
|
||||
logger.info("\(line, privacy: .public)")
|
||||
}
|
||||
}
|
||||
|
||||
extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
extension FirewallModel: OSSystemExtensionRequestDelegate {
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest,
|
||||
actionForReplacingExtension existing: OSSystemExtensionProperties,
|
||||
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
|
||||
@@ -390,12 +480,14 @@ extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
|
||||
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
|
||||
Task { @MainActor in
|
||||
extensionStep = .running("Approve Firewall in System Settings › General › Login Items & Extensions")
|
||||
log("Waiting for user approval")
|
||||
// Only MDM can pre-approve a system extension, so take them to the switch.
|
||||
if let pane = URL(string: "x-apple.systempreferences:com.apple.ExtensionsPreferences?extensionPointIdentifier=com.apple.system_extension.network_extension") {
|
||||
NSWorkspace.shared.open(pane)
|
||||
// macOS shows its own alert that opens System Settings; this says what to do there.
|
||||
var instruction = "In System Settings › Privacy & Security, click Allow for StrixFirewall"
|
||||
if #available(macOS 15, *) {
|
||||
instruction = "In System Settings › General › Login Items & Extensions, click Network Extensions and turn on StrixFirewall"
|
||||
}
|
||||
extensionStep = .needsAction(instruction)
|
||||
awaitingApproval = true
|
||||
log("Waiting for user approval")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -405,12 +497,12 @@ extension InstallerModel: OSSystemExtensionRequestDelegate {
|
||||
if result == .willCompleteAfterReboot {
|
||||
log("Extension will finish installing after a reboot")
|
||||
}
|
||||
finishRequest(.success(()))
|
||||
finishRequest(request, .success(()))
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
|
||||
Task { @MainActor in finishRequest(.failure(error)) }
|
||||
Task { @MainActor in finishRequest(request, .failure(error)) }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@ObservedObject var model: FirewallModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Text(model.screening ? "Screening downloads" : "Not running")
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Setup") {
|
||||
SetupWindow.open(openWindow)
|
||||
}
|
||||
|
||||
Button(model.screening ? "Pause" : "Resume") {
|
||||
Task { await model.setEnabled(!model.screening) }
|
||||
}
|
||||
.disabled(model.busy || !model.configured)
|
||||
|
||||
Button("Restart") {
|
||||
Task { await model.restart() }
|
||||
}
|
||||
.disabled(model.busy)
|
||||
|
||||
if !model.blocked.isEmpty {
|
||||
Divider()
|
||||
ForEach(model.blocked) { package in
|
||||
if !package.malicious {
|
||||
Menu("Held back \(package.label) (\(package.eco))") {
|
||||
Text(package.reason)
|
||||
Button("Trust this version") {
|
||||
Task { await model.trust(package) }
|
||||
}
|
||||
.disabled(model.busy || !package.trustable)
|
||||
}
|
||||
} else {
|
||||
Menu("Blocked \(package.label) (\(package.eco))") {
|
||||
Text("Malicious: \(package.reason)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -1,7 +0,0 @@
|
||||
import Foundation
|
||||
|
||||
enum FirewallIDs {
|
||||
static let appBundleID = "ai.strix.firewall"
|
||||
static let extensionBundleID = "ai.strix.firewall.extension"
|
||||
static let caCommonName = "Strix Firewall"
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
import SwiftUI
|
||||
|
||||
struct MenuBarView: View {
|
||||
@ObservedObject var model: InstallerModel
|
||||
@Environment(\.openWindow) private var openWindow
|
||||
|
||||
var body: some View {
|
||||
Text(model.screening ? "Screening downloads" : "Not running")
|
||||
|
||||
Divider()
|
||||
|
||||
Button("Installer") {
|
||||
SetupWindow.open(openWindow)
|
||||
}
|
||||
|
||||
Button(model.screening ? "Pause" : "Resume") {
|
||||
Task { await model.setEnabled(!model.screening) }
|
||||
}
|
||||
.disabled(model.busy || !model.configured)
|
||||
|
||||
Button("Restart") {
|
||||
Task { await model.restart() }
|
||||
}
|
||||
.disabled(model.busy)
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -3,7 +3,7 @@
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>Label</key>
|
||||
<string>ai.strix.firewall.env</string>
|
||||
<string>com.usestrix.firewall.env</string>
|
||||
<key>ProgramArguments</key>
|
||||
<array>
|
||||
<string>/bin/launchctl</string>
|
||||
+46
-25
@@ -1,8 +1,10 @@
|
||||
#!/bin/bash
|
||||
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
|
||||
# Builds and signs StrixFirewall.app and packages it as build/Firewall.pkg.
|
||||
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
|
||||
# makes a Debug build and skips notarization.
|
||||
#
|
||||
# Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
|
||||
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
|
||||
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing
|
||||
# because macOS refuses to activate a Developer ID extension that is not notarized.
|
||||
set -euo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
@@ -10,27 +12,42 @@ cd "$(dirname "$0")"
|
||||
: "${TEAM:?set TEAM in macos/.env}"
|
||||
export TEAM
|
||||
|
||||
APP_ID=ai.strix.firewall
|
||||
EXT_ID=ai.strix.firewall.extension
|
||||
# Identities are matched as a substring of the certificate name; set them if more than one matches.
|
||||
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension.
|
||||
if [ "${1:-}" = debug ]; then
|
||||
CONFIGURATION=Debug
|
||||
IDENTITY=${DEBUG_IDENTITY:-Apple Development}
|
||||
NE_PROVIDER=app-proxy-provider
|
||||
else
|
||||
CONFIGURATION=Release
|
||||
IDENTITY=${IDENTITY:-Developer ID Application}
|
||||
NE_PROVIDER=app-proxy-provider-systemextension
|
||||
: "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
|
||||
fi
|
||||
export NE_PROVIDER
|
||||
|
||||
APP_ID=com.usestrix.firewall
|
||||
EXT_ID=com.usestrix.firewall.extension
|
||||
VERSION=0.1
|
||||
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
|
||||
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
|
||||
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
|
||||
APP=build/Build/Products/Debug/Installer.app
|
||||
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app
|
||||
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
|
||||
PKG_ROOT=build/pkg-root
|
||||
|
||||
main() {
|
||||
build
|
||||
sign
|
||||
install_app
|
||||
package
|
||||
[ $CONFIGURATION = Debug ] || notarize
|
||||
}
|
||||
|
||||
build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
-destination generic/platform=macOS \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
@@ -39,40 +56,44 @@ sign() {
|
||||
echo "==> Signing with $IDENTITY"
|
||||
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
|
||||
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall-uninstall"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
|
||||
codesign --force --options runtime --timestamp=none \
|
||||
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements App/App.entitlements -s "$IDENTITY" "$APP"
|
||||
codesign --verify --deep --strict "$APP"
|
||||
}
|
||||
|
||||
# The extension can only be activated from /Applications.
|
||||
install_app() {
|
||||
echo "==> Installing /Applications/Firewall.app"
|
||||
rm -rf /Applications/Firewall.app
|
||||
cp -R "$APP" /Applications/Firewall.app
|
||||
}
|
||||
|
||||
# Marked non-relocatable so the installer never updates another copy with the same bundle id.
|
||||
package() {
|
||||
echo "==> Packaging build/Firewall.pkg"
|
||||
rm -rf "$PKG_ROOT"
|
||||
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
|
||||
cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app"
|
||||
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
|
||||
|
||||
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
|
||||
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
|
||||
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
|
||||
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
|
||||
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
|
||||
build/Firewall.pkg >/dev/null
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
|
||||
notarize() {
|
||||
echo "==> Notarizing build/Firewall.pkg"
|
||||
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
|
||||
xcrun stapler staple build/Firewall.pkg
|
||||
}
|
||||
|
||||
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
|
||||
profile_for() {
|
||||
local want=$TEAM.$1 newest= newest_time=0 id t
|
||||
local want=$TEAM.$1 newest= newest_time=0 profile id t
|
||||
for p in "$PROFILES"/*.provisionprofile; do
|
||||
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
|
||||
profile=$(security cms -D -i "$p")
|
||||
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
|
||||
[ "$id" = "$want" ] || continue
|
||||
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
|
||||
grep -q "\"$NE_PROVIDER\"" || continue
|
||||
t=$(stat -f %m "$p")
|
||||
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
|
||||
done
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
user=$(stat -f %Su /dev/console)
|
||||
[ "$user" = root ] && exit 0
|
||||
uid=$(id -u "$user")
|
||||
agent=/Library/LaunchAgents/ai.strix.firewall.env.plist
|
||||
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
|
||||
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
|
||||
launchctl bootstrap "gui/$uid" "$agent"
|
||||
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
|
||||
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install
|
||||
exit 0
|
||||
+35
-15
@@ -1,6 +1,6 @@
|
||||
name: Firewall
|
||||
options:
|
||||
bundleIdPrefix: ai.strix.firewall
|
||||
bundleIdPrefix: com.usestrix.firewall
|
||||
deploymentTarget: { macOS: "13.0" }
|
||||
createIntermediateGroups: true
|
||||
|
||||
@@ -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}
|
||||
@@ -33,8 +33,8 @@ targets:
|
||||
sources: [Extension]
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
|
||||
PRODUCT_NAME: ai.strix.firewall.extension
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
|
||||
PRODUCT_NAME: com.usestrix.firewall.extension
|
||||
PRODUCT_MODULE_NAME: Extension
|
||||
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
|
||||
info:
|
||||
@@ -44,7 +44,7 @@ targets:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
NetworkExtension:
|
||||
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
|
||||
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
|
||||
NEProviderClasses:
|
||||
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
|
||||
entitlements:
|
||||
@@ -52,13 +52,13 @@ targets:
|
||||
properties:
|
||||
# Xcode injects these from the profile; we sign by hand, and NE's IPC
|
||||
# check rejects a caller without them.
|
||||
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.networking.networkextension:
|
||||
- app-proxy-provider
|
||||
- ${NE_PROVIDER}
|
||||
# NE requires NEMachServiceName to start with one of these.
|
||||
com.apple.security.application-groups:
|
||||
- ${TEAM}.ai.strix.firewall
|
||||
- ${TEAM}.com.usestrix.firewall
|
||||
com.apple.security.network.client: true
|
||||
com.apple.security.network.server: true
|
||||
dependencies:
|
||||
@@ -87,33 +87,53 @@ targets:
|
||||
$([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
done
|
||||
|
||||
Installer:
|
||||
StrixFirewall:
|
||||
type: application
|
||||
platform: macOS
|
||||
sources:
|
||||
- Installer
|
||||
- App
|
||||
- path: ../assets/logo-transparent.png
|
||||
buildPhase: resources
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||
info:
|
||||
path: Installer/Info.plist
|
||||
path: App/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
LSUIElement: true
|
||||
entitlements:
|
||||
path: Installer/Installer.entitlements
|
||||
path: App/App.entitlements
|
||||
properties:
|
||||
com.apple.application-identifier: ${TEAM}.ai.strix.firewall
|
||||
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
|
||||
com.apple.developer.team-identifier: ${TEAM}
|
||||
com.apple.developer.system-extension.install: true
|
||||
com.apple.developer.networking.networkextension:
|
||||
- app-proxy-provider
|
||||
- ${NE_PROVIDER}
|
||||
com.apple.security.network.client: true
|
||||
dependencies:
|
||||
- target: Extension
|
||||
embed: true
|
||||
codeSign: true
|
||||
# Uninstall runs this as root to remove what the extension left.
|
||||
postBuildScripts:
|
||||
- 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"
|
||||
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 --features machine-wide --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"
|
||||
Reference in new issue
Block a user