Compare commits

..
Author SHA1 Message Date
Ian f9ed658b11 Start the tray when the install finishes
The MSI's exit dialog starts it from the unelevated UI process, so it runs
as the signed-in user.
2026-10-22 16:20:18 -04:00
Ian 219765e4ff Open the folder of dated service logs from the tray 2026-10-22 16:20:11 -04:00
Ian 8b0d734f02 Show screening status in the Windows notification area
firewall-tray.exe shows a tray icon: whether the service is screening,
paused or not running, with Pause/Resume and Open log. The MSI
starts it at every sign-in and closes it before replacing or removing it.

The icon is the Strix logo, white on a dark taskbar and black on a light
one like the macOS template image, and dimmed when not screening.
tray.png is assets/logo-transparent.png scaled and padded to 32x32:
sips -Z 32 logo-transparent.png --out a.png && sips -p 32 32 a.png --out tray.png
2026-10-22 16:20:08 -04:00
Ian 10fdd0f272 Install machine-wide screening on Windows with an MSI
The MSI installs both binaries, registers and starts the service with a
DACL that lets interactive users start, stop and pause it, restarts it on
failure, and sets NODE_EXTRA_CA_CERTS and UV_SYSTEM_CERTS. Removing it runs
`firewall-service.exe uninstall` once the service is deleted, which clears
the hosts entries and removes the CA, its TPM key and the data folder but
the config.
2026-10-22 15:23:40 -04:00
7 changed files with 21 additions and 107 deletions

No files matched your search

+5 -9
View File
@@ -89,16 +89,12 @@ async fn handle(req: Request, firewall: &Firewall) -> Result<Response, Infallibl
}
info!(method = %req.method(), %url, "forwarding");
// 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 index = firewall.is_index_url(&url);
let (mut parts, body) = req.into_parts();
if tls {
let Ok(uri) = url.parse() else {
return Ok(text(400, "firewall: expected an absolute URL\n"));
};
parts.uri = uri;
}
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 {
-5
View File
@@ -47,11 +47,6 @@ impl Firewall {
self.config.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 {
self.config.registries.iter().any(|r| r.index && r.host() == host_of(url))
}
+7 -22
View File
@@ -26,7 +26,6 @@ use std::net::{IpAddr, Ipv4Addr};
use std::sync::Arc;
use std::time::Duration;
use tracing_appender::rolling::{RollingFileAppender, Rotation};
use windows::Win32::Foundation::ERROR_GEN_FAILURE;
use windows_service::service::*;
use windows_service::service_control_handler::{
self, ServiceControlHandlerResult, ServiceStatusHandle,
@@ -74,12 +73,8 @@ fn run_until_stopped() -> Result<(), BoxError> {
| ServiceControl::Shutdown
| ServiceControl::Pause
| ServiceControl::Continue => {
// Answered once handled, so a failed pause fails the caller's ControlService.
let (reply, handled) = tokio::sync::oneshot::channel();
let _ = control_sender.send((control, reply));
handled
.blocking_recv()
.unwrap_or(ServiceControlHandlerResult::NoError)
let _ = control_sender.send(control);
ServiceControlHandlerResult::NoError
}
ServiceControl::Interrogate => ServiceControlHandlerResult::NoError,
_ => ServiceControlHandlerResult::NotImplemented,
@@ -94,26 +89,16 @@ fn run_until_stopped() -> Result<(), BoxError> {
}
};
// Only pausing removes our hosts entries; a stop or crash keeps them, failing closed.
while let Some((control, reply)) = control_receiver.recv().await {
while let Some(control) = control_receiver.recv().await {
let (hosts, state) = match control {
ServiceControl::Pause => (&[][..], ServiceState::Paused),
ServiceControl::Continue => (&hosts[..], ServiceState::Running),
_ => break,
};
let result = match write_hosts_entries(hosts) {
Ok(()) => {
set_status(&status, state, false)?;
ServiceControlHandlerResult::NoError
}
Err(error) => {
tracing::error!(%error, "could not rewrite the hosts file");
let code = error
.raw_os_error()
.map_or(ERROR_GEN_FAILURE.0, |code| code as u32);
ServiceControlHandlerResult::Other(code)
}
};
let _ = reply.send(result);
match write_hosts_entries(hosts) {
Ok(()) => set_status(&status, state, false)?,
Err(error) => tracing::error!(%error, "could not rewrite the hosts file"),
}
}
Ok::<_, BoxError>(())
});
+5 -37
View File
@@ -3,7 +3,6 @@
use super::*;
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::*;
@@ -15,7 +14,6 @@ use windows_service::service::{Service, ServiceAccess, ServiceState};
use windows_service::service_manager::{ServiceManager, ServiceManagerAccess};
const MENU_PAUSE: &str = "pause";
const MENU_RESTART: &str = "restart";
const MENU_LOG: &str = "log";
const MENU_QUIT: &str = "quit";
const LOGO: &[u8] = include_bytes!("tray.png");
@@ -33,7 +31,6 @@ pub fn run() -> Result<(), BoxError> {
&status,
&PredefinedMenuItem::separator(),
&pause,
&MenuItem::with_id(MENU_RESTART, "Restart", true, None),
&MenuItem::with_id(MENU_LOG, "Open logs", true, None),
&PredefinedMenuItem::separator(),
&MenuItem::with_id(MENU_QUIT, "Quit", true, None),
@@ -126,11 +123,6 @@ impl Tray {
|| Ok(open_service()?.resume().map(drop)?)
}
MENU_PAUSE => || Ok(open_service()?.start::<&str>(&[])?),
MENU_RESTART => {
// Off the UI thread, since stopping takes a while.
std::thread::spawn(|| restart().unwrap_or_else(show_error));
return;
}
MENU_LOG => {
let log = HSTRING::from(data_dir().as_path());
let none = PCWSTR::null();
@@ -139,19 +131,13 @@ impl Tray {
}
_ => return,
};
action().unwrap_or_else(show_error);
if let Err(error) = action() {
let text = HSTRING::from(error.to_string());
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
}
}
fn show_error(error: BoxError) {
let text = match error.source() {
Some(source) => format!("{error}: {source}"),
None => error.to_string(),
};
let text = HSTRING::from(text);
unsafe { MessageBoxW(None, &text, w!("Strix Firewall"), MB_ICONERROR) };
}
/// `None` when the service is not installed.
fn service_state() -> Option<ServiceState> {
let manager =
@@ -180,28 +166,10 @@ fn status_text(state: Option<ServiceState>) -> &'static str {
fn open_service() -> Result<Service, BoxError> {
let manager = ServiceManager::local_computer(None::<&str>, ServiceManagerAccess::CONNECT)?;
let access = ServiceAccess::QUERY_STATUS
| ServiceAccess::START
| ServiceAccess::STOP
| ServiceAccess::PAUSE_CONTINUE;
let access = ServiceAccess::START | ServiceAccess::PAUSE_CONTINUE;
Ok(manager.open_service(SERVICE_NAME, access)?)
}
/// Starting again re-reads the config.
fn restart() -> Result<(), BoxError> {
let service = open_service()?;
if service.query_status()?.current_state != ServiceState::Stopped {
service.stop()?;
}
for _ in 0..60 {
if service.query_status()?.current_state == ServiceState::Stopped {
return Ok(service.start::<&str>(&[])?);
}
std::thread::sleep(Duration::from_millis(500));
}
Err("The service did not stop.".into())
}
/// The alpha channel of the RGBA logo, and its width.
fn logo_mask() -> Result<(Vec<u8>, u32), BoxError> {
let mut reader = png::Decoder::new(Cursor::new(LOGO)).read_info()?;
+1 -22
View File
@@ -1,9 +1,7 @@
use firewall::aliasing::forward::downgrade;
use firewall::http::https_url;
use firewall::{aliasing, Firewall, FirewallConfig};
use firewall::Firewall;
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);
@@ -37,25 +35,6 @@ 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
View File
@@ -47,7 +47,6 @@ build() {
echo "==> Building"
xcodegen generate >/dev/null
xcodebuild -project Firewall.xcodeproj -scheme StrixFirewall -configuration $CONFIGURATION \
-destination generic/platform=macOS \
-derivedDataPath build CODE_SIGNING_ALLOWED=NO CODE_SIGNING_REQUIRED=NO \
CODE_SIGN_IDENTITY="" CODE_SIGN_ENTITLEMENTS="" build >/dev/null
}
+3 -11
View File
@@ -10,7 +10,7 @@ settings:
"CARGO_PROFILE[config=Debug]": debug
"LIBRARY_SEARCH_PATHS[arch=arm64]": $(SRCROOT)/../core/target/aarch64-apple-darwin/$(CARGO_PROFILE)
"LIBRARY_SEARCH_PATHS[arch=x86_64]": $(SRCROOT)/../core/target/x86_64-apple-darwin/$(CARGO_PROFILE)
ARCHS: arm64 x86_64
ARCHS: arm64
"ONLY_ACTIVE_ARCH[config=Debug]": "YES"
SWIFT_VERSION: "5.0"
DEVELOPMENT_TEAM: ${TEAM}
@@ -127,13 +127,5 @@ targets:
unset CC CXX CFLAGS CXXFLAGS
export SDKROOT="$(xcrun --sdk macosx --show-sdk-path)"
cd "$SRCROOT/../core"
bins=
for arch in $ARCHS; do
case $arch in
arm64|arm64e) triple=aarch64-apple-darwin ;;
x86_64) triple=x86_64-apple-darwin ;;
esac
cargo build --bin firewall-uninstall --target $triple $([ "$CONFIGURATION" = Debug ] || echo --release)
bins="$bins target/$triple/$CARGO_PROFILE/firewall-uninstall"
done
lipo -create $bins -output "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/firewall-uninstall"
cargo build --bin firewall-uninstall --target aarch64-apple-darwin $([ "$CONFIGURATION" = Debug ] || echo --release)
cp "target/aarch64-apple-darwin/$CARGO_PROFILE/firewall-uninstall" "$TARGET_BUILD_DIR/$EXECUTABLE_FOLDER_PATH/"