diff --git a/rust/Cargo.lock b/rust/Cargo.lock index b7d0333ee..4274e9c75 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2193,6 +2193,7 @@ dependencies = [ "anyhow", "arboard", "atomicwrites", + "backoff", "chrono", "clap", "connlib-client-shared", @@ -2200,24 +2201,29 @@ dependencies = [ "derive_more 1.0.0", "dirs 5.0.1", "firezone-bin-shared", - "firezone-headless-client", "firezone-logging", "firezone-telemetry", "futures", "hex", + "humantime", + "ip-packet", "keyring", "native-dialog", "nix 0.29.0", "output_vt100", + "phoenix-channel", "png", "rand 0.8.5", "reqwest", "rustls", "sadness-generator", + "sd-notify", "secrecy", "semver", "serde", "serde_json", + "serde_variant", + "strum", "subtle", "tauri", "tauri-build", @@ -2228,6 +2234,7 @@ dependencies = [ "tauri-runtime", "tauri-utils", "tauri-winrt-notification", + "tempfile", "thiserror 1.0.69", "tokio", "tokio-stream", @@ -2239,6 +2246,7 @@ dependencies = [ "url", "uuid", "windows 0.61.1", + "windows-service", "winreg 0.52.0", "zip", ] @@ -2248,7 +2256,6 @@ name = "firezone-headless-client" version = "1.4.8" dependencies = [ "anyhow", - "atomicwrites", "backoff", "clap", "connlib-client-shared", @@ -2260,9 +2267,6 @@ dependencies = [ "futures", "humantime", "ip-packet", - "ip_network", - "ipconfig", - "itertools 0.13.0", "known-folders", "libc", "nix 0.29.0", @@ -2270,27 +2274,14 @@ dependencies = [ "opentelemetry-stdout", "opentelemetry_sdk", "phoenix-channel", - "resolv-conf", - "rtnetlink", "rustls", "sd-notify", "secrecy", - "serde", - "serde_json", - "serde_variant", - "strum", - "tempfile", - "thiserror 1.0.69", "tokio", "tokio-stream", - "tokio-util", "tracing", "tracing-subscriber", "url", - "uuid", - "windows 0.61.1", - "windows-service", - "winreg 0.52.0", ] [[package]] diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 252a397e7..b481564a4 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -50,6 +50,7 @@ backoff = { version = "0.4", features = ["tokio"] } base64 = { version = "0.22.1", default-features = false } bimap = "0.6" boringtun = { version = "0.6", default-features = false } +bufferpool = { path = "bufferpool" } bytecodec = "0.5.0" bytes = { version = "1.9.0", default-features = false } caps = "0.5.5" @@ -96,7 +97,6 @@ keyring = "3.6.2" known-folders = "1.2.0" l4-tcp-dns-server = { path = "connlib/l4-tcp-dns-server" } l4-udp-dns-server = { path = "connlib/l4-udp-dns-server" } -bufferpool = { path = "bufferpool" } libc = "0.2.172" lockfree-object-pool = "0.1.6" log = "0.4" @@ -131,6 +131,7 @@ ringbuffer = "0.15.0" rtnetlink = { version = "0.14.1", default-features = false, features = ["tokio_socket"] } rustls = { version = "0.23.21", default-features = false, features = ["ring"] } sadness-generator = "0.6.0" +sd-notify = "0.4.5" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 secrecy = "0.8" semver = "1.0.26" sentry = { version = "0.36.0", default-features = false } @@ -188,6 +189,7 @@ which = "4.4.2" windows = "0.61.0" windows-core = "0.61.0" windows-implement = "0.60.0" +windows-service = "0.8.0" winreg = "0.52.0" zbus = "5.5.0" zip = { version = "2", default-features = false } diff --git a/rust/bin-shared/Cargo.toml b/rust/bin-shared/Cargo.toml index 6fe40e898..0ae24390e 100644 --- a/rust/bin-shared/Cargo.toml +++ b/rust/bin-shared/Cargo.toml @@ -68,11 +68,12 @@ features = [ "Win32_NetworkManagement_Ndis", "Win32_Networking_WinSock", "Win32_Security", - # COM is needed to listen for network change events "Win32_System_Com", # Needed to listen for system DNS changes "Win32_System_Registry", "Win32_System_Threading", + "Win32_System_SystemInformation", # For uptime + "Win32_System_GroupPolicy", # For NRPT when GPO is used ] [target.'cfg(windows)'.dev-dependencies] diff --git a/rust/connlib/clients/shared/src/callbacks.rs b/rust/connlib/clients/shared/src/callbacks.rs index e9501f0d6..dd667683e 100644 --- a/rust/connlib/clients/shared/src/callbacks.rs +++ b/rust/connlib/clients/shared/src/callbacks.rs @@ -5,6 +5,7 @@ use std::{ net::{IpAddr, Ipv4Addr, Ipv6Addr}, sync::Arc, }; +use tokio::sync::mpsc; /// Traits that will be used by connlib to callback the client upper layers. pub trait Callbacks: Clone + Send + Sync { @@ -121,6 +122,80 @@ where } } +/// Messages that connlib can produce and send to the headless Client, IPC service, or GUI process. +/// +/// i.e. callbacks +// The names are CamelCase versions of the connlib callbacks. +#[expect(clippy::enum_variant_names)] +pub enum ConnlibMsg { + OnDisconnect { + error_msg: String, + is_authentication_error: bool, + }, + /// Use this as `TunnelReady`, per `callbacks.rs` + OnSetInterfaceConfig { + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + search_domain: Option, + ipv4_routes: Vec, + ipv6_routes: Vec, + }, + OnUpdateResources(Vec), +} + +#[derive(Clone)] +pub struct ChannelCallbackHandler { + cb_tx: mpsc::Sender, +} + +impl ChannelCallbackHandler { + pub fn new() -> (Self, mpsc::Receiver) { + let (cb_tx, cb_rx) = mpsc::channel(1_000); + + (Self { cb_tx }, cb_rx) + } +} + +impl Callbacks for ChannelCallbackHandler { + fn on_disconnect(&self, error: DisconnectError) { + self.cb_tx + .try_send(ConnlibMsg::OnDisconnect { + error_msg: error.to_string(), + is_authentication_error: error.is_authentication_error(), + }) + .expect("should be able to send OnDisconnect"); + } + + fn on_set_interface_config( + &self, + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + search_domain: Option, + ipv4_routes: Vec, + ipv6_routes: Vec, + ) { + self.cb_tx + .try_send(ConnlibMsg::OnSetInterfaceConfig { + ipv4, + ipv6, + dns, + search_domain, + ipv4_routes, + ipv6_routes, + }) + .expect("Should be able to send OnSetInterfaceConfig"); + } + + fn on_update_resources(&self, resources: Vec) { + tracing::debug!(len = resources.len(), "New resource list"); + self.cb_tx + .try_send(ConnlibMsg::OnUpdateResources(resources)) + .expect("Should be able to send OnUpdateResources"); + } +} + #[cfg(test)] mod tests { use phoenix_channel::StatusCode; @@ -135,4 +210,10 @@ mod tests { assert!(disconnect_error.to_string().contains("401 Unauthorized")); // Apple client relies on this. } + + // Make sure it's okay to store a bunch of these to mitigate #5880 + #[test] + fn callback_msg_size() { + assert_eq!(std::mem::size_of::(), 120) + } } diff --git a/rust/connlib/clients/shared/src/lib.rs b/rust/connlib/clients/shared/src/lib.rs index ba276e81d..2c9b5915f 100644 --- a/rust/connlib/clients/shared/src/lib.rs +++ b/rust/connlib/clients/shared/src/lib.rs @@ -1,7 +1,7 @@ //! Main connlib library for clients. pub use crate::serde_routelist::{V4RouteList, V6RouteList}; use callbacks::BackgroundCallbacks; -pub use callbacks::{Callbacks, DisconnectError}; +pub use callbacks::{Callbacks, ChannelCallbackHandler, ConnlibMsg, DisconnectError}; pub use connlib_model::StaticSecret; pub use eventloop::Eventloop; pub use firezone_tunnel::messages::client::{IngressMessages, ResourceDescription}; diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index c6da0020a..f1e368b5e 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -16,20 +16,23 @@ tauri-build = { workspace = true, features = [] } anyhow = { workspace = true } arboard = { workspace = true } atomicwrites = { workspace = true } +backoff = { workspace = true } chrono = { workspace = true } clap = { workspace = true, features = ["derive", "env"] } connlib-client-shared = { workspace = true } connlib-model = { workspace = true } derive_more = { workspace = true, features = ["debug"] } firezone-bin-shared = { workspace = true } -firezone-headless-client = { workspace = true } firezone-logging = { workspace = true } firezone-telemetry = { workspace = true } futures = { workspace = true } hex = { workspace = true } +humantime = { workspace = true } +ip-packet = { workspace = true } keyring = { workspace = true, features = ["crypto-rust", "sync-secret-service", "windows-native"] } native-dialog = { workspace = true } output_vt100 = { workspace = true } +phoenix-channel = { workspace = true } png = { workspace = true } # `png` is mostly free since we already need it for Tauri rand = { workspace = true } reqwest = { workspace = true, features = ["stream", "rustls-tls"] } @@ -39,6 +42,8 @@ secrecy = { workspace = true } semver = { workspace = true, features = ["serde"] } serde = { workspace = true, features = ["derive"] } serde_json = { workspace = true } +serde_variant = { workspace = true } +strum = { workspace = true } subtle = { workspace = true } tauri = { workspace = true, features = ["tray-icon", "image-png"] } tauri-plugin-dialog = { workspace = true } @@ -62,20 +67,26 @@ zip = { workspace = true, features = ["deflate", "time"] } dirs = { workspace = true } nix = { workspace = true, features = ["user"] } tracing-journald = { workspace = true } +sd-notify = { workspace = true } [target.'cfg(target_os = "macos")'.dependencies] [target.'cfg(target_os = "windows")'.dependencies] tauri-winrt-notification = "0.7.2" winreg = { workspace = true } +windows-service = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies.windows] workspace = true features = [ "Win32_Foundation", "Win32_System_Threading", + "Win32_System_Pipes", # For IPC system ] +[dev-dependencies] +tempfile = { workspace = true } + [features] # this feature is used for production builds or when `devPath` points to the filesystem # DO NOT REMOVE!! diff --git a/rust/gui-client/src-tauri/src/bin/firezone-client-ipc.rs b/rust/gui-client/src-tauri/src/bin/firezone-client-ipc.rs index 97b797359..76c1c8e37 100644 --- a/rust/gui-client/src-tauri/src/bin/firezone-client-ipc.rs +++ b/rust/gui-client/src-tauri/src/bin/firezone-client-ipc.rs @@ -1,7 +1,97 @@ +#![cfg_attr(test, allow(clippy::unwrap_used))] + +use clap::Parser as _; +use firezone_bin_shared::{DnsControlMethod, TOKEN_ENV_KEY}; +use firezone_gui_client::service; +use std::path::PathBuf; + fn main() -> anyhow::Result<()> { rustls::crypto::ring::default_provider() .install_default() .expect("Calling `install_default` only once per process should always succeed"); - firezone_headless_client::run_only_ipc_service() + // Docs indicate that `remove_var` should actually be marked unsafe + // SAFETY: We haven't spawned any other threads, this code should be the first + // thing to run after entering `main` and parsing CLI args. + // So nobody else is reading the environment. + unsafe { + // This removes the token from the environment per . We run as root so it may not do anything besides defense-in-depth. + std::env::remove_var(TOKEN_ENV_KEY); + } + assert!(std::env::var(TOKEN_ENV_KEY).is_err()); + + let cli = Cli::try_parse()?; + + match cli.command { + Cmd::Install => service::install(), + Cmd::Run => service::run(cli.log_dir, cli.dns_control), + Cmd::RunDebug => service::run_debug(cli.dns_control), + Cmd::RunSmokeTest => service::run_smoke_test(), + } +} + +#[derive(clap::Parser)] +#[command(author, version, about, long_about = None)] +pub struct Cli { + #[command(subcommand)] + command: Cmd, + + #[cfg(target_os = "linux")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "systemd-resolved")] + dns_control: DnsControlMethod, + + #[cfg(target_os = "windows")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "nrpt")] + dns_control: DnsControlMethod, + + #[cfg(target_os = "macos")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "none")] + dns_control: DnsControlMethod, + + /// File logging directory. Should be a path that's writeable by the current user. + #[arg(short, long, env = "LOG_DIR")] + log_dir: Option, + + /// Maximum length of time to retry connecting to the portal if we're having internet issues or + /// it's down. Accepts human times. e.g. "5m" or "1h" or "30d". + #[arg(short, long, env = "MAX_PARTITION_TIME")] + max_partition_time: Option, +} + +#[derive(clap::Subcommand)] +enum Cmd { + /// Needed to test the IPC service on aarch64 Windows, + /// where the Tauri MSI bundler doesn't work yet + Install, + Run, + RunDebug, + RunSmokeTest, +} + +impl Default for Cmd { + fn default() -> Self { + Self::Run + } +} + +#[cfg(test)] +mod tests { + use super::{Cli, Cmd}; + use clap::Parser; + use std::path::PathBuf; + + const EXE_NAME: &str = "firezone-client-ipc"; + + // Can't remember how Clap works sometimes + // Also these are examples + #[test] + fn cli() { + let actual = + Cli::try_parse_from([EXE_NAME, "--log-dir", "bogus_log_dir", "run-debug"]).unwrap(); + assert!(matches!(actual.command, Cmd::RunDebug)); + assert_eq!(actual.log_dir, Some(PathBuf::from("bogus_log_dir"))); + + let actual = Cli::try_parse_from([EXE_NAME, "run"]).unwrap(); + assert!(matches!(actual.command, Cmd::Run)); + } } diff --git a/rust/gui-client/src-tauri/src/main.rs b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs similarity index 82% rename from rust/gui-client/src-tauri/src/main.rs rename to rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs index 832f3727a..61052b560 100644 --- a/rust/gui-client/src-tauri/src/main.rs +++ b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs @@ -7,30 +7,12 @@ use anyhow::{Context as _, Result, bail}; use clap::{Args, Parser}; use controller::Failure; +use firezone_gui_client::{controller, deep_link, elevation, gui, logging, settings}; use firezone_telemetry::Telemetry; +use gui::RunConfig; use settings::AdvancedSettings; use tracing_subscriber::EnvFilter; -mod about; -mod auth; -mod controller; -mod debug_commands; -mod deep_link; -mod elevation; -mod gui; -mod ipc; -mod logging; -mod settings; -mod updates; -mod uptime; -mod welcome; - -/// The Sentry "release" we are part of. -/// -/// IPC service and GUI client are always bundled into a single release. -/// Hence, we have a single constant for IPC service and GUI client. -const RELEASE: &str = concat!("gui-client@", env!("CARGO_PKG_VERSION")); - fn main() -> anyhow::Result<()> { // Mitigates a bug in Ubuntu 22.04 - Under Wayland, some features of the window decorations like minimizing, closing the windows, etc., doesn't work unless you double-click the titlebar first. // SAFETY: No other thread is running yet @@ -45,14 +27,26 @@ fn main() -> anyhow::Result<()> { .install_default() .expect("Calling `install_default` only once per process should always succeed"); + let config = gui::RunConfig { + inject_faults: cli.inject_faults, + debug_update_check: cli.debug_update_check, + smoke_test: cli + .command + .as_ref() + .is_some_and(|c| matches!(c, Cmd::SmokeTest)), + no_deep_links: cli.no_deep_links, + quit_after: cli.quit_after, + fail_with: cli.fail_on_purpose(), + }; + match cli.command { None => { if cli.no_deep_links { - return run_gui(cli); + return run_gui(config); } match elevation::gui_check() { // Our elevation is correct (not elevated), just run the GUI - Ok(true) => run_gui(cli), + Ok(true) => run_gui(config), Ok(false) => bail!("The GUI should run as a normal user, not elevated"), #[cfg(target_os = "linux")] // Windows/MacOS elevation check never fails. Err(error) => { @@ -61,9 +55,20 @@ fn main() -> anyhow::Result<()> { } } } - Some(Cmd::Debug { command }) => debug_commands::run(command), + Some(Cmd::Debug { + command: DebugCommand::Replicate6791, + }) => firezone_gui_client::auth::replicate_6791(), + Some(Cmd::Debug { + command: DebugCommand::SetAutostart(SetAutostartArgs { enabled }), + }) => { + firezone_gui_client::logging::setup_stdout()?; + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(firezone_gui_client::gui::set_autostart(enabled))?; + + Ok(()) + } // If we already tried to elevate ourselves, don't try again - Some(Cmd::Elevated) => run_gui(cli), + Some(Cmd::Elevated) => run_gui(config), Some(Cmd::OpenDeepLink(deep_link)) => { let rt = tokio::runtime::Runtime::new()?; if let Err(error) = rt.block_on(deep_link::open(&deep_link.url)) { @@ -77,15 +82,15 @@ fn main() -> anyhow::Result<()> { let mut telemetry = Telemetry::default(); telemetry.start( settings.api_url.as_ref(), - crate::RELEASE, + firezone_gui_client::RELEASE, firezone_telemetry::GUI_DSN, ); // Don't fix the log filter for smoke tests let logging::Handles { logger: _logger, reloader, - } = start_logging(&settings.log_filter)?; - let result = gui::run(cli, settings, reloader, telemetry); + } = firezone_gui_client::logging::setup_gui(&settings.log_filter)?; + let result = gui::run(config, settings, reloader, telemetry); if let Err(error) = &result { // In smoke-test mode, don't show the dialog, since it might be running // unattended in CI and the dialog would hang forever @@ -103,13 +108,13 @@ fn main() -> anyhow::Result<()> { /// /// Automatically logs or shows error dialogs for important user-actionable errors // Can't `instrument` this because logging isn't running when we enter it. -fn run_gui(cli: Cli) -> Result<()> { +fn run_gui(config: RunConfig) -> Result<()> { let mut settings = settings::load_advanced_settings().unwrap_or_default(); let mut telemetry = Telemetry::default(); // In the future telemetry will be opt-in per organization, that's why this isn't just at the top of `main` telemetry.start( settings.api_url.as_ref(), - crate::RELEASE, + firezone_gui_client::RELEASE, firezone_telemetry::GUI_DSN, ); // Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope. @@ -121,9 +126,9 @@ fn run_gui(cli: Cli) -> Result<()> { let logging::Handles { logger: _logger, reloader, - } = start_logging(&settings.log_filter)?; + } = firezone_gui_client::logging::setup_gui(&settings.log_filter)?; - match gui::run(cli, settings, reloader, telemetry) { + match gui::run(config, settings, reloader, telemetry) { Ok(()) => Ok(()), Err(anyhow) => { if anyhow @@ -146,7 +151,7 @@ fn run_gui(cli: Cli) -> Result<()> { if anyhow .root_cause() - .is::() + .is::() { show_error_dialog("Couldn't find Firezone IPC service. Is the service running?")?; return Err(anyhow); @@ -194,24 +199,6 @@ fn show_error_dialog(msg: &str) -> Result<()> { Ok(()) } -/// Starts logging -/// -/// Don't drop the log handle or logging will stop. -fn start_logging(directives: &str) -> Result { - let logging_handles = logging::setup(directives)?; - let system_uptime_seconds = firezone_bin_shared::uptime::get().map(|dur| dur.as_secs()); - tracing::info!( - arch = std::env::consts::ARCH, - os = std::env::consts::OS, - version = env!("CARGO_PKG_VERSION"), - ?directives, - ?system_uptime_seconds, - "`gui-client` started logging" - ); - - Ok(logging_handles) -} - /// The debug / test flags like `crash_on_purpose` and `test_update_notification` /// don't propagate when we use `RunAs` to elevate ourselves. So those must be run /// from an admin terminal, or with "Run as administrator" in the right-click menu. @@ -268,7 +255,7 @@ impl Cli { enum Cmd { Debug { #[command(subcommand)] - command: debug_commands::Cmd, + command: DebugCommand, }, Elevated, OpenDeepLink(DeepLink), @@ -276,6 +263,28 @@ enum Cmd { SmokeTest, } +#[derive(clap::Subcommand)] +enum DebugCommand { + Replicate6791, + SetAutostart(SetAutostartArgs), +} + +#[derive(clap::Parser)] +struct SetAutostartArgs { + #[clap(action=clap::ArgAction::Set)] + enabled: bool, +} + +#[derive(clap::Parser)] +struct CheckTokenArgs { + token: String, +} + +#[derive(clap::Parser)] +struct StoreTokenArgs { + token: String, +} + #[derive(Args)] pub struct DeepLink { // TODO: Should be `Secret`? diff --git a/rust/headless-client/src/clear_logs.rs b/rust/gui-client/src-tauri/src/clear_logs.rs similarity index 100% rename from rust/headless-client/src/clear_logs.rs rename to rust/gui-client/src-tauri/src/clear_logs.rs diff --git a/rust/gui-client/src-tauri/src/controller.rs b/rust/gui-client/src-tauri/src/controller.rs index f456c00ca..b06499001 100644 --- a/rust/gui-client/src-tauri/src/controller.rs +++ b/rust/gui-client/src-tauri/src/controller.rs @@ -8,11 +8,6 @@ use crate::{ use anyhow::{Context, Result, anyhow}; use connlib_model::ResourceView; use firezone_bin_shared::DnsControlMethod; -use firezone_headless_client::{ - IpcClientMsg::{self, SetDisabledResources}, - IpcServerMsg, IpcServiceError, -}; - use firezone_logging::FilterReloadHandle; use firezone_telemetry::Telemetry; use futures::{ @@ -83,10 +78,6 @@ pub enum ControllerRequest { SchemeRequest(SecretString), SignIn, SystemTrayMenu(system_tray::Event), - #[cfg_attr( - any(target_os = "linux", target_os = "macos"), - expect(dead_code, reason = "Doesn't work in Linux yet and is unused on MacOS") - )] UpdateNotificationClicked(Url), } @@ -176,7 +167,7 @@ impl Status { enum EventloopTick { NetworkChanged(Result<()>), DnsChanged(Result<()>), - IpcMsg(Option>), + IpcMsg(Option>), ControllerRequest(Option), UpdateNotification(Option>), } @@ -365,7 +356,7 @@ impl Controller { } self.ipc_client - .send_msg(&IpcClientMsg::StartTelemetry { + .send_msg(&ipc::ClientMsg::StartTelemetry { environment, release: crate::RELEASE.to_string(), account_slug, @@ -403,7 +394,7 @@ impl Controller { self.advanced_settings = *settings; self.ipc_client - .send_msg(&IpcClientMsg::ApplyLogFilter { + .send_msg(&ipc::ClientMsg::ApplyLogFilter { directives: self.advanced_settings.log_filter.clone(), }) .await?; @@ -422,7 +413,7 @@ impl Controller { if let Err(error) = logging::clear_gui_logs().await { tracing::error!("Failed to clear GUI logs: {error:#}"); } - self.ipc_client.send_msg(&IpcClientMsg::ClearLogs).await?; + self.ipc_client.send_msg(&ipc::ClientMsg::ClearLogs).await?; self.clear_logs_callback = Some(completion_tx); } Req::ExportLogs { path, stem } => logging::export_logs_to(path, stem) @@ -537,7 +528,9 @@ impl Controller { Req::SystemTrayMenu(system_tray::Event::Quit) => { tracing::info!("User clicked Quit in the menu"); self.status = Status::Quitting; - self.ipc_client.send_msg(&IpcClientMsg::Disconnect).await?; + self.ipc_client + .send_msg(&ipc::ClientMsg::Disconnect) + .await?; self.refresh_system_tray_menu(); } Req::UpdateNotificationClicked(download_url) => { @@ -550,9 +543,9 @@ impl Controller { Ok(()) } - async fn handle_ipc_msg(&mut self, msg: IpcServerMsg) -> Result> { + async fn handle_ipc_msg(&mut self, msg: ipc::ServerMsg) -> Result> { match msg { - IpcServerMsg::ClearedLogs(result) => { + ipc::ServerMsg::ClearedLogs(result) => { let Some(tx) = self.clear_logs_callback.take() else { return Err(anyhow!( "Can't handle `IpcClearedLogs` when there's no callback waiting for a `ClearLogs` result" @@ -561,15 +554,15 @@ impl Controller { tx.send(result) .map_err(|_| anyhow!("Couldn't send `ClearLogs` result to Tauri task"))?; } - IpcServerMsg::ConnectResult(result) => { + ipc::ServerMsg::ConnectResult(result) => { self.handle_connect_result(result).await?; } - IpcServerMsg::DisconnectedGracefully => { + ipc::ServerMsg::DisconnectedGracefully => { if let Status::Quitting = self.status { return Ok(ControlFlow::Break(())); } } - IpcServerMsg::OnDisconnect { + ipc::ServerMsg::OnDisconnect { error_msg, is_authentication_error, } => { @@ -590,7 +583,7 @@ impl Controller { .context("Couldn't show Disconnected alert")?; } } - IpcServerMsg::OnUpdateResources(resources) => { + ipc::ServerMsg::OnUpdateResources(resources) => { if !self.status.needs_resource_updates() { return Ok(ControlFlow::Continue(())); } @@ -600,7 +593,7 @@ impl Controller { self.update_disabled_resources().await?; } - IpcServerMsg::TerminatingGracefully => { + ipc::ServerMsg::TerminatingGracefully => { tracing::info!("IPC service exited gracefully"); self.integration .set_tray_icon(system_tray::icon_terminating()); @@ -611,7 +604,7 @@ impl Controller { return Ok(ControlFlow::Break(())); } - IpcServerMsg::TunnelReady => { + ipc::ServerMsg::TunnelReady => { let Status::WaitingForTunnel { start_instant } = self.status else { // If we are not waiting for a tunnel, continue. return Ok(ControlFlow::Continue(())); @@ -629,7 +622,7 @@ impl Controller { Ok(ControlFlow::Continue(())) } - async fn handle_connect_result(&mut self, result: Result<(), IpcServiceError>) -> Result<()> { + async fn handle_connect_result(&mut self, result: Result<(), ipc::Error>) -> Result<()> { let Status::WaitingForPortal { start_instant, token, @@ -649,7 +642,7 @@ impl Controller { self.refresh_system_tray_menu(); Ok(()) } - Err(IpcServiceError::Io(error)) => { + Err(ipc::Error::Io(error)) => { // This is typically something like, we don't have Internet access so we can't // open the PhoenixChannel's WebSocket. tracing::info!( @@ -662,7 +655,7 @@ impl Controller { self.refresh_system_tray_menu(); Ok(()) } - Err(IpcServiceError::Other(error)) => { + Err(ipc::Error::Other(error)) => { // We log this here directly instead of forwarding it because errors hard-abort the event-loop and we still want to be able to export logs and stuff. // See . tracing::error!("Failed to connect to Firezone: {error}"); @@ -717,7 +710,7 @@ impl Controller { } self.ipc_client - .send_msg(&SetDisabledResources(disabled_resources)) + .send_msg(&ipc::ClientMsg::SetDisabledResources(disabled_resources)) .await?; self.refresh_system_tray_menu(); @@ -798,7 +791,9 @@ impl Controller { tracing::debug!("disconnecting connlib"); // This is redundant if the token is expired, in that case // connlib already disconnected itself. - self.ipc_client.send_msg(&IpcClientMsg::Disconnect).await?; + self.ipc_client + .send_msg(&ipc::ClientMsg::Disconnect) + .await?; self.refresh_system_tray_menu(); Ok(()) } diff --git a/rust/gui-client/src-tauri/src/debug_commands.rs b/rust/gui-client/src-tauri/src/debug_commands.rs deleted file mode 100644 index 2fa8e1d71..000000000 --- a/rust/gui-client/src-tauri/src/debug_commands.rs +++ /dev/null @@ -1,40 +0,0 @@ -//! CLI subcommands used to test features / dependencies before integrating -//! them with the GUI, or to exercise features programmatically. - -use anyhow::Result; - -#[derive(clap::Subcommand)] -pub(crate) enum Cmd { - Replicate6791, - SetAutostart(SetAutostartArgs), -} - -#[derive(clap::Parser)] -pub(crate) struct SetAutostartArgs { - #[clap(action=clap::ArgAction::Set)] - enabled: bool, -} - -#[derive(clap::Parser)] -pub(crate) struct CheckTokenArgs { - token: String, -} - -#[derive(clap::Parser)] -pub(crate) struct StoreTokenArgs { - token: String, -} - -pub fn run(cmd: Cmd) -> Result<()> { - match cmd { - Cmd::Replicate6791 => crate::auth::replicate_6791(), - Cmd::SetAutostart(SetAutostartArgs { enabled }) => set_autostart(enabled), - } -} - -fn set_autostart(enabled: bool) -> Result<()> { - firezone_headless_client::setup_stdout_logging()?; - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(crate::gui::set_autostart(enabled))?; - Ok(()) -} diff --git a/rust/gui-client/src-tauri/src/deep_link/linux.rs b/rust/gui-client/src-tauri/src/deep_link/linux.rs index 940dfaf4f..67bdb1e3c 100644 --- a/rust/gui-client/src-tauri/src/deep_link/linux.rs +++ b/rust/gui-client/src-tauri/src/deep_link/linux.rs @@ -85,7 +85,7 @@ impl Server { } pub async fn open(url: &url::Url) -> Result<()> { - firezone_headless_client::setup_stdout_logging()?; + crate::logging::setup_stdout()?; let path = sock_path()?; let mut stream = UnixStream::connect(&path).await?; diff --git a/rust/gui-client/src-tauri/src/deep_link/windows.rs b/rust/gui-client/src-tauri/src/deep_link/windows.rs index 545f7f2bb..207e1ee3b 100644 --- a/rust/gui-client/src-tauri/src/deep_link/windows.rs +++ b/rust/gui-client/src-tauri/src/deep_link/windows.rs @@ -104,7 +104,7 @@ pub async fn open(url: &url::Url) -> Result<()> { } fn pipe_path() -> String { - firezone_headless_client::ipc::platform::named_pipe_path(&format!("{BUNDLE_ID}.deep_link")) + crate::ipc::platform::named_pipe_path(&format!("{BUNDLE_ID}.deep_link")) } /// Registers the current exe as the handler for our deep link scheme. diff --git a/rust/gui-client/src-tauri/src/elevation.rs b/rust/gui-client/src-tauri/src/elevation.rs index c67b96c32..27872c2e5 100644 --- a/rust/gui-client/src-tauri/src/elevation.rs +++ b/rust/gui-client/src-tauri/src/elevation.rs @@ -1,16 +1,17 @@ -pub(crate) use platform::gui_check; +pub use platform::gui_check; #[cfg(target_os = "linux")] mod platform { use anyhow::{Context as _, Result}; - use firezone_headless_client::FIREZONE_GROUP; + + const FIREZONE_GROUP: &str = "firezone-client"; /// Returns true if all permissions are correct for the GUI to run /// /// Everything that needs root / admin powers happens in the IPC services, /// so for security and practicality reasons the GUIs must be non-root. /// (In Linux by default a root GUI app barely works at all) - pub(crate) fn gui_check() -> Result { + pub fn gui_check() -> Result { let user = std::env::var("USER").context("Unable to determine current user")?; if user == "root" { return Ok(false); @@ -33,7 +34,7 @@ mod platform { } #[derive(Debug, thiserror::Error)] - pub(crate) enum Error { + pub enum Error { #[error("User is not part of {FIREZONE_GROUP} group")] UserNotInFirezoneGroup, #[error(transparent)] @@ -41,7 +42,7 @@ mod platform { } impl Error { - pub(crate) fn user_friendly_msg(&self) -> String { + pub fn user_friendly_msg(&self) -> String { match self { Error::UserNotInFirezoneGroup => format!( "You are not a member of the group `{FIREZONE_GROUP}`. Try `sudo usermod -aG {FIREZONE_GROUP} $USER` and then reboot" @@ -61,12 +62,12 @@ mod platform { /// On Windows, some users will run as admin, and the GUI does work correctly, /// unlike on Linux where most distros don't like to mix root GUI apps with X11 / Wayland. #[expect(clippy::unnecessary_wraps)] - pub(crate) fn gui_check() -> Result { + pub fn gui_check() -> Result { Ok(true) } #[derive(Debug, Clone, Copy, thiserror::Error)] - pub(crate) enum Error {} + pub enum Error {} } #[cfg(target_os = "macos")] @@ -74,12 +75,12 @@ mod platform { use anyhow::Result; #[expect(clippy::unnecessary_wraps)] - pub(crate) fn gui_check() -> Result { + pub fn gui_check() -> Result { Ok(true) } #[derive(Debug, Clone, Copy, thiserror::Error)] - pub(crate) enum Error {} + pub enum Error {} } #[cfg(test)] diff --git a/rust/gui-client/src-tauri/src/gui.rs b/rust/gui-client/src-tauri/src/gui.rs index 65731c0bb..554c975e3 100644 --- a/rust/gui-client/src-tauri/src/gui.rs +++ b/rust/gui-client/src-tauri/src/gui.rs @@ -4,8 +4,8 @@ //! The real macOS Client is in `swift/apple` use crate::{ - Cli, Cmd, about, - controller::{Controller, ControllerRequest, CtlrTx, GuiIntegration}, + about, + controller::{Controller, ControllerRequest, CtlrTx, Failure, GuiIntegration}, deep_link, logging, settings::{self, AdvancedSettings}, updates, @@ -33,7 +33,7 @@ mod os; #[path = "gui/os_windows.rs"] mod os; -pub(crate) use os::set_autostart; +pub use os::set_autostart; /// All managed state that we might need to access from odd places like Tauri commands. /// @@ -114,10 +114,19 @@ impl GuiIntegration for TauriIntegration { } } +pub struct RunConfig { + pub inject_faults: bool, + pub debug_update_check: bool, + pub smoke_test: bool, + pub no_deep_links: bool, + pub quit_after: Option, + pub fail_with: Option, +} + /// Runs the Tauri GUI and returns on exit or unrecoverable error #[instrument(skip_all)] -pub(crate) fn run( - cli: Cli, +pub fn run( + config: RunConfig, advanced_settings: AdvancedSettings, reloader: firezone_logging::FilterReloadHandle, mut telemetry: telemetry::Telemetry, @@ -138,7 +147,7 @@ pub(crate) fn run( let managed = Managed { ctlr_tx: ctlr_tx.clone(), - inject_faults: cli.inject_faults, + inject_faults: config.inject_faults, }; let (handle_tx, handle_rx) = tokio::sync::oneshot::channel(); @@ -175,13 +184,13 @@ pub(crate) fn run( .setup(move |app| { // Check for updates tokio::spawn(async move { - if let Err(error) = updates::checker_task(updates_tx, cli.debug_update_check).await + if let Err(error) = updates::checker_task(updates_tx, config.debug_update_check).await { tracing::error!("Error in updates::checker_task: {error:#}"); } }); - if let Some(Cmd::SmokeTest) = &cli.command { + if config.smoke_test { let ctlr_tx = ctlr_tx.clone(); tokio::spawn(async move { if let Err(error) = smoke_test(ctlr_tx).await { @@ -191,8 +200,8 @@ pub(crate) fn run( }); } - tracing::debug!(cli.no_deep_links); - if !cli.no_deep_links { + tracing::debug!(config.no_deep_links); + if !config.no_deep_links { // The single-instance check is done, so register our exe // to handle deep links let exe = tauri_utils::platform::current_exe().context("Can't find our own exe path")?; @@ -200,7 +209,7 @@ pub(crate) fn run( tokio::spawn(accept_deep_links(deep_link_server, ctlr_tx.clone())); } - if let Some(failure) = cli.fail_on_purpose() { + if let Some(failure) = config.fail_with { let ctlr_tx = ctlr_tx.clone(); tokio::spawn(async move { let delay = 5; @@ -214,7 +223,7 @@ pub(crate) fn run( }); } - if let Some(delay) = cli.quit_after { + if let Some(delay) = config.quit_after { let ctlr_tx = ctlr_tx.clone(); tokio::spawn(async move { tracing::warn!("Will quit gracefully in {delay} seconds."); diff --git a/rust/gui-client/src-tauri/src/gui/os_linux.rs b/rust/gui-client/src-tauri/src/gui/os_linux.rs index 06d96ef75..dae90189c 100644 --- a/rust/gui-client/src-tauri/src/gui/os_linux.rs +++ b/rust/gui-client/src-tauri/src/gui/os_linux.rs @@ -2,7 +2,7 @@ use anyhow::{Context as _, Result}; use tauri::AppHandle; use tauri_plugin_notification::NotificationExt as _; -pub(crate) async fn set_autostart(enabled: bool) -> Result<()> { +pub async fn set_autostart(enabled: bool) -> Result<()> { let dir = dirs::config_local_dir() .context("Can't compute `config_local_dir`")? .join("autostart"); diff --git a/rust/gui-client/src-tauri/src/gui/os_macos.rs b/rust/gui-client/src-tauri/src/gui/os_macos.rs index 1b4a7e39c..51ea03d7e 100644 --- a/rust/gui-client/src-tauri/src/gui/os_macos.rs +++ b/rust/gui-client/src-tauri/src/gui/os_macos.rs @@ -2,7 +2,7 @@ use super::CtlrTx; use anyhow::{Result, bail}; -pub(crate) async fn set_autostart(_enabled: bool) -> Result<()> { +pub async fn set_autostart(_enabled: bool) -> Result<()> { bail!("Not implemented") } diff --git a/rust/gui-client/src-tauri/src/gui/os_windows.rs b/rust/gui-client/src-tauri/src/gui/os_windows.rs index 5913650ba..44a598485 100644 --- a/rust/gui-client/src-tauri/src/gui/os_windows.rs +++ b/rust/gui-client/src-tauri/src/gui/os_windows.rs @@ -4,7 +4,7 @@ use firezone_bin_shared::BUNDLE_ID; use firezone_logging::err_with_src; use tauri::AppHandle; -pub(crate) async fn set_autostart(_enabled: bool) -> Result<()> { +pub async fn set_autostart(_enabled: bool) -> Result<()> { todo!() } diff --git a/rust/gui-client/src-tauri/src/ipc.rs b/rust/gui-client/src-tauri/src/ipc.rs index de11110e0..664b1e42b 100644 --- a/rust/gui-client/src-tauri/src/ipc.rs +++ b/rust/gui-client/src-tauri/src/ipc.rs @@ -1,23 +1,46 @@ use anyhow::{Context as _, Result}; -use firezone_headless_client::{IpcClientMsg, ipc}; +use connlib_model::{ResourceId, ResourceView}; use futures::SinkExt; +use platform::{ClientStream, ServerStream}; use secrecy::{ExposeSecret, SecretString}; -use std::net::IpAddr; +use std::{collections::BTreeSet, io, net::IpAddr}; +use tokio::io::{ReadHalf, WriteHalf}; +use tokio_util::{ + bytes::BytesMut, + codec::{FramedRead, FramedWrite, LengthDelimitedCodec}, +}; -pub use firezone_headless_client::ipc::ClientRead; +pub(crate) use platform::Server; + +pub type ClientRead = FramedRead, Decoder>; +pub type ClientWrite = FramedWrite, Encoder>; +pub(crate) type ServerRead = FramedRead, Decoder>; +pub(crate) type ServerWrite = FramedWrite, Encoder>; + +#[cfg(target_os = "linux")] +#[path = "ipc/linux.rs"] +pub(crate) mod platform; + +#[cfg(target_os = "windows")] +#[path = "ipc/windows.rs"] +pub(crate) mod platform; + +#[cfg(target_os = "macos")] +#[path = "ipc/macos.rs"] +pub(crate) mod platform; pub struct Client { // Needed temporarily to avoid a big refactor. We can remove this in the future. - tx: ipc::ClientWrite, + tx: ClientWrite, } impl Client { - pub async fn new() -> Result<(Self, ipc::ClientRead)> { + pub async fn new() -> Result<(Self, ClientRead)> { tracing::debug!( client_pid = std::process::id(), "Connecting to IPC service..." ); - let (rx, tx) = ipc::connect_to_service(ipc::ServiceId::Prod).await?; + let (rx, tx) = connect_to_service(ServiceId::Prod).await?; Ok((Self { tx }, rx)) } @@ -27,7 +50,7 @@ impl Client { Ok(()) } - pub async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { + pub async fn send_msg(&mut self, msg: &ClientMsg) -> Result<()> { self.tx .send(msg) .await @@ -37,7 +60,7 @@ impl Client { pub async fn connect_to_firezone(&mut self, api_url: &str, token: SecretString) -> Result<()> { let token = token.expose_secret().clone(); - self.send_msg(&IpcClientMsg::Connect { + self.send_msg(&ClientMsg::Connect { api_url: api_url.to_string(), token, }) @@ -47,7 +70,7 @@ impl Client { } pub async fn reset(&mut self) -> Result<()> { - self.send_msg(&IpcClientMsg::Reset) + self.send_msg(&ClientMsg::Reset) .await .context("Couldn't send Reset")?; Ok(()) @@ -55,9 +78,324 @@ impl Client { /// Tell connlib about the system's default resolvers pub async fn set_dns(&mut self, dns: Vec) -> Result<()> { - self.send_msg(&IpcClientMsg::SetDns(dns)) + self.send_msg(&ClientMsg::SetDns(dns)) .await .context("Couldn't send SetDns")?; Ok(()) } } + +#[derive(Debug, thiserror::Error)] +#[error("Couldn't find IPC service `{0}`")] +pub struct NotFound(String); + +/// A name that both the server and client can use to find each other +/// +/// In the platform-specific code, this is translated to a Unix Domain Socket +/// path on Linux, and a named pipe name on Windows. +/// These have different restrictions on naming. +/// +/// UDS are mostly like normal +/// files, so for production we want them in `/run/dev.firezone.client`, which +/// systemd will create for us, and the Client can trust no other service +/// will impersonate that path. For tests we want them in `/run/user/$UID/`, +/// which we can use without root privilege. +/// +/// Named pipes are not part of the normal file hierarchy, they can only +/// have 2 or 3 slashes in them, and we don't distinguish yet between +/// privileged and non-privileged named pipes. Windows is slowly rolling out +/// UDS support, so in a year or two we might be better off making it UDS +/// on all platforms. +/// +/// Because the paths are so different (and Windows actually uses a `String`), +/// we have this `ServiceId` abstraction instead of just a `PathBuf`. +#[derive(Clone, Copy)] +pub enum ServiceId { + /// The IPC service used by Firezone GUI Client in production + /// + /// This must go in `/run/dev.firezone.client` on Linux, which requires + /// root permission + Prod, + /// An IPC service used for unit tests. + /// + /// This must go in `/run/user/$UID/dev.firezone.client` on Linux so + /// the unit tests won't need root. + /// + /// Includes an ID so that multiple tests can + /// run in parallel. + /// + /// The ID should have A-Z, 0-9 only, no dots or slashes, because of Windows named pipes name restrictions. + #[cfg(test)] + Test(&'static str), +} + +pub struct Decoder { + inner: LengthDelimitedCodec, + _decode_type: std::marker::PhantomData, +} + +pub struct Encoder { + inner: LengthDelimitedCodec, + _encode_type: std::marker::PhantomData, +} + +impl Default for Decoder { + fn default() -> Self { + Self { + inner: LengthDelimitedCodec::new(), + _decode_type: Default::default(), + } + } +} + +impl Default for Encoder { + fn default() -> Self { + Self { + inner: LengthDelimitedCodec::new(), + _encode_type: Default::default(), + } + } +} + +impl tokio_util::codec::Decoder for Decoder { + type Error = anyhow::Error; + type Item = D; + + fn decode(&mut self, buf: &mut BytesMut) -> Result> { + let Some(msg) = self.inner.decode(buf)? else { + return Ok(None); + }; + let msg = serde_json::from_slice(&msg) + .with_context(|| format!("Error while deserializing {}", std::any::type_name::()))?; + Ok(Some(msg)) + } +} + +impl tokio_util::codec::Encoder<&E> for Encoder { + type Error = anyhow::Error; + + fn encode(&mut self, msg: &E, buf: &mut BytesMut) -> Result<()> { + let msg = serde_json::to_string(msg)?; + self.inner.encode(msg.into(), buf)?; + Ok(()) + } +} + +/// Connect to the IPC service +/// +/// Public because the GUI Client will need it +pub async fn connect_to_service(id: ServiceId) -> Result<(ClientRead, ClientWrite)> { + // This is how ChatGPT recommended, and I couldn't think of any more clever + // way before I asked it. + let mut last_err = None; + + for _ in 0..10 { + match platform::connect_to_service(id).await { + Ok(stream) => { + let (rx, tx) = tokio::io::split(stream); + let rx = FramedRead::new(rx, Decoder::default()); + let tx = FramedWrite::new(tx, Encoder::default()); + return Ok((rx, tx)); + } + Err(error) => { + tracing::debug!("Couldn't connect to IPC service: {error}"); + last_err = Some(error); + + // This won't come up much for humans but it helps the automated + // tests pass + tokio::time::sleep(std::time::Duration::from_millis(100)).await; + } + } + } + Err(last_err.expect("Impossible - Exhausted all retries but didn't get any errors")) +} + +impl platform::Server { + pub(crate) async fn next_client_split(&mut self) -> Result<(ServerRead, ServerWrite)> { + let (rx, tx) = tokio::io::split(self.next_client().await?); + let rx = FramedRead::new(rx, Decoder::default()); + let tx = FramedWrite::new(tx, Encoder::default()); + Ok((rx, tx)) + } +} + +#[derive(Debug, PartialEq, serde::Deserialize, serde::Serialize)] +pub enum ClientMsg { + ClearLogs, + Connect { + api_url: String, + token: String, + }, + Disconnect, + ApplyLogFilter { + directives: String, + }, + Reset, + SetDns(Vec), + SetDisabledResources(BTreeSet), + StartTelemetry { + environment: String, + release: String, + account_slug: Option, + }, +} + +/// Messages that end up in the GUI, either forwarded from connlib or from the IPC service. +#[derive(Debug, serde::Deserialize, serde::Serialize, strum::Display)] +pub enum ServerMsg { + /// The IPC service finished clearing its log dir. + ClearedLogs(Result<(), String>), + ConnectResult(Result<(), Error>), + DisconnectedGracefully, + OnDisconnect { + error_msg: String, + is_authentication_error: bool, + }, + OnUpdateResources(Vec), + /// The IPC service is terminating, maybe due to a software update + /// + /// This is a hint that the Client should exit with a message like, + /// "Firezone is updating, please restart the GUI" instead of an error like, + /// "IPC connection closed". + TerminatingGracefully, + /// The interface and tunnel are ready for traffic. + TunnelReady, +} + +// All variants are `String` because almost no error type implements `Serialize` +#[derive(Debug, serde::Deserialize, serde::Serialize, thiserror::Error)] +pub enum Error { + #[error("IO error: {0}")] + Io(String), + #[error("{0}")] + Other(String), +} + +impl From for Error { + fn from(v: io::Error) -> Self { + Self::Io(v.to_string()) + } +} + +impl From for Error { + fn from(v: anyhow::Error) -> Self { + Self::Other(format!("{v:#}")) + } +} + +#[cfg(test)] +mod tests { + use super::{platform::Server, *}; + use anyhow::{Result, bail, ensure}; + use futures::{SinkExt, StreamExt}; + use std::time::Duration; + use tokio::{task::JoinHandle, time::timeout}; + + #[tokio::test] + async fn no_such_service() -> Result<()> { + let _guard = firezone_logging::test("trace"); + const ID: ServiceId = ServiceId::Test("H56FRXVH"); + + if super::connect_to_service(ID).await.is_ok() { + bail!("`connect_to_service` should have failed for a non-existent service"); + } + Ok(()) + } + + /// Make sure the IPC client and server can exchange messages + #[tokio::test] + async fn smoke() -> Result<()> { + let _guard = firezone_logging::test("trace"); + let loops = 10; + const ID: ServiceId = ServiceId::Test("OB5SZCGN"); + + let mut server = Server::new(ID).expect("Error while starting IPC server"); + + let server_task: tokio::task::JoinHandle> = tokio::spawn(async move { + for _ in 0..loops { + let (mut rx, mut tx) = server + .next_client_split() + .await + .expect("Error while waiting for next IPC client"); + while let Some(req) = rx.next().await { + let req = req.expect("Error while reading from IPC client"); + ensure!(req == ClientMsg::Reset); + tx.send(&ServerMsg::OnUpdateResources(vec![])) + .await + .expect("Error while writing to IPC client"); + } + tracing::info!("Client disconnected"); + } + Ok(()) + }); + + let client_task: JoinHandle> = tokio::spawn(async move { + for _ in 0..loops { + let (mut rx, mut tx) = super::connect_to_service(ID) + .await + .context("Error while connecting to IPC server")?; + + let req = ClientMsg::Reset; + for _ in 0..10 { + tx.send(&req) + .await + .expect("Error while writing to IPC server"); + let resp = rx + .next() + .await + .expect("Should have gotten a reply from the IPC server") + .expect("Error while reading from IPC server"); + ensure!(matches!(resp, ServerMsg::OnUpdateResources(_))); + } + } + Ok(()) + }); + + let client_result = client_task.await; + match &client_result { + Err(panic) => { + tracing::error!(?panic, "Client panic"); + } + Ok(Err(error)) => { + tracing::error!("Client error: {error:#}"); + } + _ => (), + } + + let server_result = server_task.await; + match &server_result { + Err(panic) => { + tracing::error!(?panic, "Server panic"); + } + Ok(Err(error)) => { + tracing::error!("Server error: {error:#}"); + } + _ => (), + } + + if client_result.is_err() || server_result.is_err() { + anyhow::bail!("Something broke."); + } + Ok(()) + } + + /// Replicate #5143 + /// + /// When the IPC service has disconnected from a GUI and loops over, sometimes + /// the named pipe is not ready. If our IPC code doesn't handle this right, + /// this test will fail. + #[tokio::test] + async fn loop_to_next_client() -> Result<()> { + let _guard = firezone_logging::test("trace"); + + let mut server = Server::new(ServiceId::Test("H6L73DG5"))?; + for i in 0..5 { + if let Ok(Err(err)) = timeout(Duration::from_secs(1), server.next_client()).await { + Err(err).with_context(|| { + format!("Couldn't listen for next IPC client, iteration {i}") + })?; + } + } + Ok(()) + } +} diff --git a/rust/headless-client/src/ipc_service/ipc/linux.rs b/rust/gui-client/src-tauri/src/ipc/linux.rs similarity index 89% rename from rust/headless-client/src/ipc_service/ipc/linux.rs rename to rust/gui-client/src-tauri/src/ipc/linux.rs index 6cda463af..a6707ac00 100644 --- a/rust/headless-client/src/ipc_service/ipc/linux.rs +++ b/rust/gui-client/src-tauri/src/ipc/linux.rs @@ -41,19 +41,22 @@ pub async fn connect_to_service(id: ServiceId) -> Result { impl Server { /// Platform-specific setup - pub(crate) async fn new(id: ServiceId) -> Result { + pub(crate) fn new(id: ServiceId) -> Result { let sock_path = ipc_path(id); + + tracing::debug!(socket = %sock_path.display(), "Creating new IPC server"); + // Remove the socket if a previous run left it there - tokio::fs::remove_file(&sock_path).await.ok(); + std::fs::remove_file(&sock_path).ok(); // Create the dir if possible, needed for test paths under `/run/user` let dir = sock_path .parent() .context("`sock_path` should always have a parent")?; - tokio::fs::create_dir_all(dir).await?; + std::fs::create_dir_all(dir).context("Failed to create socket parent directory")?; let listener = UnixListener::bind(&sock_path) .with_context(|| format!("Couldn't bind UDS `{}`", sock_path.display()))?; let perms = std::fs::Permissions::from_mode(0o660); - tokio::fs::set_permissions(&sock_path, perms).await?; + std::fs::set_permissions(&sock_path, perms).context("Failed to set permissions on UDS")?; // TODO: Change this to `notify_service_controller` and put it in // the same place in the IPC service's main loop as in the Headless Client. @@ -87,6 +90,7 @@ impl Server { fn ipc_path(id: ServiceId) -> PathBuf { match id { ServiceId::Prod => PathBuf::from("/run").join(BUNDLE_ID).join("ipc.sock"), + #[cfg(test)] ServiceId::Test(id) => firezone_bin_shared::known_dirs::runtime() .expect("`known_dirs::runtime()` should always work") .join(format!("ipc_test_{id}.sock")), diff --git a/rust/headless-client/src/ipc_service/ipc/macos.rs b/rust/gui-client/src-tauri/src/ipc/macos.rs similarity index 79% rename from rust/headless-client/src/ipc_service/ipc/macos.rs rename to rust/gui-client/src-tauri/src/ipc/macos.rs index 5cb6f0ce0..809472e7e 100644 --- a/rust/headless-client/src/ipc_service/ipc/macos.rs +++ b/rust/gui-client/src-tauri/src/ipc/macos.rs @@ -16,11 +16,7 @@ pub async fn connect_to_service(_id: ServiceId) -> Result { } impl Server { - #[expect( - clippy::unused_async, - reason = "Signture must match other operating systems" - )] - pub(crate) async fn new(_id: ServiceId) -> Result { + pub(crate) fn new(_id: ServiceId) -> Result { bail!("not implemented") } diff --git a/rust/headless-client/src/ipc_service/ipc/windows.rs b/rust/gui-client/src-tauri/src/ipc/windows.rs similarity index 97% rename from rust/headless-client/src/ipc_service/ipc/windows.rs rename to rust/gui-client/src-tauri/src/ipc/windows.rs index 9a044fc64..be5d0e4a9 100644 --- a/rust/headless-client/src/ipc_service/ipc/windows.rs +++ b/rust/gui-client/src-tauri/src/ipc/windows.rs @@ -46,10 +46,8 @@ pub(crate) async fn connect_to_service(id: ServiceId) -> Result { impl Server { /// Platform-specific setup - /// - /// This is async on Linux - #[expect(clippy::unused_async)] - pub(crate) async fn new(id: ServiceId) -> Result { + #[expect(clippy::unnecessary_wraps, reason = "Linux impl is fallible")] + pub(crate) fn new(id: ServiceId) -> Result { let pipe_path = ipc_path(id); Ok(Self { pipe_path }) } @@ -159,6 +157,7 @@ fn create_pipe_server(pipe_path: &str) -> Result String { let name = match id { ServiceId::Prod => format!("{BUNDLE_ID}.ipc_service"), + #[cfg(test)] ServiceId::Test(id) => format!("{BUNDLE_ID}_test_{id}.ipc_service"), }; named_pipe_path(&name) @@ -199,7 +198,7 @@ mod tests { async fn single_instance() -> anyhow::Result<()> { let _guard = firezone_logging::test("trace"); const ID: ServiceId = ServiceId::Test("2GOCMPBG"); - let mut server_1 = Server::new(ID).await?; + let mut server_1 = Server::new(ID)?; let pipe_path = server_1.pipe_path.clone(); tokio::spawn(async move { diff --git a/rust/gui-client/src-tauri/src/lib.rs b/rust/gui-client/src-tauri/src/lib.rs new file mode 100644 index 000000000..137405c93 --- /dev/null +++ b/rust/gui-client/src-tauri/src/lib.rs @@ -0,0 +1,26 @@ +#![cfg_attr(test, allow(clippy::unwrap_used))] + +mod about; +mod clear_logs; +mod updates; +mod uptime; +mod welcome; + +// TODO: See how many of these we can make private. +pub mod auth; +pub mod controller; +pub mod deep_link; +pub mod elevation; +pub mod gui; +pub mod ipc; +pub mod logging; +pub mod service; +pub mod settings; + +pub use clear_logs::clear_logs; + +/// The Sentry "release" we are part of. +/// +/// IPC service and GUI client are always bundled into a single release. +/// Hence, we have a single constant for IPC service and GUI client. +pub const RELEASE: &str = concat!("gui-client@", env!("CARGO_PKG_VERSION")); diff --git a/rust/gui-client/src-tauri/src/logging.rs b/rust/gui-client/src-tauri/src/logging.rs index 2b70af897..6f6a3bbe2 100644 --- a/rust/gui-client/src-tauri/src/logging.rs +++ b/rust/gui-client/src-tauri/src/logging.rs @@ -3,7 +3,7 @@ use crate::gui::Managed; use anyhow::{Context as _, Result, bail}; use firezone_bin_shared::known_dirs; -use firezone_logging::err_with_src; +use firezone_logging::{FilterReloadHandle, err_with_src}; use serde::Serialize; use std::{ fs, @@ -12,7 +12,7 @@ use std::{ }; use tauri_plugin_dialog::DialogExt as _; use tokio::task::spawn_blocking; -use tracing_subscriber::{Layer, Registry, layer::SubscriberExt}; +use tracing_subscriber::{EnvFilter, Layer, Registry, layer::SubscriberExt}; use super::controller::{ControllerRequest, CtlrTx}; @@ -120,7 +120,7 @@ pub enum Error { /// We need two of these filters for some reason, and `EnvFilter` doesn't implement /// `Clone` yet, so that's why we take the directives string /// -pub fn setup(directives: &str) -> Result { +pub fn setup_gui(directives: &str) -> Result { if let Err(error) = output_vt100::try_init() { tracing::debug!("Failed to init terminal colors: {error}"); } @@ -152,7 +152,16 @@ pub fn setup(directives: &str) -> Result { .with(firezone_logging::sentry_layer()); firezone_logging::init(subscriber)?; - tracing::debug!(log_path = %log_path.display(), syslog_identifier = syslog_identifier.map(tracing::field::display)); + tracing::info!( + arch = std::env::consts::ARCH, + os = std::env::consts::OS, + version = env!("CARGO_PKG_VERSION"), + %directives, + system_uptime = firezone_bin_shared::uptime::get().map(tracing::field::debug), + log_path = %log_path.display(), + syslog_identifier = syslog_identifier.map(tracing::field::display), + "`gui-client` started logging" + ); Ok(Handles { logger, @@ -160,6 +169,96 @@ pub fn setup(directives: &str) -> Result { }) } +/// Starts logging for the production IPC service +/// +/// Returns: A `Handle` that must be kept alive. Dropping it stops logging +/// and flushes the log file. +pub fn setup_ipc( + log_path: Option, +) -> Result<( + firezone_logging::file::Handle, + firezone_logging::FilterReloadHandle, +)> { + // If `log_dir` is Some, use that. Else call `ipc_service_logs` + let log_path = log_path.map_or_else( + || known_dirs::ipc_service_logs().context("Should be able to compute IPC service logs dir"), + Ok, + )?; + std::fs::create_dir_all(&log_path) + .context("We should have permissions to create our log dir")?; + + let directives = get_log_filter().context("Couldn't read log filter")?; + + let (file_filter, file_reloader) = firezone_logging::try_filter(&directives)?; + let (stdout_filter, stdout_reloader) = firezone_logging::try_filter(&directives)?; + + let (file_layer, file_handle) = firezone_logging::file::layer(&log_path, "ipc-service"); + + let stdout_layer = tracing_subscriber::fmt::layer() + .with_ansi(firezone_logging::stdout_supports_ansi()) + .event_format(firezone_logging::Format::new().without_timestamp()); + + let subscriber = Registry::default() + .with(file_layer.with_filter(file_filter)) + .with(stdout_layer.with_filter(stdout_filter)) + .with(firezone_logging::sentry_layer()); + firezone_logging::init(subscriber)?; + + tracing::info!( + arch = std::env::consts::ARCH, + os = std::env::consts::OS, + version = env!("CARGO_PKG_VERSION"), + ?directives, + system_uptime = firezone_bin_shared::uptime::get().map(tracing::field::debug), + log_path = %log_path.display(), + "`ipc-service` started logging" + ); + + Ok((file_handle, file_reloader.merge(stdout_reloader))) +} + +/// Sets up logging for stdout only, with INFO level by default +pub fn setup_stdout() -> Result { + let directives = get_log_filter().context("Can't read log filter")?; + let (filter, reloader) = firezone_logging::try_filter(&directives)?; + let layer = tracing_subscriber::fmt::layer() + .event_format(firezone_logging::Format::new()) + .with_filter(filter); + let subscriber = Registry::default().with(layer); + firezone_logging::init(subscriber)?; + + Ok(reloader) +} +/// Reads the log filter for the IPC service or for debug commands +/// +/// e.g. `info` +/// +/// Reads from: +/// 1. `RUST_LOG` env var +/// 2. `known_dirs::ipc_log_filter()` file +/// 3. Hard-coded default `SERVICE_RUST_LOG` +/// +/// Errors if something is badly wrong, e.g. the directory for the config file +/// can't be computed +pub(crate) fn get_log_filter() -> Result { + #[cfg(not(debug_assertions))] + const DEFAULT_LOG_FILTER: &str = "info"; + #[cfg(debug_assertions)] + const DEFAULT_LOG_FILTER: &str = "debug"; + + if let Ok(filter) = std::env::var(EnvFilter::DEFAULT_ENV) { + return Ok(filter); + } + + if let Ok(filter) = std::fs::read_to_string(firezone_bin_shared::known_dirs::ipc_log_filter()?) + .map(|s| s.trim().to_string()) + { + return Ok(filter); + } + + Ok(DEFAULT_LOG_FILTER.to_string()) +} + #[cfg(target_os = "linux")] fn system_layer() -> Result { let layer = tracing_journald::layer()?; @@ -187,8 +286,7 @@ pub struct FileCount { /// If we get an error while removing a file, we still try to remove all other /// files, then we return the most recent error. pub async fn clear_gui_logs() -> Result<()> { - firezone_headless_client::clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?) - .await + crate::clear_logs(&known_dirs::logs().context("Can't compute GUI log dir")?).await } /// Exports logs to a zip file diff --git a/rust/headless-client/src/ipc_service.rs b/rust/gui-client/src-tauri/src/service.rs similarity index 66% rename from rust/headless-client/src/ipc_service.rs rename to rust/gui-client/src-tauri/src/service.rs index 919b0458d..b545c5546 100644 --- a/rust/headless-client/src/ipc_service.rs +++ b/rust/gui-client/src-tauri/src/service.rs @@ -1,240 +1,40 @@ -use crate::{CallbackHandler, CliCommon, ConnlibMsg}; use anyhow::{Context as _, Result, bail}; use atomicwrites::{AtomicFile, OverwriteBehavior}; -use clap::Parser; -use connlib_model::ResourceView; +use backoff::ExponentialBackoffBuilder; +use connlib_client_shared::ConnlibMsg; use firezone_bin_shared::{ - DnsControlMethod, DnsController, TOKEN_ENV_KEY, TunDeviceManager, device_id, device_info, - known_dirs, + DnsControlMethod, DnsController, TunDeviceManager, device_id, device_info, known_dirs, platform::{tcp_socket_factory, udp_socket_factory}, signals, }; -use firezone_logging::{FilterReloadHandle, err_with_src, sentry_layer, telemetry_span}; +use firezone_logging::{FilterReloadHandle, err_with_src, telemetry_span}; use firezone_telemetry::Telemetry; use futures::{ Future as _, SinkExt as _, Stream as _, future::poll_fn, task::{Context, Poll}, }; -use phoenix_channel::{DeviceInfo, LoginUrl}; -use secrecy::SecretString; -use serde::{Deserialize, Serialize}; -use std::{ - collections::BTreeSet, - io::{self, Write}, - net::IpAddr, - path::PathBuf, - pin::pin, - sync::Arc, - time::Duration, -}; +use phoenix_channel::{DeviceInfo, LoginUrl, PhoenixChannel, get_user_agent}; +use secrecy::{Secret, SecretString}; +use std::{io::Write, pin::pin, sync::Arc, time::Duration}; use tokio::{sync::mpsc, time::Instant}; -use tracing_subscriber::{Layer, Registry, layer::SubscriberExt}; use url::Url; -pub mod ipc; -use backoff::ExponentialBackoffBuilder; -use connlib_model::ResourceId; -use ipc::{Server as IpcServer, ServiceId}; -use phoenix_channel::{PhoenixChannel, get_user_agent}; -use secrecy::Secret; - #[cfg(target_os = "linux")] -#[path = "ipc_service/linux.rs"] -pub mod platform; +#[path = "service/linux.rs"] +mod platform; #[cfg(target_os = "windows")] -#[path = "ipc_service/windows.rs"] -pub mod platform; +#[path = "service/windows.rs"] +mod platform; #[cfg(target_os = "macos")] -#[path = "ipc_service/macos.rs"] -pub mod platform; +#[path = "service/macos.rs"] +mod platform; -#[derive(clap::Parser)] -#[command(author, version, about, long_about = None)] -struct Cli { - #[command(subcommand)] - command: Cmd, +pub use platform::{elevation_check, install, run}; - #[command(flatten)] - common: CliCommon, -} - -#[derive(clap::Subcommand)] -enum Cmd { - /// Needed to test the IPC service on aarch64 Windows, - /// where the Tauri MSI bundler doesn't work yet - Install, - Run, - RunDebug, - RunSmokeTest, -} - -impl Default for Cmd { - fn default() -> Self { - Self::Run - } -} - -#[derive(Debug, PartialEq, Deserialize, Serialize)] -pub enum ClientMsg { - ClearLogs, - Connect { - api_url: String, - token: String, - }, - Disconnect, - ApplyLogFilter { - directives: String, - }, - Reset, - SetDns(Vec), - SetDisabledResources(BTreeSet), - StartTelemetry { - environment: String, - release: String, - account_slug: Option, - }, -} - -/// Messages that end up in the GUI, either forwarded from connlib or from the IPC service. -#[derive(Debug, Deserialize, Serialize, strum::Display)] -pub enum ServerMsg { - /// The IPC service finished clearing its log dir. - ClearedLogs(Result<(), String>), - ConnectResult(Result<(), Error>), - DisconnectedGracefully, - OnDisconnect { - error_msg: String, - is_authentication_error: bool, - }, - OnUpdateResources(Vec), - /// The IPC service is terminating, maybe due to a software update - /// - /// This is a hint that the Client should exit with a message like, - /// "Firezone is updating, please restart the GUI" instead of an error like, - /// "IPC connection closed". - TerminatingGracefully, - /// The interface and tunnel are ready for traffic. - TunnelReady, -} - -// All variants are `String` because almost no error type implements `Serialize` -#[derive(Debug, Deserialize, Serialize, thiserror::Error)] -pub enum Error { - #[error("IO error: {0}")] - Io(String), - #[error("{0}")] - Other(String), -} - -impl From for Error { - fn from(v: io::Error) -> Self { - Self::Io(v.to_string()) - } -} - -impl From for Error { - fn from(v: anyhow::Error) -> Self { - Self::Other(format!("{v:#}")) - } -} - -/// Only called from the GUI Client's build of the IPC service -pub fn run_only_ipc_service() -> Result<()> { - // Docs indicate that `remove_var` should actually be marked unsafe - // SAFETY: We haven't spawned any other threads, this code should be the first - // thing to run after entering `main` and parsing CLI args. - // So nobody else is reading the environment. - unsafe { - // This removes the token from the environment per . We run as root so it may not do anything besides defense-in-depth. - std::env::remove_var(TOKEN_ENV_KEY); - } - assert!(std::env::var(TOKEN_ENV_KEY).is_err()); - let cli = Cli::try_parse()?; - match cli.command { - Cmd::Install => platform::install_ipc_service(), - Cmd::Run => platform::run_ipc_service(cli.common), - Cmd::RunDebug => run_debug_ipc_service(cli), - Cmd::RunSmokeTest => run_smoke_test(), - } -} - -fn run_debug_ipc_service(cli: Cli) -> Result<()> { - let log_filter_reloader = crate::setup_stdout_logging()?; - tracing::info!( - arch = std::env::consts::ARCH, - // version = env!("CARGO_PKG_VERSION"), TODO: Fix once `ipc_service` is moved to `gui-client`. - system_uptime_seconds = firezone_bin_shared::uptime::get().map(|dur| dur.as_secs()), - ); - if !platform::elevation_check()? { - bail!("IPC service failed its elevation check, try running as admin / root"); - } - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - let _guard = rt.enter(); - let mut signals = signals::Terminate::new()?; - let mut telemetry = Telemetry::default(); - - rt.block_on(ipc_listen( - cli.common.dns_control, - &log_filter_reloader, - &mut signals, - &mut telemetry, - )) - .inspect(|_| rt.block_on(telemetry.stop())) - .inspect_err(|e| { - tracing::error!("IPC service failed: {e:#}"); - - rt.block_on(telemetry.stop_on_crash()) - }) -} - -#[cfg(not(debug_assertions))] -fn run_smoke_test() -> Result<()> { - anyhow::bail!("Smoke test is not built for release binaries."); -} - -/// Listen for exactly one connection from a GUI, then exit -/// -/// This makes the timing neater in case the GUI starts up slowly. -#[cfg(debug_assertions)] -fn run_smoke_test() -> Result<()> { - let log_filter_reloader = crate::setup_stdout_logging()?; - if !platform::elevation_check()? { - bail!("IPC service failed its elevation check, try running as admin / root"); - } - let rt = tokio::runtime::Builder::new_current_thread() - .enable_all() - .build()?; - let _guard = rt.enter(); - let mut dns_controller = DnsController { - dns_control_method: Default::default(), - }; - // Deactivate Firezone DNS control in case the system or IPC service crashed - // and we need to recover. - dns_controller.deactivate()?; - let mut signals = signals::Terminate::new()?; - let mut telemetry = Telemetry::default(); - - // Couldn't get the loop to work here yet, so SIGHUP is not implemented - rt.block_on(async { - device_id::get_or_create().context("Failed to read / create device ID")?; - let mut server = IpcServer::new(ServiceId::Prod).await?; - let _ = Handler::new( - &mut server, - &mut dns_controller, - &log_filter_reloader, - &mut telemetry, - ) - .await? - .run(&mut signals) - .await; - Ok::<_, anyhow::Error>(()) - }) -} +use crate::ipc::{self, ServiceId}; /// Run the IPC service and terminate gracefully if we catch a terminate signal /// @@ -254,7 +54,7 @@ async fn ipc_listen( Telemetry::set_firezone_id(firezone_id); - let mut server = IpcServer::new(ServiceId::Prod).await?; + let mut server = ipc::Server::new(ServiceId::Prod)?; let mut dns_controller = DnsController { dns_control_method }; loop { let mut handler_fut = pin!(Handler::new( @@ -307,7 +107,7 @@ struct Session { enum Event { Callback(ConnlibMsg), CallbackChannelClosed, - Ipc(ClientMsg), + Ipc(ipc::ClientMsg), IpcDisconnected, IpcError(anyhow::Error), Terminate, @@ -323,7 +123,7 @@ enum HandlerOk { impl<'a> Handler<'a> { async fn new( - server: &mut IpcServer, + server: &mut ipc::Server, dns_controller: &'a mut DnsController, log_filter_reloader: &'a FilterReloadHandle, telemetry: &'a mut Telemetry, @@ -389,7 +189,7 @@ impl<'a> Handler<'a> { "Caught SIGINT / SIGTERM / Ctrl+C while an IPC client is connected" ); // Ignore the result here because we're terminating anyway. - let _ = self.send_ipc(ServerMsg::TerminatingGracefully).await; + let _ = self.send_ipc(ipc::ServerMsg::TerminatingGracefully).await; break HandlerOk::ServiceTerminating; } } @@ -433,7 +233,7 @@ impl<'a> Handler<'a> { } => { let _ = self.session.take(); self.dns_controller.deactivate()?; - self.send_ipc(ServerMsg::OnDisconnect { + self.send_ipc(ipc::ServerMsg::OnDisconnect { error_msg, is_authentication_error, }) @@ -455,45 +255,48 @@ impl<'a> Handler<'a> { self.tun_device.set_routes(ipv4_routes, ipv6_routes).await?; self.dns_controller.flush()?; - self.send_ipc(ServerMsg::TunnelReady).await?; + self.send_ipc(ipc::ServerMsg::TunnelReady).await?; } ConnlibMsg::OnUpdateResources(resources) => { // On every resources update, flush DNS to mitigate self.dns_controller.flush()?; - self.send_ipc(ServerMsg::OnUpdateResources(resources)) + self.send_ipc(ipc::ServerMsg::OnUpdateResources(resources)) .await?; } } Ok(()) } - async fn handle_ipc_msg(&mut self, msg: ClientMsg) -> Result<()> { + async fn handle_ipc_msg(&mut self, msg: ipc::ClientMsg) -> Result<()> { match msg { - ClientMsg::ClearLogs => { + ipc::ClientMsg::ClearLogs => { let result = crate::clear_logs( &firezone_bin_shared::known_dirs::ipc_service_logs() .context("Can't compute logs dir")?, ) .await; - self.send_ipc(ServerMsg::ClearedLogs(result.map_err(|e| e.to_string()))) - .await? + self.send_ipc(ipc::ServerMsg::ClearedLogs( + result.map_err(|e| e.to_string()), + )) + .await? } - ClientMsg::Connect { api_url, token } => { + ipc::ClientMsg::Connect { api_url, token } => { // Warning: Connection errors don't bubble to callers of `handle_ipc_msg`. let token = secrecy::SecretString::from(token); let result = self.connect_to_firezone(&api_url, token); - self.send_ipc(ServerMsg::ConnectResult(result)).await? + self.send_ipc(ipc::ServerMsg::ConnectResult(result)).await? } - ClientMsg::Disconnect => { + ipc::ClientMsg::Disconnect => { if self.session.take().is_some() { self.dns_controller.deactivate()?; } // Always send `DisconnectedGracefully` even if we weren't connected, // so this will be idempotent. - self.send_ipc(ServerMsg::DisconnectedGracefully).await?; + self.send_ipc(ipc::ServerMsg::DisconnectedGracefully) + .await?; } - ClientMsg::ApplyLogFilter { directives } => { + ipc::ClientMsg::ApplyLogFilter { directives } => { self.log_filter_reloader.reload(&directives)?; let path = known_dirs::ipc_log_filter()?; @@ -504,7 +307,7 @@ impl<'a> Handler<'a> { tracing::warn!(path = %path.display(), %directives, "Failed to write new log directives: {}", err_with_src(&e)); } } - ClientMsg::Reset => { + ipc::ClientMsg::Reset => { if self.last_connlib_start_instant.is_some() { tracing::debug!("Ignoring reset since we're still signing in"); return Ok(()); @@ -516,7 +319,7 @@ impl<'a> Handler<'a> { session.connlib.reset(); } - ClientMsg::SetDns(resolvers) => { + ipc::ClientMsg::SetDns(resolvers) => { let Some(session) = self.session.as_ref() else { tracing::debug!("Cannot set DNS resolvers if we're signed out"); return Ok(()); @@ -525,7 +328,7 @@ impl<'a> Handler<'a> { tracing::debug!(?resolvers); session.connlib.set_dns(resolvers); } - ClientMsg::SetDisabledResources(disabled_resources) => { + ipc::ClientMsg::SetDisabledResources(disabled_resources) => { let Some(session) = self.session.as_ref() else { // At this point, the GUI has already saved the disabled Resources to disk, so it'll be correct on the next sign-in anyway. tracing::debug!("Cannot set disabled resources if we're signed out"); @@ -534,7 +337,7 @@ impl<'a> Handler<'a> { session.connlib.set_disabled_resources(disabled_resources); } - ClientMsg::StartTelemetry { + ipc::ClientMsg::StartTelemetry { environment, release, account_slug, @@ -555,7 +358,11 @@ impl<'a> Handler<'a> { /// Panics if there's no Tokio runtime or if connlib is already connected /// /// Throws matchable errors for bad URLs, unable to reach the portal, or unable to create the tunnel device - fn connect_to_firezone(&mut self, api_url: &str, token: SecretString) -> Result<(), Error> { + fn connect_to_firezone( + &mut self, + api_url: &str, + token: SecretString, + ) -> Result<(), ipc::Error> { let _connect_span = telemetry_span!("connect_to_firezone").entered(); assert!(self.session.is_none()); @@ -576,8 +383,7 @@ impl<'a> Handler<'a> { .context("Failed to create `LoginUrl`")?; self.last_connlib_start_instant = Some(Instant::now()); - let (cb_tx, cb_rx) = mpsc::channel(1_000); - let callbacks = CallbackHandler { cb_tx }; + let (callbacks, cb_rx) = connlib_client_shared::ChannelCallbackHandler::new(); // Synchronous DNS resolution here let portal = PhoenixChannel::disconnected( @@ -624,7 +430,7 @@ impl<'a> Handler<'a> { Ok(()) } - async fn send_ipc(&mut self, msg: ServerMsg) -> Result<()> { + async fn send_ipc(&mut self, msg: ipc::ServerMsg) -> Result<()> { self.ipc_tx .send(&msg) .await @@ -634,69 +440,81 @@ impl<'a> Handler<'a> { } } -/// Starts logging for the production IPC service -/// -/// Returns: A `Handle` that must be kept alive. Dropping it stops logging -/// and flushes the log file. -fn setup_logging( - log_dir: Option, -) -> Result<( - firezone_logging::file::Handle, - firezone_logging::FilterReloadHandle, -)> { - // If `log_dir` is Some, use that. Else call `ipc_service_logs` - let log_dir = log_dir.map_or_else( - || known_dirs::ipc_service_logs().context("Should be able to compute IPC service logs dir"), - Ok, - )?; - std::fs::create_dir_all(&log_dir) - .context("We should have permissions to create our log dir")?; - - let directives = crate::get_log_filter().context("Couldn't read log filter")?; - - let (file_filter, file_reloader) = firezone_logging::try_filter(&directives)?; - let (stdout_filter, stdout_reloader) = firezone_logging::try_filter(&directives)?; - - let (file_layer, file_handle) = firezone_logging::file::layer(&log_dir, "ipc-service"); - - let stdout_layer = tracing_subscriber::fmt::layer() - .with_ansi(firezone_logging::stdout_supports_ansi()) - .event_format(firezone_logging::Format::new().without_timestamp()); - - let subscriber = Registry::default() - .with(file_layer.with_filter(file_filter)) - .with(stdout_layer.with_filter(stdout_filter)) - .with(sentry_layer()); - firezone_logging::init(subscriber)?; - +pub fn run_debug(dns_control: DnsControlMethod) -> Result<()> { + let log_filter_reloader = crate::logging::setup_stdout()?; tracing::info!( arch = std::env::consts::ARCH, // version = env!("CARGO_PKG_VERSION"), TODO: Fix once `ipc_service` is moved to `gui-client`. system_uptime_seconds = firezone_bin_shared::uptime::get().map(|dur| dur.as_secs()), - %directives ); - - Ok((file_handle, file_reloader.merge(stdout_reloader))) -} - -#[cfg(test)] -mod tests { - use super::{Cli, Cmd}; - use clap::Parser; - use std::path::PathBuf; - - const EXE_NAME: &str = "firezone-client-ipc"; - - // Can't remember how Clap works sometimes - // Also these are examples - #[test] - fn cli() { - let actual = - Cli::try_parse_from([EXE_NAME, "--log-dir", "bogus_log_dir", "run-debug"]).unwrap(); - assert!(matches!(actual.command, Cmd::RunDebug)); - assert_eq!(actual.common.log_dir, Some(PathBuf::from("bogus_log_dir"))); - - let actual = Cli::try_parse_from([EXE_NAME, "run"]).unwrap(); - assert!(matches!(actual.command, Cmd::Run)); + if !elevation_check()? { + bail!("IPC service failed its elevation check, try running as admin / root"); } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let _guard = rt.enter(); + let mut signals = signals::Terminate::new()?; + let mut telemetry = Telemetry::default(); + + rt.block_on(ipc_listen( + dns_control, + &log_filter_reloader, + &mut signals, + &mut telemetry, + )) + .inspect(|_| rt.block_on(telemetry.stop())) + .inspect_err(|e| { + tracing::error!("IPC service failed: {e:#}"); + + rt.block_on(telemetry.stop_on_crash()) + }) +} + +/// Listen for exactly one connection from a GUI, then exit +/// +/// This makes the timing neater in case the GUI starts up slowly. +#[cfg(debug_assertions)] +pub fn run_smoke_test() -> Result<()> { + use crate::ipc::{self, ServiceId}; + use anyhow::{Context as _, bail}; + use firezone_bin_shared::{DnsController, device_id}; + + let log_filter_reloader = crate::logging::setup_stdout()?; + if !elevation_check()? { + bail!("IPC service failed its elevation check, try running as admin / root"); + } + let rt = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()?; + let _guard = rt.enter(); + let mut dns_controller = DnsController { + dns_control_method: Default::default(), + }; + // Deactivate Firezone DNS control in case the system or IPC service crashed + // and we need to recover. + dns_controller.deactivate()?; + let mut signals = signals::Terminate::new()?; + let mut telemetry = Telemetry::default(); + + // Couldn't get the loop to work here yet, so SIGHUP is not implemented + rt.block_on(async { + device_id::get_or_create().context("Failed to read / create device ID")?; + let mut server = ipc::Server::new(ServiceId::Prod)?; + let _ = Handler::new( + &mut server, + &mut dns_controller, + &log_filter_reloader, + &mut telemetry, + ) + .await? + .run(&mut signals) + .await; + Ok::<_, anyhow::Error>(()) + }) +} + +#[cfg(not(debug_assertions))] +pub fn run_smoke_test() -> Result<()> { + anyhow::bail!("Smoke test is not built for release binaries."); } diff --git a/rust/headless-client/src/ipc_service/linux.rs b/rust/gui-client/src-tauri/src/service/linux.rs similarity index 75% rename from rust/headless-client/src/ipc_service/linux.rs rename to rust/gui-client/src-tauri/src/service/linux.rs index d96219879..54df7ec18 100644 --- a/rust/headless-client/src/ipc_service/linux.rs +++ b/rust/gui-client/src-tauri/src/service/linux.rs @@ -1,14 +1,15 @@ -use super::CliCommon; +use std::path::PathBuf; + use anyhow::{Result, bail}; -use firezone_bin_shared::signals; +use firezone_bin_shared::{DnsControlMethod, signals}; use firezone_telemetry::Telemetry; /// Cross-platform entry point for systemd / Windows services /// /// Linux uses the CLI args from here, Windows does not -pub(crate) fn run_ipc_service(cli: CliCommon) -> Result<()> { - let (_handle, log_filter_reloader) = super::setup_logging(cli.log_dir)?; +pub fn run(log_dir: Option, dns_control: DnsControlMethod) -> Result<()> { + let (_handle, log_filter_reloader) = crate::logging::setup_ipc(log_dir)?; if !elevation_check()? { bail!("IPC service failed its elevation check, try running as admin / root"); } @@ -20,7 +21,7 @@ pub(crate) fn run_ipc_service(cli: CliCommon) -> Result<()> { let mut telemetry = Telemetry::default(); rt.block_on(super::ipc_listen( - cli.dns_control, + dns_control, &log_filter_reloader, &mut signals, &mut telemetry, @@ -36,10 +37,10 @@ pub(crate) fn run_ipc_service(cli: CliCommon) -> Result<()> { /// Returns true if the IPC service can run properly // Fallible on Windows #[expect(clippy::unnecessary_wraps)] -pub(crate) fn elevation_check() -> Result { +pub fn elevation_check() -> Result { Ok(nix::unistd::getuid().is_root()) } -pub(crate) fn install_ipc_service() -> Result<()> { +pub fn install() -> Result<()> { bail!("`install_ipc_service` not implemented and not needed on Linux") } diff --git a/rust/gui-client/src-tauri/src/service/macos.rs b/rust/gui-client/src-tauri/src/service/macos.rs new file mode 100644 index 000000000..7474b6dd3 --- /dev/null +++ b/rust/gui-client/src-tauri/src/service/macos.rs @@ -0,0 +1,18 @@ +use anyhow::{Result, bail}; +use firezone_bin_shared::DnsControlMethod; +use std::path::PathBuf; + +pub fn run(log_dir: Option, _dns_control: DnsControlMethod) -> Result<()> { + // We call this here to avoid a dead-code warning. + let (_handle, _log_filter_reloader) = crate::logging::setup_ipc(log_dir)?; + + bail!("not implemented") +} + +pub fn elevation_check() -> Result { + bail!("not implemented") +} + +pub fn install() -> Result<()> { + bail!("not implemented") +} diff --git a/rust/headless-client/src/ipc_service/windows.rs b/rust/gui-client/src-tauri/src/service/windows.rs similarity index 98% rename from rust/headless-client/src/ipc_service/windows.rs rename to rust/gui-client/src-tauri/src/service/windows.rs index c9343143f..6ab7763c5 100644 --- a/rust/headless-client/src/ipc_service/windows.rs +++ b/rust/gui-client/src-tauri/src/service/windows.rs @@ -1,9 +1,9 @@ -use crate::CliCommon; use anyhow::{Context as _, Result, bail}; use firezone_bin_shared::DnsControlMethod; use firezone_logging::FilterReloadHandle; use firezone_telemetry::Telemetry; use futures::channel::mpsc; +use std::path::PathBuf; use std::{ ffi::{OsStr, OsString, c_void}, mem::size_of, @@ -33,7 +33,7 @@ const SERVICE_NAME: &str = "firezone_client_ipc"; const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; /// Returns true if the IPC service can run properly -pub(crate) fn elevation_check() -> Result { +pub fn elevation_check() -> Result { let token = ProcessToken::our_process().context("Failed to get process token")?; let elevated = token .is_elevated() @@ -148,7 +148,7 @@ impl Drop for ProcessToken { } } -pub(crate) fn install_ipc_service() -> Result<()> { +pub fn install() -> Result<()> { let manager_access = ServiceManagerAccess::CONNECT | ServiceManagerAccess::CREATE_SERVICE; let service_manager = ServiceManager::local_computer(None::<&str>, manager_access)?; @@ -190,7 +190,7 @@ fn uninstall_ipc_service(service_manager: &ServiceManager, name: impl AsRef Result<()> { +pub fn run(_log_dir: Option, _dns_control: DnsControlMethod) -> Result<()> { windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_run).context("windows_service::service_dispatcher failed. This isn't running in an interactive terminal, right?") } @@ -201,7 +201,7 @@ fn service_run(arguments: Vec) { // `arguments` doesn't seem to work right when running as a Windows service // (even though it's meant for that) so just use the default log dir. let (handle, log_filter_reloader) = - super::setup_logging(None).expect("Should be able to set up logging"); + crate::logging::setup_ipc(None).expect("Should be able to set up logging"); if let Err(error) = fallible_service_run(arguments, handle, log_filter_reloader) { tracing::error!("`fallible_windows_service_run` returned an error: {error:#}"); } diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index d0350073c..9895a018b 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -9,7 +9,6 @@ license = { workspace = true } [dependencies] anyhow = { workspace = true } -atomicwrites = { workspace = true } # Needed to safely backup `/etc/resolv.conf` backoff = { workspace = true } clap = { workspace = true, features = ["derive", "env", "string"] } connlib-client-shared = { workspace = true } @@ -21,60 +20,27 @@ firezone-telemetry = { workspace = true } futures = { workspace = true } humantime = { workspace = true } ip-packet = { workspace = true } -ip_network = { workspace = true } opentelemetry = { workspace = true, features = ["metrics"] } opentelemetry-stdout = { workspace = true, features = ["metrics"] } opentelemetry_sdk = { workspace = true, features = ["rt-tokio"] } phoenix-channel = { workspace = true } rustls = { workspace = true } secrecy = { workspace = true } -serde = { workspace = true, features = ["derive"] } -serde_json = { workspace = true } -serde_variant = { workspace = true } -strum = { workspace = true } -thiserror = { workspace = true } # This actually relies on many other features in Tokio, so this will probably # fail to build outside the workspace. tokio = { workspace = true, features = ["macros", "signal", "process", "time", "fs", "rt"] } tokio-stream = { workspace = true } -tokio-util = { workspace = true, features = ["codec"] } tracing = { workspace = true } tracing-subscriber = { workspace = true, features = ["env-filter"] } url = { workspace = true } -uuid = { workspace = true, features = ["std", "v4", "serde"] } - -[dev-dependencies] -tempfile = { workspace = true } [target.'cfg(target_os = "linux")'.dependencies] libc = { workspace = true } nix = { workspace = true, features = ["fs", "user", "socket"] } -resolv-conf = { workspace = true } -rtnetlink = { workspace = true } -sd-notify = "0.4.5" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 +sd-notify = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] -ipconfig = "0.3.2" -itertools = { workspace = true } known-folders = { workspace = true } -windows-service = "0.8.0" -winreg = { workspace = true } - -[target.'cfg(windows)'.dependencies.windows] -workspace = true -features = [ - # For DNS control and route control - "Win32_Foundation", - "Win32_NetworkManagement_IpHelper", - "Win32_NetworkManagement_Ndis", - "Win32_Networking_WinSock", - - "Win32_Security", # For named pipe IPC - "Win32_System_GroupPolicy", # For NRPT when GPO is used - "Win32_System_SystemInformation", # For uptime - "Win32_System_SystemServices", - "Win32_System_Pipes", -] [lints] workspace = true diff --git a/rust/headless-client/src/ipc_service/ipc.rs b/rust/headless-client/src/ipc_service/ipc.rs deleted file mode 100644 index e87808719..000000000 --- a/rust/headless-client/src/ipc_service/ipc.rs +++ /dev/null @@ -1,279 +0,0 @@ -use crate::{IpcClientMsg, IpcServerMsg}; -use anyhow::{Context as _, Result}; -use tokio::io::{ReadHalf, WriteHalf}; -use tokio_util::{ - bytes::BytesMut, - codec::{FramedRead, FramedWrite, LengthDelimitedCodec}, -}; - -#[cfg(target_os = "linux")] -#[path = "ipc/linux.rs"] -mod platform; - -#[cfg(target_os = "windows")] -#[path = "ipc/windows.rs"] -pub mod platform; - -#[cfg(target_os = "macos")] -#[path = "ipc/macos.rs"] -pub mod platform; - -pub(crate) use platform::Server; -use platform::{ClientStream, ServerStream}; - -pub type ClientRead = FramedRead, Decoder>; -pub type ClientWrite = FramedWrite, Encoder>; -pub(crate) type ServerRead = FramedRead, Decoder>; -pub(crate) type ServerWrite = FramedWrite, Encoder>; - -#[derive(Debug, thiserror::Error)] -#[error("Couldn't find IPC service `{0}`")] -pub struct NotFound(String); - -/// A name that both the server and client can use to find each other -/// -/// In the platform-specific code, this is translated to a Unix Domain Socket -/// path on Linux, and a named pipe name on Windows. -/// These have different restrictions on naming. -/// -/// UDS are mostly like normal -/// files, so for production we want them in `/run/dev.firezone.client`, which -/// systemd will create for us, and the Client can trust no other service -/// will impersonate that path. For tests we want them in `/run/user/$UID/`, -/// which we can use without root privilege. -/// -/// Named pipes are not part of the normal file hierarchy, they can only -/// have 2 or 3 slashes in them, and we don't distinguish yet between -/// privileged and non-privileged named pipes. Windows is slowly rolling out -/// UDS support, so in a year or two we might be better off making it UDS -/// on all platforms. -/// -/// Because the paths are so different (and Windows actually uses a `String`), -/// we have this `ServiceId` abstraction instead of just a `PathBuf`. -#[derive(Clone, Copy)] -pub enum ServiceId { - /// The IPC service used by Firezone GUI Client in production - /// - /// This must go in `/run/dev.firezone.client` on Linux, which requires - /// root permission - Prod, - /// An IPC service used for unit tests. - /// - /// This must go in `/run/user/$UID/dev.firezone.client` on Linux so - /// the unit tests won't need root. - /// - /// Includes an ID so that multiple tests can - /// run in parallel. - /// - /// The ID should have A-Z, 0-9 only, no dots or slashes, because of Windows named pipes name restrictions. - Test(&'static str), -} - -pub struct Decoder { - inner: LengthDelimitedCodec, - _decode_type: std::marker::PhantomData, -} - -pub struct Encoder { - inner: LengthDelimitedCodec, - _encode_type: std::marker::PhantomData, -} - -impl Default for Decoder { - fn default() -> Self { - Self { - inner: LengthDelimitedCodec::new(), - _decode_type: Default::default(), - } - } -} - -impl Default for Encoder { - fn default() -> Self { - Self { - inner: LengthDelimitedCodec::new(), - _encode_type: Default::default(), - } - } -} - -impl tokio_util::codec::Decoder for Decoder { - type Error = anyhow::Error; - type Item = D; - - fn decode(&mut self, buf: &mut BytesMut) -> Result> { - let Some(msg) = self.inner.decode(buf)? else { - return Ok(None); - }; - let msg = serde_json::from_slice(&msg) - .with_context(|| format!("Error while deserializing {}", std::any::type_name::()))?; - Ok(Some(msg)) - } -} - -impl tokio_util::codec::Encoder<&E> for Encoder { - type Error = anyhow::Error; - - fn encode(&mut self, msg: &E, buf: &mut BytesMut) -> Result<()> { - let msg = serde_json::to_string(msg)?; - self.inner.encode(msg.into(), buf)?; - Ok(()) - } -} - -/// Connect to the IPC service -/// -/// Public because the GUI Client will need it -pub async fn connect_to_service(id: ServiceId) -> Result<(ClientRead, ClientWrite)> { - // This is how ChatGPT recommended, and I couldn't think of any more clever - // way before I asked it. - let mut last_err = None; - - for _ in 0..10 { - match platform::connect_to_service(id).await { - Ok(stream) => { - let (rx, tx) = tokio::io::split(stream); - let rx = FramedRead::new(rx, Decoder::default()); - let tx = FramedWrite::new(tx, Encoder::default()); - return Ok((rx, tx)); - } - Err(error) => { - tracing::debug!("Couldn't connect to IPC service: {error}"); - last_err = Some(error); - - // This won't come up much for humans but it helps the automated - // tests pass - tokio::time::sleep(std::time::Duration::from_millis(100)).await; - } - } - } - Err(last_err.expect("Impossible - Exhausted all retries but didn't get any errors")) -} - -impl platform::Server { - pub(crate) async fn next_client_split(&mut self) -> Result<(ServerRead, ServerWrite)> { - let (rx, tx) = tokio::io::split(self.next_client().await?); - let rx = FramedRead::new(rx, Decoder::default()); - let tx = FramedWrite::new(tx, Encoder::default()); - Ok((rx, tx)) - } -} - -#[cfg(test)] -mod tests { - use super::{platform::Server, *}; - use anyhow::{Result, bail, ensure}; - use futures::{SinkExt, StreamExt}; - use std::time::Duration; - use tokio::{task::JoinHandle, time::timeout}; - - #[tokio::test] - async fn no_such_service() -> Result<()> { - let _guard = firezone_logging::test("trace"); - const ID: ServiceId = ServiceId::Test("H56FRXVH"); - - if super::connect_to_service(ID).await.is_ok() { - bail!("`connect_to_service` should have failed for a non-existent service"); - } - Ok(()) - } - - /// Make sure the IPC client and server can exchange messages - #[tokio::test] - async fn smoke() -> Result<()> { - let _guard = firezone_logging::test("trace"); - let loops = 10; - const ID: ServiceId = ServiceId::Test("OB5SZCGN"); - - let mut server = Server::new(ID) - .await - .expect("Error while starting IPC server"); - - let server_task: tokio::task::JoinHandle> = tokio::spawn(async move { - for _ in 0..loops { - let (mut rx, mut tx) = server - .next_client_split() - .await - .expect("Error while waiting for next IPC client"); - while let Some(req) = rx.next().await { - let req = req.expect("Error while reading from IPC client"); - ensure!(req == IpcClientMsg::Reset); - tx.send(&IpcServerMsg::OnUpdateResources(vec![])) - .await - .expect("Error while writing to IPC client"); - } - tracing::info!("Client disconnected"); - } - Ok(()) - }); - - let client_task: JoinHandle> = tokio::spawn(async move { - for _ in 0..loops { - let (mut rx, mut tx) = super::connect_to_service(ID) - .await - .context("Error while connecting to IPC server")?; - - let req = IpcClientMsg::Reset; - for _ in 0..10 { - tx.send(&req) - .await - .expect("Error while writing to IPC server"); - let resp = rx - .next() - .await - .expect("Should have gotten a reply from the IPC server") - .expect("Error while reading from IPC server"); - ensure!(matches!(resp, IpcServerMsg::OnUpdateResources(_))); - } - } - Ok(()) - }); - - let client_result = client_task.await; - match &client_result { - Err(panic) => { - tracing::error!(?panic, "Client panic"); - } - Ok(Err(error)) => { - tracing::error!("Client error: {error:#}"); - } - _ => (), - } - - let server_result = server_task.await; - match &server_result { - Err(panic) => { - tracing::error!(?panic, "Server panic"); - } - Ok(Err(error)) => { - tracing::error!("Server error: {error:#}"); - } - _ => (), - } - - if client_result.is_err() || server_result.is_err() { - anyhow::bail!("Something broke."); - } - Ok(()) - } - - /// Replicate #5143 - /// - /// When the IPC service has disconnected from a GUI and loops over, sometimes - /// the named pipe is not ready. If our IPC code doesn't handle this right, - /// this test will fail. - #[tokio::test] - async fn loop_to_next_client() -> Result<()> { - let _guard = firezone_logging::test("trace"); - - let mut server = Server::new(ServiceId::Test("H6L73DG5")).await?; - for i in 0..5 { - if let Ok(Err(err)) = timeout(Duration::from_secs(1), server.next_client()).await { - Err(err).with_context(|| { - format!("Couldn't listen for next IPC client, iteration {i}") - })?; - } - } - Ok(()) - } -} diff --git a/rust/headless-client/src/ipc_service/macos.rs b/rust/headless-client/src/ipc_service/macos.rs deleted file mode 100644 index 8e3795561..000000000 --- a/rust/headless-client/src/ipc_service/macos.rs +++ /dev/null @@ -1,17 +0,0 @@ -use super::CliCommon; -use anyhow::{Result, bail}; - -pub(crate) fn run_ipc_service(cli: CliCommon) -> Result<()> { - // We call this here to avoid a dead-code warning. - let (_handle, _log_filter_reloader) = super::setup_logging(cli.log_dir)?; - - bail!("not implemented") -} - -pub(crate) fn elevation_check() -> Result { - bail!("not implemented") -} - -pub(crate) fn install_ipc_service() -> Result<()> { - bail!("not implemented") -} diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs deleted file mode 100644 index 5913f8fb9..000000000 --- a/rust/headless-client/src/lib.rs +++ /dev/null @@ -1,182 +0,0 @@ -//! A library for the privileged tunnel process for a Linux Firezone Client -//! -//! This is built both standalone and as part of the GUI package. Building it -//! standalone is faster and skips all the GUI dependencies. We can use that build for -//! CLI use cases. -//! -//! Building it as a binary within the `gui-client` package allows the -//! Tauri deb bundler to pick it up easily. -//! Otherwise we would just make it a normal binary crate. - -#![cfg_attr(test, allow(clippy::unwrap_used))] - -use anyhow::{Context as _, Result}; -use connlib_client_shared::Callbacks; -use connlib_model::ResourceView; -use dns_types::DomainName; -use firezone_bin_shared::DnsControlMethod; -use firezone_logging::FilterReloadHandle; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - path::PathBuf, -}; -use tokio::sync::mpsc; -use tracing_subscriber::{EnvFilter, Layer as _, Registry, fmt, layer::SubscriberExt as _}; - -mod clear_logs; -mod ipc_service; - -pub use clear_logs::clear_logs; -pub use ipc_service::{ - ClientMsg as IpcClientMsg, Error as IpcServiceError, ServerMsg as IpcServerMsg, ipc, - run_only_ipc_service, -}; - -use ip_network::{Ipv4Network, Ipv6Network}; - -/// Only used on Linux -pub const FIREZONE_GROUP: &str = "firezone-client"; - -/// CLI args common to both the IPC service and the headless Client -#[derive(clap::Parser)] -pub struct CliCommon { - #[cfg(target_os = "linux")] - #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "systemd-resolved")] - pub dns_control: DnsControlMethod, - - #[cfg(target_os = "windows")] - #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "nrpt")] - pub dns_control: DnsControlMethod, - - #[cfg(target_os = "macos")] - #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "none")] - pub dns_control: DnsControlMethod, - - /// File logging directory. Should be a path that's writeable by the current user. - #[arg(short, long, env = "LOG_DIR")] - pub log_dir: Option, - - /// Maximum length of time to retry connecting to the portal if we're having internet issues or - /// it's down. Accepts human times. e.g. "5m" or "1h" or "30d". - #[arg(short, long, env = "MAX_PARTITION_TIME")] - pub max_partition_time: Option, -} - -/// Messages that connlib can produce and send to the headless Client, IPC service, or GUI process. -/// -/// i.e. callbacks -// The names are CamelCase versions of the connlib callbacks. -#[expect(clippy::enum_variant_names)] -pub enum ConnlibMsg { - OnDisconnect { - error_msg: String, - is_authentication_error: bool, - }, - /// Use this as `TunnelReady`, per `callbacks.rs` - OnSetInterfaceConfig { - ipv4: Ipv4Addr, - ipv6: Ipv6Addr, - dns: Vec, - search_domain: Option, - ipv4_routes: Vec, - ipv6_routes: Vec, - }, - OnUpdateResources(Vec), -} - -#[derive(Clone)] -pub struct CallbackHandler { - pub cb_tx: mpsc::Sender, -} - -impl Callbacks for CallbackHandler { - fn on_disconnect(&self, error: connlib_client_shared::DisconnectError) { - self.cb_tx - .try_send(ConnlibMsg::OnDisconnect { - error_msg: error.to_string(), - is_authentication_error: error.is_authentication_error(), - }) - .expect("should be able to send OnDisconnect"); - } - - fn on_set_interface_config( - &self, - ipv4: Ipv4Addr, - ipv6: Ipv6Addr, - dns: Vec, - search_domain: Option, - ipv4_routes: Vec, - ipv6_routes: Vec, - ) { - self.cb_tx - .try_send(ConnlibMsg::OnSetInterfaceConfig { - ipv4, - ipv6, - dns, - search_domain, - ipv4_routes, - ipv6_routes, - }) - .expect("Should be able to send OnSetInterfaceConfig"); - } - - fn on_update_resources(&self, resources: Vec) { - tracing::debug!(len = resources.len(), "New resource list"); - self.cb_tx - .try_send(ConnlibMsg::OnUpdateResources(resources)) - .expect("Should be able to send OnUpdateResources"); - } -} - -/// Sets up logging for stdout only, with INFO level by default -pub fn setup_stdout_logging() -> Result { - let directives = get_log_filter().context("Can't read log filter")?; - let (filter, reloader) = firezone_logging::try_filter(&directives)?; - let layer = fmt::layer() - .event_format(firezone_logging::Format::new()) - .with_filter(filter); - let subscriber = Registry::default().with(layer); - firezone_logging::init(subscriber)?; - - Ok(reloader) -} - -/// Reads the log filter for the IPC service or for debug commands -/// -/// e.g. `info` -/// -/// Reads from: -/// 1. `RUST_LOG` env var -/// 2. `known_dirs::ipc_log_filter()` file -/// 3. Hard-coded default `SERVICE_RUST_LOG` -/// -/// Errors if something is badly wrong, e.g. the directory for the config file -/// can't be computed -pub(crate) fn get_log_filter() -> Result { - #[cfg(not(debug_assertions))] - const DEFAULT_LOG_FILTER: &str = "info"; - #[cfg(debug_assertions)] - const DEFAULT_LOG_FILTER: &str = "debug"; - - if let Ok(filter) = std::env::var(EnvFilter::DEFAULT_ENV) { - return Ok(filter); - } - - if let Ok(filter) = std::fs::read_to_string(firezone_bin_shared::known_dirs::ipc_log_filter()?) - .map(|s| s.trim().to_string()) - { - return Ok(filter); - } - - Ok(DEFAULT_LOG_FILTER.to_string()) -} - -#[cfg(test)] -mod tests { - use super::*; - // Make sure it's okay to store a bunch of these to mitigate #5880 - #[test] - fn callback_msg_size() { - assert_eq!(std::mem::size_of::(), 120) - } -} diff --git a/rust/headless-client/src/main.rs b/rust/headless-client/src/main.rs index 50bada880..cb6a8b45a 100644 --- a/rust/headless-client/src/main.rs +++ b/rust/headless-client/src/main.rs @@ -5,14 +5,13 @@ use anyhow::{Context as _, Result, anyhow}; use backoff::ExponentialBackoffBuilder; use clap::Parser; -use connlib_client_shared::Session; +use connlib_client_shared::{ChannelCallbackHandler, ConnlibMsg, Session}; use firezone_bin_shared::{ - DnsController, TOKEN_ENV_KEY, TunDeviceManager, device_id, device_info, new_dns_notifier, - new_network_notifier, + DnsControlMethod, DnsController, TOKEN_ENV_KEY, TunDeviceManager, device_id, device_info, + new_dns_notifier, new_network_notifier, platform::{tcp_socket_factory, udp_socket_factory}, signals, }; -use firezone_headless_client::{CallbackHandler, CliCommon, ConnlibMsg}; use firezone_logging::telemetry_span; use firezone_telemetry::Telemetry; use firezone_telemetry::otel; @@ -26,7 +25,7 @@ use std::{ path::{Path, PathBuf}, sync::Arc, }; -use tokio::{sync::mpsc, time::Instant}; +use tokio::time::Instant; use tokio_stream::wrappers::ReceiverStream; #[cfg(target_os = "linux")] @@ -41,8 +40,6 @@ mod platform; #[path = "macos.rs"] mod platform; -use platform::default_token_path; - /// Command-line args for the headless Client #[derive(Parser)] #[command(author, version, about, long_about = None)] @@ -52,8 +49,26 @@ struct Cli { #[command(subcommand)] _command: Option, - #[command(flatten)] - common: CliCommon, + #[cfg(target_os = "linux")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "systemd-resolved")] + dns_control: DnsControlMethod, + + #[cfg(target_os = "windows")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "nrpt")] + dns_control: DnsControlMethod, + + #[cfg(target_os = "macos")] + #[arg(long, env = "FIREZONE_DNS_CONTROL", default_value = "none")] + dns_control: DnsControlMethod, + + /// File logging directory. Should be a path that's writeable by the current user. + #[arg(short, long, env = "LOG_DIR")] + log_dir: Option, + + /// Maximum length of time to retry connecting to the portal if we're having internet issues or + /// it's down. Accepts human times. e.g. "5m" or "1h" or "30d". + #[arg(short, long, env = "MAX_PARTITION_TIME")] + max_partition_time: Option, #[arg( short = 'u', @@ -100,7 +115,7 @@ struct Cli { // until anyone asks for it, env vars are okay and files on disk are slightly better. // (Since we run as root and the env var on a headless system is probably stored // on disk somewhere anyway.) - #[arg(default_value = default_token_path().display().to_string(), env = "FIREZONE_TOKEN_PATH", long)] + #[arg(default_value = platform::default_token_path().display().to_string(), env = "FIREZONE_TOKEN_PATH", long)] token_path: PathBuf, } @@ -144,7 +159,6 @@ fn main() -> Result<()> { // TODO: This might have the same issue with fatal errors not getting logged // as addressed for the IPC service in PR #5216 let (layer, _handle) = cli - .common .log_dir .as_deref() .map(|dir| firezone_logging::file::layer(dir, "firezone-headless-client")) @@ -153,7 +167,7 @@ fn main() -> Result<()> { // Deactivate DNS control before starting telemetry or connecting to the portal, // in case a previous run of Firezone left DNS control on and messed anything up. - let dns_control_method = cli.common.dns_control; + let dns_control_method = cli.dns_control; let mut dns_controller = DnsController { dns_control_method }; // Deactivate Firezone DNS control in case the system or IPC service crashed // and we need to recover. @@ -181,7 +195,7 @@ fn main() -> Result<()> { ) })?; // TODO: Should this default to 30 days? - let max_partition_time = cli.common.max_partition_time.map(|d| d.into()); + let max_partition_time = cli.max_partition_time.map(|d| d.into()); // AKA "Device ID", not the Firezone slug let firezone_id = match cli.firezone_id { @@ -207,8 +221,7 @@ fn main() -> Result<()> { return Ok(()); } - let (cb_tx, cb_rx) = mpsc::channel(1_000); - let callbacks = CallbackHandler { cb_tx }; + let (callbacks, cb_rx) = ChannelCallbackHandler::new(); // The name matches that in `ipc_service.rs` let mut last_connlib_start_instant = Some(Instant::now()); @@ -416,6 +429,6 @@ mod tests { let actual = Cli::try_parse_from([exe_name, "--check", "--log-dir", "bogus_log_dir"]).unwrap(); assert!(actual.check); - assert_eq!(actual.common.log_dir, Some(PathBuf::from("bogus_log_dir"))); + assert_eq!(actual.log_dir, Some(PathBuf::from("bogus_log_dir"))); } }