mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 05:19:35 -10:00
Compare commits
32
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
60fcc7adc9 | ||
|
|
5036ba69a5 | ||
|
|
c16be8d492 | ||
|
|
a162c5307b | ||
|
|
21f3cfb147 | ||
|
|
a3f53315cf | ||
|
|
677884aa36 | ||
|
|
e6691b252c | ||
|
|
a2f631866e | ||
|
|
b42185ee6f | ||
|
|
97a5a148e0 | ||
|
|
877807740b | ||
|
|
cd9b0614bc | ||
|
|
6cad09d32d | ||
|
|
23db767cc1 | ||
|
|
37a56fb7d2 | ||
|
|
6c69036db0 | ||
|
|
4fd3021760 | ||
|
|
d01ac3634b | ||
|
|
91da9d68c9 | ||
|
|
e0b723a732 | ||
|
|
fc02226cd6 | ||
|
|
5068b3578d | ||
|
|
8d781f3f98 | ||
|
|
e521cbdefa | ||
|
|
674f3bdcd3 | ||
|
|
2df02bf23f | ||
|
|
a3e5ab1802 | ||
|
|
da750007fc | ||
|
|
d9de2a8a94 | ||
|
|
b3f47664df | ||
|
|
bdcc2e93dc |
No files matched your search
@@ -0,0 +1,54 @@
|
||||
name: Build Firewall.pkg
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
inputs:
|
||||
plan:
|
||||
required: true
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
pkg:
|
||||
runs-on: macos-26
|
||||
env:
|
||||
TEAM: ${{ vars.APPLE_TEAM }}
|
||||
NOTARY_PROFILE: firewall
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: false
|
||||
- run: brew install xcodegen
|
||||
- name: Install signing identities, profiles and notary key
|
||||
env:
|
||||
CERTIFICATES: ${{ secrets.MACOS_CERTIFICATES }}
|
||||
CERTIFICATES_PASSWORD: ${{ secrets.MACOS_CERTIFICATES_PASSWORD }}
|
||||
APP_PROFILE: ${{ secrets.MACOS_APP_PROFILE }}
|
||||
EXTENSION_PROFILE: ${{ secrets.MACOS_EXTENSION_PROFILE }}
|
||||
NOTARY_KEY: ${{ secrets.NOTARY_KEY }}
|
||||
NOTARY_KEY_ID: ${{ secrets.NOTARY_KEY_ID }}
|
||||
NOTARY_ISSUER: ${{ secrets.NOTARY_ISSUER }}
|
||||
run: |
|
||||
keychain="$RUNNER_TEMP/signing.keychain-db"
|
||||
security create-keychain -p "" "$keychain"
|
||||
security set-keychain-settings "$keychain"
|
||||
security unlock-keychain -p "" "$keychain"
|
||||
security default-keychain -s "$keychain"
|
||||
security list-keychains -d user -s "$keychain" login.keychain
|
||||
base64 --decode <<<"$CERTIFICATES" > "$RUNNER_TEMP/certificates.p12"
|
||||
security import "$RUNNER_TEMP/certificates.p12" -k "$keychain" -P "$CERTIFICATES_PASSWORD" \
|
||||
-T /usr/bin/codesign -T /usr/bin/pkgbuild
|
||||
security set-key-partition-list -S apple-tool:,apple: -s -k "" "$keychain" >/dev/null
|
||||
|
||||
profiles="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
|
||||
mkdir -p "$profiles"
|
||||
base64 --decode <<<"$APP_PROFILE" > "$profiles/app.provisionprofile"
|
||||
base64 --decode <<<"$EXTENSION_PROFILE" > "$profiles/extension.provisionprofile"
|
||||
|
||||
printf '%s' "$NOTARY_KEY" > "$RUNNER_TEMP/notary.p8"
|
||||
xcrun notarytool store-credentials "$NOTARY_PROFILE" \
|
||||
--key "$RUNNER_TEMP/notary.p8" --key-id "$NOTARY_KEY_ID" --issuer "$NOTARY_ISSUER"
|
||||
- run: macos/build.sh
|
||||
- uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: artifacts-macos-pkg
|
||||
path: macos/build/Firewall.pkg
|
||||
@@ -211,14 +211,24 @@ jobs:
|
||||
path: |
|
||||
${{ steps.cargo-dist.outputs.paths }}
|
||||
${{ env.BUILD_MANIFEST_NAME }}
|
||||
|
||||
custom-build-macos-pkg:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
uses: ./.github/workflows/build-macos-pkg.yml
|
||||
with:
|
||||
plan: ${{ needs.plan.outputs.val }}
|
||||
secrets: inherit
|
||||
# Determines if we should publish/announce
|
||||
host:
|
||||
needs:
|
||||
- plan
|
||||
- build-local-artifacts
|
||||
- build-global-artifacts
|
||||
- custom-build-macos-pkg
|
||||
# Only run if we're "publishing", and only if plan, local and global didn't fail (skipped is fine)
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
if: ${{ always() && needs.plan.result == 'success' && needs.plan.outputs.publishing == 'true' && (needs.build-global-artifacts.result == 'skipped' || needs.build-global-artifacts.result == 'success') && (needs.custom-build-macos-pkg.result == 'skipped' || needs.custom-build-macos-pkg.result == 'success') && (needs.build-local-artifacts.result == 'skipped' || needs.build-local-artifacts.result == 'success') }}
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
@@ -278,52 +288,6 @@ jobs:
|
||||
|
||||
gh release create "${{ needs.plan.outputs.tag }}" --target "$RELEASE_COMMIT" $PRERELEASE_FLAG --title "$ANNOUNCEMENT_TITLE" --notes-file "$RUNNER_TEMP/notes.txt" artifacts/*
|
||||
|
||||
publish-homebrew-formula:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
PLAN: ${{ needs.plan.outputs.val }}
|
||||
GITHUB_USER: "axo bot"
|
||||
GITHUB_EMAIL: "[email protected]"
|
||||
if: ${{ !fromJson(needs.plan.outputs.val).announcement_is_prerelease || fromJson(needs.plan.outputs.val).publish_prereleases }}
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
with:
|
||||
persist-credentials: true
|
||||
repository: "usestrix/homebrew-tap"
|
||||
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
|
||||
# So we have access to the formula
|
||||
- name: Fetch homebrew formulae
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
pattern: artifacts-*
|
||||
path: Formula/
|
||||
merge-multiple: true
|
||||
# This is extra complex because you can make your Formula name not match your app name
|
||||
# so we need to find releases with a *.rb file, and publish with that filename.
|
||||
- name: Commit formula files
|
||||
run: |
|
||||
git config --global user.name "${GITHUB_USER}"
|
||||
git config --global user.email "${GITHUB_EMAIL}"
|
||||
|
||||
for release in $(echo "$PLAN" | jq --compact-output '.releases[] | select([.artifacts[] | endswith(".rb")] | any)'); do
|
||||
filename=$(echo "$release" | jq '.artifacts[] | select(endswith(".rb"))' --raw-output)
|
||||
name=$(echo "$filename" | sed "s/\.rb$//")
|
||||
version=$(echo "$release" | jq .app_version --raw-output)
|
||||
|
||||
export PATH="/home/linuxbrew/.linuxbrew/bin:$PATH"
|
||||
brew update
|
||||
# We avoid reformatting user-provided data such as the app description and homepage.
|
||||
brew style --except-cops FormulaAudit/Homepage,FormulaAudit/Desc,FormulaAuditStrict --fix "Formula/${filename}" || true
|
||||
|
||||
git add "Formula/${filename}"
|
||||
git commit -m "${name} ${version}"
|
||||
done
|
||||
git push
|
||||
|
||||
custom-publish-crates:
|
||||
needs:
|
||||
- plan
|
||||
@@ -342,12 +306,11 @@ jobs:
|
||||
needs:
|
||||
- plan
|
||||
- host
|
||||
- publish-homebrew-formula
|
||||
- custom-publish-crates
|
||||
# use "always() && ..." to allow us to wait for all publish jobs while
|
||||
# still allowing individual publish jobs to skip themselves (for prereleases).
|
||||
# "host" however must run to completion, no skipping allowed!
|
||||
if: ${{ always() && needs.host.result == 'success' && (needs.publish-homebrew-formula.result == 'skipped' || needs.publish-homebrew-formula.result == 'success') && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }}
|
||||
if: ${{ always() && needs.host.result == 'success' && (needs.custom-publish-crates.result == 'skipped' || needs.custom-publish-crates.result == 'success') }}
|
||||
runs-on: "ubuntu-22.04"
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
Generated
+249
-7
@@ -261,6 +261,15 @@ dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block2"
|
||||
version = "0.6.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5"
|
||||
dependencies = [
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -669,6 +678,16 @@ dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dispatch2"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.7"
|
||||
@@ -680,12 +699,32 @@ dependencies = [
|
||||
"syn 3.0.6",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dpi"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76"
|
||||
|
||||
[[package]]
|
||||
name = "either"
|
||||
version = "1.18.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
|
||||
|
||||
[[package]]
|
||||
name = "embed-resource"
|
||||
version = "3.0.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6168487b5e7eb2673e6efe1200b1848016e436a6ca0b8031bf04bd9d493a249f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"memchr",
|
||||
"rustc_version",
|
||||
"toml",
|
||||
"vswhom",
|
||||
"winreg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "encoding_rs"
|
||||
version = "0.8.42"
|
||||
@@ -751,6 +790,15 @@ dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fdeflate"
|
||||
version = "0.3.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c"
|
||||
dependencies = [
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "find-msvc-tools"
|
||||
version = "0.1.13"
|
||||
@@ -771,7 +819,7 @@ checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb"
|
||||
dependencies = [
|
||||
"crc32fast",
|
||||
"libz-sys",
|
||||
"miniz_oxide",
|
||||
"miniz_oxide 0.9.1",
|
||||
"zlib-rs",
|
||||
]
|
||||
|
||||
@@ -1452,6 +1500,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "keyboard-types"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fbe853b403ae61a04233030ae8a79d94975281ed9770a1f9e246732b534b28d"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "konst"
|
||||
version = "0.2.20"
|
||||
@@ -1595,6 +1653,16 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a"
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.8.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316"
|
||||
dependencies = [
|
||||
"adler2",
|
||||
"simd-adler32",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "miniz_oxide"
|
||||
version = "0.9.1"
|
||||
@@ -1636,6 +1704,24 @@ dependencies = [
|
||||
"uuid",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "muda"
|
||||
version = "0.21.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4340e89c178916e62879c5c36b7f699019369be413e496178a6983ce91b8bc03"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"dpi",
|
||||
"keyboard-types",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
"png",
|
||||
"thiserror",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multer"
|
||||
version = "3.1.0"
|
||||
@@ -1742,6 +1828,66 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "08849bbd4767dfae9457696856ae1c84fe4e0281bbe4a7abff2d0e06fb7981f8"
|
||||
dependencies = [
|
||||
"objc2-encode",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-app-kit"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
"objc2-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"dispatch2",
|
||||
"objc2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-core-graphics"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "objc2-encode"
|
||||
version = "4.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33"
|
||||
|
||||
[[package]]
|
||||
name = "objc2-foundation"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"block2",
|
||||
"objc2",
|
||||
"objc2-core-foundation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "oid-registry"
|
||||
version = "0.8.1"
|
||||
@@ -1864,6 +2010,19 @@ version = "0.3.34"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548"
|
||||
|
||||
[[package]]
|
||||
name = "png"
|
||||
version = "0.18.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61"
|
||||
dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
"crc32fast",
|
||||
"fdeflate",
|
||||
"flate2",
|
||||
"miniz_oxide 0.8.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "portable-atomic"
|
||||
version = "1.15.0"
|
||||
@@ -3098,6 +3257,15 @@ dependencies = [
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sha1"
|
||||
version = "0.11.0"
|
||||
@@ -3225,12 +3393,15 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||
|
||||
[[package]]
|
||||
name = "strix-firewall"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-rc.2"
|
||||
dependencies = [
|
||||
"clap",
|
||||
"core-foundation 0.10.1",
|
||||
"directories",
|
||||
"embed-resource",
|
||||
"muda",
|
||||
"percent-encoding",
|
||||
"png",
|
||||
"rama",
|
||||
"rand",
|
||||
"rcgen",
|
||||
@@ -3246,6 +3417,7 @@ dependencies = [
|
||||
"tracing",
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"tray-icon",
|
||||
"which",
|
||||
"windows",
|
||||
"windows-registry",
|
||||
@@ -3523,10 +3695,25 @@ dependencies = [
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.1+spec-1.1.0"
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned",
|
||||
"toml_datetime",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "1.1.2+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
@@ -3545,13 +3732,19 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "toml_parser"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
version = "1.1.5+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
|
||||
checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
|
||||
dependencies = [
|
||||
"winnow",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "tower-layer"
|
||||
version = "0.3.3"
|
||||
@@ -3652,6 +3845,25 @@ dependencies = [
|
||||
"tracing-serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tray-icon"
|
||||
version = "0.26.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4f5cc5858ecf9574ddd8f0032a2ff7ffd6f7724d2b81e48b9374984b3fd6ddc9"
|
||||
dependencies = [
|
||||
"crossbeam-channel",
|
||||
"muda",
|
||||
"objc2",
|
||||
"objc2-app-kit",
|
||||
"objc2-core-foundation",
|
||||
"objc2-core-graphics",
|
||||
"objc2-foundation",
|
||||
"once_cell",
|
||||
"png",
|
||||
"thiserror",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "try-lock"
|
||||
version = "0.2.5"
|
||||
@@ -3742,6 +3954,26 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vswhom"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"vswhom-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vswhom-sys"
|
||||
version = "0.1.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "walkdir"
|
||||
version = "2.5.0"
|
||||
@@ -4117,6 +4349,16 @@ dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "winreg"
|
||||
version = "0.56.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d6f32a0ff4a9f6f01231eb2059cc85479330739333e0e58cadf03b6af2cca10"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "wit-bindgen"
|
||||
version = "0.57.1"
|
||||
|
||||
+37
-12
@@ -1,8 +1,9 @@
|
||||
[package]
|
||||
name = "strix-firewall"
|
||||
version = "0.1.0"
|
||||
version = "0.1.0-rc.2"
|
||||
edition = "2021"
|
||||
license = "Apache-2.0"
|
||||
authors = ["Strix"]
|
||||
description = "Screens package downloads for known malware before they reach your machine"
|
||||
repository = "https://github.com/usestrix/firewall"
|
||||
homepage = "https://github.com/usestrix/firewall"
|
||||
@@ -10,14 +11,20 @@ homepage = "https://github.com/usestrix/firewall"
|
||||
# as an integration test; new tests/*.rs need an explicit [[test]] entry.
|
||||
autotests = false
|
||||
|
||||
# Only Windows ships the service.
|
||||
# Only Windows ships the service and tray.
|
||||
[package.metadata.dist.binaries]
|
||||
x86_64-pc-windows-msvc = ["firewall", "firewall-service"]
|
||||
x86_64-pc-windows-msvc = ["firewall", "firewall-service", "firewall-tray"]
|
||||
aarch64-apple-darwin = ["firewall"]
|
||||
x86_64-apple-darwin = ["firewall"]
|
||||
aarch64-unknown-linux-gnu = ["firewall"]
|
||||
x86_64-unknown-linux-gnu = ["firewall"]
|
||||
|
||||
[package.metadata.wix]
|
||||
upgrade-guid = "D944C2F6-BF55-4B21-A60E-1BBDB1A66293"
|
||||
path-guid = "033B1050-23D0-4F5A-A0D3-F4757B4B67E0"
|
||||
license = false
|
||||
eula = false
|
||||
|
||||
[lib]
|
||||
name = "firewall"
|
||||
# The macOS extension links a staticlib, built on demand:
|
||||
@@ -31,6 +38,17 @@ path = "src/main.rs"
|
||||
[[bin]]
|
||||
name = "firewall-service"
|
||||
path = "src/bin/firewall-service.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-tray"
|
||||
path = "src/bin/firewall-tray.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[bin]]
|
||||
name = "firewall-uninstall"
|
||||
path = "src/bin/firewall-uninstall.rs"
|
||||
required-features = ["machine-wide"]
|
||||
|
||||
[[example]]
|
||||
name = "proxy-test"
|
||||
@@ -72,6 +90,14 @@ path = "tests/tpm.rs"
|
||||
name = "managers"
|
||||
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]
|
||||
clap = "4.6.6"
|
||||
directories = "6.0.0"
|
||||
@@ -96,22 +122,21 @@ security-framework = "3.7.0"
|
||||
security-framework-sys = "2.17.0"
|
||||
|
||||
[target.'cfg(windows)'.dependencies]
|
||||
muda = { version = "0.21.2", default-features = false }
|
||||
png = "0.18.1"
|
||||
rama = { version = "0.4.0", features = ["dns-hickory"] }
|
||||
schannel = "0.1.29"
|
||||
tokio = { version = "1.53.1", features = ["net"] }
|
||||
tracing-appender = "0.2.5"
|
||||
windows = { version = "0.62", features = [
|
||||
"Win32_Foundation",
|
||||
"Win32_Security",
|
||||
"Win32_Security_Authorization",
|
||||
"Win32_Security_Cryptography",
|
||||
"Win32_System_Com",
|
||||
"Win32_System_Environment",
|
||||
"Win32_UI_Shell",
|
||||
] }
|
||||
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_LibraryLoader", "Win32_System_Pipes", "Win32_System_Registry", "Win32_System_Threading", "Win32_UI_Controls", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
|
||||
windows-registry = "0.6.1"
|
||||
windows-service = "0.8.0"
|
||||
|
||||
# Only on Windows hosts, where the tray and service are built.
|
||||
[target.'cfg(windows)'.build-dependencies]
|
||||
embed-resource = "3.0.12"
|
||||
|
||||
[dev-dependencies]
|
||||
tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread"] }
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
//! The tray's task dialogs exist only in version 6 of the common controls, which takes a manifest.
|
||||
//! Both machine-wide binaries also get the Strix icon.
|
||||
fn main() {
|
||||
println!("cargo:rerun-if-changed=src/bin/firewall-tray.manifest");
|
||||
let msvc = std::env::var("CARGO_CFG_TARGET_ENV").as_deref() == Ok("msvc");
|
||||
if msvc && std::env::var_os("CARGO_FEATURE_MACHINE_WIDE").is_some() {
|
||||
let manifest = concat!(env!("CARGO_MANIFEST_DIR"), "/src/bin/firewall-tray.manifest");
|
||||
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFEST:EMBED");
|
||||
println!("cargo:rustc-link-arg-bin=firewall-tray=/MANIFESTINPUT:{manifest}");
|
||||
#[cfg(windows)]
|
||||
embed_resource::compile_for(
|
||||
"src/bin/firewall.rc",
|
||||
["firewall-tray", "firewall-service"],
|
||||
embed_resource::NONE,
|
||||
)
|
||||
// Fails the build, rather than ship without the icon.
|
||||
.manifest_required()
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
use_small_heuristics = "Max"
|
||||
@@ -80,6 +80,7 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
|
||||
warn!(method = %req.method(), uri = %req.uri(), "unroutable, expected an absolute URL");
|
||||
return Ok(text(400, "firewall: expected an absolute URL\n"));
|
||||
};
|
||||
let url = firewall.mirrored(url);
|
||||
|
||||
let reason = policies::screen(&url, firewall).await;
|
||||
telemetry::screened(firewall, &url, reason.as_deref());
|
||||
@@ -89,12 +90,16 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
|
||||
}
|
||||
|
||||
info!(method = %req.method(), %url, "forwarding");
|
||||
let index = firewall.is_index_url(&url);
|
||||
// Only an HTTPS registry was downgraded to reach us, so only it is upgraded back and rewritten.
|
||||
let tls = firewall.is_https_url(&url);
|
||||
let index = tls && firewall.is_index_url(&url);
|
||||
let (mut parts, body) = req.into_parts();
|
||||
let Ok(uri) = url.parse() else {
|
||||
return Ok(text(400, "firewall: expected an absolute URL\n"));
|
||||
};
|
||||
parts.uri = uri;
|
||||
if tls {
|
||||
let Ok(uri) = url.parse() else {
|
||||
return Ok(text(400, "firewall: expected an absolute URL\n"));
|
||||
};
|
||||
parts.uri = uri;
|
||||
}
|
||||
|
||||
let client = DecompressionLayer::new().into_layer(EasyHttpWebClient::default());
|
||||
let response = match client.serve(Request::from_parts(parts, body)).await {
|
||||
@@ -125,6 +130,9 @@ pub fn downgrade(firewall: &Firewall, body: Vec<u8>) -> Vec<u8> {
|
||||
Ok(text) => text,
|
||||
Err(raw) => return raw.into_bytes(),
|
||||
};
|
||||
for (upstream, mirror) in firewall.mirrors() {
|
||||
text = text.replace(mirror, upstream);
|
||||
}
|
||||
for host in firewall.hosts() {
|
||||
text = text.replace(&format!("https://{host}"), &format!("http://{host}"));
|
||||
}
|
||||
|
||||
@@ -11,4 +11,5 @@ pub static BUN: Manager = Manager {
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -17,4 +17,5 @@ pub static CARGO: Manager = Manager {
|
||||
"source.firewall.registry=\"sparse+http://index.crates.io/\"",
|
||||
],
|
||||
}],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -8,4 +8,5 @@ pub static GEM: Manager = Manager {
|
||||
when: &["install", "update", "fetch"],
|
||||
flags: &["--clear-sources", "--source", "http://rubygems.org/"],
|
||||
}],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -11,4 +11,5 @@ pub static GO: Manager = Manager {
|
||||
("GOFLAGS", "-mod=mod"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -36,6 +36,14 @@ pub struct Manager {
|
||||
pub command: &'static str,
|
||||
pub env: &'static [(&'static str, &'static str)],
|
||||
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] =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
use super::{Manager, PROXY};
|
||||
use super::{Lockfile, Manager, PROXY};
|
||||
|
||||
pub static NPM: Manager = Manager {
|
||||
command: "npm",
|
||||
@@ -8,4 +8,5 @@ pub static NPM: Manager = Manager {
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: Some(Lockfile::Restore("package-lock.json")),
|
||||
};
|
||||
@@ -13,4 +13,5 @@ pub static PDM: Manager = Manager {
|
||||
("PDM_PYPI_VERIFY_SSL", "false"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -12,5 +12,5 @@ pub static PIP: Manager = recipe("pip");
|
||||
pub static PIP3: Manager = recipe("pip3");
|
||||
|
||||
const fn recipe(name: &'static str) -> Manager {
|
||||
Manager { command: name, env: PIP_ENV, args: &[] }
|
||||
Manager { command: name, env: PIP_ENV, args: &[], lockfile: None }
|
||||
}
|
||||
@@ -13,4 +13,5 @@ pub static PIPENV: Manager = Manager {
|
||||
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -9,4 +9,5 @@ pub static PNPM: Manager = Manager {
|
||||
("pnpm_config_registry", "http://registry.npmjs.org/"),
|
||||
],
|
||||
args: &[],
|
||||
lockfile: None,
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
use super::{Manager, PROXY, PYPI_SIMPLE};
|
||||
use super::{Lockfile, Manager, PROXY, PYPI_SIMPLE};
|
||||
|
||||
pub static UV: Manager = Manager {
|
||||
command: "uv",
|
||||
env: &[("HTTP_PROXY", PROXY), ("http_proxy", PROXY), ("UV_DEFAULT_INDEX", PYPI_SIMPLE)],
|
||||
args: &[],
|
||||
lockfile: Some(Lockfile::Warn("uv.lock")),
|
||||
};
|
||||
@@ -1,12 +1,16 @@
|
||||
pub mod forward;
|
||||
pub mod managers;
|
||||
#[cfg(windows)]
|
||||
mod windows;
|
||||
|
||||
pub use forward::spawn;
|
||||
pub use managers::*;
|
||||
|
||||
use crate::policies::registries::BUILT_IN;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Command;
|
||||
use tracing::{info, warn};
|
||||
|
||||
pub struct Invocation {
|
||||
pub program: String,
|
||||
@@ -34,7 +38,8 @@ impl Invocation {
|
||||
|
||||
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
|
||||
let proxy = crate::local_url(port, "");
|
||||
let mut command = Command::new(resolve(&invocation.program)?);
|
||||
let program = resolve(&invocation.program)?;
|
||||
let mut command = Command::new(&program);
|
||||
command.args(invocation.arguments(&proxy));
|
||||
if let Some(dir) = dir {
|
||||
command.current_dir(dir);
|
||||
@@ -42,10 +47,47 @@ pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::R
|
||||
for (key, value) in managers::variables(&proxy) {
|
||||
command.env(key, value);
|
||||
}
|
||||
Ok(command.status()?.code().unwrap_or(1))
|
||||
#[cfg(windows)]
|
||||
windows::prepare();
|
||||
let status = command.status();
|
||||
#[cfg(windows)]
|
||||
let status = status.map_err(|error| windows::explain(error, &program));
|
||||
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.
|
||||
fn resolve(program: &str) -> std::io::Result<PathBuf> {
|
||||
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}"),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::Path;
|
||||
use std::sync::Once;
|
||||
use tracing::debug;
|
||||
use windows::core::{BOOL, PCWSTR};
|
||||
use windows::Win32::System::Console::{SetConsoleCtrlHandler, CTRL_BREAK_EVENT, CTRL_C_EVENT};
|
||||
use windows::Win32::System::JobObjects::{
|
||||
AssignProcessToJobObject, CreateJobObjectW, JobObjectExtendedLimitInformation,
|
||||
SetInformationJobObject, JOBOBJECT_EXTENDED_LIMIT_INFORMATION,
|
||||
JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE,
|
||||
};
|
||||
use windows::Win32::System::Threading::GetCurrentProcess;
|
||||
|
||||
/// Called before spawning, so the manager behaves as it would run from the shell directly.
|
||||
/// Once per process: a caller may run several managers.
|
||||
pub fn prepare() {
|
||||
static PREPARED: Once = Once::new();
|
||||
PREPARED.call_once(|| {
|
||||
// The console sends Ctrl+C to the manager too. Exiting first would hand the prompt back
|
||||
// while a .cmd shim still asks "Terminate batch job (Y/N)?", so wait for its exit instead.
|
||||
// A handler, unlike ignoring the signal outright, is not inherited by the manager.
|
||||
let _ = unsafe { SetConsoleCtrlHandler(Some(ignore_interrupt), true) };
|
||||
if let Err(error) = kill_children_on_exit() {
|
||||
debug!(%error, "the manager may outlive the firewall");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
unsafe extern "system" fn ignore_interrupt(event: u32) -> BOOL {
|
||||
(event == CTRL_C_EVENT || event == CTRL_BREAK_EVENT).into()
|
||||
}
|
||||
|
||||
/// Joins a job that kills what is left in it once the firewall exits, even if killed itself.
|
||||
/// The manager inherits the job when spawned, as do its own children.
|
||||
fn kill_children_on_exit() -> windows::core::Result<()> {
|
||||
let mut limits = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default();
|
||||
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
|
||||
// Never closed: the handle closing as the firewall exits is what kills the rest.
|
||||
let job = unsafe { CreateJobObjectW(None, PCWSTR::null())? };
|
||||
unsafe {
|
||||
SetInformationJobObject(
|
||||
job,
|
||||
JobObjectExtendedLimitInformation,
|
||||
(&raw const limits).cast(),
|
||||
size_of_val(&limits) as u32,
|
||||
)?
|
||||
};
|
||||
unsafe { AssignProcessToJobObject(job, GetCurrentProcess()) }
|
||||
}
|
||||
|
||||
/// Rust refuses to pass a line break to a batch file, where it would cut the command short.
|
||||
pub fn explain(error: Error, program: &Path) -> Error {
|
||||
let batch = program
|
||||
.extension()
|
||||
.is_some_and(|ext| ext.eq_ignore_ascii_case("cmd") || ext.eq_ignore_ascii_case("bat"));
|
||||
if !batch || error.kind() != ErrorKind::InvalidInput {
|
||||
return error;
|
||||
}
|
||||
let name = program.file_name().unwrap_or(program.as_os_str()).to_string_lossy();
|
||||
Error::new(
|
||||
ErrorKind::InvalidInput,
|
||||
format!("{name} is a batch file, which cannot be passed an argument with a line break"),
|
||||
)
|
||||
}
|
||||
@@ -2,14 +2,25 @@
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() {
|
||||
if let Err(e) = firewall::os::windows::run_service() {
|
||||
tracing::error!("could not run the service: {e}");
|
||||
firewall::telemetry::error("service");
|
||||
firewall::telemetry::flush();
|
||||
std::process::exit(1)
|
||||
// Run by the MSI as it removes the firewall.
|
||||
if std::env::args().nth(1).as_deref() == Some("uninstall") {
|
||||
firewall::init_logging("info");
|
||||
if let Err(e) = firewall::os::windows::uninstall() {
|
||||
fail("uninstall", &format!("could not uninstall: {e}"))
|
||||
}
|
||||
} else if let Err(e) = firewall::os::windows::run_service() {
|
||||
fail("service", &format!("could not run the service: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn fail(kind: &'static str, message: &str) -> ! {
|
||||
tracing::error!("{message}");
|
||||
firewall::telemetry::error(kind);
|
||||
firewall::telemetry::flush();
|
||||
std::process::exit(1)
|
||||
}
|
||||
|
||||
/// The service is Windows-only.
|
||||
#[cfg(not(windows))]
|
||||
fn main() {}
|
||||
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<assembly xmlns="urn:schemas-microsoft-com:asm.v1" manifestVersion="1.0">
|
||||
<dependency>
|
||||
<dependentAssembly>
|
||||
<assemblyIdentity type="win32" name="Microsoft.Windows.Common-Controls" version="6.0.0.0"
|
||||
processorArchitecture="*" publicKeyToken="6595b64144ccf1df" language="*"/>
|
||||
</dependentAssembly>
|
||||
</dependency>
|
||||
</assembly>
|
||||
@@ -0,0 +1,11 @@
|
||||
//! The notification-area icon, started at sign-in. A GUI app, so no console opens with it.
|
||||
#![windows_subsystem = "windows"]
|
||||
|
||||
#[cfg(windows)]
|
||||
fn main() -> Result<(), rama::error::BoxError> {
|
||||
firewall::os::windows::run_tray()
|
||||
}
|
||||
|
||||
/// The tray is Windows-only.
|
||||
#[cfg(not(windows))]
|
||||
fn main() {}
|
||||
@@ -0,0 +1,16 @@
|
||||
//! Run as root by the macOS app's uninstall.
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
fn main() {
|
||||
firewall::init_logging("info");
|
||||
if let Err(e) = firewall::os::macos::uninstall() {
|
||||
tracing::error!("could not uninstall: {e}");
|
||||
firewall::telemetry::error("uninstall");
|
||||
firewall::telemetry::flush();
|
||||
std::process::exit(1)
|
||||
}
|
||||
}
|
||||
|
||||
/// Only macOS installs anything system-wide.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
fn main() {}
|
||||
@@ -0,0 +1,2 @@
|
||||
// The icon Explorer, UAC and the tray's popups show.
|
||||
1 ICON "strix.ico"
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1,154 @@
|
||||
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())
|
||||
}
|
||||
+90
-28
@@ -1,28 +1,29 @@
|
||||
use crate::policies::registries::{Registry, BUILT_IN};
|
||||
use crate::policies::registries::{host_of, BUILT_IN};
|
||||
use directories::ProjectDirs;
|
||||
use rama::net::uri::Uri;
|
||||
use serde::Deserialize;
|
||||
use serde_json::{Map, Value};
|
||||
use serde_json::{json, Map, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::io::{Error, ErrorKind};
|
||||
use std::path::PathBuf;
|
||||
use std::num::{NonZeroU16, NonZeroU64};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(default, deny_unknown_fields)]
|
||||
pub struct FirewallConfig {
|
||||
pub port: u16,
|
||||
pub port: NonZeroU16,
|
||||
/// Allow a package when a filter could not check it.
|
||||
pub fail_open: bool,
|
||||
/// 0 turns the age check off.
|
||||
pub min_age_hours: u64,
|
||||
pub denylist: DenylistSource,
|
||||
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
/// Packages exempt from the age check, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
|
||||
pub allow: HashMap<String, Vec<AllowedPackage>>,
|
||||
pub telemetry: bool,
|
||||
/// Not describable in the file yet, so this is always the built-in table.
|
||||
#[serde(skip)]
|
||||
pub registries: Vec<&'static Registry>,
|
||||
/// Mirrors laid out like a built-in registry, by its name: `{"npm": ["https://npm.example.com/"]}`.
|
||||
pub registries: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
@@ -40,19 +41,19 @@ pub struct DenylistSource {
|
||||
pub base: String,
|
||||
/// Polled for a new commit, which re-fetches every list in use.
|
||||
pub revision: String,
|
||||
pub poll_secs: u64,
|
||||
pub poll_secs: NonZeroU64,
|
||||
}
|
||||
|
||||
impl Default for FirewallConfig {
|
||||
fn default() -> Self {
|
||||
FirewallConfig {
|
||||
port: DEFAULT_PORT,
|
||||
port: NonZeroU16::new(DEFAULT_PORT).unwrap(),
|
||||
fail_open: true,
|
||||
min_age_hours: 0,
|
||||
min_age_hours: 24,
|
||||
denylist: DenylistSource::default(),
|
||||
allow: HashMap::new(),
|
||||
telemetry: true,
|
||||
registries: BUILT_IN.to_vec(),
|
||||
registries: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -63,7 +64,7 @@ impl Default for DenylistSource {
|
||||
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
|
||||
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
|
||||
.into(),
|
||||
poll_secs: 10,
|
||||
poll_secs: NonZeroU64::new(10).unwrap(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,9 +86,13 @@ impl FirewallConfig {
|
||||
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 invalid =
|
||||
|e| Error::new(ErrorKind::InvalidData, format!("{}: {e}", path.display()));
|
||||
let layer: Value = serde_json::from_str(&text).map_err(invalid)?;
|
||||
FirewallConfig::deserialize(&layer).map_err(invalid)?;
|
||||
let 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");
|
||||
};
|
||||
@@ -97,19 +102,7 @@ impl FirewallConfig {
|
||||
}
|
||||
|
||||
pub fn local_url(&self, path: &str) -> String {
|
||||
crate::local_url(self.port, path)
|
||||
}
|
||||
|
||||
/// Without building a `Firewall`, which needs a runtime.
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
crate::local_url(self.port.get(), path)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -129,3 +122,72 @@ fn merge(base: &mut Map<String, Value>, top: Map<String, 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)
|
||||
}
|
||||
+72
-7
@@ -1,4 +1,4 @@
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::config::{AllowedPackage, FirewallConfig};
|
||||
use crate::policies::filters::age::Age;
|
||||
use crate::policies::filters::denylist::Denylist;
|
||||
use crate::policies::filters::osv::Osv;
|
||||
@@ -6,11 +6,16 @@ use crate::policies::filters::Filter;
|
||||
use crate::policies::registries::*;
|
||||
use regex::Regex;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use std::time::Instant;
|
||||
|
||||
pub struct Firewall {
|
||||
pub config: FirewallConfig,
|
||||
registries: Vec<&'static Registry>,
|
||||
patterns: HashMap<&'static str, Vec<Regex>>,
|
||||
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 {
|
||||
@@ -21,18 +26,19 @@ impl Default for Firewall {
|
||||
|
||||
impl Firewall {
|
||||
pub fn new(config: FirewallConfig) -> Self {
|
||||
let registries = with_mirrors(&config.registries);
|
||||
let mut patterns = HashMap::new();
|
||||
for registry in &config.registries {
|
||||
for registry in ®istries {
|
||||
let compiled = registry.patterns.iter().map(|p| Regex::new(p.re).expect(p.re));
|
||||
patterns.insert(registry.prefix, compiled.collect());
|
||||
}
|
||||
let ecosystems = config.registries.iter().map(|r| r.osv_name);
|
||||
let ecosystems = registries.iter().map(|r| r.osv_name);
|
||||
let denylist = Denylist::new(config.denylist.clone(), ecosystems);
|
||||
let mut filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
|
||||
if config.min_age_hours > 0 {
|
||||
filters.push(Box::new(Age::new(config.min_age_hours)));
|
||||
}
|
||||
Firewall { config, patterns, filters }
|
||||
Firewall { config, registries, patterns, filters, age_lifted: Mutex::default() }
|
||||
}
|
||||
|
||||
pub fn match_on<'a>(
|
||||
@@ -40,15 +46,49 @@ impl Firewall {
|
||||
url: &'a str,
|
||||
key: fn(&Registry) -> &'static str,
|
||||
) -> Option<(&'static Registry, &'a str)> {
|
||||
self.config.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
|
||||
self.registries.iter().find_map(|r| url.strip_prefix(key(r)).map(|rest| (*r, rest)))
|
||||
}
|
||||
|
||||
pub fn hosts(&self) -> Vec<&'static str> {
|
||||
self.config.hosts()
|
||||
let mut hosts: Vec<&'static str> = Vec::new();
|
||||
for registry in &self.registries {
|
||||
let host = registry.host();
|
||||
if !hosts.contains(&host) {
|
||||
hosts.push(host);
|
||||
}
|
||||
}
|
||||
hosts
|
||||
}
|
||||
|
||||
pub fn is_https_url(&self, url: &str) -> bool {
|
||||
let host = host_of(url);
|
||||
self.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
|
||||
}
|
||||
|
||||
pub fn is_index_url(&self, url: &str) -> bool {
|
||||
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
|
||||
self.registries.iter().any(|r| r.index && r.host() == host_of(url))
|
||||
}
|
||||
|
||||
/// The URL at the first mirror of the built-in registry it is on, if one is configured.
|
||||
pub fn mirrored(&self, url: String) -> String {
|
||||
for (upstream, mirror) in self.mirrors() {
|
||||
if let Some(rest) = url.strip_prefix(upstream) {
|
||||
return format!("{mirror}{rest}");
|
||||
}
|
||||
}
|
||||
url
|
||||
}
|
||||
|
||||
/// Each built-in upstream with a mirror, paired with its first one.
|
||||
pub fn mirrors(&self) -> Vec<(&'static str, &str)> {
|
||||
let mut mirrors = Vec::new();
|
||||
for registry in BUILT_IN {
|
||||
let first = self.config.registries.get(registry.name()).and_then(|m| m.first());
|
||||
if let Some(mirror) = first {
|
||||
mirrors.push((registry.upstream, mirror.as_str()));
|
||||
}
|
||||
}
|
||||
mirrors
|
||||
}
|
||||
|
||||
pub fn resolve(&self, registry: &'static Registry, rest: &str) -> Resolved {
|
||||
@@ -58,4 +98,29 @@ impl Firewall {
|
||||
pub fn filters(&self) -> &[Box<dyn Filter>] {
|
||||
&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
|
||||
}
|
||||
+6
-1
@@ -1,4 +1,5 @@
|
||||
pub mod aliasing;
|
||||
pub mod blocks;
|
||||
pub mod config;
|
||||
pub mod firewall;
|
||||
pub mod http;
|
||||
@@ -24,7 +25,11 @@ pub fn local_url(port: u16, path: &str) -> String {
|
||||
}
|
||||
|
||||
pub fn init_logging(default_level: &str) {
|
||||
fmt().with_env_filter(log_filter(default_level)).with_target(false).with_writer(std::io::stderr).init();
|
||||
fmt()
|
||||
.with_env_filter(log_filter(default_level))
|
||||
.with_target(false)
|
||||
.with_writer(std::io::stderr)
|
||||
.init();
|
||||
}
|
||||
|
||||
pub(crate) fn log_filter(default_level: &str) -> EnvFilter {
|
||||
|
||||
+1
-17
@@ -12,10 +12,6 @@ fn main() {
|
||||
firewall::init_logging("info");
|
||||
serve()
|
||||
}
|
||||
if matches.subcommand_matches("uninstall").is_some() {
|
||||
firewall::init_logging("info");
|
||||
uninstall()
|
||||
}
|
||||
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
|
||||
let program = command.next().unwrap();
|
||||
firewall::init_logging("warn");
|
||||
@@ -44,8 +40,6 @@ fn options() -> Command {
|
||||
Command::new("serve")
|
||||
.about("Serve a local registry for package managers configured to use it"),
|
||||
)
|
||||
// Run as root by the macOS app's uninstall.
|
||||
.subcommand(Command::new("uninstall").hide(true))
|
||||
.arg(
|
||||
Arg::new("COMMAND")
|
||||
.help("A command to run with its package downloads screened")
|
||||
@@ -64,10 +58,7 @@ fn serve() -> ! {
|
||||
Ok(()) => exit(0),
|
||||
Err(e) if e.kind() == ErrorKind::AddrInUse => {
|
||||
let location = config::path().map_or("the config".into(), |p| p.display().to_string());
|
||||
fail(
|
||||
"port",
|
||||
&format!("port {port} is in use, set another in {location}"),
|
||||
)
|
||||
fail("port", &format!("port {port} is in use, set another in {location}"))
|
||||
}
|
||||
Err(e) if e.kind() == ErrorKind::PermissionDenied => {
|
||||
fail("port", &format!("not allowed to listen on port {port}"))
|
||||
@@ -76,13 +67,6 @@ fn serve() -> ! {
|
||||
}
|
||||
}
|
||||
|
||||
fn uninstall() -> ! {
|
||||
match firewall::os::uninstall() {
|
||||
Ok(()) => exit(0),
|
||||
Err(e) => fail("uninstall", &format!("could not uninstall: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
telemetry::set_mode("wrapper");
|
||||
let command = &invocation.program;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
use crate::http::{blocked, https_url};
|
||||
use crate::{policies, telemetry};
|
||||
use crate::Firewall;
|
||||
use crate::{policies, telemetry};
|
||||
use rama::error::BoxError;
|
||||
use rama::http::{Request, Response};
|
||||
use rama::{Layer, Service};
|
||||
|
||||
@@ -75,8 +75,8 @@ fn create() -> Result<SecKey, BoxError> {
|
||||
.set_key_type(KeyType::ec_sec_prime_random())
|
||||
.set_token(Token::SecureEnclave)
|
||||
.set_access_control(access);
|
||||
let key = SecKey::new(&options)
|
||||
.map_err(|e| format!("secure enclave key generation failed: {e}"))?;
|
||||
let key =
|
||||
SecKey::new(&options).map_err(|e| format!("secure enclave key generation failed: {e}"))?;
|
||||
|
||||
let blob = key
|
||||
.attributes()
|
||||
@@ -102,7 +102,10 @@ fn load(blob: &[u8]) -> Result<SecKey, BoxError> {
|
||||
CFString::wrap_under_get_rule(kSecAttrKeyClass),
|
||||
CFString::wrap_under_get_rule(kSecAttrKeyClassPrivate).into_CFType(),
|
||||
),
|
||||
(CFString::from_static_string(TOKEN_OBJECT_ID), CFData::from_buffer(blob).into_CFType()),
|
||||
(
|
||||
CFString::from_static_string(TOKEN_OBJECT_ID),
|
||||
CFData::from_buffer(blob).into_CFType(),
|
||||
),
|
||||
])
|
||||
};
|
||||
let mut error = ptr::null_mut();
|
||||
|
||||
@@ -5,8 +5,8 @@ mod uninstall;
|
||||
pub use uninstall::uninstall;
|
||||
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::{mitm, telemetry};
|
||||
use crate::Firewall;
|
||||
use crate::{blocks, mitm, telemetry};
|
||||
use rama::bytes::Bytes;
|
||||
use rama::error::BoxError;
|
||||
use rama::io::BridgeIo;
|
||||
@@ -39,6 +39,7 @@ const CONFIG_FILE: &str = "/Library/Application Support/Firewall/config.json";
|
||||
struct Handler {
|
||||
ca: Arc<mitm::CertificateAuthority>,
|
||||
hosts: Vec<&'static str>,
|
||||
firewall: Arc<Firewall>,
|
||||
service: mitm::MitmService,
|
||||
}
|
||||
|
||||
@@ -58,7 +59,7 @@ impl TransparentProxyHandler for Handler {
|
||||
let message = String::from_utf8_lossy(&message).into_owned();
|
||||
Some(Bytes::from(match message.as_str() {
|
||||
"ca" => self.ca.pem.clone(),
|
||||
other => format!("error: unknown message {other}"),
|
||||
other => blocks::answer(&self.firewall, CONFIG_FILE.as_ref(), other),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -106,15 +107,16 @@ pub fn system_config() -> Option<PathBuf> {
|
||||
fn handler(exec: Executor) -> Result<Handler, BoxError> {
|
||||
telemetry::set_mode("extension");
|
||||
telemetry::heartbeat();
|
||||
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()]).inspect_err(|_| telemetry::error("config"))?;
|
||||
let config = FirewallConfig::load_from(&[CONFIG_FILE.into()])
|
||||
.inspect_err(|_| telemetry::error("config"))?;
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
let hosts = firewall.hosts();
|
||||
let key = enclave::EnclaveKey::load_or_create().inspect_err(|_| telemetry::error("enclave"))?;
|
||||
let signer: mitm::CaSigner = Arc::new(key);
|
||||
let ca = install::certificate_authority(&hosts, &*signer);
|
||||
let ca = Arc::new(ca.inspect_err(|_| telemetry::error("ca"))?);
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, service })
|
||||
let service = mitm::mitm_service(firewall.clone(), &ca, signer, None, exec);
|
||||
Ok(Handler { ca, hosts, firewall, service })
|
||||
}
|
||||
|
||||
/// stderr is invisible in a system extension, so events go to the unified log:
|
||||
@@ -124,7 +126,8 @@ fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
|
||||
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
|
||||
if let Ok(oslog) = OsLogLayer::new(BUNDLE_ID, "extension") {
|
||||
let oslog = oslog.with_privacy(Privacy::Public);
|
||||
let _ = tracing_subscriber::registry().with(crate::log_filter("info")).with(oslog).try_init();
|
||||
let _ =
|
||||
tracing_subscriber::registry().with(crate::log_filter("info")).with(oslog).try_init();
|
||||
}
|
||||
true
|
||||
}
|
||||
|
||||
+1
-7
@@ -4,16 +4,10 @@ pub mod macos;
|
||||
pub mod windows;
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub use macos::{system_config, uninstall};
|
||||
pub use macos::system_config;
|
||||
#[cfg(windows)]
|
||||
pub use windows::system_config;
|
||||
|
||||
/// Elsewhere nothing is installed system-wide.
|
||||
#[cfg(not(target_os = "macos"))]
|
||||
pub fn uninstall() -> Result<(), rama::error::BoxError> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(any(target_os = "macos", windows)))]
|
||||
pub fn system_config() -> Option<std::path::PathBuf> {
|
||||
None
|
||||
|
||||
+40
-14
@@ -7,9 +7,12 @@ 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!(
|
||||
@@ -20,6 +23,9 @@ const DATA_DIR_ACL: &str = concat!(
|
||||
"(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],
|
||||
@@ -31,9 +37,17 @@ pub fn load_or_create_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))?;
|
||||
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
|
||||
}
|
||||
};
|
||||
@@ -50,25 +64,39 @@ pub fn load_or_create_ca(
|
||||
Ok(ca)
|
||||
}
|
||||
|
||||
/// Created on first use, its owner and ACL reset every time. One a user made first, or a link,
|
||||
/// 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();
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&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)?
|
||||
std::fs::remove_dir_all(&dir)?;
|
||||
false
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
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)?;
|
||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||
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)
|
||||
}
|
||||
|
||||
@@ -89,9 +117,7 @@ fn owned_by_admins(path: &Path) -> windows::core::Result<bool> {
|
||||
.ok()?
|
||||
};
|
||||
let admins = [WinBuiltinAdministratorsSid, WinLocalSystemSid];
|
||||
let owned = admins
|
||||
.into_iter()
|
||||
.any(|sid| unsafe { IsWellKnownSid(owner, sid) }.as_bool());
|
||||
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)
|
||||
|
||||
+10
-20
@@ -2,11 +2,16 @@
|
||||
//! 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;
|
||||
@@ -43,9 +48,7 @@ pub fn system_config() -> Option<PathBuf> {
|
||||
/// 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 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"),
|
||||
@@ -66,17 +69,12 @@ fn expand_environment(path: &HSTRING) -> PathBuf {
|
||||
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)],
|
||||
))
|
||||
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()
|
||||
(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
|
||||
@@ -86,11 +84,7 @@ fn write_hosts_entries(hosts: &[&'static str]) -> std::io::Result<()> {
|
||||
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();
|
||||
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");
|
||||
}
|
||||
@@ -115,11 +109,7 @@ fn security_descriptor(sddl: &str) -> windows::core::Result<Owned<HLOCAL>> {
|
||||
}
|
||||
|
||||
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_if_missing(LOCAL_MACHINE.open(key).and_then(|key| key.get_string(value)))
|
||||
}
|
||||
|
||||
/// `None` for a missing key or value.
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
//! How the tray asks the service what it blocked, and allows a new package, 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::*;
|
||||
use windows::Win32::System::Pipes::{GetNamedPipeClientProcessId, ImpersonateNamedPipeClient};
|
||||
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 ") && !client_is_admin(client.get_ref())? {
|
||||
"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())
|
||||
}
|
||||
|
||||
/// Elevated, as only an elevated token has the Administrators group enabled.
|
||||
fn client_is_admin(client: &NamedPipeServer) -> windows::core::Result<bool> {
|
||||
let mut sid = [0u32; SECURITY_MAX_SID_SIZE as usize / 4];
|
||||
let admins = PSID(sid.as_mut_ptr().cast());
|
||||
let mut size = SECURITY_MAX_SID_SIZE;
|
||||
unsafe { CreateWellKnownSid(WinBuiltinAdministratorsSid, None, Some(admins), &mut size)? };
|
||||
unsafe { ImpersonateNamedPipeClient(HANDLE(client.as_raw_handle()))? };
|
||||
let mut member = false.into();
|
||||
let checked = unsafe { CheckTokenMembership(None, admins, &mut member) };
|
||||
// Carrying on as the client would be worse than stopping.
|
||||
if unsafe { RevertToSelf() }.is_err() {
|
||||
std::process::abort();
|
||||
}
|
||||
checked?;
|
||||
Ok(member.as_bool())
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
use super::ca::{load_or_create_ca, secure_data_dir};
|
||||
use super::tpm::CaKey;
|
||||
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME};
|
||||
use 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};
|
||||
@@ -26,6 +28,7 @@ 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,
|
||||
@@ -73,8 +76,10 @@ fn run_until_stopped() -> Result<(), BoxError> {
|
||||
| ServiceControl::Shutdown
|
||||
| ServiceControl::Pause
|
||||
| ServiceControl::Continue => {
|
||||
let _ = control_sender.send(control);
|
||||
ServiceControlHandlerResult::NoError
|
||||
// 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,
|
||||
@@ -89,16 +94,24 @@ fn run_until_stopped() -> Result<(), BoxError> {
|
||||
}
|
||||
};
|
||||
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
|
||||
while let Some(control) = control_receiver.recv().await {
|
||||
while let Some((control, reply)) = control_receiver.recv().await {
|
||||
let (hosts, state) = match control {
|
||||
ServiceControl::Pause => (&[][..], ServiceState::Paused),
|
||||
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
|
||||
_ => break,
|
||||
};
|
||||
match write_hosts_entries(hosts) {
|
||||
Ok(()) => set_status(&status, state, false)?,
|
||||
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
|
||||
}
|
||||
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>(())
|
||||
});
|
||||
@@ -138,16 +151,19 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
|
||||
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_debug().into_layer(upstream);
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,13 @@ impl CaKey {
|
||||
}
|
||||
}
|
||||
|
||||
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>());
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.5 KiB |
@@ -0,0 +1,531 @@
|
||||
//! 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, HRESULT, PCWSTR};
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::LibraryLoader::GetModuleHandleW;
|
||||
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
|
||||
use windows::Win32::System::Threading::*;
|
||||
use windows::Win32::UI::Controls::*;
|
||||
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 requests, and `allow` the elevated `firewall-tray` command.
|
||||
const ALLOW: &str = "allow";
|
||||
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> {
|
||||
let args: Vec<String> = std::env::args().skip(1).collect();
|
||||
if let [verb, id, label @ ..] = &args[..] {
|
||||
if verb == ALLOW {
|
||||
// Run by `run_elevated`, so the service can tell an admin asked.
|
||||
let trusted = ask_service(&format!("{ALLOW} {id}"))
|
||||
.and_then(|reply| answered(&format!("Trusted {}", label.join(" ")), &reply));
|
||||
if let Err(error) = trusted {
|
||||
show_error(error);
|
||||
std::process::exit(1);
|
||||
}
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// 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 let Some(trust) = alert(&package) {
|
||||
allow(package, trust, &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 trustable = package.trustable();
|
||||
let once =
|
||||
MenuItem::with_id(format!("{ALLOW_ONCE}:{i}"), "Allow Once", trustable, None);
|
||||
let always =
|
||||
MenuItem::with_id(format!("{ALLOW}:{i}"), "Trust this version", trustable, None);
|
||||
let items: &[&dyn IsMenuItem] =
|
||||
if package.malicious() { &[&reason] } else { &[&reason, &once, &always] };
|
||||
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((verb @ (ALLOW | ALLOW_ONCE), i)) = id.split_once(':') {
|
||||
let trust = verb == ALLOW;
|
||||
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(package, trust, &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);
|
||||
}
|
||||
}
|
||||
|
||||
const ALLOW_ONCE_BUTTON: i32 = 100;
|
||||
const TRUST_BUTTON: i32 = 101;
|
||||
/// `firewall.rc`'s icon, embedded by `build.rs`.
|
||||
const STRIX_ICON: PCWSTR = PCWSTR(1 as _);
|
||||
|
||||
/// `Some(true)` if the user chose to trust it, `Some(false)` to allow it once.
|
||||
/// A task dialog, as a message box can't have both buttons.
|
||||
fn alert(package: &BlockedPackage) -> Option<bool> {
|
||||
let text = format!("{} {}.", package.label(), package.reason);
|
||||
let (title, text, icon) = 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, TD_ERROR_ICON)
|
||||
} else {
|
||||
("Held back a new package".to_owned(), text, STRIX_ICON)
|
||||
};
|
||||
let button = |id, text| TASKDIALOG_BUTTON { nButtonID: id, pszButtonText: text };
|
||||
let allow = [
|
||||
button(ALLOW_ONCE_BUTTON, w!("Allow Once")),
|
||||
button(TRUST_BUTTON, w!("Trust this version")),
|
||||
];
|
||||
let buttons: &[_] = if package.trustable() { &allow } else { &[] };
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(linked(&text)));
|
||||
let dialog = TASKDIALOGCONFIG {
|
||||
cbSize: size_of::<TASKDIALOGCONFIG>() as u32,
|
||||
dwFlags: TDF_ENABLE_HYPERLINKS,
|
||||
// Where `STRIX_ICON` is loaded from; the `TD_` icons are Windows'.
|
||||
hInstance: unsafe { GetModuleHandleW(None) }.ok()?.into(),
|
||||
dwCommonButtons: TDCBF_CLOSE_BUTTON,
|
||||
pszWindowTitle: w!("Strix Firewall"),
|
||||
Anonymous1: TASKDIALOGCONFIG_0 { pszMainIcon: icon },
|
||||
pszMainInstruction: PCWSTR(title.as_ptr()),
|
||||
pszContent: PCWSTR(text.as_ptr()),
|
||||
cButtons: buttons.len() as u32,
|
||||
pButtons: buttons.as_ptr(),
|
||||
pfCallback: Some(on_dialog),
|
||||
..Default::default()
|
||||
};
|
||||
let mut chosen = 0;
|
||||
unsafe { TaskDialogIndirect(&dialog, Some(&raw mut chosen), None, None) }.ok()?;
|
||||
match chosen {
|
||||
ALLOW_ONCE_BUTTON => Some(false),
|
||||
TRUST_BUTTON => Some(true),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Asks for the foreground, as `MB_SETFOREGROUND` did; Windows may still leave it behind.
|
||||
/// Opens the links `linked` made, in the browser.
|
||||
unsafe extern "system" fn on_dialog(
|
||||
window: HWND,
|
||||
event: TASKDIALOG_NOTIFICATIONS,
|
||||
_: WPARAM,
|
||||
link: LPARAM,
|
||||
_: isize,
|
||||
) -> HRESULT {
|
||||
if event == TDN_CREATED {
|
||||
let _ = unsafe { SetForegroundWindow(window) };
|
||||
} else if event == TDN_HYPERLINK_CLICKED {
|
||||
let link = PCWSTR(link.0 as *const u16);
|
||||
// Only web pages, whatever a block's reason contains.
|
||||
let web = unsafe { link.to_string() }
|
||||
.is_ok_and(|link| link.starts_with("https://") || link.starts_with("http://"));
|
||||
if web {
|
||||
let none = PCWSTR::null();
|
||||
unsafe { ShellExecuteW(None, w!("open"), link, none, none, SW_SHOWNORMAL) };
|
||||
}
|
||||
}
|
||||
S_OK
|
||||
}
|
||||
|
||||
/// A task dialog's markup for the text, with each URL a link.
|
||||
fn linked(text: &str) -> String {
|
||||
let mut marked = String::new();
|
||||
let mut rest = text;
|
||||
while let Some(start) =
|
||||
["https://", "http://"].iter().filter_map(|scheme| rest.find(scheme)).min()
|
||||
{
|
||||
let end = rest[start..].find(|c: char| c.is_whitespace() || c == ',' || c == ')');
|
||||
let url = rest[start..end.map_or(rest.len(), |end| start + end)].trim_end_matches('.');
|
||||
marked += &rest[..start];
|
||||
marked += &format!("<a href=\"{url}\">{url}</a>");
|
||||
rest = &rest[start + url.len()..];
|
||||
}
|
||||
marked + rest
|
||||
}
|
||||
|
||||
/// Trusting writes the config for every user, so it goes through a UAC prompt.
|
||||
/// The elevated process shows its own errors and late replies.
|
||||
fn allow(package: BlockedPackage, trust: bool, allowed: &Sender<BlockedPackage>) {
|
||||
let allowed_it = if trust {
|
||||
run_elevated(&[ALLOW, &package.id.to_string(), &package.label()])
|
||||
.map(|code| code == Some(0))
|
||||
} else {
|
||||
let title = format!("Allowed {} for the next minute", package.label());
|
||||
ask_service(&format!("{ALLOW_ONCE} {}", package.id))
|
||||
.and_then(|reply| answered(&title, &reply))
|
||||
.map(|()| true)
|
||||
};
|
||||
match allowed_it {
|
||||
Ok(true) => {
|
||||
let _ = allowed.send(package);
|
||||
}
|
||||
Ok(false) => {}
|
||||
Err(error) => show_error(error),
|
||||
}
|
||||
}
|
||||
|
||||
/// Tells the user, under `title`, when the install had already stopped waiting.
|
||||
fn answered(title: &str, reply: &str) -> Result<(), BoxError> {
|
||||
match reply {
|
||||
"ok" => Ok(()),
|
||||
"late" => {
|
||||
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) };
|
||||
Ok(())
|
||||
}
|
||||
other => Err(other.into()),
|
||||
}
|
||||
}
|
||||
|
||||
/// This program as an admin, waiting for its exit code; `None` if the user said no.
|
||||
fn run_elevated(args: &[&str]) -> Result<Option<u32>, BoxError> {
|
||||
let exe = HSTRING::from(std::env::current_exe()?.as_path());
|
||||
let parameters = HSTRING::from(args.join(" "));
|
||||
let mut info = SHELLEXECUTEINFOW {
|
||||
cbSize: size_of::<SHELLEXECUTEINFOW>() as u32,
|
||||
fMask: SEE_MASK_NOCLOSEPROCESS,
|
||||
lpVerb: w!("runas"),
|
||||
lpFile: PCWSTR(exe.as_ptr()),
|
||||
lpParameters: PCWSTR(parameters.as_ptr()),
|
||||
nShow: SW_HIDE.0,
|
||||
..Default::default()
|
||||
};
|
||||
if let Err(error) = unsafe { ShellExecuteExW(&mut info) } {
|
||||
if error.code() == ERROR_CANCELLED.to_hresult() {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(error.into());
|
||||
}
|
||||
let process = unsafe { Owned::new(info.hProcess) };
|
||||
unsafe { WaitForSingleObject(*process, INFINITE) };
|
||||
let mut code = 1;
|
||||
unsafe { GetExitCodeProcess(*process, &mut code)? };
|
||||
Ok(Some(code))
|
||||
}
|
||||
|
||||
/// 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
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
use super::ca::remove_our_cas_except;
|
||||
use super::tpm::CaKey;
|
||||
use super::*;
|
||||
use std::io::ErrorKind;
|
||||
use std::path::Path;
|
||||
use tracing::info;
|
||||
|
||||
/// Run by the MSI after it deletes the service, which would restore the hosts entries. Does
|
||||
/// nothing for what is already gone.
|
||||
pub fn uninstall() -> Result<(), BoxError> {
|
||||
write_hosts_entries(&[])?;
|
||||
remove_our_cas_except(None)?;
|
||||
none_if_missing(LOCAL_MACHINE.remove_tree(REGISTRY_KEY))?;
|
||||
CaKey::delete_machine_key()?;
|
||||
let entries = match std::fs::read_dir(data_dir()) {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Vec::new(),
|
||||
entries => entries?.collect::<Result<Vec<_>, _>>()?,
|
||||
};
|
||||
for path in entries.into_iter().map(|entry| entry.path()) {
|
||||
// The admin's config stays for a reinstall, as on macOS.
|
||||
if path != config_file() {
|
||||
remove_path(&path)?;
|
||||
}
|
||||
}
|
||||
// Fails while the config is in it.
|
||||
let _ = std::fs::remove_dir(data_dir());
|
||||
info!("removed the CA, its key and the data folder");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A file or a folder, if it is there.
|
||||
fn remove_path(path: &Path) -> std::io::Result<()> {
|
||||
let removed = match path.is_dir() {
|
||||
true => std::fs::remove_dir_all(path),
|
||||
false => std::fs::remove_file(path),
|
||||
};
|
||||
match removed {
|
||||
Err(error) if error.kind() == ErrorKind::NotFound => Ok(()),
|
||||
removed => removed,
|
||||
}
|
||||
}
|
||||
@@ -63,9 +63,7 @@ impl Filter for Denylist {
|
||||
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
|
||||
Box::pin(async move {
|
||||
let eco = registry.osv_name;
|
||||
let load = || async {
|
||||
RwLock::new(Arc::new(load(&self.source, eco).await))
|
||||
};
|
||||
let load = || async { RwLock::new(Arc::new(load(&self.source, eco).await)) };
|
||||
let list = self.lists[eco].get_or_init(load).await;
|
||||
let list = Arc::clone(&list.read().expect("denylist lock"));
|
||||
match &*list {
|
||||
@@ -143,7 +141,7 @@ pub fn describe(advisories: &[&Advisory]) -> String {
|
||||
|
||||
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
|
||||
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
|
||||
let period = Duration::from_secs(source.poll_secs);
|
||||
let period = Duration::from_secs(source.poll_secs.get());
|
||||
let mut seen = revision(&source, None).await;
|
||||
loop {
|
||||
tokio::time::sleep(period).await;
|
||||
|
||||
@@ -3,6 +3,7 @@ pub mod denylist;
|
||||
pub mod osv;
|
||||
|
||||
use super::registries::{Package, Registry};
|
||||
use crate::config::AllowedPackage;
|
||||
use crate::Firewall;
|
||||
use std::future::Future;
|
||||
use std::pin::Pin;
|
||||
@@ -25,16 +26,19 @@ pub trait Filter: Send + Sync {
|
||||
}
|
||||
|
||||
/// 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(
|
||||
firewall: &Firewall,
|
||||
registry: &'static Registry,
|
||||
pkg: &Package,
|
||||
) -> Option<(&'static str, Verdict)> {
|
||||
if allowed(firewall, registry, pkg) {
|
||||
return None;
|
||||
}
|
||||
let lifted = allowed(firewall, registry, pkg)
|
||||
|| firewall.age_check_lifted(registry.eco, |entry| matches(registry, pkg, entry));
|
||||
let mut unsure = None;
|
||||
for filter in firewall.filters() {
|
||||
if lifted && filter.name() == "age" {
|
||||
continue;
|
||||
}
|
||||
match filter.check(registry, pkg).await {
|
||||
Verdict::Allow => continue,
|
||||
Verdict::Deny(reason) => return Some((filter.name(), Verdict::Deny(reason))),
|
||||
@@ -49,13 +53,15 @@ fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
|
||||
let Some(entries) = firewall.config.allow.get(registry.eco) else {
|
||||
return false;
|
||||
};
|
||||
entries.iter().any(|entry| {
|
||||
let name_matches = match registry.normalize {
|
||||
Some(normalize) => normalize(&entry.name) == pkg.name,
|
||||
None => entry.name == pkg.name,
|
||||
};
|
||||
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
|
||||
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
|
||||
name_matches && (version.is_none() || version == pkg.version.as_deref())
|
||||
})
|
||||
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())
|
||||
}
|
||||
@@ -41,7 +41,11 @@ impl Filter for Osv {
|
||||
if hits.is_empty() {
|
||||
Verdict::Allow
|
||||
} else {
|
||||
Verdict::Deny(format!("is flagged malicious by OSV ({})", hits.join(", ")))
|
||||
let links: Vec<String> = hits
|
||||
.iter()
|
||||
.map(|id| format!("{id} https://osv.dev/vulnerability/{id}"))
|
||||
.collect();
|
||||
Verdict::Deny(format!("is flagged malicious by OSV: {}", links.join(", ")))
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -69,7 +69,11 @@ pub async fn screen(url: &str, firewall: &Firewall) -> Option<String> {
|
||||
};
|
||||
info!(eco = registry.eco, pkg = %pkg.label(), %url, "screening");
|
||||
match consult(firewall, registry, &pkg).await {
|
||||
Some((_, Verdict::Deny(reason))) => {
|
||||
Some((filter, Verdict::Deny(reason))) => {
|
||||
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((filter, Verdict::Unknown(e))) if !firewall.config.fail_open => {
|
||||
|
||||
@@ -36,7 +36,7 @@ pub struct Pattern {
|
||||
}
|
||||
|
||||
/// Requests to the local server match on `prefix`; intercepted URLs match on `upstream`.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Registry {
|
||||
pub prefix: &'static str,
|
||||
pub eco: &'static str,
|
||||
@@ -52,7 +52,7 @@ pub struct Registry {
|
||||
}
|
||||
|
||||
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Published {
|
||||
pub url: &'static str,
|
||||
pub pointer: &'static str,
|
||||
@@ -63,6 +63,11 @@ impl Registry {
|
||||
pub fn host(&self) -> &'static str {
|
||||
host_of(self.upstream)
|
||||
}
|
||||
|
||||
/// What the config file calls it.
|
||||
pub fn name(&self) -> &'static str {
|
||||
self.prefix.trim_matches('/')
|
||||
}
|
||||
}
|
||||
|
||||
/// The host of an https:// URL, with the path dropped.
|
||||
|
||||
@@ -11,9 +11,12 @@ pub static PYPI: Registry = Registry {
|
||||
normalize: Some(pep503),
|
||||
patterns: &[
|
||||
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
|
||||
// A mirror serves the files from its index host.
|
||||
WHEEL,
|
||||
SDIST,
|
||||
Pattern { re: "", rewrite: Some(FILES) },
|
||||
],
|
||||
published: None,
|
||||
published: PUBLISHED,
|
||||
};
|
||||
|
||||
/// The directories are hash shards, but the filename names the distribution.
|
||||
@@ -24,25 +27,28 @@ pub static PYPI_FILES: Registry = Registry {
|
||||
upstream: "https://files.pythonhosted.org/",
|
||||
index: false,
|
||||
normalize: Some(pep503),
|
||||
patterns: &[
|
||||
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
|
||||
Pattern {
|
||||
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
|
||||
rewrite: None,
|
||||
},
|
||||
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
|
||||
Pattern {
|
||||
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
|
||||
rewrite: None,
|
||||
},
|
||||
],
|
||||
published: Some(Published {
|
||||
url: "https://pypi.org/pypi/{name}/{version}/json",
|
||||
pointer: "/urls/0/upload_time_iso_8601",
|
||||
escape: None,
|
||||
}),
|
||||
patterns: &[WHEEL, SDIST],
|
||||
published: PUBLISHED,
|
||||
};
|
||||
|
||||
// A wheel escapes - _ and . to _, so the first two fields are exactly name and version.
|
||||
const WHEEL: Pattern = Pattern {
|
||||
re: r"^packages/.+/(?<name>[^-/]+)-(?<version>[^-/]+)-[^/]+\.whl(?:\.metadata)?$",
|
||||
rewrite: None,
|
||||
};
|
||||
|
||||
// An sdist keeps the name's hyphens, so the version is the last field starting with a digit.
|
||||
const SDIST: Pattern = Pattern {
|
||||
re: r"^packages/.+/(?<name>.+)-(?<version>\d[^-/]*)\.(?:tar\.gz|zip|tar\.bz2)$",
|
||||
rewrite: None,
|
||||
};
|
||||
|
||||
const PUBLISHED: Option<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.
|
||||
fn pep503(name: &str) -> String {
|
||||
let mut out = String::with_capacity(name.len());
|
||||
|
||||
@@ -15,7 +15,7 @@ use std::sync::Arc;
|
||||
use tracing::{info, info_span, warn, Instrument};
|
||||
|
||||
pub fn serve(config: FirewallConfig) -> std::io::Result<()> {
|
||||
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port))?;
|
||||
let bound = std::net::TcpListener::bind(("127.0.0.1", config.port.get()))?;
|
||||
bound.set_nonblocking(true)?;
|
||||
crate::runtime()?.block_on(async move {
|
||||
let firewall = Arc::new(Firewall::new(config));
|
||||
|
||||
@@ -23,11 +23,7 @@ static TELEMETRY: LazyLock<Option<Telemetry>> = LazyLock::new(|| {
|
||||
return None;
|
||||
}
|
||||
let session = format!("{:016x}", rand::random::<u64>());
|
||||
Some(Telemetry {
|
||||
key,
|
||||
session,
|
||||
events: Mutex::default(),
|
||||
})
|
||||
Some(Telemetry { key, session, events: Mutex::default() })
|
||||
});
|
||||
|
||||
/// How the firewall is running: "extension", "service", "wrapper" or "proxy".
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
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
|
||||
);
|
||||
}
|
||||
+93
-10
@@ -1,4 +1,4 @@
|
||||
use firewall::config::path;
|
||||
use firewall::config::{path, AllowedPackage};
|
||||
use firewall::policies::filters::consult;
|
||||
use firewall::policies::{screen, Package};
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
@@ -7,7 +7,7 @@ use firewall::{Firewall, FirewallConfig};
|
||||
fn a_file_only_has_to_set_what_it_changes() {
|
||||
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
|
||||
assert!(!config.fail_open);
|
||||
assert_eq!(config.port, firewall::DEFAULT_PORT);
|
||||
assert_eq!(config.port.get(), firewall::DEFAULT_PORT);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -15,6 +15,28 @@ fn a_misspelled_setting_is_an_error() {
|
||||
assert!(serde_json::from_str::<FirewallConfig>(r#"{"fail-open": false}"#).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nonsense_values_are_errors() {
|
||||
let file = std::env::temp_dir().join(format!("firewall-nonsense-{}.json", std::process::id()));
|
||||
for bad in [
|
||||
r#"{"allow": {"nmp": [{"name": "left-pad"}]}}"#,
|
||||
r#"{"denylist": {"poll_secs": 0}}"#,
|
||||
r#"{"denylist": {"base": "raw.githubusercontent.com/usestrix"}}"#,
|
||||
r#"{"denylist": {"revision": "https://"}}"#,
|
||||
r#"{"port": 0}"#,
|
||||
r#"{"min_age_hours": 18446744073709551615}"#,
|
||||
r#"{"registries": {"npm": ["http://npm.example.com/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com:8443/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://[email protected]/"]}}"#,
|
||||
r#"{"registries": {"npm": ["https://npm.example.com/?a=/"]}}"#,
|
||||
] {
|
||||
std::fs::write(&file, bad).expect("write");
|
||||
assert!(FirewallConfig::load_from(std::slice::from_ref(&file)).is_err(), "{bad}");
|
||||
}
|
||||
std::fs::remove_file(&file).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_config_lives_under_the_config_directory() {
|
||||
let path = path().expect("a home directory");
|
||||
@@ -27,8 +49,13 @@ fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
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");
|
||||
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();
|
||||
@@ -39,14 +66,43 @@ fn a_later_file_wins_and_allow_lists_add_up() {
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_allowed_version_skips_the_filters() {
|
||||
// An unreachable denylist would otherwise leave the package unchecked.
|
||||
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 a_pypi_mirror_screens_the_files_it_serves() {
|
||||
let config = r#"{"registries": {"pypi": ["https://art.example.com/pypi/"]}}"#;
|
||||
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
|
||||
let url = "https://art.example.com/pypi/packages/ab/cd/six-1.16.0-py2.py3-none-any.whl";
|
||||
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()));
|
||||
}
|
||||
|
||||
#[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 = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
|
||||
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
|
||||
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
|
||||
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]
|
||||
@@ -57,3 +113,30 @@ async fn failing_closed_blocks_what_could_not_be_checked() {
|
||||
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"));
|
||||
}
|
||||
+36
-1
@@ -1,7 +1,9 @@
|
||||
use firewall::aliasing::forward::downgrade;
|
||||
use firewall::http::https_url;
|
||||
use firewall::Firewall;
|
||||
use firewall::{aliasing, Firewall, FirewallConfig};
|
||||
use rama::http::{Body, Request};
|
||||
use std::io::{Read, Write};
|
||||
use std::net::{TcpListener, TcpStream};
|
||||
|
||||
fn request(target: &str, host: Option<&str>) -> Request {
|
||||
let mut builder = Request::builder().method("GET").uri(target);
|
||||
@@ -35,6 +37,26 @@ fn requests_always_go_out_over_tls() {
|
||||
assert_eq!(https_url(&request("/six", None)), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_plain_http_upstream_is_not_upgraded() {
|
||||
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
|
||||
let addr = upstream.local_addr().expect("address");
|
||||
std::thread::spawn(move || {
|
||||
let (mut stream, _) = upstream.accept().expect("accept");
|
||||
let _ = stream.read(&mut [0; 4096]);
|
||||
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
|
||||
});
|
||||
|
||||
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
|
||||
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
|
||||
let request =
|
||||
format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
proxy.write_all(request.as_bytes()).expect("write");
|
||||
let mut response = String::new();
|
||||
proxy.read_to_string(&mut response).expect("read");
|
||||
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn downgrade_touches_known_hosts_in_text_only() {
|
||||
let text = b"https://pypi.org/x https://example.com/y".to_vec();
|
||||
@@ -43,3 +65,16 @@ async fn downgrade_touches_known_hosts_in_text_only() {
|
||||
let binary = vec![0x1f, 0x8b, 0xff, 0xfe];
|
||||
assert_eq!(downgrade(&firewall, binary.clone()), binary);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_mirror_stands_in_for_its_registry() {
|
||||
let mut config = FirewallConfig::default();
|
||||
config.registries.insert("pypi".into(), vec!["https://art.example.com/pypi/".into()]);
|
||||
let firewall = Firewall::new(config);
|
||||
let mirrored = firewall.mirrored("https://pypi.org/simple/six/".into());
|
||||
assert_eq!(mirrored, "https://art.example.com/pypi/simple/six/");
|
||||
let npm = "https://registry.npmjs.org/left-pad";
|
||||
assert_eq!(firewall.mirrored(npm.into()), npm);
|
||||
let links = b"https://art.example.com/pypi/packages/six.whl".to_vec();
|
||||
assert_eq!(downgrade(&firewall, links), b"http://pypi.org/packages/six.whl".to_vec());
|
||||
}
|
||||
@@ -36,7 +36,8 @@ pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
|
||||
fn run(probe: &Probe, port: u16) -> Outcome {
|
||||
let args = probe.args.iter().map(|arg| arg.to_string()).collect();
|
||||
let dir = probe.setup.and_then(|prepare| prepare());
|
||||
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref()) {
|
||||
match aliasing::run(port, &Invocation::new(probe.manager.command.into(), args), dir.as_deref())
|
||||
{
|
||||
Ok(0) => Outcome::Ok,
|
||||
Ok(_) => Outcome::Failed,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,
|
||||
|
||||
@@ -0,0 +1,193 @@
|
||||
<?xml version='1.0' encoding='windows-1252'?>
|
||||
<!--
|
||||
Copyright (C) 2017 Christopher R. Field.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
-->
|
||||
|
||||
<?if $(sys.BUILDARCH) = x64 or $(sys.BUILDARCH) = arm64 ?>
|
||||
<?define PlatformProgramFilesFolder = "ProgramFiles64Folder" ?>
|
||||
<?else ?>
|
||||
<?define PlatformProgramFilesFolder = "ProgramFilesFolder" ?>
|
||||
<?endif ?>
|
||||
|
||||
<Wix xmlns='http://schemas.microsoft.com/wix/2006/wi' xmlns:util='http://schemas.microsoft.com/wix/UtilExtension'>
|
||||
|
||||
<Product
|
||||
Id='*'
|
||||
Name='strix-firewall'
|
||||
UpgradeCode='D944C2F6-BF55-4B21-A60E-1BBDB1A66293'
|
||||
Manufacturer='Strix'
|
||||
Language='1033'
|
||||
Codepage='1252'
|
||||
Version='$(var.Version)'>
|
||||
|
||||
<Package Id='*'
|
||||
Keywords='Installer'
|
||||
Description='Screens package downloads for known malware before they reach your machine'
|
||||
Manufacturer='Strix'
|
||||
InstallerVersion='500'
|
||||
Languages='1033'
|
||||
Compressed='yes'
|
||||
InstallScope='perMachine'
|
||||
SummaryCodepage='1252'
|
||||
/>
|
||||
|
||||
<MajorUpgrade
|
||||
Schedule='afterInstallInitialize'
|
||||
DowngradeErrorMessage='A newer version of [ProductName] is already installed. Setup will now exit.'/>
|
||||
|
||||
<Media Id='1' Cabinet='media1.cab' EmbedCab='yes' DiskPrompt='CD-ROM #1'/>
|
||||
<Property Id='DiskPrompt' Value='strix-firewall Installation'/>
|
||||
|
||||
<Directory Id='TARGETDIR' Name='SourceDir'>
|
||||
<Directory Id='$(var.PlatformProgramFilesFolder)' Name='PFiles'>
|
||||
<Directory Id='APPLICATIONFOLDER' Name='Strix Firewall'>
|
||||
<Component Id='Path' Guid='033B1050-23D0-4F5A-A0D3-F4757B4B67E0' KeyPath='yes'>
|
||||
<Environment
|
||||
Id='PATH'
|
||||
Name='PATH'
|
||||
Value='[APPLICATIONFOLDER]'
|
||||
Permanent='no'
|
||||
Part='last'
|
||||
Action='set'
|
||||
System='yes'/>
|
||||
</Component>
|
||||
<Component Id='binary0' Guid='*'>
|
||||
<File
|
||||
Id='exe0'
|
||||
Name='firewall.exe'
|
||||
DiskId='1'
|
||||
Source='$(var.CargoTargetBinDir)\firewall.exe'
|
||||
KeyPath='yes'/>
|
||||
</Component>
|
||||
<Component Id='binary1' Guid='*'>
|
||||
<File
|
||||
Id='exe1'
|
||||
Name='firewall-service.exe'
|
||||
DiskId='1'
|
||||
Source='$(var.CargoTargetBinDir)\firewall-service.exe'
|
||||
KeyPath='yes'/>
|
||||
<ServiceInstall
|
||||
Name='StrixFirewall'
|
||||
DisplayName='Strix Firewall'
|
||||
Description='Screens package downloads for known malware'
|
||||
Type='ownProcess'
|
||||
Start='auto'
|
||||
ErrorControl='normal'
|
||||
Vital='yes'>
|
||||
<!-- The default, except that interactive users (IU) may also start (RP), stop (WP) and pause (DT) it, as the tray does. -->
|
||||
<PermissionEx Sddl='D:(A;;CCLCSWRPWPDTLOCRRC;;;SY)(A;;CCDCLCSWRPWPDTLOCRSDRCWDWO;;;BA)(A;;CCLCSWRPWPDTLOCRRC;;;IU)(A;;CCLCSWLOCRRC;;;SU)'/>
|
||||
</ServiceInstall>
|
||||
<!-- Waits, so a service that cannot start fails the install. -->
|
||||
<ServiceControl Id='StrixFirewall' Name='StrixFirewall' Start='install' Stop='both' Remove='uninstall' Wait='yes'/>
|
||||
<!-- For clients that read no trust store. SSL_CERT_FILE would replace OpenSSL's bundle. -->
|
||||
<Environment Id='NODE_EXTRA_CA_CERTS' Name='NODE_EXTRA_CA_CERTS' Value='[CommonAppDataFolder]Strix Firewall\ca.pem' Action='set' System='yes'/>
|
||||
<Environment Id='UV_SYSTEM_CERTS' Name='UV_SYSTEM_CERTS' Value='1' Action='set' System='yes'/>
|
||||
</Component>
|
||||
<Component Id='binary2' Guid='*'>
|
||||
<File
|
||||
Id='exe2'
|
||||
Name='firewall-tray.exe'
|
||||
DiskId='1'
|
||||
Source='$(var.CargoTargetBinDir)\firewall-tray.exe'
|
||||
KeyPath='yes'/>
|
||||
<RegistryValue
|
||||
Root='HKLM'
|
||||
Key='SOFTWARE\Microsoft\Windows\CurrentVersion\Run'
|
||||
Name='Strix Firewall'
|
||||
Type='string'
|
||||
Value='"[#exe2]"'/>
|
||||
</Component>
|
||||
</Directory>
|
||||
</Directory>
|
||||
</Directory>
|
||||
|
||||
<Feature
|
||||
Id='Binaries'
|
||||
Title='Application'
|
||||
Description='Installs the firewall and its service.'
|
||||
Level='1'
|
||||
ConfigurableDirectory='APPLICATIONFOLDER'
|
||||
AllowAdvertise='no'
|
||||
Display='expand'
|
||||
Absent='disallow'>
|
||||
<ComponentRef Id='binary0'/>
|
||||
<ComponentRef Id='binary1'/>
|
||||
<ComponentRef Id='binary2'/>
|
||||
|
||||
<Feature
|
||||
Id='Environment'
|
||||
Title='PATH Environment Variable'
|
||||
Description='Add the install location of the [ProductName] executable to the PATH system environment variable. This allows the [ProductName] executable to be called from any location.'
|
||||
Level='1'
|
||||
Absent='allow'>
|
||||
<ComponentRef Id='Path'/>
|
||||
</Feature>
|
||||
</Feature>
|
||||
|
||||
<!-- Removing the app must not leave the hosts file pointing at a service that is gone. -->
|
||||
<CustomAction
|
||||
Id='UninstallFirewall'
|
||||
FileKey='exe1'
|
||||
ExeCommand='uninstall'
|
||||
Execute='deferred'
|
||||
Impersonate='no'
|
||||
Return='check'/>
|
||||
<!--
|
||||
Registry hosts lead nowhere while the service is down, so bring it back, also when it fails to
|
||||
start. Through sc.exe, as MsiServiceConfigFailureActions cannot restart a service.
|
||||
-->
|
||||
<CustomAction
|
||||
Id='SetFailureActions'
|
||||
Directory='APPLICATIONFOLDER'
|
||||
ExeCommand='"[System64Folder]sc.exe" failure StrixFirewall reset= 3600 actions= restart/5000/restart/5000/restart/5000'
|
||||
Execute='deferred'
|
||||
Impersonate='no'
|
||||
Return='check'/>
|
||||
<CustomAction
|
||||
Id='SetFailureFlag'
|
||||
Directory='APPLICATIONFOLDER'
|
||||
ExeCommand='"[System64Folder]sc.exe" failureflag StrixFirewall 1'
|
||||
Execute='deferred'
|
||||
Impersonate='no'
|
||||
Return='check'/>
|
||||
<!-- From the exit dialog, which runs as the signed-in user rather than elevated. -->
|
||||
<CustomAction
|
||||
Id='StartTray'
|
||||
Directory='APPLICATIONFOLDER'
|
||||
ExeCommand='"[#exe2]"'
|
||||
Return='asyncNoWait'/>
|
||||
<!-- Before its file is replaced or removed. Trays WM_CLOSE does not reach, as in other sessions, are terminated. -->
|
||||
<util:CloseApplication Id='CloseTray' Target='firewall-tray.exe' CloseMessage='yes' RebootPrompt='no' TerminateProcess='0'/>
|
||||
<InstallExecuteSequence>
|
||||
<!-- An upgrade removes the old version too, but should keep screening. -->
|
||||
<Custom Action='UninstallFirewall' After='DeleteServices'>REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE</Custom>
|
||||
<Custom Action='SetFailureActions' After='InstallServices'>NOT REMOVE~="ALL"</Custom>
|
||||
<Custom Action='SetFailureFlag' After='SetFailureActions'>NOT REMOVE~="ALL"</Custom>
|
||||
<!-- The default, before InstallFiles, is after RemoveFiles, which would find the tray still running. -->
|
||||
<Custom Action='WixCloseApplications' Before='RemoveFiles'/>
|
||||
</InstallExecuteSequence>
|
||||
|
||||
<SetProperty Id='ARPINSTALLLOCATION' Value='[APPLICATIONFOLDER]' After='CostFinalize'/>
|
||||
<Property Id='ARPHELPLINK' Value='https://github.com/usestrix/firewall'/>
|
||||
|
||||
<UI>
|
||||
<UIRef Id='WixUI_FeatureTree'/>
|
||||
<Publish Dialog='WelcomeDlg' Control='Next' Event='NewDialog' Value='CustomizeDlg' Order='99'>1</Publish>
|
||||
<Publish Dialog='CustomizeDlg' Control='Back' Event='NewDialog' Value='WelcomeDlg' Order='99'>1</Publish>
|
||||
<Publish Dialog='ExitDialog' Control='Finish' Event='DoAction' Value='StartTray' Order='1'>NOT Installed</Publish>
|
||||
</UI>
|
||||
</Product>
|
||||
|
||||
</Wix>
|
||||
+6
-3
@@ -4,7 +4,10 @@ members = ["cargo:core"]
|
||||
[dist]
|
||||
cargo-dist-version = "0.32.0"
|
||||
ci = "github"
|
||||
installers = ["shell", "powershell", "homebrew"]
|
||||
tap = "usestrix/homebrew-tap"
|
||||
publish-jobs = ["homebrew", "./publish-crates"]
|
||||
installers = ["shell", "powershell", "msi"]
|
||||
# core/wix/main.wxs is edited to install the service.
|
||||
allow-dirty = ["msi"]
|
||||
features = ["machine-wide"]
|
||||
publish-jobs = ["./publish-crates"]
|
||||
global-artifacts-jobs = ["./build-macos-pkg"]
|
||||
targets = ["aarch64-apple-darwin", "x86_64-apple-darwin", "aarch64-unknown-linux-gnu", "x86_64-unknown-linux-gnu", "x86_64-pc-windows-msvc"]
|
||||
@@ -0,0 +1,93 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,11 +29,13 @@ enum FirewallError: Error, LocalizedError {
|
||||
case extensionUnavailable
|
||||
case screeningDidNotStart(NEVPNStatus)
|
||||
case extensionStopped(String)
|
||||
case extensionRefused(String)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
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 .extensionStopped(reason):
|
||||
return "The extension stopped: \(reason). Its log: log show --last 5m --predicate 'subsystem BEGINSWITH \"\(FirewallIDs.appBundleID)\"'"
|
||||
}
|
||||
@@ -51,6 +53,7 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
@Published private(set) var completed = false
|
||||
@Published private(set) var removed = false
|
||||
@Published private(set) var screening = false
|
||||
@Published private(set) var blocked: [BlockedPackage] = []
|
||||
|
||||
private let logger = Logger(subsystem: FirewallIDs.appBundleID, category: "installer")
|
||||
private var pendingRequest: (request: OSSystemExtensionRequest, continuation: CheckedContinuation<Void, Error>)?
|
||||
@@ -72,6 +75,7 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
await refresh()
|
||||
if CommandLine.arguments.contains("--install") { await install() }
|
||||
}
|
||||
Task { await watchBlocks() }
|
||||
}
|
||||
|
||||
// MARK: - Flows
|
||||
@@ -199,6 +203,39 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
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) }
|
||||
}
|
||||
if blocked.count > 10 { blocked.removeFirst(blocked.count - 10) }
|
||||
}
|
||||
try? await Task.sleep(nanoseconds: 500_000_000)
|
||||
}
|
||||
}
|
||||
|
||||
private func stage(_ step: ReferenceWritableKeyPath<FirewallModel, StepStatus>,
|
||||
running: String,
|
||||
work: @MainActor () async throws -> String) async -> Bool {
|
||||
@@ -280,10 +317,10 @@ final class FirewallModel: NSObject, ObservableObject {
|
||||
return true
|
||||
}
|
||||
|
||||
/// The extension wrote these as root, so the bundled CLI removes them behind an admin prompt; returns what to report if it failed.
|
||||
/// The extension wrote these as root, so the bundled firewall-uninstall removes them behind an admin prompt; returns what to report if it failed.
|
||||
private func removeStoredCA() -> String? {
|
||||
let cli = Bundle.main.path(forAuxiliaryExecutable: "firewall") ?? ""
|
||||
let script = "do shell script quoted form of \"\(cli)\" & \" uninstall\" with administrator privileges"
|
||||
let uninstaller = Bundle.main.path(forAuxiliaryExecutable: "firewall-uninstall") ?? ""
|
||||
let script = "do shell script quoted form of \"\(uninstaller)\" with administrator privileges"
|
||||
var error: NSDictionary?
|
||||
NSAppleScript(source: script)?.executeAndReturnError(&error)
|
||||
if let error {
|
||||
|
||||
@@ -22,5 +22,28 @@ struct MenuBarView: View {
|
||||
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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
+3
-2
@@ -47,7 +47,8 @@ build() {
|
||||
echo "==> Building"
|
||||
xcodegen generate >/dev/null
|
||||
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
|
||||
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
-destination generic/platform=macOS \
|
||||
-derivedDataPath build CURRENT_PROJECT_VERSION="$(date +%s)" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
|
||||
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
|
||||
}
|
||||
|
||||
@@ -55,7 +56,7 @@ sign() {
|
||||
echo "==> Signing with $IDENTITY"
|
||||
cp "$(profile_for $APP_ID)" "$APP/Contents/embedded.provisionprofile"
|
||||
cp "$(profile_for $EXT_ID)" "$EXT/Contents/embedded.provisionprofile"
|
||||
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall"
|
||||
codesign --force --options runtime --timestamp -s "$IDENTITY" "$APP/Contents/MacOS/firewall-uninstall"
|
||||
codesign --force --options runtime --timestamp \
|
||||
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
|
||||
codesign --force --options runtime --timestamp \
|
||||
|
||||
+17
-5
@@ -10,7 +10,7 @@ settings:
|
||||
"CARGO_PROFILE[config=Debug]": debug
|
||||
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE)
|
||||
"LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
|
||||
ARCHS: arm64
|
||||
ARCHS: arm64 x86_64
|
||||
"ONLY_ACTIVE_ARCH[config=Debug]": "YES"
|
||||
SWIFT_VERSION: "5.0"
|
||||
DEVELOPMENT_TEAM: ${TEAM}
|
||||
@@ -19,6 +19,8 @@ settings:
|
||||
ENABLE_DEBUG_DYLIB: "NO"
|
||||
CODE_SIGN_STYLE: Automatic
|
||||
CODE_SIGN_IDENTITY: Apple Development
|
||||
# build.sh sets a fresh one per build; macOS only replaces an installed extension whose version differs.
|
||||
CURRENT_PROJECT_VERSION: "1"
|
||||
|
||||
packages:
|
||||
Rama:
|
||||
@@ -42,6 +44,7 @@ targets:
|
||||
properties:
|
||||
CFBundlePackageType: SYSX
|
||||
CFBundleShortVersionString: "0.1"
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
NetworkExtension:
|
||||
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
|
||||
@@ -102,6 +105,7 @@ targets:
|
||||
path: App/Info.plist
|
||||
properties:
|
||||
CFBundleShortVersionString: "0.1"
|
||||
CFBundleVersion: $(CURRENT_PROJECT_VERSION)
|
||||
LSMinimumSystemVersion: $(MACOSX_DEPLOYMENT_TARGET)
|
||||
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
|
||||
LSUIElement: true
|
||||
@@ -118,14 +122,22 @@ targets:
|
||||
- target: Extension
|
||||
embed: true
|
||||
codeSign: true
|
||||
# Uninstall runs the CLI as root to remove what the extension left.
|
||||
# Uninstall runs this as root to remove what the extension left.
|
||||
postBuildScripts:
|
||||
- name: Embed the CLI
|
||||
- name: Embed firewall-uninstall
|
||||
basedOnDependencyAnalysis: false
|
||||
script: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
unset CC CXX CFLAGS CXXFLAGS
|
||||
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
|
||||
cd "$SRCROOT/../core"
|
||||
cargo build --bin firewall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"
|
||||
bins=
|
||||
for arch in $ARCHS; do
|
||||
case $arch in
|
||||
arm64|arm64e) triple=aarch64-apple-darwin ;;
|
||||
x86_64) triple=x86_64-apple-darwin ;;
|
||||
esac
|
||||
cargo build --bin firewall-uninstall --features machine-wide --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
|
||||
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
|
||||
done
|
||||
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
|
||||
Reference in new issue
Block a user