Implementing channels logic (#1619)

This commit is contained in:
Andrew Dryga
2023-05-22 19:49:50 -06:00
committed by GitHub
parent f5fdd56812
commit 70a03d39e6
80 changed files with 2425 additions and 520 deletions

View File

@@ -1,7 +1,8 @@
name: Test
name: Elixir
on:
# TODO: Enable this for PRs against `cloud` when chaos settles
# pull_request:
pull_request:
branches:
- cloud
push:
branches:
- cloud
@@ -58,7 +59,7 @@ jobs:
- name: Compile Dependencies
run: mix deps.compile --skip-umbrella-children
- name: Compile Application
run: mix compile
run: mix compile --warnings-as-errors
- name: Setup Database
run: |
mix ecto.create
@@ -68,7 +69,7 @@ jobs:
E2E_MAX_WAIT_SECONDS: 20
run: |
# XXX: This can fail when coveralls is down
mix coveralls.github --umbrella --warnings-as-errors
mix test --warnings-as-errors
- name: Test Report
uses: dorny/test-reporter@v1
if: ${{ github.event.pull_request.head.repo.full_name == github.repository && (success() || failure()) }}
@@ -412,7 +413,7 @@ jobs:
with:
platforms: linux/amd64
build-args: |
VERSION=${{ github.sha }}
VERSION=0.0.0-dev.${{ github.sha }}
file: Dockerfile.prod
context: .
push: false

View File

@@ -48,7 +48,7 @@ RUN mix do deps.get, deps.compile, compile
# busting the Docker build cache unnecessarily
COPY apps/web/assets/package.json /var/app/apps/web/assets/package.json
COPY apps/web/assets/yarn.lock /var/app/apps/web/assets/yarn.lock
RUN cd apps/web/assets && yarn install
RUN cd apps/web/assets && mix do assets.setup, assets.deploy
COPY config /var/app/config
COPY apps /var/app/apps

View File

@@ -43,11 +43,11 @@ ARG VERSION=0.0.0-docker
ENV VERSION=$VERSION
# compile assets
RUN cd apps/web/assets \
&& yarn install --frozen-lockfile \
&& yarn deploy \
&& cd .. \
&& mix phx.digest
RUN mix tailwind.install --if-missing \
&& mix esbuild.install --if-missing \
&& mix tailwind default --minify \
&& mix esbuild default --minify \
&& mix phx.digest
# Compile the release
RUN mix compile

View File

@@ -1,20 +1,126 @@
defmodule API.Client.Channel do
use API, :channel
alias Domain.Clients
# TODO: we need to self-terminate channel once the user token is set to expire, preventing
# users from holding infinite session for if they want to keep websocket open for a while
alias API.Client.Views
alias Domain.{Clients, Resources, Gateways, Relays}
@impl true
def join("client", _payload, socket) do
send(self(), :after_join)
{:ok, socket}
expires_in =
DateTime.diff(socket.assigns.subject.expires_at, DateTime.utc_now(), :millisecond)
if expires_in > 0 do
Process.send_after(self(), :token_expired, expires_in)
send(self(), :after_join)
{:ok, socket}
else
{:error, %{"reason" => "token_expired"}}
end
end
@impl true
def handle_info(:after_join, socket) do
:ok = Clients.connect_client(socket.assigns.client, socket)
:ok = push(socket, "resources", %{resources: []})
{:ok, resources} = Domain.Resources.list_resources(socket.assigns.subject)
:ok =
push(socket, "init", %{
resources: Views.Resource.render_many(resources),
interface: Views.Interface.render(socket.assigns.client)
})
:ok = Clients.connect_client(socket.assigns.client)
{:noreply, socket}
end
def handle_info(:token_expired, socket) do
push(socket, "token_expired", %{})
{:stop, :token_expired, socket}
end
def handle_info(
{:connect, socket_ref, resource_id, gateway_public_key, rtc_session_description},
socket
) do
reply(
socket_ref,
{:ok,
%{
resource_id: resource_id,
persistent_keepalive: 25,
gateway_public_key: gateway_public_key,
gateway_rtc_session_description: rtc_session_description
}}
)
{:noreply, socket}
end
def handle_info({:resource_added, resource_id}, socket) do
with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject) do
push(socket, "resource_added", Views.Resource.render(resource))
end
{:noreply, socket}
end
def handle_info({:resource_updated, resource_id}, socket) do
with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject) do
push(socket, "resource_updated", Views.Resource.render(resource))
end
{:noreply, socket}
end
def handle_info({:resource_removed, resource_id}, socket) do
push(socket, "resource_removed", resource_id)
{:noreply, socket}
end
@impl true
def handle_in("list_relays", %{"resource_id" => resource_id}, socket) do
with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject),
# :ok = Resource.authorize(resource, socket.assigns.subject),
{:ok, [_ | _] = relays} <- Relays.list_connected_relays_for_resource(resource) do
reply = {:ok, %{relays: Views.Relay.render_many(relays, socket.assigns.expires_at)}}
{:reply, reply, socket}
else
{:ok, []} -> {:reply, {:error, :offline}, socket}
{:error, :not_found} -> {:reply, {:error, :not_found}, socket}
end
end
def handle_in(
"request_connection",
%{
"resource_id" => resource_id,
"client_rtc_session_description" => client_rtc_session_description,
"client_preshared_key" => preshared_key
},
socket
) do
with {:ok, resource} <- Resources.fetch_resource_by_id(resource_id, socket.assigns.subject),
# :ok = Resource.authorize(resource, socket.assigns.subject),
{:ok, [_ | _] = gateways} <-
Gateways.list_connected_gateways_for_resource(resource) do
gateway = Enum.random(gateways)
Phoenix.PubSub.broadcast(
Domain.PubSub,
API.Gateway.Socket.id(gateway),
{:request_connection, {self(), socket_ref(socket)},
%{
client_id: socket.assigns.client.id,
resource_id: resource_id,
authorization_expires_at: socket.assigns.expires_at,
client_rtc_session_description: client_rtc_session_description,
client_preshared_key: preshared_key
}}
)
{:noreply, socket}
else
{:error, :not_found} -> {:reply, {:error, :not_found}, socket}
{:ok, []} -> {:reply, {:error, :offline}, socket}
end
end
end

View File

@@ -12,8 +12,6 @@ defmodule API.Client.Socket do
def connect(%{"token" => token} = attrs, socket, connect_info) do
%{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info
# TODO: we want to scope tokens for specific use cases, so token generated in auth flow
# should be only good for websockets, but not to be put in a browser cookie
with {:ok, subject} <- Auth.sign_in(token, user_agent, remote_ip),
{:ok, client} <- Clients.upsert_client(attrs, subject) do
socket =

View File

@@ -0,0 +1,11 @@
defmodule API.Client.Views.Interface do
alias Domain.Clients
def render(%Clients.Client{} = client) do
%{
upstream_dns: Domain.Config.fetch_config!(:default_client_dns),
ipv4: client.ipv4,
ipv6: client.ipv6
}
end
end

View File

@@ -0,0 +1,39 @@
defmodule API.Client.Views.Relay do
alias Domain.Relays
def render_many(relays, expires_at) do
Enum.flat_map(relays, &render(&1, expires_at))
end
def render(%Relays.Relay{} = relay, expires_at) do
[
maybe_render(relay, expires_at, relay.ipv4),
maybe_render(relay, expires_at, relay.ipv6)
]
|> List.flatten()
end
defp maybe_render(%Relays.Relay{}, _expires_at, nil), do: []
defp maybe_render(%Relays.Relay{} = relay, expires_at, address) do
%{
username: username,
password: password,
expires_at: expires_at
} = Relays.generate_username_and_password(relay, expires_at)
[
%{
type: :stun,
uri: "stun:#{address}:#{relay.port}"
},
%{
type: :turn,
uri: "turn:#{address}:#{relay.port}",
username: username,
password: password,
expires_at: expires_at
}
]
end
end

View File

@@ -0,0 +1,16 @@
defmodule API.Client.Views.Resource do
alias Domain.Resources
def render_many(resources) do
Enum.map(resources, &render/1)
end
def render(%Resources.Resource{} = resource) do
%{
id: resource.id,
address: resource.address,
ipv4: resource.ipv4,
ipv6: resource.ipv6
}
end
end

View File

@@ -1,16 +1,95 @@
defmodule API.Gateway.Channel do
use API, :channel
alias Domain.Gateways
alias API.Gateway.Views
alias Domain.{Clients, Resources, Relays, Gateways}
@impl true
def join("gateway", _payload, socket) do
send(self(), :after_join)
socket = assign(socket, :refs, %{})
{:ok, socket}
end
@impl true
def handle_info(:after_join, socket) do
Gateways.connect_gateway(socket.assigns.gateway, socket)
push(socket, "init", %{
interface: Views.Interface.render(socket.assigns.gateway),
# TODO: move to settings
ipv4_masquerade_enabled: true,
ipv6_masquerade_enabled: true
})
:ok = Gateways.connect_gateway(socket.assigns.gateway)
{:noreply, socket}
end
def handle_info({:request_connection, {channel_pid, socket_ref}, attrs}, socket) do
%{
client_id: client_id,
resource_id: resource_id,
authorization_expires_at: authorization_expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
} = attrs
client = Clients.fetch_client_by_id!(client_id, preload: [:actor])
resource = Resources.fetch_resource_by_id!(resource_id)
{:ok, relays} = Relays.list_connected_relays_for_resource(resource)
ref = Ecto.UUID.generate()
push(socket, "request_connection", %{
ref: ref,
actor: Views.Actor.render(client.actor),
relays: Views.Relay.render_many(relays, authorization_expires_at),
resource: Views.Resource.render(resource),
client: Views.Client.render(client, rtc_session_description, preshared_key),
expires_at: DateTime.to_unix(authorization_expires_at, :second)
})
refs = Map.put(socket.assigns.refs, ref, {channel_pid, socket_ref, resource_id})
socket = assign(socket, :refs, refs)
{:noreply, socket}
end
@impl true
def handle_in(
"connection_ready",
%{
"ref" => ref,
"gateway_rtc_session_description" => rtc_session_description
},
socket
) do
{{channel_pid, socket_ref, resource_id}, refs} = Map.pop(socket.assigns.refs, ref)
socket = assign(socket, :refs, refs)
send(
channel_pid,
{:connect, socket_ref, resource_id, socket.assigns.gateway.public_key,
rtc_session_description}
)
{:reply, :ok, socket}
end
# def handle_in("metrics", params, socket) do
# %{
# "started_at" => started_at,
# "ended_at" => ended_at,
# "metrics" => [
# %{
# "client_id" => client_id,
# "resource_id" => resource_id,
# "rx_bytes" => 0,
# "tx_packets" => 0
# }
# ]
# }
# :ok = Gateways.update_metrics(socket.assigns.relay, metrics)
# {:noreply, socket}
# end
end

View File

@@ -52,5 +52,6 @@ defmodule API.Gateway.Socket do
end
@impl true
def id(socket), do: "gateway:#{socket.assigns.gateway.id}"
def id(%Gateways.Gateway{} = gateway), do: "gateway:#{gateway.id}"
def id(socket), do: id(socket.assigns.gateway)
end

View File

@@ -0,0 +1,9 @@
defmodule API.Gateway.Views.Actor do
alias Domain.Actors
def render(%Actors.Actor{} = actor) do
%{
id: actor.id
}
end
end

View File

@@ -0,0 +1,17 @@
defmodule API.Gateway.Views.Client do
alias Domain.Clients
def render(%Clients.Client{} = client, client_rtc_session_description, preshared_key) do
%{
id: client.id,
rtc_session_description: client_rtc_session_description,
peer: %{
persistent_keepalive: 25,
public_key: client.public_key,
preshared_key: preshared_key,
ipv4: client.ipv4,
ipv6: client.ipv6
}
}
end
end

View File

@@ -0,0 +1,10 @@
defmodule API.Gateway.Views.Interface do
alias Domain.Gateways
def render(%Gateways.Gateway{} = gateway) do
%{
ipv4: gateway.ipv4,
ipv6: gateway.ipv6
}
end
end

View File

@@ -0,0 +1,5 @@
defmodule API.Gateway.Views.Relay do
def render_many(relays, expires_at) do
Enum.flat_map(relays, &API.Client.Views.Relay.render(&1, expires_at))
end
end

View File

@@ -0,0 +1,12 @@
defmodule API.Gateway.Views.Resource do
alias Domain.Resources
def render(%Resources.Resource{} = resource) do
%{
id: resource.id,
address: resource.address,
ipv4: resource.ipv4,
ipv6: resource.ipv6
}
end
end

View File

@@ -10,7 +10,8 @@ defmodule API.Relay.Channel do
@impl true
def handle_info({:after_join, stamp_secret}, socket) do
:ok = Relays.connect_relay(socket.assigns.relay, stamp_secret, socket)
push(socket, "init", %{})
:ok = Relays.connect_relay(socket.assigns.relay, stamp_secret)
{:noreply, socket}
end
end

View File

@@ -52,5 +52,6 @@ defmodule API.Relay.Socket do
end
@impl true
def id(socket), do: "relay:#{socket.assigns.relay.id}"
def id(%Relays.Relay{} = relay), do: "relay:#{relay.id}"
def id(socket), do: id(socket.assigns.relay)
end

View File

@@ -1,26 +1,240 @@
defmodule API.Client.ChannelTest do
use API.ChannelCase
alias Domain.ClientsFixtures
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures}
alias Domain.{ClientsFixtures, RelaysFixtures, GatewaysFixtures}
setup do
client = ClientsFixtures.create_client()
account = AccountsFixtures.create_account()
actor = ActorsFixtures.create_actor(role: :admin, account: account)
identity = AuthFixtures.create_identity(actor: actor, account: account)
subject = AuthFixtures.create_subject(identity)
client = ClientsFixtures.create_client(subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account)
resource =
ResourcesFixtures.create_resource(
account: account,
gateways: [%{gateway_id: gateway.id}]
)
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
{:ok, _reply, socket} =
API.Client.Socket
|> socket("client:#{client.id}", %{client: client})
|> socket("client:#{client.id}", %{
client: client,
subject: subject,
expires_at: expires_at
})
|> subscribe_and_join(API.Client.Channel, "client")
%{client: client, socket: socket}
%{
account: account,
actor: actor,
identity: identity,
subject: subject,
client: client,
gateway: gateway,
resource: resource,
socket: socket
}
end
test "tracks presence after join", %{client: client, socket: socket} do
presence = Domain.Clients.Presence.list(socket)
describe "join/3" do
test "tracks presence after join", %{client: client} do
presence = Domain.Clients.Presence.list("clients")
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id)
assert is_number(online_at)
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id)
assert is_number(online_at)
end
test "expires the channel when token is expired", %{client: client, subject: subject} do
expires_at = DateTime.utc_now() |> DateTime.add(25, :millisecond)
subject = %{subject | expires_at: expires_at}
{:ok, _reply, _socket} =
API.Client.Socket
|> socket("client:#{client.id}", %{
client: client,
subject: subject
})
|> subscribe_and_join(API.Client.Channel, "client")
assert_push "token_expired", %{}, 250
end
test "sends list of resources after join", %{
client: client,
resource: resource
} do
assert_push "init", %{resources: resources, interface: interface}
assert resources == [
%{
address: resource.address,
id: resource.id,
ipv4: resource.ipv4,
ipv6: resource.ipv6
}
]
assert interface == %{
ipv4: client.ipv4,
ipv6: client.ipv6,
upstream_dns: [
%Postgrex.INET{address: {1, 1, 1, 1}, netmask: nil},
%Postgrex.INET{address: {1, 0, 0, 1}, netmask: nil}
]
}
end
end
test "sends list of resources after join" do
assert_push "resources", %{resources: []}
describe "handle_in/3 list_relays" do
test "returns error when resource is not found", %{socket: socket} do
ref = push(socket, "list_relays", %{"resource_id" => Ecto.UUID.generate()})
assert_reply ref, :error, :not_found
end
test "returns error when there are no online relays", %{resource: resource, socket: socket} do
ref = push(socket, "list_relays", %{"resource_id" => resource.id})
assert_reply ref, :error, :offline
end
test "returns list of online relays", %{account: account, resource: resource, socket: socket} do
relay = RelaysFixtures.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
ref = push(socket, "list_relays", %{"resource_id" => resource.id})
assert_reply ref, :ok, %{relays: relays}
ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}"
ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}"
ipv6_stun_uri = "stun:#{relay.ipv6}:#{relay.port}"
ipv6_turn_uri = "turn:#{relay.ipv6}:#{relay.port}"
assert [
%{
type: :stun,
uri: ^ipv4_stun_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password1,
username: username1,
uri: ^ipv4_turn_uri
},
%{
type: :stun,
uri: ^ipv6_stun_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password2,
username: username2,
uri: ^ipv6_turn_uri
}
] = relays
assert username1 != username2
assert password1 != password2
assert [expires_at, salt] = String.split(username1, ":", parts: 2)
expires_at = expires_at |> String.to_integer() |> DateTime.from_unix!()
socket_expires_at = DateTime.truncate(socket.assigns.expires_at, :second)
assert expires_at == socket_expires_at
assert is_binary(salt)
end
end
describe "handle_in/3 request_connection" do
test "returns error when resource is not found", %{socket: socket} do
attrs = %{
"resource_id" => Ecto.UUID.generate(),
"client_rtc_session_description" => "RTC_SD",
"client_preshared_key" => "PSK"
}
ref = push(socket, "request_connection", attrs)
assert_reply ref, :error, :not_found
end
test "returns error when all gateways are offline", %{
resource: resource,
socket: socket
} do
attrs = %{
"resource_id" => resource.id,
"client_rtc_session_description" => "RTC_SD",
"client_preshared_key" => "PSK"
}
ref = push(socket, "request_connection", attrs)
assert_reply ref, :error, :offline
end
test "returns error when all gateways connected to the resource are offline", %{
account: account,
resource: resource,
socket: socket
} do
attrs = %{
"resource_id" => resource.id,
"client_rtc_session_description" => "RTC_SD",
"client_preshared_key" => "PSK"
}
gateway = GatewaysFixtures.create_gateway(account: account)
:ok = Domain.Gateways.connect_gateway(gateway)
ref = push(socket, "request_connection", attrs)
assert_reply ref, :error, :offline
end
test "broadcasts request_connection to the gateways and then returns connect message", %{
resource: resource,
gateway: gateway,
client: client,
socket: socket
} do
public_key = gateway.public_key
resource_id = resource.id
client_id = client.id
:ok = Domain.Gateways.connect_gateway(gateway)
Phoenix.PubSub.subscribe(Domain.PubSub, API.Gateway.Socket.id(gateway))
attrs = %{
"resource_id" => resource.id,
"client_rtc_session_description" => "RTC_SD",
"client_preshared_key" => "PSK"
}
ref = push(socket, "request_connection", attrs)
assert_receive {:request_connection, {channel_pid, socket_ref}, payload}
assert %{
resource_id: ^resource_id,
client_id: ^client_id,
client_preshared_key: "PSK",
client_rtc_session_description: "RTC_SD",
authorization_expires_at: authorization_expires_at
} = payload
assert authorization_expires_at == socket.assigns.expires_at
send(channel_pid, {:connect, socket_ref, resource.id, gateway.public_key, "FULL_RTC_SD"})
assert_reply ref, :ok, %{
resource_id: ^resource_id,
persistent_keepalive: 25,
gateway_public_key: ^public_key,
gateway_rtc_session_description: "FULL_RTC_SD"
}
end
end
end

View File

@@ -51,7 +51,7 @@ defmodule API.Client.SocketTest do
describe "id/1" do
test "creates a channel for a client" do
client = %{id: Ecto.UUID.generate()}
client = ClientsFixtures.create_client()
socket = socket(API.Client.Socket, "", %{client: client})
assert id(socket) == "client:#{client.id}"

View File

@@ -1,22 +1,203 @@
defmodule API.Gateway.ChannelTest do
use API.ChannelCase
alias Domain.GatewaysFixtures
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures}
alias Domain.{ClientsFixtures, RelaysFixtures, GatewaysFixtures}
setup do
gateway = GatewaysFixtures.create_gateway()
account = AccountsFixtures.create_account()
actor = ActorsFixtures.create_actor(role: :admin, account: account)
identity = AuthFixtures.create_identity(actor: actor, account: account)
subject = AuthFixtures.create_subject(identity)
client = ClientsFixtures.create_client(subject: subject)
gateway = GatewaysFixtures.create_gateway(account: account)
resource =
ResourcesFixtures.create_resource(
account: account,
gateways: [%{gateway_id: gateway.id}]
)
{:ok, _, socket} =
API.Gateway.Socket
|> socket("gateway:#{gateway.id}", %{gateway: gateway})
|> subscribe_and_join(API.Gateway.Channel, "gateway")
%{gateway: gateway, socket: socket}
relay = RelaysFixtures.create_relay(account: account)
%{
account: account,
actor: actor,
identity: identity,
subject: subject,
client: client,
gateway: gateway,
resource: resource,
relay: relay,
socket: socket
}
end
test "tracks presence after join", %{gateway: gateway, socket: socket} do
presence = Domain.Gateways.Presence.list(socket)
describe "join/3" do
test "tracks presence after join", %{gateway: gateway} do
presence = Domain.Gateways.Presence.list("gateways")
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id)
assert is_number(online_at)
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id)
assert is_number(online_at)
end
test "sends list of resources after join", %{
gateway: gateway
} do
assert_push "init", %{
interface: interface,
ipv4_masquerade_enabled: true,
ipv6_masquerade_enabled: true
}
assert interface == %{
ipv4: gateway.ipv4,
ipv6: gateway.ipv6
}
end
end
describe "handle_info/2 :request_connection" do
test "pushes request_connection message", %{
client: client,
resource: resource,
relay: relay,
socket: socket
} do
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
preshared_key = "PSK"
rtc_session_description = "RTC_SD"
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
send(
socket.channel_pid,
{:request_connection, {channel_pid, socket_ref},
%{
client_id: client.id,
resource_id: resource.id,
authorization_expires_at: expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
}}
)
assert_push "request_connection", payload
assert is_binary(payload.ref)
assert payload.actor == %{id: client.actor_id}
ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}"
ipv4_turn_uri = "turn:#{relay.ipv4}:#{relay.port}"
ipv6_stun_uri = "stun:#{relay.ipv6}:#{relay.port}"
ipv6_turn_uri = "turn:#{relay.ipv6}:#{relay.port}"
assert [
%{
type: :stun,
uri: ^ipv4_stun_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password1,
username: username1,
uri: ^ipv4_turn_uri
},
%{
type: :stun,
uri: ^ipv6_stun_uri
},
%{
type: :turn,
expires_at: expires_at_unix,
password: password2,
username: username2,
uri: ^ipv6_turn_uri
}
] = payload.relays
assert username1 != username2
assert password1 != password2
assert [username_expires_at_unix, username_salt] = String.split(username1, ":", parts: 2)
assert username_expires_at_unix == to_string(DateTime.to_unix(expires_at, :second))
assert DateTime.from_unix!(expires_at_unix) == DateTime.truncate(expires_at, :second)
assert is_binary(username_salt)
assert payload.resource == %{
address: resource.address,
id: resource.id,
ipv4: resource.ipv4,
ipv6: resource.ipv6
}
assert payload.client == %{
id: client.id,
peer: %{
ipv4: client.ipv4,
ipv6: client.ipv6,
persistent_keepalive: 25,
preshared_key: preshared_key,
public_key: client.public_key
},
rtc_session_description: rtc_session_description
}
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
end
end
describe "handle_in/3 connection_ready" do
test "forwards RFC session description to the client channel", %{
client: client,
resource: resource,
relay: relay,
gateway: gateway,
socket: socket
} do
channel_pid = self()
socket_ref = make_ref()
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
preshared_key = "PSK"
gateway_public_key = gateway.public_key
rtc_session_description = "RTC_SD"
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
send(
socket.channel_pid,
{:request_connection, {channel_pid, socket_ref},
%{
client_id: client.id,
resource_id: resource.id,
authorization_expires_at: expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
}}
)
assert_push "request_connection", %{ref: ref}
push_ref =
push(socket, "connection_ready", %{
"ref" => ref,
"gateway_rtc_session_description" => rtc_session_description
})
assert_reply push_ref, :ok
assert_receive {:connect, ^socket_ref, resource_id, ^gateway_public_key,
^rtc_session_description}
assert resource_id == resource.id
end
end
end

View File

@@ -66,7 +66,7 @@ defmodule API.Gateway.SocketTest do
describe "id/1" do
test "creates a channel for a gateway" do
gateway = %{id: Ecto.UUID.generate()}
gateway = GatewaysFixtures.create_gateway()
socket = socket(API.Gateway.Socket, "", %{gateway: gateway})
assert id(socket) == "gateway:#{gateway.id}"

View File

@@ -15,10 +15,16 @@ defmodule API.Relay.ChannelTest do
%{relay: relay, socket: socket}
end
test "tracks presence after join", %{relay: relay, socket: socket} do
presence = Domain.Relays.Presence.list(socket)
describe "join/3" do
test "tracks presence after join", %{relay: relay} do
presence = Domain.Relays.Presence.list("relays")
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id)
assert is_number(online_at)
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id)
assert is_number(online_at)
end
test "sends init message after join" do
assert_push "init", %{}
end
end
end

View File

@@ -66,7 +66,7 @@ defmodule API.Relay.SocketTest do
describe "id/1" do
test "creates a channel for a relay" do
relay = %{id: Ecto.UUID.generate()}
relay = RelaysFixtures.create_relay()
socket = socket(API.Relay.Socket, "", %{relay: relay})
assert id(socket) == "relay:#{relay.id}"

View File

@@ -83,9 +83,9 @@ defmodule Domain.Actors do
end)
|> Repo.transaction()
|> case do
{:ok, %{actor: actor}} ->
{:ok, %{actor: actor, identity: identity}} ->
Telemetry.add_actor()
{:ok, actor}
{:ok, %{actor | identities: [identity]}}
{:error, _step, changeset, _effects_so_far} ->
{:error, changeset}

View File

@@ -6,6 +6,11 @@ defmodule Domain.Auth do
alias Domain.Auth.{Authorizer, Subject, Context, Permission, Roles, Role, Identity}
alias Domain.Auth.{Adapters, Provider}
@default_session_duration_hours %{
admin: 3,
unprivileged: 24 * 7
}
def start_link(opts) do
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
end
@@ -95,13 +100,23 @@ defmodule Domain.Auth do
|> Repo.fetch!()
end
def create_identity(%Actors.Actor{} = actor, %Provider{} = provider, provider_identifier) do
def create_identity(
%Actors.Actor{} = actor,
%Provider{} = provider,
provider_identifier,
provider_attrs \\ %{}
) do
Identity.Changeset.create(actor, provider, provider_identifier)
|> Adapters.identity_changeset(provider)
|> Adapters.identity_changeset(provider, provider_attrs)
|> Repo.insert()
end
def replace_identity(%Identity{} = identity, provider_identifier, %Subject{} = subject) do
def replace_identity(
%Identity{} = identity,
provider_identifier,
provider_attrs \\ %{},
%Subject{} = subject
) do
required_permissions =
{:one_of,
[
@@ -120,7 +135,7 @@ defmodule Domain.Auth do
end)
|> Ecto.Multi.insert(:new_identity, fn %{identity: identity} ->
Identity.Changeset.create(identity.actor, identity.provider, provider_identifier)
|> Adapters.identity_changeset(identity.provider)
|> Adapters.identity_changeset(identity.provider, provider_attrs)
end)
|> Ecto.Multi.update(:deleted_identity, fn %{identity: identity} ->
Identity.Changeset.delete_identity(identity)
@@ -156,9 +171,9 @@ defmodule Domain.Auth do
def sign_in(%Provider{} = provider, provider_identifier, secret, user_agent, remote_ip) do
with {:ok, identity} <-
fetch_identity_by_provider_and_identifier(provider, provider_identifier),
{:ok, identity} <-
{:ok, identity, expires_at} <-
Adapters.verify_secret(provider, identity, secret) do
{:ok, build_subject(identity, user_agent, remote_ip)}
{:ok, build_subject(identity, expires_at, user_agent, remote_ip)}
else
{:error, :not_found} -> {:error, :unauthorized}
{:error, :invalid_secret} -> {:error, :unauthorized}
@@ -168,8 +183,9 @@ defmodule Domain.Auth do
def sign_in(session_token, user_agent, remote_ip) do
with {:ok, identity_id} <- verify_session_token(session_token, user_agent, remote_ip),
{:ok, expires_at} <- fetch_session_token_expires_at(session_token),
{:ok, identity} <- fetch_identity_by_id(identity_id) do
{:ok, build_subject(identity, user_agent, remote_ip)}
{:ok, build_subject(identity, expires_at, user_agent, remote_ip)}
else
{:error, :not_found} -> {:error, :unauthorized}
{:error, :invalid_token} -> {:error, :unauthorized}
@@ -184,8 +200,9 @@ defmodule Domain.Auth do
|> Repo.fetch()
end
defp build_subject(%Identity{} = identity, user_agent, remote_ip)
when is_binary(user_agent) and is_tuple(remote_ip) do
@doc false
def build_subject(%Identity{} = identity, expires_at, user_agent, remote_ip)
when is_binary(user_agent) and is_tuple(remote_ip) do
identity =
identity
|> Identity.Changeset.sign_in(user_agent, remote_ip)
@@ -199,19 +216,46 @@ defmodule Domain.Auth do
actor: identity_with_preloads.actor,
permissions: permissions,
account: identity_with_preloads.account,
expires_at: build_subject_expires_at(identity_with_preloads.actor, expires_at),
context: %Context{remote_ip: remote_ip, user_agent: user_agent}
}
end
defp build_subject_expires_at(%Actors.Actor{} = actor, expires_at) do
default_session_duration_hours = Map.fetch!(@default_session_duration_hours, actor.role)
expires_at || DateTime.utc_now() |> DateTime.add(default_session_duration_hours, :hour)
end
# Session
# TODO: we need to leverage provider token expiration here
def create_session_token_from_subject(%Subject{} = subject) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
payload = session_token_payload(subject)
{:ok, Plug.Crypto.sign(key_base, salt, payload)}
max_age = DateTime.diff(subject.expires_at, DateTime.utc_now(), :second)
{:ok, Plug.Crypto.sign(key_base, salt, payload, max_age: max_age)}
end
def fetch_session_token_expires_at(token, opts \\ []) do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
iterations = Keyword.get(opts, :key_iterations, 1000)
length = Keyword.get(opts, :key_length, 32)
digest = Keyword.get(opts, :key_digest, :sha256)
cache = Keyword.get(opts, :cache, Plug.Crypto.Keys)
secret = Plug.Crypto.KeyGenerator.generate(key_base, salt, iterations, length, digest, cache)
with {:ok, message} <- Plug.Crypto.MessageVerifier.verify(token, secret) do
{_data, signed, max_age} = Plug.Crypto.non_executable_binary_to_term(message)
{:ok, datetime} = DateTime.from_unix(signed + trunc(max_age * 1000), :millisecond)
{:ok, datetime}
else
:error -> {:error, :invalid_token}
end
end
defp session_token_payload(%Subject{identity: %Identity{} = identity, context: context}),
@@ -226,11 +270,10 @@ defmodule Domain.Auth do
config = fetch_config!()
key_base = Keyword.fetch!(config, :key_base)
salt = Keyword.fetch!(config, :salt)
max_age = Keyword.fetch!(config, :max_age)
context_payload = session_context_payload(remote_ip, user_agent)
case Plug.Crypto.verify(key_base, salt, token, max_age: max_age) do
case Plug.Crypto.verify(key_base, salt, token) do
{:ok, {:identity, identity_id, ^context_payload}} ->
{:ok, identity_id}
@@ -299,51 +342,4 @@ defmodule Domain.Auth do
missing_permissions -> {:error, {:unauthorized, missing_permissions: missing_permissions}}
end
end
############
def fetch_oidc_provider_config(provider_id) do
with {:ok, provider} <- fetch_provider(:openid_connect_providers, provider_id) do
redirect_uri =
if provider.redirect_uri do
provider.redirect_uri
else
external_url = Domain.Config.fetch_env!(:web, :external_url)
"#{external_url}auth/oidc/#{provider.id}/callback/"
end
{:ok,
%{
discovery_document_uri: provider.discovery_document_uri,
client_id: provider.client_id,
client_secret: provider.client_secret,
redirect_uri: redirect_uri,
response_type: provider.response_type,
scope: provider.scope
}}
end
end
def auto_create_users?(field, provider_id) do
fetch_provider!(field, provider_id).auto_create_users
end
defp fetch_provider(field, provider_id) do
Config.fetch_config!(field)
|> Enum.find(&(&1.id == provider_id))
|> case do
nil -> {:error, :not_found}
provider -> {:ok, provider}
end
end
defp fetch_provider!(field, provider_id) do
case fetch_provider(field, provider_id) do
{:ok, provider} ->
provider
{:error, :not_found} ->
raise RuntimeError, "Unknown provider #{provider_id}"
end
end
end

View File

@@ -30,7 +30,7 @@ defmodule Domain.Auth.Adapter do
if it's valid, or an error otherwise.
"""
@callback verify_secret(%Identity{}, secret :: term()) ::
{:ok, %Identity{}}
{:ok, %Identity{}, expires_at :: %DateTime{} | nil}
| {:error, :invalid_secret}
| {:error, :expired_secret}
| {:error, :internal_error}

View File

@@ -4,7 +4,8 @@ defmodule Domain.Auth.Adapters do
@adapters %{
email: Domain.Auth.Adapters.Email,
openid_connect: Domain.Auth.Adapters.OpenIDConnect
openid_connect: Domain.Auth.Adapters.OpenIDConnect,
userpass: Domain.Auth.Adapters.UserPass
}
@adapter_names Map.keys(@adapters)
@@ -18,8 +19,9 @@ defmodule Domain.Auth.Adapters do
Supervisor.init(@adapter_modules, strategy: :one_for_one)
end
def identity_changeset(%Ecto.Changeset{} = changeset, %Provider{} = provider) do
def identity_changeset(%Ecto.Changeset{} = changeset, %Provider{} = provider, provider_attrs) do
adapter = fetch_adapter!(provider)
changeset = Ecto.Changeset.put_change(changeset, :provider_virtual_state, provider_attrs)
%Ecto.Changeset{} = adapter.identity_changeset(provider, changeset)
end
@@ -42,7 +44,7 @@ defmodule Domain.Auth.Adapters do
adapter = fetch_adapter!(provider)
case adapter.verify_secret(identity, secret) do
{:ok, %Identity{} = identity} -> {:ok, identity}
{:ok, %Identity{} = identity, expires_at} -> {:ok, identity, expires_at}
{:error, :invalid_secret} -> {:error, :invalid_secret}
{:error, :expired_secret} -> {:error, :expired_secret}
{:error, :internal_error} -> {:error, :internal_error}

View File

@@ -77,8 +77,13 @@ defmodule Domain.Auth.Adapters.Email do
Identity.Query.by_id(identity.id)
|> Repo.fetch_and_update(
with: fn identity ->
sign_in_token_hash = identity.provider_state["sign_in_token_hash"]
sign_in_token_created_at = identity.provider_state["sign_in_token_created_at"]
sign_in_token_hash =
identity.provider_state["sign_in_token_hash"] ||
identity.provider_state[:sign_in_token_hash]
sign_in_token_created_at =
identity.provider_state["sign_in_token_created_at"] ||
identity.provider_state[:sign_in_token_created_at]
cond do
is_nil(sign_in_token_hash) ->
@@ -98,6 +103,10 @@ defmodule Domain.Auth.Adapters.Email do
end
end
)
|> case do
{:ok, identity} -> {:ok, identity, nil}
{:error, reason} -> {:error, reason}
end
end
defp sign_in_token_expired?(sign_in_token_created_at) do

View File

@@ -19,13 +19,11 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
end
@impl true
def identity_changeset(%Provider{} = provider, %Ecto.Changeset{} = changeset) do
{state, virtual_state} = identity_create_state(provider)
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
changeset
|> Domain.Validator.trim_change(:provider_identifier)
|> Ecto.Changeset.put_change(:provider_state, state)
|> Ecto.Changeset.put_change(:provider_virtual_state, virtual_state)
|> Ecto.Changeset.put_change(:provider_state, %{})
|> Ecto.Changeset.put_change(:provider_virtual_state, %{})
end
@impl true
@@ -44,10 +42,6 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
changeset
end
defp identity_create_state(%Provider{} = _provider) do
{%{}, %{}}
end
def authorization_uri(%Provider{} = provider, redirect_uri) when is_binary(redirect_uri) do
config = config_for_provider(provider)
@@ -82,6 +76,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
code: code,
code_verifier: code_verifier
})
|> case do
{:ok, identity, expires_at} -> {:ok, identity, expires_at}
{:error, :expired_token} -> {:error, :expired_secret}
{:error, :invalid_token} -> {:error, :invalid_secret}
{:error, :internal_error} -> {:error, :internal_error}
end
end
def refresh_token(%Identity{} = identity) do
@@ -98,6 +98,18 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
{:ok, claims} <- OpenIDConnect.verify(config, tokens["id_token"]),
{:ok, userinfo} <- OpenIDConnect.fetch_userinfo(config, tokens["id_token"]) do
# TODO: sync groups
expires_at =
cond do
not is_nil(tokens["expires_in"]) ->
DateTime.add(DateTime.utc_now(), tokens["expires_in"], :second)
not is_nil(claims["exp"]) ->
DateTime.from_unix!(claims["exp"])
true ->
nil
end
Identity.Query.by_id(identity.id)
|> Repo.fetch_and_update(
with: fn identity ->
@@ -105,15 +117,16 @@ defmodule Domain.Auth.Adapters.OpenIDConnect do
id_token: tokens["id_token"],
access_token: tokens["access_token"],
refresh_token: tokens["refresh_token"],
expires_at:
if(tokens["expires_in"],
do: DateTime.add(DateTime.utc_now(), tokens["expires_in"], :second)
),
expires_at: expires_at,
userinfo: userinfo,
claims: claims
})
end
)
|> case do
{:ok, identity} -> {:ok, identity, expires_at}
{:error, reason} -> {:error, reason}
end
else
{:error, {:invalid_jwt, "invalid exp claim: token has expired"}} ->
{:error, :expired_token}

View File

@@ -1,2 +1,94 @@
defmodule Domain.Auth.Providers.UserPass do
defmodule Domain.Auth.Adapters.UserPass do
@moduledoc """
This is not recommended to use in production,
it's only for development, testing, and small home labs.
"""
use Supervisor
alias Domain.Repo
alias Domain.Auth.{Identity, Provider, Adapter}
alias Domain.Auth.Adapters.UserPass.Password
@behaviour Adapter
def start_link(_init_arg) do
Supervisor.start_link(__MODULE__, nil, name: __MODULE__)
end
@impl true
def init(_init_arg) do
children = []
Supervisor.init(children, strategy: :one_for_one)
end
@impl true
def identity_changeset(%Provider{} = _provider, %Ecto.Changeset{} = changeset) do
changeset
|> Domain.Validator.trim_change(:provider_identifier)
|> validate_password()
end
defp validate_password(changeset) do
data = Map.get(changeset.data, :provider_virtual_state) || %{}
attrs = Ecto.Changeset.get_change(changeset, :provider_virtual_state) || %{}
Ecto.embedded_load(Password, data, :json)
|> Password.Changeset.changeset(attrs)
|> case do
%{valid?: false} = nested_changeset ->
{changeset, _original_type} =
Domain.Changeset.inject_embedded_changeset(
changeset,
:provider_virtual_state,
nested_changeset
)
changeset
%{valid?: true} = nested_changeset ->
password_hash = Ecto.Changeset.fetch_change!(nested_changeset, :password_hash)
changeset
|> Ecto.Changeset.put_change(:provider_state, %{password_hash: password_hash})
|> Ecto.Changeset.put_change(:provider_virtual_state, %{})
end
end
@impl true
def ensure_provisioned(%Ecto.Changeset{} = changeset) do
changeset
end
@impl true
def ensure_deprovisioned(%Ecto.Changeset{} = changeset) do
changeset
end
@impl true
def verify_secret(%Identity{} = identity, password) when is_binary(password) do
Identity.Query.by_id(identity.id)
|> Repo.fetch_and_update(
with: fn identity ->
password_hash = identity.provider_state["password_hash"]
cond do
is_nil(password_hash) ->
:invalid_secret
not Domain.Crypto.equal?(password, password_hash) ->
:invalid_secret
true ->
Ecto.Changeset.change(identity)
end
end
)
|> case do
{:ok, identity} ->
{:ok, identity, nil}
{:error, reason} ->
{:error, reason}
end
end
end

View File

@@ -1,15 +0,0 @@
defmodule Domain.Auth.Adapters.UserPass.Changeset do
# @min_password_length 12
# @max_password_length 64
# defp change_password_changeset(%Ecto.Changeset{} = changeset) do
# changeset
# |> validate_required([:password])
# |> validate_confirmation(:password, required: true)
# |> validate_length(:password, min: @min_password_length, max: @max_password_length)
# |> put_hash(:password, to: :password_hash)
# |> redact_field(:password)
# |> redact_field(:password_confirmation)
# |> validate_required([:password_hash])
# end
end

View File

@@ -0,0 +1,10 @@
defmodule Domain.Auth.Adapters.UserPass.Password do
use Domain, :schema
@primary_key false
embedded_schema do
field :password, :string, virtual: true, redact: true
field :password_hash, :string
field :password_confirmation, :string, virtual: true, redact: true
end
end

View File

@@ -0,0 +1,24 @@
defmodule Domain.Auth.Adapters.UserPass.Password.Changeset do
use Domain, :changeset
alias Domain.Auth.Adapters.UserPass.Password
@fields ~w[password]a
@min_password_length 12
@max_password_length 64
def create_changeset(attrs) do
changeset(%Password{}, attrs)
end
def changeset(struct, attrs) do
struct
|> cast(attrs, @fields)
|> validate_required(@fields)
|> validate_confirmation(:password, required: true)
|> validate_length(:password, min: @min_password_length, max: @max_password_length)
|> put_hash(:password, to: :password_hash)
|> redact_field(:password)
|> redact_field(:password_confirmation)
|> validate_required([:password_hash])
end
end

View File

@@ -1,2 +0,0 @@
defmodule Domain.Auth.Identities do
end

View File

@@ -7,7 +7,7 @@ defmodule Domain.Auth.Identity do
field :provider_identifier, :string
field :provider_state, :map
field :provider_virtual_state, :map, virtual: true, redact: true
field :provider_virtual_state, :map, virtual: true
field :last_seen_user_agent, :string
field :last_seen_remote_ip, Domain.Types.IP

View File

@@ -4,7 +4,7 @@ defmodule Domain.Auth.Provider do
schema "auth_providers" do
field :name, :string
field :adapter, Ecto.Enum, values: ~w[email openid_connect]a
field :adapter, Ecto.Enum, values: ~w[email openid_connect userpass]a
field :adapter_config, :map
belongs_to :account, Domain.Accounts.Account

View File

@@ -1,2 +0,0 @@
defmodule Domain.Auth.Providers do
end

View File

@@ -16,7 +16,8 @@ defmodule Domain.Auth.Roles do
Domain.Clients.Authorizer,
Domain.Gateways.Authorizer,
Domain.Relays.Authorizer,
Domain.Actors.Authorizer
Domain.Actors.Authorizer,
Domain.Resources.Authorizer
]
end

View File

@@ -6,21 +6,20 @@ defmodule Domain.Auth.Subject do
@type actor :: %Actors.Actor{}
@type permission :: Permission.t()
# TODO: we need to add subject expiration retrieved from IdP provider,
# so that when we exchange subject for a token we keep the expiration
# preventing session extension
@type t :: %__MODULE__{
identity: identity(),
actor: actor(),
permissions: MapSet.t(permission),
account: %Domain.Accounts.Account{},
expires_at: DateTime.t(),
context: Context.t()
}
@enforce_keys [:identity, :actor, :permissions, :account, :context]
@enforce_keys [:identity, :actor, :permissions, :account, :context, :expires_at]
defstruct identity: nil,
actor: nil,
permissions: MapSet.new(),
account: nil,
expires_at: nil,
context: %Context{}
end

View File

@@ -54,7 +54,7 @@ defmodule Domain.Changeset do
end
end
defp inject_embedded_changeset(changeset, field, nested_changeset) do
def inject_embedded_changeset(changeset, field, nested_changeset) do
original_type = Map.get(changeset.types, field)
embedded_type =

View File

@@ -157,9 +157,11 @@ defmodule Domain.Clients do
Auth.ensure_has_permissions(subject, Authorizer.manage_clients_permission())
end
def connect_client(%Client{} = client, socket) do
def connect_client(%Client{} = client) do
Phoenix.PubSub.subscribe(Domain.PubSub, "actor:#{client.actor_id}")
{:ok, _} =
Presence.track(socket, client.id, %{
Presence.track(self(), "clients", client.id, %{
online_at: System.system_time(:second)
})

View File

@@ -3,6 +3,8 @@ defmodule Domain.Config.Configuration do
alias Domain.Config.Logo
schema "configurations" do
# field :upstream_dns, {:array, :string}, default: []
field :allow_unprivileged_device_management, :boolean
field :allow_unprivileged_device_configuration, :boolean

View File

@@ -1,6 +1,7 @@
defmodule Domain.Gateways do
use Supervisor
alias Domain.{Repo, Auth, Validator}
alias Domain.Resources
alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence}
def start_link(opts) do
@@ -128,6 +129,24 @@ defmodule Domain.Gateways do
end
end
def list_connected_gateways_for_resource(%Resources.Resource{} = resource) do
connected_gateways = Presence.list("gateways")
gateways =
connected_gateways
|> Map.keys()
# XXX: This will create a pretty large query to send to Postgres,
# we probably want to load connected resources once when gateway connects,
# and persist them in the memory not to query DB every time with a
# `WHERE ... IN (...)`.
|> Gateway.Query.by_ids()
|> Gateway.Query.by_account_id(resource.account_id)
|> Gateway.Query.by_resource_id(resource.id)
|> Repo.all()
{:ok, gateways}
end
def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do
Gateway.Changeset.update_changeset(gateway, attrs)
end
@@ -180,9 +199,9 @@ defmodule Domain.Gateways do
end
end
def connect_gateway(%Gateway{} = gateway, socket) do
def connect_gateway(%Gateway{} = gateway) do
{:ok, _} =
Presence.track(socket, gateway.id, %{
Presence.track(self(), "gateways", gateway.id, %{
online_at: System.system_time(:second)
})

View File

@@ -20,6 +20,8 @@ defmodule Domain.Gateways.Gateway do
belongs_to :group, Domain.Gateways.Group
belongs_to :token, Domain.Gateways.Token
has_many :connections, Domain.Resources.Connection
field :deleted_at, :utc_datetime_usec
timestamps()
end

View File

@@ -10,6 +10,10 @@ defmodule Domain.Gateways.Gateway.Query do
where(queryable, [gateways: gateways], gateways.id == ^id)
end
def by_ids(queryable \\ all(), ids) do
where(queryable, [gateways: gateways], gateways.id in ^ids)
end
def by_user_id(queryable \\ all(), user_id) do
where(queryable, [gateways: gateways], gateways.user_id == ^user_id)
end
@@ -18,10 +22,23 @@ defmodule Domain.Gateways.Gateway.Query do
where(queryable, [gateways: gateways], gateways.account_id == ^account_id)
end
def by_resource_id(queryable \\ all(), resource_id) do
queryable
|> with_joined_connections()
|> where([connections: connections], connections.resource_id == ^resource_id)
end
def returning_all(queryable \\ all()) do
select(queryable, [gateways: gateways], gateways)
end
def with_joined_connections(queryable \\ all()) do
with_named_binding(queryable, :connections, fn queryable, binding ->
queryable
|> join(:inner, [gateways: gateways], connections in assoc(gateways, ^binding), as: ^binding)
end)
end
def with_preloaded_user(queryable \\ all()) do
with_named_binding(queryable, :user, fn queryable, binding ->
queryable

View File

@@ -7,7 +7,7 @@ defmodule Domain.Gateways.Token.Changeset do
%Gateways.Token{}
|> change()
|> put_change(:account_id, account.id)
|> put_change(:value, Domain.Crypto.rand_string())
|> put_change(:value, Domain.Crypto.rand_string(64))
|> put_hash(:value, to: :hash)
|> assoc_constraint(:group)
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")

View File

@@ -1,6 +1,7 @@
defmodule Domain.Relays do
use Supervisor
alias Domain.{Repo, Auth, Validator}
alias Domain.Resources
alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence}
def start_link(opts) do
@@ -128,6 +129,36 @@ defmodule Domain.Relays do
end
end
def list_connected_relays_for_resource(%Resources.Resource{} = resource) do
connected_relays = Presence.list("relays")
relays =
connected_relays
|> Map.keys()
|> Relay.Query.by_ids()
|> Relay.Query.by_account_id(resource.account_id)
|> Repo.all()
|> Enum.map(fn relay ->
%{metas: [%{secret: stamp_secret}]} = Map.get(connected_relays, relay.id)
%{relay | stamp_secret: stamp_secret}
end)
{:ok, relays}
end
def generate_username_and_password(%Relay{stamp_secret: stamp_secret}, %DateTime{} = expires_at)
when is_binary(stamp_secret) do
expires_at = DateTime.to_unix(expires_at, :second)
salt = Domain.Crypto.rand_string()
password = generate_hash(expires_at, stamp_secret, salt)
%{username: "#{expires_at}:#{salt}", password: password, expires_at: expires_at}
end
defp generate_hash(expires_at, stamp_secret, salt) do
:crypto.hash(:sha256, "#{expires_at}:#{stamp_secret}:#{salt}")
|> Base.encode64(padding: false)
end
def upsert_relay(%Token{} = token, attrs) do
changeset = Relay.Changeset.upsert_changeset(token, attrs)
@@ -152,9 +183,9 @@ defmodule Domain.Relays do
end
end
def connect_relay(%Relay{} = relay, secret, socket) do
def connect_relay(%Relay{} = relay, secret) do
{:ok, _} =
Presence.track(socket, relay.id, %{
Presence.track(self(), "relays", relay.id, %{
online_at: System.system_time(:second),
secret: secret
})

View File

@@ -5,13 +5,15 @@ defmodule Domain.Relays.Relay do
field :ipv4, Domain.Types.IP
field :ipv6, Domain.Types.IP
# TODO: port field
field :port, :integer, default: 3478
field :last_seen_user_agent, :string
field :last_seen_remote_ip, Domain.Types.IP
field :last_seen_version, :string
field :last_seen_at, :utc_datetime_usec
field :stamp_secret, :string, virtual: true
belongs_to :account, Domain.Accounts.Account
belongs_to :group, Domain.Relays.Group
belongs_to :token, Domain.Relays.Token

View File

@@ -3,9 +3,9 @@ defmodule Domain.Relays.Relay.Changeset do
alias Domain.Version
alias Domain.Relays
@upsert_fields ~w[ipv4 ipv6
@upsert_fields ~w[ipv4 ipv6 port
last_seen_user_agent last_seen_remote_ip]a
@conflict_replace_fields ~w[ipv4 ipv6
@conflict_replace_fields ~w[ipv4 ipv6 port
last_seen_user_agent last_seen_remote_ip
last_seen_version last_seen_at]a
@@ -19,6 +19,7 @@ defmodule Domain.Relays.Relay.Changeset do
|> cast(attrs, @upsert_fields)
|> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a)
|> validate_required_one_of(~w[ipv4 ipv6]a)
|> validate_number(:port, greater_than_or_equal_to: 1, less_than_or_equal_to: 65_535)
|> unique_constraint(:ipv4, name: :relays_account_id_ipv4_index)
|> unique_constraint(:ipv6, name: :relays_account_id_ipv6_index)
|> put_change(:last_seen_at, DateTime.utc_now())

View File

@@ -10,12 +10,20 @@ defmodule Domain.Relays.Relay.Query do
where(queryable, [relays: relays], relays.id == ^id)
end
def by_ids(queryable \\ all(), ids) do
where(queryable, [relays: relays], relays.id in ^ids)
end
def by_user_id(queryable \\ all(), user_id) do
where(queryable, [relays: relays], relays.user_id == ^user_id)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [relays: relays], relays.account_id == ^account_id)
where(
queryable,
[relays: relays],
relays.account_id == ^account_id or is_nil(relays.account_id)
)
end
def returning_all(queryable \\ all()) do

View File

@@ -6,7 +6,7 @@ defmodule Domain.Relays.Token.Changeset do
def create_changeset(%Accounts.Account{} = account) do
%Relays.Token{}
|> change()
|> put_change(:value, Domain.Crypto.rand_string())
|> put_change(:value, Domain.Crypto.rand_string(64))
|> put_hash(:value, to: :hash)
|> assoc_constraint(:group)
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")

View File

@@ -0,0 +1,117 @@
defmodule Domain.Resources do
alias Domain.{Repo, Validator, Auth}
alias Domain.Resources.{Authorizer, Resource}
def fetch_resource_by_id(id, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()),
true <- Validator.valid_uuid?(id) do
Resource.Query.by_id(id)
|> Authorizer.for_subject(subject)
|> Repo.fetch()
else
false -> {:error, :not_found}
other -> other
end
end
def fetch_resource_by_id!(id) do
if Validator.valid_uuid?(id) do
Resource.Query.by_id(id)
|> Repo.one!()
else
{:error, :not_found}
end
end
def list_resources(%Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
Resource.Query.all()
|> Authorizer.for_subject(subject)
|> Repo.list()
end
end
def create_resource(attrs, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
changeset = Resource.Changeset.create_changeset(subject.account, attrs)
Ecto.Multi.new()
|> Ecto.Multi.insert(:resource, changeset, returning: true)
|> resolve_address_multi(:ipv4)
|> resolve_address_multi(:ipv6)
|> Ecto.Multi.update(:resource_with_address, fn
%{resource: %Resource{} = resource, ipv4: ipv4, ipv6: ipv6} ->
Resource.Changeset.finalize_create_changeset(resource, ipv4, ipv6)
end)
|> Repo.transaction()
|> case do
{:ok, %{resource_with_address: resource}} ->
# TODO: Add optimistic lock to resource.updated_at to serialize the resource updates
# TODO: Broadcast only to actors that have access to the resource
# {:ok, actors} = list_authorized_actors(resource)
# Phoenix.PubSub.broadcast(
# Domain.PubSub,
# "actor_client:#{subject.actor.id}",
# {:resource_added, resource.id}
# )
{:ok, resource}
{:error, :resource, changeset, _effects_so_far} ->
{:error, changeset}
end
end
end
defp resolve_address_multi(multi, type) do
Ecto.Multi.run(multi, type, fn _repo, %{resource: %Resource{} = resource} ->
if address = Map.get(resource, type) do
{:ok, address}
else
{:ok, Domain.Network.fetch_next_available_address!(resource.account_id, type)}
end
end)
end
def update_resource(%Resource{} = resource, attrs, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
resource
|> Resource.Changeset.update_changeset(attrs)
|> Repo.update()
|> case do
{:ok, resource} ->
# Phoenix.PubSub.broadcast(
# Domain.PubSub,
# "actor_client:#{resource.actor_id}",
# {:resource_updated, resource.id}
# )
{:ok, resource}
{:error, reason} ->
{:error, reason}
end
end
end
def delete_resource(%Resource{} = resource, %Auth.Subject{} = subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_resources_permission()) do
Resource.Query.by_id(resource.id)
|> Authorizer.for_subject(subject)
|> Repo.fetch_and_update(with: &Resource.Changeset.delete_changeset/1)
|> case do
{:ok, resource} ->
# Phoenix.PubSub.broadcast(
# Domain.PubSub,
# "actor_client:#{resource.actor_id}",
# {:resource_removed, resource.id}
# )
{:ok, resource}
{:error, reason} ->
{:error, reason}
end
end
end
end

View File

@@ -0,0 +1,25 @@
defmodule Domain.Resources.Authorizer do
use Domain.Auth.Authorizer
alias Domain.Resources.Resource
def manage_resources_permission, do: build(Resource, :manage)
@impl Domain.Auth.Authorizer
def list_permissions_for_role(:admin) do
[
manage_resources_permission()
]
end
def list_permissions_for_role(_) do
[]
end
@impl Domain.Auth.Authorizer
def for_subject(queryable, %Subject{} = subject) do
cond do
has_permission?(subject, manage_resources_permission()) ->
Resource.Query.by_account_id(queryable, subject.account.id)
end
end
end

View File

@@ -0,0 +1,11 @@
defmodule Domain.Resources.Connection do
use Domain, :schema
@primary_key false
schema "resource_connections" do
belongs_to :resource, Domain.Resources.Resource, primary_key: true
belongs_to :gateway, Domain.Gateways.Gateway, primary_key: true
belongs_to :account, Domain.Accounts.Account
end
end

View File

@@ -0,0 +1,16 @@
defmodule Domain.Resources.Connection.Changeset do
use Domain, :changeset
@fields ~w[gateway_id]a
@required_fields @fields
def changeset(account_id, connection, attrs) do
connection
|> cast(attrs, @fields)
|> validate_required(@required_fields)
|> assoc_constraint(:resource)
|> assoc_constraint(:gateway)
|> assoc_constraint(:account)
|> put_change(:account_id, account_id)
end
end

View File

@@ -0,0 +1,11 @@
defmodule Domain.Resources.Connection.Query do
use Domain, :query
def all do
from(connections in Domain.Resources.Connection, as: :connections)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [connections: connections], connections.account_id == ^account_id)
end
end

View File

@@ -0,0 +1,23 @@
defmodule Domain.Resources.Resource do
use Domain, :schema
schema "resources" do
field :address, :string
field :name, :string
embeds_many :filters, Filter, on_replace: :delete do
field :protocol, Ecto.Enum, values: [tcp: 6, udp: 17, icmp: 1, all: -1]
field :ports, {:array, Domain.Types.Int4Range}, default: []
end
field :ipv4, Domain.Types.IP
field :ipv6, Domain.Types.IP
belongs_to :account, Domain.Accounts.Account
has_many :connections, Domain.Resources.Connection, on_replace: :delete
has_many :gateways, through: [:connections, :gateway]
field :deleted_at, :utc_datetime_usec
timestamps()
end
end

View File

@@ -0,0 +1,63 @@
defmodule Domain.Resources.Resource.Changeset do
use Domain, :changeset
alias Domain.Accounts
alias Domain.Resources.{Resource, Connection}
@fields ~w[address name]a
@update_fields ~w[name]a
@required_fields ~w[address]a
def create_changeset(%Accounts.Account{} = account, attrs) do
%Resource{}
|> cast(attrs, @fields)
|> validate_required(@required_fields)
|> changeset()
|> put_change(:account_id, account.id)
|> cast_assoc(:connections,
with: &Connection.Changeset.changeset(account.id, &1, &2),
required: true
)
end
def finalize_create_changeset(%Resource{} = resource, ipv4, ipv6) do
resource
|> change()
|> put_change(:ipv4, ipv4)
|> put_change(:ipv6, ipv6)
|> unique_constraint(:ipv4, name: :resources_account_id_ipv4_index)
|> unique_constraint(:ipv6, name: :resources_account_id_ipv6_index)
end
def update_changeset(%Resource{} = resource, attrs) do
resource
|> cast(attrs, @update_fields)
|> validate_required(@required_fields)
|> changeset()
|> cast_assoc(:connections,
with: &Connection.Changeset.changeset(resource.account_id, &1, &2),
required: true
)
end
defp changeset(changeset) do
changeset
|> put_default_value(:name, from: :address)
|> validate_length(:name, min: 1, max: 255)
|> unique_constraint(:address, name: :resources_account_id_address_index)
|> unique_constraint(:name, name: :resources_account_id_name_index)
|> cast_embed(:filters, with: &cast_filter/2)
|> unique_constraint(:ipv4, name: :resources_account_id_ipv4_index)
|> unique_constraint(:ipv6, name: :resources_account_id_ipv6_index)
end
def delete_changeset(%Resource{} = resource) do
resource
|> change()
|> put_default_value(:deleted_at, DateTime.utc_now())
end
defp cast_filter(%Resource.Filter{} = filter, attrs) do
filter
|> cast(attrs, [:protocol, :ports])
end
end

View File

@@ -0,0 +1,16 @@
defmodule Domain.Resources.Resource.Query do
use Domain, :query
def all do
from(resources in Domain.Resources.Resource, as: :resources)
|> where([resources: resources], is_nil(resources.deleted_at))
end
def by_id(queryable \\ all(), id) do
where(queryable, [resources: resources], resources.id == ^id)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [resources: resources], resources.account_id == ^account_id)
end
end

View File

@@ -27,10 +27,15 @@ defmodule Domain.Types.Int4Range do
end
end
def cast(num) when is_number(num) do
{:ok, Integer.to_string(num)}
end
def cast([num, num]) when is_number(num) do
{:ok, Integer.to_string(num)}
end
def cast(lower..upper) when upper >= lower, do: {:ok, "#{lower} - #{upper}"}
def cast([lower, upper]) when upper >= lower, do: {:ok, "#{lower} - #{upper}"}
def cast([_, _]), do: {:error, message: @cast_error}

View File

@@ -331,6 +331,13 @@ defmodule Domain.Validator do
changeset
end
def put_default_value(changeset, field, from: source_field) do
case fetch_field(changeset, source_field) do
{_data_or_changes, value} -> put_default_value(changeset, field, value)
:error -> changeset
end
end
def put_default_value(changeset, field, value) do
case fetch_field(changeset, field) do
{:data, nil} -> put_change(changeset, field, maybe_apply(changeset, value))

View File

@@ -34,7 +34,8 @@ defmodule Domain.MixProject do
mod: {Domain.Application, []},
extra_applications: [
:logger,
:runtime_tools
:runtime_tools,
:crypto
]
]
end

View File

@@ -8,6 +8,8 @@ defmodule Domain.Repo.Migrations.CreateRelays do
add(:ipv4, :inet)
add(:ipv6, :inet)
add(:port, :integer, null: false)
add(:last_seen_user_agent, :string, null: false)
add(:last_seen_remote_ip, :inet, null: false)
add(:last_seen_version, :string, null: false)

View File

@@ -0,0 +1,68 @@
defmodule Domain.Repo.Migrations.CreateResourcesAndConnections do
use Ecto.Migration
def change do
create table(:resources, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:address, :string, null: false)
add(:name, :string, null: false)
add(:filters, :map, default: fragment("'[]'::jsonb"), null: false)
add(
:ipv4,
references(:network_addresses,
column: :address,
type: :inet,
with: [account_id: :account_id]
)
)
add(
:ipv6,
references(:network_addresses,
column: :address,
type: :inet,
with: [account_id: :account_id]
)
)
add(:account_id, references(:accounts, type: :binary_id), null: false)
add(:deleted_at, :utc_datetime_usec)
timestamps(type: :utc_datetime_usec)
end
create(index(:resources, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL"))
create(index(:resources, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL"))
create(
index(:resources, [:account_id, :name],
unique: true,
where: "deleted_at IS NULL"
)
)
create(
index(:resources, [:account_id, :address],
unique: true,
where: "deleted_at IS NULL"
)
)
create table(:resource_connections, primary_key: false) do
add(:resource_id, references(:resources, type: :binary_id, on_delete: :delete_all),
primary_key: true,
null: false
)
add(:gateway_id, references(:gateways, type: :binary_id, on_delete: :delete_all),
primary_key: true,
null: false
)
add(:account_id, references(:accounts, type: :binary_id), null: false)
end
end
end

View File

@@ -1,246 +1,139 @@
# Script for populating the database. You can run it as:
#
# mix run priv/repo/seeds.exs
#
# Inside the script, you can read and write to any of your
# repositories directly:
#
# Domain.Repo.insert!(%Domain.SomeSchema{})
#
# We recommend using the bang functions (`insert!`, `update!`
# and so on) as they will fail if something goes wrong.
alias Domain.{Repo, Accounts, Auth, Actors, Relays, Gateways, Resources}
# alias Domain.{
# Repo,
# ConnectivityChecks,
# Devices,
# Users,
# ApiTokens,
# Rules,
# Auth.MFA
# }
{:ok, account} = Accounts.create_account(%{name: "Firezone Account"})
{:ok, _account} = Accounts.create_account(%{name: "Other Corp Account"})
# create_device = fn user, attrs ->
# Devices.Device.Changeset.create_changeset(user, attrs)
# |> Devices.Device.Changeset.configure_changeset(attrs)
# |> Repo.insert()
# end
{:ok, email_provider} =
Auth.create_provider(account, %{
name: "email",
adapter: :email,
adapter_config: %{}
})
# {:ok, unprivileged_user1} =
# Users.create_user(:unprivileged, %{
# email: "firezone-unprivileged-1@localhost"
# })
{:ok, _oidc_provider} =
Auth.create_provider(account, %{
name: "Vault",
adapter: :openid_connect,
adapter_config: %{
"client_id" => "CLIENT_ID",
"client_secret" => "CLIENT_SECRET",
"response_type" => "code",
"scope" => "openid email offline_access",
"discovery_document_uri" => "https://common.auth0.com/.well-known/openid-configuration"
}
})
# {:ok, _device} =
# create_device.(unprivileged_user1, %{
# name: "My Device",
# description: "foo bar",
# preshared_key: "27eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "4Fo+SBnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 5, 0, 1}},
# dns: ["8.8.8.8", "8.8.4.4"],
# allowed_ips: [
# %Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0},
# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0},
# %Postgrex.INET{address: {1, 1, 1, 1}}
# ],
# use_default_allowed_ips: false,
# use_default_dns: false,
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
{:ok, userpass_provider} =
Auth.create_provider(account, %{
name: "UserPass",
adapter: :userpass,
adapter_config: %{}
})
# {:ok, mfa_user} =
# Users.create_user(:unprivileged, %{
# email: "firezone-mfa@localhost",
# password: "firezone1234",
# password_confirmation: "firezone1234"
# })
unprivileged_actor_email = "firezone-unprivileged-1@localhost"
admin_actor_email = "firezone@localhost"
# secret = NimbleTOTP.secret()
{:ok, unprivileged_actor} =
Actors.create_actor(email_provider, unprivileged_actor_email, %{
type: :user,
role: :unprivileged
})
# MFA.create_method(
# %{
# name: "Google Authenticator",
# type: :totp,
# payload: %{"secret" => Base.encode64(secret)},
# code: NimbleTOTP.verification_code(secret)
# },
# mfa_user.id
# )
{:ok, admin_actor} =
Actors.create_actor(email_provider, admin_actor_email, %{
type: :user,
role: :admin
})
# {:ok, user} =
# Users.create_user(:admin, %{
# email: "firezone@localhost",
# password: "firezone1234",
# password_confirmation: "firezone1234"
# })
{:ok, _unprivileged_actor_userpass_identity} =
Auth.create_identity(unprivileged_actor, userpass_provider, unprivileged_actor_email, %{
"password" => "Firezone1234",
"password_confirmation" => "Firezone1234"
})
# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 5})
# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 30})
# {:ok, _api_token} = ApiTokens.create_api_token(user, %{"expires_in" => 1})
{:ok, _admin_actor_userpass_identity} =
Auth.create_identity(admin_actor, userpass_provider, admin_actor_email, %{
"password" => "Firezone1234",
"password_confirmation" => "Firezone1234"
})
# {:ok, _device} =
# create_device.(user, %{
# name: "wireguard-client",
# description: """
# Test device corresponding to the client configuration used in the wireguard-client container
# """,
# preshared_key: "C+Tte1echarIObr6rq+nFeYQ1QO5xo5N29ygDjMlpS8=",
# public_key: "pSLWbPiQ2mKh26IG1dMFQQWuAstFJXV91dNk+olzEjA=",
# mtu: 1280,
# persistent_keepalive: 25,
# allowed_ips: [
# %Postgrex.INET{address: {0, 0, 0, 0}, netmask: 0},
# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0}
# ],
# endpoint: "elixir",
# dns: ["127.0.0.11"],
# use_default_allowed_ips: false,
# use_default_dns: false,
# use_default_endpoint: false,
# use_default_mtu: false,
# use_default_persistent_keepalive: false
# })
unprivileged_actor_token = hd(unprivileged_actor.identities).provider_virtual_state.sign_in_token
admin_actor_token = hd(admin_actor.identities).provider_virtual_state.sign_in_token
# {:ok, _device} =
# create_device.(user, %{
# name: "Factory Device 3",
# description: "foo 3",
# preshared_key: "23eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "3Fo+SBnDJ6hi8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 1, 0, 1}},
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
admin_subject =
Auth.build_subject(
hd(admin_actor.identities),
nil,
"iOS/12.5 (iPhone) connlib/0.7.412",
{100, 64, 100, 58}
)
# {:ok, _device} =
# create_device.(user, %{
# name: "Factory Device 5",
# description: "foo 3",
# preshared_key: "23eCDMVRbFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "3Fo+SBnDJ6hb8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 3, 0, 1}},
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
IO.puts("Created users: ")
# {:ok, _device} =
# create_device.(user, %{
# name: "Factory Device 4",
# description: "foo 3",
# preshared_key: "2yeCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "3Fo+nBnDJ6hi8q4Pt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 4, 0, 1}},
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
for {role, login, password, email_token} <- [
{:unprivileged, unprivileged_actor_email, "Firezone1234", unprivileged_actor_token},
{:admin, admin_actor_email, "Firezone1234", admin_actor_token}
] do
IO.puts(" #{login}, #{role}, password: #{password}, email token: #{email_token}")
end
# {:ok, user} =
# Users.create_user(:admin, %{
# email: "firezone2@localhost",
# password: "firezone1234",
# password_confirmation: "firezone1234"
# })
IO.puts("")
# {:ok, _device} =
# create_device.(user, %{
# name: "Factory Device 2",
# description: "foo 2",
# preshared_key: "27eCDMVRVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "3Fo+SBnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 5, 0, 1}},
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
relay_group =
account
|> Relays.Group.Changeset.create_changeset(%{name: "mycorp-aws-relays", tokens: [%{}]})
|> Repo.insert!()
# {:ok, _device} =
# create_device.(user, %{
# name: "Factory Device",
# description: """
# Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula eget dolor. A\
# enean massa. Cum sociis natoque penatibus et magnis dis parturient montes, nascetur ridiculus\
# mus. Donec quam felis, ultricies nec, pellentesque eu, pretium quis, sem. Nulla consequat ma\
# ssa quis enim. Donec pede justo, fringilla vel, aliquet nec, vulputate eget, arcu. In enim ju\
# sto, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam dictum felis eu pede mollis pret\
# ium. Integer tincidunt. Cras dapibus. Vivamus elementum semper nisi. Aenean vulputate eleifen\
# d tellus. Aenean leo ligula, porttitor eu, consequat vitae, eleifend ac, enim. Aliquam lorem \
# ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus viverra nulla ut metus varius la\
# oreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi vel augue. Curabitur ullamcorpe\
# r ultricies nisi. Nam eget dui. Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aen\
# ean commodo ligula eget dolor. Aenean massa. Cum sociis natoque penatibus et magnis dis partu\
# rient montes, nascetur ridiculus mus. Donec quam felis, ultricies nec, pellentesque eu, preti\
# um quis, sem. Nulla consequat massa quis enim. Donec pede justo, fringilla vel, aliquet nec, \
# vulputate eget, arcu. In enim justo, rhoncus ut, imperdiet a, venenatis vitae, justo. Nullam \
# dictum felis eu pede mollis pretium. Integer tincidunt. Cras dapibus. Vivamus elementum sempe\
# r nisi. Aenean vulputate eleifend tellus. Aenean leo ligula, porttitor eu, consequat vitae, e\
# leifend ac, enim. Aliquam lorem ante, dapibus in, viverra quis, feugiat a, tellus. Phasellus \
# viverra nulla ut metus varius laoreet. Quisque rutrum. Aenean imperdiet. Etiam ultricies nisi\
# vel augue. Curabitur ullamcorper ultricies nisi. Nam eget dui. Lorem ipsum dolor sit amet, c\
# onsectetuer adipiscing elit. Aenean commodo ligula eget dolor. Aenean massa. Cum sociis natoq\
# ue penatibus et magnis dis parturient montes, nascetur ridiculus mus. Donec quam felis, ultri\
# cies nec, pellentesque eu, pretium quis, sem. Nulla consequat massa quis enim. Donec pede jus\
# to\
# """,
# preshared_key: "27eCDMVvVFfMVS5Rfnn9n7as4M6MemGY/oghmdrwX2E=",
# public_key: "3Fo+SNnDJ6hi8qzPt3nWLwgjCVwvpjHL35qJeatKwEc=",
# remote_ip: %Postgrex.INET{address: {127, 6, 0, 1}},
# rx_bytes: 123_917_823,
# tx_bytes: 1_934_475_211_087_234
# })
IO.puts("Created relay groups:")
IO.puts(" #{relay_group.name} token: #{hd(relay_group.tokens).value}")
IO.puts("")
# {:ok, _connectivity_check} =
# ConnectivityChecks.ConnectivityCheck.Changeset.create_changeset(%{
# response_headers: %{"Content-Type" => "text/plain"},
# response_body: "127.0.0.1",
# response_code: 200,
# url: "https://ping-dev.firez.one/0.1.19"
# })
# |> Repo.insert()
gateway_group =
account
|> Gateways.Group.Changeset.create_changeset(%{name_prefix: "mycro-aws-gws", tokens: [%{}]})
|> Repo.insert!()
# {:ok, _connectivity_check} =
# ConnectivityChecks.ConnectivityCheck.Changeset.create_changeset(%{
# response_headers: %{"Content-Type" => "text/plain"},
# response_body: "127.0.0.1",
# response_code: 400,
# url: "https://ping-dev.firez.one/0.20.0"
# })
# |> Repo.insert()
IO.puts("Created gateway groups:")
IO.puts(" #{gateway_group.name_prefix} token: #{hd(gateway_group.tokens).value}")
IO.puts("")
# Rules.create_rule(%{
# destination: "10.0.0.0/24",
# port_type: :tcp,
# port_range: "100-200"
# })
{:ok, gateway} =
Gateways.upsert_gateway(hd(gateway_group.tokens), %{
external_id: Ecto.UUID.generate(),
name_suffix: "gw-#{Domain.Crypto.rand_string(5)}",
public_key: :crypto.strong_rand_bytes(32) |> Base.encode64(),
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}}
})
# Rules.create_rule(%{
# destination: "1.2.3.4"
# })
IO.puts("Created gateways:")
gateway_name = "#{gateway_group.name_prefix}-#{gateway.name_suffix}"
IO.puts(" #{gateway_name}:")
IO.puts(" External UUID: #{gateway.external_id}")
IO.puts(" Public Key: #{gateway.public_key}")
IO.puts(" IPv4: #{gateway.ipv4} IPv6: #{gateway.ipv6}")
IO.puts("")
# Domain.Config.put_config!(:default_client_dns, ["4.3.2.1", "1.2.3.4"])
{:ok, dns_resource} =
Resources.create_resource(
%{
address: "gitlab.mycorp.com",
connections: [%{gateway_id: gateway.id}]
},
admin_subject
)
# Domain.Config.put_config!(
# :default_client_allowed_ips,
# [
# %Postgrex.INET{address: {10, 0, 0, 1}, netmask: 20},
# %Postgrex.INET{address: {0, 0, 0, 0, 0, 0, 0, 0}, netmask: 0},
# %Postgrex.INET{address: {1, 1, 1, 1}}
# ]
# )
{:ok, cidr_resource} =
Resources.create_resource(
%{
address: "172.172.0.1/16",
connections: [%{gateway_id: gateway.id}]
},
admin_subject
)
# Domain.Config.put_config!(
# :openid_connect_providers,
# [
# %{
# "id" => "vault",
# "discovery_document_uri" => "https://common.auth0.com/.well-known/openid-configuration",
# "client_id" => "CLIENT_ID",
# "client_secret" => "CLIENT_SECRET",
# "redirect_uri" => "http://localhost:13000/auth/oidc/vault/callback/",
# "response_type" => "code",
# "scope" => "openid email offline_access",
# "label" => "OIDC Vault",
# "auto_create_users" => true
# }
# ]
# )
IO.puts("Created resources:")
IO.puts(" #{dns_resource.address} - DNS - #{dns_resource.ipv4} - gateways: #{gateway_name}")
IO.puts(" #{cidr_resource.address} - CIDR - #{cidr_resource.ipv4} - gateways: #{gateway_name}")
IO.puts("")

View File

@@ -155,6 +155,7 @@ defmodule Domain.ActorsTest do
actor: %{id: Ecto.UUID.generate()},
account: %{id: Ecto.UUID.generate()},
context: nil,
expires_at: nil,
permissions: MapSet.new()
}
|> AuthFixtures.set_permissions([

View File

@@ -1,7 +1,56 @@
defmodule Domain.Auth.Adapters.EmailTest do
use Domain.DataCase, async: true
import Domain.Auth.Adapters.Email
alias Domain.AuthFixtures
alias Domain.Auth
alias Domain.{AccountsFixtures, AuthFixtures}
describe "identity_changeset/2" do
setup do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
changeset = %Auth.Identity{} |> Ecto.Changeset.change()
%{
account: account,
provider: provider,
changeset: changeset
}
end
test "puts default provider state", %{provider: provider, changeset: changeset} do
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
assert %{
provider_state: %{
sign_in_token_created_at: %DateTime{},
sign_in_token_hash: sign_in_token_hash
},
provider_virtual_state: %{sign_in_token: sign_in_token}
} = changeset.changes
assert Domain.Crypto.equal?(sign_in_token, sign_in_token_hash)
end
test "trims provider identifier", %{provider: provider, changeset: changeset} do
changeset = Ecto.Changeset.put_change(changeset, :provider_identifier, " X ")
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
assert changeset.changes.provider_identifier == "X"
end
end
describe "ensure_provisioned/1" do
test "returns changeset as is" do
changeset = %Ecto.Changeset{}
assert ensure_provisioned(changeset) == changeset
end
end
describe "ensure_deprovisioned/1" do
test "returns changeset as is" do
changeset = %Ecto.Changeset{}
assert ensure_deprovisioned(changeset) == changeset
end
end
describe "request_sign_in_token/1" do
test "returns identity with updated sign-in token" do
@@ -22,4 +71,50 @@ defmodule Domain.Auth.Adapters.EmailTest do
assert %DateTime{} = sign_in_token_created_at
end
end
describe "verify_secret/2" do
setup do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
identity = AuthFixtures.create_identity(account: account, provider: provider)
token = identity.provider_virtual_state.sign_in_token
%{account: account, provider: provider, identity: identity, token: token}
end
test "removes token after it's used", %{
identity: identity,
token: token
} do
assert {:ok, identity, nil} = verify_secret(identity, token)
assert identity.provider_state == %{}
assert identity.provider_virtual_state == %{}
end
test "returns error when token is expired", %{
account: account,
provider: provider
} do
forty_seconds_ago = DateTime.utc_now() |> DateTime.add(-1 * 15 * 60 - 1, :second)
identity =
AuthFixtures.create_identity(
account: account,
provider: provider,
provider_state: %{
"sign_in_token_hash" => Domain.Crypto.hash("dummy_token"),
"sign_in_token_created_at" => DateTime.to_iso8601(forty_seconds_ago)
}
)
assert verify_secret(identity, "dummy_token") == {:error, :expired_secret}
end
test "returns error when token is invalid", %{
identity: identity
} do
assert verify_secret(identity, "foo") == {:error, :invalid_secret}
end
end
end

View File

@@ -8,10 +8,15 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
describe "identity_changeset/2" do
setup do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_email_provider(account: account)
{provider, bypass} =
ConfigFixtures.start_openid_providers(["google"])
|> AuthFixtures.create_openid_connect_provider(account: account)
changeset = %Auth.Identity{} |> Ecto.Changeset.change()
%{
bypass: bypass,
account: account,
provider: provider,
changeset: changeset
@@ -155,12 +160,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
redirect_uri = "https://example.com/"
payload = {redirect_uri, code_verifier, "MyFakeCode"}
assert {:ok, identity} = verify_secret(identity, payload)
assert {:ok, identity, expires_at} = verify_secret(identity, payload)
assert identity.provider_state == %{
access_token: nil,
claims: claims,
expires_at: nil,
expires_at: expires_at,
id_token: token,
refresh_token: nil,
userinfo: %{
@@ -198,7 +203,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
redirect_uri = "https://example.com/"
payload = {redirect_uri, code_verifier, "MyFakeCode"}
assert {:ok, identity} = verify_secret(identity, payload)
assert {:ok, identity, _expires_at} = verify_secret(identity, payload)
assert identity.provider_state.id_token == token
assert identity.provider_state.access_token == "MY_ACCESS_TOKEN"
@@ -221,7 +226,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
redirect_uri = "https://example.com/"
payload = {redirect_uri, code_verifier, "MyFakeCode"}
assert verify_secret(identity, payload) == {:error, :expired_token}
assert verify_secret(identity, payload) == {:error, :expired_secret}
end
test "returns error when token is invalid", %{
@@ -236,7 +241,7 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
redirect_uri = "https://example.com/"
payload = {redirect_uri, code_verifier, "MyFakeCode"}
assert verify_secret(identity, payload) == {:error, :invalid_token}
assert verify_secret(identity, payload) == {:error, :invalid_secret}
end
test "returns error when provider is down", %{
@@ -283,12 +288,12 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
ConfigFixtures.expect_userinfo(bypass)
assert {:ok, identity} = refresh_token(identity)
assert {:ok, identity, expires_at} = refresh_token(identity)
assert identity.provider_state == %{
access_token: "MY_ACCESS_TOKEN",
claims: claims,
expires_at: nil,
expires_at: expires_at,
id_token: token,
refresh_token: "MY_REFRESH_TOKEN",
userinfo: %{
@@ -303,6 +308,8 @@ defmodule Domain.Auth.Adapters.OpenIDConnectTest do
"sub" => "353690423699814251281"
}
}
assert DateTime.diff(expires_at, DateTime.utc_now()) in 5..15
end
end

View File

@@ -0,0 +1,141 @@
defmodule Domain.Auth.Adapters.UserPassTest do
use Domain.DataCase, async: true
import Domain.Auth.Adapters.UserPass
alias Domain.Auth
alias Domain.{AccountsFixtures, AuthFixtures}
describe "identity_changeset/2" do
setup do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_userpass_provider(account: account)
%{
account: account,
provider: provider
}
end
test "puts password hash in the provider state", %{provider: provider} do
changeset =
%Auth.Identity{}
|> Ecto.Changeset.change(
provider_virtual_state: %{
password: "Firezone1234",
password_confirmation: "Firezone1234"
}
)
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
assert %{provider_state: state, provider_virtual_state: virtual_state} = changeset.changes
assert %{password_hash: password_hash} = state
assert Domain.Crypto.equal?("Firezone1234", password_hash)
assert virtual_state == %{}
end
test "returns error on invalid attrs", %{provider: provider} do
changeset =
%Auth.Identity{}
|> Ecto.Changeset.change(
provider_virtual_state: %{
password: "short",
password_confirmation: nil
}
)
assert changeset = identity_changeset(provider, changeset)
refute changeset.valid?
assert errors_on(changeset) == %{
provider_virtual_state: %{
password: ["should be at least 12 character(s)"],
password_confirmation: ["does not match confirmation"]
}
}
changeset =
%Auth.Identity{}
|> Ecto.Changeset.change(
provider_virtual_state: %{
password: "Firezone1234",
password_confirmation: "FirezoneDoesNotMatch"
}
)
assert changeset = identity_changeset(provider, changeset)
refute changeset.valid?
assert errors_on(changeset) == %{
provider_virtual_state: %{
password_confirmation: ["does not match confirmation"]
}
}
end
test "trims provider identifier", %{provider: provider} do
changeset =
%Auth.Identity{}
|> Ecto.Changeset.change(
provider_identifier: " X ",
provider_virtual_state: %{
password: "Firezone1234",
password_confirmation: "Firezone1234"
}
)
assert %Ecto.Changeset{} = changeset = identity_changeset(provider, changeset)
assert changeset.changes.provider_identifier == "X"
end
end
describe "ensure_provisioned/1" do
test "returns changeset as is" do
changeset = %Ecto.Changeset{}
assert ensure_provisioned(changeset) == changeset
end
end
describe "ensure_deprovisioned/1" do
test "returns changeset as is" do
changeset = %Ecto.Changeset{}
assert ensure_deprovisioned(changeset) == changeset
end
end
describe "verify_secret/2" do
setup do
account = AccountsFixtures.create_account()
provider = AuthFixtures.create_userpass_provider(account: account)
identity =
AuthFixtures.create_identity(
account: account,
provider: provider,
provider_virtual_state: %{
"password" => "Firezone1234",
"password_confirmation" => "Firezone1234"
}
)
%{
account: account,
provider: provider,
identity: identity
}
end
test "returns :invalid_secret on invalid password", %{identity: identity} do
assert verify_secret(identity, "FirezoneInvalid") == {:error, :invalid_secret}
end
test "returns :ok on valid password", %{identity: identity} do
assert {:ok, verified_identity, nil} = verify_secret(identity, "Firezone1234")
assert verified_identity.provider_state["password_hash"] ==
identity.provider_state.password_hash
end
end
end

View File

@@ -1,107 +0,0 @@
# defmodule Domain.Auth.Adapters.UserPassTest do
# use Domain.DataCase, async: true
# import Domain.Auth.Adapters.UserPass
# test "does not allow to clear the password", %{subject: subject} do
# password = "password1234"
# actor = ActorsFixtures.create_actor(role: :admin, %{password: password})
# attrs = %{
# "password" => nil,
# "password_hash" => nil
# }
# assert {:ok, updated_actor} = update_actor(actor, attrs, subject)
# assert updated_actor.password_hash == actor.password_hash
# attrs = %{
# "password" => "",
# "password_hash" => ""
# }
# assert {:ok, updated_actor} = update_actor(actor, attrs, subject)
# assert updated_actor.password_hash == actor.password_hash
# end
# test "returns error on invalid attrs", %{subject: subject, account: account} do
# assert {:error, changeset} =
# create_actor(
# account,
# :unprivileged,
# %{email: "invalid_email", password: "short"},
# subject
# )
# refute changeset.valid?
# assert errors_on(changeset) == %{
# email: ["is invalid email address"],
# password: ["should be at least 12 character(s)"],
# password_confirmation: ["can't be blank"]
# }
# assert {:error, changeset} =
# create_actor(
# account,
# :unprivileged,
# %{email: "invalid_email", password: String.duplicate("A", 65)},
# subject
# )
# refute changeset.valid?
# assert "should be at most 64 character(s)" in errors_on(changeset).password
# assert {:error, changeset} =
# create_actor(account, :unprivileged, %{email: String.duplicate(" ", 18)}, subject)
# refute changeset.valid?
# assert "can't be blank" in errors_on(changeset).email
# end
# test "requires password confirmation to match the password", %{
# subject: subject,
# account: account
# } do
# assert {:error, changeset} =
# create_actor(
# account,
# :unprivileged,
# %{password: "foo", password_confirmation: "bar"},
# subject
# )
# assert "does not match confirmation" in errors_on(changeset).password_confirmation
# assert {:error, changeset} =
# create_actor(
# account,
# :unprivileged,
# %{
# password: "password1234",
# password_confirmation: "password1234"
# },
# subject
# )
# refute Map.has_key?(errors_on(changeset), :password_confirmation)
# end
# test "returns error when email is already taken", %{subject: subject, account: account} do
# attrs = ActorsFixtures.actor_attrs()
# assert {:ok, _actor} = create_actor(account, :unprivileged, attrs, subject)
# assert {:error, changeset} = create_actor(account, :unprivileged, attrs, subject)
# refute changeset.valid?
# assert "has already been taken" in errors_on(changeset).email
# end
# test "trims email", %{subject: subject, account: account} do
# attrs = ActorsFixtures.actor_attrs()
# updated_attrs = Map.put(attrs, :email, " #{attrs.email} ")
# assert {:ok, actor} = create_actor(account, :unprivileged, updated_attrs, subject)
# assert actor.email == attrs.email
# end
# end

View File

@@ -691,6 +691,33 @@ defmodule Domain.AuthTest do
assert subject.context.user_agent == user_agent
end
test "returned subject expiration depends on user role", %{
account: account,
provider: provider,
user_agent: user_agent,
remote_ip: remote_ip
} do
actor = ActorsFixtures.create_actor(role: :admin, account: account)
identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor)
secret = identity.provider_virtual_state.sign_in_token
assert {:ok, %Auth.Subject{} = subject} =
sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip)
three_hours = 3 * 60 * 60
assert_datetime_diff(subject.expires_at, DateTime.utc_now(), three_hours)
actor = ActorsFixtures.create_actor(role: :unprivileged, account: account)
identity = AuthFixtures.create_identity(account: account, provider: provider, actor: actor)
secret = identity.provider_virtual_state.sign_in_token
assert {:ok, %Auth.Subject{} = subject} =
sign_in(provider, identity.provider_identifier, secret, user_agent, remote_ip)
one_week = 7 * 24 * 60 * 60
assert_datetime_diff(subject.expires_at, DateTime.utc_now(), one_week)
end
test "returns error when provider is disabled", %{
account: account,
provider: provider,
@@ -791,6 +818,7 @@ defmodule Domain.AuthTest do
assert reconstructed_subject.account.id == subject.account.id
assert reconstructed_subject.permissions == subject.permissions
assert reconstructed_subject.context == subject.context
assert DateTime.diff(reconstructed_subject.expires_at, subject.expires_at) <= 1
end
test "updates last signed in fields for identity on success", %{
@@ -847,6 +875,16 @@ defmodule Domain.AuthTest do
end
end
describe "fetch_session_token_expires_at/2" do
test "returns datetime when the token expires" do
subject = AuthFixtures.create_subject()
{:ok, token} = create_session_token_from_subject(subject)
assert {:ok, expires_at} = fetch_session_token_expires_at(token)
assert_datetime_diff(expires_at, DateTime.utc_now(), 60)
end
end
describe "has_permission?/2" do
setup do
account = AccountsFixtures.create_account()

View File

@@ -437,6 +437,10 @@ defmodule Domain.ClientsTest do
assert_raise Ecto.ConstraintError, fn ->
NetworkFixtures.create_address(address: client.ipv4, account: account)
end
assert_raise Ecto.ConstraintError, fn ->
NetworkFixtures.create_address(address: client.ipv6, account: account)
end
end
test "ip addresses are unique per account", %{

View File

@@ -1,7 +1,7 @@
defmodule Domain.GatewaysTest do
use Domain.DataCase, async: true
import Domain.Gateways
alias Domain.AccountsFixtures
alias Domain.{AccountsFixtures, ResourcesFixtures}
alias Domain.{NetworkFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures}
alias Domain.Gateways
@@ -440,6 +440,45 @@ defmodule Domain.GatewaysTest do
end
end
describe "list_connected_gateways_for_resource/1" do
test "returns empty list when there are no online gateways", %{account: account} do
resource = ResourcesFixtures.create_resource(account: account)
GatewaysFixtures.create_gateway(account: account)
GatewaysFixtures.create_gateway(account: account)
|> GatewaysFixtures.delete_gateway()
assert list_connected_gateways_for_resource(resource) == {:ok, []}
end
test "returns list of connected gateways for a given resource", %{account: account} do
gateway = GatewaysFixtures.create_gateway(account: account)
resource =
ResourcesFixtures.create_resource(
account: account,
gateways: [%{gateway_id: gateway.id}]
)
assert connect_gateway(gateway) == :ok
assert {:ok, [connected_gateway]} = list_connected_gateways_for_resource(resource)
assert connected_gateway.id == gateway.id
end
test "does not return connected gateways that are not connected to given resource", %{
account: account
} do
resource = ResourcesFixtures.create_resource(account: account)
gateway = GatewaysFixtures.create_gateway(account: account)
assert connect_gateway(gateway) == :ok
assert list_connected_gateways_for_resource(resource) == {:ok, []}
end
end
describe "change_gateway/1" do
test "returns changeset with given changes" do
gateway = GatewaysFixtures.create_gateway()

View File

@@ -1,7 +1,8 @@
defmodule Domain.RelaysTest do
use Domain.DataCase, async: true
import Domain.Relays
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, RelaysFixtures}
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, ResourcesFixtures}
alias Domain.RelaysFixtures
alias Domain.Relays
setup do
@@ -415,6 +416,54 @@ defmodule Domain.RelaysTest do
end
end
describe "list_connected_relays_for_resource/1" do
test "returns empty list when there are no online relays", %{account: account} do
resource = ResourcesFixtures.create_resource(account: account)
RelaysFixtures.create_relay(account: account)
RelaysFixtures.create_relay(account: account)
|> RelaysFixtures.delete_relay()
assert list_connected_relays_for_resource(resource) == {:ok, []}
end
test "returns list of connected relays", %{account: account} do
resource = ResourcesFixtures.create_resource(account: account)
relay = RelaysFixtures.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
assert connect_relay(relay, stamp_secret) == :ok
assert {:ok, [connected_relay]} = list_connected_relays_for_resource(resource)
assert connected_relay.id == relay.id
assert connected_relay.stamp_secret == stamp_secret
end
end
describe "generate_username_and_password/1" do
test "returns username and password", %{account: account} do
relay = RelaysFixtures.create_relay(account: account)
stamp_secret = Ecto.UUID.generate()
relay = %{relay | stamp_secret: stamp_secret}
expires_at = DateTime.utc_now() |> DateTime.add(3, :second)
assert %{username: username, password: password, expires_at: expires_at_unix} =
generate_username_and_password(relay, 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 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
end
end
describe "upsert_relay/3" do
setup context do
token = RelaysFixtures.create_token(account: context.account)
@@ -431,7 +480,8 @@ defmodule Domain.RelaysTest do
ipv4: "1.1.1.256",
ipv6: "fd01::10000",
last_seen_user_agent: "foo",
last_seen_remote_ip: {256, 0, 0, 0}
last_seen_remote_ip: {256, 0, 0, 0},
port: -1
}
assert {:error, changeset} = upsert_relay(token, attrs)
@@ -439,8 +489,13 @@ defmodule Domain.RelaysTest do
assert errors_on(changeset) == %{
ipv4: ["one of these fields must be present: ipv4, ipv6", "is invalid"],
ipv6: ["one of these fields must be present: ipv4, ipv6", "is invalid"],
last_seen_user_agent: ["is invalid"]
last_seen_user_agent: ["is invalid"],
port: ["must be greater than or equal to 1"]
}
attrs = %{port: 100_000}
assert {:error, changeset} = upsert_relay(token, attrs)
assert "must be less than or equal to 65535" in errors_on(changeset).port
end
test "allows creating relay with just required attributes", %{
@@ -462,6 +517,7 @@ defmodule Domain.RelaysTest do
assert relay.last_seen_user_agent == attrs.last_seen_user_agent
assert relay.last_seen_version == "0.7.412"
assert relay.last_seen_at
assert relay.port == 3478
assert Repo.aggregate(Domain.Network.Address, :count) == 0
end
@@ -508,6 +564,7 @@ defmodule Domain.RelaysTest do
assert updated_relay.ipv4 == relay.ipv4
assert updated_relay.ipv6.address == attrs.ipv6
assert updated_relay.ipv6 != relay.ipv6
assert updated_relay.port == 3478
assert Repo.aggregate(Domain.Network.Address, :count) == 0
end

View File

@@ -0,0 +1,351 @@
defmodule Domain.ResourcesTest do
use Domain.DataCase, async: true
import Domain.Resources
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures, NetworkFixtures}
alias Domain.ResourcesFixtures
alias Domain.Resources
setup do
account = AccountsFixtures.create_account()
actor = ActorsFixtures.create_actor(role: :admin, account: account)
identity = AuthFixtures.create_identity(account: account, actor: actor)
subject = AuthFixtures.create_subject(identity)
%{
account: account,
actor: actor,
identity: identity,
subject: subject
}
end
describe "fetch_resource_by_id/2" do
test "returns error when resource does not exist", %{subject: subject} do
assert fetch_resource_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found}
end
test "returns error when UUID is invalid", %{subject: subject} do
assert fetch_resource_by_id("foo", subject) == {:error, :not_found}
end
test "returns resource when resource exists", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
resource = ResourcesFixtures.create_resource(account: account, gateway: gateway)
assert {:ok, fetched_resource} = fetch_resource_by_id(resource.id, subject)
assert fetched_resource.id == resource.id
end
test "does not return deleted resources", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
{:ok, resource} =
ResourcesFixtures.create_resource(account: account, gateway: gateway)
|> delete_resource(subject)
assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found}
end
test "does not return resources in other accounts", %{subject: subject} do
resource = ResourcesFixtures.create_resource()
assert fetch_resource_by_id(resource.id, subject) == {:error, :not_found}
end
test "returns error when subject has no permission to view resources", %{subject: subject} do
subject = AuthFixtures.remove_permissions(subject)
assert fetch_resource_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
end
end
describe "list_resources/1" do
test "returns empty list when there are no resources", %{subject: subject} do
assert list_resources(subject) == {:ok, []}
end
test "does not list resources from other accounts", %{
subject: subject
} do
ResourcesFixtures.create_resource()
assert list_resources(subject) == {:ok, []}
end
test "does not list deleted resources", %{
account: account,
subject: subject
} do
ResourcesFixtures.create_resource(account: account)
|> delete_resource(subject)
assert list_resources(subject) == {:ok, []}
end
test "returns all resources", %{
account: account,
subject: subject
} do
ResourcesFixtures.create_resource(account: account)
ResourcesFixtures.create_resource(account: account)
ResourcesFixtures.create_resource()
assert {:ok, resources} = list_resources(subject)
assert length(resources) == 2
end
test "returns error when subject has no permission to manage resources", %{
subject: subject
} do
subject = AuthFixtures.remove_permissions(subject)
assert list_resources(subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
end
end
describe "create_resource/2" do
test "returns changeset error on empty attrs", %{subject: subject} do
assert {:error, changeset} = create_resource(%{}, subject)
assert errors_on(changeset) == %{
address: ["can't be blank"],
connections: ["can't be blank"]
}
end
test "returns error on invalid attrs", %{subject: subject} do
attrs = %{"name" => String.duplicate("a", 256), "filters" => :foo, "connections" => :bar}
assert {:error, changeset} = create_resource(attrs, subject)
assert errors_on(changeset) == %{
address: ["can't be blank"],
name: ["should be at most 255 character(s)"],
filters: ["is invalid"],
connections: ["is invalid"]
}
end
test "returns error on duplicate name", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
resource = ResourcesFixtures.create_resource(account: account, subject: subject)
address = ResourcesFixtures.resource_attrs().address
attrs = %{
"name" => resource.name,
"address" => address,
"connections" => [%{"gateway_id" => gateway.id}]
}
assert {:error, changeset} = create_resource(attrs, subject)
assert errors_on(changeset) == %{name: ["has already been taken"]}
end
test "creates a resource", %{account: account, subject: subject} do
gateway = GatewaysFixtures.create_gateway(account: account)
attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}])
assert {:ok, resource} = create_resource(attrs, subject)
assert resource.address == attrs.address
assert resource.name == attrs.address
assert resource.account_id == account.id
refute is_nil(resource.ipv4)
refute is_nil(resource.ipv6)
assert [
%Domain.Resources.Connection{
resource_id: resource_id,
gateway_id: gateway_id,
account_id: account_id
}
] = resource.connections
assert resource_id == resource.id
assert gateway_id == gateway.id
assert account_id == account.id
assert [
%Domain.Resources.Resource.Filter{ports: ["80", "433"], protocol: :tcp},
%Domain.Resources.Resource.Filter{ports: ["100 - 200"], protocol: :udp}
] = resource.filters
end
test "does not allow to reuse IP addresses within an account", %{
account: account,
subject: subject
} do
gateway = GatewaysFixtures.create_gateway(account: account)
attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}])
assert {:ok, resource} = create_resource(attrs, subject)
addresses =
Domain.Network.Address
|> Repo.all()
|> Enum.map(fn %Domain.Network.Address{address: address, type: type} ->
%{address: address, type: type}
end)
assert %{address: resource.ipv4, type: :ipv4} in addresses
assert %{address: resource.ipv6, type: :ipv6} in addresses
assert_raise Ecto.ConstraintError, fn ->
NetworkFixtures.create_address(address: resource.ipv4, account: account)
end
assert_raise Ecto.ConstraintError, fn ->
NetworkFixtures.create_address(address: resource.ipv6, account: account)
end
end
test "ip addresses are unique per account", %{
account: account,
subject: subject
} do
gateway = GatewaysFixtures.create_gateway(account: account)
attrs = ResourcesFixtures.resource_attrs(connections: [%{gateway_id: gateway.id}])
assert {:ok, resource} = create_resource(attrs, subject)
assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: resource.ipv4)
assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: resource.ipv6)
end
test "returns error when subject has no permission to create resources", %{
subject: subject
} do
subject = AuthFixtures.remove_permissions(subject)
assert create_resource(%{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
end
end
describe "update_resource/3" do
setup context do
resource =
ResourcesFixtures.create_resource(
account: context.account,
subject: context.subject
)
Map.put(context, :resource, resource)
end
test "does nothing on empty attrs", %{resource: resource, subject: subject} do
assert {:ok, _resource} = update_resource(resource, %{}, subject)
end
test "returns error on invalid attrs", %{resource: resource, subject: subject} do
attrs = %{"name" => String.duplicate("a", 256), "filters" => :foo, "connections" => :bar}
assert {:error, changeset} = update_resource(resource, attrs, subject)
assert errors_on(changeset) == %{
name: ["should be at most 255 character(s)"],
filters: ["is invalid"],
connections: ["is invalid"]
}
end
test "allows to update name", %{resource: resource, subject: subject} do
attrs = %{"name" => "foo"}
assert {:ok, resource} = update_resource(resource, attrs, subject)
assert resource.name == "foo"
end
test "allows to update filters", %{resource: resource, subject: subject} do
attrs = %{"filters" => []}
assert {:ok, resource} = update_resource(resource, attrs, subject)
assert resource.filters == []
end
test "allows to update connections", %{account: account, resource: resource, subject: subject} do
gateway1 = GatewaysFixtures.create_gateway(account: account)
attrs = %{"connections" => [%{gateway_id: gateway1.id}]}
assert {:ok, resource} = update_resource(resource, attrs, subject)
gateway_ids = Enum.map(resource.connections, & &1.gateway_id)
assert gateway_ids == [gateway1.id]
gateway2 = GatewaysFixtures.create_gateway(account: account)
attrs = %{"connections" => [%{gateway_id: gateway1.id}, %{gateway_id: gateway2.id}]}
assert {:ok, resource} = update_resource(resource, attrs, subject)
gateway_ids = Enum.map(resource.connections, & &1.gateway_id)
assert Enum.sort(gateway_ids) == Enum.sort([gateway1.id, gateway2.id])
attrs = %{"connections" => [%{gateway_id: gateway2.id}]}
assert {:ok, resource} = update_resource(resource, attrs, subject)
gateway_ids = Enum.map(resource.connections, & &1.gateway_id)
assert gateway_ids == [gateway2.id]
end
test "does not allow to remove all connections", %{resource: resource, subject: subject} do
attrs = %{"connections" => []}
assert {:error, changeset} = update_resource(resource, attrs, subject)
assert errors_on(changeset) == %{
connections: ["can't be blank"]
}
end
test "does not allow to update address", %{resource: resource, subject: subject} do
attrs = %{"address" => "foo"}
assert {:ok, updated_resource} = update_resource(resource, attrs, subject)
assert updated_resource.address == resource.address
end
test "returns error when subject has no permission to create resources", %{
resource: resource,
subject: subject
} do
subject = AuthFixtures.remove_permissions(subject)
assert update_resource(resource, %{}, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
end
end
describe "delete_resource/2" do
setup context do
resource =
ResourcesFixtures.create_resource(
account: context.account,
subject: context.subject
)
Map.put(context, :resource, resource)
end
test "returns error on state conflict", %{
resource: resource,
subject: subject
} do
assert {:ok, deleted} = delete_resource(resource, subject)
assert delete_resource(deleted, subject) == {:error, :not_found}
assert delete_resource(resource, subject) == {:error, :not_found}
end
test "deletes gateways", %{resource: resource, subject: subject} do
assert {:ok, deleted} = delete_resource(resource, subject)
assert deleted.deleted_at
end
test "returns error when subject has no permission to delete resources", %{
resource: resource,
subject: subject
} do
subject = AuthFixtures.remove_permissions(subject)
assert delete_resource(resource, subject) ==
{:error,
{:unauthorized,
[missing_permissions: [Resources.Authorizer.manage_resources_permission()]]}}
end
end
end

View File

@@ -21,10 +21,15 @@ defmodule Domain.DataCase do
import Ecto
import Ecto.Changeset
import Domain.DataCase
alias Domain.Repo
end
end
def assert_datetime_diff(%DateTime{} = datetime1, %DateTime{} = datetime2, is, leeway \\ 5) do
assert DateTime.diff(datetime1, datetime2, :second) in (is - leeway)..(is + leeway)
end
@doc """
A helper that transforms changeset errors into a map of messages.

View File

@@ -15,6 +15,10 @@ defmodule Domain.AuthFixtures do
Ecto.UUID.generate()
end
def random_provider_identifier(%Domain.Auth.Provider{adapter: :userpass, name: name}) do
"user-#{counter()}@#{String.downcase(name)}.com"
end
def provider_attrs(attrs \\ %{}) do
Enum.into(attrs, %{
name: "provider-#{counter()}",
@@ -54,6 +58,20 @@ defmodule Domain.AuthFixtures do
{provider, bypass}
end
def create_userpass_provider(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
{account, _attrs} =
Map.pop_lazy(attrs, :account, fn ->
AccountsFixtures.create_account()
end)
attrs = provider_attrs(adapter: :userpass)
{:ok, provider} = Auth.create_provider(account, attrs)
provider
end
def create_identity(attrs \\ %{}) do
attrs = Enum.into(attrs, %{})
@@ -81,8 +99,21 @@ defmodule Domain.AuthFixtures do
)
end)
{:ok, identity} = Auth.create_identity(actor, provider, provider_identifier)
identity
{provider_virtual_state, attrs} =
Map.pop_lazy(attrs, :provider_virtual_state, fn ->
%{}
end)
{:ok, identity} =
Auth.create_identity(actor, provider, provider_identifier, provider_virtual_state)
if state = Map.get(attrs, :provider_state) do
identity
|> Ecto.Changeset.change(provider_state: state)
|> Repo.update!()
else
identity
end
end
def create_subject do
@@ -100,6 +131,7 @@ defmodule Domain.AuthFixtures do
actor: identity.actor,
permissions: Auth.Roles.build(identity.actor.role).permissions,
account: identity.account,
expires_at: DateTime.utc_now() |> DateTime.add(60, :second),
context: %Auth.Context{remote_ip: remote_ip(), user_agent: user_agent()}
}
end

View File

@@ -0,0 +1,52 @@
# TODO: Domain.Fixtures.Resources
defmodule Domain.ResourcesFixtures do
alias Domain.{AccountsFixtures, ActorsFixtures, AuthFixtures, GatewaysFixtures}
def resource_attrs(attrs \\ %{}) do
address = "admin-#{counter()}.mycorp.com"
Enum.into(attrs, %{
address: address,
name: address,
filters: [
%{protocol: :tcp, ports: [80, 433]},
%{protocol: :udp, ports: [100..200]}
]
})
end
def create_resource(attrs \\ %{}) do
attrs = resource_attrs(attrs)
{account, attrs} =
Map.pop_lazy(attrs, :account, fn ->
AccountsFixtures.create_account()
end)
{connections, attrs} =
Map.pop_lazy(attrs, :gateways, fn ->
Enum.map(1..2, fn _ ->
gateway = GatewaysFixtures.create_gateway(account: account)
%{gateway_id: gateway.id}
end)
end)
{subject, attrs} =
Map.pop_lazy(attrs, :subject, fn ->
actor = ActorsFixtures.create_actor(role: :admin, account: account)
identity = AuthFixtures.create_identity(account: account, actor: actor)
AuthFixtures.create_subject(identity)
end)
{:ok, resource} =
attrs
|> Map.put(:connections, connections)
|> Domain.Resources.create_resource(subject)
resource
end
defp counter do
System.unique_integer([:positive])
end
end

View File

@@ -0,0 +1,15 @@
defmodule Web.MailerTestAdapter do
use Swoosh.Adapter
@impl true
def deliver(email, config) do
Swoosh.Adapters.Local.deliver(email, config)
Swoosh.Adapters.Test.deliver(email, config)
end
@impl true
def deliver_many(emails, config) do
Swoosh.Adapters.Local.deliver_many(emails, config)
Swoosh.Adapters.Test.deliver_many(emails, config)
end
end

View File

@@ -65,8 +65,7 @@ config :domain,
config :domain, Domain.Auth,
key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD",
salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX",
max_age: 30 * 60
salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX"
###############################
##### Web #####################