Compare commits

..
6 Commits
Author SHA1 Message Date
Ian Graham Martinez 20c781404f Sign an empty CRL and point leaves at it
schannel's revocation check, which cargo enables, fails closed on a leaf
without a reachable CRL. The CA gains cRLSign, which rcgen needs to sign
one, and leaves carry a distribution point when the platform serves it.
2026-10-13 10:51:44 -04:00
Ian Graham Martinez 0ba2e23032 List the screened hosts from the config
Building a Firewall needs a runtime, which an installer has no use for.
2026-10-13 10:51:44 -04:00
f500e4a42a Show Windows users PowerShell syntax and the name they type (#4)
* Show Windows users PowerShell syntax and the name they type

On Windows the help said `firewall.exe`, suggested a bash alias, and
`serve` printed bash-style variable assignments. Drop the extension,
suggest a PowerShell function (Set-Alias cannot carry arguments), and
print `$env:` assignments. Other platforms are unchanged.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* Show the cmd doskey alias beside the PowerShell one

---------

Co-authored-by: Ian Graham Martinez <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-12 17:55:39 -04:00
ian-at-strix b2e036f7c6 Run any command after firewall verbatim, -- included (#6)
bpaf always consumes `--`, so `firewall cargo run -- --release` handed
--release to cargo and our --config flags to the program. The CLI is now clap
with one trailing COMMAND argument, which keeps `--`.

Every manager's environment is set on every run, and cargo's and gem's flags
are added when the command is cargo or gem, so `firewall sh -c '…'` is
screened too. Since every command now gets HTTPS_PROXY, HTTPS to hosts that
aren't registries is tunnelled; registry hosts are still refused.
2026-10-12 16:55:27 -04:00
ian-at-strix 7f9e257a38 Merge pull request #3 from usestrix/fix/pipenv-trusted-host
Trust the PyPI hosts for pipenv's pip
2026-10-12 15:49:55 -04:00
Ian Graham MartinezandClaude Opus 5.5 098dea7cc0 Trust the PyPI hosts for pipenv's pip
pipenv installs through pip, which skips the plaintext index on an
untrusted host, so `firewall pipenv install six` fetched nothing and
failed with "No matching distribution found". Set PIP_TRUSTED_HOST as
the pip recipe already does.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:30:43 -04:00
9 changed files with 89 additions and 21 deletions

No files matched your search

+2
View File
@@ -9,6 +9,8 @@ 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
View File
@@ -80,6 +80,18 @@ 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> {
+1 -8
View File
@@ -40,14 +40,7 @@ impl Firewall {
}
pub fn hosts(&self) -> Vec<&'static str> {
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
self.config.hosts()
}
pub fn is_index_url(&self, url: &str) -> bool {
+28 -4
View File
@@ -25,11 +25,13 @@ fn options() -> Command {
"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 \
alias npm='{program} npm'\n\n\
{}\n\n\
Supported package managers: {}",
alias(&program),
managers.join(", ")
);
Command::new("firewall")
.bin_name(&program)
.about("Screens package downloads before they reach your machine")
.after_help(footer)
.arg_required_else_help(true)
@@ -91,12 +93,32 @@ 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 npm_config_registry={base}/npm/");
println!(" pip PIP_INDEX_URL={base}/pypi/simple/");
println!(" npm {}", variable("npm_config_registry", &format!("{base}/npm/")));
println!(" pip {}", variable("PIP_INDEX_URL", &format!("{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,
@@ -113,7 +135,9 @@ fn fail(kind: &'static str, message: &str) -> ! {
fn program_name() -> String {
let arg = std::env::args_os().next().unwrap_or_default();
match Path::new(&arg).file_name() {
// 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() } {
Some(name) => name.to_string_lossy().into_owned(),
None => "firewall".into(),
}
+22 -3
View File
@@ -1,10 +1,12 @@
use rama::error::BoxError;
use rama::tls::boring::core::x509::X509;
use rcgen::{
BasicConstraints, CertificateParams, DistinguishedName, DnType, GeneralSubtree, IsCa,
KeyUsagePurpose, NameConstraints, SigningKey,
BasicConstraints, CertificateParams, CertificateRevocationListParams, DistinguishedName,
DnType, GeneralSubtree, IsCa, Issuer, KeyIdMethod, KeyUsagePurpose, NameConstraints,
SerialNumber, 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>;
@@ -42,6 +44,22 @@ 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();
@@ -51,7 +69,8 @@ pub fn params(hosts: &[&str]) -> CertificateParams {
let mut params = CertificateParams::default();
params.distinguished_name = name;
params.is_ca = IsCa::Ca(BasicConstraints::Constrained(0));
params.key_usages = vec![KeyUsagePurpose::KeyCertSign];
// rcgen signs a CRL only for a CA that may.
params.key_usages = vec![KeyUsagePurpose::KeyCertSign, KeyUsagePurpose::CrlSign];
params.name_constraints = Some(NameConstraints {
permitted_subtrees: hosts
.iter()
+12 -3
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, DistinguishedName, DnType, Issuer, KeyPair};
use rcgen::{CertificateParams, CrlDistributionPoint, DistinguishedName, DnType, Issuer, KeyPair};
use std::sync::Arc;
/// Leaves name only screened hosts; any other name falls outside the CA's constraint.
@@ -14,12 +14,18 @@ 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>) -> Self {
pub(super) fn new(
ca_crt: X509,
signer: CaSigner,
hosts: Vec<&'static str>,
crl: Option<String>,
) -> Self {
let ca_params = Arc::new(params(&hosts));
Self { ca_crt, ca_params, signer, hosts }
Self { ca_crt, ca_params, signer, hosts, crl }
}
}
@@ -42,6 +48,9 @@ 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()?;
+4 -2
View File
@@ -3,7 +3,7 @@ mod issuer;
mod route;
mod screen;
pub use ca::{CaSigner, CertificateAuthority};
pub use ca::{crl, CaSigner, CertificateAuthority};
use crate::Firewall;
use issuer::ConstrainedCertIssuer;
@@ -25,14 +25,16 @@ 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());
let issuer = ConstrainedCertIssuer::new(ca.crt.clone(), signer, hosts.clone(), crl);
let relay = Arc::new(HttpMitmRelay::new(exec).with_http_middleware(FirewallLayer { firewall }));
+1 -1
View File
@@ -108,7 +108,7 @@ 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, exec);
let service = mitm::mitm_service(firewall, &ca, signer, None, exec);
Ok(Handler { ca, hosts, service })
}
+7
View File
@@ -43,3 +43,10 @@ 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");
}