From 3e9ef4772b2a7ebafb58967e1d62aa75a6f1431e Mon Sep 17 00:00:00 2001 From: Thomas Eizinger Date: Thu, 30 Oct 2025 13:13:22 +1100 Subject: [PATCH] feat(gateway): extend flow logs with more client properties (#10717) In order to make the flow logs emitted by the Gateway more useful and self-contained, we extend the `authorize_flow` message sent to the Gateway with some more context around the Client and Actor of that flow. In particular, we now also send the following to the Gateway: - `client_version` - `device_os_version` - `device_os_name` - `device_serial` - `device_uuid` - `device_identifier_for_vendor` - `device_firebase_installation_id` - `identity_id` - `identity_name` - `actor_id` - `actor_email` We only extend the `authorize_flow` message with these additional properties. The legacy messages for 1.3.x Clients remain as is. For those Clients, the above properties will be empty in the flow logs. Resolves: #10690 --------- Signed-off-by: Thomas Eizinger Co-authored-by: Jamil --- elixir/apps/api/lib/api/client/channel.ex | 3 +- elixir/apps/api/lib/api/gateway/channel.ex | 7 +- .../apps/api/lib/api/gateway/views/client.ex | 22 +- .../apps/api/lib/api/gateway/views/subject.ex | 12 ++ .../apps/api/test/api/client/channel_test.exs | 4 +- .../apps/api/test/api/client/socket_test.exs | 2 +- .../api/test/api/gateway/channel_test.exs | 47 ++++- .../test/api/gateway/views/client_test.exs | 92 ++++++++ .../apps/domain/test/support/fixtures/auth.ex | 2 +- .../domain/test/support/fixtures/clients.ex | 2 +- rust/Cargo.toml | 1 + rust/connlib/tunnel/src/gateway.rs | 80 +++++-- .../tunnel/src/gateway/client_on_gateway.rs | 61 +++++- .../tunnel/src/gateway/flow_tracker.rs | 198 +++++++++++++++++- rust/connlib/tunnel/src/messages/gateway.rs | 28 +++ rust/connlib/tunnel/src/messages/key.rs | 6 + rust/connlib/tunnel/src/tests/sut.rs | 25 ++- rust/gateway/src/eventloop.rs | 11 +- 18 files changed, 541 insertions(+), 62 deletions(-) create mode 100644 elixir/apps/api/lib/api/gateway/views/subject.ex create mode 100644 elixir/apps/api/test/api/gateway/views/client_test.exs diff --git a/elixir/apps/api/lib/api/client/channel.ex b/elixir/apps/api/lib/api/client/channel.ex index 8e2ae5daf..7740683da 100644 --- a/elixir/apps/api/lib/api/client/channel.ex +++ b/elixir/apps/api/lib/api/client/channel.ex @@ -377,7 +377,8 @@ defmodule API.Client.Channel do flow_id: flow.id, authorization_expires_at: expires_at, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: socket.assigns.subject }} ) diff --git a/elixir/apps/api/lib/api/gateway/channel.ex b/elixir/apps/api/lib/api/gateway/channel.ex index 5676c81ff..e1d1a7517 100644 --- a/elixir/apps/api/lib/api/gateway/channel.ex +++ b/elixir/apps/api/lib/api/gateway/channel.ex @@ -8,6 +8,7 @@ defmodule API.Gateway.Channel do Changes.Change, Flows, Gateways, + Auth, PubSub, Relays, Resources @@ -226,7 +227,8 @@ defmodule API.Gateway.Channel do flow_id: flow_id, authorization_expires_at: authorization_expires_at, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: %Auth.Subject{} = subject } = payload ref = @@ -244,7 +246,8 @@ defmodule API.Gateway.Channel do gateway_ice_credentials: ice_credentials.gateway, client: Views.Client.render(client, preshared_key), client_ice_credentials: ice_credentials.client, - expires_at: DateTime.to_unix(authorization_expires_at, :second) + expires_at: DateTime.to_unix(authorization_expires_at, :second), + subject: Views.Subject.render(subject) }) cache = diff --git a/elixir/apps/api/lib/api/gateway/views/client.ex b/elixir/apps/api/lib/api/gateway/views/client.ex index 504f9de95..6bad3d6de 100644 --- a/elixir/apps/api/lib/api/gateway/views/client.ex +++ b/elixir/apps/api/lib/api/gateway/views/client.ex @@ -2,12 +2,32 @@ defmodule API.Gateway.Views.Client do alias Domain.Clients def render(%Clients.Client{} = client, preshared_key) do + # The OS name can have spaces, hence split the user-agent step by step. + [os_name, rest] = String.split(client.last_seen_user_agent, "/", parts: 2) + [os_version, rest] = String.split(rest, " ", parts: 2) + [_, rest] = String.split(rest, "/", parts: 2) + + # TODO: For easier testing, we re-parse the client version here. + # Long term, we should not be parsing the user-agent at all in here. + # Instead we should directly store the parsed information in the DB. + [client_version | _] = String.split(rest, " ", parts: 2) + + # Note: We purposely omit the client_type as that will say `connlib` for older clients + # (we've only recently changed this to `apple-client` etc). + %{ id: client.id, public_key: client.public_key, preshared_key: preshared_key, ipv4: client.ipv4, - ipv6: client.ipv6 + ipv6: client.ipv6, + version: client_version, + device_serial: client.device_serial, + device_os_name: os_name, + device_os_version: os_version, + device_uuid: client.device_uuid, + firebase_installation_id: client.firebase_installation_id, + identifier_for_vendor: client.identifier_for_vendor } end diff --git a/elixir/apps/api/lib/api/gateway/views/subject.ex b/elixir/apps/api/lib/api/gateway/views/subject.ex new file mode 100644 index 000000000..15c3c17b5 --- /dev/null +++ b/elixir/apps/api/lib/api/gateway/views/subject.ex @@ -0,0 +1,12 @@ +defmodule API.Gateway.Views.Subject do + alias Domain.Auth + + def render(%Auth.Subject{} = subject) do + %{ + identity_id: get_in(subject, [Access.key(:identity), Access.key(:id)]), + identity_name: subject.actor.name, + actor_id: subject.actor.id, + actor_email: get_in(subject, [Access.key(:identity), Access.key(:email)]) + } + end +end diff --git a/elixir/apps/api/test/api/client/channel_test.exs b/elixir/apps/api/test/api/client/channel_test.exs index c8fed9dad..df6d92b81 100644 --- a/elixir/apps/api/test/api/client/channel_test.exs +++ b/elixir/apps/api/test/api/client/channel_test.exs @@ -2823,7 +2823,7 @@ defmodule API.Client.ChannelTest do account: account, group: internet_gateway_group, context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.2.0" + user_agent: "iOS/12.5 connlib/1.2.0" } ) |> Repo.preload(:group) @@ -2841,7 +2841,7 @@ defmodule API.Client.ChannelTest do account: account, group: internet_gateway_group, context: %{ - user_agent: "iOS/12.5 (iPhone) connlib/1.3.0" + user_agent: "iOS/12.5 connlib/1.3.0" } ) |> Repo.preload(:group) diff --git a/elixir/apps/api/test/api/client/socket_test.exs b/elixir/apps/api/test/api/client/socket_test.exs index ffdf92658..cd04c1945 100644 --- a/elixir/apps/api/test/api/client/socket_test.exs +++ b/elixir/apps/api/test/api/client/socket_test.exs @@ -10,7 +10,7 @@ defmodule API.Client.SocketTest do ] @connect_info %{ - user_agent: "iOS/12.7 (iPhone) connlib/1.3.0", + user_agent: "iOS/12.7 connlib/1.3.0", peer_data: %{address: {189, 172, 73, 001}}, x_headers: [ diff --git a/elixir/apps/api/test/api/gateway/channel_test.exs b/elixir/apps/api/test/api/gateway/channel_test.exs index 8a2fa4413..410b062d5 100644 --- a/elixir/apps/api/test/api/gateway/channel_test.exs +++ b/elixir/apps/api/test/api/gateway/channel_test.exs @@ -117,7 +117,8 @@ defmodule API.Gateway.ChannelTest do client: client, resource: resource, socket: socket, - gateway: gateway + gateway: gateway, + subject: subject } do expired_flow = Fixtures.Flows.create_flow( @@ -146,7 +147,8 @@ defmodule API.Gateway.ChannelTest do flow_id: expired_flow.id, authorization_expires_at: expired_expiration, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) @@ -177,7 +179,8 @@ defmodule API.Gateway.ChannelTest do client: client, resource: resource, socket: socket, - gateway: gateway + gateway: gateway, + subject: subject } do expired_flow = Fixtures.Flows.create_flow( @@ -216,7 +219,8 @@ defmodule API.Gateway.ChannelTest do flow_id: expired_flow.id, authorization_expires_at: expired_expiration, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) @@ -231,7 +235,8 @@ defmodule API.Gateway.ChannelTest do flow_id: unexpired_flow.id, authorization_expires_at: unexpired_expiration, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) @@ -1488,7 +1493,8 @@ defmodule API.Gateway.ChannelTest do account: account, gateway: gateway, resource: resource, - socket: socket + socket: socket, + subject: subject } do flow = Fixtures.Flows.create_flow( @@ -1516,7 +1522,8 @@ defmodule API.Gateway.ChannelTest do flow_id: flow.id, authorization_expires_at: expires_at, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) @@ -1542,7 +1549,22 @@ defmodule API.Gateway.ChannelTest do ipv4: client.ipv4, ipv6: client.ipv6, preshared_key: preshared_key, - public_key: client.public_key + public_key: client.public_key, + version: client.last_seen_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + # Hardcode these to avoid having to reparse the user agent. + device_os_name: "iOS", + device_os_version: "12.5" + } + + assert payload.subject == %{ + identity_id: subject.identity.id, + identity_name: subject.actor.name, + actor_id: subject.actor.id, + actor_email: subject.identity.email } assert payload.client_ice_credentials == ice_credentials.client @@ -1588,7 +1610,8 @@ defmodule API.Gateway.ChannelTest do flow_id: flow.id, authorization_expires_at: expires_at, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) @@ -1629,7 +1652,8 @@ defmodule API.Gateway.ChannelTest do account: account, resource: resource, gateway: gateway, - socket: socket + socket: socket, + subject: subject } do flow = Fixtures.Flows.create_flow( @@ -1663,7 +1687,8 @@ defmodule API.Gateway.ChannelTest do flow_id: flow.id, authorization_expires_at: expires_at, ice_credentials: ice_credentials, - preshared_key: preshared_key + preshared_key: preshared_key, + subject: subject }} ) diff --git a/elixir/apps/api/test/api/gateway/views/client_test.exs b/elixir/apps/api/test/api/gateway/views/client_test.exs new file mode 100644 index 000000000..91c80b13f --- /dev/null +++ b/elixir/apps/api/test/api/gateway/views/client_test.exs @@ -0,0 +1,92 @@ +# The user-agents in this file have been taken from the production DB. + +defmodule API.Gateway.Views.ClientTest do + use ExUnit.Case, async: true + alias Domain.Clients + + describe "render/2" do + test "parses_linux_headless_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "Ubuntu/22.4.0 headless-client/1.5.4 (x86_64; 6.8.0-1036-azure)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "Ubuntu" + assert view.device_os_version == "22.4.0" + assert view.version == "1.5.4" + end + + test "parses_macos_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "Mac OS/15.4.1 apple-client/1.5.8 (arm64; 24.4.0)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "Mac OS" + assert view.device_os_version == "15.4.1" + assert view.version == "1.5.8" + end + + test "parses_android_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "Android/12 android-client/1.5.2 (4.14.180-perf+)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "Android" + assert view.device_os_version == "12" + assert view.version == "1.5.2" + end + + test "parses_windows_gui_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "Windows/10.0.26200 gui-client/1.5.8 (x86_64)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "Windows" + assert view.device_os_version == "10.0.26200" + assert view.version == "1.5.8" + end + + test "parses_ios_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "iOS/26.0.1 apple-client/1.5.8 (25.0.0)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "iOS" + assert view.device_os_version == "26.0.1" + assert view.version == "1.5.8" + end + + test "parses_pop_os_user_agent" do + client = %Clients.Client{ + last_seen_user_agent: "Pop!_OS/24.4.0 gui-client/1.5.8 (x86_64; 6.16.3-76061603-generic)" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "Pop!_OS" + assert view.device_os_version == "24.4.0" + assert view.version == "1.5.8" + end + + test "parses_user_agent_without_additional_data" do + client = %Clients.Client{ + last_seen_user_agent: "iOS/26.0.1 apple-client/1.5.8" + } + + view = API.Gateway.Views.Client.render(client, "") + + assert view.device_os_name == "iOS" + assert view.device_os_version == "26.0.1" + assert view.version == "1.5.8" + end + end +end diff --git a/elixir/apps/domain/test/support/fixtures/auth.ex b/elixir/apps/domain/test/support/fixtures/auth.ex index fee63869f..eb210171b 100644 --- a/elixir/apps/domain/test/support/fixtures/auth.ex +++ b/elixir/apps/domain/test/support/fixtures/auth.ex @@ -36,7 +36,7 @@ defmodule Domain.Fixtures.Auth do def user_password, do: "Hello w0rld!" def remote_ip, do: {100, 64, 100, 58} - def user_agent, do: "iOS/12.5 (iPhone) connlib/1.3.0" + def user_agent, do: "iOS/12.5 connlib/1.3.0" def email(domain \\ "example.com"), do: "user-#{unique_integer()}@#{domain}" def random_provider_identifier(%Domain.Auth.Provider{adapter: :email, name: name}) do diff --git a/elixir/apps/domain/test/support/fixtures/clients.ex b/elixir/apps/domain/test/support/fixtures/clients.ex index 7509a8550..47ca489b6 100644 --- a/elixir/apps/domain/test/support/fixtures/clients.ex +++ b/elixir/apps/domain/test/support/fixtures/clients.ex @@ -7,7 +7,7 @@ defmodule Domain.Fixtures.Clients do external_id: Ecto.UUID.generate(), name: "client-#{unique_integer()}", public_key: unique_public_key(), - last_seen_user_agent: "iOS/12.7 (iPhone) connlib/1.3.0", + last_seen_user_agent: "iOS/18.6.2 apple-client/1.5.8 (24.6.0)", last_seen_remote_ip: Enum.random([unique_ipv4(), unique_ipv6()]), last_seen_remote_ip_location_region: "US", last_seen_remote_ip_location_city: "San Francisco", diff --git a/rust/Cargo.toml b/rust/Cargo.toml index b81f35cde..7fcf50392 100644 --- a/rust/Cargo.toml +++ b/rust/Cargo.toml @@ -224,6 +224,7 @@ redundant_clone = "warn" unwrap_in_result = "warn" unwrap_used = "warn" too_many_arguments = "allow" # Don't care. +large_enum_variant = "allow" # Don't care. [workspace.lints.rustdoc] private-intra-doc-links = "allow" # We don't publish any of our docs but want to catch dead links. diff --git a/rust/connlib/tunnel/src/gateway.rs b/rust/connlib/tunnel/src/gateway.rs index 8c376cb62..8294faae1 100644 --- a/rust/connlib/tunnel/src/gateway.rs +++ b/rust/connlib/tunnel/src/gateway.rs @@ -7,8 +7,8 @@ pub(crate) use crate::gateway::client_on_gateway::ClientOnGateway; use crate::gateway::client_on_gateway::TranslateOutboundResult; use crate::gateway::flow_tracker::FlowTracker; -use crate::messages::gateway::ResourceDescription; -use crate::messages::{Answer, IceCredentials, ResolveRequest, SecretKey}; +use crate::messages::gateway::{Client, ResourceDescription, Subject}; +use crate::messages::{Answer, IceCredentials, ResolveRequest}; use crate::peer_store::PeerStore; use crate::{GatewayEvent, IpConfig, p2p_control}; use anyhow::{Context, Result}; @@ -169,7 +169,6 @@ impl GatewayState { return Ok(None); }; - flow_tracker::inbound_wg::record_client(cid); flow_tracker::inbound_wg::record_decrypted_packet(&packet); let peer = self @@ -177,6 +176,8 @@ impl GatewayState { .get_mut(&cid) .with_context(|| format!("No peer for connection {cid}"))?; + flow_tracker::inbound_wg::record_client(cid, peer.client_flow_properties()); + if let Some(fz_p2p_control) = packet.as_fz_p2p_control() { let immediate_response = match fz_p2p_control.event_type() { p2p_control::ASSIGNED_IPS_EVENT => { @@ -301,23 +302,21 @@ impl GatewayState { }) } - #[tracing::instrument(level = "debug", skip_all, fields(%cid))] + #[tracing::instrument(level = "debug", skip_all, fields(cid = %client.id))] pub fn authorize_flow( &mut self, - cid: ClientId, - client_key: PublicKey, - preshared_key: SecretKey, + client: Client, + subject: Subject, client_ice: IceCredentials, gateway_ice: IceCredentials, - client_tun: IpConfig, expires_at: Option>, resource: ResourceDescription, now: Instant, ) -> Result<(), NoTurnServers> { self.node.upsert_connection( - cid, - client_key, - x25519::StaticSecret::from(preshared_key.expose_secret().0), + client.id, + client.public_key.into(), + x25519::StaticSecret::from(client.preshared_key.expose_secret().0), Credentials { username: gateway_ice.username, password: gateway_ice.password, @@ -329,7 +328,29 @@ impl GatewayState { now, )?; - let result = self.allow_access(cid, client_tun, expires_at, resource, None); + let result = self.allow_access( + client.id, + IpConfig { + v4: client.ipv4, + v6: client.ipv6, + }, + flow_tracker::ClientProperties { + version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + identity_id: subject.identity_id, + identity_name: subject.identity_name, + actor_id: subject.actor_id, + actor_email: subject.actor_email, + }, + expires_at, + resource, + None, + ); debug_assert!( result.is_ok(), "`allow_access` should never fail without a `DnsResourceEntry`" @@ -342,6 +363,7 @@ impl GatewayState { &mut self, client: ClientId, client_tun: IpConfig, + client_props: flow_tracker::ClientProperties, expires_at: Option>, resource: ResourceDescription, dns_resource_nat: Option, @@ -351,7 +373,7 @@ impl GatewayState { let peer = self .peers .entry(client) - .or_insert_with(|| ClientOnGateway::new(client, client_tun, gateway_tun)); + .or_insert_with(|| ClientOnGateway::new(client, client_tun, gateway_tun, client_props)); peer.add_resource(resource.clone(), expires_at); @@ -465,7 +487,21 @@ impl GatewayState { tracing::trace!( target: "flow_logs::tcp", - client_id = %flow.client, + client_id = %flow.client_id, + client_version = flow.client_version.map(tracing::field::display), + + device_os_name = flow.device_os_name.map(tracing::field::display), + device_os_version = flow.device_os_version.map(tracing::field::display), + device_serial = flow.device_serial.map(tracing::field::display), + device_uuid = flow.device_uuid.map(tracing::field::display), + device_identifier_for_vendor = flow.device_identifier_for_vendor.map(tracing::field::display), + device_firebase_installation_id = flow.device_firebase_installation_id.map(tracing::field::display), + + identity_id = flow.identity_id.map(tracing::field::display), + identity_name = flow.identity_name.map(tracing::field::display), + actor_id = flow.actor_id.map(tracing::field::display), + actor_email = flow.actor_email.map(tracing::field::display), + resource_id = %flow.resource_id, resource_name = %flow.resource_name, resource_address = %flow.resource_address, @@ -495,7 +531,21 @@ impl GatewayState { tracing::trace!( target: "flow_logs::udp", - client_id = %flow.client, + client_id = %flow.client_id, + client_version = flow.client_version.map(tracing::field::display), + + device_os_name = flow.device_os_name.map(tracing::field::display), + device_os_version = flow.device_os_version.map(tracing::field::display), + device_serial = flow.device_serial.map(tracing::field::display), + device_uuid = flow.device_uuid.map(tracing::field::display), + device_identifier_for_vendor = flow.device_identifier_for_vendor.map(tracing::field::display), + device_firebase_installation_id = flow.device_firebase_installation_id.map(tracing::field::display), + + identity_id = flow.identity_id.map(tracing::field::display), + identity_name = flow.identity_name.map(tracing::field::display), + actor_id = flow.actor_id.map(tracing::field::display), + actor_email = flow.actor_email.map(tracing::field::display), + resource_id = %flow.resource_id, resource_name = %flow.resource_name, resource_address = %flow.resource_address, diff --git a/rust/connlib/tunnel/src/gateway/client_on_gateway.rs b/rust/connlib/tunnel/src/gateway/client_on_gateway.rs index e78790ed5..e80f2bfa0 100644 --- a/rust/connlib/tunnel/src/gateway/client_on_gateway.rs +++ b/rust/connlib/tunnel/src/gateway/client_on_gateway.rs @@ -28,6 +28,8 @@ pub struct ClientOnGateway { client_tun: IpConfig, gateway_tun: IpConfig, + flow_properties: flow_tracker::ClientProperties, + resources: BTreeMap, /// Caches the existence of internet resource internet_resource_enabled: Option, @@ -51,11 +53,13 @@ impl ClientOnGateway { id: ClientId, client_tun: IpConfig, gateway_tun: IpConfig, + flow_properties: flow_tracker::ClientProperties, ) -> ClientOnGateway { ClientOnGateway { id, client_tun, gateway_tun, + flow_properties, resources: BTreeMap::new(), filters: IpNetworkTable::new(), permanent_translations: Default::default(), @@ -568,6 +572,10 @@ impl ClientOnGateway { pub fn id(&self) -> ClientId { self.id } + + pub fn client_flow_properties(&self) -> flow_tracker::ClientProperties { + self.flow_properties.clone() + } } #[derive(Debug)] @@ -757,7 +765,12 @@ mod tests { #[test] fn gateway_filters_expire_individually() { - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); let now = Utc::now(); let then = now + Duration::from_secs(10); let after_then = then + Duration::from_secs(10); @@ -841,7 +854,12 @@ mod tests { #[test] fn allows_packets_for_and_from_gateway_tun_ip() { - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); let request = ip_packet::make::tcp_packet( client_tun_ipv4(), @@ -876,7 +894,12 @@ mod tests { #[test] fn dns_and_cidr_filters_dot_mix() { - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); peer.add_resource(foo_dns_resource(), None); peer.add_resource(bar_cidr_resource(), None); peer.setup_nat( @@ -942,7 +965,12 @@ mod tests { #[test] fn internet_resource_doesnt_allow_all_traffic_for_dns_resources() { - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); peer.add_resource(foo_dns_resource(), None); peer.add_resource(internet_resource(), None); peer.setup_nat( @@ -994,7 +1022,12 @@ mod tests { fn dns_resource_packet_is_dropped_after_nat_session_expires() { let _guard = firezone_logging::test("trace"); - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); peer.add_resource(foo_dns_resource(), None); peer.setup_nat( foo_name().parse().unwrap(), @@ -1061,7 +1094,12 @@ mod tests { let now = Instant::now(); - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); peer.add_resource(foo_dns_resource(), None); peer.setup_nat( foo_name().parse().unwrap(), @@ -1116,7 +1154,12 @@ mod tests { let now = Instant::now(); - let mut peer = ClientOnGateway::new(client_id(), client_tun(), gateway_tun()); + let mut peer = ClientOnGateway::new( + client_id(), + client_tun(), + gateway_tun(), + flow_tracker::ClientProperties::default(), + ); peer.add_resource(foo_dns_resource(), None); peer.add_resource(baz_dns_resource(), None); peer.setup_nat( @@ -1345,6 +1388,7 @@ mod proptests { v6: client_v6, }, gateway_tun(), + flow_tracker::ClientProperties::default(), ); for (resource, _, _) in &resources { peer.add_resource(resource.clone(), None); @@ -1403,6 +1447,7 @@ mod proptests { v6: client_v6, }, gateway_tun(), + flow_tracker::ClientProperties::default(), ); for ((filters, _), resource_id) in std::iter::zip(&protocol_config, resources_ids) { @@ -1466,6 +1511,7 @@ mod proptests { v6: client_v6, }, gateway_tun(), + flow_tracker::ClientProperties::default(), ); let packet = match protocol { Protocol::Tcp { dport } => { @@ -1523,6 +1569,7 @@ mod proptests { v6: client_v6, }, gateway_tun(), + flow_tracker::ClientProperties::default(), ); let packet_allowed = match protocol_allowed { diff --git a/rust/connlib/tunnel/src/gateway/flow_tracker.rs b/rust/connlib/tunnel/src/gateway/flow_tracker.rs index ae1be285c..28ee3292d 100644 --- a/rust/connlib/tunnel/src/gateway/flow_tracker.rs +++ b/rust/connlib/tunnel/src/gateway/flow_tracker.rs @@ -33,6 +33,22 @@ pub struct FlowTracker { created_at_utc: DateTime, } +/// Additional properties we track for a client. +#[derive(Debug, Clone, Default)] +pub struct ClientProperties { + pub version: Option, + pub device_serial: Option, + pub device_uuid: Option, + pub device_os_name: Option, + pub device_os_version: Option, + pub identifier_for_vendor: Option, + pub firebase_installation_id: Option, + pub identity_id: Option, + pub identity_name: Option, + pub actor_id: Option, + pub actor_email: Option, +} + impl FlowTracker { pub fn new(now: Instant) -> Self { Self { @@ -182,7 +198,7 @@ impl FlowTracker { match (src_proto, dst_proto) { (Protocol::Tcp(src_port), Protocol::Tcp(dst_port)) => { let key = TcpFlowKey { - client, + client: client.id, resource: resource.id, src_ip, dst_ip, @@ -209,6 +225,17 @@ impl FlowTracker { domain, resource_name: resource.name, resource_address: resource.address, + client_version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + actor_id: client.actor_id, + actor_email: client.actor_email, + identity_id: client.identity_id, + identity_name: client.identity_name, }); } hash_map::Entry::Occupied(occupied) if occupied.get().context != context => { @@ -237,6 +264,17 @@ impl FlowTracker { domain, resource_name: resource.name, resource_address: resource.address, + client_version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + actor_id: client.actor_id, + actor_email: client.actor_email, + identity_id: client.identity_id, + identity_name: client.identity_name, }, ); } @@ -261,6 +299,17 @@ impl FlowTracker { domain, resource_name: resource.name, resource_address: resource.address, + client_version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + actor_id: client.actor_id, + actor_email: client.actor_email, + identity_id: client.identity_id, + identity_name: client.identity_name, }, ); } @@ -286,7 +335,7 @@ impl FlowTracker { } (Protocol::Udp(src_port), Protocol::Udp(dst_port)) => { let key = UdpFlowKey { - client, + client: client.id, resource: resource.id, src_ip, dst_ip, @@ -306,6 +355,17 @@ impl FlowTracker { domain, resource_name: resource.name, resource_address: resource.address, + client_version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + actor_id: client.actor_id, + actor_email: client.actor_email, + identity_id: client.identity_id, + identity_name: client.identity_name, }); } hash_map::Entry::Occupied(occupied) if occupied.get().context != context => { @@ -332,6 +392,17 @@ impl FlowTracker { domain, resource_name: value.resource_name, resource_address: value.resource_address, + client_version: client.version, + device_os_name: client.device_os_name, + device_os_version: client.device_os_version, + device_serial: client.device_serial, + device_uuid: client.device_uuid, + identifier_for_vendor: client.identifier_for_vendor, + firebase_installation_id: client.firebase_installation_id, + actor_id: client.actor_id, + actor_email: client.actor_email, + identity_id: client.identity_id, + identity_name: client.identity_name, }, ); } @@ -457,7 +528,20 @@ pub enum CompletedFlow { #[derive(Debug)] pub struct CompletedTcpFlow { - pub client: ClientId, + pub client_id: ClientId, + pub client_version: Option, + + pub device_os_name: Option, + pub device_os_version: Option, + pub device_serial: Option, + pub device_uuid: Option, + pub device_identifier_for_vendor: Option, + pub device_firebase_installation_id: Option, + + pub identity_id: Option, + pub identity_name: Option, + pub actor_id: Option, + pub actor_email: Option, pub resource_id: ResourceId, pub resource_name: String, @@ -486,7 +570,20 @@ pub struct CompletedTcpFlow { #[derive(Debug)] pub struct CompletedUdpFlow { - pub client: ClientId, + pub client_id: ClientId, + pub client_version: Option, + + pub device_os_name: Option, + pub device_os_version: Option, + pub device_serial: Option, + pub device_uuid: Option, + pub device_identifier_for_vendor: Option, + pub device_firebase_installation_id: Option, + + pub identity_id: Option, + pub identity_name: Option, + pub actor_id: Option, + pub actor_email: Option, pub resource_id: ResourceId, pub resource_name: String, @@ -516,7 +613,18 @@ pub struct CompletedUdpFlow { impl CompletedTcpFlow { fn new(key: TcpFlowKey, value: TcpFlowValue, end: DateTime) -> Self { Self { - client: key.client, + client_id: key.client, + client_version: value.client_version, + device_os_name: value.device_os_name, + device_os_version: value.device_os_version, + device_serial: value.device_serial, + device_uuid: value.device_uuid, + device_identifier_for_vendor: value.identifier_for_vendor, + device_firebase_installation_id: value.firebase_installation_id, + actor_id: value.actor_id, + actor_email: value.actor_email, + identity_id: value.identity_id, + identity_name: value.identity_name, resource_id: key.resource, resource_name: value.resource_name, resource_address: value.resource_address, @@ -543,7 +651,18 @@ impl CompletedTcpFlow { impl CompletedUdpFlow { fn new(key: UdpFlowKey, value: UdpFlowValue, end: DateTime) -> Self { Self { - client: key.client, + client_id: key.client, + client_version: value.client_version, + device_os_name: value.device_os_name, + device_os_version: value.device_os_version, + device_serial: value.device_serial, + device_uuid: value.device_uuid, + device_identifier_for_vendor: value.identifier_for_vendor, + device_firebase_installation_id: value.firebase_installation_id, + actor_id: value.actor_id, + actor_email: value.actor_email, + identity_id: value.identity_id, + identity_name: value.identity_name, resource_id: key.resource, resource_name: value.resource_name, resource_address: value.resource_address, @@ -599,6 +718,19 @@ struct TcpFlowValue { resource_name: String, resource_address: String, + client_version: Option, + device_serial: Option, + device_uuid: Option, + device_os_name: Option, + device_os_version: Option, + identifier_for_vendor: Option, + firebase_installation_id: Option, + + identity_id: Option, + identity_name: Option, + actor_id: Option, + actor_email: Option, + fin_tx: bool, fin_rx: bool, } @@ -614,6 +746,19 @@ struct UdpFlowValue { resource_name: String, resource_address: String, + + client_version: Option, + device_serial: Option, + device_uuid: Option, + device_os_name: Option, + device_os_version: Option, + identifier_for_vendor: Option, + firebase_installation_id: Option, + + identity_id: Option, + identity_name: Option, + actor_id: Option, + actor_email: Option, } #[derive(Debug, Default, Clone, Copy)] @@ -708,8 +853,23 @@ pub mod inbound_wg { use super::*; - pub fn record_client(cid: ClientId) { - update_current_flow_inbound_wireguard(|wg| wg.client.replace(cid)); + pub fn record_client(cid: ClientId, props: ClientProperties) { + update_current_flow_inbound_wireguard(|wg| { + wg.client.replace(Client { + id: cid, + version: props.version, + device_os_name: props.device_os_name, + device_os_version: props.device_os_version, + device_serial: props.device_serial, + actor_id: props.actor_id, + actor_email: props.actor_email, + identity_id: props.identity_id, + identity_name: props.identity_name, + device_uuid: props.device_uuid, + identifier_for_vendor: props.identifier_for_vendor, + firebase_installation_id: props.firebase_installation_id, + }) + }); } pub fn record_resource(id: ResourceId, name: String, address: String) { @@ -812,7 +972,7 @@ enum FlowData { struct InboundWireGuard { outer: OuterFlow, inner: Option, - client: Option, + client: Option, resource: Option, /// The domain name in case this packet is for a DNS resource. domain: Option, @@ -847,6 +1007,26 @@ struct InnerFlow { payload_len: usize, } +#[derive(Debug)] +struct Client { + id: ClientId, + + version: Option, + + device_serial: Option, + device_uuid: Option, + device_os_name: Option, + device_os_version: Option, + + identifier_for_vendor: Option, + firebase_installation_id: Option, + + identity_id: Option, + identity_name: Option, + actor_id: Option, + actor_email: Option, +} + #[derive(Debug)] struct Resource { id: ResourceId, diff --git a/rust/connlib/tunnel/src/messages/gateway.rs b/rust/connlib/tunnel/src/messages/gateway.rs index ace6269bb..aaf182084 100644 --- a/rust/connlib/tunnel/src/messages/gateway.rs +++ b/rust/connlib/tunnel/src/messages/gateway.rs @@ -210,6 +210,32 @@ pub struct Client { pub preshared_key: SecretKey, pub ipv4: Ipv4Addr, pub ipv6: Ipv6Addr, + #[serde(default)] + pub version: Option, + #[serde(default)] + pub device_os_name: Option, + #[serde(default)] + pub device_os_version: Option, + #[serde(default)] + pub device_serial: Option, + #[serde(default)] + pub device_uuid: Option, + #[serde(default)] + pub identifier_for_vendor: Option, + #[serde(default)] + pub firebase_installation_id: Option, +} + +#[derive(Debug, Deserialize, Clone, Default)] +pub struct Subject { + #[serde(default)] + pub identity_id: Option, + #[serde(default)] + pub identity_name: Option, + #[serde(default)] + pub actor_id: Option, + #[serde(default)] + pub actor_email: Option, } #[derive(Debug, Deserialize, Clone)] @@ -220,6 +246,8 @@ pub struct AuthorizeFlow { pub resource: ResourceDescription, pub gateway_ice_credentials: IceCredentials, pub client: Client, + #[serde(default)] + pub subject: Subject, pub client_ice_credentials: IceCredentials, #[serde(with = "ts_seconds_option")] diff --git a/rust/connlib/tunnel/src/messages/key.rs b/rust/connlib/tunnel/src/messages/key.rs index c3477c31f..011bb054e 100644 --- a/rust/connlib/tunnel/src/messages/key.rs +++ b/rust/connlib/tunnel/src/messages/key.rs @@ -53,6 +53,12 @@ impl From for Key { } } +impl From for PublicKey { + fn from(value: Key) -> Self { + value.0.into() + } +} + impl fmt::Display for Key { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!(f, "{}", Base64Display::new(&self.0, &STANDARD)) diff --git a/rust/connlib/tunnel/src/tests/sut.rs b/rust/connlib/tunnel/src/tests/sut.rs index f045802ec..bb65a7952 100644 --- a/rust/connlib/tunnel/src/tests/sut.rs +++ b/rust/connlib/tunnel/src/tests/sut.rs @@ -10,6 +10,7 @@ use super::stub_portal::StubPortal; use super::transition::{Destination, DnsQuery}; use crate::client; use crate::dns::is_subdomain; +use crate::messages::gateway::{Client, Subject}; use crate::messages::{IceCredentials, Key, SecretKey}; use crate::tests::assertions::*; use crate::tests::flux_capacitor::FluxCapacitor; @@ -823,12 +824,28 @@ impl TunnelTest { gateway .exec_mut(|g| { g.sut.authorize_flow( - src, - client_key, - preshared_key.clone(), + Client { + id: src, + public_key: client_key.into(), + preshared_key: preshared_key.clone(), + ipv4: self.client.inner().sut.tunnel_ip_config().unwrap().v4, + ipv6: self.client.inner().sut.tunnel_ip_config().unwrap().v6, + device_os_name: None, + device_serial: None, + device_uuid: None, + identifier_for_vendor: None, + firebase_installation_id: None, + version: None, + device_os_version: None, + }, + Subject { + identity_name: None, + actor_email: None, + identity_id: None, + actor_id: None, + }, client_ice.clone(), gateway_ice.clone(), - self.client.inner().sut.tunnel_ip_config().unwrap(), None, resource, now, diff --git a/rust/gateway/src/eventloop.rs b/rust/gateway/src/eventloop.rs index 81a2375dc..e5798d935 100644 --- a/rust/gateway/src/eventloop.rs +++ b/rust/gateway/src/eventloop.rs @@ -352,15 +352,10 @@ impl Eventloop { match msg { IngressMessages::AuthorizeFlow(msg) => { if let Err(snownet::NoTurnServers {}) = tunnel.state_mut().authorize_flow( - msg.client.id, - PublicKey::from(msg.client.public_key.0), - msg.client.preshared_key, + msg.client, + msg.subject, msg.client_ice_credentials, msg.gateway_ice_credentials, - IpConfig { - v4: msg.client.ipv4, - v6: msg.client.ipv6, - }, msg.expires_at, msg.resource, Instant::now(), @@ -593,6 +588,7 @@ impl Eventloop { v4: req.client.peer.ipv4, v6: req.client.peer.ipv6, }, + Default::default(), // Additional client properties are not supported for 1.3.x Clients and will just be empty. req.expires_at, req.resource, req.client @@ -648,6 +644,7 @@ impl Eventloop { v4: req.client_ipv4, v6: req.client_ipv6, }, + Default::default(), // Additional client properties are not supported for 1.3.x Clients and will just be empty. req.expires_at, req.resource, req.payload.map(|r| DnsResourceNatEntry::new(r, addresses)),