Compare commits

..
Author SHA1 Message Date
Ian 13b0995b05 Forward plain-HTTP upstreams as asked
The wrapper's forward proxy upgraded every request to HTTPS and rewrote
links in index responses. Only an HTTPS registry is downgraded to reach
us, so only that should be upgraded back and rewritten; anything else
now goes out exactly as the client sent it, port included.
2026-10-09 16:53:43 -04:00
35 changed files with 219 additions and 599 deletions

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.3 KiB

+7 -108
View File
@@ -45,56 +45,6 @@ dependencies = [
"alloc-no-stdlib",
]
[[package]]
name = "anstream"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
[[package]]
name = "anstyle-parse"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "anyhow"
version = "1.0.104"
@@ -260,6 +210,12 @@ dependencies = [
"cfg_aliases",
]
[[package]]
name = "bpaf"
version = "0.9.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "597e81e56404b7a3e26ef7b449f82a4b7a661ae76c3cac692b42d6bad06a0fbe"
[[package]]
name = "brotli"
version = "8.0.4"
@@ -357,33 +313,6 @@ dependencies = [
"libloading",
]
[[package]]
name = "clap"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946"
dependencies = [
"clap_builder",
]
[[package]]
name = "clap_builder"
version = "4.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_lex"
version = "1.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486"
[[package]]
name = "cmake"
version = "0.1.58"
@@ -393,12 +322,6 @@ dependencies = [
"cc",
]
[[package]]
name = "colorchoice"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
[[package]]
name = "combine"
version = "4.6.8"
@@ -1177,12 +1100,6 @@ dependencies = [
"serde",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.13.0"
@@ -1645,12 +1562,6 @@ version = "1.21.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "openssl-macros"
version = "0.1.1"
@@ -3093,7 +3004,7 @@ checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
name = "strix-firewall"
version = "0.1.0"
dependencies = [
"clap",
"bpaf",
"core-foundation",
"directories",
"percent-encoding",
@@ -3113,12 +3024,6 @@ dependencies = [
"which",
]
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "subtle"
version = "2.6.1"
@@ -3528,12 +3433,6 @@ version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "uuid"
version = "1.26.1"
+2 -6
View File
@@ -44,10 +44,6 @@ path = "tests/denylist.rs"
name = "osv"
path = "tests/osv.rs"
[[test]]
name = "age"
path = "tests/age.rs"
[[test]]
name = "mitm"
path = "tests/mitm.rs"
@@ -57,7 +53,7 @@ name = "managers"
path = "tests/managers.rs"
[dependencies]
clap = "4.6.6"
bpaf = "0.9.28"
directories = "6.0.0"
percent-encoding = "2.3.2"
rama = { version = "0.4.0", features = ["boring", "http-full", "ring", "rustls"] }
@@ -67,7 +63,7 @@ regex = "1.13.1"
semver = "1.0.28"
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
time = { version = "0.3.47", features = ["formatting", "parsing"] }
time = { version = "0.3.47", features = ["formatting"] }
tokio = { version = "1.53.1", features = ["io-util", "rt", "sync", "time"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
+10 -29
View File
@@ -1,20 +1,15 @@
use crate::http::{blocked, https_url, text};
use crate::{policies, telemetry};
use crate::{Firewall, FirewallConfig};
use rama::dns::client::DnsConnectorLayer;
use rama::http::body::util::BodyExt;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::CONTENT_LENGTH;
use rama::http::layer::decompression::DecompressionLayer;
use rama::http::layer::remove_header::{RemoveRequestHeaderLayer, RemoveResponseHeaderLayer};
use rama::http::layer::upgrade::{EagerHttpProxyConnector, UpgradeLayer};
use rama::http::server::HttpServer;
use rama::http::{Body, Method, Request, Response};
use rama::matcher::match_fn;
use rama::net::proxy::IoForwardService;
use rama::rt::Executor;
use rama::service::service_fn;
use rama::tcp::client::service::TcpConnector;
use rama::tcp::server::TcpListener;
use rama::{Layer, Service};
use std::convert::Infallible;
@@ -36,26 +31,8 @@ pub fn spawn(port: u16, config: FirewallConfig) -> std::io::Result<u16> {
Ok(listener) => listener,
Err(e) => return error!(error = %e, "forward proxy could not take over the socket"),
};
// Tunnels skip screening, so a registry's CONNECT falls through to `handle`, which refuses it.
let hosts = firewall.hosts();
let tunnel = UpgradeLayer::new(
exec.clone(),
match_fn(move |req: &Request| {
let host = req.uri().host().map(|host| host.to_str());
req.method() == Method::CONNECT
&& host.is_some_and(|host| !hosts.contains(&host.as_ref()))
}),
EagerHttpProxyConnector::new(
DnsConnectorLayer::new().into_layer(TcpConnector::new()),
IoForwardService::default(),
),
);
let server = HttpServer::auto(exec).service(
(
RemoveResponseHeaderLayer::hop_by_hop(),
RemoveRequestHeaderLayer::hop_by_hop(),
tunnel,
)
(RemoveResponseHeaderLayer::hop_by_hop(), RemoveRequestHeaderLayer::hop_by_hop())
.into_layer(service_fn(move |req: Request| {
let firewall = Arc::clone(&firewall);
async move { handle(req, &firewall).await }
@@ -89,12 +66,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 {
+2 -4
View File
@@ -41,10 +41,8 @@ pub struct Manager {
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) -> Vec<(String, String)> {
let env = MANAGERS.iter().flat_map(|manager| manager.env);
env.map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
pub fn variables(proxy: &str, manager: &Manager) -> Vec<(String, String)> {
manager.env.iter().map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
}
pub fn arguments(proxy: &str, manager: &Manager, typed: &[String]) -> Vec<String> {
-2
View File
@@ -9,8 +9,6 @@ pub static PIPENV: Manager = Manager {
("http_proxy", PROXY),
("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", "pypi.org files.pythonhosted.org"),
],
args: &[],
};
+12 -19
View File
@@ -9,37 +9,30 @@ use std::path::{Path, PathBuf};
use std::process::Command;
pub struct Invocation {
pub program: String,
pub manager: &'static Manager,
pub args: Vec<String>,
}
impl Invocation {
pub fn new(program: String, args: Vec<String>) -> Self {
Invocation { program, args }
}
/// What the program is run with: the typed arguments plus any its manager needs.
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, 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);
args
pub fn new(manager: &'static Manager, args: Vec<String>) -> Self {
Invocation { manager, args }
}
}
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)?);
command.args(invocation.arguments(&proxy));
let mut args = invocation.args.clone();
let ours = managers::arguments(&proxy, invocation.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);
let mut command = Command::new(resolve(invocation.manager.command)?);
command.args(args);
if let Some(dir) = dir {
command.current_dir(dir);
}
for (key, value) in managers::variables(&proxy) {
for (key, value) in managers::variables(&proxy, invocation.manager) {
command.env(key, value);
}
Ok(command.status()?.code().unwrap_or(1))
+12 -29
View File
@@ -1,11 +1,11 @@
use crate::policies::registries::{Registry, BUILT_IN};
use directories::ProjectDirs;
use serde::Deserialize;
use std::collections::HashMap;
use std::io::{Error, ErrorKind};
use std::path::PathBuf;
pub const DEFAULT_PORT: u16 = 8080;
const TOKEN_ENV: &str = "FIREWALL_GITHUB_TOKEN";
#[derive(Debug, Deserialize)]
#[serde(default, deny_unknown_fields)]
@@ -13,43 +13,37 @@ pub struct FirewallConfig {
pub port: u16,
/// Allow a package when a filter could not check it.
pub fail_open: bool,
/// 0 turns the age check off.
pub min_age_hours: u64,
pub denylist: DenylistSource,
/// Packages never screened, by ecosystem: `{"npm": [{"name": "left-pad"}]}`.
pub allow: HashMap<String, Vec<AllowedPackage>>,
pub telemetry: bool,
/// Not describable in the file yet, so this is always the built-in table.
#[serde(skip)]
pub registries: Vec<&'static Registry>,
}
#[derive(Debug, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct AllowedPackage {
pub name: String,
/// Every version when absent.
pub version: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct DenylistSource {
/// One file per ecosystem lives under here, named the way OSV names it, in lowercase.
pub base: String,
/// Falls back to `FIREWALL_GITHUB_TOKEN`, so a token need not be written to disk.
pub token: Option<String>,
/// Polled for a new commit, which re-fetches every list in use.
pub revision: String,
pub poll_secs: u64,
}
impl DenylistSource {
pub fn token(&self) -> Option<String> {
self.token.clone().or_else(|| std::env::var(TOKEN_ENV).ok())
}
}
impl Default for FirewallConfig {
fn default() -> Self {
FirewallConfig {
port: DEFAULT_PORT,
fail_open: true,
min_age_hours: 0,
denylist: DenylistSource::default(),
allow: HashMap::new(),
telemetry: true,
registries: BUILT_IN.to_vec(),
}
@@ -59,8 +53,9 @@ impl Default for FirewallConfig {
impl Default for DenylistSource {
fn default() -> Self {
DenylistSource {
base: "https://raw.githubusercontent.com/usestrix/firewall-blocklist/main".into(),
revision: "https://api.github.com/repos/usestrix/firewall-blocklist/commits/main"
base: "https://raw.githubusercontent.com/usestrix/malicious-packages/packages".into(),
token: None,
revision: "https://api.github.com/repos/usestrix/malicious-packages/commits/packages"
.into(),
poll_secs: 10,
}
@@ -85,18 +80,6 @@ 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
}
}
pub fn path() -> Option<PathBuf> {
+15 -7
View File
@@ -1,5 +1,4 @@
use crate::config::FirewallConfig;
use crate::policies::filters::age::Age;
use crate::policies::filters::denylist::Denylist;
use crate::policies::filters::osv::Osv;
use crate::policies::filters::Filter;
@@ -27,11 +26,8 @@ impl Firewall {
patterns.insert(registry.prefix, compiled.collect());
}
let ecosystems = config.registries.iter().map(|r| r.osv_name);
let denylist = Denylist::new(config.denylist.clone(), 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)));
}
let denylist = Denylist::new(config.denylist.clone(), config.denylist.token(), ecosystems);
let filters: Vec<Box<dyn Filter>> = vec![Box::new(denylist), Box::new(Osv)];
Firewall { config, patterns, filters }
}
@@ -44,7 +40,19 @@ impl Firewall {
}
pub fn hosts(&self) -> Vec<&'static str> {
self.config.hosts()
let mut hosts: Vec<&'static str> = Vec::new();
for registry in &self.config.registries {
let host = registry.host();
if !hosts.contains(&host) {
hosts.push(host);
}
}
hosts
}
pub fn is_https_url(&self, url: &str) -> bool {
let host = host_of(url);
self.config.registries.iter().any(|r| r.upstream.starts_with("https://") && r.host() == host)
}
pub fn is_index_url(&self, url: &str) -> bool {
+54 -64
View File
@@ -1,52 +1,64 @@
use clap::{Arg, Command};
use firewall::aliasing::{self, Invocation, MANAGERS};
use bpaf::{any, choice, pure, OptionParser, Parser};
use firewall::aliasing::{self, Invocation, Manager, MANAGERS};
use firewall::{config, proxy, telemetry, FirewallConfig};
use std::io::ErrorKind;
use std::path::Path;
use std::process::exit;
use tracing::{error, info};
fn main() {
let matches = options().get_matches();
if matches.subcommand_matches("serve").is_some() {
firewall::init_logging("info");
serve()
}
let mut command = matches.get_many::<String>("COMMAND").unwrap().cloned();
let program = command.next().unwrap();
firewall::init_logging("warn");
run(Invocation::new(program, command.collect()))
enum SubCommand {
Serve,
Run(Invocation),
}
fn options() -> Command {
fn main() {
match options().run() {
SubCommand::Serve => {
firewall::init_logging("info");
serve()
}
SubCommand::Run(invocation) => {
firewall::init_logging("warn");
run(invocation)
}
}
}
fn options() -> OptionParser<SubCommand> {
let program = program_name();
let managers: Vec<_> = MANAGERS.iter().map(|manager| manager.command).collect();
let footer = format!(
"Run a command through {program} to screen what it installs:\n \
{program} npm install left-pad\n\n\
Alias it to screen every install without changing how you type:\n \
{}\n\n\
Supported package managers: {}",
alias(&program),
managers.join(", ")
"Run a package manager through {program} to screen what it installs:\n \
{program} npm install left-pad\n \n \
Alias it to screen every install without changing how you type:\n \
alias npm='{program} npm'"
);
Command::new("firewall")
.bin_name(&program)
.about("Screens package downloads before they reach your machine")
.after_help(footer)
.arg_required_else_help(true)
.args_conflicts_with_subcommands(true)
.subcommand(
Command::new("serve")
.about("Serve a local registry for package managers configured to use it"),
)
.arg(
Arg::new("COMMAND")
.help("A command to run with its package downloads screened")
.num_args(1..)
.trailing_var_arg(true)
.allow_hyphen_values(true),
)
let serve = pure(())
.map(|()| SubCommand::Serve)
.to_options()
.descr("Serve a local registry for package managers configured to use it")
.command("serve");
let mut commands: Vec<_> = MANAGERS
.iter()
.map(|manager| package(manager, &program).boxed())
.collect();
commands.push(serve.boxed());
choice(commands)
.to_options()
.descr("Screens package downloads before they reach your machine")
.footer(Box::leak(footer.into_boxed_str()) as &str)
.fallback_to_usage()
}
fn package(manager: &'static Manager, program: &str) -> impl Parser<SubCommand> {
let name = manager.command;
let descr = format!("Run {name} with downloads screened: {program} {name} ARGS...");
any::<String, String, _>("ARGS", Some)
.many()
.map(move |args| SubCommand::Run(Invocation::new(manager, args)))
.to_options()
.descr(Box::leak(descr.into_boxed_str()) as &str)
.command(name)
}
fn serve() -> ! {
@@ -70,7 +82,7 @@ fn serve() -> ! {
}
fn run(invocation: Invocation) -> ! {
let command = &invocation.program;
let command = invocation.manager.command;
let port = match aliasing::spawn(0, load()) {
Ok(port) => port,
Err(e) => fail("spawn", &format!("could not start the firewall: {e}")),
@@ -93,32 +105,12 @@ fn print_setup(config: &FirewallConfig) {
let base = config.local_url("");
println!("Firewall listening on {base}\n");
println!("Point your package managers at it:");
println!(" npm {}", variable("npm_config_registry", &format!("{base}/npm/")));
println!(" pip {}", variable("PIP_INDEX_URL", &format!("{base}/pypi/simple/")));
println!(" npm npm_config_registry={base}/npm/");
println!(" pip PIP_INDEX_URL={base}/pypi/simple/");
println!(" cargo [source.crates-io] replace-with = \"firewall\"");
println!(" [source.firewall] registry = \"sparse+{base}/cargo/index/\"\n");
}
fn alias(program: &str) -> String {
if cfg!(windows) {
// Set-Alias cannot carry arguments, so a function stands in.
format!(
"PowerShell: function npm {{ {program} npm @args }}\n \
cmd: doskey npm={program} npm $*"
)
} else {
format!("alias npm='{program} npm'")
}
}
fn variable(key: &str, value: &str) -> String {
if cfg!(windows) {
format!("$env:{key} = \"{value}\"")
} else {
format!("{key}={value}")
}
}
fn load() -> FirewallConfig {
match FirewallConfig::load() {
Ok(config) => config,
@@ -135,9 +127,7 @@ fn fail(kind: &'static str, message: &str) -> ! {
fn program_name() -> String {
let arg = std::env::args_os().next().unwrap_or_default();
// Without `.exe` on Windows, which is how it is typed.
let path = Path::new(&arg);
match if cfg!(windows) { path.file_stem() } else { path.file_name() } {
match Path::new(&arg).file_name() {
Some(name) => name.to_string_lossy().into_owned(),
None => "firewall".into(),
}
+3 -22
View File
@@ -1,12 +1,10 @@
use rama::error::BoxError;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
SerialNumber, SigningKey,
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyUsagePurpose, NameConstraints, SigningKey,
};
use std::sync::Arc;
use time::{Duration, OffsetDateTime};
/// Held in the Secure Enclave on macOS, in memory elsewhere.
pub type CaSigner = Arc<dyn SigningKey + Send + Sync>;
@@ -44,22 +42,6 @@ impl CertificateAuthority {
}
}
/// Empty, for verifiers that fail closed without one, such as schannel under cargo.
/// Signed fresh for each fetch; a week's validity outlasts any verifier's cache.
pub fn crl(hosts: &[&str], signer: &dyn SigningKey) -> Result<Vec<u8>, BoxError> {
let now = OffsetDateTime::now_utc();
let list = CertificateRevocationListParams {
this_update: now - Duration::hours(1),
next_update: now + Duration::days(7),
crl_number: SerialNumber::from(now.unix_timestamp() as u64),
issuing_distribution_point: None,
revoked_certs: Vec::new(),
key_identifier_method: KeyIdMethod::Sha256,
};
let ca = params(hosts);
Ok(list.signed_by(&Issuer::from_params(&ca, signer))?.der().to_vec())
}
/// Also used by the issuer, whose issuer name must match the CA's subject.
pub fn params(hosts: &[&str]) -> CertificateParams {
let mut name = DistinguishedName::new();
@@ -69,8 +51,7 @@ pub fn params(hosts: &[&str]) -> CertificateParams {
let mut params = CertificateParams::default();
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
// rcgen signs a CRL only for a CA that may.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts
.iter()
+3 -12
View File
@@ -4,7 +4,7 @@ use rama::tls::boring::core::pkey::PKey;
use rama::tls::boring::core::x509::X509;
use rama::tls::boring::proxy::cert_issuer::{BoringMitmCertIssuer, MitmIssuedCert};
use rama::utils::collections::non_empty_vec;
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
use rcgen::{CertificateParams, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
@@ -14,18 +14,12 @@ pub struct ConstrainedCertIssuer {
ca_params: Arc<CertificateParams>,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
}
impl ConstrainedCertIssuer {
pub(super) fn new(
ca_crt: X509,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
pub(super) fn new(ca_crt: X509, signer: CaSigner, hosts: Vec<&'static str>) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts, crl }
Self { ca_crt, ca_params, signer, hosts }
}
}
@@ -48,9 +42,6 @@ impl BoringMitmCertIssuer for ConstrainedCertIssuer {
subject.push(DnType::CommonName, common_name);
let mut leaf = CertificateParams::new(names)?;
leaf.distinguished_name = subject;
if let Some(crl) = &self.crl {
leaf.crl_distribution_points = vec![CrlDistributionPoint { uris: vec![crl.clone()] }];
}
// The TLS server needs the leaf key in memory, so it can't be the platform key.
let leaf_key = KeyPair::generate()?;
+2 -4
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{crl, CaSigner, CertificateAuthority};
pub use ca::{CaSigner, CertificateAuthority};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
@@ -25,16 +25,14 @@ type RelayService = Arc<HttpMitmRelay<FirewallLayer>>;
type InterceptService =
TlsMitmRelayService<CachedBoringMitmCertIssuer<ConstrainedCertIssuer>, RelayService>;
/// `crl` is where leaves say an empty CRL is served, for verifiers that insist on one.
pub fn mitm_service(
firewall: Arc<Firewall>,
ca: &CertificateAuthority,
signer: CaSigner,
crl: Option<String>,
exec: Executor,
) -> MitmService {
let hosts = firewall.hosts();
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone());
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
+4 -4
View File
@@ -25,10 +25,10 @@ use std::sync::Arc;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;
const BUNDLE_ID: &str = "com.usestrix.firewall";
const BUNDLE_ID: &str = "ai.strix.firewall";
const KEYCHAIN_SERVICE: &str = BUNDLE_ID;
const KEYCHAIN_ACCOUNT: &str = "ca";
/// Must match `macos/LaunchAgents/com.usestrix.firewall.env.plist`, which points npm at it.
/// Must match `macos/LaunchAgents/ai.strix.firewall.env.plist`, which points npm at it.
const CA_FILE: &str = "/Library/Application Support/Firewall/ca.pem";
#[derive(Clone)]
@@ -108,12 +108,12 @@ fn handler(exec: Executor) -> Result<Handler, BoxError> {
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);
let service = mitm::mitm_service(firewall, &ca, signer, exec);
Ok(Handler { ca, hosts, service })
}
/// stderr is invisible in a system extension, so events go to the unified log:
/// `log stream --predicate 'subsystem == "com.usestrix.firewall"'`.
/// `log stream --predicate 'subsystem == "ai.strix.firewall"'`.
fn init(_config: Option<&TransparentProxyInitConfig>) -> bool {
// A panic crossing the FFI boundary aborts, and its message would go to stderr.
std::panic::set_hook(Box::new(|panic| tracing::error!("{panic}")));
+1 -1
View File
@@ -7,7 +7,7 @@ use std::io::ErrorKind;
use tracing::info;
/// Installed by the pkg.
const ENV_AGENT: &str = "/Library/LaunchAgents/com.usestrix.firewall.env.plist";
const ENV_AGENT: &str = "/Library/LaunchAgents/ai.strix.firewall.env.plist";
/// Removes what only root can: the CA, its enclave key and exported copy, and the env
/// agent. Trust settings need an authorization dialog, so the installer drops those itself.
-72
View File
@@ -1,72 +0,0 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use crate::policies::registries::{Published, Registry};
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::USER_AGENT;
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use serde_json::Value;
use time::format_description::well_known::Rfc3339;
use time::{Duration, OffsetDateTime};
pub struct Age {
min: Duration,
}
impl Age {
pub fn new(hours: u64) -> Self {
Age { min: Duration::hours(hours as i64) }
}
}
impl Filter for Age {
fn name(&self) -> &'static str {
"age"
}
fn check<'a>(&'a self, registry: &'static Registry, pkg: &'a Package) -> VerdictFuture<'a> {
Box::pin(async move {
let Some(version) = pkg.version.as_deref() else { return Verdict::Allow };
let Some(published) = &registry.published else { return Verdict::Allow };
let published_at = match published_at(published, &pkg.name, version).await {
Ok(at) => at,
Err(e) => return Verdict::Unknown(e),
};
let age = OffsetDateTime::now_utc() - published_at;
if age >= self.min {
return Verdict::Allow;
}
let (hours, min) = (age.whole_hours(), self.min.whole_hours());
Verdict::Deny(format!("was published {hours} hours ago, under the {min}-hour minimum"))
})
}
}
async fn published_at(
published: &Published,
name: &str,
version: &str,
) -> Result<OffsetDateTime, String> {
let name = match published.escape {
Some(escape) => escape(name),
None => name.to_string(),
};
let url = published.url.replace("{name}", &name).replace("{version}", version);
let pointer = published.pointer.replace("{version}", version);
let client = EasyHttpWebClient::default();
let response = client.get(&url).header(USER_AGENT, AGENT).send().await;
let response = response.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
}
let text = response.try_into_string().await.map_err(|e| e.to_string())?;
let body: Value =
serde_json::from_str(&text).map_err(|e| format!("{url} sent unreadable JSON: {e}"))?;
let Some(stamp) = body.pointer(&pointer).and_then(Value::as_str) else {
return Err(format!("{url} gave no publish time"));
};
OffsetDateTime::parse(stamp, &Rfc3339).map_err(|e| format!("{url} sent a bad time: {e}"))
}
+33 -15
View File
@@ -1,9 +1,9 @@
use super::{Filter, Verdict, VerdictFuture, AGENT};
use super::{Filter, Verdict, VerdictFuture};
use crate::config::DenylistSource;
use crate::policies::registries::Registry;
use crate::policies::Package;
use rama::http::client::EasyHttpWebClient;
use rama::http::header::{ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::header::{AUTHORIZATION, ETAG, IF_NONE_MATCH, USER_AGENT};
use rama::http::service::client::HttpClientExt;
use rama::http::{BodyExtractExt, StatusCode};
use semver::Version;
@@ -14,6 +14,9 @@ use tokio::sync::OnceCell;
use tokio::task::JoinHandle;
use tracing::{info, warn};
/// The GitHub API turns away requests without a user agent.
const AGENT: &str = "strix-firewall";
#[derive(Debug, PartialEq)]
pub enum AffectedVersions {
All,
@@ -37,15 +40,20 @@ type Lists = HashMap<&'static str, OnceCell<RwLock<Arc<FetchedDenylist>>>>;
pub struct Denylist {
source: DenylistSource,
token: Option<String>,
lists: Arc<Lists>,
watch: JoinHandle<()>,
}
impl Denylist {
pub fn new(source: DenylistSource, ecosystems: impl Iterator<Item = &'static str>) -> Self {
pub fn new(
source: DenylistSource,
token: Option<String>,
ecosystems: impl Iterator<Item = &'static str>,
) -> Self {
let lists: Arc<Lists> = Arc::new(ecosystems.map(|eco| (eco, OnceCell::new())).collect());
let watch = tokio::spawn(watch(source.clone(), Arc::clone(&lists)));
Denylist { source, lists, watch }
let watch = tokio::spawn(watch(source.clone(), token.clone(), Arc::clone(&lists)));
Denylist { source, token, lists, watch }
}
}
@@ -64,7 +72,7 @@ impl Filter for Denylist {
Box::pin(async move {
let eco = registry.osv_name;
let load = || async {
RwLock::new(Arc::new(load(&self.source, eco).await))
RwLock::new(Arc::new(load(&self.source, self.token.as_deref(), eco).await))
};
let list = self.lists[eco].get_or_init(load).await;
let list = Arc::clone(&list.read().expect("denylist lock"));
@@ -142,12 +150,12 @@ pub fn describe(advisories: &[&Advisory]) -> String {
}
/// A failed fetch leaves the last good list in place; a list that never loaded is retried every tick.
async fn watch(source: DenylistSource, lists: Arc<Lists>) {
async fn watch(source: DenylistSource, token: Option<String>, lists: Arc<Lists>) {
let period = Duration::from_secs(source.poll_secs);
let mut seen = revision(&source, None).await;
let mut seen = revision(&source, token.as_deref(), None).await;
loop {
tokio::time::sleep(period).await;
let changed = revision(&source, seen.as_deref()).await;
let changed = revision(&source, token.as_deref(), seen.as_deref()).await;
if let Some(current) = &changed {
info!(revision = %current, "denylist source changed");
seen = changed.clone();
@@ -158,7 +166,7 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
if changed.is_none() && !failed {
continue;
}
if let Ok(packages) = load(&source, eco).await {
if let Ok(packages) = load(&source, token.as_deref(), eco).await {
*list.write().expect("denylist lock") = Arc::new(Ok(packages));
}
}
@@ -166,9 +174,16 @@ async fn watch(source: DenylistSource, lists: Arc<Lists>) {
}
/// GitHub's 304 for an unchanged `If-None-Match` costs no rate limit.
async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String> {
async fn revision(
source: &DenylistSource,
token: Option<&str>,
seen: Option<&str>,
) -> Option<String> {
let client = EasyHttpWebClient::default();
let mut request = client.get(&source.revision).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
if let Some(seen) = seen {
request = request.header(IF_NONE_MATCH, seen);
}
@@ -185,8 +200,8 @@ async fn revision(source: &DenylistSource, seen: Option<&str>) -> Option<String>
response.headers().get(ETAG)?.to_str().ok().map(str::to_owned)
}
async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, &eco.to_lowercase()).await;
async fn load(source: &DenylistSource, token: Option<&str>, eco: &'static str) -> FetchedDenylist {
let list = fetch(source, token, &eco.to_lowercase()).await;
match &list {
Ok(packages) => info!(%eco, packages = packages.len(), "denylist loaded"),
Err(error) => warn!(%eco, %error, "denylist unavailable"),
@@ -194,10 +209,13 @@ async fn load(source: &DenylistSource, eco: &'static str) -> FetchedDenylist {
list
}
async fn fetch(source: &DenylistSource, eco: &str) -> FetchedDenylist {
async fn fetch(source: &DenylistSource, token: Option<&str>, eco: &str) -> FetchedDenylist {
let url = format!("{}/{eco}", source.base);
let client = EasyHttpWebClient::default();
let request = client.get(&url).header(USER_AGENT, AGENT);
let mut request = client.get(&url).header(USER_AGENT, AGENT);
if let Some(token) = token {
request = request.header(AUTHORIZATION, format!("Bearer {token}"));
}
let response = request.send().await.map_err(|e| e.to_string())?;
if response.status() != StatusCode::OK {
return Err(format!("{url} replied {}", response.status()));
-22
View File
@@ -1,4 +1,3 @@
pub mod age;
pub mod denylist;
pub mod osv;
@@ -7,9 +6,6 @@ use crate::Firewall;
use std::future::Future;
use std::pin::Pin;
/// Some registry APIs turn away requests without a user agent.
const AGENT: &str = "strix-firewall";
pub type VerdictFuture<'a> = Pin<Box<dyn Future<Output = Verdict> + Send + 'a>>;
/// `Unknown` means the filter could not check the package.
@@ -30,9 +26,6 @@ pub async fn consult(
registry: &'static Registry,
pkg: &Package,
) -> Option<(&'static str, Verdict)> {
if allowed(firewall, registry, pkg) {
return None;
}
let mut unsure = None;
for filter in firewall.filters() {
match filter.check(registry, pkg).await {
@@ -44,18 +37,3 @@ pub async fn consult(
}
unsure
}
fn allowed(firewall: &Firewall, registry: &Registry, pkg: &Package) -> bool {
let Some(entries) = firewall.config.allow.get(registry.eco) else {
return false;
};
entries.iter().any(|entry| {
let name_matches = match registry.normalize {
Some(normalize) => normalize(&entry.name) == pkg.name,
None => entry.name == pkg.name,
};
// Go writes a leading v that the parsed version drops, and PEP 440 ignores one.
let version = entry.version.as_deref().map(|v| v.strip_prefix('v').unwrap_or(v));
name_matches && (version.is_none() || version == pkg.version.as_deref())
})
}
+1 -8
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static CARGO_INDEX: Registry = Registry {
prefix: "/cargo/index/",
@@ -16,7 +16,6 @@ pub static CARGO_INDEX: Registry = Registry {
// Sparse index paths shard by name: le/ft/left-pad, 3/s/syn, 2/cc.
Pattern { re: r"(?<name>[^/]+)$", rewrite: None },
],
published: None,
};
pub static CARGO_DL: Registry = Registry {
@@ -27,11 +26,6 @@ pub static CARGO_DL: Registry = Registry {
index: false,
normalize: None,
patterns: &[Pattern { re: r"^(?<name>[^/]+)/(?<version>[^/]+)", rewrite: None }],
published: Some(Published {
url: "https://crates.io/api/v1/crates/{name}/{version}",
pointer: "/version/created_at",
escape: None,
}),
};
/// `cargo add` resolves versions here, named by config.json's "api" field.
@@ -43,5 +37,4 @@ pub static CRATES_API: Registry = Registry {
index: false,
normalize: None,
patterns: &[],
published: None,
};
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GEM: Registry = Registry {
prefix: "/gem/",
@@ -16,9 +16,4 @@ pub static GEM: Registry = Registry {
// The compact index, which names no version.
Pattern { re: r"^info/(?<name>[^/]+)$", rewrite: None },
],
published: Some(Published {
url: "https://rubygems.org/api/v2/rubygems/{name}/versions/{version}.json",
pointer: "/created_at",
escape: None,
}),
};
+1 -17
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static GO: Registry = Registry {
prefix: "/go/",
@@ -13,11 +13,6 @@ pub static GO: Registry = Registry {
// The version list and the latest lookup, which name no version.
Pattern { re: r"^(?<name>.+)/@(?:v/list|latest)$", rewrite: None },
],
published: Some(Published {
url: "https://proxy.golang.org/{name}/@v/v{version}.info",
pointer: "/Time",
escape: Some(escape),
}),
};
/// The proxy protocol writes every capital as !<lowercase>; advisories do not.
@@ -32,14 +27,3 @@ fn unescape(path: &str) -> String {
}
out
}
fn escape(path: &str) -> String {
let mut out = String::with_capacity(path.len());
for c in path.chars() {
if c.is_ascii_uppercase() {
out.push('!');
}
out.push(c.to_ascii_lowercase());
}
out
}
-9
View File
@@ -48,15 +48,6 @@ pub struct Registry {
/// PyPI escapes a name into a filename and Go escapes its capitals; advisories do neither.
pub normalize: Option<fn(&str) -> String>,
pub patterns: &'static [Pattern],
pub published: Option<Published>,
}
/// A URL to fetch and a JSON pointer to the timestamp in its reply.
#[derive(Debug)]
pub struct Published {
pub url: &'static str,
pub pointer: &'static str,
pub escape: Option<fn(&str) -> String>,
}
impl Registry {
+1 -6
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
pub static NPM: Registry = Registry {
prefix: "/npm/",
@@ -18,9 +18,4 @@ pub static NPM: Registry = Registry {
rewrite: Some(("https://registry.npmjs.org/", "/npm/")),
},
],
published: Some(Published {
url: "https://registry.npmjs.org/{name}",
pointer: "/time/{version}",
escape: None,
}),
};
+1 -7
View File
@@ -1,4 +1,4 @@
use super::{Pattern, Published, Registry};
use super::{Pattern, Registry};
const FILES: (&str, &str) = ("https://files.pythonhosted.org/", "/pypi-files/");
@@ -13,7 +13,6 @@ pub static PYPI: Registry = Registry {
Pattern { re: r"^simple/(?<name>[^/]+)", rewrite: Some(FILES) },
Pattern { re: "", rewrite: Some(FILES) },
],
published: None,
};
/// The directories are hash shards, but the filename names the distribution.
@@ -36,11 +35,6 @@ pub static PYPI_FILES: Registry = Registry {
rewrite: None,
},
],
published: Some(Published {
url: "https://pypi.org/pypi/{name}/{version}/json",
pointer: "/urls/0/upload_time_iso_8601",
escape: None,
}),
};
/// PEP 503: runs of - _ and . collapse to one -, lowercased, which also un-escapes a wheel.
-27
View File
@@ -1,27 +0,0 @@
use firewall::policies::screen;
use firewall::{Firewall, FirewallConfig};
use rama::rt::blocking::Runtime;
const OLD_DOWNLOADS: &[&str] = &[
"https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
"https://files.pythonhosted.org/packages/aa/bb/requests-2.31.0-py3-none-any.whl",
"https://static.crates.io/crates/serde/1.0.200",
"https://rubygems.org/gems/rake-13.0.6.gem",
"https://proxy.golang.org/github.com/!burnt!sushi/toml/@v/v1.3.2.zip",
];
#[test]
#[ignore = "queries every registry"]
fn every_registry_reports_when_a_version_was_published() {
let runtime = Runtime::try_new().expect("runtime");
runtime.block_on(async {
let firewall =
|hours| Firewall::new(FirewallConfig { min_age_hours: hours, ..Default::default() });
let (lenient, strict) = (firewall(24), firewall(24 * 365 * 100));
for url in OLD_DOWNLOADS {
assert_eq!(screen(url, &lenient).await, None, "{url}");
let blocked = screen(url, &strict).await.unwrap_or_default();
assert!(blocked.contains("hour minimum"), "{url}: {blocked:?}");
}
});
}
+2 -14
View File
@@ -1,13 +1,12 @@
use firewall::config::path;
use firewall::policies::filters::consult;
use firewall::policies::Package;
use firewall::{Firewall, FirewallConfig};
use firewall::FirewallConfig;
#[test]
fn a_file_only_has_to_set_what_it_changes() {
let config: FirewallConfig = serde_json::from_str(r#"{"fail_open": false}"#).expect("parses");
assert!(!config.fail_open);
assert_eq!(config.port, firewall::DEFAULT_PORT);
assert_eq!(config.denylist.token, FirewallConfig::default().denylist.token);
}
#[test]
@@ -20,14 +19,3 @@ fn the_config_lives_under_the_config_directory() {
let path = path().expect("a home directory");
assert!(path.ends_with("config.json"), "{}", path.display());
}
#[tokio::test]
async fn an_allowed_version_skips_the_filters() {
// An unreachable denylist would otherwise leave the package unchecked.
let config = r#"{"allow": {"go": [{"name": "golang.org/x/net", "version": "v0.20.0"}]}, "denylist": {"base": "http://127.0.0.1:1"}}"#;
let firewall = Firewall::new(serde_json::from_str(config).expect("parses"));
let (registry, _) = firewall.match_on("/go/golang.org/x/net/@v/list", |r| r.prefix).expect("go");
let pkg = |version: &str| Package { name: "golang.org/x/net".into(), version: Some(version.into()) };
assert!(consult(&firewall, registry, &pkg("0.20.0")).await.is_none());
assert!(consult(&firewall, registry, &pkg("0.21.0")).await.is_some());
}
+22 -1
View File
@@ -1,7 +1,9 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::Firewall;
use firewall::{aliasing, Firewall, FirewallConfig};
use rama::http::{Body, Request};
use std::io::{Read, Write};
use std::net::{TcpListener, TcpStream};
fn request(target: &str, host: Option<&str>) -> Request {
let mut builder = Request::builder().method("GET").uri(target);
@@ -35,6 +37,25 @@ fn requests_always_go_out_over_tls() {
assert_eq!(https_url(&request("/six", None)), None);
}
#[test]
fn a_plain_http_upstream_is_not_upgraded() {
let upstream = TcpListener::bind("127.0.0.1:0").expect("bind");
let addr = upstream.local_addr().expect("address");
std::thread::spawn(move || {
let (mut stream, _) = upstream.accept().expect("accept");
let _ = stream.read(&mut [0; 4096]);
let _ = stream.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 2\r\n\r\nok");
});
let port = aliasing::spawn(0, FirewallConfig::default()).expect("spawn");
let mut proxy = TcpStream::connect(("127.0.0.1", port)).expect("connect");
let request = format!("GET http://{addr}/x HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
proxy.write_all(request.as_bytes()).expect("write");
let mut response = String::new();
proxy.read_to_string(&mut response).expect("read");
assert!(response.starts_with("HTTP/1.1 200") && response.ends_with("ok"), "{response}");
}
#[tokio::test]
async fn downgrade_touches_known_hosts_in_text_only() {
let text = b"https://pypi.org/x https://example.com/y".to_vec();
+1 -21
View File
@@ -1,19 +1,10 @@
use firewall::aliasing::managers::*;
use firewall::aliasing::Invocation;
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");
assert_eq!(args.first().map(String::as_str), Some("run"));
assert_eq!(args[args.len() - 2..], ["--", "--release"]);
}
#[test]
fn recipes_expand_into_the_environment() {
let var = |name: &str, value: &str| (name.to_string(), value.to_string());
let npm = variables("http://127.0.0.1:9000");
let npm = variables("http://127.0.0.1:9000", &NPM);
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", &GEM, &["install".to_string()]);
@@ -21,17 +12,6 @@ fn recipes_expand_into_the_environment() {
assert!(arguments("http://127.0.0.1:9000", &GEM, &["sources".to_string()]).is_empty());
}
#[test]
fn no_two_managers_set_a_variable_differently() {
let mut seen = std::collections::HashMap::new();
for manager in MANAGERS {
for (key, value) in manager.env {
let first = *seen.entry(key).or_insert(value);
assert_eq!(first, value, "{} sets {key} differently", manager.command);
}
}
}
#[tokio::test]
async fn every_overridden_registry_is_known() {
let hosts = Firewall::default().hosts();
-7
View File
@@ -43,10 +43,3 @@ fn stored_ca_is_rejected_when_the_signing_key_changed() {
assert!(mitm::CertificateAuthority::from_blob(&blob, &hosts, &signer()).is_none());
}
/// rcgen signs a CRL only for a CA with cRLSign, and schannel wants one for every leaf.
#[test]
fn ca_signs_an_empty_crl() {
let crl = mitm::crl(&["registry.npmjs.org", "pypi.org"], &signer()).expect("sign a crl");
assert_eq!(crl[0], 0x30, "a DER SEQUENCE");
}
+1 -1
View File
@@ -36,7 +36,7 @@ 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, args), dir.as_deref()) {
Ok(0) => Outcome::Ok,
Ok(_) => Outcome::Failed,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Outcome::Absent,
+2 -2
View File
@@ -1,7 +1,7 @@
import Foundation
enum FirewallIDs {
static let appBundleID = "com.usestrix.firewall"
static let extensionBundleID = "com.usestrix.firewall.extension"
static let appBundleID = "ai.strix.firewall"
static let extensionBundleID = "ai.strix.firewall.extension"
static let caCommonName = "Strix Firewall"
}
@@ -3,7 +3,7 @@
<plist version="1.0">
<dict>
<key>Label</key>
<string>com.usestrix.firewall.env</string>
<string>ai.strix.firewall.env</string>
<key>ProgramArguments</key>
<array>
<string>/bin/launchctl</string>
+14 -41
View File
@@ -1,10 +1,8 @@
#!/bin/bash
# Builds and signs Firewall.app, installs it to /Applications, and packages it as build/Firewall.pkg.
# The package is notarized with the notarytool keychain profile in NOTARY_PROFILE; `build.sh debug`
# makes a Debug build and skips notarization.
#
# Xcode builds the bundles unsigned; they are signed here. Debug builds use Apple Development signing
# because macOS refuses to activate a Developer ID extension that is not notarized.
# Xcode builds the bundles unsigned; they are signed here because the granted NetworkExtension
# entitlement has no -systemextension variant, so Xcode cannot sign them itself.
set -euo pipefail
cd "$(dirname "$0")"
@@ -12,27 +10,13 @@ cd "$(dirname "$0")"
: "${TEAM:?set TEAM in macos/.env}"
export TEAM
# Identities are matched as a substring of the certificate name; set them if more than one matches.
# Development profiles grant only the plain NetworkExtension values, Developer ID ones only -systemextension.
if [ "${1:-}" = debug ]; then
CONFIGURATION=Debug
IDENTITY=${DEBUG_IDENTITY:-Apple Development}
NE_PROVIDER=app-proxy-provider
else
CONFIGURATION=Release
IDENTITY=${IDENTITY:-Developer ID Application}
NE_PROVIDER=app-proxy-provider-systemextension
: "${NOTARY_PROFILE:?set NOTARY_PROFILE in macos/.env}"
fi
export NE_PROVIDER
APP_ID=com.usestrix.firewall
EXT_ID=com.usestrix.firewall.extension
APP_ID=ai.strix.firewall
EXT_ID=ai.strix.firewall.extension
VERSION=0.1
INSTALLER_IDENTITY=${INSTALLER_IDENTITY:-Developer ID Installer}
IDENTITY=${IDENTITY:-$(security find-identity -v -p codesigning | awk -F'"' '/Apple Development/ {print $2; exit}')}
PROFILES="$HOME/Library/Developer/Xcode/UserData/Provisioning Profiles"
APP=build/Build/Products/$CONFIGURATION/Installer.app
APP=build/Build/Products/Debug/Installer.app
EXT=$APP/Contents/Library/SystemExtensions/$EXT_ID.systemextension
PKG_ROOT=build/pkg-root
@@ -41,13 +25,12 @@ main() {
sign
install_app
package
[ $CONFIGURATION = Debug ] || notarize
}
build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration $CONFIGURATION \
xcodebuild -project Firewall.xcodeproj -scheme Installer -configuration Debug \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
@@ -56,9 +39,9 @@ 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 \
codesign --force --options runtime --timestamp=none \
--entitlements Extension/Extension.entitlements -s "$IDENTITY" "$EXT"
codesign --force --options runtime --timestamp \
codesign --force --options runtime --timestamp=none \
--entitlements Installer/Installer.entitlements -s "$IDENTITY" "$APP"
codesign --verify --deep --strict "$APP"
}
@@ -76,30 +59,20 @@ package() {
rm -rf "$PKG_ROOT"
mkdir -p "$PKG_ROOT/Applications" "$PKG_ROOT/Library/LaunchAgents"
cp -R "$APP" "$PKG_ROOT/Applications/Firewall.app"
cp LaunchAgents/com.usestrix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
cp LaunchAgents/ai.strix.firewall.env.plist "$PKG_ROOT/Library/LaunchAgents/"
pkgbuild --analyze --root "$PKG_ROOT" build/components.plist >/dev/null
plutil -replace 0.BundleIsRelocatable -bool NO build/components.plist
pkgbuild --root "$PKG_ROOT" --component-plist build/components.plist --scripts pkg \
--identifier $APP_ID --version $VERSION --install-location / --sign "$INSTALLER_IDENTITY" \
build/Firewall.pkg >/dev/null
--identifier $APP_ID --version $VERSION --install-location / build/Firewall.pkg >/dev/null
}
notarize() {
echo "==> Notarizing build/Firewall.pkg"
xcrun notarytool submit build/Firewall.pkg --keychain-profile "$NOTARY_PROFILE" --wait
xcrun stapler staple build/Firewall.pkg
}
# Prints the newest provisioning profile Xcode has downloaded for a bundle id that grants NE_PROVIDER.
# Prints the newest provisioning profile Xcode has downloaded for a bundle id.
profile_for() {
local want=$TEAM.$1 newest= newest_time=0 profile id t
local want=$TEAM.$1 newest= newest_time=0 id t
for p in "$PROFILES"/*.provisionprofile; do
profile=$(security cms -D -i "$p")
id=$(plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - - <<<"$profile")
id=$(security cms -D -i "$p" | plutil -extract Entitlements.com\\.apple\\.application-identifier raw -o - -)
[ "$id" = "$want" ] || continue
plutil -extract Entitlements.com\\.apple\\.developer\\.networking\\.networkextension json -o - - <<<"$profile" |
grep -q "\"$NE_PROVIDER\"" || continue
t=$(stat -f %m "$p")
if [ "$t" -gt "$newest_time" ]; then newest=$p; newest_time=$t; fi
done
+1 -1
View File
@@ -3,7 +3,7 @@
user=$(stat -f %Su /dev/console)
[ "$user" = root ] && exit 0
uid=$(id -u "$user")
agent=/Library/LaunchAgents/com.usestrix.firewall.env.plist
agent=/Library/LaunchAgents/ai.strix.firewall.env.plist
launchctl bootout "gui/$uid" "$agent" 2>/dev/null
launchctl bootstrap "gui/$uid" "$agent"
launchctl asuser "$uid" sudo -u "$user" open -a /Applications/Firewall.app --args --install
+10 -10
View File
@@ -1,6 +1,6 @@
name: Firewall
options:
bundleIdPrefix: com.usestrix.firewall
bundleIdPrefix: ai.strix.firewall
deploymentTarget: { macOS: "13.0" }
createIntermediateGroups: true
@@ -33,8 +33,8 @@ targets:
sources: [Extension]
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall.extension
PRODUCT_NAME: com.usestrix.firewall.extension
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall.extension
PRODUCT_NAME: ai.strix.firewall.extension
PRODUCT_MODULE_NAME: Extension
OTHER_LDFLAGS: [-lfirewall, -framework, NetworkExtension, -lz]
info:
@@ -44,7 +44,7 @@ targets:
CFBundleShortVersionString: "0.1"
NSSystemExtensionUsageDescription: Firewall uses a network extension to screen package downloads before they install.
NetworkExtension:
NEMachServiceName: ${TEAM}.com.usestrix.firewall.extension
NEMachServiceName: ${TEAM}.ai.strix.firewall.extension
NEProviderClasses:
com.apple.networkextension.app-proxy: RamaAppleNetworkExtension.RamaTransparentProxyProvider
entitlements:
@@ -52,13 +52,13 @@ targets:
properties:
# Xcode injects these from the profile; we sign by hand, and NE's IPC
# check rejects a caller without them.
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall.extension
com.apple.application-identifier: ${TEAM}.ai.strix.firewall.extension
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.networking.networkextension:
- ${NE_PROVIDER}
- app-proxy-provider
# NE requires NEMachServiceName to start with one of these.
com.apple.security.application-groups:
- ${TEAM}.com.usestrix.firewall
- ${TEAM}.ai.strix.firewall
com.apple.security.network.client: true
com.apple.security.network.server: true
dependencies:
@@ -96,7 +96,7 @@ targets:
buildPhase: resources
settings:
base:
PRODUCT_BUNDLE_IDENTIFIER: com.usestrix.firewall
PRODUCT_BUNDLE_IDENTIFIER: ai.strix.firewall
info:
path: Installer/Info.plist
properties:
@@ -107,11 +107,11 @@ targets:
entitlements:
path: Installer/Installer.entitlements
properties:
com.apple.application-identifier: ${TEAM}.com.usestrix.firewall
com.apple.application-identifier: ${TEAM}.ai.strix.firewall
com.apple.developer.team-identifier: ${TEAM}
com.apple.developer.system-extension.install: true
com.apple.developer.networking.networkextension:
- ${NE_PROVIDER}
- app-proxy-provider
com.apple.security.network.client: true
dependencies:
- target: Extension