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 <thomas@eizinger.io>
Co-authored-by: Jamil <jamilbk@users.noreply.github.com>
This commit is contained in:
Thomas Eizinger
2025-10-30 13:13:22 +11:00
committed by GitHub
parent f872754540
commit 3e9ef4772b
18 changed files with 541 additions and 62 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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:
[

View File

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

View File

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

View File

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

View File

@@ -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",