feat(connlib): only conditionally hash firezone ID (#9633)

A bit of legacy that we have inherited around our Firezone ID is that
the ID stored on the user's device is sha'd before being passed to the
portal as the "external ID". This makes it difficult to correlate IDs in
Sentry and PostHog with the data we have in the portal. For Sentry and
PostHog, we submit the raw UUID stored on the user's device.

As a first step in overcoming this, we embed an "external ID" in those
services as well IF the provided Firezone ID is a valid UUID. This will
allow us to immediately correlate those events.

As a second step, we automatically generate all new Firezone IDs for the
Windows and Linux Client as `hex(sha256(uuid))`. These won't parse as
valid UUIDs and therefore will be submitted as is to the portal.

As a third step, we update all documentation around generating Firezone
IDs to use `uuidgen | sha256` instead of just `uuidgen`. This is
effectively the equivalent of (2) but for the Headless Client and
Gateway where the Firezone ID can be configured via environment
variables.

Resolves: #9382

---------

Signed-off-by: Thomas Eizinger <thomas@eizinger.io>
Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
Thomas Eizinger
2025-06-24 09:05:48 +02:00
committed by GitHub
parent 686918f1d1
commit a91dda139f
12 changed files with 151 additions and 51 deletions

5
rust/Cargo.lock generated
View File

@@ -2311,6 +2311,7 @@ dependencies = [
"flume",
"futures",
"gat-lending-iterator",
"hex",
"hex-literal",
"ip-packet",
"ip_network",
@@ -2327,6 +2328,7 @@ dependencies = [
"rtnetlink",
"serde",
"serde_json",
"sha2",
"smbios-lib",
"socket-factory",
"tempfile",
@@ -2570,6 +2572,7 @@ name = "firezone-telemetry"
version = "0.1.0"
dependencies = [
"anyhow",
"hex",
"ip-packet",
"moka",
"opentelemetry",
@@ -2579,9 +2582,11 @@ dependencies = [
"sentry",
"serde",
"serde_json",
"sha2",
"thiserror 2.0.12",
"tokio",
"tracing",
"uuid",
]
[[package]]

View File

@@ -15,11 +15,13 @@ dns-types = { workspace = true }
firezone-logging = { workspace = true }
futures = { workspace = true, features = ["std", "async-await"] }
gat-lending-iterator = { workspace = true }
hex = { workspace = true }
hex-literal = { workspace = true }
ip-packet = { workspace = true }
ip_network = { workspace = true, features = ["serde"] }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true }
sha2 = { workspace = true }
smbios-lib = { workspace = true }
socket-factory = { workspace = true }
thiserror = { workspace = true }
@@ -75,11 +77,11 @@ features = [
[dev-dependencies]
bufferpool = { workspace = true }
bytes = { workspace = true }
tempfile = { workspace = true }
tokio = { workspace = true, features = ["macros", "rt-multi-thread"] }
[target.'cfg(target_os = "linux")'.dev-dependencies]
mutants = "0.0.3" # Needed to mark functions as exempt from `cargo-mutants` testing
tempfile = { workspace = true }
[target.'cfg(windows)'.dev-dependencies]
ip-packet = { workspace = true }

View File

@@ -2,13 +2,14 @@
use anyhow::{Context as _, Result};
use atomicwrites::{AtomicFile, OverwriteBehavior};
use sha2::Digest;
use std::{
fs,
io::Write,
path::{Path, PathBuf},
};
#[derive(Debug, Clone)]
#[derive(Debug, Clone, PartialEq)]
pub struct DeviceId {
pub id: String,
}
@@ -27,12 +28,18 @@ pub(crate) fn path() -> Result<PathBuf> {
/// Returns the device ID without generating it
pub fn get() -> Result<DeviceId> {
let path = path()?;
let content = fs::read_to_string(&path).context("Failed to read file")?;
let id = get_at(&path)?;
Ok(id)
}
fn get_at(path: &Path) -> Result<DeviceId> {
let content = fs::read_to_string(path).context("Failed to read file")?;
let device_id_json = serde_json::from_str::<DeviceIdJson>(&content)
.context("Failed to deserialize content as JSON")?;
Ok(DeviceId {
id: device_id_json.device_id(),
id: device_id_json.id,
})
}
@@ -46,6 +53,12 @@ pub fn get() -> Result<DeviceId> {
/// 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<DeviceId> {
let path = path()?;
let id = get_or_create_at(&path)?;
Ok(id)
}
pub fn get_or_create_at(path: &Path) -> Result<DeviceId> {
let dir = path
.parent()
.context("Device ID path should always have a parent")?;
@@ -60,31 +73,29 @@ pub fn get_or_create() -> Result<DeviceId> {
})?;
// Try to read it from the disk
if let Some(j) = fs::read_to_string(&path)
if let Some(j) = fs::read_to_string(path)
.ok()
.and_then(|s| serde_json::from_str::<DeviceIdJson>(&s).ok())
{
let id = j.device_id();
tracing::debug!(?id, "Loaded device ID from disk");
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 });
set_id_permissions(path).context("Couldn't set permissions on Firezone ID file")?;
return Ok(DeviceId { id: j.id });
}
// Couldn't read, it's missing or invalid, generate a new one and save it.
let id = uuid::Uuid::new_v4();
let j = DeviceIdJson { id };
let id = hex::encode(sha2::Sha256::digest(uuid::Uuid::new_v4().to_string()));
let j = DeviceIdJson { id: id.clone() };
let content =
serde_json::to_string(&j).context("Impossible: Failed to serialize firezone-id")?;
let file = AtomicFile::new(&path, OverwriteBehavior::DisallowOverwrite);
let file = AtomicFile::new(path, OverwriteBehavior::DisallowOverwrite);
file.write(|f| f.write_all(content.as_bytes()))
.context("Failed to write firezone-id file")?;
let id = j.device_id();
tracing::debug!(?id, "Saved device ID to disk");
set_id_permissions(&path).context("Couldn't set permissions on Firezone ID file")?;
tracing::debug!(%id, "Saved device ID to disk");
set_id_permissions(path).context("Couldn't set permissions on Firezone ID file")?;
Ok(DeviceId { id })
}
@@ -123,11 +134,42 @@ fn set_id_permissions(_: &Path) -> Result<()> {
#[derive(serde::Deserialize, serde::Serialize)]
struct DeviceIdJson {
id: uuid::Uuid,
id: String,
}
impl DeviceIdJson {
fn device_id(&self) -> String {
self.id.to_string()
#[cfg(test)]
mod tests {
use tempfile::tempdir;
use uuid::Uuid;
use super::*;
#[test]
fn creates_id_if_not_exist() {
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();
assert_eq!(created_device_id, read_device_id);
}
#[test]
fn does_not_override_existing_id() {
let dir = tempdir().unwrap();
let path = dir.path().join("id.json");
let plain_id = Uuid::new_v4();
let json = serde_json::to_string(&serde_json::json!({
"id": plain_id
}))
.unwrap();
std::fs::write(&path, json).unwrap();
let read_device_id = get_or_create_at(&path).unwrap();
assert_eq!(read_device_id.id, plain_id.to_string());
}
}

View File

@@ -6,6 +6,7 @@ use std::{
iter,
marker::PhantomData,
net::{Ipv4Addr, Ipv6Addr},
str::FromStr as _,
};
use url::Url;
use uuid::Uuid;
@@ -83,7 +84,12 @@ impl LoginUrl<PublicKeyParam> {
device_name: Option<String>,
device_info: DeviceInfo,
) -> Result<Self, LoginUrlError<E>> {
let external_id = hex::encode(sha2::Sha256::digest(device_id));
let external_id = if uuid::Uuid::from_str(&device_id).is_ok() {
hex::encode(sha2::Sha256::digest(device_id))
} else {
device_id
};
let device_name = device_name
.or(get_host_name())
.unwrap_or_else(|| Uuid::new_v4().to_string());
@@ -116,7 +122,11 @@ impl LoginUrl<PublicKeyParam> {
device_id: String,
device_name: Option<String>,
) -> Result<Self, LoginUrlError<E>> {
let external_id = hex::encode(sha2::Sha256::digest(device_id));
let external_id = if uuid::Uuid::from_str(&device_id).is_ok() {
hex::encode(sha2::Sha256::digest(device_id))
} else {
device_id
};
let device_name = device_name
.or(get_host_name())
.unwrap_or_else(|| Uuid::new_v4().to_string());

View File

@@ -19,8 +19,8 @@ Linux host:
securely in your Gateway's shell environment. The Gateway requires this
variable at startup.
1. Set `FIREZONE_ID` to a unique string to identify this gateway in the portal,
e.g. `export FIREZONE_ID=$(uuidgen)`. The Gateway requires this variable at
startup.
e.g. `export FIREZONE_ID=$(head -c 32 /dev/urandom | sha256)`. The Gateway requires this variable at
startup. We recommend this to be a 64 character hex string.
1. Now, you can start the Gateway with:
```

View File

@@ -7,7 +7,7 @@ use anyhow::{Context, Result};
use backoff::ExponentialBackoffBuilder;
use clap::Parser;
use firezone_bin_shared::{
TunDeviceManager, http_health_check,
TunDeviceManager, device_id, http_health_check,
platform::{tcp_socket_factory, udp_socket_factory},
};
@@ -25,12 +25,10 @@ use std::sync::Arc;
use std::{collections::BTreeSet, path::Path};
use std::{fmt, pin::pin};
use std::{process::ExitCode, str::FromStr};
use tokio::io::AsyncWriteExt;
use tokio::signal::ctrl_c;
use tracing_subscriber::layer;
use tun::Tun;
use url::Url;
use uuid::Uuid;
mod eventloop;
@@ -215,12 +213,9 @@ async fn get_firezone_id(env_id: Option<String>) -> Result<String> {
}
}
let id_path = Path::new(ID_PATH);
tokio::fs::create_dir_all(id_path.parent().context("Missing parent")?).await?;
let mut id_file = tokio::fs::File::create(id_path).await?;
let id = Uuid::new_v4().to_string();
id_file.write_all(id.as_bytes()).await?;
Ok(id)
let device_id = device_id::get_or_create_at(Path::new(ID_PATH))?;
Ok(device_id.id)
}
#[derive(Parser, Debug)]

View File

@@ -19,8 +19,8 @@ To run the headless Client:
1. Ensure `/etc/dev.firezone.client/token` is only readable by root (i.e. `chmod 400`)
1. Ensure `/etc/dev.firezone.client/token` contains the Service account token. The Client needs this before it can start
1. Set `FIREZONE_ID` to a unique string to identify this client in the portal,
e.g. `export FIREZONE_ID=$(uuidgen)`. The client requires this variable at
startup.
e.g. `export FIREZONE_ID=$(head -c 32 /dev/urandom | sha256)`. The client requires this variable at
startup. We recommend this to be a 64 character hex string.
1. Set `LOG_DIR` to a suitable directory for writing logs
```
export LOG_DIR=/tmp/firezone-logs

View File

@@ -6,6 +6,7 @@ license = { workspace = true }
[dependencies]
anyhow = { workspace = true }
hex = { workspace = true }
ip-packet = { workspace = true }
moka = { workspace = true, features = ["sync"] }
opentelemetry = { workspace = true }
@@ -15,8 +16,10 @@ reqwest = { workspace = true }
sentry = { workspace = true, features = ["contexts", "backtrace", "debug-images", "panic", "reqwest", "rustls", "tracing", "release-health"] }
serde = { workspace = true }
serde_json = { workspace = true }
sha2 = { workspace = true }
tokio = { workspace = true, features = ["rt", "rt-multi-thread"] }
tracing = { workspace = true }
uuid = { workspace = true }
[dev-dependencies]
thiserror = { workspace = true }

View File

@@ -1,5 +1,8 @@
use std::str::FromStr;
use anyhow::{Context as _, Result, bail};
use serde::Serialize;
use sha2::Digest as _;
use crate::{Env, posthog::RUNTIME};
@@ -20,23 +23,47 @@ pub fn new_session(distinct_id: String, api_url: String) {
/// Associate several properties with a particular "distinct_id" in PostHog.
pub fn identify(distinct_id: String, api_url: String, release: String) {
RUNTIME.spawn(async move {
if let Err(e) = capture(
"$identify",
distinct_id,
api_url,
IdentifyProperties {
set: PersonProperties {
release,
os: std::env::consts::OS.to_owned(),
RUNTIME.spawn({
let distinct_id = distinct_id.clone();
let api_url = api_url.clone();
async move {
if let Err(e) = capture(
"$identify",
distinct_id,
api_url,
IdentifyProperties {
set: PersonProperties {
release,
os: std::env::consts::OS.to_owned(),
},
},
},
)
.await
{
tracing::debug!("Failed to log `$identify` event: {e:#}");
)
.await
{
tracing::debug!("Failed to log `$identify` event: {e:#}");
}
}
});
// Create an alias ID for the user so we can also find them under the "external ID" used in the portal.
if uuid::Uuid::from_str(&distinct_id).is_ok() {
RUNTIME.spawn(async move {
if let Err(e) = capture(
"$create_alias",
distinct_id.clone(),
api_url,
CreateAliasProperties {
alias: hex::encode(sha2::Sha256::digest(&distinct_id)),
distinct_id,
},
)
.await
{
tracing::debug!("Failed to log `$create_alias` event: {e:#}");
}
});
}
}
async fn capture<P>(
@@ -107,3 +134,9 @@ struct PersonProperties {
#[serde(rename = "$os")]
os: String,
}
#[derive(serde::Serialize)]
struct CreateAliasProperties {
distinct_id: String,
alias: String,
}

View File

@@ -7,6 +7,7 @@ use sentry::{
BeforeCallback,
protocol::{Event, SessionStatus},
};
use sha2::Digest as _;
pub mod analytics;
pub mod feature_flags;
@@ -215,7 +216,16 @@ impl Telemetry {
pub fn set_firezone_id(id: String) {
update_user({
let id = id.clone();
|user| user.id = Some(id)
move |user| {
user.id = Some(id.clone());
if uuid::Uuid::from_str(&id).is_ok() {
user.other.insert(
"external_id".to_owned(),
serde_json::Value::String(hex::encode(sha2::Sha256::digest(&id))),
);
}
}
});
let Some(client) = sentry::Hub::main().client() else {

View File

@@ -39,7 +39,7 @@ for RUNNING_CONTAINER in $CURRENTLY_RUNNING; do
else
# Generate a new FIREZONE_ID if not found
if ! grep -q "^FIREZONE_ID=" variables.env; then
echo "FIREZONE_ID=$(uuidgen)" >>variables.env
echo "FIREZONE_ID=$(head -c 32 /dev/urandom | sha256)" >>variables.env
fi
fi

View File

@@ -57,7 +57,7 @@ Set some environment variables to configure it:
```bash
export FIREZONE_NAME="Development API test client"
export FIREZONE_ID=$(uuidgen)
export FIREZONE_ID=$(head -c 32 /dev/urandom | sha256)
export FIREZONE_TOKEN=<TOKEN>
export LOG_DIR="./"
sudo -E ./firezone-client-headless-linux_1.0.0_x86_64