diff --git a/rust/Cargo.lock b/rust/Cargo.lock index 91d0aaffa..c74fbabdc 100644 --- a/rust/Cargo.lock +++ b/rust/Cargo.lock @@ -2361,6 +2361,7 @@ dependencies = [ "gat-lending-iterator", "hex", "hex-literal", + "hmac", "ip-packet", "ip_network", "ipconfig", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index 355601366..36b5cf33b 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -95,6 +95,7 @@ hex = "0.4.3" hex-display = "0.3.0" hex-literal = "1.0.0" hickory-resolver = "0.25.2" +hmac = "0.12.1" http = "1.3.1" http-body-util = "0.1.3" http-client = { path = "connlib/http-client" } diff --git a/rust/bin-shared/Cargo.toml b/rust/bin-shared/Cargo.toml index 821c82afc..9ca99f9b9 100644 --- a/rust/bin-shared/Cargo.toml +++ b/rust/bin-shared/Cargo.toml @@ -34,6 +34,7 @@ uuid = { workspace = true, features = ["v4"] } [target.'cfg(target_os = "linux")'.dependencies] atomicwrites = { workspace = true } dirs = { workspace = true } +hmac = { workspace = true } libc = { workspace = true } netlink-packet-core = { workspace = true } netlink-packet-route = { workspace = true } @@ -80,7 +81,7 @@ features = [ bufferpool = { workspace = true } bytes = { workspace = true } tempfile = { workspace = true } -tokio = { workspace = true, features = ["macros", "rt-multi-thread"] } +tokio = { workspace = true, features = ["macros", "rt-multi-thread", "fs"] } [target.'cfg(target_os = "linux")'.dev-dependencies] mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing diff --git a/rust/bin-shared/src/device_id.rs b/rust/bin-shared/src/device_id.rs index 5f3671da7..33db9bf0e 100644 --- a/rust/bin-shared/src/device_id.rs +++ b/rust/bin-shared/src/device_id.rs @@ -9,16 +9,33 @@ use std::{ path::{Path, PathBuf}, }; +/// Randomly generated, hex-encoded 128bit identifier for Clients. +/// +/// Together with a unique hardware ID, like `/etc/machine-id`, this can be used to deterministically compute a device ID. +const CLIENT_APP_ID: &str = "e1e465ce763e4759945c650ac334501f"; + +/// Randomly generated, hex-encoded 128bit identifier for Gateways. +/// +/// Together with a unique hardware ID, like `/etc/machine-id`, this can be used to deterministically compute a device ID. +const GATEWAY_APP_ID: &str = "753b38f9f96947ef8083802d5909a372"; + #[derive(Debug, Clone, PartialEq)] pub struct DeviceId { pub id: String, + pub source: Source, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum Source { + Disk, + HardwareId, } /// Returns the path of the randomly-generated Firezone device ID /// /// e.g. `C:\ProgramData\dev.firezone.client/firezone-id.json` or /// `/var/lib/dev.firezone.client/config/firezone-id.json`. -pub fn path() -> Result { +pub fn client_path() -> Result { let path = crate::known_dirs::tunnel_service_config() .context("Failed to compute path for firezone-id file")? .join("firezone-id.json"); @@ -26,23 +43,13 @@ pub fn path() -> Result { } /// Returns the device ID without generating it -pub fn get() -> Result { - let path = path()?; - let id = get_at(&path)?; +pub fn get_client() -> Result { + let path = client_path()?; + let id = get_at_or_compute(&path, CLIENT_APP_ID)?; Ok(id) } -fn get_at(path: &Path) -> Result { - let content = fs::read_to_string(path).context("Failed to read file")?; - let device_id_json = serde_json::from_str::(&content) - .context("Failed to deserialize content as JSON")?; - - Ok(DeviceId { - id: device_id_json.id, - }) -} - /// Returns the device ID, generating it and saving it to disk if needed. /// /// Per and , @@ -51,14 +58,22 @@ fn get_at(path: &Path) -> Result { /// Returns: The UUID as a String, suitable for sending verbatim to `client_shared::Session::connect`. /// /// Errors: If the disk is unwritable when initially generating the ID, or unwritable when re-generating an invalid ID. -pub fn get_or_create() -> Result { - let path = path()?; - let id = get_or_create_at(&path)?; +pub fn get_or_create_client() -> Result { + let path = client_path()?; + let id = get_or_create_at(&path, CLIENT_APP_ID)?; Ok(id) } -pub fn get_or_create_at(path: &Path) -> Result { +pub fn get_or_create_gateway() -> Result { + const ID_PATH: &str = "/var/lib/firezone/gateway_id"; + + let id = get_or_create_at(Path::new(ID_PATH), GATEWAY_APP_ID)?; + + Ok(id) +} + +fn get_or_create_at(path: &Path, app_id: &str) -> Result { let dir = path .parent() .context("Device ID path should always have a parent")?; @@ -73,14 +88,8 @@ pub fn get_or_create_at(path: &Path) -> Result { })?; // Try to read it from the disk - if let Some(j) = fs::read_to_string(path) - .ok() - .and_then(|s| serde_json::from_str::(&s).ok()) - { - tracing::debug!(id = %j.id, "Loaded device ID from disk"); - // Correct permissions for #6989 - set_id_permissions(path).context("Couldn't set permissions on Firezone ID file")?; - return Ok(DeviceId { id: j.id }); + if let Ok(id) = get_at_or_compute(path, app_id) { + return Ok(id); } // Couldn't read, it's missing or invalid, generate a new one and save it. @@ -96,7 +105,38 @@ pub fn get_or_create_at(path: &Path) -> Result { tracing::debug!(%id, "Saved device ID to disk"); set_id_permissions(path).context("Couldn't set permissions on Firezone ID file")?; - Ok(DeviceId { id }) + + Ok(DeviceId { + id: j.id, + source: Source::Disk, + }) +} + +/// Reads the device ID from the given path, or if that fails, attempts to compute it from a hardware ID. +fn get_at_or_compute(path: &Path, app_id: &str) -> Result { + match (get_at(path), compute_from_hardware_id(app_id)) { + (Ok(fs_id), _) => Ok(fs_id), + (Err(_), Ok(derived_id)) => Ok(derived_id), + (Err(fs_err), Err(derive_err)) => { + anyhow::bail!("Failed to read ({fs_err:#}) and derive ({derive_err:#}) device ID") + } + } +} + +fn get_at(path: &Path) -> Result { + let content = fs::read_to_string(path).context("Failed to read file")?; + let j = serde_json::from_str::(&content) + .context("Failed to deserialize content as JSON")?; + + tracing::debug!(id = %j.id, "Loaded device ID from disk"); + + // Correct permissions for #6989 + set_id_permissions(path).context("Couldn't set permissions on Firezone ID file")?; + + Ok(DeviceId { + id: j.id, + source: Source::Disk, + }) } #[cfg(target_os = "linux")] @@ -132,6 +172,35 @@ fn set_id_permissions(_: &Path) -> Result<()> { Ok(()) } +#[cfg(target_os = "linux")] +fn compute_from_hardware_id(app_id: &str) -> Result { + use hmac::Mac; + + let machine_id = + fs::read_to_string("/etc/machine-id").context("Failed to read `/etc/machine-id`")?; + + let bytes = hmac::Hmac::::new_from_slice(app_id.as_bytes()) + .context("Failed to create HMAC instance")? + .chain_update(&machine_id) + .finalize() + .into_bytes() + .to_vec(); + + let id = hex::encode(bytes); + + tracing::debug!(%id, "Derived device ID from /etc/machine-id"); + + Ok(DeviceId { + id, + source: Source::HardwareId, + }) +} + +#[cfg(not(target_os = "linux"))] +fn compute_from_hardware_id(_: &str) -> Result { + anyhow::bail!("Not implemented") +} + #[derive(serde::Deserialize, serde::Serialize)] struct DeviceIdJson { id: String, @@ -149,8 +218,8 @@ mod tests { let dir = tempdir().unwrap(); let path = dir.path().join("id.json"); - let created_device_id = get_or_create_at(&path).unwrap(); - let read_device_id = get_at(&path).unwrap(); + let created_device_id = get_or_create_at(&path, CLIENT_APP_ID).unwrap(); + let read_device_id = get_at_or_compute(&path, CLIENT_APP_ID).unwrap(); assert_eq!(created_device_id, read_device_id); } @@ -168,8 +237,18 @@ mod tests { .unwrap(); std::fs::write(&path, json).unwrap(); - let read_device_id = get_or_create_at(&path).unwrap(); + let read_device_id = get_or_create_at(&path, CLIENT_APP_ID).unwrap(); assert_eq!(read_device_id.id, plain_id.to_string()); } + + #[test] + #[cfg(target_os = "linux")] + fn compute_device_id_hardware_id() { + let _guard = firezone_logging::test("debug"); + + let id = compute_from_hardware_id(CLIENT_APP_ID).unwrap(); + + assert!(!id.id.is_empty()) + } } diff --git a/rust/gateway/src/main.rs b/rust/gateway/src/main.rs index 68e15fd70..d08d7b0c0 100644 --- a/rust/gateway/src/main.rs +++ b/rust/gateway/src/main.rs @@ -22,7 +22,7 @@ use phoenix_channel::get_user_agent; use phoenix_channel::PhoenixChannel; use secrecy::{ExposeSecret, SecretBox, SecretString}; -use std::{collections::BTreeSet, fmt, path::Path}; +use std::{collections::BTreeSet, fmt}; use std::{path::PathBuf, process::ExitCode}; use std::{sync::Arc, time::Duration}; use tracing_subscriber::layer; @@ -31,7 +31,6 @@ use url::Url; mod eventloop; -const ID_PATH: &str = "/var/lib/firezone/gateway_id"; const RELEASE: &str = concat!("gateway@", env!("CARGO_PKG_VERSION")); fn main() -> ExitCode { @@ -124,7 +123,7 @@ async fn try_main(cli: Cli, telemetry: &mut Telemetry) -> Result<()> { }; } - let firezone_id = get_firezone_id(cli.firezone_id.clone()).await + let firezone_id = get_firezone_id(cli.firezone_id.clone()) .context("Couldn't read FIREZONE_ID or write it to disk: Please provide it through the env variable or provide rw access to /var/lib/firezone/")?; let token = match cli.token.clone() { @@ -251,20 +250,14 @@ fn tonic_otlp_exporter( Ok(metric_exporter) } -async fn get_firezone_id(env_id: Option) -> Result { +fn get_firezone_id(env_id: Option) -> Result { if let Some(id) = env_id && !id.is_empty() { return Ok(id); } - if let Ok(id) = tokio::fs::read_to_string(ID_PATH).await - && !id.is_empty() - { - return Ok(id); - } - - let device_id = device_id::get_or_create_at(Path::new(ID_PATH))?; + let device_id = device_id::get_or_create_gateway()?; Ok(device_id.id) } diff --git a/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs index 61ac2cab8..87b861439 100644 --- a/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs +++ b/rust/gui-client/src-tauri/src/bin/firezone-gui-client.rs @@ -85,7 +85,7 @@ fn try_main( // Get the device ID before starting Tokio, so that all the worker threads will inherit the correct scope. // Technically this means we can fail to get the device ID on a newly-installed system, since the Tunnel service may not have fully started up when the GUI process reaches this point, but in practice it's unlikely. - let id = firezone_bin_shared::device_id::get().context("Failed to get device ID")?; + let id = firezone_bin_shared::device_id::get_client().context("Failed to get device ID")?; if cli.is_telemetry_allowed() { rt.block_on(telemetry.start( diff --git a/rust/gui-client/src-tauri/src/elevation.rs b/rust/gui-client/src-tauri/src/elevation.rs index de1eb8d80..5b75b4044 100644 --- a/rust/gui-client/src-tauri/src/elevation.rs +++ b/rust/gui-client/src-tauri/src/elevation.rs @@ -36,9 +36,11 @@ mod platform { impl Error { pub fn user_friendly_msg(&self) -> String { match self { - Error::UserNotInFirezoneGroup => format!( - "You are not a member of the group `{FIREZONE_CLIENT_GROUP}`. Try `sudo usermod -aG {FIREZONE_CLIENT_GROUP} $USER` and then reboot" - ), + Error::UserNotInFirezoneGroup => { + format!( + "You are not a member of the group `{FIREZONE_CLIENT_GROUP}`. If you have just installed Firezone for the first time, you need to reboot your computer for membership changes to take effect." + ) + } Error::Other(e) => format!("Failed to determine group ownership: {e:#}"), } } diff --git a/rust/gui-client/src-tauri/src/service.rs b/rust/gui-client/src-tauri/src/service.rs index 29f72d168..69f27417e 100644 --- a/rust/gui-client/src-tauri/src/service.rs +++ b/rust/gui-client/src-tauri/src/service.rs @@ -113,12 +113,13 @@ async fn ipc_listen( ) -> Result<()> { // Create the device ID and Tunnel service config dir if needed // This also gives the GUI a safe place to put the log filter config - let device_id = device_id::get_or_create().context("Failed to read / create device ID")?; + let device_id = + device_id::get_or_create_client().context("Failed to read / create device ID")?; // Fix up the group of the device ID file and directory so the GUI client can access it. #[cfg(target_os = "linux")] - { - let path = device_id::path().context("Failed to access device ID path")?; + if device_id.source == device_id::Source::Disk { + let path = device_id::client_path().context("Failed to access device ID path")?; let group_id = crate::firezone_client_group() .context("Failed to get `firezone-client` group")? .gid @@ -630,7 +631,8 @@ impl<'a> Handler<'a> { ) -> Result { let started_at = Instant::now(); - let device_id = device_id::get_or_create().context("Failed to get-or-create device ID")?; + let device_id = + device_id::get_or_create_client().context("Failed to get-or-create device ID")?; let url = LoginUrl::client( Url::parse(api_url).context("Failed to parse URL")?, @@ -744,7 +746,8 @@ pub fn run_smoke_test() -> Result<()> { // Couldn't get the loop to work here yet, so SIGHUP is not implemented rt.block_on(async { - let device_id = device_id::get_or_create().context("Failed to read / create device ID")?; + let device_id = + device_id::get_or_create_client().context("Failed to read / create device ID")?; let mut server = ipc::Server::new(SocketId::Tunnel)?; let _ = Handler::new( device_id, diff --git a/rust/headless-client/src/main.rs b/rust/headless-client/src/main.rs index 34c657f1b..d0017f05d 100644 --- a/rust/headless-client/src/main.rs +++ b/rust/headless-client/src/main.rs @@ -246,7 +246,7 @@ fn try_main() -> Result<()> { // AKA "Device ID", not the Firezone slug let firezone_id = match cli.firezone_id.clone() { Some(id) => id, - None => device_id::get_or_create().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id, + None => device_id::get_or_create_client().context("Could not get `firezone_id` from CLI, could not read it from disk, could not generate it and save it to disk")?.id, }; let mut telemetry = if cli.is_telemetry_allowed() {