fix(portal): use reproducible relay creds (#9857)

When giving TURN credentials to clients and gateways, it's important
that they remain consistent across hiccups in the portal connection so
that relayed connections are not interrupted during a deploy, or if the
user's internet is flaky, or the GCP load balancer decides to disconnect
the client/gateway.

Prior to this PR, that was not the case because we essentially tied TURN
credentials, required for data plane packet flows, to the WebSocket
connection, a control plane element. This happened because we generated
random `expires_at` and `salt` elements on _each_ connection to the
portal.

Instead, what we do now is make these reproducible and tied to the auth
token by hashing then base64-encoding it. The expiry is tied to the
auth-token's expiry.


Fixes #9856
This commit is contained in:
Jamil
2025-07-14 10:42:11 -07:00
committed by GitHub
parent 2e0ed018ee
commit 1e577d31b9
11 changed files with 121 additions and 81 deletions

View File

@@ -107,7 +107,12 @@ defmodule API.Client.Channel do
push(socket, "init", %{
resources: Views.Resource.render_many(resources),
relays: Views.Relay.render_many(relays, socket.assigns.subject.expires_at),
relays:
Views.Relay.render_many(
relays,
socket.assigns.turn_salt,
socket.assigns.subject.expires_at
),
interface:
Views.Interface.render(%{
socket.assigns.client
@@ -439,7 +444,12 @@ defmodule API.Client.Channel do
payload = %{
disconnected_ids: [relay_id],
connected: Views.Relay.render_many(relays, socket.assigns.subject.expires_at)
connected:
Views.Relay.render_many(
relays,
socket.assigns.turn_salt,
socket.assigns.subject.expires_at
)
}
{:noreply, Debouncer.queue_leave(self(), socket, relay_id, payload)}
@@ -492,7 +502,12 @@ defmodule API.Client.Channel do
push(socket, "relays_presence", %{
disconnected_ids: disconnected_ids,
connected: Views.Relay.render_many(relays, socket.assigns.subject.expires_at)
connected:
Views.Relay.render_many(
relays,
socket.assigns.turn_salt,
socket.assigns.subject.expires_at
)
})
{:noreply, socket}

View File

@@ -28,8 +28,14 @@ defmodule API.Client.Socket do
account_id: subject.account.id
})
# For Relay credentials
turn_salt =
Domain.Crypto.hash(:sha256, token)
|> Base.url_encode64(padding: false)
socket =
socket
|> assign(:turn_salt, turn_salt)
|> assign(:subject, subject)
|> assign(:client, client)
|> assign(:opentelemetry_span_ctx, OpenTelemetry.Tracer.current_span_ctx())

View File

@@ -1,52 +1,34 @@
defmodule API.Client.Views.Relay do
alias Domain.Relays
def render_many(relays, expires_at, stun_or_turn \\ :turn) do
Enum.flat_map(relays, &render(&1, expires_at, stun_or_turn))
end
def render(%Relays.Relay{} = relay, expires_at, stun_or_turn) do
[
maybe_render(relay, expires_at, relay.ipv4, stun_or_turn),
maybe_render(relay, expires_at, relay.ipv6, stun_or_turn)
]
def render_many(relays, salt, expires_at) do
relays
|> Enum.map(fn relay ->
[
render_addr(relay, salt, expires_at, relay.ipv4),
render_addr(relay, salt, expires_at, relay.ipv6)
]
end)
|> List.flatten()
end
defp maybe_render(%Relays.Relay{}, _expires_at, nil, _stun_or_turn), do: []
defp render_addr(_relay, _salt, _expires_at, nil), do: []
# STUN returns the reflective candidates to the peer and is used for hole-punching;
# TURN is used to real actual traffic if hole-punching fails. It requires authentication.
# WebRTC will automatically fail back to STUN if TURN fails,
# so there is no need to send both of them along with each other.
defp maybe_render(%Relays.Relay{} = relay, _expires_at, address, :stun) do
[
%{
id: relay.id,
type: :stun,
addr: "#{format_address(address)}:#{relay.port}"
}
]
end
defp maybe_render(%Relays.Relay{} = relay, expires_at, address, :turn) do
defp render_addr(%Relays.Relay{} = relay, salt, expires_at, address) do
%{
username: username,
password: password,
expires_at: expires_at
} = Relays.generate_username_and_password(relay, expires_at)
} = Relays.generate_username_and_password(relay, salt, expires_at)
[
%{
id: relay.id,
type: :turn,
addr: "#{format_address(address)}:#{relay.port}",
username: username,
password: password,
expires_at: expires_at
}
]
%{
id: relay.id,
type: :turn,
addr: "#{format_address(address)}:#{relay.port}",
username: username,
password: password,
expires_at: expires_at
}
end
defp format_address(%Postgrex.INET{address: address} = inet) when tuple_size(address) == 4,

View File

@@ -39,7 +39,7 @@ defmodule API.Gateway.Channel do
:ok = Gateways.Presence.connect(socket.assigns.gateway)
# Return all connected relays for the account
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(14, :day)
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(90, :day)
{:ok, relays} = select_relays(socket)
:ok = Enum.each(relays, &Domain.Relays.subscribe_to_relay_presence/1)
:ok = maybe_subscribe_for_relays_presence(relays, socket)
@@ -49,7 +49,8 @@ defmodule API.Gateway.Channel do
push(socket, "init", %{
account_slug: account.slug,
interface: Views.Interface.render(socket.assigns.gateway),
relays: Views.Relay.render_many(relays, relay_credentials_expire_at),
relays:
Views.Relay.render_many(relays, socket.assigns.turn_salt, relay_credentials_expire_at),
# These aren't used but needed for API compatibility
config: %{
ipv4_masquerade_enabled: true,
@@ -165,7 +166,7 @@ defmodule API.Gateway.Channel do
} do
:ok = Domain.Relays.unsubscribe_from_relay_presence(relay_id)
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(14, :day)
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(90, :day)
{:ok, relays} = select_relays(socket, [relay_id])
:ok = maybe_subscribe_for_relays_presence(relays, socket)
@@ -179,7 +180,12 @@ defmodule API.Gateway.Channel do
payload = %{
disconnected_ids: [relay_id],
connected: Views.Relay.render_many(relays, relay_credentials_expire_at)
connected:
Views.Relay.render_many(
relays,
socket.assigns.turn_salt,
relay_credentials_expire_at
)
}
socket = Debouncer.queue_leave(self(), socket, relay_id, payload)
@@ -207,7 +213,7 @@ defmodule API.Gateway.Channel do
{:ok, relays} = select_relays(socket)
if length(relays) > 0 do
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(14, :day)
relay_credentials_expire_at = DateTime.utc_now() |> DateTime.add(90, :day)
:ok =
Relays.unsubscribe_from_relays_presence_in_account(socket.assigns.gateway.account_id)
@@ -235,7 +241,12 @@ defmodule API.Gateway.Channel do
push(socket, "relays_presence", %{
disconnected_ids: disconnected_ids,
connected: Views.Relay.render_many(relays, relay_credentials_expire_at)
connected:
Views.Relay.render_many(
relays,
socket.assigns.turn_salt,
relay_credentials_expire_at
)
})
{:noreply, socket}

View File

@@ -27,8 +27,14 @@ defmodule API.Gateway.Socket do
version: gateway.last_seen_version
})
# For Relay credentials
turn_salt =
Domain.Crypto.hash(:sha256, encoded_token)
|> Base.url_encode64(padding: false)
socket =
socket
|> assign(:turn_salt, turn_salt)
|> assign(:token_id, token.id)
|> assign(:gateway_group, group)
|> assign(:gateway, gateway)

View File

@@ -1,5 +1,5 @@
defmodule API.Gateway.Views.Relay do
def render_many(relays, expires_at) do
API.Client.Views.Relay.render_many(relays, expires_at)
def render_many(relays, salt, expires_at) do
API.Client.Views.Relay.render_many(relays, salt, expires_at)
end
end

View File

@@ -137,7 +137,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -191,7 +192,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -213,7 +215,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -231,7 +234,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -245,7 +249,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -258,7 +263,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client") ==
{:error, %{reason: :invalid_version}}
@@ -397,7 +403,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -470,7 +477,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -557,7 +565,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -609,7 +618,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -673,7 +683,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -1435,7 +1446,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -1509,7 +1521,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -1862,7 +1875,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -1931,7 +1945,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -2142,7 +2157,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")
@@ -2353,7 +2369,8 @@ defmodule API.Client.ChannelTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")

View File

@@ -23,7 +23,8 @@ defmodule API.Gateway.ChannelTest do
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
@@ -341,7 +342,8 @@ defmodule API.Gateway.ChannelTest do
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
@@ -393,7 +395,8 @@ defmodule API.Gateway.ChannelTest do
gateway: gateway,
gateway_group: gateway_group,
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test")
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Gateway.Channel, "gateway")

View File

@@ -270,10 +270,11 @@ defmodule Domain.Relays do
{:ok, relays}
end
def generate_username_and_password(%Relay{stamp_secret: stamp_secret}, %DateTime{} = expires_at)
# TODO: Relays
# Revisit credential lifetime when https://github.com/firezone/firezone/issues/8222 is implemented
def generate_username_and_password(%Relay{stamp_secret: stamp_secret}, salt, expires_at)
when is_binary(stamp_secret) do
expires_at = DateTime.to_unix(expires_at, :second)
salt = Domain.Crypto.random_token()
password = generate_hash(expires_at, stamp_secret, salt)
%{username: "#{expires_at}:#{salt}", password: password, expires_at: expires_at}
end

View File

@@ -60,7 +60,8 @@ defmodule Domain.Events.Hooks.ActorGroupMembershipsTest do
opentelemetry_ctx: OpenTelemetry.Ctx.new(),
opentelemetry_span_ctx: OpenTelemetry.Tracer.start_span("test"),
client: client,
subject: subject
subject: subject,
turn_salt: "test_salt"
})
|> subscribe_and_join(API.Client.Channel, "client")

View File

@@ -786,25 +786,23 @@ defmodule Domain.RelaysTest do
end
end
describe "generate_username_and_password/1" do
describe "generate_username_and_password/3" do
test "returns username and password", %{account: account} do
relay = Fixtures.Relays.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
stamp_secret = "test_secret"
turn_salt = "test_salt"
relay = %{relay | stamp_secret: stamp_secret}
expires_at = DateTime.utc_now() |> DateTime.add(3, :second)
{:ok, expires_at, _} = DateTime.from_iso8601("2023-10-01T00:00:00Z")
assert %{username: username, password: password, expires_at: expires_at_unix} =
generate_username_and_password(relay, expires_at)
generate_username_and_password(relay, turn_salt, expires_at)
assert [username_expires_at_unix, username_salt] = String.split(username, ":", parts: 2)
assert username_expires_at_unix == to_string(expires_at_unix)
assert username_salt == turn_salt
assert DateTime.from_unix!(expires_at_unix) == DateTime.truncate(expires_at, :second)
expected_hash =
:crypto.hash(:sha256, "#{expires_at_unix}:#{stamp_secret}:#{username_salt}")
|> Base.encode64(padding: false, case: :lower)
assert password == expected_hash
assert username == "1696118400:test_salt"
assert password == "P0+gMB7RdvcvPv3eYFh1VSJUJh/FoAmOjUOqU8dToD8"
end
end