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
12 changed files with 123 additions and 263 deletions

No files matched your search

+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"
+1 -1
View File
@@ -53,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"] }
+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
View File
@@ -80,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> {
+13 -1
View File
@@ -40,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(),
}
+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();
+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,