mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
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:
@@ -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}
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user