diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 6184432ed..021e82fde 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1955,6 +1955,7 @@ dependencies = [ "git-version", "humantime", "ipconfig", + "known-folders", "nix 0.28.0", "resolv-conf", "sd-notify", @@ -1964,7 +1965,9 @@ dependencies = [ "tokio", "tokio-util", "tracing", + "tracing-subscriber", "url", + "windows-service", ] [[package]] @@ -7535,6 +7538,17 @@ dependencies = [ "windows-targets 0.52.5", ] +[[package]] +name = "windows-service" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24d6bcc7f734a4091ecf8d7a64c5f7d7066f45585c1861eba06449909609c8a" +dependencies = [ + "bitflags 2.5.0", + "widestring", + "windows-sys 0.52.0", +] + [[package]] name = "windows-sys" version = "0.36.1" diff --git a/rust/gui-client/docs/intended_behavior.md b/rust/gui-client/docs/intended_behavior.md index 9884634a7..5ec0c4868 100644 --- a/rust/gui-client/docs/intended_behavior.md +++ b/rust/gui-client/docs/intended_behavior.md @@ -39,7 +39,7 @@ Best performed on a clean VM 1. Export the logs 1. Expect the zip file to start with "firezone_logs_" 1. Expect `zipinfo` to show a single directory in the root of the zip, to prevent zip bombing -1. Expect two subdirectories in the zip, "connlib", and "app", each with 3 files, totalling 6 files +1. Expect two subdirectories in the zip, "connlib", and "app", with 3 and 2 files respectively, totalling 5 files ## Settings tab diff --git a/rust/gui-client/src-tauri/tauri.conf.json b/rust/gui-client/src-tauri/tauri.conf.json index 7b8c997d6..41b0d711c 100644 --- a/rust/gui-client/src-tauri/tauri.conf.json +++ b/rust/gui-client/src-tauri/tauri.conf.json @@ -35,7 +35,14 @@ "icons/icon.png" ], "publisher": "Firezone", - "shortDescription": "Firezone" + "shortDescription": "Firezone", + "windows": { + "wix": { + "componentRefs": ["FirezoneClientIpcService"], + "fragmentPaths": ["./win_files/service.wxs"], + "template": "./win_files/main.wxs" + } + } }, "security": { "csp": null diff --git a/rust/gui-client/src-tauri/win_files/main.wxs b/rust/gui-client/src-tauri/win_files/main.wxs new file mode 100644 index 000000000..79b28789e --- /dev/null +++ b/rust/gui-client/src-tauri/win_files/main.wxs @@ -0,0 +1,315 @@ + + + + + + + + + + + + + + + + + + + + + {{#if allow_downgrades}} + + {{else}} + + {{/if}} + + + Installed AND NOT UPGRADINGPRODUCTCODE + + + + + {{#if banner_path}} + + {{/if}} + {{#if dialog_image_path}} + + {{/if}} + {{#if license}} + + {{/if}} + + + + + + + + + + + + + + + + + + + + WIXUI_EXITDIALOGOPTIONALCHECKBOX = 1 and NOT Installed + + + + {{#unless license}} + + 1 + 1 + {{/unless}} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{#each binaries as |bin| ~}} + + {{/each~}} + {{#if enable_elevated_update_task}} + + + + + + + + + + {{/if}} + {{resources}} + + + + + + + + + + + + + + + + + + + + + {{#each merge_modules as |msm| ~}} + + + + + + + + {{/each~}} + + + + + + {{#each resource_file_ids as |resource_file_id| ~}} + + {{/each~}} + + {{#if enable_elevated_update_task}} + + + + {{/if}} + + + + + + + + + + + {{#each binaries as |bin| ~}} + + {{/each~}} + + + + + {{#each component_group_refs as |id| ~}} + + {{/each~}} + {{#each component_refs as |id| ~}} + + {{/each~}} + {{#each feature_group_refs as |id| ~}} + + {{/each~}} + {{#each feature_refs as |id| ~}} + + {{/each~}} + {{#each merge_refs as |id| ~}} + + {{/each~}} + + + {{#if install_webview}} + + + + + + + {{#if download_bootstrapper}} + + + + + + + {{/if}} + + + {{#if webview2_bootstrapper_path}} + + + + + + + + {{/if}} + + + {{#if webview2_installer_path}} + + + + + + + + {{/if}} + + {{/if}} + + {{#if enable_elevated_update_task}} + + + + + NOT(REMOVE) + + + + + + + (REMOVE = "ALL") AND NOT UPGRADINGPRODUCTCODE + + + {{/if}} + + + + diff --git a/rust/gui-client/src-tauri/win_files/service.wxs b/rust/gui-client/src-tauri/win_files/service.wxs new file mode 100644 index 000000000..087412bdd --- /dev/null +++ b/rust/gui-client/src-tauri/win_files/service.wxs @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index 62a226b93..c3ca3a7c9 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -37,6 +37,9 @@ dirs = "5.0.1" [target.'cfg(target_os = "windows")'.dependencies] ipconfig = "0.3.2" +known-folders = "1.1.0" +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } +windows-service = "0.7.0" [lints] workspace = true diff --git a/rust/headless-client/src/imp_linux.rs b/rust/headless-client/src/imp_linux.rs index 37502e25c..cfbe2846a 100644 --- a/rust/headless-client/src/imp_linux.rs +++ b/rust/headless-client/src/imp_linux.rs @@ -63,8 +63,12 @@ pub fn default_token_path() -> PathBuf { .join("token") } +/// Only called from the GUI Client's build of the IPC service +/// +/// On Linux this is the same as running with `ipc-service` pub fn run_only_ipc_service() -> Result<()> { let cli = Cli::parse(); + // systemd supplies this but maybe we should hard-code a better default let (layer, _handle) = cli.log_dir.as_deref().map(file_logger::layer).unzip(); setup_global_subscriber(layer); tracing::info!(git_version = crate::GIT_VERSION); @@ -72,7 +76,9 @@ pub fn run_only_ipc_service() -> Result<()> { if !nix::unistd::getuid().is_root() { anyhow::bail!("This is the IPC service binary, it's not meant to run interactively."); } - run_ipc_service(cli) + let rt = tokio::runtime::Runtime::new()?; + let (_shutdown_tx, shutdown_rx) = mpsc::channel(1); + run_ipc_service(cli, rt, shutdown_rx) } pub(crate) fn check_token_permissions(path: &Path) -> Result<()> { @@ -178,9 +184,12 @@ pub fn sock_path() -> PathBuf { .join("ipc.sock") } -pub(crate) fn run_ipc_service(cli: Cli) -> Result<()> { - let rt = tokio::runtime::Runtime::new()?; - tracing::info!("run_daemon"); +pub(crate) fn run_ipc_service( + cli: Cli, + rt: tokio::runtime::Runtime, + _shutdown_rx: mpsc::Receiver<()>, +) -> Result<()> { + tracing::info!("run_ipc_service"); rt.block_on(async { ipc_listen(cli).await }) } diff --git a/rust/headless-client/src/imp_windows.rs b/rust/headless-client/src/imp_windows.rs index 10cf8cf39..55fdc78e3 100644 --- a/rust/headless-client/src/imp_windows.rs +++ b/rust/headless-client/src/imp_windows.rs @@ -1,13 +1,28 @@ use crate::Cli; -use anyhow::Result; +use anyhow::{Context as _, Result}; use clap::Parser; use connlib_client_shared::file_logger; -use firezone_cli_utils::setup_global_subscriber; use std::{ + ffi::OsString, net::IpAddr, path::{Path, PathBuf}, + str::FromStr, task::{Context, Poll}, + time::Duration, }; +use tokio::sync::mpsc; +use tracing::subscriber::set_global_default; +use tracing_subscriber::{layer::SubscriberExt as _, EnvFilter, Layer, Registry}; +use windows_service::{ + service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, + ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, +}; + +const SERVICE_NAME: &str = "firezone_client_ipc"; +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; pub(crate) struct Signals { sigint: tokio::signal::windows::CtrlC, @@ -39,18 +54,119 @@ pub(crate) fn default_token_path() -> std::path::PathBuf { PathBuf::from("token.txt") } +/// Only called from the GUI Client's build of the IPC service +/// +/// On Windows, this is wrapped specially so that Windows' service controller +/// can launch it. pub fn run_only_ipc_service() -> Result<()> { - let cli = Cli::parse(); - let (layer, _handle) = cli.log_dir.as_deref().map(file_logger::layer).unzip(); - setup_global_subscriber(layer); - tracing::info!(git_version = crate::GIT_VERSION); - - run_ipc_service(cli) + windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_run)?; + Ok(()) } -pub(crate) fn run_ipc_service(_cli: Cli) -> Result<()> { - // TODO: Process split on Windows - todo!() +// Generates `ffi_service_run` from `service_run` +windows_service::define_windows_service!(ffi_service_run, windows_service_run); + +fn windows_service_run(_arguments: Vec) { + if let Err(_e) = fallible_windows_service_run() { + todo!(); + } +} + +#[cfg(debug_assertions)] +const SERVICE_RUST_LOG: &str = "debug"; + +#[cfg(not(debug_assertions))] +const SERVICE_RUST_LOG: &str = "info"; + +// Most of the Windows-specific service stuff should go here +fn fallible_windows_service_run() -> Result<()> { + let cli = Cli::parse(); + let log_path = + crate::known_dirs::imp::ipc_service_logs().context("Can't compute IPC service logs dir")?; + std::fs::create_dir_all(&log_path)?; + let (layer, _handle) = file_logger::layer(&log_path); + let filter = EnvFilter::from_str(SERVICE_RUST_LOG)?; + let subscriber = Registry::default().with(layer.with_filter(filter)); + set_global_default(subscriber)?; + tracing::info!(git_version = crate::GIT_VERSION); + + let rt = tokio::runtime::Runtime::new()?; + let (shutdown_tx, shutdown_rx) = mpsc::channel(1); + + let event_handler = move |control_event| -> ServiceControlHandlerResult { + tracing::debug!(?control_event); + match control_event { + // TODO + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + ServiceControl::Stop => { + tracing::info!("Got stop signal from service controller"); + shutdown_tx.blocking_send(()).unwrap(); + ServiceControlHandlerResult::NoError + } + ServiceControl::UserEvent(_) => ServiceControlHandlerResult::NoError, + ServiceControl::Continue + | ServiceControl::NetBindAdd + | ServiceControl::NetBindDisable + | ServiceControl::NetBindEnable + | ServiceControl::NetBindRemove + | ServiceControl::ParamChange + | ServiceControl::Pause + | ServiceControl::Preshutdown + | ServiceControl::Shutdown + | ServiceControl::HardwareProfileChange(_) + | ServiceControl::PowerEvent(_) + | ServiceControl::SessionChange(_) + | ServiceControl::TimeChange + | ServiceControl::TriggerEvent => ServiceControlHandlerResult::NotImplemented, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + // Tell Windows that we're running (equivalent to sd_notify in systemd) + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + run_ipc_service(cli, rt, shutdown_rx)?; + + // Tell Windows that we're stopping + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + Ok(()) +} + +/// Common entry point for both the Windows-wrapped IPC service and the debug IPC service +/// +/// Running as a Windows service is complicated, so to make debugging easier +/// we'll have a dev-only mode that runs all the IPC code as a normal process +/// in an admin console. +pub(crate) fn run_ipc_service( + cli: Cli, + rt: tokio::runtime::Runtime, + shutdown_rx: mpsc::Receiver<()>, +) -> Result<()> { + tracing::info!("run_ipc_service"); + rt.block_on(async { ipc_listen(cli, shutdown_rx).await }) +} + +async fn ipc_listen(_cli: Cli, mut shutdown_rx: mpsc::Receiver<()>) -> Result<()> { + shutdown_rx.recv().await; + + Ok(()) } pub fn system_resolvers() -> Result> { diff --git a/rust/headless-client/src/known_dirs.rs b/rust/headless-client/src/known_dirs.rs index b1975e67c..17f96afe1 100644 --- a/rust/headless-client/src/known_dirs.rs +++ b/rust/headless-client/src/known_dirs.rs @@ -10,7 +10,7 @@ pub use imp::{logs, runtime, session, settings}; #[cfg(any(target_os = "linux", target_os = "macos"))] -mod imp { +pub mod imp { use connlib_shared::BUNDLE_ID; use std::path::PathBuf; @@ -47,9 +47,20 @@ mod imp { } #[cfg(target_os = "windows")] -mod imp { +pub mod imp { + use connlib_shared::BUNDLE_ID; + use known_folders::{get_known_folder_path, KnownFolder}; use std::path::PathBuf; + pub fn ipc_service_logs() -> Option { + Some( + get_known_folder_path(KnownFolder::ProgramData)? + .join(BUNDLE_ID) + .join("data") + .join("logs"), + ) + } + /// e.g. `C:\Users\Alice\AppData\Local\dev.firezone.client\data\logs` /// /// See connlib docs for details diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs index 9148b9e65..b57d77338 100644 --- a/rust/headless-client/src/lib.rs +++ b/rust/headless-client/src/lib.rs @@ -165,15 +165,20 @@ pub fn run() -> Result<()> { tracing::info!(git_version = crate::GIT_VERSION); + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let (_shutdown_tx, shutdown_rx) = mpsc::channel(1); + match cli.command() { Cmd::Auto => { if let Some(token) = get_token(token_env_var, &cli)? { - run_standalone(cli, &token) + run_standalone(cli, rt, &token) } else { - imp::run_ipc_service(cli) + imp::run_ipc_service(cli, rt, shutdown_rx) } } - Cmd::IpcService => imp::run_ipc_service(cli), + Cmd::IpcService => imp::run_ipc_service(cli, rt, shutdown_rx), Cmd::Standalone => { let token = get_token(token_env_var, &cli)?.with_context(|| { format!( @@ -181,7 +186,7 @@ pub fn run() -> Result<()> { cli.token_path ) })?; - run_standalone(cli, &token) + run_standalone(cli, rt, &token) } } } @@ -193,11 +198,8 @@ enum SignalKind { Interrupt, } -fn run_standalone(cli: Cli, token: &SecretString) -> Result<()> { +fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) -> Result<()> { tracing::info!("Running in standalone mode"); - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; let _guard = rt.enter(); // TODO: Should this default to 30 days? let max_partition_time = cli.max_partition_time.map(|d| d.into()); diff --git a/scripts/build/tauri-rename-windows.sh b/scripts/build/tauri-rename-windows.sh index e410ee751..1098c3bb2 100755 --- a/scripts/build/tauri-rename-windows.sh +++ b/scripts/build/tauri-rename-windows.sh @@ -17,3 +17,9 @@ function make_hash() { make_hash "$BINARY_DEST_PATH.exe" make_hash "$BINARY_DEST_PATH.msi" make_hash "$BINARY_DEST_PATH.pdb" + +# Test-install the MSI package, since it already exists here +msiexec //i "$BINARY_DEST_PATH.msi" //log install.log //qn +# For debugging +cat install.log +sc query FirezoneClientIpcService | grep RUNNING