Compare commits

..
Author SHA1 Message Date
Ian a0283f8dd1 Give every build a new bundle version so macOS replaces the installed extension 2026-10-23 09:05:43 -04:00
ian-at-strix 6c69036db0 Screen mirrors listed in the config file, and reject unusable config values (#73)
* Screen mirrors listed in the config file

* Reject config values the firewall cannot use
2026-10-22 18:26:59 -04:00
ian-at-strix 4fd3021760 Create the Windows data folder with its ACL instead of setting it after (#55) 2026-10-22 18:25:28 -04:00
19 changed files with 73 additions and 94 deletions

No files matched your search

+1 -1
View File
@@ -125,7 +125,7 @@ schannel = "0.1.29"
tokio = { version = "1.53.1", features = ["net"] }
tracing-appender = "0.2.5"
tray-icon = { version = "0.26.1", default-features = false }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows = { version = "0.62", features = ["Win32_Foundation", "Win32_Security", "Win32_Security_Authorization", "Win32_Security_Cryptography", "Win32_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Threading", "Win32_UI_Shell", "Win32_UI_WindowsAndMessaging"] }
windows-registry = "0.6.1"
windows-service = "0.8.0"
+1 -1
View File
@@ -8,7 +8,7 @@ pub static BUN: Manager = Manager {
("https_proxy", PROXY),
("HTTP_PROXY", PROXY),
("HTTPS_PROXY", PROXY),
("npm_config_registry", "http://{npm}/"),
("npm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+1 -1
View File
@@ -14,7 +14,7 @@ pub static CARGO: Manager = Manager {
"--config",
"source.crates-io.replace-with=\"firewall\"",
"--config",
"source.firewall.registry=\"sparse+http://{cargo/index}/\"",
"source.firewall.registry=\"sparse+http://index.crates.io/\"",
],
}],
};
+1 -1
View File
@@ -6,6 +6,6 @@ pub static GEM: Manager = Manager {
// gem has no variable for the source list, and gem env and gem sources reject the flags.
args: &[Args {
when: &["install", "update", "fetch"],
flags: &["--clear-sources", "--source", "http://{gem}/"],
flags: &["--clear-sources", "--source", "http://rubygems.org/"],
}],
};
+1 -1
View File
@@ -6,7 +6,7 @@ pub static GO: Manager = Manager {
env: &[
("HTTP_PROXY", PROXY),
("http_proxy", PROXY),
("GOPROXY", "http://{go}"),
("GOPROXY", "http://proxy.golang.org"),
("GOSUMDB", "off"),
("GOFLAGS", "-mod=mod"),
],
+8 -26
View File
@@ -9,9 +9,6 @@ mod pipenv;
mod pnpm;
mod uv;
use crate::policies::registries::{host_of, BUILT_IN};
use crate::FirewallConfig;
pub use bun::BUN;
pub use cargo::CARGO;
pub use gem::GEM;
@@ -23,7 +20,7 @@ pub use pipenv::PIPENV;
pub use pnpm::PNPM;
pub use uv::UV;
pub const PYPI_SIMPLE: &str = "http://{pypi}/simple";
pub const PYPI_SIMPLE: &str = "http://pypi.org/simple";
pub const PROXY: &str = "{proxy}";
@@ -34,9 +31,7 @@ pub struct Args {
}
/// An https:// registry arrives as a CONNECT tunnel naming only the host, so each
/// manager also overrides its registry URL to http://. `{npm}` and the like name a
/// built-in registry, and expand to its first mirror, if any, without the scheme;
/// `{host:npm}` to just its host.
/// manager also overrides its registry URL to http://.
pub struct Manager {
pub command: &'static str,
pub env: &'static [(&'static str, &'static str)],
@@ -47,36 +42,23 @@ pub static MANAGERS: &[&Manager] =
&[&NPM, &PIP, &PIP3, &CARGO, &GO, &GEM, &UV, &PDM, &PIPENV, &PNPM, &BUN];
/// Every manager's variables, so whichever the command starts is pointed at the firewall.
pub fn variables(proxy: &str, config: &FirewallConfig) -> Vec<(String, String)> {
pub fn variables(proxy: &str) -> Vec<(String, String)> {
let env = MANAGERS.iter().flat_map(|manager| manager.env);
env.map(|(key, value)| (key.to_string(), expand(value, proxy, config))).collect()
env.map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
}
pub fn arguments(
proxy: &str,
config: &FirewallConfig,
manager: &Manager,
typed: &[String],
) -> Vec<String> {
pub fn arguments(proxy: &str, manager: &Manager, typed: &[String]) -> Vec<String> {
let mut out = Vec::new();
for args in manager.args {
let wanted =
args.when.is_empty() || typed.iter().any(|arg| args.when.contains(&arg.as_str()));
if wanted {
out.extend(args.flags.iter().map(|flag| expand(flag, proxy, config)));
out.extend(args.flags.iter().map(|flag| expand(flag, proxy)));
}
}
out
}
fn expand(value: &str, proxy: &str, config: &FirewallConfig) -> String {
let mut value = value.replace(PROXY, proxy);
for registry in BUILT_IN {
let mirror = config.registries.get(registry.name()).and_then(|mirrors| mirrors.first());
let upstream = mirror.map_or(registry.upstream, String::as_str);
let address = upstream.trim_start_matches("https://").trim_end_matches('/');
value = value.replace(&format!("{{{}}}", registry.name()), address);
value = value.replace(&format!("{{host:{}}}", registry.name()), host_of(upstream));
}
value
fn expand(value: &str, proxy: &str) -> String {
value.replace(PROXY, proxy)
}
+1 -1
View File
@@ -5,7 +5,7 @@ pub static NPM: Manager = Manager {
env: &[
("npm_config_proxy", PROXY),
("npm_config_https_proxy", PROXY),
("npm_config_registry", "http://{npm}/"),
("npm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+1 -1
View File
@@ -4,7 +4,7 @@ use super::{Manager, PROXY, PYPI_SIMPLE};
const PIP_ENV: &[(&str, &str)] = &[
("PIP_PROXY", PROXY),
("PIP_INDEX_URL", PYPI_SIMPLE),
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
("PIP_DISABLE_PIP_VERSION_CHECK", "1"),
];
+1 -1
View File
@@ -10,7 +10,7 @@ pub static PIPENV: Manager = Manager {
("https_proxy", PROXY),
("PIPENV_PYPI_MIRROR", PYPI_SIMPLE),
// pipenv installs through pip, which skips a plaintext index on a host it doesn't trust.
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
("PIP_TRUSTED_HOST", "pypi.org files.pythonhosted.org"),
],
args: &[],
};
+1 -1
View File
@@ -6,7 +6,7 @@ pub static PNPM: Manager = Manager {
env: &[
("pnpm_config_proxy", PROXY),
("pnpm_config_https_proxy", PROXY),
("pnpm_config_registry", "http://{npm}/"),
("pnpm_config_registry", "http://registry.npmjs.org/"),
],
args: &[],
};
+5 -11
View File
@@ -6,7 +6,6 @@ mod windows;
pub use forward::spawn;
pub use managers::*;
use crate::FirewallConfig;
use std::io::{Error, ErrorKind};
use std::path::{Path, PathBuf};
use std::process::Command;
@@ -22,12 +21,12 @@ impl Invocation {
}
/// What the program is run with: the typed arguments plus any its manager needs.
pub fn arguments(&self, proxy: &str, config: &FirewallConfig) -> Vec<String> {
pub fn arguments(&self, proxy: &str) -> Vec<String> {
let mut args = self.args.clone();
let Some(manager) = MANAGERS.iter().find(|manager| manager.command == self.program) else {
return args;
};
let ours = managers::arguments(proxy, config, manager, &args);
let ours = managers::arguments(proxy, manager, &args);
// Anything after `--` belongs to another program, so ours go before it.
let at = args.iter().position(|arg| arg == "--").unwrap_or(args.len());
args.splice(at..at, ours);
@@ -35,20 +34,15 @@ impl Invocation {
}
}
pub fn run(
port: u16,
config: &FirewallConfig,
invocation: &Invocation,
dir: Option<&Path>,
) -> std::io::Result<i32> {
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
let proxy = crate::local_url(port, "");
let program = resolve(&invocation.program)?;
let mut command = Command::new(&program);
command.args(invocation.arguments(&proxy, config));
command.args(invocation.arguments(&proxy));
if let Some(dir) = dir {
command.current_dir(dir);
}
for (key, value) in managers::variables(&proxy, config) {
for (key, value) in managers::variables(&proxy) {
command.env(key, value);
}
#[cfg(windows)]
+2 -2
View File
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
pub const DEFAULT_PORT: u16 = 8080;
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct FirewallConfig {
pub port: NonZeroU16,
@@ -26,7 +26,7 @@ pub struct FirewallConfig {
pub registries: HashMap<String, Vec<String>>,
}
#[derive(Debug, Clone, Deserialize)]
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub name: String,
+2 -3
View File
@@ -73,14 +73,13 @@ fn serve() -> ! {
fn run(invocation: Invocation) -> ! {
telemetry::set_mode("wrapper");
let command = &invocation.program;
let config = load();
let port = match aliasing::spawn(0, config.clone()) {
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
Err(e) => fail("spawn", &format!("could not start the firewall: {e}")),
};
info!(url = %firewall::local_url(port, ""), "firewall listening");
let result = aliasing::run(port, &config, &invocation, None);
let result = aliasing::run(port, &invocation, None);
telemetry::flush();
match result {
Ok(code) => exit(code),
+23 -8
View File
@@ -11,6 +11,7 @@ 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.
@@ -65,25 +66,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)
}
+13 -26
View File
@@ -1,11 +1,11 @@
use firewall::aliasing::managers::*;
use firewall::aliasing::Invocation;
use firewall::{Firewall, FirewallConfig};
use firewall::Firewall;
#[test]
fn arguments_after_a_double_dash_reach_the_program() {
let typed = ["run", "--", "--release"].map(String::from).to_vec();
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000", &FirewallConfig::default());
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000");
assert_eq!(args.first().map(String::as_str), Some("run"));
assert_eq!(args[args.len() - 2..], ["--", "--release"]);
}
@@ -13,23 +13,12 @@ fn arguments_after_a_double_dash_reach_the_program() {
#[test]
fn recipes_expand_into_the_environment() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let config = FirewallConfig::default();
let npm = variables("http://127.0.0.1:9000", &config);
let npm = variables("http://127.0.0.1:9000");
assert!(npm.contains(&var("npm_config_proxy", "http://127.0.0.1:9000")));
assert!(npm.contains(&var("npm_config_registry", "http://registry.npmjs.org/")));
let gem = arguments("http://127.0.0.1:9000", &config, &GEM, &["install".to_string()]);
let gem = arguments("http://127.0.0.1:9000", &GEM, &["install".to_string()]);
assert_eq!(gem, ["--clear-sources", "--source", "http://rubygems.org/"]);
assert!(arguments("http://127.0.0.1:9000", &config, &GEM, &["sources".to_string()]).is_empty());
}
#[test]
fn a_mirror_replaces_the_registry_it_copies() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let config = r#"{"registries": {"npm": ["https://npm.example.com/"], "pypi": ["https://art.example.com/pypi/"]}}"#;
let env = variables("http://127.0.0.1:9000", &serde_json::from_str(config).expect("parses"));
assert!(env.contains(&var("npm_config_registry", "http://npm.example.com/")));
assert!(env.contains(&var("PIP_INDEX_URL", "http://art.example.com/pypi/simple")));
assert!(env.contains(&var("PIP_TRUSTED_HOST", "art.example.com files.pythonhosted.org")));
assert!(arguments("http://127.0.0.1:9000", &GEM, &["sources".to_string()]).is_empty());
}
#[test]
@@ -45,17 +34,15 @@ fn no_two_managers_set_a_variable_differently() {
#[tokio::test]
async fn every_overridden_registry_is_known() {
let config = FirewallConfig::default();
let hosts = Firewall::default().hosts();
let values = variables("", &config).into_iter().map(|(_, value)| value);
let flags = MANAGERS.iter().flat_map(|manager| {
let typed: Vec<String> = manager.args.iter().flat_map(|args| args.when).map(|arg| arg.to_string()).collect();
arguments("", &config, manager, &typed)
});
for value in values.chain(flags) {
for (_, after) in value.match_indices("http://").map(|(i, _)| value.split_at(i + 7)) {
let host = after.split(['/', '"']).next().unwrap_or(after);
assert!(hosts.contains(&host), "{value} points at {host}");
for manager in MANAGERS {
let values = manager.env.iter().map(|(_, value)| *value);
let flags = manager.args.iter().flat_map(|args| args.flags.iter().copied());
for value in values.chain(flags) {
for (_, after) in value.match_indices("http://").map(|(i, _)| value.split_at(i + 7)) {
let host = after.split(['/', '"']).next().unwrap_or(after);
assert!(hosts.contains(&host), "{} points at {host}", manager.command);
}
}
}
}
+2 -3
View File
@@ -6,11 +6,10 @@ use probe::Outcome;
fn main() -> std::io::Result<()> {
firewall::init_logging("info");
let config = FirewallConfig::load()?;
let port = aliasing::spawn(0, config.clone())?;
let port = aliasing::spawn(0, FirewallConfig::load()?)?;
println!("firewall proxy on {}\n", firewall::local_url(port, ""));
let results = probe::all(port, &config);
let results = probe::all(port);
println!();
let mut failed = 0;
for (name, outcome) in &results {
+4 -5
View File
@@ -1,5 +1,4 @@
use firewall::aliasing::{self, Invocation, Manager, CARGO, GEM, GO, NPM, PIP, PIP3, UV};
use firewall::FirewallConfig;
use std::path::PathBuf;
struct Probe {
@@ -30,14 +29,14 @@ static PROBES: &[Probe] = &[
},
];
pub fn all(port: u16, config: &FirewallConfig) -> Vec<(&'static str, Outcome)> {
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port, config))).collect()
pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port))).collect()
}
fn run(probe: &Probe, port: u16, config: &FirewallConfig) -> 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, config, &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,
+1 -1
View File
@@ -48,7 +48,7 @@ build() {
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
-derivedDataPath build CURRENT_PROJECT_VERSION="$(date +%s)" CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+4
View File
@@ -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