From 1ef775dee10418a03bb349baef441cb36edf8fc8 Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Mon, 20 May 2024 16:37:29 -0500 Subject: [PATCH] feat(windows-client): run the GUI and tunnel in separate processes (#4978) Ready for review. Closes #3712. Supersedes #4940. Refs #4963. I haven't figured out if it needs any new automated tests (unit, integration, etc.) but the code itself is ready for review. There is more refactoring that could be done, or could be left for later. ```[tasklist] - [x] Move wintun setup from GUI to IPC service / headless client - [x] Make sure the device ID is in a sensible place - [x] Export IPC service logs in the zips - [x] Test GUI + SC IPC service on Windows (f4db808919a passed) - [x] Make sure IPC service does not busy-loop - [x] Test un-install checklist for Windows - [x] Test upgrade checklist for Windows - [x] Test GUI + systemd IPC service on Linux (c4ab7e7 passed) - [x] Test upgrade checklist for Linux - [x] Test un-install checklist for Linux - [x] Make sure the IPC service logs out and deactivates DNS control if the GUI crashes - [x] Test network changing - [x] (it's intended behavior) ~~Look into spurious `on_update_resources` (fad86babd7)~~ - [x] ~~Test max partition time on offline laptop~~ (I ended up just setting a 30-day default in the code) - [x] Make sure headless Client does not busy-loop - [x] Test standalone headless on Linux - [ ] Add unit / integration tests - [ ] Think about security a bit #3971 ``` --------- Signed-off-by: Reactor Scram Co-authored-by: Jamil --- rust/Cargo.lock | 4 +- rust/connlib/clients/shared/src/lib.rs | 3 +- rust/gui-client/src-tauri/Cargo.toml | 7 +- .../src-tauri/src/bin/firezone-client-ipc.rs | 2 +- rust/gui-client/src-tauri/src/client.rs | 19 +- .../src-tauri/src/client/debug_commands.rs | 2 +- .../src-tauri/src/client/deep_link/linux.rs | 2 +- .../src-tauri/src/client/deep_link/windows.rs | 66 +-- .../src-tauri/src/client/elevation.rs | 89 +--- rust/gui-client/src-tauri/src/client/gui.rs | 24 +- rust/gui-client/src-tauri/src/client/ipc.rs | 76 +++ .../src-tauri/src/client/ipc/linux.rs | 82 +++ .../src-tauri/src/client/ipc/macos.rs | 26 + .../src-tauri/src/client/ipc/windows.rs | 117 +++++ .../src-tauri/src/client/logging.rs | 43 +- .../src-tauri/src/client/resolvers.rs | 4 +- .../src-tauri/src/client/settings.rs | 4 +- .../src/client/tunnel-wrapper/in_proc.rs | 128 ----- .../src/client/tunnel-wrapper/ipc.rs | 147 ------ rust/headless-client/Cargo.toml | 16 +- rust/headless-client/src/imp_windows.rs | 192 ------- rust/headless-client/src/known_dirs.rs | 39 +- rust/headless-client/src/lib.rs | 88 +++- .../src/{imp_linux.rs => linux.rs} | 25 +- rust/headless-client/src/windows.rs | 485 ++++++++++++++++++ .../src/windows}/wintun/README.md | 0 .../src/windows}/wintun/bin/amd64/wintun.dll | Bin .../src/windows}/wintun/bin/arm64/wintun.dll | Bin .../src/windows}/wintun_install.rs | 4 +- 29 files changed, 976 insertions(+), 718 deletions(-) create mode 100644 rust/gui-client/src-tauri/src/client/ipc.rs create mode 100644 rust/gui-client/src-tauri/src/client/ipc/linux.rs create mode 100644 rust/gui-client/src-tauri/src/client/ipc/macos.rs create mode 100644 rust/gui-client/src-tauri/src/client/ipc/windows.rs delete mode 100644 rust/gui-client/src-tauri/src/client/tunnel-wrapper/in_proc.rs delete mode 100644 rust/gui-client/src-tauri/src/client/tunnel-wrapper/ipc.rs delete mode 100644 rust/headless-client/src/imp_windows.rs rename rust/headless-client/src/{imp_linux.rs => linux.rs} (94%) create mode 100644 rust/headless-client/src/windows.rs rename rust/{gui-client => headless-client/src/windows}/wintun/README.md (100%) rename rust/{gui-client => headless-client/src/windows}/wintun/bin/amd64/wintun.dll (100%) rename rust/{gui-client => headless-client/src/windows}/wintun/bin/arm64/wintun.dll (100%) rename rust/{gui-client/src-tauri/src/client => headless-client/src/windows}/wintun_install.rs (95%) diff --git a/rust/Cargo.lock b/rust/Cargo.lock index e2937a2df..4e92d9f14 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -1944,7 +1944,6 @@ dependencies = [ "output_vt100", "rand 0.8.5", "reqwest", - "ring", "sadness-generator", "secrecy", "semver", @@ -1990,15 +1989,18 @@ dependencies = [ "known-folders", "nix 0.28.0", "resolv-conf", + "ring", "sd-notify", "secrecy", "serde", "serde_json", + "thiserror", "tokio", "tokio-util", "tracing", "tracing-subscriber", "url", + "windows 0.56.0", "windows-service", ] diff --git a/rust/connlib/clients/shared/src/lib.rs b/rust/connlib/clients/shared/src/lib.rs index 1e4f31f18..8fdbfbd03 100644 --- a/rust/connlib/clients/shared/src/lib.rs +++ b/rust/connlib/clients/shared/src/lib.rs @@ -193,7 +193,8 @@ mod tests { async fn device_windows() { // Install wintun so the test can run // CI only needs x86_64 for now - let wintun_bytes = include_bytes!("../../../../gui-client/wintun/bin/amd64/wintun.dll"); + let wintun_bytes = + include_bytes!("../../../../headless-client/src/windows/wintun/bin/amd64/wintun.dll"); let wintun_path = connlib_shared::windows::wintun_dll_path().unwrap(); tokio::fs::create_dir_all(wintun_path.parent().unwrap()) .await diff --git a/rust/gui-client/src-tauri/Cargo.toml b/rust/gui-client/src-tauri/Cargo.toml index 9cfd7e62c..a7051c42e 100644 --- a/rust/gui-client/src-tauri/Cargo.toml +++ b/rust/gui-client/src-tauri/Cargo.toml @@ -32,7 +32,6 @@ native-dialog = "0.7.0" output_vt100 = "0.1" rand = "0.8.5" reqwest = { version = "0.12.4", default-features = false, features = ["stream", "rustls-tls"] } -ring = "0.17" sadness-generator = "0.5.0" secrecy = { workspace = true } serde = { version = "1.0", features = ["derive"] } @@ -44,6 +43,7 @@ tauri-runtime = "0.14.2" tauri-utils = "1.5.3" thiserror = { version = "1.0", default-features = false } tokio = { version = "1.36.0", features = ["signal", "time", "macros", "rt", "rt-multi-thread"] } +tokio-util = { version = "0.7.11", features = ["codec"] } tracing = { workspace = true } tracing-log = "0.2" tracing-panic = "0.1.2" @@ -55,7 +55,6 @@ zip = { version = "1.2.3", features = ["deflate", "time"], default-features = fa [target.'cfg(target_os = "linux")'.dependencies] dirs = "5.0.1" nix = { version = "0.28.0", features = ["user"] } -tokio-util = { version = "0.7.11", features = ["codec"] } [target.'cfg(target_os = "macos")'.dependencies] @@ -74,15 +73,11 @@ features = [ "Win32_Foundation", # For listening for network change events "Win32_Networking_NetworkListManager", - # For deep_link module - "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", - # For deep_link module - "Win32_System_SystemServices", ] [features] 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 7e0a2fc12..b5fbcb570 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,3 +1,3 @@ fn main() -> anyhow::Result<()> { - firezone_headless_client::imp::run_only_ipc_service() + firezone_headless_client::run_only_ipc_service() } diff --git a/rust/gui-client/src-tauri/src/client.rs b/rust/gui-client/src-tauri/src/client.rs index a47ef8f8b..29b56411c 100644 --- a/rust/gui-client/src-tauri/src/client.rs +++ b/rust/gui-client/src-tauri/src/client.rs @@ -1,4 +1,4 @@ -use anyhow::Result; +use anyhow::{bail, Result}; use clap::{Args, Parser}; use firezone_headless_client::FIREZONE_GROUP; use std::path::PathBuf; @@ -10,6 +10,7 @@ mod debug_commands; mod deep_link; mod elevation; mod gui; +mod ipc; mod logging; mod network_changes; mod resolvers; @@ -18,9 +19,6 @@ mod updates; mod uptime; mod welcome; -#[cfg(target_os = "windows")] -mod wintun_install; - /// Output of `git describe` at compile time /// e.g. `1.0.0-pre.4-20-ged5437c88-modified` where: /// @@ -66,14 +64,10 @@ pub(crate) fn run() -> Result<()> { match cli.command { None => { - match elevation::check() { - // We're already elevated, just run the GUI + match elevation::is_normal_user() { + // Our elevation is correct (not elevated), just run the GUI Ok(true) => run_gui(cli), - Ok(false) => { - // We're not elevated, ask Powershell to re-launch us, then exit. On Linux this is completely different. - elevation::elevate()?; - Ok(()) - } + Ok(false) => bail!("The GUI should run as a normal user, not elevated"), Err(error) => { show_error_dialog(&error)?; Err(error.into()) @@ -92,8 +86,7 @@ pub(crate) fn run() -> Result<()> { Ok(()) } Some(Cmd::SmokeTest) => { - // Check for elevation. This also ensures wintun.dll is installed. - if !elevation::check()? { + if !elevation::is_normal_user()? { anyhow::bail!("`smoke-test` failed its elevation check"); } diff --git a/rust/gui-client/src-tauri/src/client/debug_commands.rs b/rust/gui-client/src-tauri/src/client/debug_commands.rs index 50cff36cf..4ac6dac56 100644 --- a/rust/gui-client/src-tauri/src/client/debug_commands.rs +++ b/rust/gui-client/src-tauri/src/client/debug_commands.rs @@ -26,7 +26,7 @@ pub fn run(cmd: Cmd) -> Result<()> { } fn check_for_updates() -> Result<()> { - client::logging::debug_command_setup()?; + firezone_headless_client::debug_command_setup()?; let rt = tokio::runtime::Runtime::new().unwrap(); let version = rt.block_on(client::updates::check())?; diff --git a/rust/gui-client/src-tauri/src/client/deep_link/linux.rs b/rust/gui-client/src-tauri/src/client/deep_link/linux.rs index cb5921396..220e39faa 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link/linux.rs +++ b/rust/gui-client/src-tauri/src/client/deep_link/linux.rs @@ -81,7 +81,7 @@ impl Server { } pub(crate) async fn open(url: &url::Url) -> Result<()> { - crate::client::logging::debug_command_setup()?; + firezone_headless_client::debug_command_setup()?; let path = sock_path()?; let mut stream = UnixStream::connect(&path).await?; diff --git a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs b/rust/gui-client/src-tauri/src/client/deep_link/windows.rs index 1d030c1d7..fe998e4d9 100644 --- a/rust/gui-client/src-tauri/src/client/deep_link/windows.rs +++ b/rust/gui-client/src-tauri/src/client/deep_link/windows.rs @@ -5,9 +5,8 @@ use super::FZ_SCHEME; use anyhow::{Context, Result}; use connlib_shared::BUNDLE_ID; use secrecy::Secret; -use std::{ffi::c_void, io, path::Path}; +use std::{io, path::Path}; use tokio::{io::AsyncReadExt, io::AsyncWriteExt, net::windows::named_pipe}; -use windows::Win32::Security as WinSec; /// A server for a named pipe, so we can receive deep links from other instances /// of the client launched by web browsers @@ -32,40 +31,8 @@ impl Server { let mut server_options = named_pipe::ServerOptions::new(); server_options.first_pipe_instance(true); - // This will allow non-admin clients to connect to us even if we're running as admin - let mut sd = WinSec::SECURITY_DESCRIPTOR::default(); - let psd = WinSec::PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut c_void); - // SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading - // or lifetime problems because we only pass pointers to our local vars to - // Win32, and Win32 shouldn't save them anywhere. - unsafe { - // ChatGPT pointed me to these functions, it's better than the official MS docs - WinSec::InitializeSecurityDescriptor( - psd, - windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION, - ) - .context("InitializeSecurityDescriptor failed")?; - WinSec::SetSecurityDescriptorDacl(psd, true, None, false) - .context("SetSecurityDescriptorDacl failed")?; - } - - let mut sa = WinSec::SECURITY_ATTRIBUTES { - // TODO: Try `size_of_val` here instead - nLength: std::mem::size_of::() - .try_into() - .unwrap(), - lpSecurityDescriptor: psd.0, - bInheritHandle: false.into(), - }; - - // TODO: On the IPC branch I found that this will cause prefix issues - // with other named pipes. Change it. - let path = named_pipe_path(BUNDLE_ID); - let sa_ptr = &mut sa as *mut _ as *mut c_void; - // SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading - // or lifetime problems because we only pass pointers to our local vars to - // Win32, and Win32 shouldn't save them anywhere. - let server = unsafe { server_options.create_with_security_attributes_raw(path, sa_ptr) } + let server = server_options + .create(pipe_path()) .map_err(|_| super::Error::CantListen)?; tracing::debug!("server is bound"); @@ -101,9 +68,8 @@ impl Server { /// Open a deep link by sending it to the already-running instance of the app pub async fn open(url: &url::Url) -> Result<()> { - let path = named_pipe_path(BUNDLE_ID); let mut client = named_pipe::ClientOptions::new() - .open(path) + .open(pipe_path()) .context("Couldn't connect to named pipe server")?; client .write_all(url.as_str().as_bytes()) @@ -112,6 +78,10 @@ pub async fn open(url: &url::Url) -> Result<()> { Ok(()) } +fn pipe_path() -> String { + firezone_headless_client::platform::named_pipe_path(&format!("{BUNDLE_ID}.deep_link")) +} + /// Registers the current exe as the handler for our deep link scheme. /// /// This is copied almost verbatim from tauri-plugin-deep-link's `register` fn, with an improvement @@ -147,23 +117,3 @@ fn set_registry_values(id: &str, exe: &str) -> Result<(), io::Error> { Ok(()) } - -/// Returns a valid name for a Windows named pipe -/// -/// # Arguments -/// -/// * `id` - BUNDLE_ID, e.g. `dev.firezone.client` -fn named_pipe_path(id: &str) -> String { - format!(r"\\.\pipe\{}", id) -} - -#[cfg(test)] -mod tests { - #[test] - fn named_pipe_path() { - assert_eq!( - super::named_pipe_path("dev.firezone.client"), - r"\\.\pipe\dev.firezone.client" - ); - } -} diff --git a/rust/gui-client/src-tauri/src/client/elevation.rs b/rust/gui-client/src-tauri/src/client/elevation.rs index 3faeecf66..c67bbc095 100644 --- a/rust/gui-client/src-tauri/src/client/elevation.rs +++ b/rust/gui-client/src-tauri/src/client/elevation.rs @@ -1,12 +1,15 @@ -pub(crate) use imp::{check, elevate}; +pub(crate) use imp::is_normal_user; #[cfg(target_os = "linux")] mod imp { use crate::client::gui::Error; - use anyhow::{Context, Result}; + use anyhow::Context; + /// Returns true if we're running without root privileges + /// + /// On Linux we've already switched to IPC, so the process must NOT be elevated #[allow(clippy::print_stderr)] - pub(crate) fn check() -> Result { + pub(crate) fn is_normal_user() -> anyhow::Result { // Must use `eprintln` here because `tracing` won't be initialized yet. let user = std::env::var("USER").context("USER env var should be set")?; if user == "root" { @@ -14,7 +17,7 @@ mod imp { return Ok(false); } - let fz_gid = firezone_headless_client::imp::firezone_group()?.gid; + let fz_gid = firezone_headless_client::platform::firezone_group()?.gid; let groups = nix::unistd::getgroups().context("`nix::unistd::getgroups`")?; if !groups.contains(&fz_gid) { return Err(Error::UserNotInFirezoneGroup); @@ -22,87 +25,29 @@ mod imp { Ok(true) } - - pub(crate) fn elevate() -> Result<()> { - anyhow::bail!("Firezone must not elevate on Linux."); - } } // Stub only #[cfg(target_os = "macos")] mod imp { - use anyhow::Result; - - pub(crate) fn check() -> Result { + /// Placeholder for cargo check on macOS + pub(crate) fn is_normal_user() -> anyhow::Result { Ok(true) } - - pub(crate) fn elevate() -> Result<()> { - unimplemented!() - } } #[cfg(target_os = "windows")] mod imp { - use crate::client::{gui::Error, wintun_install}; - use anyhow::{Context, Result}; - use std::{os::windows::process::CommandExt, str::FromStr}; + use crate::client::gui::Error; - /// Check if we have elevated privileges, extract wintun.dll if needed. + // Returns true on Windows /// - /// Returns true if already elevated, false if not elevated, error if we can't be sure - pub(crate) fn check() -> Result { - // Almost the same as the code in tun_windows.rs in connlib - const TUNNEL_UUID: &str = "72228ef4-cb84-4ca5-a4e6-3f8636e75757"; - const TUNNEL_NAME: &str = "Firezone Elevation Check"; - - let path = match wintun_install::ensure_dll() { - Ok(x) => x, - Err(wintun_install::Error::PermissionDenied) => return Ok(false), - Err(e) => { - return Err(e) - .context("Failed to ensure wintun.dll is installed") - .map_err(Error::Other) - } - }; - - // SAFETY: Unsafe needed because we're loading a DLL from disk and it has arbitrary C code in it. - // `wintun_install::ensure_dll` checks the hash before we get here. This protects against accidental corruption, but not against attacks. (Because of TOCTOU) - let wintun = - unsafe { wintun::load_from_path(path) }.context("Failed to load wintun.dll")?; - let uuid = - uuid::Uuid::from_str(TUNNEL_UUID).context("Impossible: Hard-coded UUID is invalid")?; - - // Wintun hides the exact Windows error, so let's assume the only way Adapter::create can fail is if we're not elevated. - if wintun::Adapter::create(&wintun, "Firezone", TUNNEL_NAME, Some(uuid.as_u128())).is_err() - { - return Ok(false); - } + /// On Windows we are switching to IPC, and checking for elevation is complicated, + /// so it just always returns true. The Windows GUI does work correctly even if + /// elevated, so we should warn users that it doesn't need elevation, but it's + /// not a show-stopper if they accidentally "Run as admin". + #[allow(clippy::unnecessary_wraps)] + pub(crate) fn is_normal_user() -> anyhow::Result { Ok(true) } - - pub(crate) fn elevate() -> Result<()> { - /// Hides Powershell's console on Windows - /// - /// - const CREATE_NO_WINDOW: u32 = 0x08000000; - - let current_exe = tauri_utils::platform::current_exe()?; - if current_exe.display().to_string().contains('\"') { - anyhow::bail!("The exe path must not contain double quotes, it makes it hard to elevate with Powershell"); - } - std::process::Command::new("powershell") - .creation_flags(CREATE_NO_WINDOW) - .arg("-Command") - .arg("Start-Process") - .arg("-FilePath") - .arg(format!(r#""{}""#, current_exe.display())) - .arg("-Verb") - .arg("RunAs") - .arg("-ArgumentList") - .arg("elevated") - .spawn() - .context("Failed to elevate ourselves with `RunAs`")?; - Ok(()) - } } diff --git a/rust/gui-client/src-tauri/src/client/gui.rs b/rust/gui-client/src-tauri/src/client/gui.rs index c2eeae01a..83c4c6c2f 100644 --- a/rust/gui-client/src-tauri/src/client/gui.rs +++ b/rust/gui-client/src-tauri/src/client/gui.rs @@ -4,7 +4,9 @@ //! The real macOS Client is in `swift/apple` use crate::client::{ - self, about, deep_link, logging, network_changes, + self, about, deep_link, + ipc::{self, CallbackHandler}, + logging, network_changes, settings::{self, AdvancedSettings}, Failure, }; @@ -16,7 +18,6 @@ use std::{path::PathBuf, str::FromStr, sync::Arc, time::Duration}; use system_tray_menu::Event as TrayMenuEvent; use tauri::{Manager, SystemTray, SystemTrayEvent}; use tokio::sync::{mpsc, oneshot, Notify}; -use tunnel_wrapper::CallbackHandler; use url::Url; use ControllerRequest as Req; @@ -39,21 +40,6 @@ mod os; #[allow(clippy::unnecessary_wraps)] mod os; -// This syntax is odd, but it helps `cargo-mutants` understand the platform-specific modules -#[cfg(target_os = "windows")] -#[path = "tunnel-wrapper/in_proc.rs"] -mod tunnel_wrapper_in_proc; - -#[cfg(target_os = "linux")] -#[path = "tunnel-wrapper/ipc.rs"] -mod tunnel_wrapper_ipc; - -#[cfg(target_os = "windows")] -use tunnel_wrapper_in_proc as tunnel_wrapper; - -#[cfg(target_os = "linux")] -use tunnel_wrapper_ipc as tunnel_wrapper; - pub(crate) type CtlrTx = mpsc::Sender; /// All managed state that we might need to access from odd places like Tauri commands. @@ -495,7 +481,7 @@ struct Controller { /// Everything related to a signed-in user session struct Session { callback_handler: CallbackHandler, - connlib: tunnel_wrapper::TunnelWrapper, + connlib: ipc::Client, } impl Controller { @@ -518,7 +504,7 @@ impl Controller { "Calling connlib Session::connect" ); - let mut connlib = tunnel_wrapper::connect( + let mut connlib = ipc::Client::connect( api_url.as_str(), token, callback_handler.clone(), diff --git a/rust/gui-client/src-tauri/src/client/ipc.rs b/rust/gui-client/src-tauri/src/client/ipc.rs new file mode 100644 index 000000000..7ffb84200 --- /dev/null +++ b/rust/gui-client/src-tauri/src/client/ipc.rs @@ -0,0 +1,76 @@ +use crate::client::gui::{ControllerRequest, CtlrTx}; +use anyhow::{Context as _, Result}; +use arc_swap::ArcSwap; +use connlib_client_shared::callbacks::ResourceDescription; +use firezone_headless_client::IpcClientMsg; + +use std::{ + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + sync::Arc, +}; +use tokio::sync::Notify; + +pub(crate) use platform::Client; + +#[cfg(target_os = "linux")] +#[path = "ipc/linux.rs"] +mod platform; + +// Stub only +#[cfg(target_os = "macos")] +#[path = "ipc/macos.rs"] +mod platform; + +#[cfg(target_os = "windows")] +#[path = "ipc/windows.rs"] +mod platform; + +#[derive(Clone)] +pub(crate) struct CallbackHandler { + pub notify_controller: Arc, + pub ctlr_tx: CtlrTx, + pub resources: Arc>>, +} + +// Callbacks must all be non-blocking +impl connlib_client_shared::Callbacks for CallbackHandler { + fn on_disconnect(&self, error: &connlib_client_shared::Error) { + // The errors don't implement `Serialize`, so we don't get a machine-readable + // error here, but we should consider it an error anyway. `on_disconnect` + // is always an error + tracing::error!("on_disconnect {error:?}"); + self.ctlr_tx + .try_send(ControllerRequest::Disconnected) + .expect("controller channel failed"); + } + + fn on_set_interface_config(&self, _: Ipv4Addr, _: Ipv6Addr, _: Vec) -> Option { + self.ctlr_tx + .try_send(ControllerRequest::TunnelReady) + .expect("controller channel failed"); + None + } + + fn on_update_resources(&self, resources: Vec) { + tracing::debug!("on_update_resources"); + self.resources.store(resources.into()); + self.notify_controller.notify_one(); + } +} + +impl Client { + pub(crate) async fn reconnect(&mut self) -> Result<()> { + self.send_msg(&IpcClientMsg::Reconnect) + .await + .context("Couldn't send Reconnect")?; + Ok(()) + } + + /// Tell connlib about the system's default resolvers + pub(crate) async fn set_dns(&mut self, dns: Vec) -> Result<()> { + self.send_msg(&IpcClientMsg::SetDns(dns)) + .await + .context("Couldn't send SetDns")?; + Ok(()) + } +} diff --git a/rust/gui-client/src-tauri/src/client/ipc/linux.rs b/rust/gui-client/src-tauri/src/client/ipc/linux.rs new file mode 100644 index 000000000..a1a9ca8e3 --- /dev/null +++ b/rust/gui-client/src-tauri/src/client/ipc/linux.rs @@ -0,0 +1,82 @@ +use anyhow::{Context as _, Result}; +use connlib_client_shared::Callbacks; +use firezone_headless_client::{platform::sock_path, IpcClientMsg, IpcServerMsg}; +use futures::{SinkExt, StreamExt}; +use secrecy::{ExposeSecret, SecretString}; +use tokio::net::{unix::OwnedWriteHalf, UnixStream}; +use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; + +/// Forwards events to and from connlib +pub(crate) struct Client { + recv_task: tokio::task::JoinHandle>, + tx: FramedWrite, +} + +impl Client { + pub(crate) async fn disconnect(mut self) -> Result<()> { + self.send_msg(&IpcClientMsg::Disconnect) + .await + .context("Couldn't send Disconnect")?; + self.tx.close().await?; + self.recv_task.abort(); + Ok(()) + } + + pub(crate) async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { + self.tx + .send( + serde_json::to_string(msg) + .context("Couldn't encode IPC message as JSON")? + .into(), + ) + .await + .context("Couldn't send IPC message")?; + Ok(()) + } + + pub(crate) async fn connect( + api_url: &str, + token: SecretString, + callback_handler: super::CallbackHandler, + tokio_handle: tokio::runtime::Handle, + ) -> Result { + tracing::info!(pid = std::process::id(), "Connecting to IPC service..."); + let stream = UnixStream::connect(sock_path()) + .await + .context("Couldn't connect to UDS")?; + let (rx, tx) = stream.into_split(); + // Receives messages from the IPC service + let mut rx = FramedRead::new(rx, LengthDelimitedCodec::new()); + let tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); + + // TODO: Make sure this joins / drops somewhere + let recv_task = tokio_handle.spawn(async move { + while let Some(msg) = rx.next().await.transpose()? { + let msg: IpcServerMsg = serde_json::from_slice(&msg)?; + match msg { + IpcServerMsg::Ok => {} + IpcServerMsg::OnDisconnect => callback_handler.on_disconnect( + &connlib_client_shared::Error::Other("errors can't be serialized"), + ), + IpcServerMsg::OnUpdateResources(v) => callback_handler.on_update_resources(v), + IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { + callback_handler.on_set_interface_config(ipv4, ipv6, dns); + } + } + } + Ok(()) + }); + + let mut client = Self { recv_task, tx }; + let token = token.expose_secret().clone(); + client + .send_msg(&IpcClientMsg::Connect { + api_url: api_url.to_string(), + token, + }) + .await + .context("Couldn't send Connect message")?; + + Ok(client) + } +} diff --git a/rust/gui-client/src-tauri/src/client/ipc/macos.rs b/rust/gui-client/src-tauri/src/client/ipc/macos.rs new file mode 100644 index 000000000..496801308 --- /dev/null +++ b/rust/gui-client/src-tauri/src/client/ipc/macos.rs @@ -0,0 +1,26 @@ +use anyhow::Result; +use firezone_headless_client::IpcClientMsg; +use secrecy::SecretString; + +pub(crate) struct Client {} + +impl Client { + #[allow(clippy::unused_async)] + pub(crate) async fn disconnect(self) -> Result<()> { + unimplemented!() + } + + #[allow(clippy::unused_async)] + pub(crate) async fn send_msg(&mut self, _msg: &IpcClientMsg) -> Result<()> { + unimplemented!() + } + + pub(crate) async fn connect( + _api_url: &str, + _token: SecretString, + _callback_handler: super::CallbackHandler, + _tokio_handle: tokio::runtime::Handle, + ) -> Result { + unimplemented!() + } +} diff --git a/rust/gui-client/src-tauri/src/client/ipc/windows.rs b/rust/gui-client/src-tauri/src/client/ipc/windows.rs new file mode 100644 index 000000000..8a7a1d183 --- /dev/null +++ b/rust/gui-client/src-tauri/src/client/ipc/windows.rs @@ -0,0 +1,117 @@ +use anyhow::{anyhow, Context as _, Result}; +use connlib_client_shared::Callbacks; +use firezone_headless_client::{IpcClientMsg, IpcServerMsg}; +use futures::{SinkExt, Stream}; +use secrecy::{ExposeSecret, SecretString}; +use std::{pin::pin, task::Poll}; +use tokio::{net::windows::named_pipe, sync::mpsc}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; + +pub(crate) struct Client { + task: tokio::task::JoinHandle>, + // Needed temporarily to avoid a big refactor. We can remove this in the future. + tx: mpsc::Sender, +} + +impl Client { + pub(crate) async fn disconnect(mut self) -> Result<()> { + self.send_msg(&IpcClientMsg::Disconnect) + .await + .context("Couldn't send Disconnect")?; + self.task.abort(); + Ok(()) + } + + #[allow(clippy::unused_async)] + pub(crate) async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { + self.tx + .send(serde_json::to_string(msg).context("Couldn't encode IPC message as JSON")?) + .await + .context("Couldn't send IPC message")?; + Ok(()) + } + + pub(crate) async fn connect( + api_url: &str, + token: SecretString, + callback_handler: super::CallbackHandler, + tokio_handle: tokio::runtime::Handle, + ) -> Result { + tracing::info!(pid = std::process::id(), "Connecting to IPC service..."); + let ipc = named_pipe::ClientOptions::new() + .open(firezone_headless_client::windows::pipe_path()) + .context("Couldn't connect to named pipe server")?; + let ipc = Framed::new(ipc, LengthDelimitedCodec::new()); + // This channel allows us to communicate with the GUI even though NamedPipeClient + // doesn't have `into_split`. + let (tx, mut rx) = mpsc::channel(1); + + let task = tokio_handle.spawn(async move { + let mut ipc = pin!(ipc); + loop { + let ev = std::future::poll_fn(|cx| { + match rx.poll_recv(cx) { + Poll::Ready(Some(msg)) => return Poll::Ready(Ok(IpcEvent::Gui(msg))), + Poll::Ready(None) => { + return Poll::Ready(Err(anyhow!("MPSC channel from GUI closed"))) + } + Poll::Pending => {} + } + + match ipc.as_mut().poll_next(cx) { + Poll::Ready(Some(msg)) => { + let msg = serde_json::from_slice(&msg?)?; + return Poll::Ready(Ok(IpcEvent::Connlib(msg))); + } + Poll::Ready(None) => { + return Poll::Ready(Err(anyhow!("IPC service disconnected from us"))) + } + Poll::Pending => {} + } + + Poll::Pending + }) + .await; + + match ev { + Ok(IpcEvent::Gui(msg)) => ipc.send(msg.into()).await?, + Ok(IpcEvent::Connlib(msg)) => match msg { + IpcServerMsg::Ok => {} + IpcServerMsg::OnDisconnect => callback_handler.on_disconnect( + &connlib_client_shared::Error::Other("errors can't be serialized"), + ), + IpcServerMsg::OnUpdateResources(v) => { + callback_handler.on_update_resources(v) + } + IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns } => { + callback_handler.on_set_interface_config(ipv4, ipv6, dns); + } + }, + Err(error) => { + tracing::error!(?error, "Error while waiting for IPC tx/rx"); + // TODO: Catch that error when the task is joined + Err(error)? + } + } + } + }); + + let mut client = Self { task, tx }; + let token = token.expose_secret().clone(); + client + .send_msg(&IpcClientMsg::Connect { + api_url: api_url.to_string(), + token, + }) + .await + .context("Couldn't send Connect message")?; + Ok(client) + } +} + +enum IpcEvent { + /// The GUI wants to send a message to the service + Gui(String), + /// The connlib instance in the service wants to send a message to the GUI + Connlib(IpcServerMsg), +} diff --git a/rust/gui-client/src-tauri/src/client/logging.rs b/rust/gui-client/src-tauri/src/client/logging.rs index 79f7ff133..d9605e2e3 100644 --- a/rust/gui-client/src-tauri/src/client/logging.rs +++ b/rust/gui-client/src-tauri/src/client/logging.rs @@ -49,7 +49,7 @@ pub(crate) enum Error { /// Set up logs after the process has started pub(crate) fn setup(log_filter: &str) -> Result { - let log_path = app_log_path()?.src; + let log_path = known_dirs::logs().context("Can't compute app log dir")?; std::fs::create_dir_all(&log_path).map_err(Error::CreateDirAll)?; let (layer, logger) = file_logger::layer(&log_path); @@ -73,17 +73,6 @@ pub(crate) fn setup(log_filter: &str) -> Result { }) } -/// Sets up logging for stderr only, with INFO level by default -pub(crate) fn debug_command_setup() -> Result<(), Error> { - let filter = EnvFilter::builder() - .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) - .from_env_lossy(); - let layer = fmt::layer().with_filter(filter); - let subscriber = Registry::default().with(layer); - set_global_default(subscriber)?; - Ok(()) -} - #[tauri::command] pub(crate) async fn clear_logs() -> StdResult<(), String> { clear_logs_inner().await.map_err(|e| e.to_string()) @@ -221,34 +210,16 @@ async fn count_one_dir(path: &Path) -> Result { Ok(file_count) } -#[cfg(target_os = "linux")] fn log_paths() -> Result> { Ok(vec![ LogPath { - // TODO: This is magic, it must match the systemd file - src: PathBuf::from("/var/log").join(connlib_shared::BUNDLE_ID), + src: firezone_headless_client::known_dirs::ipc_service_logs() + .context("Can't compute IPC service logs dir")?, dst: PathBuf::from("connlib"), }, - app_log_path()?, + LogPath { + src: known_dirs::logs().context("Can't compute app log dir")?, + dst: PathBuf::from("app"), + }, ]) } - -/// Windows doesn't have separate connlib logs until #3712 merges -#[cfg(not(target_os = "linux"))] -fn log_paths() -> Result> { - Ok(vec![app_log_path()?]) -} - -/// Log dir for just the GUI app -/// -/// e.g. `$HOME/.cache/dev.firezone.client/data/logs` -/// or `%LOCALAPPDATA%/dev.firezone.client/data/logs` -/// -/// On Windows this also happens to contain the connlib logs, -/// until #3712 merges -fn app_log_path() -> Result { - Ok(LogPath { - src: known_dirs::logs().context("Can't compute app log dir")?, - dst: PathBuf::from("app"), - }) -} diff --git a/rust/gui-client/src-tauri/src/client/resolvers.rs b/rust/gui-client/src-tauri/src/client/resolvers.rs index c24d4ef71..4d3989900 100644 --- a/rust/gui-client/src-tauri/src/client/resolvers.rs +++ b/rust/gui-client/src-tauri/src/client/resolvers.rs @@ -8,7 +8,7 @@ mod imp { use std::net::IpAddr; pub fn get() -> Result> { - firezone_headless_client::imp::get_system_default_resolvers_systemd_resolved() + firezone_headless_client::platform::get_system_default_resolvers_systemd_resolved() } } @@ -28,6 +28,6 @@ mod imp { use std::net::IpAddr; pub fn get() -> Result> { - firezone_headless_client::imp::system_resolvers() + firezone_headless_client::platform::system_resolvers() } } diff --git a/rust/gui-client/src-tauri/src/client/settings.rs b/rust/gui-client/src-tauri/src/client/settings.rs index 60967547b..ebfc0d871 100644 --- a/rust/gui-client/src-tauri/src/client/settings.rs +++ b/rust/gui-client/src-tauri/src/client/settings.rs @@ -22,7 +22,7 @@ impl Default for AdvancedSettings { Self { auth_base_url: Url::parse("https://app.firez.one").unwrap(), api_url: Url::parse("wss://api.firez.one").unwrap(), - log_filter: "firezone_gui_client=debug,firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,boringtun=debug,snownet=debug,str0m=info,info".to_string(), + log_filter: "firezone_gui_client=debug,info".to_string(), } } } @@ -33,7 +33,7 @@ impl Default for AdvancedSettings { Self { auth_base_url: Url::parse("https://app.firezone.dev").unwrap(), api_url: Url::parse("wss://api.firezone.dev").unwrap(), - log_filter: "str0m=warn,info".to_string(), + log_filter: "info".to_string(), } } } diff --git a/rust/gui-client/src-tauri/src/client/tunnel-wrapper/in_proc.rs b/rust/gui-client/src-tauri/src/client/tunnel-wrapper/in_proc.rs deleted file mode 100644 index 4b71dda98..000000000 --- a/rust/gui-client/src-tauri/src/client/tunnel-wrapper/in_proc.rs +++ /dev/null @@ -1,128 +0,0 @@ -//! In-process wrapper for connlib -//! -//! This is used so that Windows can keep connlib in the GUI process a little longer, -//! until the Linux process splitting is settled. Once both platforms are split, -//! this should be deleted. -//! -//! With this module, the main GUI module should have no direct dependence on connlib. -//! And some things in here will live in the tunnel process after the IPC split. -//! (It's okay to depend on trivial things like `BUNDLE_ID`) - -use anyhow::{Context, Result}; -use arc_swap::ArcSwap; -use connlib_client_shared::Sockets; -use connlib_shared::{callbacks::ResourceDescription, keypair, LoginUrl}; -use secrecy::SecretString; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::Arc, - time::Duration, -}; -use tokio::sync::Notify; - -use super::ControllerRequest; -use super::CtlrTx; - -/// We have valid use cases for headless Windows clients -/// (IoT devices, point-of-sale devices, etc), so try to reconnect for 30 days if there's -/// been a partition. -const MAX_PARTITION_TIME: Duration = Duration::from_secs(60 * 60 * 24 * 30); - -// This will stay in the GUI process -#[derive(Clone)] -pub(crate) struct CallbackHandler { - pub notify_controller: Arc, - pub ctlr_tx: CtlrTx, - pub resources: Arc>>, -} - -/// Forwards events to and from connlib -/// -/// In the `in_proc` module this is just a stub. The real purpose is to abstract -/// over both in-proc connlib instances and connlib instances living in the tunnel -/// process, across an IPC boundary. -pub(crate) struct TunnelWrapper { - session: connlib_client_shared::Session, -} - -impl TunnelWrapper { - #[allow(clippy::unused_async)] - pub(crate) async fn disconnect(self) -> Result<()> { - self.session.disconnect(); - Ok(()) - } - - #[allow(clippy::unused_async)] - pub(crate) async fn reconnect(&mut self) -> Result<()> { - self.session.reconnect(); - Ok(()) - } - - #[allow(clippy::unused_async)] - pub(crate) async fn set_dns(&mut self, dns: Vec) -> Result<()> { - self.session.set_dns(dns); - Ok(()) - } -} - -/// Starts connlib in-process -/// -/// This is `async` because the IPC version is async -#[allow(clippy::unused_async)] -pub async fn connect( - api_url: &str, - token: SecretString, - callback_handler: CallbackHandler, - tokio_handle: tokio::runtime::Handle, -) -> Result { - // Device ID should be in the tunnel process - let device_id = - connlib_shared::device_id::get().context("Failed to read / create device ID")?; - - // Private keys should be generated in the tunnel process - let (private_key, public_key) = keypair(); - - let login = LoginUrl::client(api_url, &token, device_id.id, None, public_key.to_bytes())?; - - // Deactivate DNS control since that can prevent us from bootstrapping a connection - // to the portal. Maybe we could bring up a sentinel resolver before - // connecting to the portal, but right now the portal seems to need system DNS - // for the first connection. - connlib_shared::deactivate_dns_control()?; - - // All direct calls into connlib must be in the tunnel process - let session = connlib_client_shared::Session::connect( - login, - Sockets::new(), - private_key, - None, - callback_handler, - Some(MAX_PARTITION_TIME), - tokio_handle, - ); - Ok(TunnelWrapper { session }) -} - -// Callbacks must all be non-blocking -impl connlib_client_shared::Callbacks for CallbackHandler { - fn on_disconnect(&self, error: &connlib_client_shared::Error) { - tracing::debug!("on_disconnect {error:?}"); - self.ctlr_tx - .try_send(ControllerRequest::Disconnected) - .expect("controller channel failed"); - } - - fn on_set_interface_config(&self, _: Ipv4Addr, _: Ipv6Addr, _: Vec) -> Option { - tracing::info!("on_set_interface_config"); - self.ctlr_tx - .try_send(ControllerRequest::TunnelReady) - .expect("controller channel failed"); - None - } - - fn on_update_resources(&self, resources: Vec) { - tracing::debug!("on_update_resources"); - self.resources.store(resources.into()); - self.notify_controller.notify_one(); - } -} diff --git a/rust/gui-client/src-tauri/src/client/tunnel-wrapper/ipc.rs b/rust/gui-client/src-tauri/src/client/tunnel-wrapper/ipc.rs deleted file mode 100644 index bd1d8b3e2..000000000 --- a/rust/gui-client/src-tauri/src/client/tunnel-wrapper/ipc.rs +++ /dev/null @@ -1,147 +0,0 @@ -use anyhow::{Context, Result}; -use arc_swap::ArcSwap; -use connlib_client_shared::Callbacks; -use connlib_shared::callbacks::ResourceDescription; -use firezone_headless_client::{imp::sock_path, IpcClientMsg, IpcServerMsg}; -use futures::{SinkExt, StreamExt}; -use secrecy::{ExposeSecret, SecretString}; -use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr}, - sync::Arc, -}; -use tokio::{ - net::{unix::OwnedWriteHalf, UnixStream}, - sync::Notify, -}; -use tokio_util::codec::{FramedRead, FramedWrite, LengthDelimitedCodec}; - -use super::ControllerRequest; -use super::CtlrTx; - -#[derive(Clone)] -pub(crate) struct CallbackHandler { - pub notify_controller: Arc, - pub ctlr_tx: CtlrTx, - pub resources: Arc>>, -} - -/// Forwards events to and from connlib -pub(crate) struct TunnelWrapper { - recv_task: tokio::task::JoinHandle>, - tx: FramedWrite, -} - -impl TunnelWrapper { - pub(crate) async fn disconnect(mut self) -> Result<()> { - self.send_msg(&IpcClientMsg::Disconnect) - .await - .context("Couldn't send Disconnect")?; - self.tx.close().await?; - self.recv_task.abort(); - Ok(()) - } - - pub(crate) async fn reconnect(&mut self) -> Result<()> { - self.send_msg(&IpcClientMsg::Reconnect) - .await - .context("Couldn't send Reconnect")?; - Ok(()) - } - - /// Tell connlib about the system's default resolvers - /// - /// `dns` is passed as value because the in-proc impl needs that - pub(crate) async fn set_dns(&mut self, dns: Vec) -> Result<()> { - self.send_msg(&IpcClientMsg::SetDns(dns)) - .await - .context("Couldn't send SetDns")?; - Ok(()) - } - - async fn send_msg(&mut self, msg: &IpcClientMsg) -> Result<()> { - self.tx - .send( - serde_json::to_string(msg) - .context("Couldn't encode IPC message as JSON")? - .into(), - ) - .await - .context("Couldn't send IPC message")?; - Ok(()) - } -} - -pub async fn connect( - api_url: &str, - token: SecretString, - callback_handler: CallbackHandler, - tokio_handle: tokio::runtime::Handle, -) -> Result { - tracing::info!(pid = std::process::id(), "Connecting to IPC service..."); - let stream = UnixStream::connect(sock_path()) - .await - .context("Couldn't connect to UDS")?; - let (rx, tx) = stream.into_split(); - let mut rx = FramedRead::new(rx, LengthDelimitedCodec::new()); - let tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); - - // TODO: Make sure this joins / drops somewhere - let recv_task = tokio_handle.spawn(async move { - while let Some(msg) = rx.next().await { - let msg = msg?; - let msg: IpcServerMsg = serde_json::from_slice(&msg)?; - match msg { - IpcServerMsg::Ok => {} - IpcServerMsg::OnDisconnect => callback_handler.on_disconnect( - &connlib_client_shared::Error::Other("errors can't be serialized"), - ), - IpcServerMsg::OnUpdateResources(v) => callback_handler.on_update_resources(v), - IpcServerMsg::TunnelReady => callback_handler.on_tunnel_ready(), - } - } - Ok(()) - }); - - let mut client = TunnelWrapper { recv_task, tx }; - let token = token.expose_secret().clone(); - client - .send_msg(&IpcClientMsg::Connect { - api_url: api_url.to_string(), - token, - }) - .await - .context("Couldn't send Connect message")?; - - Ok(client) -} - -// Callbacks must all be non-blocking -// TODO: DRY -impl connlib_client_shared::Callbacks for CallbackHandler { - fn on_disconnect(&self, error: &connlib_client_shared::Error) { - // The connlib error type cannot be serialized, but `on_disconnect` is - // always an error, so at least log it as such on the GUI side. - tracing::error!("on_disconnect {error:?}"); - self.ctlr_tx - .try_send(ControllerRequest::Disconnected) - .expect("controller channel failed"); - } - - fn on_set_interface_config(&self, _: Ipv4Addr, _: Ipv6Addr, _: Vec) -> Option { - unimplemented!() - } - - fn on_update_resources(&self, resources: Vec) { - tracing::debug!("on_update_resources"); - self.resources.store(resources.into()); - self.notify_controller.notify_one(); - } -} - -impl CallbackHandler { - fn on_tunnel_ready(&self) { - self.ctlr_tx - .try_send(ControllerRequest::TunnelReady) - .expect("controller channel failed"); - } -} diff --git a/rust/headless-client/Cargo.toml b/rust/headless-client/Cargo.toml index 20aeee4d6..5efb780e0 100644 --- a/rust/headless-client/Cargo.toml +++ b/rust/headless-client/Cargo.toml @@ -13,6 +13,7 @@ clap = { version = "4.5", features = ["derive", "env"] } connlib-client-shared = { workspace = true } connlib-shared = { workspace = true } firezone-cli-utils = { workspace = true } +futures = "0.3.30" git-version = "0.3.9" humantime = "2.1" secrecy = { workspace = true } @@ -21,16 +22,16 @@ serde_json = "1.0.115" # This actually relies on many other features in Tokio, so this will probably # fail to build outside the workspace. tokio = { version = "1.36.0", features = ["macros", "signal"] } +tokio-util = { version = "0.7.11", features = ["codec"] } tracing = { workspace = true } +tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } url = { version = "2.3.1", default-features = false } [target.'cfg(target_os = "linux")'.dependencies] dirs = "5.0.1" -futures = "0.3.30" nix = { version = "0.28.0", features = ["fs", "user"] } resolv-conf = "0.7.0" sd-notify = "0.4.1" # This is a pure Rust re-implementation, so it isn't vulnerable to CVE-2024-3094 -tokio-util = { version = "0.7.11", features = ["codec"] } [target.'cfg(target_os = "macos")'.dependencies] dirs = "5.0.1" @@ -38,8 +39,19 @@ dirs = "5.0.1" [target.'cfg(target_os = "windows")'.dependencies] ipconfig = "0.3.2" known-folders = "1.1.0" +ring = "0.17" +thiserror = { version = "1.0", default-features = false } tracing-subscriber = { version = "0.3.17", features = ["env-filter"] } windows-service = "0.7.0" +[target.'cfg(target_os = "windows")'.dependencies.windows] +version = "0.56.0" +features = [ + # For named pipe IPC + "Win32_Security", + # For named pipe IPC + "Win32_System_SystemServices", +] + [lints] workspace = true diff --git a/rust/headless-client/src/imp_windows.rs b/rust/headless-client/src/imp_windows.rs deleted file mode 100644 index 0f0bf391c..000000000 --- a/rust/headless-client/src/imp_windows.rs +++ /dev/null @@ -1,192 +0,0 @@ -use crate::Cli; -use anyhow::{Context as _, Result}; -use clap::Parser; -use connlib_client_shared::file_logger; -use std::{ - ffi::OsString, - net::IpAddr, - path::{Path, PathBuf}, - str::FromStr, - task::{Context, Poll}, - time::Duration, -}; -use tokio::sync::mpsc; -use tracing::subscriber::set_global_default; -use tracing_subscriber::{layer::SubscriberExt as _, EnvFilter, Layer, Registry}; -use windows_service::{ - service::{ - ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, - ServiceType, - }, - service_control_handler::{self, ServiceControlHandlerResult}, -}; - -const SERVICE_NAME: &str = "firezone_client_ipc"; -const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; - -pub(crate) struct Signals { - sigint: tokio::signal::windows::CtrlC, -} - -impl Signals { - pub(crate) fn new() -> Result { - let sigint = tokio::signal::windows::ctrl_c()?; - Ok(Self { sigint }) - } - - pub(crate) fn poll(&mut self, cx: &mut Context) -> Poll { - if self.sigint.poll_recv(cx).is_ready() { - return Poll::Ready(super::SignalKind::Interrupt); - } - Poll::Pending - } -} - -// The return value is useful on Linux -#[allow(clippy::unnecessary_wraps)] -pub(crate) fn check_token_permissions(_path: &Path) -> Result<()> { - // TODO: Make sure the token is only readable by admin / our service user on Windows - Ok(()) -} - -pub(crate) fn default_token_path() -> std::path::PathBuf { - // TODO: System-wide default token path for Windows - PathBuf::from("token.txt") -} - -/// Only called from the GUI Client's build of the IPC service -/// -/// On Windows, this is wrapped specially so that Windows' service controller -/// can launch it. -pub fn run_only_ipc_service() -> Result<()> { - windows_service::service_dispatcher::start(SERVICE_NAME, ffi_service_run)?; - Ok(()) -} - -// Generates `ffi_service_run` from `service_run` -windows_service::define_windows_service!(ffi_service_run, windows_service_run); - -fn windows_service_run(_arguments: Vec) { - if let Err(_e) = fallible_windows_service_run() { - todo!(); - } -} - -#[cfg(debug_assertions)] -const SERVICE_RUST_LOG: &str = "debug"; - -#[cfg(not(debug_assertions))] -const SERVICE_RUST_LOG: &str = "info"; - -// Most of the Windows-specific service stuff should go here -fn fallible_windows_service_run() -> Result<()> { - let cli = Cli::parse(); - let log_path = - crate::known_dirs::imp::ipc_service_logs().context("Can't compute IPC service logs dir")?; - std::fs::create_dir_all(&log_path)?; - let (layer, _handle) = file_logger::layer(&log_path); - let filter = EnvFilter::from_str(SERVICE_RUST_LOG)?; - let subscriber = Registry::default().with(layer.with_filter(filter)); - set_global_default(subscriber)?; - tracing::info!(git_version = crate::GIT_VERSION); - - let rt = tokio::runtime::Runtime::new()?; - let (shutdown_tx, shutdown_rx) = mpsc::channel(1); - - let event_handler = move |control_event| -> ServiceControlHandlerResult { - tracing::debug!(?control_event); - match control_event { - // TODO - ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, - ServiceControl::Stop => { - tracing::info!("Got stop signal from service controller"); - shutdown_tx.blocking_send(()).unwrap(); - ServiceControlHandlerResult::NoError - } - ServiceControl::UserEvent(_) => ServiceControlHandlerResult::NoError, - ServiceControl::Continue - | ServiceControl::NetBindAdd - | ServiceControl::NetBindDisable - | ServiceControl::NetBindEnable - | ServiceControl::NetBindRemove - | ServiceControl::ParamChange - | ServiceControl::Pause - | ServiceControl::Preshutdown - | ServiceControl::Shutdown - | ServiceControl::HardwareProfileChange(_) - | ServiceControl::PowerEvent(_) - | ServiceControl::SessionChange(_) - | ServiceControl::TimeChange - | ServiceControl::TriggerEvent => ServiceControlHandlerResult::NotImplemented, - _ => ServiceControlHandlerResult::NotImplemented, - } - }; - - // Fixes , - // DNS rules persisting after reboot - connlib_shared::deactivate_dns_control().ok(); - - // Tell Windows that we're running (equivalent to sd_notify in systemd) - let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; - status_handle.set_service_status(ServiceStatus { - service_type: SERVICE_TYPE, - current_state: ServiceState::Running, - controls_accepted: ServiceControlAccept::STOP | ServiceControlAccept::SHUTDOWN, - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - })?; - - if let Err(error) = run_ipc_service(cli, rt, shutdown_rx) { - tracing::error!(?error, "error from run_ipc_service"); - } - - // Tell Windows that we're stopping - status_handle.set_service_status(ServiceStatus { - service_type: SERVICE_TYPE, - current_state: ServiceState::Stopped, - controls_accepted: ServiceControlAccept::empty(), - exit_code: ServiceExitCode::Win32(0), - checkpoint: 0, - wait_hint: Duration::default(), - process_id: None, - })?; - Ok(()) -} - -/// Common entry point for both the Windows-wrapped IPC service and the debug IPC service -/// -/// Running as a Windows service is complicated, so to make debugging easier -/// we'll have a dev-only mode that runs all the IPC code as a normal process -/// in an admin console. -pub(crate) fn run_ipc_service( - cli: Cli, - rt: tokio::runtime::Runtime, - shutdown_rx: mpsc::Receiver<()>, -) -> Result<()> { - tracing::info!("run_ipc_service"); - rt.block_on(async { ipc_listen(cli, shutdown_rx).await }) -} - -async fn ipc_listen(_cli: Cli, mut shutdown_rx: mpsc::Receiver<()>) -> Result<()> { - shutdown_rx.recv().await; - - Ok(()) -} - -pub fn system_resolvers() -> Result> { - let resolvers = ipconfig::get_adapters()? - .iter() - .flat_map(|adapter| adapter.dns_servers()) - .filter(|ip| match ip { - IpAddr::V4(_) => true, - // Filter out bogus DNS resolvers on my dev laptop that start with fec0: - IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]), - }) - .copied() - .collect(); - // This is private, so keep it at `debug` or `trace` - tracing::debug!(?resolvers); - Ok(resolvers) -} diff --git a/rust/headless-client/src/known_dirs.rs b/rust/headless-client/src/known_dirs.rs index 17f96afe1..70d7ca24d 100644 --- a/rust/headless-client/src/known_dirs.rs +++ b/rust/headless-client/src/known_dirs.rs @@ -7,13 +7,19 @@ //! //! I wanted the ProgramData folder on Windows, which `dirs` alone doesn't provide. -pub use imp::{logs, runtime, session, settings}; +pub use platform::{ipc_service_logs, logs, runtime, session, settings}; -#[cfg(any(target_os = "linux", target_os = "macos"))] -pub mod imp { +#[cfg(target_os = "linux")] +pub mod platform { use connlib_shared::BUNDLE_ID; use std::path::PathBuf; + #[allow(clippy::unnecessary_wraps)] + pub fn ipc_service_logs() -> Option { + // TODO: This is magic, it must match the systemd file + Some(PathBuf::from("/var/log").join(connlib_shared::BUNDLE_ID)) + } + /// e.g. `/home/alice/.cache/dev.firezone.client/data/logs` /// /// Logs are considered cache because they're not configs and it's technically okay @@ -46,8 +52,31 @@ pub mod imp { } } +#[cfg(target_os = "macos")] +pub mod platform { + pub fn ipc_service_logs() -> Option { + unimplemented!() + } + + pub fn logs() -> Option { + unimplemented!() + } + + pub fn runtime() -> Option { + unimplemented!() + } + + pub fn session() -> Option { + unimplemented!() + } + + pub fn settings() -> Option { + unimplemented!() + } +} + #[cfg(target_os = "windows")] -pub mod imp { +pub mod platform { use connlib_shared::BUNDLE_ID; use known_folders::{get_known_folder_path, KnownFolder}; use std::path::PathBuf; @@ -113,7 +142,7 @@ mod tests { #[test] fn smoke() { - for dir in [logs(), runtime(), session(), settings()] { + for dir in [ipc_service_logs(), logs(), runtime(), session(), settings()] { let dir = dir.expect("should have gotten Some(path)"); assert!(dir .components() diff --git a/rust/headless-client/src/lib.rs b/rust/headless-client/src/lib.rs index 7b21c9d83..7264f27c5 100644 --- a/rust/headless-client/src/lib.rs +++ b/rust/headless-client/src/lib.rs @@ -14,22 +14,29 @@ use connlib_client_shared::{file_logger, keypair, Callbacks, LoginUrl, Session, use connlib_shared::callbacks; use firezone_cli_utils::setup_global_subscriber; use secrecy::SecretString; -use std::{future, net::IpAddr, path::PathBuf, task::Poll}; +use std::{ + future, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + path::PathBuf, + task::Poll, +}; use tokio::sync::mpsc; +use tracing::subscriber::set_global_default; +use tracing_subscriber::{fmt, layer::SubscriberExt, EnvFilter, Layer as _, Registry}; -use imp::default_token_path; +use platform::default_token_path; pub mod known_dirs; #[cfg(target_os = "linux")] -pub mod imp_linux; +pub mod linux; #[cfg(target_os = "linux")] -pub use imp_linux as imp; +pub use linux as platform; #[cfg(target_os = "windows")] -pub mod imp_windows; +pub mod windows; #[cfg(target_os = "windows")] -pub use imp_windows as imp; +pub use windows as platform; /// Only used on Linux pub const FIREZONE_GROUP: &str = "firezone-client"; @@ -124,8 +131,12 @@ pub enum IpcClientMsg { pub enum IpcServerMsg { Ok, OnDisconnect, + OnSetInterfaceConfig { + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + }, OnUpdateResources(Vec), - TunnelReady, } pub fn run_only_headless_client() -> Result<()> { @@ -141,7 +152,8 @@ pub fn run_only_headless_client() -> 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`. So nobody else is reading the environment. + // thing to run after entering `main` and parsing CLI args. + // So nobody else is reading the environment. #[allow(unused_unsafe)] unsafe { // This removes the token from the environment per . We run as root so it may not do anything besides defense-in-depth. @@ -164,18 +176,7 @@ pub fn run_only_headless_client() -> Result<()> { cli.token_path ) })?; - run_standalone(cli, rt, &token) -} - -// Allow dead code because Windows doesn't have an obvious SIGHUP equivalent -#[allow(dead_code)] -enum SignalKind { - Hangup, - Interrupt, -} - -fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) -> Result<()> { - tracing::info!("Running in standalone mode"); + tracing::info!("Running in headless / standalone mode"); let _guard = rt.enter(); // TODO: Should this default to 30 days? let max_partition_time = cli.max_partition_time.map(|d| d.into()); @@ -187,7 +188,13 @@ fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) - }; let (private_key, public_key) = keypair(); - let login = LoginUrl::client(cli.api_url, token, firezone_id, None, public_key.to_bytes())?; + let login = LoginUrl::client( + cli.api_url, + &token, + firezone_id, + None, + public_key.to_bytes(), + )?; if cli.check { tracing::info!("Check passed"); @@ -197,6 +204,7 @@ fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) - let (on_disconnect_tx, mut on_disconnect_rx) = mpsc::channel(1); let callback_handler = CallbackHandler { on_disconnect_tx }; + platform::setup_before_connlib()?; let session = Session::connect( login, Sockets::new(), @@ -207,9 +215,9 @@ fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) - rt.handle().clone(), ); // TODO: this should be added dynamically - session.set_dns(imp::system_resolvers().unwrap_or_default()); + session.set_dns(platform::system_resolvers().unwrap_or_default()); - let mut signals = imp::Signals::new()?; + let mut signals = platform::Signals::new()?; let result = rt.block_on(async { future::poll_fn(|cx| loop { @@ -240,6 +248,27 @@ fn run_standalone(cli: Cli, rt: tokio::runtime::Runtime, token: &SecretString) - result } +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. + #[allow(unused_unsafe)] + 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()); + platform::run_only_ipc_service() +} + +// Allow dead code because Windows doesn't have an obvious SIGHUP equivalent +#[allow(dead_code)] +enum SignalKind { + Hangup, + Interrupt, +} + #[derive(Clone)] struct CallbackHandler { /// Channel for an error message if connlib disconnects due to an error @@ -298,7 +327,7 @@ fn read_token_file(cli: &Cli) -> Result> { if std::fs::metadata(&path).is_err() { return Ok(None); } - imp::check_token_permissions(&path)?; + platform::check_token_permissions(&path)?; let Ok(bytes) = std::fs::read(&path) else { // We got the metadata a second ago, but can't read the file itself. @@ -312,3 +341,14 @@ fn read_token_file(cli: &Cli) -> Result> { tracing::info!(?path, "Loaded token from disk"); Ok(Some(token)) } + +/// Sets up logging for stderr only, with INFO level by default +pub fn debug_command_setup() -> Result<()> { + let filter = EnvFilter::builder() + .with_default_directive(tracing_subscriber::filter::LevelFilter::INFO.into()) + .from_env_lossy(); + let layer = fmt::layer().with_filter(filter); + let subscriber = Registry::default().with(layer); + set_global_default(subscriber)?; + Ok(()) +} diff --git a/rust/headless-client/src/imp_linux.rs b/rust/headless-client/src/linux.rs similarity index 94% rename from rust/headless-client/src/imp_linux.rs rename to rust/headless-client/src/linux.rs index fd7814c73..b412a36f6 100644 --- a/rust/headless-client/src/imp_linux.rs +++ b/rust/headless-client/src/linux.rs @@ -66,7 +66,7 @@ pub fn default_token_path() -> PathBuf { /// Only called from the GUI Client's build of the IPC service /// /// On Linux this is the same as running with `ipc-service` -pub fn run_only_ipc_service() -> Result<()> { +pub(crate) fn run_only_ipc_service() -> Result<()> { let cli = Cli::parse(); // systemd supplies this but maybe we should hard-code a better default let (layer, _handle) = cli.log_dir.as_deref().map(file_logger::layer).unzip(); @@ -210,6 +210,7 @@ async fn ipc_listen(cli: Cli) -> Result<()> { sd_notify::notify(true, &[sd_notify::NotifyState::Ready])?; loop { + connlib_shared::deactivate_dns_control()?; tracing::info!("Listening for GUI to connect over IPC..."); let (stream, _) = listener.accept().await?; let cred = stream.peer_cred()?; @@ -242,10 +243,15 @@ impl Callbacks for CallbackHandlerIpc { .expect("should be able to send OnDisconnect"); } - fn on_set_interface_config(&self, _: Ipv4Addr, _: Ipv6Addr, _: Vec) -> Option { + fn on_set_interface_config( + &self, + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + ) -> Option { tracing::info!("TunnelReady (on_set_interface_config)"); self.cb_tx - .try_send(IpcServerMsg::TunnelReady) + .try_send(IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns }) .expect("Should be able to send TunnelReady"); None } @@ -259,7 +265,6 @@ impl Callbacks for CallbackHandlerIpc { } async fn handle_ipc_client(cli: &Cli, stream: UnixStream) -> Result<()> { - connlib_shared::deactivate_dns_control()?; let (rx, tx) = stream.into_split(); let mut rx = FramedRead::new(rx, LengthDelimitedCodec::new()); let mut tx = FramedWrite::new(tx, LengthDelimitedCodec::new()); @@ -300,7 +305,9 @@ async fn handle_ipc_client(cli: &Cli, stream: UnixStream) -> Result<()> { private_key, None, callback_handler.clone(), - cli.max_partition_time.map(|t| t.into()), + cli.max_partition_time + .map(|t| t.into()) + .or(Some(std::time::Duration::from_secs(60 * 60 * 24 * 30))), tokio::runtime::Handle::try_current()?, )); } @@ -319,6 +326,14 @@ async fn handle_ipc_client(cli: &Cli, stream: UnixStream) -> Result<()> { Ok(()) } +/// Platform-specific setup needed for connlib +/// +/// On Linux this does nothing +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn setup_before_connlib() -> Result<()> { + Ok(()) +} + #[cfg(test)] mod tests { use std::net::IpAddr; diff --git a/rust/headless-client/src/windows.rs b/rust/headless-client/src/windows.rs new file mode 100644 index 000000000..921f61ce1 --- /dev/null +++ b/rust/headless-client/src/windows.rs @@ -0,0 +1,485 @@ +//! Implementation of headless Client and IPC service for Windows +//! +//! Try not to panic in the IPC service. Windows doesn't consider the +//! service to be stopped even if its only process ends, for some reason. +//! We must tell Windows explicitly when our service is stopping. + +use crate::{IpcClientMsg, IpcServerMsg, SignalKind}; +use anyhow::{anyhow, Context as _, Result}; +use clap::Parser; +use connlib_client_shared::{callbacks, file_logger, keypair, Callbacks, LoginUrl, Sockets}; +use connlib_shared::BUNDLE_ID; +use futures::{SinkExt, Stream}; +use std::{ + ffi::{c_void, OsString}, + future::{poll_fn, Future}, + net::{IpAddr, Ipv4Addr, Ipv6Addr}, + path::{Path, PathBuf}, + pin::pin, + str::FromStr, + task::{Context, Poll}, + time::Duration, +}; +use tokio::{net::windows::named_pipe, sync::mpsc}; +use tokio_util::codec::{Framed, LengthDelimitedCodec}; +use tracing::subscriber::set_global_default; +use tracing_subscriber::{layer::SubscriberExt as _, EnvFilter, Layer, Registry}; +use url::Url; +use windows::Win32::Security as WinSec; +use windows_service::{ + service::{ + ServiceControl, ServiceControlAccept, ServiceExitCode, ServiceState, ServiceStatus, + ServiceType, + }, + service_control_handler::{self, ServiceControlHandlerResult}, +}; + +mod wintun_install; + +#[cfg(debug_assertions)] +const SERVICE_RUST_LOG: &str = "firezone_headless_client=debug,firezone_tunnel=trace,phoenix_channel=debug,connlib_shared=debug,connlib_client_shared=debug,boringtun=debug,snownet=debug,str0m=info,info"; + +#[cfg(not(debug_assertions))] +const SERVICE_RUST_LOG: &str = "str0m=warn,info"; + +const SERVICE_NAME: &str = "firezone_client_ipc"; +const SERVICE_TYPE: ServiceType = ServiceType::OWN_PROCESS; + +pub(crate) struct Signals { + sigint: tokio::signal::windows::CtrlC, +} + +impl Signals { + pub(crate) fn new() -> Result { + let sigint = tokio::signal::windows::ctrl_c()?; + Ok(Self { sigint }) + } + + pub(crate) fn poll(&mut self, cx: &mut Context) -> Poll { + if self.sigint.poll_recv(cx).is_ready() { + return Poll::Ready(SignalKind::Interrupt); + } + Poll::Pending + } +} + +#[derive(clap::Parser, Default)] +#[command(author, version, about, long_about = None)] +struct CliIpcService { + #[command(subcommand)] + command: CmdIpc, +} + +#[derive(clap::Subcommand)] +enum CmdIpc { + #[command(hide = true)] + DebugIpcService, + IpcService, +} + +impl Default for CmdIpc { + fn default() -> Self { + Self::IpcService + } +} + +// The return value is useful on Linux +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn check_token_permissions(_path: &Path) -> Result<()> { + // TODO: For Headless Client, make sure the token is only readable by admin / our service user on Windows + Ok(()) +} + +pub(crate) fn default_token_path() -> std::path::PathBuf { + // TODO: For Headless Client, system-wide default token path for Windows + PathBuf::from("token.txt") +} + +/// Only called from the GUI Client's build of the IPC service +/// +/// On Windows, this is wrapped specially so that Windows' service controller +/// can launch it. +pub(crate) fn run_only_ipc_service() -> Result<()> { + let cli = CliIpcService::parse(); + match cli.command { + CmdIpc::DebugIpcService => run_debug_ipc_service(cli), + CmdIpc::IpcService => 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?"), + } +} + +fn run_debug_ipc_service(cli: CliIpcService) -> Result<()> { + crate::debug_command_setup()?; + let rt = tokio::runtime::Runtime::new()?; + let mut ipc_service = pin!(ipc_listen(cli)); + let mut signals = Signals::new()?; + rt.block_on(async { + std::future::poll_fn(|cx| { + match signals.poll(cx) { + Poll::Ready(SignalKind::Hangup) => { + return Poll::Ready(Err(anyhow::anyhow!( + "Impossible, we don't catch Hangup on Windows" + ))); + } + Poll::Ready(SignalKind::Interrupt) => { + tracing::info!("Caught Interrupt signal"); + return Poll::Ready(Ok(())); + } + Poll::Pending => {} + } + + match ipc_service.as_mut().poll(cx) { + Poll::Ready(Ok(())) => { + return Poll::Ready(Err(anyhow::anyhow!( + "Impossible, ipc_listen can't return Ok" + ))); + } + Poll::Ready(Err(error)) => { + return Poll::Ready(Err(error).context("ipc_listen failed")); + } + Poll::Pending => {} + } + + Poll::Pending + }) + .await + }) +} + +// Generates `ffi_service_run` from `service_run` +windows_service::define_windows_service!(ffi_service_run, windows_service_run); + +fn windows_service_run(_arguments: Vec) { + if let Err(error) = fallible_windows_service_run() { + tracing::error!(?error, "fallible_windows_service_run returned an error"); + } +} + +// Most of the Windows-specific service stuff should go here +fn fallible_windows_service_run() -> Result<()> { + let log_path = + crate::known_dirs::ipc_service_logs().context("Can't compute IPC service logs dir")?; + std::fs::create_dir_all(&log_path)?; + let (layer, _handle) = file_logger::layer(&log_path); + let filter = EnvFilter::from_str(SERVICE_RUST_LOG)?; + let subscriber = Registry::default().with(layer.with_filter(filter)); + set_global_default(subscriber)?; + tracing::info!(git_version = crate::GIT_VERSION); + + let rt = tokio::runtime::Runtime::new()?; + let (shutdown_tx, mut shutdown_rx) = mpsc::channel(1); + + let event_handler = move |control_event| -> ServiceControlHandlerResult { + tracing::debug!(?control_event); + match control_event { + // TODO + ServiceControl::Interrogate => ServiceControlHandlerResult::NoError, + ServiceControl::Stop => { + tracing::info!("Got stop signal from service controller"); + shutdown_tx.blocking_send(()).unwrap(); + ServiceControlHandlerResult::NoError + } + ServiceControl::UserEvent(_) => ServiceControlHandlerResult::NoError, + ServiceControl::Continue + | ServiceControl::NetBindAdd + | ServiceControl::NetBindDisable + | ServiceControl::NetBindEnable + | ServiceControl::NetBindRemove + | ServiceControl::ParamChange + | ServiceControl::Pause + | ServiceControl::Preshutdown + | ServiceControl::Shutdown + | ServiceControl::HardwareProfileChange(_) + | ServiceControl::PowerEvent(_) + | ServiceControl::SessionChange(_) + | ServiceControl::TimeChange + | ServiceControl::TriggerEvent => ServiceControlHandlerResult::NotImplemented, + _ => ServiceControlHandlerResult::NotImplemented, + } + }; + + // Fixes , + // DNS rules persisting after reboot + connlib_shared::deactivate_dns_control().ok(); + + // Tell Windows that we're running (equivalent to sd_notify in systemd) + let status_handle = service_control_handler::register(SERVICE_NAME, event_handler)?; + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Running, + controls_accepted: ServiceControlAccept::STOP, + exit_code: ServiceExitCode::Win32(0), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + + let mut ipc_service = pin!(ipc_listen(CliIpcService::default())); + let result = rt.block_on(async { + std::future::poll_fn(|cx| { + match shutdown_rx.poll_recv(cx) { + Poll::Ready(Some(())) => { + tracing::info!("Got shutdown signal"); + return Poll::Ready(Ok(())); + } + Poll::Ready(None) => { + return Poll::Ready(Err(anyhow!( + "shutdown channel unexpectedly dropped, shutting down" + ))) + } + Poll::Pending => {} + } + + match ipc_service.as_mut().poll(cx) { + Poll::Ready(Ok(())) => { + return Poll::Ready(Err(anyhow!("Impossible, ipc_listen can't return Ok"))) + } + Poll::Ready(Err(error)) => { + return Poll::Ready(Err(error.context("ipc_listen failed"))) + } + Poll::Pending => {} + } + + Poll::Pending + }) + .await + }); + + // Tell Windows that we're stopping + status_handle.set_service_status(ServiceStatus { + service_type: SERVICE_TYPE, + current_state: ServiceState::Stopped, + controls_accepted: ServiceControlAccept::empty(), + exit_code: ServiceExitCode::Win32(if result.is_ok() { 0 } else { 1 }), + checkpoint: 0, + wait_hint: Duration::default(), + process_id: None, + })?; + result +} + +async fn ipc_listen(_cli: CliIpcService) -> Result<()> { + setup_before_connlib()?; + loop { + // This is redundant on the first loop. After that it clears the rules + // between GUI instances. + connlib_shared::deactivate_dns_control()?; + let server = create_pipe_server()?; + tracing::info!("Listening for GUI to connect over IPC..."); + server + .connect() + .await + .context("Couldn't accept IPC connection from GUI")?; + if let Err(error) = handle_ipc_client(server).await { + tracing::error!(?error, "Error while handling IPC client"); + } + } +} + +fn create_pipe_server() -> Result { + let mut server_options = named_pipe::ServerOptions::new(); + server_options.first_pipe_instance(true); + + // This will allow non-admin clients to connect to us even though we're running with privilege + let mut sd = WinSec::SECURITY_DESCRIPTOR::default(); + let psd = WinSec::PSECURITY_DESCRIPTOR(&mut sd as *mut _ as *mut c_void); + // SAFETY: Unsafe needed to call Win32 API. There shouldn't be any threading or lifetime problems, because we only pass pointers to our local vars to Win32, and Win32 shouldn't sae them anywhere. + unsafe { + // ChatGPT pointed me to these functions + WinSec::InitializeSecurityDescriptor( + psd, + windows::Win32::System::SystemServices::SECURITY_DESCRIPTOR_REVISION, + ) + .context("InitializeSecurityDescriptor failed")?; + WinSec::SetSecurityDescriptorDacl(psd, true, None, false) + .context("SetSecurityDescriptorDacl failed")?; + } + + let mut sa = WinSec::SECURITY_ATTRIBUTES { + nLength: 0, + lpSecurityDescriptor: psd.0, + bInheritHandle: false.into(), + }; + sa.nLength = std::mem::size_of_val(&sa) + .try_into() + .context("Size of SECURITY_ATTRIBUTES struct is not right")?; + + let sa_ptr = &mut sa as *mut _ as *mut c_void; + // SAFETY: Unsafe needed to call Win32 API. We only pass pointers to local vars, and Win32 shouldn't store them, so there shouldn't be any threading of lifetime problems. + let server = unsafe { server_options.create_with_security_attributes_raw(pipe_path(), sa_ptr) } + .context("Failed to listen on named pipe")?; + Ok(server) +} + +/// Named pipe for IPC between GUI client and IPC service +pub fn pipe_path() -> String { + named_pipe_path(&format!("{BUNDLE_ID}.ipc_service")) +} + +enum IpcEvent { + /// A message that the client sent us + Client(IpcClientMsg), + /// A message that connlib wants to send + Connlib(IpcServerMsg), + /// The IPC client disconnected + IpcDisconnect, +} + +#[derive(Clone)] +struct CallbackHandlerIpc { + cb_tx: mpsc::Sender, +} + +impl Callbacks for CallbackHandlerIpc { + fn on_disconnect(&self, _error: &connlib_client_shared::Error) { + self.cb_tx + .try_send(IpcServerMsg::OnDisconnect) + .expect("should be able to send OnDisconnect"); + } + + fn on_set_interface_config( + &self, + ipv4: Ipv4Addr, + ipv6: Ipv6Addr, + dns: Vec, + ) -> Option { + tracing::info!("TunnelReady"); + self.cb_tx + .try_send(IpcServerMsg::OnSetInterfaceConfig { ipv4, ipv6, dns }) + .expect("Should be able to send TunnelReady"); + None + } + + fn on_update_resources(&self, resources: Vec) { + tracing::info!(len = resources.len(), "New resource list"); + self.cb_tx + .try_send(IpcServerMsg::OnUpdateResources(resources)) + .expect("Should be able to send OnUpdateResources"); + } +} + +async fn handle_ipc_client(server: named_pipe::NamedPipeServer) -> Result<()> { + let framed = Framed::new(server, LengthDelimitedCodec::new()); + let mut framed = pin!(framed); + let (cb_tx, mut cb_rx) = mpsc::channel(100); + + let mut connlib = None; + let callback_handler = CallbackHandlerIpc { cb_tx }; + loop { + let ev = poll_fn(|cx| { + match cb_rx.poll_recv(cx) { + Poll::Ready(Some(msg)) => return Poll::Ready(Ok(IpcEvent::Connlib(msg))), + Poll::Ready(None) => { + return Poll::Ready(Err(anyhow!( + "Impossible - MPSC channel from connlib closed" + ))) + } + Poll::Pending => {} + } + + match framed.as_mut().poll_next(cx) { + Poll::Ready(Some(msg)) => { + let msg = serde_json::from_slice(&msg?)?; + return Poll::Ready(Ok(IpcEvent::Client(msg))); + } + Poll::Ready(None) => return Poll::Ready(Ok(IpcEvent::IpcDisconnect)), + Poll::Pending => {} + } + + Poll::Pending + }) + .await; + + match ev { + Ok(IpcEvent::Client(msg)) => match msg { + IpcClientMsg::Connect { api_url, token } => { + let token = secrecy::SecretString::from(token); + assert!(connlib.is_none()); + let device_id = connlib_shared::device_id::get() + .context("Failed to read / create device ID")?; + let (private_key, public_key) = keypair(); + + let login = LoginUrl::client( + Url::parse(&api_url)?, + &token, + device_id.id, + None, + public_key.to_bytes(), + )?; + + connlib = Some(connlib_client_shared::Session::connect( + login, + Sockets::new(), + private_key, + None, + callback_handler.clone(), + Some(std::time::Duration::from_secs(60 * 60 * 24 * 30)), + tokio::runtime::Handle::try_current()?, + )); + } + IpcClientMsg::Disconnect => { + if let Some(connlib) = connlib.take() { + connlib.disconnect(); + } + } + IpcClientMsg::Reconnect => { + connlib.as_mut().context("No connlib session")?.reconnect() + } + IpcClientMsg::SetDns(v) => { + connlib.as_mut().context("No connlib session")?.set_dns(v) + } + }, + Ok(IpcEvent::Connlib(msg)) => framed.send(serde_json::to_string(&msg)?.into()).await?, + Ok(IpcEvent::IpcDisconnect) => { + tracing::info!("IPC client disconnected"); + break; + } + Err(e) => Err(e)?, + } + } + Ok(()) +} + +pub fn system_resolvers() -> Result> { + let resolvers = ipconfig::get_adapters()? + .iter() + .flat_map(|adapter| adapter.dns_servers()) + .filter(|ip| match ip { + IpAddr::V4(_) => true, + // Filter out bogus DNS resolvers on my dev laptop that start with fec0: + IpAddr::V6(ip) => !ip.octets().starts_with(&[0xfe, 0xc0]), + }) + .copied() + .collect(); + // This is private, so keep it at `debug` or `trace` + tracing::debug!(?resolvers); + Ok(resolvers) +} + +/// Returns a valid name for a Windows named pipe +/// +/// # Arguments +/// +/// * `id` - BUNDLE_ID, e.g. `dev.firezone.client` +pub fn named_pipe_path(id: &str) -> String { + format!(r"\\.\pipe\{}", id) +} + +/// Platform-specific setup needed for connlib +/// +/// On Windows this installs wintun.dll +#[allow(clippy::unnecessary_wraps)] +pub(crate) fn setup_before_connlib() -> Result<()> { + wintun_install::ensure_dll()?; + Ok(()) +} + +#[cfg(test)] +mod tests { + #[test] + fn named_pipe_path() { + assert_eq!( + super::named_pipe_path("dev.firezone.client"), + r"\\.\pipe\dev.firezone.client" + ); + } +} diff --git a/rust/gui-client/wintun/README.md b/rust/headless-client/src/windows/wintun/README.md similarity index 100% rename from rust/gui-client/wintun/README.md rename to rust/headless-client/src/windows/wintun/README.md diff --git a/rust/gui-client/wintun/bin/amd64/wintun.dll b/rust/headless-client/src/windows/wintun/bin/amd64/wintun.dll similarity index 100% rename from rust/gui-client/wintun/bin/amd64/wintun.dll rename to rust/headless-client/src/windows/wintun/bin/amd64/wintun.dll diff --git a/rust/gui-client/wintun/bin/arm64/wintun.dll b/rust/headless-client/src/windows/wintun/bin/arm64/wintun.dll similarity index 100% rename from rust/gui-client/wintun/bin/arm64/wintun.dll rename to rust/headless-client/src/windows/wintun/bin/arm64/wintun.dll diff --git a/rust/gui-client/src-tauri/src/client/wintun_install.rs b/rust/headless-client/src/windows/wintun_install.rs similarity index 95% rename from rust/gui-client/src-tauri/src/client/wintun_install.rs rename to rust/headless-client/src/windows/wintun_install.rs index 76337810d..96b31cf2a 100644 --- a/rust/gui-client/src-tauri/src/client/wintun_install.rs +++ b/rust/headless-client/src/windows/wintun_install.rs @@ -84,7 +84,7 @@ fn dll_already_exists(path: &Path, dll_bytes: &DllBytes) -> bool { #[cfg(target_arch = "x86_64")] fn get_dll_bytes() -> DllBytes { DllBytes { - bytes: include_bytes!("../../../wintun/bin/amd64/wintun.dll"), + bytes: include_bytes!("wintun/bin/amd64/wintun.dll"), expected_sha256: "e5da8447dc2c320edc0fc52fa01885c103de8c118481f683643cacc3220dafce", } } @@ -92,7 +92,7 @@ fn get_dll_bytes() -> DllBytes { #[cfg(target_arch = "aarch64")] fn get_dll_bytes() -> DllBytes { DllBytes { - bytes: include_bytes!("../../../wintun/bin/arm64/wintun.dll"), + bytes: include_bytes!("wintun/bin/arm64/wintun.dll"), expected_sha256: "f7ba89005544be9d85231a9e0d5f23b2d15b3311667e2dad0debd344918a3f80", } }