Compare commits

..
Author SHA1 Message Date
Ian ad26ebc93b logo 2026-10-13 00:14:04 -04:00
Ian 4d29b065b4 Show the cmd doskey alias beside the PowerShell one 2026-10-12 17:54:46 -04:00
Ian Graham MartinezandClaude Opus 5.5 d679fa0fed Show Windows users PowerShell syntax and the name they type
On Windows the help said `firewall.exe`, suggested a bash alias, and
`serve` printed bash-style variable assignments. Drop the extension,
suggest a PowerShell function (Set-Alias cannot carry arguments), and
print `$env:` assignments. Other platforms are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 17:51:34 -04:00
78 changed files with 308 additions and 3451 deletions

No files matched your search

+4 -473
View File
@@ -172,17 +172,6 @@ dependencies = [
"pin-project-lite", "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]] [[package]]
name = "asynk-strim" name = "asynk-strim"
version = "0.1.5" version = "0.1.5"
@@ -261,15 +250,6 @@ dependencies = [
"hybrid-array", "hybrid-array",
] ]
[[package]]
name = "block2"
version = "0.6.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
dependencies = [
"objc2",
]
[[package]] [[package]]
name = "borsh" name = "borsh"
version = "1.8.1" version = "1.8.1"
@@ -476,16 +456,6 @@ dependencies = [
"unicode-xid", "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]] [[package]]
name = "core-foundation" name = "core-foundation"
version = "0.10.1" version = "0.10.1"
@@ -526,12 +496,6 @@ dependencies = [
"cfg-if", "cfg-if",
] ]
[[package]]
name = "critical-section"
version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b"
[[package]] [[package]]
name = "crossbeam-channel" name = "crossbeam-channel"
version = "0.5.17" version = "0.5.17"
@@ -678,16 +642,6 @@ dependencies = [
"windows-sys 0.61.2", "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]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.7" version = "0.2.7"
@@ -699,12 +653,6 @@ dependencies = [
"syn 3.0.6", "syn 3.0.6",
] ]
[[package]]
name = "dpi"
version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
[[package]] [[package]]
name = "either" name = "either"
version = "1.18.0" version = "1.18.0"
@@ -776,15 +724,6 @@ dependencies = [
"getrandom 0.4.3", "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]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.13" version = "0.1.13"
@@ -805,7 +744,7 @@ checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
dependencies = [ dependencies = [
"crc32fast", "crc32fast",
"libz-sys", "libz-sys",
"miniz_oxide 0.9.1", "miniz_oxide",
"zlib-rs", "zlib-rs",
] ]
@@ -1058,78 +997,6 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" 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]] [[package]]
name = "http" name = "http"
version = "1.5.0" version = "1.5.0"
@@ -1291,19 +1158,6 @@ dependencies = [
"hashbrown 0.17.1", "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]] [[package]]
name = "ipnet" name = "ipnet"
version = "2.12.2" version = "2.12.2"
@@ -1486,16 +1340,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "konst" name = "konst"
version = "0.2.20" version = "0.2.20"
@@ -1639,16 +1483,6 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" 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]] [[package]]
name = "miniz_oxide" name = "miniz_oxide"
version = "0.9.1" version = "0.9.1"
@@ -1690,24 +1524,6 @@ dependencies = [
"uuid", "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]] [[package]]
name = "multer" name = "multer"
version = "3.1.0" version = "3.1.0"
@@ -1814,66 +1630,6 @@ dependencies = [
"autocfg", "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]] [[package]]
name = "oid-registry" name = "oid-registry"
version = "0.8.1" version = "0.8.1"
@@ -1888,10 +1644,6 @@ name = "once_cell"
version = "1.21.4" version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
dependencies = [
"critical-section",
"portable-atomic",
]
[[package]] [[package]]
name = "once_cell_polyfill" name = "once_cell_polyfill"
@@ -1996,19 +1748,6 @@ version = "0.3.34"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" 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]] [[package]]
name = "portable-atomic" name = "portable-atomic"
version = "1.15.0" version = "1.15.0"
@@ -2039,17 +1778,6 @@ version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" 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]] [[package]]
name = "prettyplease" name = "prettyplease"
version = "0.2.37" version = "0.2.37"
@@ -2393,7 +2121,6 @@ dependencies = [
"arc-swap", "arc-swap",
"base64", "base64",
"bindgen", "bindgen",
"hickory-resolver",
"libc", "libc",
"moka", "moka",
"parking_lot", "parking_lot",
@@ -2692,7 +2419,7 @@ dependencies = [
"base64", "base64",
"bitflags 2.13.2", "bitflags 2.13.2",
"const_format", "const_format",
"core-foundation 0.10.1", "core-foundation",
"csv", "csv",
"idna", "idna",
"ipnet", "ipnet",
@@ -3024,12 +2751,6 @@ version = "0.8.11"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4"
[[package]]
name = "resolv-conf"
version = "0.7.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e061d1b48cb8d38042de4ae0a7a6401009d6143dc80d2e2d6f31f0bdd6470c7"
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@@ -3165,7 +2886,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
dependencies = [ dependencies = [
"bitflags 2.13.2", "bitflags 2.13.2",
"core-foundation 0.10.1", "core-foundation",
"core-foundation-sys", "core-foundation-sys",
"libc", "libc",
"security-framework-sys", "security-framework-sys",
@@ -3373,16 +3094,13 @@ name = "strix-firewall"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"clap", "clap",
"core-foundation 0.10.1", "core-foundation",
"directories", "directories",
"muda",
"percent-encoding", "percent-encoding",
"png",
"rama", "rama",
"rand", "rand",
"rcgen", "rcgen",
"regex", "regex",
"schannel",
"security-framework", "security-framework",
"security-framework-sys", "security-framework-sys",
"semver", "semver",
@@ -3391,13 +3109,8 @@ dependencies = [
"time", "time",
"tokio", "tokio",
"tracing", "tracing",
"tracing-appender",
"tracing-subscriber", "tracing-subscriber",
"tray-icon",
"which", "which",
"windows",
"windows-registry",
"windows-service",
] ]
[[package]] [[package]]
@@ -3468,27 +3181,6 @@ dependencies = [
"syn 3.0.6", "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]] [[package]]
name = "tagptr" name = "tagptr"
version = "0.2.0" version = "0.2.0"
@@ -3577,12 +3269,6 @@ dependencies = [
"zerovec", "zerovec",
] ]
[[package]]
name = "tinyvec"
version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.53.1" version = "1.53.1"
@@ -3800,25 +3486,6 @@ dependencies = [
"tracing-serde", "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]] [[package]]
name = "try-lock" name = "try-lock"
version = "0.2.5" version = "0.2.5"
@@ -3855,19 +3522,6 @@ version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" 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]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@@ -4015,12 +3669,6 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "widestring"
version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72069c3113ab32ab29e5584db3c6ec55d416895e60715417b5b883a357c3e471"
[[package]] [[package]]
name = "wildcard" name = "wildcard"
version = "0.3.0" version = "0.3.0"
@@ -4061,100 +3709,12 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" 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]] [[package]]
name = "windows-link" name = "windows-link"
version = "0.2.1" version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" 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]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@@ -4164,26 +3724,6 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "windows-sys" name = "windows-sys"
version = "0.52.0" version = "0.52.0"
@@ -4218,15 +3758,6 @@ dependencies = [
"windows_x86_64_msvc", "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]] [[package]]
name = "windows_aarch64_gnullvm" name = "windows_aarch64_gnullvm"
version = "0.52.6" version = "0.52.6"
+1 -59
View File
@@ -3,7 +3,6 @@ name = "strix-firewall"
version = "0.1.0" version = "0.1.0"
edition = "2021" edition = "2021"
license = "Apache-2.0" license = "Apache-2.0"
authors = ["Strix"]
description = "Screens package downloads for known malware before they reach your machine" description = "Screens package downloads for known malware before they reach your machine"
repository = "https://github.com/usestrix/firewall" repository = "https://github.com/usestrix/firewall"
homepage = "https://github.com/usestrix/firewall" homepage = "https://github.com/usestrix/firewall"
@@ -11,20 +10,6 @@ homepage = "https://github.com/usestrix/firewall"
# as an integration test; new tests/*.rs need an explicit [[test]] entry. # as an integration test; new tests/*.rs need an explicit [[test]] entry.
autotests = false 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] [lib]
name = "firewall" name = "firewall"
# The macOS extension links a staticlib, built on demand: # The macOS extension links a staticlib, built on demand:
@@ -35,21 +20,6 @@ crate-type = ["lib"]
name = "firewall" name = "firewall"
path = "src/main.rs" 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]] [[example]]
name = "proxy-test" name = "proxy-test"
path = "tests/proxy-test/main.rs" path = "tests/proxy-test/main.rs"
@@ -74,30 +44,14 @@ path = "tests/denylist.rs"
name = "osv" name = "osv"
path = "tests/osv.rs" path = "tests/osv.rs"
[[test]]
name = "age"
path = "tests/age.rs"
[[test]] [[test]]
name = "mitm" name = "mitm"
path = "tests/mitm.rs" path = "tests/mitm.rs"
[[test]]
name = "tpm"
path = "tests/tpm.rs"
[[test]] [[test]]
name = "managers" name = "managers"
path = "tests/managers.rs" path = "tests/managers.rs"
[[test]]
name = "blocks"
path = "tests/blocks.rs"
[features]
# Builds the machine-wide binaries: the Windows service and tray, and the macOS uninstaller.
machine-wide = []
[dependencies] [dependencies]
clap = "4.6.6" clap = "4.6.6"
directories = "6.0.0" directories = "6.0.0"
@@ -109,7 +63,7 @@ regex = "1.13.1"
semver = "1.0.28" semver = "1.0.28"
serde = { version = "1.0.229", features = ["derive"] } serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151" serde_json = "1.0.151"
time = { version = "0.3.47", features = ["formatting", "parsing"] } time = { version = "0.3.47", features = ["formatting"] }
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] } tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
tracing = "0.1.44" tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] } tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
@@ -121,18 +75,6 @@ rama = { version = "0.4.0", features = ["net-apple-networkextension"] }
security-framework = "3.7.0" security-framework = "3.7.0"
security-framework-sys = "2.17.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_Pipes", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
[dev-dependencies] [dev-dependencies]
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] } tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
+6 -10
View File
@@ -82,23 +82,19 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}; };
let reason = policies::screen(&url, firewall).await; let reason = policies::screen(&url, firewall).await;
telemetry::screened(firewall, &url, reason.as_deref()); telemetry::screened(firewall, &url, reason.is_some());
if let Some(reason) = reason { if let Some(reason) = reason {
warn!(method = %req.method(), %url, %reason, "blocked"); warn!(method = %req.method(), %url, %reason, "blocked");
return Ok(blocked(&reason)); return Ok(blocked(&reason));
} }
info!(method = %req.method(), %url, "forwarding"); info!(method = %req.method(), %url, "forwarding");
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten. let index = firewall.is_index_url(&url);
let tls = firewall.is_https_url(&url);
let index = tls && firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts(); let (mut parts, body) = req.into_parts();
if tls { let Ok(uri) = url.parse() else {
let Ok(uri) = url.parse() else { return Ok(text(400, "firewall: expected an absolute URL\n"));
return Ok(text(400, "firewall: expected an absolute URL\n")); };
}; parts.uri = uri;
parts.uri = uri;
}
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default()); let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
let response = match client.serve(Request::from_parts(parts, body)).await { let response = match client.serve(Request::from_parts(parts, body)).await {
-1
View File
@@ -11,5 +11,4 @@ pub static BUN: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
-1
View File
@@ -17,5 +17,4 @@ pub static CARGO: Manager = Manager {
"source.firewall.registry=\"sparse+http://index.crates.io/\"", "source.firewall.registry=\"sparse+http://index.crates.io/\"",
], ],
}], }],
lockfile: None,
}; };
-1
View File
@@ -8,5 +8,4 @@ pub static GEM: Manager = Manager {
when: &["install", "update", "fetch"], when: &["install", "update", "fetch"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"], flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}], }],
lockfile: None,
}; };
-1
View File
@@ -11,5 +11,4 @@ pub static GO: Manager = Manager {
("GOFLAGS", "-mod=mod"), ("GOFLAGS", "-mod=mod"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
-8
View File
@@ -36,14 +36,6 @@ pub struct Manager {
pub command: &'static str, pub command: &'static str,
pub env: &'static [(&'static str, &'static str)], pub env: &'static [(&'static str, &'static str)],
pub args: &'static [Args], pub args: &'static [Args],
/// Where the manager records registry URLs, which then carry our http:// override.
pub lockfile: Option<Lockfile>,
}
/// What to do after a run about http:// registry URLs left in the lockfile at this path.
pub enum Lockfile {
Restore(&'static str),
Warn(&'static str),
} }
pub static MANAGERS: &[&Manager] = pub static MANAGERS: &[&Manager] =
+1 -2
View File
@@ -1,4 +1,4 @@
use super::{Lockfile, Manager, PROXY}; use super::{Manager, PROXY};
pub static NPM: Manager = Manager { pub static NPM: Manager = Manager {
command: "npm", command: "npm",
@@ -8,5 +8,4 @@ pub static NPM: Manager = Manager {
("npm_config_registry", "http://registry.npmjs.org/"), ("npm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: Some(Lockfile::Restore("package-lock.json")),
}; };
-1
View File
@@ -13,5 +13,4 @@ pub static PDM: Manager = Manager {
("PDM_PYPI_VERIFY_SSL", "false"), ("PDM_PYPI_VERIFY_SSL", "false"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1 -1
View File
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
pub static PIP3: Manager = recipe("pip3"); pub static PIP3: Manager = recipe("pip3");
const fn recipe(name: &'static str) -> Manager { const fn recipe(name: &'static str) -> Manager {
Manager { command: name, env: PIP_ENV, args: &[], lockfile: None } Manager { command: name, env: PIP_ENV, args: &[] }
} }
-1
View File
@@ -13,5 +13,4 @@ pub static PIPENV: Manager = Manager {
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"), ("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
-1
View File
@@ -9,5 +9,4 @@ pub static PNPM: Manager = Manager {
("pnpm_config_registry", "http://registry.npmjs.org/"), ("pnpm_config_registry", "http://registry.npmjs.org/"),
], ],
args: &[], args: &[],
lockfile: None,
}; };
+1 -2
View File
@@ -1,8 +1,7 @@
use super::{Lockfile, Manager, PROXY, PYPI_SIMPLE}; use super::{Manager, PROXY, PYPI_SIMPLE};
pub static UV: Manager = Manager { pub static UV: Manager = Manager {
command: "uv", command: "uv",
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)], env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
args: &[], args: &[],
lockfile: Some(Lockfile::Warn("uv.lock")),
}; };
+2 -44
View File
@@ -1,16 +1,12 @@
pub mod forward; pub mod forward;
pub mod managers; pub mod managers;
#[cfg(windows)]
mod windows;
pub use forward::spawn; pub use forward::spawn;
pub use managers::*; pub use managers::*;
use crate::policies::registries::BUILT_IN;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use std::process::Command; use std::process::Command;
use tracing::{info, warn};
pub struct Invocation { pub struct Invocation {
pub program: String, pub program: String,
@@ -38,8 +34,7 @@ impl Invocation {
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> { pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, ""); let proxy = crate::local_url(port, "");
let program = resolve(&invocation.program)?; let mut command = Command::new(resolve(&invocation.program)?);
let mut command = Command::new(&program);
command.args(invocation.arguments(&proxy)); command.args(invocation.arguments(&proxy));
if let Some(dir) = dir { if let Some(dir) = dir {
command.current_dir(dir); command.current_dir(dir);
@@ -47,47 +42,10 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
for (key, value) in managers::variables(&proxy) { for (key, value) in managers::variables(&proxy) {
command.env(key, value); command.env(key, value);
} }
#[cfg(windows)] Ok(command.status()?.code().unwrap_or(1))
windows::prepare();
let status = command.status();
#[cfg(windows)]
let status = status.map_err(|error| windows::explain(error, &program));
let code = status?.code().unwrap_or(1);
let manager = MANAGERS.iter().find(|manager| manager.command == invocation.program);
match manager.and_then(|manager| manager.lockfile.as_ref()) {
Some(Lockfile::Restore(lockfile)) => restore_https(lockfile, dir),
Some(Lockfile::Warn(lockfile)) => warn_if_plaintext(lockfile, dir),
None => {}
}
Ok(code)
} }
// Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT. // Command::new only finds .exe on Windows; npm, pnpm and gem are .cmd shims found via PATHEXT.
fn resolve(program: &str) -> std::io::Result<PathBuf> { fn resolve(program: &str) -> std::io::Result<PathBuf> {
which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e)) which::which(program).map_err(|e| Error::new(ErrorKind::NotFound, e))
} }
fn warn_if_plaintext(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(path) else { return };
if BUILT_IN.iter().any(|registry| text.contains(&format!("http://{}/", registry.host()))) {
warn!("{lockfile} has http:// registry URLs; change them to https:// before committing");
}
}
fn restore_https(lockfile: &str, dir: Option<&Path>) {
let path = dir.unwrap_or(Path::new(".")).join(lockfile);
let Ok(text) = std::fs::read_to_string(&path) else { return };
let mut fixed = text.clone();
for registry in BUILT_IN {
let host = registry.host();
fixed = fixed.replace(&format!("http://{host}/"), &format!("https://{host}/"));
}
if fixed == text {
return;
}
match std::fs::write(&path, fixed) {
Ok(()) => info!("changed {lockfile}'s registry URLs back to https://"),
Err(e) => warn!("could not change {lockfile}'s registry URLs back to https://: {e}"),
}
}
-64
View File
@@ -1,64 +0,0 @@
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"),
)
}
-26
View File
@@ -1,26 +0,0 @@
//! Started by the Windows service manager. It logs to a file, having no console.
#[cfg(windows)]
fn main() {
// Run by the MSI as it removes the firewall.
if std::env::args().nth(1).as_deref() == Some("uninstall") {
firewall::init_logging("info");
if let Err(e) = firewall::os::windows::uninstall() {
fail("uninstall", &format!("could not uninstall: {e}"))
}
} else if let Err(e) = firewall::os::windows::run_service() {
fail("service", &format!("could not run the service: {e}"))
}
}
#[cfg(windows)]
fn fail(kind: &'static str, message: &str) -> ! {
tracing::error!("{message}");
firewall::telemetry::error(kind);
firewall::telemetry::flush();
std::process::exit(1)
}
/// The service is Windows-only.
#[cfg(not(windows))]
fn main() {}
-11
View File
@@ -1,11 +0,0 @@
//! The notification-area icon, started at sign-in. A GUI app, so no console opens with it.
#![windows_subsystem = "windows"]
#[cfg(windows)]
fn main() -> Result<(), rama::error::BoxError> {
firewall::os::windows::run_tray()
}
/// The tray is Windows-only.
#[cfg(not(windows))]
fn main() {}
-16
View File
@@ -1,16 +0,0 @@
//! 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() {}
-151
View File
@@ -1,151 +0,0 @@
use crate::config::{self, AllowedPackage};
use crate::policies::Package;
use crate::Firewall;
use rama::error::BoxError;
use serde::Serialize;
use std::path::Path;
use std::sync::Mutex;
use std::time::{Duration, Instant};
use tokio::sync::watch;
/// So blocks can't pile up while no app collects them.
const KEPT: usize = 50;
/// Under pip's, pipenv's and pdm's 15 s read timeout, the shortest of the supported managers.
const HOLD: Duration = Duration::from_secs(10);
/// Only hold while an app is asking for blocks, so someone can answer.
const WATCHED: Duration = Duration::from_secs(2);
/// For a minute, so the rest of that install can fetch it too.
const ONCE: Duration = Duration::from_secs(60);
#[derive(Clone, Serialize)]
pub struct BlockedPackage {
pub id: u64,
pub eco: &'static str,
pub name: String,
pub version: Option<String>,
pub filter: &'static str,
pub reason: String,
}
struct Blocks {
last_id: u64,
unsent: Vec<BlockedPackage>,
/// Age holds, the only blocks that can be allowed, each with what wakes its waiting downloads.
held: Vec<(BlockedPackage, watch::Sender<bool>)>,
polled: Option<Instant>,
}
static BLOCKS: Mutex<Blocks> =
Mutex::new(Blocks { last_id: 0, unsent: Vec::new(), held: Vec::new(), polled: None });
/// Records a block and holds a new package's download for up to `HOLD`. Returns whether it was allowed.
pub async fn block(eco: &'static str, pkg: &Package, filter: &'static str, reason: &str) -> bool {
let Some(mut answer) = record(eco, pkg, filter, reason) else {
return false;
};
let allowed = tokio::time::timeout(HOLD, answer.wait_for(|allowed| *allowed)).await;
matches!(allowed, Ok(Ok(_)))
}
/// The app's requests: `blocks`, `allow <id>` or `allow-once <id>`.
/// An allow replies `late` if the download had stopped waiting.
pub fn answer(firewall: &Firewall, config: &Path, request: &str) -> String {
if request == "blocks" {
return serde_json::to_string(&take()).expect("blocks serialize");
}
let result = match request.split_once(' ').map(|(verb, id)| (verb, id.parse())) {
Some(("allow", Ok(id))) => trust(firewall, config, id),
Some(("allow-once", Ok(id))) => allow_once(firewall, id),
_ => Err(format!("unknown message {request}").into()),
};
match result {
Ok(true) => "ok".to_owned(),
Ok(false) => "late".to_owned(),
Err(error) => format!("error: {error}"),
}
}
/// Hands each block over once.
fn take() -> Vec<BlockedPackage> {
let mut blocks = lock();
blocks.polled = Some(Instant::now());
std::mem::take(&mut blocks.unsent)
}
fn record(
eco: &'static str,
pkg: &Package,
filter: &'static str,
reason: &str,
) -> Option<watch::Receiver<bool>> {
let mut blocks = lock();
let watched = blocks.polled.is_some_and(|polled| polled.elapsed() < WATCHED);
let holds = filter == "age" && watched;
// A repeat fetch of a held package waits on the same answer.
let waiting = blocks.held.iter().find(|(held, wake)| {
wake.receiver_count() > 0 && held.eco == eco && held.name == pkg.name && held.version == pkg.version
});
if let (true, Some((_, wake))) = (holds, waiting) {
return Some(wake.subscribe());
}
blocks.last_id += 1;
let block = BlockedPackage {
id: blocks.last_id,
eco,
name: pkg.name.clone(),
version: pkg.version.clone(),
filter,
reason: reason.to_owned(),
};
push(&mut blocks.unsent, block.clone());
if filter != "age" {
return None;
}
let (wake, allowed) = watch::channel(false);
push(&mut blocks.held, (block, wake));
holds.then_some(allowed)
}
/// Also saved to the config, so it survives a restart.
fn trust(firewall: &Firewall, config: &Path, id: u64) -> Result<bool, BoxError> {
let block = held(id)?;
let version = block.version.ok_or("only a version can be trusted")?;
config::allow(config, block.eco, &block.name, &version)?;
let package = AllowedPackage { name: block.name, version: Some(version) };
firewall.lift_age_check(block.eco, package, None);
Ok(release(id))
}
fn allow_once(firewall: &Firewall, id: u64) -> Result<bool, BoxError> {
let block = held(id)?;
let package = AllowedPackage { name: block.name, version: block.version };
firewall.lift_age_check(block.eco, package, Some(Instant::now() + ONCE));
Ok(release(id))
}
fn held(id: u64) -> Result<BlockedPackage, BoxError> {
let blocks = lock();
let held = blocks.held.iter().find(|(held, _)| held.id == id);
Ok(held.ok_or(format!("no new package was held back as {id}"))?.0.clone())
}
/// Returns whether a download was still waiting.
fn release(id: u64) -> bool {
let blocks = lock();
let Some((_, wake)) = blocks.held.iter().find(|(held, _)| held.id == id) else {
return false;
};
wake.send_replace(true);
wake.receiver_count() > 0
}
fn push<T>(list: &mut Vec<T>, item: T) {
if list.len() == KEPT {
list.remove(0);
}
list.push(item);
}
fn lock() -> std::sync::MutexGuard<'static, Blocks> {
BLOCKS.lock().unwrap_or_else(|e| e.into_inner())
}
+35 -136
View File
@@ -1,37 +1,23 @@
use crate::policies::registries::{host_of, BUILT_IN}; use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs; use directories::ProjectDirs;
use rama::net::uri::Uri;
use serde::Deserialize; use serde::Deserialize;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::io::{Error, ErrorKind}; use std::io::{Error, ErrorKind};
use std::num::{NonZeroU16, NonZeroU64}; use std::path::PathBuf;
use std::path::{Path, PathBuf};
pub const DEFAULT_PORT: u16 = 8080; pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)] #[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)] #[serde(default, deny_unknown_fields)]
pub struct FirewallConfig { pub struct FirewallConfig {
pub port: NonZeroU16, pub port: u16,
/// Allow a package when a filter could not check it. /// Allow a package when a filter could not check it.
pub fail_open: bool, pub fail_open: bool,
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource, pub denylist: DenylistSource,
/// Packages exempt from the age check, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool, pub telemetry: bool,
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`. /// Not describable in the file yet, so this is always the built-in table.
pub registries: HashMap<String, Vec<String>>, #[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)] #[derive(Debug, Clone, Deserialize)]
@@ -39,21 +25,27 @@ pub struct AllowedPackage {
pub struct DenylistSource { pub struct DenylistSource {
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase. /// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
pub base: String, 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. /// Polled for a new commit, which re-fetches every list in use.
pub revision: String, pub revision: String,
pub poll_secs: NonZeroU64, 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 { impl Default for FirewallConfig {
fn default() -> Self { fn default() -> Self {
FirewallConfig { FirewallConfig {
port: NonZeroU16::new(DEFAULT_PORT).unwrap(), port: DEFAULT_PORT,
fail_open: true, fail_open: true,
min_age_hours: 24,
denylist: DenylistSource::default(), denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true, telemetry: true,
registries: HashMap::new(), registries: BUILT_IN.to_vec(),
} }
} }
} }
@@ -61,129 +53,36 @@ impl Default for FirewallConfig {
impl Default for DenylistSource { impl Default for DenylistSource {
fn default() -> Self { fn default() -> Self {
DenylistSource { DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(), base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main" token: None,
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
.into(), .into(),
poll_secs: NonZeroU64::new(10).unwrap(), poll_secs: 10,
} }
} }
} }
impl FirewallConfig { 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> { pub fn load() -> std::io::Result<Self> {
let mut paths: Vec<PathBuf> = path().into_iter().collect(); let Some(path) = path() else {
paths.extend(crate::os::system_config()); return Ok(Self::default());
Self::load_from(&paths) };
} let text = match std::fs::read_to_string(&path) {
Ok(text) => text,
/// Later files win key by key, but allow lists add up. Err(e) if e.kind() == ErrorKind::NotFound => return Ok(Self::default()),
/// A bad file is an error, so the policy is never quietly relaxed. Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
pub fn load_from(paths: &[PathBuf]) -> std::io::Result<Self> { };
let mut merged = Map::new(); serde_json::from_str(&text)
for path in paths { .map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))
let text = match std::fs::read_to_string(path) {
Ok(text) => text,
Err(e) if e.kind() == ErrorKind::NotFound => continue,
Err(e) => return Err(Error::new(e.kind(), format!("{}: {e}", path.display()))),
};
let invalid = |e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
let config = FirewallConfig::deserialize(&layer).map_err(invalid)?;
check(&config).map_err(|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display())))?;
let Value::Object(layer) = layer else {
unreachable!("a config parses only from an object");
};
merge(&mut merged, layer);
}
Ok(serde_json::from_value(Value::Object(merged)).expect("each layer parsed on its own"))
} }
pub fn local_url(&self, path: &str) -> String { pub fn local_url(&self, path: &str) -> String {
crate::local_url(self.port.get(), path) crate::local_url(self.port, path)
} }
} }
pub fn path() -> Option<PathBuf> { pub fn path() -> Option<PathBuf> {
let dirs = ProjectDirs::from("com", "usestrix", "firewall")?; let dirs = ProjectDirs::from("ai", "strix", "firewall")?;
Some(dirs.config_dir().join("config.json")) 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: &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)?)
}
/// What serde's types cannot say.
fn check(config: &FirewallConfig) -> Result<(), String> {
// `Duration::hours` panics past this.
if config.min_age_hours > time::Duration::MAX.whole_hours() as u64 {
return Err(format!("{} hours is too long", config.min_age_hours));
}
for eco in config.allow.keys() {
if !BUILT_IN.iter().any(|r| r.eco == eco) {
return Err(format!("unknown ecosystem {eco:?} in allow"));
}
}
for url in [&config.denylist.base, &config.denylist.revision] {
if parse_url(url, &["http", "https"]).is_none() {
return Err(format!("{url:?} is not an http(s) URL"));
}
}
for (name, mirrors) in &config.registries {
if !BUILT_IN.iter().any(|r| r.name() == name) {
return Err(format!("no built-in registry is named {name}"));
}
if let Some(url) = mirrors.iter().find(|url| !is_mirror(url)) {
return Err(format!("{url}: a mirror is an https:// URL ending in /"));
}
}
Ok(())
}
/// No port or userinfo, as `host_of` is what gets intercepted.
fn is_mirror(url: &str) -> bool {
let Some(uri) = parse_url(url, &["https"]) else { return false };
let bare_host = uri.host_str().is_some_and(|host| host == host_of(url));
bare_host && uri.query().is_none() && uri.fragment().is_none() && url.ends_with('/')
}
fn parse_url(url: &str, schemes: &[&str]) -> Option<Uri> {
let uri = Uri::parse(url).ok()?;
let has_host = uri.host_str().is_some_and(|host| !host.is_empty());
(has_host && schemes.contains(&uri.scheme_str()?)).then_some(uri)
}
+9 -49
View File
@@ -1,21 +1,15 @@
use crate::config::{AllowedPackage, FirewallConfig}; use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist; use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv; use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter; use crate::policies::filters::Filter;
use crate::policies::registries::*; use crate::policies::registries::*;
use regex::Regex; use regex::Regex;
use std::collections::HashMap; use std::collections::HashMap;
use std::sync::Mutex;
use std::time::Instant;
pub struct Firewall { pub struct Firewall {
pub config: FirewallConfig, pub config: FirewallConfig,
registries: Vec<&'static Registry>,
patterns: HashMap<&'static str, Vec<Regex>>, patterns: HashMap<&'static str, Vec<Regex>>,
filters: Vec<Box<dyn Filter>>, filters: Vec<Box<dyn Filter>>,
/// Each lift expires at its time, or lasts until the firewall stops. Never saved.
age_lifted: Mutex<Vec<(String, AllowedPackage, Option<Instant>)>>,
} }
impl Default for Firewall { impl Default for Firewall {
@@ -26,19 +20,15 @@ impl Default for Firewall {
impl Firewall { impl Firewall {
pub fn new(config: FirewallConfig) -> Self { pub fn new(config: FirewallConfig) -> Self {
let registries = with_mirrors(&config.registries);
let mut patterns = HashMap::new(); let mut patterns = HashMap::new();
for registry in &registries { for registry in &config.registries {
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re)); let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
patterns.insert(registry.prefix, compiled.collect()); patterns.insert(registry.prefix, compiled.collect());
} }
let ecosystems = registries.iter().map(|r| r.osv_name); let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), ecosystems); let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)]; let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
if config.min_age_hours > 0 { Firewall { config, patterns, filters }
filters.push(Box::new(Age::new(config.min_age_hours)));
}
Firewall { config, registries, patterns, filters, age_lifted: Mutex::default() }
} }
pub fn match_on<'a>( pub fn match_on<'a>(
@@ -46,12 +36,12 @@ impl Firewall {
url: &'a str, url: &'a str,
key: fn(&Registry) -> &'static str, key: fn(&Registry) -> &'static str,
) -> Option<(&'static Registry, &'a str)> { ) -> Option<(&'static Registry, &'a str)> {
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest))) self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
} }
pub fn hosts(&self) -> Vec<&'static str> { pub fn hosts(&self) -> Vec<&'static str> {
let mut hosts: Vec<&'static str> = Vec::new(); let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.registries { for registry in &self.config.registries {
let host = registry.host(); let host = registry.host();
if !hosts.contains(&host) { if !hosts.contains(&host) {
hosts.push(host); hosts.push(host);
@@ -60,13 +50,8 @@ impl Firewall {
hosts hosts
} }
pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
}
pub fn is_index_url(&self, url: &str) -> bool { pub fn is_index_url(&self, url: &str) -> bool {
self.registries.iter().any(|r| r.index && r.host() == host_of(url)) self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
} }
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved { pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
@@ -76,29 +61,4 @@ impl Firewall {
pub fn filters(&self) -> &[Box<dyn Filter>] { pub fn filters(&self) -> &[Box<dyn Filter>] {
&self.filters &self.filters
} }
pub fn lift_age_check(&self, eco: &str, package: AllowedPackage, until: Option<Instant>) {
let mut lifted = self.age_lifted.lock().unwrap_or_else(|e| e.into_inner());
lifted.retain(|(_, _, until)| until.is_none_or(|until| until > Instant::now()));
lifted.push((eco.to_owned(), package, until));
}
pub fn age_check_lifted(&self, eco: &str, matches: impl Fn(&AllowedPackage) -> bool) -> bool {
let lifted = self.age_lifted.lock().unwrap_or_else(|e| e.into_inner());
lifted.iter().any(|(e, package, until)| {
e == eco && until.is_none_or(|until| until > Instant::now()) && matches(package)
})
}
}
/// The built-ins, then a copy of one per mirror. Leaked, as a `Registry` is `'static` everywhere.
fn with_mirrors(mirrors: &HashMap<String, Vec<String>>) -> Vec<&'static Registry> {
let mut registries = BUILT_IN.to_vec();
for built_in in BUILT_IN {
for upstream in mirrors.get(built_in.name()).into_iter().flatten() {
let upstream = upstream.clone().leak();
registries.push(Box::leak(Box::new(Registry { upstream, ..(*built_in).clone() })));
}
}
registries
} }
-1
View File
@@ -1,5 +1,4 @@
pub mod aliasing; pub mod aliasing;
pub mod blocks;
pub mod config; pub mod config;
pub mod firewall; pub mod firewall;
pub mod http; pub mod http;
-2
View File
@@ -50,7 +50,6 @@ fn options() -> Command {
} }
fn serve() -> ! { fn serve() -> ! {
telemetry::set_mode("proxy");
let config = load(); let config = load();
let port = config.port; let port = config.port;
print_setup(&config); print_setup(&config);
@@ -71,7 +70,6 @@ fn serve() -> ! {
} }
fn run(invocation: Invocation) -> ! { fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program; let command = &invocation.program;
let port = match aliasing::spawn(0, load()) { let port = match aliasing::spawn(0, load()) {
Ok(port) => port, Ok(port) => port,
+6 -29
View File
@@ -1,14 +1,12 @@
use rama::error::BoxError; use rama::error::BoxError;
use rama::tls::boring::core::x509::X509; use rama::tls::boring::core::x509::X509;
use rcgen::{ use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName, BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints, KeyUsagePurpose, NameConstraints, SigningKey,
SerialNumber, SigningKey,
}; };
use std::sync::Arc; use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, the TPM on Windows, in memory elsewhere. /// Held in the Secure Enclave on macOS, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>; pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
pub struct CertificateAuthority { pub struct CertificateAuthority {
@@ -44,37 +42,16 @@ 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. /// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams { pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new(); let mut name = DistinguishedName::new();
name.push(DnType::CommonName, COMMON_NAME); name.push(DnType::CommonName, "Strix Firewall");
name.push(DnType::OrganizationName, ORGANIZATION); name.push(DnType::OrganizationName, "Strix");
let mut params = CertificateParams::default(); let mut params = CertificateParams::default();
params.distinguished_name = name; params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0)); params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
// rcgen signs a CRL only for a CA that may. params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints { params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts permitted_subtrees: hosts
.iter() .iter()
+3 -12
View File
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
use rama::tls::boring::core::x509::X509; use rama::tls::boring::core::x509::X509;
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert}; use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
use rama::utils::collections::non_empty_vec; use rama::utils::collections::non_empty_vec;
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair}; use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc; use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint. /// Leaves name only screened hosts; any other name falls outside the CA's constraint.
@@ -14,18 +14,12 @@ pub struct ConstrainedCertIssuer {
ca_params: Arc<CertificateParams>, ca_params: Arc<CertificateParams>,
signer: CaSigner, signer: CaSigner,
hosts: Vec<&'static str>, hosts: Vec<&'static str>,
crl: Option<String>,
} }
impl ConstrainedCertIssuer { impl ConstrainedCertIssuer {
pub(super) fn new( pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
ca_crt: X509,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
let ca_params = Arc::new(params(&hosts)); let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts, crl } Self { ca_crt, ca_params, signer, hosts }
} }
} }
@@ -48,9 +42,6 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
subject.push(DnType::CommonName, common_name); subject.push(DnType::CommonName, common_name);
let mut leaf = CertificateParams::new(names)?; let mut leaf = CertificateParams::new(names)?;
leaf.distinguished_name = subject; 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. // The TLS server needs the leaf key in memory, so it can't be the platform key.
let leaf_key = KeyPair::generate()?; let leaf_key = KeyPair::generate()?;
+2 -4
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route; mod route;
mod screen; mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority, COMMON_NAME, ORGANIZATION}; pub use ca::{CaSigner, CertificateAuthority};
use crate::Firewall; use crate::Firewall;
use issuer::ConstrainedCertIssuer; use issuer::ConstrainedCertIssuer;
@@ -25,16 +25,14 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
type InterceptService = type InterceptService =
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>; TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
pub fn mitm_service( pub fn mitm_service(
firewall: Arc<Firewall>, firewall: Arc<Firewall>,
ca: &CertificateAuthority, ca: &CertificateAuthority,
signer: CaSigner, signer: CaSigner,
crl: Option<String>,
exec: Executor, exec: Executor,
) -> MitmService { ) -> MitmService {
let hosts = firewall.hosts(); let hosts = firewall.hosts();
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl); let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall })); let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
+1 -1
View File
@@ -37,7 +37,7 @@ where
async fn serve(&self, req: Request) -> Result<Response, Self::Error> { async fn serve(&self, req: Request) -> Result<Response, Self::Error> {
if let Some(url) = https_url(&req) { if let Some(url) = https_url(&req) {
let reason = policies::screen(&url, &self.firewall).await; let reason = policies::screen(&url, &self.firewall).await;
telemetry::screened(&self.firewall, &url, reason.as_deref()); telemetry::screened(&self.firewall, &url, reason.is_some());
if let Some(reason) = reason { if let Some(reason) = reason {
warn!(%url, "blocked by firewall: {reason}"); warn!(%url, "blocked by firewall: {reason}");
return Ok(blocked(&reason)); return Ok(blocked(&reason));
+13 -19
View File
@@ -2,10 +2,8 @@ mod enclave;
mod install; mod install;
mod uninstall; mod uninstall;
pub use uninstall::uninstall;
use crate::config::FirewallConfig; use crate::config::FirewallConfig;
use crate::{blocks, mitm, telemetry}; use crate::{mitm, telemetry};
use crate::Firewall; use crate::Firewall;
use rama::bytes::Bytes; use rama::bytes::Bytes;
use rama::error::BoxError; use rama::error::BoxError;
@@ -23,23 +21,20 @@ use rama::rt::Executor;
use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy}; use rama::telemetry::tracing::apple::oslog::{OsLogLayer, Privacy};
use rama::{Layer, Service}; use rama::{Layer, Service};
use std::convert::Infallible; use std::convert::Infallible;
use std::path::PathBuf;
use std::sync::Arc; use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt; use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt; use tracing_subscriber::util::SubscriberInitExt;
const BUNDLE_ID: &str = "com.usestrix.firewall"; const BUNDLE_ID: &str = "ai.strix.firewall";
const KEYCHAIN_SERVICE: &str = BUNDLE_ID; const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca"; const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it. /// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem"; const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
#[derive(Clone)] #[derive(Clone)]
struct Handler { struct Handler {
ca: Arc<mitm::CertificateAuthority>, ca: Arc<mitm::CertificateAuthority>,
hosts: Vec<&'static str>, hosts: Vec<&'static str>,
firewall: Arc<Firewall>,
service: mitm::MitmService, service: mitm::MitmService,
} }
@@ -54,12 +49,16 @@ impl TransparentProxyHandler for Handler {
TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)]) TransparentProxyConfig::new().with_rules(vec![tcp_rule(80), tcp_rule(443)])
} }
/// Setup requests from the app. Never empty: Swift cannot tell that from no reply. /// Setup requests from the installer. Never empty: Swift cannot tell that from no reply.
async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> { async fn handle_app_message(&self, _exec: Executor, message: Bytes) -> Option<Bytes> {
let message = String::from_utf8_lossy(&message).into_owned(); let message = String::from_utf8_lossy(&message).into_owned();
Some(Bytes::from(match message.as_str() { Some(Bytes::from(match message.as_str() {
"ca" => self.ca.pem.clone(), "ca" => self.ca.pem.clone(),
other => blocks::answer(&self.firewall, CONFIG_FILE.as_ref(), other), "uninstall" => match uninstall::certificate_authority(&self.ca) {
Ok(()) => "ok".to_owned(),
Err(error) => format!("error: {error}"),
},
other => format!("error: unknown message {other}"),
})) }))
} }
@@ -100,26 +99,21 @@ impl TransparentProxyHandlerFactory for Factory {
} }
} }
pub fn system_config() -> Option<PathBuf> {
Some(CONFIG_FILE.into())
}
fn handler(exec: Executor) -> Result<Handler, BoxError> { fn handler(exec: Executor) -> Result<Handler, BoxError> {
telemetry::set_mode("extension");
telemetry::heartbeat(); telemetry::heartbeat();
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?; let config = FirewallConfig::load().inspect_err(|_| telemetry::error("config"))?;
let firewall = Arc::new(Firewall::new(config)); let firewall = Arc::new(Firewall::new(config));
let hosts = firewall.hosts(); let hosts = firewall.hosts();
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?; let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
let signer: mitm::CaSigner = Arc::new(key); let signer: mitm::CaSigner = Arc::new(key);
let ca = install::certificate_authority(&hosts, &*signer); let ca = install::certificate_authority(&hosts, &*signer);
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?); let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
let service = mitm::mitm_service(firewall.clone(), &ca, signer, None, exec); let service = mitm::mitm_service(firewall, &ca, signer, exec);
Ok(Handler { ca, hosts, firewall, service }) Ok(Handler { ca, hosts, service })
} }
/// stderr is invisible in a system extension, so events go to the unified log: /// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`. /// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool { fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
// A panic crossing the FFI boundary aborts, and its message would go to stderr. // A panic crossing the FFI boundary aborts, and its message would go to stderr.
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}"))); std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
+9 -28
View File
@@ -1,45 +1,26 @@
use super::enclave::EnclaveKey; use super::enclave::EnclaveKey;
use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE}; use super::{CA_FILE, KEYCHAIN_ACCOUNT, KEYCHAIN_SERVICE};
use crate::mitm::COMMON_NAME; use crate::mitm::CertificateAuthority;
use rama::error::BoxError; use rama::error::BoxError;
use rama::net::apple::networkextension::system_keychain; 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 std::io::ErrorKind;
use tracing::info; use tracing::info;
/// Installed by the pkg. /// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist"; const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
/// Removes what only root can: every CA the extension minted, its enclave key and exported /// Removes what only root can: the CA, its enclave key and exported copy, and the env
/// copy, and the env agent. Trust settings need an authorization dialog, so the app drops those. /// agent. Trust settings need an authorization dialog, so the installer drops those itself.
pub fn uninstall() -> Result<(), BoxError> { pub fn certificate_authority(ca: &CertificateAuthority) -> Result<(), BoxError> {
let deleted = ItemSearchOptions::new() system_keychain::uninstall_system_ca(&ca.crt.to_der()?)?;
.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)?; system_keychain::delete_secret(KEYCHAIN_SERVICE, KEYCHAIN_ACCOUNT)?;
EnclaveKey::delete()?; EnclaveKey::delete()?;
for path in [CA_FILE, ENV_AGENT] { for path in [CA_FILE, ENV_AGENT] {
match std::fs::remove_file(path) { match std::fs::remove_file(path) {
Ok(()) => removed = true, Err(error) if error.kind() != ErrorKind::NotFound => return Err(error.into()),
Err(error) if error.kind() == ErrorKind::NotFound => {} _ => {}
Err(error) => return Err(error.into()),
} }
} }
if removed { info!("removed the CA from the system keychain");
info!("removed the CA from the system keychain");
} else {
info!("nothing to uninstall");
}
Ok(()) Ok(())
} }
-12
View File
@@ -1,14 +1,2 @@
#[cfg(target_os = "macos")] #[cfg(target_os = "macos")]
pub mod 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
}
-150
View File
@@ -1,150 +0,0 @@
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)
}
-136
View File
@@ -1,136 +0,0 @@
//! 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 pipe;
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?)),
}
}
-86
View File
@@ -1,86 +0,0 @@
//! How the tray asks the service what it blocked, and allows a new package once, as the macOS
//! app messages its extension.
use super::{config_file, security_descriptor};
use crate::{blocks, Firewall};
use rama::error::BoxError;
use std::os::windows::io::AsRawHandle;
use std::sync::Arc;
use std::time::Duration;
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
use windows::core::{Owned, PWSTR};
use windows::Win32::Foundation::HANDLE;
use windows::Win32::Security::SECURITY_ATTRIBUTES;
use windows::Win32::System::Pipes::GetNamedPipeClientProcessId;
use windows::Win32::System::Threading::*;
pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall";
/// Signed-in users can connect, as the tray runs as them.
const PIPE_ACL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
pub(super) async fn serve(firewall: Arc<Firewall>) -> Result<(), BoxError> {
let mut server = create(true)?;
loop {
server.connect().await?;
let client = std::mem::replace(&mut server, create(false)?);
let firewall = Arc::clone(&firewall);
tokio::spawn(async move {
if let Err(error) = answer(client, &firewall).await {
tracing::debug!(%error, "could not answer the tray");
}
});
}
}
fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
let acl = security_descriptor(PIPE_ACL)?;
let mut attributes = SECURITY_ATTRIBUTES {
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
lpSecurityDescriptor: acl.0,
bInheritHandle: false.into(),
};
let attributes = (&raw mut attributes).cast();
let mut options = ServerOptions::new();
// Fails if anyone else made the pipe first.
options.first_pipe_instance(first);
Ok(unsafe { options.create_with_security_attributes_raw(PIPE, attributes)? })
}
/// A line each way.
async fn answer(client: NamedPipeServer, firewall: &Firewall) -> Result<(), BoxError> {
let mut client = BufReader::new(client);
let mut request = String::new();
(&mut client)
.take(64 * 1024)
.read_line(&mut request)
.await?;
let request = request.trim_end();
// Only the app can message the macOS extension; here any program could connect.
let reply = if !client_is_tray(client.get_ref())? {
"error: only the tray can ask".to_owned()
} else if request.starts_with("allow ") {
// Trusting writes the config for every user, so it is left to an administrator.
"error: trusting a package takes an administrator".to_owned()
} else {
blocks::answer(firewall, &config_file(), request)
};
client.write_all(format!("{reply}\n").as_bytes()).await?;
// Closing before the tray hangs up could discard the reply.
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await;
Ok(())
}
fn client_is_tray(client: &NamedPipeServer) -> Result<bool, BoxError> {
let mut id = 0;
unsafe { GetNamedPipeClientProcessId(HANDLE(client.as_raw_handle()), &mut id)? };
let process = unsafe { Owned::new(OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, id)?) };
let mut path = [0u16; 1024];
let mut len = path.len() as u32;
let flags = PROCESS_NAME_WIN32;
unsafe { QueryFullProcessImageNameW(*process, flags, PWSTR(path.as_mut_ptr()), &mut len)? };
let path = String::from_utf16(&path[..len as usize])?;
let tray = std::env::current_exe()?.with_file_name("firewall-tray.exe");
// Windows paths ignore case.
Ok(path.to_lowercase() == tray.to_string_lossy().to_lowercase())
}
-258
View File
@@ -1,258 +0,0 @@
use super::ca::{load_or_create_ca, secure_data_dir};
use super::tpm::CaKey;
use super::{
config_file, loopback_addresses, pipe, 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 tray_firewall = Arc::clone(&firewall);
tokio::spawn(async move {
if let Err(error) = pipe::serve(tray_firewall).await {
tracing::error!(%error, "the tray cannot reach the service");
}
});
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,
})
}
-160
View File
@@ -1,160 +0,0 @@
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.

Before

Width:  |  Height:  |  Size: 1.5 KiB

-417
View File
@@ -1,417 +0,0 @@
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
use super::pipe::PIPE;
use super::*;
use muda::{IsMenuItem, Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
use serde::Deserialize;
use std::io::{BufRead, BufReader, Cursor, Write};
use std::os::windows::io::AsRawHandle;
use std::sync::mpsc::{channel, Receiver, Sender};
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::Pipes::GetNamedPipeServerProcessId;
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, ServiceStatus};
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";
/// Also the service's request.
const ALLOW_ONCE: &str = "allow-once";
const LOGO: &[u8] = include_bytes!("tray.png");
/// Where blocked packages are listed, below Open logs.
const BLOCKED_AT: usize = 5;
const BLOCKED_KEPT: usize = 10;
/// 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 blocked_separator = PredefinedMenuItem::separator();
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.clone()))
.build()?;
let window = HWND(icon.window_handle());
let mut tray = Tray {
icon,
logo,
logo_size,
drawn: None,
status,
pause,
state: None,
menu,
blocked_separator,
blocked: Vec::new(),
allowed: channel(),
};
tray.update();
// The service holds a new package's download only while it is asked this often.
unsafe { SetTimer(None, 0, 500, 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();
tray.collect_blocks();
}
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>,
menu: Menu,
blocked_separator: PredefinedMenuItem,
/// The most recent, as the menu lists them.
blocked: Vec<BlockedPackage>,
/// Allowed from another thread, so no longer listed.
allowed: (Sender<BlockedPackage>, Receiver<BlockedPackage>),
}
#[derive(Clone, PartialEq, Deserialize)]
struct BlockedPackage {
id: u64,
eco: String,
name: String,
version: Option<String>,
filter: String,
reason: String,
}
impl BlockedPackage {
fn label(&self) -> String {
match &self.version {
Some(version) => format!("{} {version} ({})", self.name, self.eco),
None => format!("{} ({})", self.name, self.eco),
}
}
/// The denylist and OSV only flag packages known to be malicious; the age check only holds
/// back new ones.
fn malicious(&self) -> bool {
self.filter != "age"
}
/// Malware is never allowed, and a new package only by version.
fn trustable(&self) -> bool {
!self.malicious() && self.version.is_some()
}
}
impl Tray {
fn update(&mut self) {
self.state = service_status().map(|status| status.current_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());
}
/// The service hands each block over once, so whichever tray asks first shows it.
fn collect_blocks(&mut self) {
let allowed: Vec<_> = self.allowed.1.try_iter().collect();
if !allowed.is_empty() {
self.blocked.retain(|blocked| !allowed.contains(blocked));
self.list_blocked();
}
if self.state != Some(ServiceState::Running) {
return;
}
let Ok(reply) = ask_service("blocks") else {
return;
};
let Ok(packages) = serde_json::from_str::<Vec<BlockedPackage>>(&reply) else {
return;
};
let mut new = Vec::new();
for package in packages {
if !self.blocked.contains(&package) {
self.blocked.push(package.clone());
new.push(package);
}
}
if new.is_empty() {
return;
}
let excess = self.blocked.len().saturating_sub(BLOCKED_KEPT);
self.blocked.drain(..excess);
self.list_blocked();
// Off the UI thread, so the menu keeps working while they are open.
let allowed = self.allowed.0.clone();
std::thread::spawn(move || {
for package in new {
if alert(&package) {
allow_once(package, &allowed);
}
}
});
}
fn list_blocked(&self) {
while self.menu.items().len() > BLOCKED_AT + 2 {
self.menu.remove_at(BLOCKED_AT);
}
let _ = self.menu.insert(&self.blocked_separator, BLOCKED_AT);
for (i, package) in self.blocked.iter().enumerate() {
let verb = if package.malicious() {
"Blocked"
} else {
"Held back"
};
let reason = if package.malicious() {
format!("Malicious: {}", package.reason)
} else {
package.reason.clone()
};
let reason = MenuItem::new(reason, false, None);
let once = MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", true, None);
let items: &[&dyn IsMenuItem] = if package.trustable() {
&[&reason, &once]
} else {
&[&reason]
};
let label = format!("{verb} {}", package.label());
if let Ok(item) = Submenu::with_items(label, true, items) {
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i);
}
}
}
fn on_menu(&self, id: &str) {
if let Some(i) = id.strip_prefix(ALLOW_ONCE).and_then(|i| i.strip_prefix(':')) {
let package = i.parse().ok().and_then(|i: usize| self.blocked.get(i));
if let Some(package) = package.cloned() {
let allowed = self.allowed.0.clone();
std::thread::spawn(move || allow_once(package, &allowed));
}
return;
}
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);
}
}
/// Whether the user chose to allow it once.
fn alert(package: &BlockedPackage) -> bool {
let text = format!("{} {}.", package.label(), package.reason);
let (title, text, style) = if package.malicious() {
let title = format!("Blocked malware: {}", package.label());
let text = text
+ "\n\nIt 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.";
(title, text, MB_ICONERROR)
} else if !package.trustable() {
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
} else {
let text = text + "\n\nAllow it once?";
("Held back a new package".to_owned(), text, MB_YESNO | MB_ICONINFORMATION)
};
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) == IDYES }
}
/// Tells the user when the install had already stopped waiting.
fn allow_once(package: BlockedPackage, allowed: &Sender<BlockedPackage>) {
let reply = match ask_service(&format!("{ALLOW_ONCE} {}", package.id)) {
Ok(reply) => reply,
Err(error) => return show_error(error),
};
match reply.as_str() {
"ok" => {}
"late" => {
let title = format!("Allowed {} for the next minute", package.label());
let text = "The install had already stopped waiting for it. Run it again to finish.";
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
unsafe { MessageBoxW(None, &text, &title, MB_ICONINFORMATION | MB_SETFOREGROUND) };
}
other => return show_error(other.into()),
}
let _ = allowed.send(package);
}
/// One request per connection, as the service answers.
fn ask_service(request: &str) -> Result<String, BoxError> {
let mut pipe = std::fs::OpenOptions::new()
.read(true)
.write(true)
.open(PIPE)?;
// Any program can create the pipe if it gets there before the service.
let mut server = 0;
unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut server)? };
if service_status().and_then(|status| status.process_id) != Some(server) {
return Err("Another program is posing as the firewall service.".into());
}
pipe.write_all(format!("{request}\n").as_bytes())?;
let mut reply = String::new();
BufReader::new(pipe).read_line(&mut reply)?;
Ok(reply.trim_end().to_owned())
}
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_status() -> Option<ServiceStatus> {
let manager =
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
let service = manager
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
.ok()?;
service.query_status().ok()
}
fn light_taskbar() -> bool {
let personalize = r"Software\Microsoft\Windows\CurrentVersion\Themes\Personalize";
let Ok(key) = CURRENT_USER.open(personalize) else {
return false;
};
key.get_u32("SystemUsesLightTheme")
.is_ok_and(|light| light == 1)
}
fn status_text(state: Option<ServiceState>) -> &'static str {
match state {
Some(ServiceState::Running) => "Screening downloads",
Some(ServiceState::Paused) => "Paused",
_ => "Not running",
}
}
fn open_service() -> Result<Service, BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::PAUSE_CONTINUE;
Ok(manager.open_service(SERVICE_NAME, access)?)
}
/// Starting again re-reads the config.
fn restart() -> Result<(), BoxError> {
let service = open_service()?;
if service.query_status()?.current_state != ServiceState::Stopped {
service.stop()?;
}
for _ in 0..60 {
if service.query_status()?.current_state == ServiceState::Stopped {
return Ok(service.start::<&str>(&[])?);
}
std::thread::sleep(Duration::from_millis(500));
}
Err("The service did not stop.".into())
}
/// The alpha channel of the RGBA logo, and its width.
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
let mut rgba = vec![0; reader.output_buffer_size().ok_or("logo too large")?];
let info = reader.next_frame(&mut rgba)?;
let mut alpha = Vec::with_capacity(rgba.len() / 4);
for pixel in rgba.chunks(4) {
alpha.push(pixel[3]);
}
Ok((alpha, info.width))
}
/// White on a dark taskbar and black on a light one, as macOS draws a template image; dimmed
/// when not screening.
fn logo_rgba(mask: &[u8], light: bool, screening: bool) -> Vec<u8> {
let shade = if light { 0 } else { 255 };
let mut rgba = Vec::with_capacity(mask.len() * 4);
for &alpha in mask {
let alpha = if screening { alpha } else { alpha / 2 };
rgba.extend([shade, shade, shade, alpha]);
}
rgba
}
-41
View File
@@ -1,41 +0,0 @@
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,
}
}
-72
View File
@@ -1,72 +0,0 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use crate::policies::registries::{Published, Registry};
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::USER_AGENT;
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
pub struct Age {
min: Duration,
}
impl Age {
pub fn new(hours: u64) -> Self {
Age { min: Duration::hours(hours as i64) }
}
}
impl Filter for Age {
fn name(&self) -> &'static str {
"age"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
let Some(published) = &registry.published else { return Verdict::Allow };
let published_at = match published_at(published, &pkg.name, version).await {
Ok(at) => at,
Err(e) => return Verdict::Unknown(e),
};
let age = OffsetDateTime::now_utc() - published_at;
if age >= self.min {
return Verdict::Allow;
}
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
})
}
}
async fn published_at(
published: &Published,
name: &str,
version: &str,
) -> Result<OffsetDateTime, String> {
let name = match published.escape {
Some(escape) => escape(name),
None => name.to_string(),
};
let url = published.url.replace("{name}", &name).replace("{version}", version);
let pointer = published.pointer.replace("{version}", version);
let client = EasyHttpWebClient::default();
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
let response = response.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
}
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
let body: Value =
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
return Err(format!("{url} gave no publish time"));
};
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
}
+34 -16
View File
@@ -1,9 +1,9 @@
use super::{Filter, Verdict, VerdictFuture, AGENT}; use super::{Filter, Verdict, VerdictFuture};
use crate::config::DenylistSource; use crate::config::DenylistSource;
use crate::policies::registries::Registry; use crate::policies::registries::Registry;
use crate::policies::Package; use crate::policies::Package;
use rama::http::client::EasyHttpWebClient; use rama::http::client::EasyHttpWebClient;
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT}; use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::service::client::HttpClientExt; use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode}; use rama::http::{BodyExtractExt, StatusCode};
use semver::Version; use semver::Version;
@@ -14,6 +14,9 @@ use tokio::sync::OnceCell;
use tokio::task::JoinHandle; use tokio::task::JoinHandle;
use tracing::{info, warn}; use tracing::{info, warn};
/// The GitHub API turns away requests without a user agent.
const AGENT: &str = "strix-firewall";
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub enum AffectedVersions { pub enum AffectedVersions {
All, All,
@@ -37,15 +40,20 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
pub struct Denylist { pub struct Denylist {
source: DenylistSource, source: DenylistSource,
token: Option<String>,
lists: Arc<Lists>, lists: Arc<Lists>,
watch: JoinHandle<()>, watch: JoinHandle<()>,
} }
impl Denylist { impl Denylist {
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self { pub fn new(
source: DenylistSource,
token: Option<String>,
ecosystems: impl Iterator<Item = &'static str>,
) -> Self {
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect()); let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists))); let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
Denylist { source, lists, watch } Denylist { source, token, lists, watch }
} }
} }
@@ -64,7 +72,7 @@ impl Filter for Denylist {
Box::pin(async move { Box::pin(async move {
let eco = registry.osv_name; let eco = registry.osv_name;
let load = || async { let load = || async {
RwLock::new(Arc::new(load(&self.source, eco).await)) RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
}; };
let list = self.lists[eco].get_or_init(load).await; let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock")); let list = Arc::clone(&list.read().expect("denylist lock"));
@@ -142,12 +150,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. /// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) { async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs.get()); let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, None).await; let mut seen = revision(&source, token.as_deref(), None).await;
loop { loop {
tokio::time::sleep(period).await; tokio::time::sleep(period).await;
let changed = revision(&source, seen.as_deref()).await; let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
if let Some(current) = &changed { if let Some(current) = &changed {
info!(revision = %current, "denylist source changed"); info!(revision = %current, "denylist source changed");
seen = changed.clone(); seen = changed.clone();
@@ -158,7 +166,7 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
if changed.is_none() && !failed { if changed.is_none() && !failed {
continue; continue;
} }
if let Ok(packages) = load(&source, eco).await { if let Ok(packages) = load(&source, token.as_deref(), eco).await {
*list.write().expect("denylist lock") = Arc::new(Ok(packages)); *list.write().expect("denylist lock") = Arc::new(Ok(packages));
} }
} }
@@ -166,9 +174,16 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
} }
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit. /// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> { async fn revision(
source: &DenylistSource,
token: Option<&str>,
seen: Option<&str>,
) -> Option<String> {
let client = EasyHttpWebClient::default(); let client = EasyHttpWebClient::default();
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT); 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 { if let Some(seen) = seen {
request = request.header(IF_NONE_MATCH, seen); request = request.header(IF_NONE_MATCH, seen);
} }
@@ -185,8 +200,8 @@ async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String>
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned) response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
} }
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist { async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, &eco.to_lowercase()).await; let list = fetch(source, token, &eco.to_lowercase()).await;
match &list { match &list {
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"), Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
Err(error) => warn!(%eco, %error, "denylist unavailable"), Err(error) => warn!(%eco, %error, "denylist unavailable"),
@@ -194,10 +209,13 @@ async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
list list
} }
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist { async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
let url = format!("{}/{eco}", source.base); let url = format!("{}/{eco}", source.base);
let client = EasyHttpWebClient::default(); let client = EasyHttpWebClient::default();
let request = client.get(&url).header(USER_AGENT, AGENT); let mut request = client.get(&url).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(|e| e.to_string())?; let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK { if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status())); return Err(format!("{url} replied {}", response.status()));
-28
View File
@@ -1,16 +1,11 @@
pub mod age;
pub mod denylist; pub mod denylist;
pub mod osv; pub mod osv;
use super::registries::{Package, Registry}; use super::registries::{Package, Registry};
use crate::config::AllowedPackage;
use crate::Firewall; use crate::Firewall;
use std::future::Future; use std::future::Future;
use std::pin::Pin; 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>>; pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
/// `Unknown` means the filter could not check the package. /// `Unknown` means the filter could not check the package.
@@ -26,19 +21,13 @@ pub trait Filter: Send + Sync {
} }
/// Asks every filter. A `Deny` wins; failing that, the first filter that could not tell. /// Asks every filter. A `Deny` wins; failing that, the first filter that could not tell.
/// An allowed package skips only the age check, so malware is never let through.
pub async fn consult( pub async fn consult(
firewall: &Firewall, firewall: &Firewall,
registry: &'static Registry, registry: &'static Registry,
pkg: &Package, pkg: &Package,
) -> Option<(&'static str, Verdict)> { ) -> Option<(&'static str, Verdict)> {
let lifted = allowed(firewall, registry, pkg)
|| firewall.age_check_lifted(registry.eco, |entry| matches(registry, pkg, entry));
let mut unsure = None; let mut unsure = None;
for filter in firewall.filters() { for filter in firewall.filters() {
if lifted && filter.name() == "age" {
continue;
}
match filter.check(registry, pkg).await { match filter.check(registry, pkg).await {
Verdict::Allow => continue, Verdict::Allow => continue,
Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))), Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))),
@@ -48,20 +37,3 @@ pub async fn consult(
} }
unsure 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| matches(registry, pkg, entry))
}
fn matches(registry: &Registry, pkg: &Package, entry: &AllowedPackage) -> bool {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
}
+1 -3
View File
@@ -41,9 +41,7 @@ impl Filter for Osv {
if hits.is_empty() { if hits.is_empty() {
Verdict::Allow Verdict::Allow
} else { } else {
let links: Vec<String> = Verdict::Deny(format!("is flagged malicious by OSV ({})", hits.join(", ")))
hits.iter().map(|id| format!("{id} https://osv.dev/vulnerability/{id}")).collect();
Verdict::Deny(format!("is flagged malicious by OSV: {}", links.join(", ")))
} }
}) })
} }
+1 -8
View File
@@ -69,16 +69,9 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
}; };
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening"); info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
match consult(firewall, registry, &pkg).await { match consult(firewall, registry, &pkg).await {
Some((filter, Verdict::Deny(reason))) => { Some((_, Verdict::Deny(reason))) => {
if crate::blocks::block(registry.eco, &pkg, filter, &reason).await {
info!(eco = registry.eco, pkg = %pkg.label(), "allowed while held");
return None;
}
Some(format!("{} {reason}", pkg.label())) 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, _ => None,
} }
} }
+1 -8
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry}; use super::{Pattern, Registry};
pub static CARGO_INDEX: Registry = Registry { pub static CARGO_INDEX: Registry = Registry {
prefix: "/cargo/index/", prefix: "/cargo/index/",
@@ -16,7 +16,6 @@ pub static CARGO_INDEX: Registry = Registry {
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc. // Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
Pattern { re: r"(?<name>[^/]+)$", rewrite: None }, Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
], ],
published: None,
}; };
pub static CARGO_DL: Registry = Registry { pub static CARGO_DL: Registry = Registry {
@@ -27,11 +26,6 @@ pub static CARGO_DL: Registry = Registry {
index: false, index: false,
normalize: None, normalize: None,
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: 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. /// `cargo add` resolves versions here, named by config.json's "api" field.
@@ -43,5 +37,4 @@ pub static CRATES_API: Registry = Registry {
index: false, index: false,
normalize: None, normalize: None,
patterns: &[], patterns: &[],
published: None,
}; };
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry}; use super::{Pattern, Registry};
pub static GEM: Registry = Registry { pub static GEM: Registry = Registry {
prefix: "/gem/", prefix: "/gem/",
@@ -16,9 +16,4 @@ pub static GEM: Registry = Registry {
// The compact index, which names no version. // The compact index, which names no version.
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None }, 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 -17
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry}; use super::{Pattern, Registry};
pub static GO: Registry = Registry { pub static GO: Registry = Registry {
prefix: "/go/", prefix: "/go/",
@@ -13,11 +13,6 @@ pub static GO: Registry = Registry {
// The version list and the latest lookup, which name no version. // The version list and the latest lookup, which name no version.
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None }, 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. /// The proxy protocol writes every capital as !<lowercase>; advisories do not.
@@ -32,14 +27,3 @@ fn unescape(path: &str) -> String {
} }
out 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
}
+1 -15
View File
@@ -36,7 +36,7 @@ pub struct Pattern {
} }
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`. /// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
#[derive(Debug, Clone)] #[derive(Debug)]
pub struct Registry { pub struct Registry {
pub prefix: &'static str, pub prefix: &'static str,
pub eco: &'static str, pub eco: &'static str,
@@ -48,26 +48,12 @@ pub struct Registry {
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither. /// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
pub normalize: Option<fn(&str) -> String>, pub normalize: Option<fn(&str) -> String>,
pub patterns: &'static [Pattern], pub patterns: &'static [Pattern],
pub published: Option<Published>,
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug, Clone)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
pub escape: Option<fn(&str) -> String>,
} }
impl Registry { impl Registry {
pub fn host(&self) -> &'static str { pub fn host(&self) -> &'static str {
host_of(self.upstream) host_of(self.upstream)
} }
/// What the config file calls it.
pub fn name(&self) -> &'static str {
self.prefix.trim_matches('/')
}
} }
/// The host of an https:// URL, with the path dropped. /// The host of an https:// URL, with the path dropped.
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry}; use super::{Pattern, Registry};
pub static NPM: Registry = Registry { pub static NPM: Registry = Registry {
prefix: "/npm/", prefix: "/npm/",
@@ -18,9 +18,4 @@ pub static NPM: Registry = Registry {
rewrite: Some(("https://registry.npmjs.org/", "/npm/")), rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
}, },
], ],
published: Some(Published {
url: "https://registry.npmjs.org/{name}",
pointer: "/time/{version}",
escape: None,
}),
}; };
+1 -7
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry}; use super::{Pattern, Registry};
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/"); const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
@@ -13,7 +13,6 @@ pub static PYPI: Registry = Registry {
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) }, Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
Pattern { re: "", rewrite: Some(FILES) }, Pattern { re: "", rewrite: Some(FILES) },
], ],
published: None,
}; };
/// The directories are hash shards, but the filename names the distribution. /// The directories are hash shards, but the filename names the distribution.
@@ -36,11 +35,6 @@ pub static PYPI_FILES: Registry = Registry {
rewrite: None, 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. /// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
+2 -6
View File
@@ -1,6 +1,6 @@
use crate::http::{blocked, reply, text}; use crate::http::{blocked, reply, text};
use crate::policies::{self, Check, Decision}; use crate::policies::{self, Check, Decision};
use crate::{telemetry, Firewall, FirewallConfig}; use crate::{Firewall, FirewallConfig};
use rama::http::body::util::BodyExt; use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient; use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE; use rama::http::header::CONTENT_TYPE;
@@ -15,11 +15,10 @@ use std::sync::Arc;
use tracing::{info, info_span, warn, Instrument}; use tracing::{info, info_span, warn, Instrument};
pub fn serve(config: FirewallConfig) -> std::io::Result<()> { pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?; let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?;
bound.set_nonblocking(true)?; bound.set_nonblocking(true)?;
crate::runtime()?.block_on(async move { crate::runtime()?.block_on(async move {
let firewall = Arc::new(Firewall::new(config)); let firewall = Arc::new(Firewall::new(config));
telemetry::heartbeat();
let exec = Executor::default(); let exec = Executor::default();
let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?; let listener = TcpListener::try_from_std_tcp_listener(bound, exec.clone())?;
let server = HttpServer::auto(exec).service(service_fn(move |req: Request| { let server = HttpServer::auto(exec).service(service_fn(move |req: Request| {
@@ -45,7 +44,6 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
} }
Decision::Block { eco, pkg, filter, reason } => { Decision::Block { eco, pkg, filter, reason } => {
let reason = format!("{} {reason}", pkg.label()); let reason = format!("{} {reason}", pkg.label());
telemetry::screened_package(eco, &pkg, Some(&reason));
warn!(%eco, %filter, "blocked by firewall: {reason}"); warn!(%eco, %filter, "blocked by firewall: {reason}");
blocked(&reason) blocked(&reason)
} }
@@ -53,11 +51,9 @@ async fn screen(target: String, firewall: &Firewall) -> Response {
match &check { match &check {
Check::NoPackage => {} Check::NoPackage => {}
Check::Allowed(pkg) => { Check::Allowed(pkg) => {
telemetry::screened_package(target.eco, pkg, None);
info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter") info!(eco = %target.eco, pkg = %pkg.label(), "cleared by every filter")
} }
Check::Unverified { pkg, filter, error } => { Check::Unverified { pkg, filter, error } => {
telemetry::screened_package(target.eco, pkg, None);
info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing") info!(pkg = %pkg.label(), %filter, %error, "filter could not check, allowing")
} }
} }
+7 -20
View File
@@ -1,11 +1,10 @@
use crate::policies::Package;
use crate::{Firewall, FirewallConfig}; use crate::{Firewall, FirewallConfig};
use rama::http::client::EasyHttpWebClient; use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_TYPE; use rama::http::header::CONTENT_TYPE;
use rama::http::service::client::HttpClientExt; use rama::http::service::client::HttpClientExt;
use rama::rt::blocking::Runtime; use rama::rt::blocking::Runtime;
use serde_json::{json, Value}; use serde_json::{json, Value};
use std::sync::{LazyLock, Mutex, OnceLock}; use std::sync::{LazyLock, Mutex};
use std::time::Duration; use std::time::Duration;
use time::format_description::well_known::Rfc3339; use time::format_description::well_known::Rfc3339;
use time::OffsetDateTime; use time::OffsetDateTime;
@@ -30,40 +29,29 @@ 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 { struct Telemetry {
key: &'static str, key: &'static str,
session: String, session: String,
events: Mutex<Vec<Value>>, events: Mutex<Vec<Value>>,
} }
pub fn screened(firewall: &Firewall, url: &str, reason: Option<&str>) { pub fn screened(firewall: &Firewall, url: &str, blocked: bool) {
if TELEMETRY.is_none() { if TELEMETRY.is_none() {
return; return;
} }
let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else { let Some((registry, rest)) = firewall.match_on(url, |r| r.upstream) else {
return; return;
}; };
if let Some(pkg) = firewall.resolve(registry, rest).pkg { let Some(pkg) = firewall.resolve(registry, rest).pkg else {
screened_package(registry.eco, &pkg, reason); return;
} };
} let mut properties = json!({ "eco": registry.eco, "blocked": blocked });
if blocked {
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["package"] = pkg.label().into();
properties["reason"] = reason.into();
} }
record("package_screened", properties); record("package_screened", properties);
} }
pub fn set_mode(mode: &'static str) {
MODE.set(mode).ok();
}
pub fn error(kind: &'static str) { pub fn error(kind: &'static str) {
record("error", json!({ "kind": kind })); record("error", json!({ "kind": kind }));
} }
@@ -93,7 +81,6 @@ pub fn flush() {
fn record(event: &str, mut properties: Value) { fn record(event: &str, mut properties: Value) {
let Some(telemetry) = &*TELEMETRY else { return }; let Some(telemetry) = &*TELEMETRY else { return };
properties["distinct_id"] = telemetry.session.clone().into(); properties["distinct_id"] = telemetry.session.clone().into();
properties["mode"] = MODE.get().copied().into();
properties["os"] = std::env::consts::OS.into(); properties["os"] = std::env::consts::OS.into();
properties["arch"] = std::env::consts::ARCH.into(); properties["arch"] = std::env::consts::ARCH.into();
properties["$process_person_profile"] = false.into(); properties["$process_person_profile"] = false.into();
-27
View File
@@ -1,27 +0,0 @@
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:?}");
}
});
}
-27
View File
@@ -1,27 +0,0 @@
use firewall::blocks::{answer, block};
use firewall::policies::Package;
use firewall::Firewall;
use std::path::Path;
use std::time::Duration;
#[tokio::test]
async fn allowing_once_releases_the_held_download() {
let firewall = Firewall::default();
let config = Path::new("/nonexistent/config.json");
let pkg = Package { name: "left-pad".into(), version: Some("1.3.0".into()) };
assert!(!block("npm", &pkg, "age", "is new").await, "held while nothing was asking");
answer(&firewall, config, "blocks");
let held = tokio::spawn(async move { block("npm", &pkg, "age", "is new").await });
let id = loop {
let blocks: serde_json::Value = serde_json::from_str(&answer(&firewall, config, "blocks")).unwrap();
if let Some(id) = blocks[0]["id"].as_u64() {
break id;
}
tokio::time::sleep(Duration::from_millis(10)).await;
};
assert_eq!(answer(&firewall, config, &format!("allow-once {id}")), "ok");
assert!(held.await.unwrap(), "the waiting download goes through");
assert!(firewall.age_check_lifted("npm", |p| p.name == "left-pad"), "and the rest of the install");
assert!(!block("npm", &Package { name: "evil".into(), version: None }, "denylist", "is malware").await);
}
+4 -105
View File
@@ -1,13 +1,12 @@
use firewall::config::{path, AllowedPackage}; use firewall::config::path;
use firewall::policies::filters::consult; use firewall::FirewallConfig;
use firewall::policies::{screen, Package};
use firewall::{Firewall, FirewallConfig};
#[test] #[test]
fn a_file_only_has_to_set_what_it_changes() { fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses"); let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open); assert!(!config.fail_open);
assert_eq!(config.port.get(), firewall::DEFAULT_PORT); assert_eq!(config.port, firewall::DEFAULT_PORT);
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
} }
#[test] #[test]
@@ -15,108 +14,8 @@ fn a_misspelled_setting_is_an_error() {
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err()); assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
} }
#[test]
fn nonsense_values_are_errors() {
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
for bad in [
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
r#"{"denylist": {"poll_secs": 0}}"#,
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
r#"{"denylist": {"revision": "https://"}}"#,
r#"{"port": 0}"#,
r#"{"min_age_hours": 18446744073709551615}"#,
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
] {
std::fs::write(&file, bad).expect("write");
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
}
std::fs::remove_file(&file).ok();
}
#[test] #[test]
fn the_config_lives_under_the_config_directory() { fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory"); let path = path().expect("a home directory");
assert!(path.ends_with("config.json"), "{}", path.display()); assert!(path.ends_with("config.json"), "{}", path.display());
} }
#[test]
fn a_later_file_wins_and_allow_lists_add_up() {
let dir = std::env::temp_dir().join(format!("firewall-layers-{}", std::process::id()));
std::fs::create_dir_all(&dir).expect("temp dir");
let user = dir.join("user.json");
let system = dir.join("system.json");
std::fs::write(&user, r#"{"min_age_hours": 1, "fail_open": false, "allow": {"npm": [{"name": "a"}]}}"#).expect("write");
std::fs::write(&system, r#"{"min_age_hours": 24, "allow": {"npm": [{"name": "b"}]}}"#).expect("write");
let missing = dir.join("missing.json");
let config = FirewallConfig::load_from(&[user, system, missing]).expect("loads");
std::fs::remove_dir_all(&dir).ok();
assert_eq!(config.min_age_hours, 24);
assert!(!config.fail_open);
let names: Vec<&str> = config.allow["npm"].iter().map(|p| p.name.as_str()).collect();
assert_eq!(names, ["a", "b"]);
}
#[tokio::test]
async fn a_mirror_is_screened_like_the_registry_it_copies() {
let config = r#"{"registries": {"npm": ["https://npm.example.com/"]}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
assert!(firewall.hosts().contains(&"npm.example.com"));
let url = "https://npm.example.com/left-pad/-/left-pad-1.3.0.tgz";
let (registry, rest) = firewall.match_on(url, |r| r.upstream).expect("the mirror");
assert_eq!(firewall.resolve(registry, rest).pkg.map(|p| p.label()), Some("[email protected]".into()));
let unknown = std::env::temp_dir().join(format!("firewall-mirror-{}.json", std::process::id()));
std::fs::write(&unknown, r#"{"registries": {"nmp": ["https://npm.example.com/"]}}"#).expect("write");
let loaded = FirewallConfig::load_from(std::slice::from_ref(&unknown));
std::fs::remove_file(&unknown).ok();
assert!(loaded.is_err());
}
#[tokio::test]
async fn an_allowed_version_is_still_checked_for_malware() {
// The unreachable denylist shows that it still runs.
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 = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
let verdict = consult(&firewall, registry, &pkg).await;
assert_eq!(verdict.map(|(filter, _)| filter), Some("denylist"));
}
#[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:?}");
}
#[tokio::test]
async fn lifting_the_age_check_still_checks_for_malware() {
let config = r#"{"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 = Package { name: "golang.org/x/net".into(), version: Some("0.20.0".into()) };
firewall.lift_age_check("go", AllowedPackage { name: pkg.name.clone(), version: None }, None);
let (filter, _) = consult(&firewall, registry, &pkg).await.expect("the denylist still runs");
assert_eq!(filter, "denylist");
}
#[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", "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"));
}
+1 -22
View File
@@ -1,9 +1,7 @@
use firewall::aliasing::forward::downgrade; use firewall::aliasing::forward::downgrade;
use firewall::http::https_url; use firewall::http::https_url;
use firewall::{aliasing, Firewall, FirewallConfig}; use firewall::Firewall;
use rama::http::{Body, Request}; use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request { fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target); let mut builder = Request::builder().method("GET").uri(target);
@@ -37,25 +35,6 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None); 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] #[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() { async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec(); let text = b"https://pypi.org/x https://example.com/y".to_vec();
-7
View File
@@ -43,10 +43,3 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none()); 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");
}
-21
View File
@@ -1,21 +0,0 @@
#![cfg(windows)]
use firewall::mitm::CertificateAuthority;
use firewall::os::windows::CaKey;
use rcgen::PublicKeyData;
/// Skipped on machines without a TPM.
#[test]
fn key_signs_a_verifiable_ca() {
let key = match CaKey::create_test_key() {
Ok(key) => key,
Err(error) => return eprintln!("skipping, no TPM key: {error}"),
};
let ca = CertificateAuthority::generate(&["registry.npmjs.org"], &key);
let spki = key.subject_public_key_info();
key.delete_from_tpm().expect("delete the test key");
let ca = ca.expect("mint a ca");
let public = ca.crt.public_key().expect("ca public key");
assert!(ca.crt.verify(&public).expect("verify the ca"));
assert_eq!(public.public_key_to_der().unwrap(), spki);
}
-193
View File
@@ -1,193 +0,0 @@
<?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>
+1 -4
View File
@@ -4,10 +4,7 @@ members = ["cargo:core"]
[dist] [dist]
cargo-dist-version = "0.32.0" cargo-dist-version = "0.32.0"
ci = "github" ci = "github"
installers = ["shell", "powershell", "homebrew", "msi"] installers = ["shell", "powershell", "homebrew"]
# core/wix/main.wxs is edited to install the service.
allow-dirty = ["msi"]
features = ["machine-wide"]
tap = "usestrix/homebrew-tap" tap = "usestrix/homebrew-tap"
publish-jobs = ["homebrew", "./publish-crates"] 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"] targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
+2 -2
View File
@@ -3,5 +3,5 @@ Firewall.xcodeproj
.env .env
Extension/Extension.entitlements Extension/Extension.entitlements
Extension/Info.plist Extension/Info.plist
App/App.entitlements Installer/Installer.entitlements
App/Info.plist Installer/Info.plist
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

-24
View File
@@ -1,24 +0,0 @@
{
"fill" : {
"solid" : "srgb:0.07000,0.07000,0.07000,1.00000"
},
"groups" : [
{
"layers" : [
{
"image-name" : "logo.png",
"name" : "logo",
"position" : {
"scale" : 1.8,
"translation-in-points" : [0, 0]
}
}
]
}
],
"supported-platforms" : {
"squares" : [
"macOS"
]
}
}
-3
View File
@@ -1,3 +0,0 @@
{
"info" : { "author" : "xcode", "version" : 1 }
}
-93
View File
@@ -1,93 +0,0 @@
import AppKit
import Security
struct BlockedPackage: Codable, Hashable, Identifiable {
let id: UInt64
let eco: String
let name: String
let version: String?
let filter: String
let reason: String
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 }
}
enum Allowance {
/// For a minute, so the rest of that install can fetch it too.
case once
case always
}
@MainActor
enum BlockAlert {
static func show(_ package: BlockedPackage) -> Allowance? {
let alert = NSAlert()
alert.addButton(withTitle: "OK")
if package.malicious {
alert.alertStyle = .critical
alert.messageText = "Blocked malware: \(package.label) (\(package.eco))"
setText(alert, "\(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"
setText(alert, "\(package.label) (\(package.eco)) \(package.reason).")
if package.trustable {
alert.addButton(withTitle: "Allow Once")
alert.addButton(withTitle: "Trust this version")
}
}
NSApp.activate(ignoringOtherApps: true)
switch alert.runModal() {
case .alertSecondButtonReturn: return .once
case .alertThirdButtonReturn: return .always
default: return nil
}
}
static func late(_ package: BlockedPackage, _ allowance: Allowance) {
let alert = NSAlert()
alert.messageText = allowance == .once ? "Allowed \(package.label) for the next minute" : "Trusted \(package.label)"
alert.informativeText = "The install had already stopped waiting for it. Run it again to finish."
NSApp.activate(ignoringOtherApps: true)
alert.runModal()
}
/// An alert's own text is plain, so this text goes in a selectable field, where its links open on click.
private static func setText(_ alert: NSAlert, _ text: String) {
let linked = NSMutableAttributedString(string: text, attributes: [.font: NSFont.systemFont(ofSize: NSFont.smallSystemFontSize)])
let detector = try! NSDataDetector(types: NSTextCheckingResult.CheckingType.link.rawValue)
for match in detector.matches(in: text, range: NSRange(text.startIndex..., in: text)) {
if let url = match.url { linked.addAttribute(.link, value: url, range: match.range) }
}
let field = NSTextField(wrappingLabelWithString: "")
field.allowsEditingTextAttributes = true
field.isSelectable = true
field.attributedStringValue = linked
field.preferredMaxLayoutWidth = 300
field.frame.size = field.fittingSize
alert.accessoryView = field
}
}
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
}
}
}
}
-7
View File
@@ -1,7 +0,0 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "com.usestrix.firewall"
static let extensionBundleID = "com.usestrix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
-49
View File
@@ -1,49 +0,0 @@
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("Allow Once") {
Task { await model.allow(package, .once) }
}
.disabled(model.busy || !package.trustable)
Button("Trust this version") {
Task { await model.allow(package, .always) }
}
.disabled(model.busy || !package.trustable)
}
} else {
Menu("Blocked \(package.label) (\(package.eco))") {
Text("Malicious: \(package.reason)")
}
}
}
}
}
}
@@ -1,7 +1,7 @@
import SwiftUI import SwiftUI
struct ContentView: View { struct ContentView: View {
@ObservedObject var model: FirewallModel @ObservedObject var model: InstallerModel
@Environment(\.dismiss) private var dismiss @Environment(\.dismiss) private var dismiss
var body: some View { var body: some View {
@@ -10,9 +10,11 @@ struct ContentView: View {
Divider() Divider()
steps steps
Divider() Divider()
transcript
Divider()
footer footer
} }
.frame(width: 540) .frame(width: 540, height: 560)
.task { await model.refresh() } .task { await model.refresh() }
.onAppear(perform: recenterIfOffscreen) .onAppear(perform: recenterIfOffscreen)
.onChange(of: model.completed) { done in .onChange(of: model.completed) { done in
@@ -36,11 +38,9 @@ struct ContentView: View {
private var header: some View { private var header: some View {
HStack(spacing: 14) { HStack(spacing: 14) {
Image(nsImage: NSImage(named: "logo-transparent")!) Image(systemName: "shield.lefthalf.filled")
.renderingMode(.template) .font(.system(size: 34, weight: .light))
.resizable() .foregroundStyle(.tint)
.scaledToFit()
.frame(height: 40)
VStack(alignment: .leading, spacing: 3) { VStack(alignment: .leading, spacing: 3) {
Text("Firewall").font(.title2.weight(.semibold)) Text("Firewall").font(.title2.weight(.semibold))
Text("Screens package downloads before they reach your machine.") Text("Screens package downloads before they reach your machine.")
@@ -70,6 +70,27 @@ struct ContentView: View {
.padding(20) .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 { private var footer: some View {
HStack { HStack {
if model.busy { ProgressView().controlSize(.small) } if model.busy { ProgressView().controlSize(.small) }
@@ -84,11 +105,8 @@ struct ContentView: View {
.font(.callout) .font(.callout)
} }
Spacer() Spacer()
if model.awaitingApproval {
Button("Open System Settings") { model.openApprovalSettings() }
}
Button("Uninstall") { Task { await model.uninstall() } } Button("Uninstall") { Task { await model.uninstall() } }
.disabled(model.busy && !model.awaitingApproval) .disabled(model.busy)
Button("Install") { Task { await model.install() } } Button("Install") { Task { await model.install() } }
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
.disabled(model.busy || model.completed) .disabled(model.busy || model.completed)
@@ -111,10 +129,9 @@ private struct StepRow: View {
Text(title).font(.body.weight(.medium)) Text(title).font(.body.weight(.medium))
Text(note).font(.caption).foregroundStyle(.secondary) Text(note).font(.caption).foregroundStyle(.secondary)
Text(status.detail) Text(status.detail)
.font(font) .font(.caption)
.foregroundStyle(tint) .foregroundStyle(tint)
.textSelection(.enabled) .textSelection(.enabled)
.fixedSize(horizontal: false, vertical: true)
} }
Spacer() Spacer()
} }
@@ -129,7 +146,7 @@ private struct StepRow: View {
.foregroundStyle(.secondary) .foregroundStyle(.secondary)
.frame(width: 20, height: 20) .frame(width: 20, height: 20)
.overlay(Circle().strokeBorder(.secondary.opacity(0.4))) .overlay(Circle().strokeBorder(.secondary.opacity(0.4)))
case .running, .needsAction: case .running:
ProgressView().controlSize(.small) ProgressView().controlSize(.small)
case .done: case .done:
Image(systemName: "checkmark.circle.fill").foregroundStyle(.green) Image(systemName: "checkmark.circle.fill").foregroundStyle(.green)
@@ -138,15 +155,9 @@ private struct StepRow: View {
} }
} }
private var font: Font {
if case .needsAction = status { return .system(size: 14) }
return .caption
}
private var tint: Color { private var tint: Color {
switch status { switch status {
case .pending, .running: return .secondary case .pending, .running: return .secondary
case .needsAction: return .orange
case .done: return .green case .done: return .green
case .failed: return .orange case .failed: return .orange
} }
+7
View File
@@ -0,0 +1,7 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "ai.strix.firewall"
static let extensionBundleID = "ai.strix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
@@ -1,8 +1,8 @@
import SwiftUI import SwiftUI
@main @main
struct FirewallApp: App { struct InstallerApp: App {
@StateObject private var model = FirewallModel() @StateObject private var model = InstallerModel()
var body: some Scene { var body: some Scene {
MenuBarExtra { 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. /// An accessory app restores no windows, so setup is raised from the label, the one view built at launch.
private struct MenuBarLabel: View { private struct MenuBarLabel: View {
@ObservedObject var model: FirewallModel @ObservedObject var model: InstallerModel
@Environment(\.openWindow) private var openWindow @Environment(\.openWindow) private var openWindow
@State private var raised = false @State private var raised = false
@@ -7,15 +7,13 @@ import SystemExtensions
enum StepStatus: Equatable { enum StepStatus: Equatable {
case pending case pending
case running(String) case running(String)
/// Waiting on the user to do something outside the app.
case needsAction(String)
case done(String) case done(String)
case failed(String) case failed(String)
var detail: String { var detail: String {
switch self { switch self {
case .pending: return "Not started" case .pending: return "Not started"
case let .running(text), let .needsAction(text), let .done(text), let .failed(text): return text case let .running(text), let .done(text), let .failed(text): return text
} }
} }
@@ -25,38 +23,33 @@ enum StepStatus: Equatable {
} }
} }
enum FirewallError: Error, LocalizedError { enum InstallerError: Error, LocalizedError {
case extensionUnavailable case extensionUnavailable
case screeningDidNotStart(NEVPNStatus)
case extensionStopped(String)
case extensionRefused(String) case extensionRefused(String)
case screeningDidNotStart(NEVPNStatus)
var errorDescription: String? { var errorDescription: String? {
switch self { switch self {
case .extensionUnavailable: return "The extension is not running" case .extensionUnavailable: return "The extension is not running"
case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
case let .extensionRefused(reply): return "The extension replied \(reply)" case let .extensionRefused(reply): return "The extension replied \(reply)"
case let .extensionStopped(reason): case let .screeningDidNotStart(status): return "Screening did not start (\(status.label))"
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
} }
} }
} }
@MainActor @MainActor
final class FirewallModel: NSObject, ObservableObject { final class InstallerModel: NSObject, ObservableObject {
@Published private(set) var extensionStep = StepStatus.pending @Published private(set) var extensionStep = StepStatus.pending
@Published private(set) var screeningStep = StepStatus.pending @Published private(set) var screeningStep = StepStatus.pending
@Published private(set) var trustStep = StepStatus.pending @Published private(set) var trustStep = StepStatus.pending
@Published private var operations = 0 @Published private(set) var busy = false
var busy: Bool { operations > 0 }
@Published private(set) var awaitingApproval = false
@Published private(set) var completed = false @Published private(set) var completed = false
@Published private(set) var removed = false @Published private(set) var removed = false
@Published private(set) var transcript: [String] = []
@Published private(set) var screening = false @Published private(set) var screening = false
@Published private(set) var blocked: [BlockedPackage] = []
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer") private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)? private var pendingRequest: CheckedContinuation<Void, Error>?
private var statusObserver: NSObjectProtocol? private var statusObserver: NSObjectProtocol?
// NE only posts status notifications for connections that are still referenced. // NE only posts status notifications for connections that are still referenced.
@Published private var watched: NETransparentProxyManager? @Published private var watched: NETransparentProxyManager?
@@ -75,7 +68,6 @@ final class FirewallModel: NSObject, ObservableObject {
await refresh() await refresh()
if CommandLine.arguments.contains("--install") { await install() } if CommandLine.arguments.contains("--install") { await install() }
} }
Task { await watchBlocks() }
} }
// MARK: - Flows // MARK: - Flows
@@ -95,8 +87,8 @@ final class FirewallModel: NSObject, ObservableObject {
} }
func install() async { func install() async {
operations += 1 busy = true
defer { operations -= 1 } defer { busy = false }
removed = false removed = false
guard CompetingExtensions.confirmInstall() else { guard CompetingExtensions.confirmInstall() else {
@@ -118,43 +110,23 @@ final class FirewallModel: NSObject, ObservableObject {
completed = true completed = true
log("Setup complete") 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. /// After the admin gate, every stage runs even if an earlier one fails, so a partial uninstall never strands the rest.
func uninstall() async { func uninstall() async {
operations += 1 busy = true
defer { operations -= 1 } defer { busy = false }
completed = false completed = false
removed = 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. // Install needs admin, so uninstall does too: untrusting the CA prompts for it, and a cancel stops here.
guard await untrustAuthorities() else { guard await untrustAuthorities() else {
log("Uninstall cancelled") log("Uninstall cancelled")
return return
} }
// Stop screening first so the extension cannot mint a fresh CA after it is removed. // The extension owns the private key, so ask it before tearing down the session that carries the request.
let strandedKey = await removeStoredCA()
await removeConfiguration() await removeConfiguration()
let strandedKey = removeStoredCA()
await deactivate() await deactivate()
if let strandedKey { trustStep = .failed(strandedKey) } if let strandedKey { trustStep = .failed(strandedKey) }
@@ -178,16 +150,16 @@ final class FirewallModel: NSObject, ObservableObject {
/// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config. /// Restarting the tunnel rebuilds the provider, which is what makes it re-read the config.
func restart() async { func restart() async {
operations += 1 busy = true
defer { operations -= 1 } defer { busy = false }
guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return } guard await stage(\.screeningStep, running: "Restarting screening", work: restartScreening) else { return }
// A restart can mint a new CA when the screened hosts change. // A restart can mint a new CA when the screened hosts change.
_ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority) _ = await stage(\.trustStep, running: "Waiting for the extension's CA", work: trustCertificateAuthority)
} }
func setEnabled(_ enabled: Bool) async { func setEnabled(_ enabled: Bool) async {
operations += 1 busy = true
defer { operations -= 1 } defer { busy = false }
if enabled { if enabled {
_ = await stage(\.screeningStep, running: "Starting screening", work: startScreening) _ = await stage(\.screeningStep, running: "Starting screening", work: startScreening)
} else { } else {
@@ -195,48 +167,7 @@ final class FirewallModel: NSObject, ObservableObject {
} }
} }
func openApprovalSettings() { private func stage(_ step: ReferenceWritableKeyPath<InstallerModel, StepStatus>,
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) }
}
/// Trusting changes the config for every user, so it takes an admin.
func allow(_ package: BlockedPackage, _ allowance: Allowance) async {
guard package.trustable else { return }
if allowance == .always, !AdminAuthorization.confirm() {
log("Trust cancelled for \(package.label)")
return
}
do {
let reply = try await providerMessage("\(allowance == .once ? "allow-once" : "allow") \(package.id)")
guard reply == "ok" || reply == "late" else { throw FirewallError.extensionRefused(reply) }
blocked.removeAll { $0 == package }
log("Allowed \(package.label) (\(package.eco)) \(allowance)")
if reply == "late" { BlockAlert.late(package, allowance) }
} catch {
log("Could not allow \(package.label): \(error.localizedDescription)")
}
}
/// 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 let allowance = BlockAlert.show(package) { await allow(package, allowance) }
}
blocked = Array(blocked.suffix(10))
}
try? await Task.sleep(nanoseconds: 500_000_000)
}
}
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
running: String, running: String,
work: @MainActor () async throws -> String) async -> Bool { work: @MainActor () async throws -> String) async -> Bool {
self[keyPath: step] = .running(running) self[keyPath: step] = .running(running)
@@ -280,9 +211,6 @@ final class FirewallModel: NSObject, ObservableObject {
forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main)) forExtensionWithIdentifier: FirewallIDs.extensionBundleID, queue: .main))
extensionStep = .pending extensionStep = .pending
log("System extension deactivated, macOS finishes removing it on the next reboot") log("System extension deactivated, macOS finishes removing it on the next reboot")
} catch OSSystemExtensionError.extensionNotFound {
extensionStep = .pending
log("No system extension to deactivate")
} catch { } catch {
extensionStep = .failed(error.localizedDescription) extensionStep = .failed(error.localizedDescription)
log("Deactivation failed: \(error.localizedDescription)") log("Deactivation failed: \(error.localizedDescription)")
@@ -317,18 +245,17 @@ final class FirewallModel: NSObject, ObservableObject {
return true return true
} }
/// The extension wrote these as root, so the bundled firewall-uninstall removes them behind an admin prompt; returns what to report if it failed. /// Only root can delete the key, so the extension does it; returns what to report if it survived.
private func removeStoredCA() -> String? { private func removeStoredCA() async -> String? {
let uninstaller = Bundle.main.path(forAuxiliaryExecutable: "firewall-uninstall") ?? "" do {
let script = "do shell script quoted form of \"\(uninstaller)\" with administrator privileges" let reply = try await providerMessage("uninstall")
var error: NSDictionary? guard reply == "ok" else { throw InstallerError.extensionRefused(reply) }
NSAppleScript(source: script)?.executeAndReturnError(&error) log("Removed the CA, its exported copy and the env agent")
if let error { return nil
log("Could not remove the CA: \(error[NSAppleScript.errorMessage] ?? error)") } catch {
log("Could not remove the CA: \(error.localizedDescription)")
return "The CA private key is still in the System keychain" 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 // MARK: - System extension
@@ -336,18 +263,14 @@ final class FirewallModel: NSObject, ObservableObject {
private func submit(_ request: OSSystemExtensionRequest) async throws { private func submit(_ request: OSSystemExtensionRequest) async throws {
request.delegate = self request.delegate = self
try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<Void, Error>) in
pendingRequest = (request, continuation) pendingRequest = continuation
OSSystemExtensionManager.shared.submitRequest(request) OSSystemExtensionManager.shared.submitRequest(request)
} }
} }
/// Callbacks for an abandoned request can still arrive, so only the pending one is finished. private func finishRequest(_ result: Result<Void, Error>) {
private func finishRequest(_ request: OSSystemExtensionRequest, _ result: Result<Void, Error>) { pendingRequest?.resume(with: result)
guard let pending = pendingRequest, pending.request === request else { return }
pendingRequest = nil pendingRequest = nil
awaitingApproval = false
NSApp.activate(ignoringOtherApps: true)
pending.continuation.resume(with: result)
} }
// MARK: - Network extension // MARK: - Network extension
@@ -372,22 +295,16 @@ final class FirewallModel: NSObject, ObservableObject {
try await manager.loadFromPreferences() try await manager.loadFromPreferences()
watched = manager watched = manager
screeningStep = .running("Starting the extension")
try manager.connection.startVPNTunnel() try manager.connection.startVPNTunnel()
guard try await wait(for: .connected, on: manager.connection) else { guard try await wait(for: .connected, on: manager.connection) else {
do { throw InstallerError.screeningDidNotStart(manager.connection.status)
try await manager.connection.fetchLastDisconnectError()
} catch {
throw FirewallError.extensionStopped(error.localizedDescription)
}
throw FirewallError.screeningDidNotStart(manager.connection.status)
} }
return "Screening package downloads" return "Screening package downloads"
} }
private func restartScreening() async throws -> String { private func restartScreening() async throws -> String {
guard let manager = try await loadManager() else { guard let manager = try await loadManager() else {
throw FirewallError.extensionUnavailable throw InstallerError.extensionUnavailable
} }
manager.connection.stopVPNTunnel() manager.connection.stopVPNTunnel()
_ = try await wait(for: .disconnected, on: manager.connection) _ = try await wait(for: .disconnected, on: manager.connection)
@@ -395,7 +312,7 @@ final class FirewallModel: NSObject, ObservableObject {
} }
private func stopScreening() async throws -> String { private func stopScreening() async throws -> String {
guard let manager = try await loadManager() else { throw FirewallError.extensionUnavailable } guard let manager = try await loadManager() else { throw InstallerError.extensionUnavailable }
manager.isEnabled = false manager.isEnabled = false
try await manager.saveToPreferences() try await manager.saveToPreferences()
manager.connection.stopVPNTunnel() manager.connection.stopVPNTunnel()
@@ -403,13 +320,9 @@ final class FirewallModel: NSObject, ObservableObject {
return "Screening disabled" 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 { private func wait(for status: NEVPNStatus, on connection: NEVPNConnection) async throws -> Bool {
var connecting = false
for _ in 0 ..< 60 { for _ in 0 ..< 60 {
if connection.status == status { return true } 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) try await Task.sleep(nanoseconds: 250_000_000)
} }
return false return false
@@ -445,13 +358,13 @@ final class FirewallModel: NSObject, ObservableObject {
private func providerMessage(_ message: String) async throws -> String { private func providerMessage(_ message: String) async throws -> String {
guard let session = watched?.connection as? NETunnelProviderSession else { guard let session = watched?.connection as? NETunnelProviderSession else {
throw FirewallError.extensionUnavailable throw InstallerError.extensionUnavailable
} }
return try await withCheckedThrowingContinuation { continuation in return try await withCheckedThrowingContinuation { continuation in
do { do {
try session.sendProviderMessage(Data(message.utf8)) { response in try session.sendProviderMessage(Data(message.utf8)) { response in
guard let response, let reply = String(data: response, encoding: .utf8) else { guard let response, let reply = String(data: response, encoding: .utf8) else {
continuation.resume(throwing: FirewallError.extensionUnavailable) continuation.resume(throwing: InstallerError.extensionUnavailable)
return return
} }
continuation.resume(returning: reply) continuation.resume(returning: reply)
@@ -463,11 +376,12 @@ final class FirewallModel: NSObject, ObservableObject {
} }
private func log(_ line: String) { private func log(_ line: String) {
transcript.append("\(Date().formatted(date: .omitted, time: .standard)) \(line)")
logger.info("\(line, privacy: .public)") logger.info("\(line, privacy: .public)")
} }
} }
extension FirewallModel: OSSystemExtensionRequestDelegate { extension InstallerModel: OSSystemExtensionRequestDelegate {
nonisolated func request(_ request: OSSystemExtensionRequest, nonisolated func request(_ request: OSSystemExtensionRequest,
actionForReplacingExtension existing: OSSystemExtensionProperties, actionForReplacingExtension existing: OSSystemExtensionProperties,
withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction { withExtension replacement: OSSystemExtensionProperties) -> OSSystemExtensionRequest.ReplacementAction {
@@ -476,14 +390,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) { nonisolated func requestNeedsUserApproval(_ request: OSSystemExtensionRequest) {
Task { @MainActor in Task { @MainActor in
// macOS shows its own alert that opens System Settings; this says what to do there. extensionStep = .running("Approve Firewall in System Settings › General › Login Items & Extensions")
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") 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)
}
} }
} }
@@ -493,12 +405,12 @@ extension FirewallModel: OSSystemExtensionRequestDelegate {
if result == .willCompleteAfterReboot { if result == .willCompleteAfterReboot {
log("Extension will finish installing after a reboot") log("Extension will finish installing after a reboot")
} }
finishRequest(request, .success(())) finishRequest(.success(()))
} }
} }
nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) { nonisolated func request(_ request: OSSystemExtensionRequest, didFailWithError error: Error) {
Task { @MainActor in finishRequest(request, .failure(error)) } Task { @MainActor in finishRequest(.failure(error)) }
} }
} }
+26
View File
@@ -0,0 +1,26 @@
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)
}
}
File renamed without changes.
@@ -3,7 +3,7 @@
<plist version="1.0"> <plist version="1.0">
<dict> <dict>
<key>Label</key> <key>Label</key>
<string>com.usestrix.firewall.env</string> <string>ai.strix.firewall.env</string>
<key>ProgramArguments</key> <key>ProgramArguments</key>
<array> <array>
<string>/bin/launchctl</string> <string>/bin/launchctl</string>
+25 -46
View File
@@ -1,10 +1,8 @@
#!/bin/bash #!/bin/bash
# Builds and signs StrixFirewall.app and packages it as build/Firewall.pkg. # Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
# makes a Debug build and skips notarization.
# #
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing # Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
# because macOS refuses to activate a Developer ID extension that is not notarized. # entitlement has no -systemextension variant, so Xcode cannot sign them itself.
set -euo pipefail set -euo pipefail
cd "$(dirname "$0")" cd "$(dirname "$0")"
@@ -12,42 +10,27 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}" : "${TEAM:?set TEAM in macos/.env}"
export TEAM export TEAM
# Identities are matched as a substring of the certificate name; set them if more than one matches. APP_ID=ai.strix.firewall
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension. EXT_ID=ai.strix.firewall.extension
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 VERSION=0.1
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer} IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles" PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/$CONFIGURATION/StrixFirewall.app APP=build/Build/Products/Debug/Installer.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root PKG_ROOT=build/pkg-root
main() { main() {
build build
sign sign
install_app
package package
[ $CONFIGURATION = Debug ] || notarize
} }
build() { build() {
echo "==> Building" echo "==> Building"
xcodegen generate >/dev/null xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \ xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \ -derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
} }
@@ -56,44 +39,40 @@ sign() {
echo "==> Signing with $IDENTITY" echo "==> Signing with $IDENTITY"
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile" cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile" cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall-uninstall" codesign --force --options runtime --timestamp=none \
codesign --force --options runtime --timestamp \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT" --entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \ codesign --force --options runtime --timestamp=none \
--entitlements App/App.entitlements -s "$IDENTITY" "$APP" --entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$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. # Marked non-relocatable so the installer never updates another copy with the same bundle id.
package() { package() {
echo "==> Packaging build/Firewall.pkg" echo "==> Packaging build/Firewall.pkg"
rm -rf "$PKG_ROOT" rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents" mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/StrixFirewall.app" cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/" cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \ pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \ --identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
build/Firewall.pkg >/dev/null
} }
notarize() { # Prints the newest provisioning profile Xcode has downloaded for a bundle id.
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() { profile_for() {
local want=$TEAM.$1 newest= newest_time=0 profile id t local want=$TEAM.$1 newest= newest_time=0 id t
for p in "$PROFILES"/*.provisionprofile; do for p in "$PROFILES"/*.provisionprofile; do
profile=$(security cms -D -i "$p") id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
[ "$id" = "$want" ] || continue [ "$id" = "$want" ] || continue
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
grep -q "\"$NE_PROVIDER\"" || continue
t=$(stat -f %m "$p") t=$(stat -f %m "$p")
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
done done
+2 -2
View File
@@ -3,8 +3,8 @@
user=$(stat -f %Su /dev/console) user=$(stat -f %Su /dev/console)
[ "$user" = root ] && exit 0 [ "$user" = root ] && exit 0
uid=$(id -u "$user") uid=$(id -u "$user")
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist agent=/Library/LaunchAgents/ai.strix.firewall.env.plist
launchctl bootout "gui/$uid" "$agent" 2>/dev/null launchctl bootout "gui/$uid" "$agent" 2>/dev/null
launchctl bootstrap "gui/$uid" "$agent" launchctl bootstrap "gui/$uid" "$agent"
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/StrixFirewall.app --args --install launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
exit 0 exit 0
+15 -35
View File
@@ -1,6 +1,6 @@
name: Firewall name: Firewall
options: options:
bundleIdPrefix: com.usestrix.firewall bundleIdPrefix: ai.strix.firewall
deploymentTarget: { macOS: "13.0" } deploymentTarget: { macOS: "13.0" }
createIntermediateGroups: true createIntermediateGroups: true
@@ -10,7 +10,7 @@ settings:
"CARGO_PROFILE[config=Debug]": debug "CARGO_PROFILE[config=Debug]": debug
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE) "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) "LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
ARCHS: arm64 x86_64 ARCHS: arm64
"ONLY_ACTIVE_ARCH[config=Debug]": "YES" "ONLY_ACTIVE_ARCH[config=Debug]": "YES"
SWIFT_VERSION: "5.0" SWIFT_VERSION: "5.0"
DEVELOPMENT_TEAM: ${TEAM} DEVELOPMENT_TEAM: ${TEAM}
@@ -33,8 +33,8 @@ targets:
sources: [Extension] sources: [Extension]
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
PRODUCT_NAME: com.usestrix.firewall.extension PRODUCT_NAME: ai.strix.firewall.extension
PRODUCT_MODULE_NAME: Extension PRODUCT_MODULE_NAME: Extension
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz] OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
info: info:
@@ -44,7 +44,7 @@ targets:
CFBundleShortVersionString: "0.1" CFBundleShortVersionString: "0.1"
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install. NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension: NetworkExtension:
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
NEProviderClasses: NEProviderClasses:
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
entitlements: entitlements:
@@ -52,13 +52,13 @@ targets:
properties: properties:
# Xcode injects these from the profile; we sign by hand, and NE's IPC # Xcode injects these from the profile; we sign by hand, and NE's IPC
# check rejects a caller without them. # check rejects a caller without them.
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
com.apple.developer.team-identifier: ${TEAM} com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.networking.networkextension: com.apple.developer.networking.networkextension:
- ${NE_PROVIDER} - app-proxy-provider
# NE requires NEMachServiceName to start with one of these. # NE requires NEMachServiceName to start with one of these.
com.apple.security.application-groups: com.apple.security.application-groups:
- ${TEAM}.com.usestrix.firewall - ${TEAM}.ai.strix.firewall
com.apple.security.network.client: true com.apple.security.network.client: true
com.apple.security.network.server: true com.apple.security.network.server: true
dependencies: dependencies:
@@ -87,53 +87,33 @@ targets:
$([ "$CONFIGURATION" = Debug ] || echo --release) $([ "$CONFIGURATION" = Debug ] || echo --release)
done done
StrixFirewall: Installer:
type: application type: application
platform: macOS platform: macOS
sources: sources:
- App - Installer
- path: ../assets/logo-transparent.png - path: ../assets/logo-transparent.png
buildPhase: resources buildPhase: resources
settings: settings:
base: base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
info: info:
path: App/Info.plist path: Installer/Info.plist
properties: properties:
CFBundleShortVersionString: "0.1" CFBundleShortVersionString: "0.1"
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET) LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install. NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
LSUIElement: true LSUIElement: true
entitlements: entitlements:
path: App/App.entitlements path: Installer/Installer.entitlements
properties: properties:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall com.apple.application-identifier: ${TEAM}.ai.strix.firewall
com.apple.developer.team-identifier: ${TEAM} com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.system-extension.install: true com.apple.developer.system-extension.install: true
com.apple.developer.networking.networkextension: com.apple.developer.networking.networkextension:
- ${NE_PROVIDER} - app-proxy-provider
com.apple.security.network.client: true com.apple.security.network.client: true
dependencies: dependencies:
- target: Extension - target: Extension
embed: true embed: true
codeSign: 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"