mirror of
https://github.com/usestrix/firewall.git
synced 2026-11-09 18:08:49 -10:00
Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5df8e11459 | ||
|
|
bf0d8d5be8 | ||
|
|
6fb4db7492 |
No files matched your search
+1
-1
@@ -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_Storage_FileSystem", "Win32_System_Com", "Win32_System_Console", "Win32_System_Environment", "Win32_System_JobObjects", "Win32_System_Pipes", "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_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"
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ pub static BUN: Manager = Manager {
|
||||
("https_proxy", PROXY),
|
||||
("HTTP_PROXY", PROXY),
|
||||
("HTTPS_PROXY", PROXY),
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
("npm_config_registry", "http://{npm}/"),
|
||||
],
|
||||
args: &[],
|
||||
};
|
||||
@@ -14,7 +14,7 @@ pub static CARGO: Manager = Manager {
|
||||
"--config",
|
||||
"source.crates-io.replace-with=\"firewall\"",
|
||||
"--config",
|
||||
"source.firewall.registry=\"sparse+http://index.crates.io/\"",
|
||||
"source.firewall.registry=\"sparse+http://{cargo/index}/\"",
|
||||
],
|
||||
}],
|
||||
};
|
||||
@@ -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://rubygems.org/"],
|
||||
flags: &["--clear-sources", "--source", "http://{gem}/"],
|
||||
}],
|
||||
};
|
||||
@@ -6,7 +6,7 @@ pub static GO: Manager = Manager {
|
||||
env: &[
|
||||
("HTTP_PROXY", PROXY),
|
||||
("http_proxy", PROXY),
|
||||
("GOPROXY", "http://proxy.golang.org"),
|
||||
("GOPROXY", "http://{go}"),
|
||||
("GOSUMDB", "off"),
|
||||
("GOFLAGS", "-mod=mod"),
|
||||
],
|
||||
|
||||
@@ -9,6 +9,9 @@ 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;
|
||||
@@ -20,7 +23,7 @@ pub use pipenv::PIPENV;
|
||||
pub use pnpm::PNPM;
|
||||
pub use uv::UV;
|
||||
|
||||
pub const PYPI_SIMPLE: &str = "http://pypi.org/simple";
|
||||
pub const PYPI_SIMPLE: &str = "http://{pypi}/simple";
|
||||
|
||||
pub const PROXY: &str = "{proxy}";
|
||||
|
||||
@@ -31,7 +34,9 @@ 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://.
|
||||
/// 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.
|
||||
pub struct Manager {
|
||||
pub command: &'static str,
|
||||
pub env: &'static [(&'static str, &'static str)],
|
||||
@@ -42,23 +47,36 @@ 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)> {
|
||||
pub fn variables(proxy: &str, config: &FirewallConfig) -> Vec<(String, String)> {
|
||||
let env = MANAGERS.iter().flat_map(|manager| manager.env);
|
||||
env.map(|(key, value)| (key.to_string(), expand(value, proxy))).collect()
|
||||
env.map(|(key, value)| (key.to_string(), expand(value, proxy, config))).collect()
|
||||
}
|
||||
|
||||
pub fn arguments(proxy: &str, manager: &Manager, typed: &[String]) -> Vec<String> {
|
||||
pub fn arguments(
|
||||
proxy: &str,
|
||||
config: &FirewallConfig,
|
||||
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)));
|
||||
out.extend(args.flags.iter().map(|flag| expand(flag, proxy, config)));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn expand(value: &str, proxy: &str) -> String {
|
||||
value.replace(PROXY, proxy)
|
||||
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
|
||||
}
|
||||
@@ -5,7 +5,7 @@ pub static NPM: Manager = Manager {
|
||||
env: &[
|
||||
("npm_config_proxy", PROXY),
|
||||
("npm_config_https_proxy", PROXY),
|
||||
("npm_config_registry", "http://registry.npmjs.org/"),
|
||||
("npm_config_registry", "http://{npm}/"),
|
||||
],
|
||||
args: &[],
|
||||
};
|
||||
@@ -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", "pypi.org files.pythonhosted.org"),
|
||||
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
|
||||
("PIP_DISABLE_PIP_VERSION_CHECK", "1"),
|
||||
];
|
||||
|
||||
|
||||
@@ -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", "pypi.org files.pythonhosted.org"),
|
||||
("PIP_TRUSTED_HOST", "{host:pypi} {host:pypi-files}"),
|
||||
],
|
||||
args: &[],
|
||||
};
|
||||
@@ -6,7 +6,7 @@ pub static PNPM: Manager = Manager {
|
||||
env: &[
|
||||
("pnpm_config_proxy", PROXY),
|
||||
("pnpm_config_https_proxy", PROXY),
|
||||
("pnpm_config_registry", "http://registry.npmjs.org/"),
|
||||
("pnpm_config_registry", "http://{npm}/"),
|
||||
],
|
||||
args: &[],
|
||||
};
|
||||
@@ -6,6 +6,7 @@ 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;
|
||||
@@ -21,12 +22,12 @@ impl Invocation {
|
||||
}
|
||||
|
||||
/// What the program is run with: the typed arguments plus any its manager needs.
|
||||
pub fn arguments(&self, proxy: &str) -> Vec<String> {
|
||||
pub fn arguments(&self, proxy: &str, config: &FirewallConfig) -> 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);
|
||||
let ours = managers::arguments(proxy, config, 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);
|
||||
@@ -34,15 +35,20 @@ impl Invocation {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn run(port: u16, invocation: &Invocation, dir: Option<&Path>) -> std::io::Result<i32> {
|
||||
pub fn run(
|
||||
port: u16,
|
||||
config: &FirewallConfig,
|
||||
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));
|
||||
command.args(invocation.arguments(&proxy, config));
|
||||
if let Some(dir) = dir {
|
||||
command.current_dir(dir);
|
||||
}
|
||||
for (key, value) in managers::variables(&proxy) {
|
||||
for (key, value) in managers::variables(&proxy, config) {
|
||||
command.env(key, value);
|
||||
}
|
||||
#[cfg(windows)]
|
||||
|
||||
@@ -10,7 +10,7 @@ use std::path::{Path, PathBuf};
|
||||
|
||||
pub const DEFAULT_PORT: u16 = 8080;
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[derive(Debug, Clone, 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, Deserialize)]
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct AllowedPackage {
|
||||
pub name: String,
|
||||
|
||||
+3
-2
@@ -73,13 +73,14 @@ fn serve() -> ! {
|
||||
fn run(invocation: Invocation) -> ! {
|
||||
telemetry::set_mode("wrapper");
|
||||
let command = &invocation.program;
|
||||
let port = match aliasing::spawn(0, load()) {
|
||||
let config = load();
|
||||
let port = match aliasing::spawn(0, config.clone()) {
|
||||
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, &invocation, None);
|
||||
let result = aliasing::run(port, &config, &invocation, None);
|
||||
telemetry::flush();
|
||||
match result {
|
||||
Ok(code) => exit(code),
|
||||
|
||||
@@ -11,7 +11,6 @@ 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.
|
||||
@@ -66,39 +65,25 @@ pub fn load_or_create_ca(
|
||||
Ok(ca)
|
||||
}
|
||||
|
||||
/// Created on first use with its ACL, which is reset every time. One a user made first, or a link,
|
||||
/// Created on first use, its owner and ACL 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();
|
||||
let exists = match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => {
|
||||
std::fs::remove_dir(&dir)?;
|
||||
false
|
||||
}
|
||||
match std::fs::symlink_metadata(&dir) {
|
||||
Ok(meta) if meta.file_type().is_symlink() => std::fs::remove_dir(&dir)?,
|
||||
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)?;
|
||||
false
|
||||
std::fs::remove_dir_all(&dir)?
|
||||
}
|
||||
Ok(_) => true,
|
||||
Err(_) => false,
|
||||
};
|
||||
_ => {}
|
||||
}
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
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)?;
|
||||
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))? };
|
||||
}
|
||||
unsafe { SetFileSecurityW(&path, info, PSECURITY_DESCRIPTOR(acl.0)).ok()? };
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
//! LocalSystem service screens it as the macOS extension does.
|
||||
|
||||
mod ca;
|
||||
mod pipe;
|
||||
mod service;
|
||||
mod tpm;
|
||||
mod tray;
|
||||
|
||||
@@ -1,58 +0,0 @@
|
||||
//! How the tray asks the service what it blocked, as the macOS app messages its extension.
|
||||
|
||||
use super::security_descriptor;
|
||||
use crate::blocks;
|
||||
use rama::error::BoxError;
|
||||
use std::time::Duration;
|
||||
use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::windows::named_pipe::{NamedPipeServer, ServerOptions};
|
||||
use windows::Win32::Security::SECURITY_ATTRIBUTES;
|
||||
|
||||
pub(super) const PIPE: &str = r"\\.\pipe\StrixFirewall";
|
||||
/// Signed-in users can connect, as the tray runs as them.
|
||||
const PIPE_ACL: &str = "D:P(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;IU)";
|
||||
|
||||
pub(super) async fn serve() -> Result<(), BoxError> {
|
||||
let mut server = create(true)?;
|
||||
loop {
|
||||
server.connect().await?;
|
||||
let client = std::mem::replace(&mut server, create(false)?);
|
||||
tokio::spawn(async move {
|
||||
if let Err(error) = answer(client).await {
|
||||
tracing::debug!(%error, "could not answer the tray");
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn create(first: bool) -> Result<NamedPipeServer, BoxError> {
|
||||
let acl = security_descriptor(PIPE_ACL)?;
|
||||
let mut attributes = SECURITY_ATTRIBUTES {
|
||||
nLength: size_of::<SECURITY_ATTRIBUTES>() as u32,
|
||||
lpSecurityDescriptor: acl.0,
|
||||
bInheritHandle: false.into(),
|
||||
};
|
||||
let attributes = (&raw mut attributes).cast();
|
||||
let mut options = ServerOptions::new();
|
||||
// Fails if anyone else made the pipe first.
|
||||
options.first_pipe_instance(first);
|
||||
Ok(unsafe { options.create_with_security_attributes_raw(PIPE, attributes)? })
|
||||
}
|
||||
|
||||
/// A line each way.
|
||||
async fn answer(client: NamedPipeServer) -> Result<(), BoxError> {
|
||||
let mut client = BufReader::new(client);
|
||||
let mut request = String::new();
|
||||
(&mut client)
|
||||
.take(64 * 1024)
|
||||
.read_line(&mut request)
|
||||
.await?;
|
||||
let reply = match request.trim_end() {
|
||||
"blocks" => serde_json::to_string(&blocks::take())?,
|
||||
other => format!("error: unknown request {other}"),
|
||||
};
|
||||
client.write_all(format!("{reply}\n").as_bytes()).await?;
|
||||
// Closing before the tray hangs up could discard the reply.
|
||||
let _ = tokio::time::timeout(Duration::from_secs(5), client.read(&mut [0])).await;
|
||||
Ok(())
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
use super::ca::{load_or_create_ca, secure_data_dir};
|
||||
use super::tpm::CaKey;
|
||||
use super::{
|
||||
config_file, loopback_addresses, pipe, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME,
|
||||
};
|
||||
use super::{config_file, loopback_addresses, write_hosts_entries, CRL_ADDRESS, SERVICE_NAME};
|
||||
use crate::config::FirewallConfig;
|
||||
use crate::http::{reply, text};
|
||||
use crate::{mitm, telemetry, Firewall};
|
||||
@@ -155,12 +153,6 @@ async fn start_screening(status: &ServiceStatusHandle) -> Result<Vec<&'static st
|
||||
let crl = HttpServer::auto(exec.clone()).service(crl);
|
||||
tokio::spawn(listen_on(CRL_ADDRESS, 80, &exec)?.serve(crl));
|
||||
|
||||
tokio::spawn(async {
|
||||
if let Err(error) = pipe::serve().await {
|
||||
tracing::error!(%error, "the tray cannot reach the service");
|
||||
}
|
||||
});
|
||||
|
||||
let service = mitm::mitm_service(firewall, &ca, signer, Some(crl_url), exec.clone());
|
||||
for (address, host) in loopback_addresses(&hosts) {
|
||||
for port in [80, 443] {
|
||||
|
||||
+8
-136
@@ -1,21 +1,17 @@
|
||||
//! The notification-area icon, started at sign-in as the menu bar app is on macOS.
|
||||
|
||||
use super::pipe::PIPE;
|
||||
use super::*;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem, Submenu};
|
||||
use serde::Deserialize;
|
||||
use std::io::{BufRead, BufReader, Cursor, Write};
|
||||
use std::os::windows::io::AsRawHandle;
|
||||
use muda::{Menu, MenuEvent, MenuItem, PredefinedMenuItem};
|
||||
use std::io::Cursor;
|
||||
use std::time::Duration;
|
||||
use tray_icon::{Icon, MouseButtonState, TrayIcon, TrayIconBuilder, TrayIconEvent};
|
||||
use windows::core::{w, PCWSTR};
|
||||
use windows::Win32::Foundation::*;
|
||||
use windows::Win32::System::Pipes::GetNamedPipeServerProcessId;
|
||||
use windows::Win32::System::Threading::CreateMutexW;
|
||||
use windows::Win32::UI::Shell::*;
|
||||
use windows::Win32::UI::WindowsAndMessaging::*;
|
||||
use windows_registry::CURRENT_USER;
|
||||
use windows_service::service::{Service, ServiceAccess, ServiceState, ServiceStatus};
|
||||
use windows_service::service::{Service, ServiceAccess, ServiceState};
|
||||
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
|
||||
|
||||
const MENU_PAUSE: &str = "pause";
|
||||
@@ -23,9 +19,6 @@ const MENU_RESTART: &str = "restart";
|
||||
const MENU_LOG: &str = "log";
|
||||
const MENU_QUIT: &str = "quit";
|
||||
const LOGO: &[u8] = include_bytes!("tray.png");
|
||||
/// Where blocked packages are listed, below Open logs.
|
||||
const BLOCKED_AT: usize = 5;
|
||||
const BLOCKED_KEPT: usize = 10;
|
||||
|
||||
/// Runs until Quit, once per session.
|
||||
pub fn run() -> Result<(), BoxError> {
|
||||
@@ -36,7 +29,6 @@ pub fn run() -> Result<(), BoxError> {
|
||||
}
|
||||
let status = MenuItem::new("", false, None);
|
||||
let pause = MenuItem::with_id(MENU_PAUSE, "", false, None);
|
||||
let blocked_separator = PredefinedMenuItem::separator();
|
||||
let menu = Menu::with_items(&[
|
||||
&status,
|
||||
&PredefinedMenuItem::separator(),
|
||||
@@ -47,9 +39,7 @@ pub fn run() -> Result<(), BoxError> {
|
||||
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
|
||||
])?;
|
||||
let (logo, logo_size) = logo_mask()?;
|
||||
let icon = TrayIconBuilder::new()
|
||||
.with_menu(Box::new(menu.clone()))
|
||||
.build()?;
|
||||
let icon = TrayIconBuilder::new().with_menu(Box::new(menu)).build()?;
|
||||
let window = HWND(icon.window_handle());
|
||||
let mut tray = Tray {
|
||||
icon,
|
||||
@@ -59,9 +49,6 @@ pub fn run() -> Result<(), BoxError> {
|
||||
status,
|
||||
pause,
|
||||
state: None,
|
||||
menu,
|
||||
blocked_separator,
|
||||
blocked: Vec::new(),
|
||||
};
|
||||
tray.update();
|
||||
unsafe { SetTimer(None, 0, 2000, None) };
|
||||
@@ -75,7 +62,6 @@ pub fn run() -> Result<(), BoxError> {
|
||||
// Ours, not the icon window's own hover timer.
|
||||
if message.message == WM_TIMER && message.hwnd.is_invalid() {
|
||||
tray.update();
|
||||
tray.collect_blocks();
|
||||
}
|
||||
while let Ok(event) = TrayIconEvent::receiver().try_recv() {
|
||||
// The menu opens on release, so this keeps it current.
|
||||
@@ -108,39 +94,11 @@ struct Tray {
|
||||
pause: MenuItem,
|
||||
/// As the menu last showed it.
|
||||
state: Option<ServiceState>,
|
||||
menu: Menu,
|
||||
blocked_separator: PredefinedMenuItem,
|
||||
/// The most recent, as the menu lists them.
|
||||
blocked: Vec<BlockedPackage>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Deserialize)]
|
||||
struct BlockedPackage {
|
||||
eco: String,
|
||||
name: String,
|
||||
version: Option<String>,
|
||||
filter: String,
|
||||
reason: String,
|
||||
}
|
||||
|
||||
impl BlockedPackage {
|
||||
fn label(&self) -> String {
|
||||
match &self.version {
|
||||
Some(version) => format!("{} {version} ({})", self.name, self.eco),
|
||||
None => format!("{} ({})", self.name, self.eco),
|
||||
}
|
||||
}
|
||||
|
||||
/// The denylist and OSV only flag packages known to be malicious; the age check only holds
|
||||
/// back new ones.
|
||||
fn malicious(&self) -> bool {
|
||||
self.filter != "age"
|
||||
}
|
||||
}
|
||||
|
||||
impl Tray {
|
||||
fn update(&mut self) {
|
||||
self.state = service_status().map(|status| status.current_state);
|
||||
self.state = service_state();
|
||||
let screening = self.state == Some(ServiceState::Running);
|
||||
let light = light_taskbar();
|
||||
if self.drawn != Some((light, screening)) {
|
||||
@@ -159,58 +117,6 @@ impl Tray {
|
||||
self.pause.set_enabled(self.state.is_some());
|
||||
}
|
||||
|
||||
/// The service hands each block over once, so whichever tray asks first shows it.
|
||||
fn collect_blocks(&mut self) {
|
||||
if self.state != Some(ServiceState::Running) {
|
||||
return;
|
||||
}
|
||||
let Ok(reply) = ask_service("blocks") else {
|
||||
return;
|
||||
};
|
||||
let Ok(packages) = serde_json::from_str::<Vec<BlockedPackage>>(&reply) else {
|
||||
return;
|
||||
};
|
||||
let mut new = Vec::new();
|
||||
for package in packages {
|
||||
if !self.blocked.contains(&package) {
|
||||
self.blocked.push(package.clone());
|
||||
new.push(package);
|
||||
}
|
||||
}
|
||||
if new.is_empty() {
|
||||
return;
|
||||
}
|
||||
let excess = self.blocked.len().saturating_sub(BLOCKED_KEPT);
|
||||
self.blocked.drain(..excess);
|
||||
self.list_blocked();
|
||||
// Off the UI thread, so the menu keeps working while they are open.
|
||||
std::thread::spawn(move || new.iter().for_each(alert));
|
||||
}
|
||||
|
||||
fn list_blocked(&self) {
|
||||
while self.menu.items().len() > BLOCKED_AT + 2 {
|
||||
self.menu.remove_at(BLOCKED_AT);
|
||||
}
|
||||
let _ = self.menu.insert(&self.blocked_separator, BLOCKED_AT);
|
||||
for (i, package) in self.blocked.iter().enumerate() {
|
||||
let verb = if package.malicious() {
|
||||
"Blocked"
|
||||
} else {
|
||||
"Held back"
|
||||
};
|
||||
let reason = if package.malicious() {
|
||||
format!("Malicious: {}", package.reason)
|
||||
} else {
|
||||
package.reason.clone()
|
||||
};
|
||||
let reason = MenuItem::new(reason, false, None);
|
||||
let label = format!("{verb} {}", package.label());
|
||||
if let Ok(item) = Submenu::with_items(label, true, &[&reason]) {
|
||||
let _ = self.menu.insert(&item, BLOCKED_AT + 1 + i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn on_menu(&self, id: &str) {
|
||||
let state = self.state;
|
||||
let screening = state == Some(ServiceState::Running);
|
||||
@@ -237,39 +143,6 @@ impl Tray {
|
||||
}
|
||||
}
|
||||
|
||||
fn alert(package: &BlockedPackage) {
|
||||
let text = format!("{} {}.", package.label(), package.reason);
|
||||
let (title, text, style) = if package.malicious() {
|
||||
let title = format!("Blocked malware: {}", package.label());
|
||||
let text = text
|
||||
+ "\n\nIt was not installed. Don't install it from another source. If you didn't expect this "
|
||||
+ "package, a dependency may be compromised; tell your security team.";
|
||||
(title, text, MB_ICONERROR)
|
||||
} else {
|
||||
("Held back a new package".to_owned(), text, MB_ICONINFORMATION)
|
||||
};
|
||||
let (title, text) = (HSTRING::from(title), HSTRING::from(text));
|
||||
unsafe { MessageBoxW(None, &text, &title, style | MB_SETFOREGROUND) };
|
||||
}
|
||||
|
||||
/// One request per connection, as the service answers.
|
||||
fn ask_service(request: &str) -> Result<String, BoxError> {
|
||||
let mut pipe = std::fs::OpenOptions::new()
|
||||
.read(true)
|
||||
.write(true)
|
||||
.open(PIPE)?;
|
||||
// Any program can create the pipe if it gets there before the service.
|
||||
let mut server = 0;
|
||||
unsafe { GetNamedPipeServerProcessId(HANDLE(pipe.as_raw_handle()), &mut server)? };
|
||||
if service_status().and_then(|status| status.process_id) != Some(server) {
|
||||
return Err("Another program is posing as the firewall service.".into());
|
||||
}
|
||||
pipe.write_all(format!("{request}\n").as_bytes())?;
|
||||
let mut reply = String::new();
|
||||
BufReader::new(pipe).read_line(&mut reply)?;
|
||||
Ok(reply.trim_end().to_owned())
|
||||
}
|
||||
|
||||
fn show_error(error: BoxError) {
|
||||
let text = match error.source() {
|
||||
Some(source) => format!("{error}: {source}"),
|
||||
@@ -280,13 +153,13 @@ fn show_error(error: BoxError) {
|
||||
}
|
||||
|
||||
/// `None` when the service is not installed.
|
||||
fn service_status() -> Option<ServiceStatus> {
|
||||
fn service_state() -> Option<ServiceState> {
|
||||
let manager =
|
||||
ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT).ok()?;
|
||||
let service = manager
|
||||
.open_service(SERVICE_NAME, ServiceAccess::QUERY_STATUS)
|
||||
.ok()?;
|
||||
service.query_status().ok()
|
||||
Some(service.query_status().ok()?.current_state)
|
||||
}
|
||||
|
||||
fn light_taskbar() -> bool {
|
||||
@@ -294,8 +167,7 @@ fn light_taskbar() -> bool {
|
||||
let Ok(key) = CURRENT_USER.open(personalize) else {
|
||||
return false;
|
||||
};
|
||||
key.get_u32("SystemUsesLightTheme")
|
||||
.is_ok_and(|light| light == 1)
|
||||
key.get_u32("SystemUsesLightTheme").is_ok_and(|light| light == 1)
|
||||
}
|
||||
|
||||
fn status_text(state: Option<ServiceState>) -> &'static str {
|
||||
|
||||
+26
-13
@@ -1,11 +1,11 @@
|
||||
use firewall::aliasing::managers::*;
|
||||
use firewall::aliasing::Invocation;
|
||||
use firewall::Firewall;
|
||||
use firewall::{Firewall, FirewallConfig};
|
||||
|
||||
#[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");
|
||||
let args = Invocation::new("cargo".into(), typed).arguments("http://127.0.0.1:9000", &FirewallConfig::default());
|
||||
assert_eq!(args.first().map(String::as_str), Some("run"));
|
||||
assert_eq!(args[args.len() - 2..], ["--", "--release"]);
|
||||
}
|
||||
@@ -13,12 +13,23 @@ 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 npm = variables("http://127.0.0.1:9000");
|
||||
let config = FirewallConfig::default();
|
||||
let npm = variables("http://127.0.0.1:9000", &config);
|
||||
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()]);
|
||||
let gem = arguments("http://127.0.0.1:9000", &config, &GEM, &["install".to_string()]);
|
||||
assert_eq!(gem, ["--clear-sources", "--source", "http://rubygems.org/"]);
|
||||
assert!(arguments("http://127.0.0.1:9000", &GEM, &["sources".to_string()]).is_empty());
|
||||
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")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -34,15 +45,17 @@ 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();
|
||||
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);
|
||||
}
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,10 +6,11 @@ use probe::Outcome;
|
||||
|
||||
fn main() -> std::io::Result<()> {
|
||||
firewall::init_logging("info");
|
||||
let port = aliasing::spawn(0, FirewallConfig::load()?)?;
|
||||
let config = FirewallConfig::load()?;
|
||||
let port = aliasing::spawn(0, config.clone())?;
|
||||
println!("firewall proxy on {}\n", firewall::local_url(port, ""));
|
||||
|
||||
let results = probe::all(port);
|
||||
let results = probe::all(port, &config);
|
||||
println!();
|
||||
let mut failed = 0;
|
||||
for (name, outcome) in &results {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
use firewall::aliasing::{self, Invocation, Manager, CARGO, GEM, GO, NPM, PIP, PIP3, UV};
|
||||
use firewall::FirewallConfig;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct Probe {
|
||||
@@ -29,14 +30,14 @@ static PROBES: &[Probe] = &[
|
||||
},
|
||||
];
|
||||
|
||||
pub fn all(port: u16) -> Vec<(&'static str, Outcome)> {
|
||||
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port))).collect()
|
||||
pub fn all(port: u16, config: &FirewallConfig) -> Vec<(&'static str, Outcome)> {
|
||||
PROBES.iter().map(|probe| (probe.manager.command, run(probe, port, config))).collect()
|
||||
}
|
||||
|
||||
fn run(probe: &Probe, port: u16) -> Outcome {
|
||||
fn run(probe: &Probe, port: u16, config: &FirewallConfig) -> 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, config, &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,
|
||||
|
||||
Reference in new issue
Block a user