refactor(rust): move service implementation to GUI client (#9045)

The module and crate structure around the GUI client and its background
service are currently a mess of circular dependencies. Most of the
service implementation actually sits in `firezone-headless-client`
because the headless-client and the service share certain modules. We
have recently moved most of these to `firezone-bin-shared` which is the
correct place for these modules.

In order to move the background service to `firezone-gui-client`, we
need to untangle a few more things in the GUI client. Those are done
commit-by-commit in this PR. With that out the way, we can finally move
the service module to the GUI client; where is should actually live
given that it has nothing to do with the headless client.

As a result, the headless-client is - as one would expect - really just
a thin wrapper around connlib itself and is reduced down to 4 files with
this PR.

To make things more consistent in the GUI client, we move the `main.rs`
file also into `bin/`. By convention `bin/` is where you define binaries
if a crate has more than one. cargo will then build all of them.

Eventually, we can optimise the compile-times for `firezone-gui-client`
by splitting it into multiple crates:

- Shared structs like IPC messages
- Background service
- GUI client

This will be useful because it allows only re-compiling of the GUI
client alone if nothing in `connlib` changes and vice versa.

Resolves: #6913
Resolves: #5754
This commit is contained in:
Thomas Eizinger
2025-05-08 23:22:09 +10:00
committed by GitHub
parent e96fbde493
commit 18ec6c6860
33 changed files with 985 additions and 1036 deletions

27
rust/Cargo.lock generated
View File

@@ -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]]

View File

@@ -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 }

View File

@@ -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]

View File

@@ -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<IpAddr>,
search_domain: Option<DomainName>,
ipv4_routes: Vec<Ipv4Network>,
ipv6_routes: Vec<Ipv6Network>,
},
OnUpdateResources(Vec<ResourceView>),
}
#[derive(Clone)]
pub struct ChannelCallbackHandler {
cb_tx: mpsc::Sender<ConnlibMsg>,
}
impl ChannelCallbackHandler {
pub fn new() -> (Self, mpsc::Receiver<ConnlibMsg>) {
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<IpAddr>,
search_domain: Option<DomainName>,
ipv4_routes: Vec<Ipv4Network>,
ipv6_routes: Vec<Ipv6Network>,
) {
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<ResourceView>) {
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::<ConnlibMsg>(), 120)
}
}

View File

@@ -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};

View File

@@ -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!!

View File

@@ -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 <https://security.stackexchange.com/a/271285>. 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<PathBuf>,
/// 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<humantime::Duration>,
}
#[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));
}
}

View File

@@ -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::<firezone_headless_client::ipc::NotFound>()
.is::<firezone_gui_client::ipc::NotFound>()
{
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<logging::Handles> {
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`?

View File

@@ -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<Result<IpcServerMsg>>),
IpcMsg(Option<Result<ipc::ServerMsg>>),
ControllerRequest(Option<ControllerRequest>),
UpdateNotification(Option<Option<updates::Notification>>),
}
@@ -365,7 +356,7 @@ impl<I: GuiIntegration> Controller<I> {
}
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
Ok(())
}
async fn handle_ipc_msg(&mut self, msg: IpcServerMsg) -> Result<ControlFlow<()>> {
async fn handle_ipc_msg(&mut self, msg: ipc::ServerMsg) -> Result<ControlFlow<()>> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
.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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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<I: GuiIntegration> Controller<I> {
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 <https://github.com/firezone/firezone/issues/6547>.
tracing::error!("Failed to connect to Firezone: {error}");
@@ -717,7 +710,7 @@ impl<I: GuiIntegration> Controller<I> {
}
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<I: GuiIntegration> Controller<I> {
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(())
}

View File

@@ -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(())
}

View File

@@ -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?;

View File

@@ -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.

View File

@@ -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<bool, Error> {
pub fn gui_check() -> Result<bool, Error> {
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<bool, Error> {
pub fn gui_check() -> Result<bool, Error> {
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<bool, Error> {
pub fn gui_check() -> Result<bool, Error> {
Ok(true)
}
#[derive(Debug, Clone, Copy, thiserror::Error)]
pub(crate) enum Error {}
pub enum Error {}
}
#[cfg(test)]

View File

@@ -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<u64>,
pub fail_with: Option<Failure>,
}
/// 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.");

View File

@@ -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");

View File

@@ -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")
}

View File

@@ -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!()
}

View File

@@ -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<ReadHalf<ClientStream>, Decoder<ServerMsg>>;
pub type ClientWrite = FramedWrite<WriteHalf<ClientStream>, Encoder<ClientMsg>>;
pub(crate) type ServerRead = FramedRead<ReadHalf<ServerStream>, Decoder<ClientMsg>>;
pub(crate) type ServerWrite = FramedWrite<WriteHalf<ServerStream>, Encoder<ServerMsg>>;
#[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<IpAddr>) -> 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<D> {
inner: LengthDelimitedCodec,
_decode_type: std::marker::PhantomData<D>,
}
pub struct Encoder<E> {
inner: LengthDelimitedCodec,
_encode_type: std::marker::PhantomData<E>,
}
impl<D> Default for Decoder<D> {
fn default() -> Self {
Self {
inner: LengthDelimitedCodec::new(),
_decode_type: Default::default(),
}
}
}
impl<E> Default for Encoder<E> {
fn default() -> Self {
Self {
inner: LengthDelimitedCodec::new(),
_encode_type: Default::default(),
}
}
}
impl<D: serde::de::DeserializeOwned> tokio_util::codec::Decoder for Decoder<D> {
type Error = anyhow::Error;
type Item = D;
fn decode(&mut self, buf: &mut BytesMut) -> Result<Option<D>> {
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::<D>()))?;
Ok(Some(msg))
}
}
impl<E: serde::Serialize> tokio_util::codec::Encoder<&E> for Encoder<E> {
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<IpAddr>),
SetDisabledResources(BTreeSet<ResourceId>),
StartTelemetry {
environment: String,
release: String,
account_slug: Option<String>,
},
}
/// 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<ResourceView>),
/// 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<io::Error> for Error {
fn from(v: io::Error) -> Self {
Self::Io(v.to_string())
}
}
impl From<anyhow::Error> 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<Result<()>> = 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<Result<()>> = 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(())
}
}

View File

@@ -41,19 +41,22 @@ pub async fn connect_to_service(id: ServiceId) -> Result<ClientStream> {
impl Server {
/// Platform-specific setup
pub(crate) async fn new(id: ServiceId) -> Result<Self> {
pub(crate) fn new(id: ServiceId) -> Result<Self> {
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")),

View File

@@ -16,11 +16,7 @@ pub async fn connect_to_service(_id: ServiceId) -> Result<ClientStream> {
}
impl Server {
#[expect(
clippy::unused_async,
reason = "Signture must match other operating systems"
)]
pub(crate) async fn new(_id: ServiceId) -> Result<Self> {
pub(crate) fn new(_id: ServiceId) -> Result<Self> {
bail!("not implemented")
}

View File

@@ -46,10 +46,8 @@ pub(crate) async fn connect_to_service(id: ServiceId) -> Result<ClientStream> {
impl Server {
/// Platform-specific setup
///
/// This is async on Linux
#[expect(clippy::unused_async)]
pub(crate) async fn new(id: ServiceId) -> Result<Self> {
#[expect(clippy::unnecessary_wraps, reason = "Linux impl is fallible")]
pub(crate) fn new(id: ServiceId) -> Result<Self> {
let pipe_path = ipc_path(id);
Ok(Self { pipe_path })
}
@@ -159,6 +157,7 @@ fn create_pipe_server(pipe_path: &str) -> Result<named_pipe::NamedPipeServer, Pi
fn ipc_path(id: ServiceId) -> 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 {

View File

@@ -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"));

View File

@@ -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
/// <https://github.com/tokio-rs/tracing/issues/2360>
pub fn setup(directives: &str) -> Result<Handles> {
pub fn setup_gui(directives: &str) -> Result<Handles> {
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<Handles> {
.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<Handles> {
})
}
/// 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<PathBuf>,
) -> 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<FilterReloadHandle> {
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<String> {
#[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<tracing_journald::Layer> {
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

View File

@@ -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<IpAddr>),
SetDisabledResources(BTreeSet<ResourceId>),
StartTelemetry {
environment: String,
release: String,
account_slug: Option<String>,
},
}
/// 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<ResourceView>),
/// 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<io::Error> for Error {
fn from(v: io::Error) -> Self {
Self::Io(v.to_string())
}
}
impl From<anyhow::Error> 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 <https://security.stackexchange.com/a/271285>. 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. <https://github.com/firezone/firezone/issues/4899>
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 <https://github.com/firezone/firezone/issues/5052>
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<PathBuf>,
) -> 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. <https://github.com/firezone/firezone/issues/4899>
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.");
}

View File

@@ -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<PathBuf>, 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<bool> {
pub fn elevation_check() -> Result<bool> {
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")
}

View File

@@ -0,0 +1,18 @@
use anyhow::{Result, bail};
use firezone_bin_shared::DnsControlMethod;
use std::path::PathBuf;
pub fn run(log_dir: Option<PathBuf>, _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<bool> {
bail!("not implemented")
}
pub fn install() -> Result<()> {
bail!("not implemented")
}

View File

@@ -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<bool> {
pub fn elevation_check() -> Result<bool> {
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<OsSt
/// 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<()> {
pub fn run(_log_dir: Option<PathBuf>, _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<OsString>) {
// `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:#}");
}

View File

@@ -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. <https://github.com/firezone/firezone/pull/4328#discussion_r1540342142>
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

View File

@@ -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<ReadHalf<ClientStream>, Decoder<IpcServerMsg>>;
pub type ClientWrite = FramedWrite<WriteHalf<ClientStream>, Encoder<IpcClientMsg>>;
pub(crate) type ServerRead = FramedRead<ReadHalf<ServerStream>, Decoder<IpcClientMsg>>;
pub(crate) type ServerWrite = FramedWrite<WriteHalf<ServerStream>, Encoder<IpcServerMsg>>;
#[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<D> {
inner: LengthDelimitedCodec,
_decode_type: std::marker::PhantomData<D>,
}
pub struct Encoder<E> {
inner: LengthDelimitedCodec,
_encode_type: std::marker::PhantomData<E>,
}
impl<D> Default for Decoder<D> {
fn default() -> Self {
Self {
inner: LengthDelimitedCodec::new(),
_decode_type: Default::default(),
}
}
}
impl<E> Default for Encoder<E> {
fn default() -> Self {
Self {
inner: LengthDelimitedCodec::new(),
_encode_type: Default::default(),
}
}
}
impl<D: serde::de::DeserializeOwned> tokio_util::codec::Decoder for Decoder<D> {
type Error = anyhow::Error;
type Item = D;
fn decode(&mut self, buf: &mut BytesMut) -> Result<Option<D>> {
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::<D>()))?;
Ok(Some(msg))
}
}
impl<E: serde::Serialize> tokio_util::codec::Encoder<&E> for Encoder<E> {
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<Result<()>> = 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<Result<()>> = 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(())
}
}

View File

@@ -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<bool> {
bail!("not implemented")
}
pub(crate) fn install_ipc_service() -> Result<()> {
bail!("not implemented")
}

View File

@@ -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<PathBuf>,
/// 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<humantime::Duration>,
}
/// 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<IpAddr>,
search_domain: Option<DomainName>,
ipv4_routes: Vec<Ipv4Network>,
ipv6_routes: Vec<Ipv6Network>,
},
OnUpdateResources(Vec<ResourceView>),
}
#[derive(Clone)]
pub struct CallbackHandler {
pub cb_tx: mpsc::Sender<ConnlibMsg>,
}
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<IpAddr>,
search_domain: Option<DomainName>,
ipv4_routes: Vec<Ipv4Network>,
ipv6_routes: Vec<Ipv6Network>,
) {
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<ResourceView>) {
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<FilterReloadHandle> {
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<String> {
#[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::<ConnlibMsg>(), 120)
}
}

View File

@@ -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<Cmd>,
#[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<PathBuf>,
/// 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<humantime::Duration>,
#[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. <https://github.com/firezone/firezone/issues/4899>
@@ -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")));
}
}