mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
27
rust/Cargo.lock
generated
27
rust/Cargo.lock
generated
@@ -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]]
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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!!
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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`?
|
||||
@@ -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(())
|
||||
}
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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?;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
|
||||
@@ -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!()
|
||||
}
|
||||
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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")),
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
26
rust/gui-client/src-tauri/src/lib.rs
Normal file
26
rust/gui-client/src-tauri/src/lib.rs
Normal 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"));
|
||||
@@ -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
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
18
rust/gui-client/src-tauri/src/service/macos.rs
Normal file
18
rust/gui-client/src-tauri/src/service/macos.rs
Normal 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")
|
||||
}
|
||||
@@ -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:#}");
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user