mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
Control channels for Clients, Relays and Gateways (#1551)
This commit is contained in:
@@ -4,9 +4,6 @@ defmodule API.Application do
|
||||
@impl true
|
||||
def start(_type, _args) do
|
||||
children = [
|
||||
API.Client.Presence,
|
||||
API.Gateway.Presence,
|
||||
API.Relay.Presence,
|
||||
API.Telemetry,
|
||||
API.Endpoint
|
||||
]
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
defmodule API.Client.Channel do
|
||||
use API, :channel
|
||||
alias API.Client.Presence
|
||||
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
|
||||
|
||||
@impl true
|
||||
def join("client", _payload, socket) do
|
||||
@@ -10,11 +13,8 @@ defmodule API.Client.Channel do
|
||||
|
||||
@impl true
|
||||
def handle_info(:after_join, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, socket.assigns.client.id, %{
|
||||
online_at: System.system_time(:second)
|
||||
})
|
||||
|
||||
:ok = Clients.connect_client(socket.assigns.client, socket)
|
||||
:ok = push(socket, "resources", %{resources: []})
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule API.Client.Socket do
|
||||
use Phoenix.Socket
|
||||
alias Domain.{Auth, Clients}
|
||||
|
||||
## Channels
|
||||
|
||||
@@ -8,11 +9,18 @@ defmodule API.Client.Socket do
|
||||
## Authentication
|
||||
|
||||
@impl true
|
||||
def connect(%{"token" => token, "id" => external_id}, socket, connect_info) do
|
||||
%{user_agent: user_agent, peer_data: peer_data} = connect_info
|
||||
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.consume_auth_token(token, remote_ip, user_agent),
|
||||
{:ok, client} <- Clients.upsert_client(attrs, subject) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:subject, subject)
|
||||
|> assign(:client, client)
|
||||
|
||||
with {:ok, subject} <- Auth.fetch_subject_by_token(token),
|
||||
{:ok, client} <- Clients.upsert_client(external_id, user_agent, peer_data, subject) do
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -5,10 +5,28 @@ defmodule API.Endpoint do
|
||||
plug Phoenix.CodeReloader
|
||||
end
|
||||
|
||||
plug Plug.RewriteOn, [:x_forwarded_proto]
|
||||
plug Plug.MethodOverride
|
||||
|
||||
plug RemoteIp,
|
||||
headers: ["x-forwarded-for"],
|
||||
proxies: {__MODULE__, :external_trusted_proxies, []},
|
||||
clients: {__MODULE__, :clients, []}
|
||||
|
||||
plug Plug.RequestId
|
||||
plug Plug.Telemetry, event_prefix: [:phoenix, :endpoint]
|
||||
|
||||
socket "/gateway", API.Gateway.Socket, API.Sockets.options()
|
||||
socket "/client", API.Client.Socket, API.Sockets.options()
|
||||
socket "/relay", API.Relay.Socket, API.Sockets.options()
|
||||
|
||||
def external_trusted_proxies do
|
||||
Domain.Config.fetch_env!(:api, :external_trusted_proxies)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
|
||||
def clients do
|
||||
Domain.Config.fetch_env!(:api, :private_clients)
|
||||
|> Enum.map(&to_string/1)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
defmodule API.Gateway.Channel do
|
||||
use API, :channel
|
||||
alias API.Gateway.Presence
|
||||
alias Domain.Gateways
|
||||
|
||||
@impl true
|
||||
def join("gateway", _payload, socket) do
|
||||
@@ -10,11 +10,7 @@ defmodule API.Gateway.Channel do
|
||||
|
||||
@impl true
|
||||
def handle_info(:after_join, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, socket.assigns.gateway.id, %{
|
||||
online_at: System.system_time(:second)
|
||||
})
|
||||
|
||||
Gateways.connect_gateway(socket.assigns.gateway, socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule API.Gateway.Socket do
|
||||
use Phoenix.Socket
|
||||
alias Domain.Gateways
|
||||
|
||||
## Channels
|
||||
|
||||
@@ -7,9 +8,47 @@ defmodule API.Gateway.Socket do
|
||||
|
||||
## Authentication
|
||||
|
||||
def encode_token!(%Gateways.Token{value: value} = token) when not is_nil(value) do
|
||||
body = {token.id, token.value}
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
Plug.Crypto.sign(key_base, salt, body)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
def connect(%{"token" => encrypted_secret} = attrs, socket, connect_info) do
|
||||
%{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info
|
||||
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
max_age = Keyword.fetch!(config, :max_age)
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.take(~w[external_id name_suffix public_key])
|
||||
|> Map.put("last_seen_user_agent", user_agent)
|
||||
|> Map.put("last_seen_remote_ip", remote_ip)
|
||||
|
||||
with {:ok, {id, secret}} <-
|
||||
Plug.Crypto.verify(key_base, salt, encrypted_secret, max_age: max_age),
|
||||
{:ok, token} <- Gateways.use_token_by_id_and_secret(id, secret),
|
||||
{:ok, gateway} <- Gateways.upsert_gateway(token, attrs) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:gateway, gateway)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def connect(_params, _socket, _connect_info) do
|
||||
{:error, :invalid}
|
||||
end
|
||||
|
||||
defp fetch_config! do
|
||||
Domain.Config.fetch_env!(:api, __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -1,20 +1,16 @@
|
||||
defmodule API.Relay.Channel do
|
||||
use API, :channel
|
||||
alias API.Relay.Presence
|
||||
alias Domain.Relays
|
||||
|
||||
@impl true
|
||||
def join("relay", _payload, socket) do
|
||||
send(self(), :after_join)
|
||||
def join("relay", %{"stamp_secret" => stamp_secret}, socket) do
|
||||
send(self(), {:after_join, stamp_secret})
|
||||
{:ok, socket}
|
||||
end
|
||||
|
||||
@impl true
|
||||
def handle_info(:after_join, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, socket.assigns.relay.id, %{
|
||||
online_at: System.system_time(:second)
|
||||
})
|
||||
|
||||
def handle_info({:after_join, stamp_secret}, socket) do
|
||||
:ok = Relays.connect_relay(socket.assigns.relay, stamp_secret, socket)
|
||||
{:noreply, socket}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule API.Relay.Socket do
|
||||
use Phoenix.Socket
|
||||
alias Domain.Relays
|
||||
|
||||
## Channels
|
||||
|
||||
@@ -7,9 +8,47 @@ defmodule API.Relay.Socket do
|
||||
|
||||
## Authentication
|
||||
|
||||
def encode_token!(%Relays.Token{value: value} = token) when not is_nil(value) do
|
||||
body = {token.id, token.value}
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
Plug.Crypto.sign(key_base, salt, body)
|
||||
end
|
||||
|
||||
@impl true
|
||||
def connect(_params, socket, _connect_info) do
|
||||
{:ok, socket}
|
||||
def connect(%{"token" => encrypted_secret} = attrs, socket, connect_info) do
|
||||
%{user_agent: user_agent, peer_data: %{address: remote_ip}} = connect_info
|
||||
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
max_age = Keyword.fetch!(config, :max_age)
|
||||
|
||||
attrs =
|
||||
attrs
|
||||
|> Map.take(~w[ipv4 ipv6])
|
||||
|> Map.put("last_seen_user_agent", user_agent)
|
||||
|> Map.put("last_seen_remote_ip", remote_ip)
|
||||
|
||||
with {:ok, {id, secret}} <-
|
||||
Plug.Crypto.verify(key_base, salt, encrypted_secret, max_age: max_age),
|
||||
{:ok, token} <- Relays.use_token_by_id_and_secret(id, secret),
|
||||
{:ok, relay} <- Relays.upsert_relay(token, attrs) do
|
||||
socket =
|
||||
socket
|
||||
|> assign(:relay, relay)
|
||||
|
||||
{:ok, socket}
|
||||
end
|
||||
end
|
||||
|
||||
def connect(_params, _socket, _connect_info) do
|
||||
{:error, :invalid}
|
||||
end
|
||||
|
||||
defp fetch_config! do
|
||||
Domain.Config.fetch_env!(:api, __MODULE__)
|
||||
end
|
||||
|
||||
@impl true
|
||||
|
||||
@@ -16,28 +16,11 @@ defmodule API.Sockets do
|
||||
]
|
||||
end
|
||||
|
||||
@spec handle_error(Plug.Conn.t(), :invalid | :rate_limit | :unauthenticated) :: Plug.Conn.t()
|
||||
def handle_error(conn, :unauthenticated), do: Plug.Conn.send_resp(conn, 403, "Forbidden")
|
||||
def handle_error(conn, :invalid), do: Plug.Conn.send_resp(conn, 422, "Unprocessable Entity")
|
||||
def handle_error(conn, :rate_limit), do: Plug.Conn.send_resp(conn, 429, "Too many requests")
|
||||
|
||||
defp parse_ip(connect_info) do
|
||||
case get_ip_address(connect_info) do
|
||||
ip when ip in ["", nil] ->
|
||||
:x_forward_for_header_issue
|
||||
|
||||
ip when is_tuple(ip) ->
|
||||
:inet.ntoa(ip) |> List.to_string()
|
||||
end
|
||||
end
|
||||
|
||||
defp get_ip_address(%{peer_data: %{address: address}, x_headers: []}) do
|
||||
address
|
||||
end
|
||||
|
||||
defp get_ip_address(%{x_headers: x_headers}) do
|
||||
RemoteIp.from(x_headers, HeaderHelpers.remote_ip_opts())
|
||||
end
|
||||
|
||||
# if Mix.env() == :test do
|
||||
# defp maybe_allow_sandbox_access(%{user_agent: user_agent}) do
|
||||
# %{owner: owner_pid, repo: repos} =
|
||||
|
||||
@@ -55,7 +55,8 @@ defmodule API.MixProject do
|
||||
{:telemetry_poller, "~> 1.0"},
|
||||
|
||||
# Other deps
|
||||
{:jason, "~> 1.2"}
|
||||
{:jason, "~> 1.2"},
|
||||
{:remote_ip, "~> 1.1"}
|
||||
|
||||
# Test deps
|
||||
]
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
defmodule API.Client.ChannelTest do
|
||||
use API.ChannelCase
|
||||
alias Domain.ClientsFixtures
|
||||
|
||||
setup do
|
||||
client = %{id: Ecto.UUID.generate()}
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
{:ok, _reply, socket} =
|
||||
API.Client.Socket
|
||||
@@ -13,24 +14,13 @@ defmodule API.Client.ChannelTest do
|
||||
end
|
||||
|
||||
test "tracks presence after join", %{client: client, socket: socket} do
|
||||
presence = API.Client.Presence.list(socket)
|
||||
presence = Domain.Clients.Presence.list(socket)
|
||||
|
||||
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, client.id)
|
||||
assert is_number(online_at)
|
||||
end
|
||||
|
||||
# test "ping replies with status ok", %{socket: socket} do
|
||||
# ref = push(socket, "ping", %{"hello" => "there"})
|
||||
# assert_reply ref, :ok, %{"hello" => "there"}
|
||||
# end
|
||||
|
||||
# test "shout broadcasts to client:lobby", %{socket: socket} do
|
||||
# push(socket, "shout", %{"hello" => "all"})
|
||||
# assert_broadcast "shout", %{"hello" => "all"}
|
||||
# end
|
||||
|
||||
# test "broadcasts are pushed to the client", %{socket: socket} do
|
||||
# broadcast_from!(socket, "broadcast", %{"some" => "data"})
|
||||
# assert_push "broadcast", %{"some" => "data"}
|
||||
# end
|
||||
test "sends list of resources after join" do
|
||||
assert_push "resources", %{resources: []}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,16 +1,52 @@
|
||||
defmodule API.Client.SocketTest do
|
||||
use API.ChannelCase, async: true
|
||||
import API.Client.Socket
|
||||
import API.Client.Socket, only: [id: 1]
|
||||
alias API.Client.Socket
|
||||
alias Domain.Auth
|
||||
alias Domain.{SubjectFixtures, ClientsFixtures}
|
||||
|
||||
@connect_info %{
|
||||
user_agent: "iOS/12.7 (iPhone) connlib/0.1.1",
|
||||
peer_data: %{address: {189, 172, 73, 153}}
|
||||
}
|
||||
|
||||
describe "connect/3" do
|
||||
setup do
|
||||
socket = socket(API.Client.Socket, "", %{})
|
||||
%{socket: socket}
|
||||
test "returns error when token is missing" do
|
||||
assert connect(Socket, %{}, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "returns error when token is missing", %{socket: socket} do
|
||||
connect_info = %{user_agent: "Elixir", peer_data: %{ip: {127, 0, 0, 1}}}
|
||||
assert connect(%{}, socket, connect_info) == {:error, :invalid}
|
||||
test "returns error when token is invalid" do
|
||||
attrs = connect_attrs(token: "foo")
|
||||
assert connect(Socket, attrs, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "creates a new client" do
|
||||
subject = SubjectFixtures.create_subject()
|
||||
token = Auth.create_auth_token(subject)
|
||||
|
||||
attrs = connect_attrs(token: token)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert client = Map.fetch!(socket.assigns, :client)
|
||||
|
||||
assert client.external_id == attrs["external_id"]
|
||||
assert client.public_key == attrs["public_key"]
|
||||
assert client.preshared_key == attrs["preshared_key"]
|
||||
assert client.last_seen_user_agent == @connect_info.user_agent
|
||||
assert client.last_seen_remote_ip.address == @connect_info.peer_data.address
|
||||
assert client.last_seen_version == "0.1.1"
|
||||
end
|
||||
|
||||
test "updates existing client" do
|
||||
subject = SubjectFixtures.create_subject()
|
||||
existing_client = ClientsFixtures.create_client(subject: subject)
|
||||
token = Auth.create_auth_token(subject)
|
||||
|
||||
attrs = connect_attrs(token: token, external_id: existing_client.external_id)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert client = Repo.one(Domain.Clients.Client)
|
||||
assert client.id == socket.assigns.client.id
|
||||
end
|
||||
end
|
||||
|
||||
@@ -22,4 +58,11 @@ defmodule API.Client.SocketTest do
|
||||
assert id(socket) == "client:#{client.id}"
|
||||
end
|
||||
end
|
||||
|
||||
defp connect_attrs(attrs) do
|
||||
ClientsFixtures.client_attrs()
|
||||
|> Map.take(~w[external_id public_key preshared_key]a)
|
||||
|> Map.merge(Enum.into(attrs, %{}))
|
||||
|> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -1,29 +1,22 @@
|
||||
defmodule API.Gateway.ChannelTest do
|
||||
use API.ChannelCase
|
||||
alias Domain.GatewaysFixtures
|
||||
|
||||
setup do
|
||||
gateway = %{id: Ecto.UUID.generate()}
|
||||
gateway = GatewaysFixtures.create_gateway()
|
||||
|
||||
{:ok, _, socket} =
|
||||
API.Gateway.Socket
|
||||
|> socket("gateway:#{gateway.id}", %{gateway: gateway})
|
||||
|> subscribe_and_join(API.Gateway.Channel, "gateway")
|
||||
|
||||
%{socket: socket}
|
||||
%{gateway: gateway, socket: socket}
|
||||
end
|
||||
|
||||
# test "ping replies with status ok", %{socket: socket} do
|
||||
# ref = push(socket, "ping", %{"hello" => "there"})
|
||||
# assert_reply ref, :ok, %{"hello" => "there"}
|
||||
# end
|
||||
test "tracks presence after join", %{gateway: gateway, socket: socket} do
|
||||
presence = Domain.Gateways.Presence.list(socket)
|
||||
|
||||
# test "shout broadcasts to client:lobby", %{socket: socket} do
|
||||
# push(socket, "shout", %{"hello" => "all"})
|
||||
# assert_broadcast "shout", %{"hello" => "all"}
|
||||
# end
|
||||
|
||||
# test "broadcasts are pushed to the client", %{socket: socket} do
|
||||
# broadcast_from!(socket, "broadcast", %{"some" => "data"})
|
||||
# assert_push "broadcast", %{"some" => "data"}
|
||||
# end
|
||||
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, gateway.id)
|
||||
assert is_number(online_at)
|
||||
end
|
||||
end
|
||||
|
||||
82
apps/api/test/api/gateway/socket_test.exs
Normal file
82
apps/api/test/api/gateway/socket_test.exs
Normal file
@@ -0,0 +1,82 @@
|
||||
defmodule API.Gateway.SocketTest do
|
||||
use API.ChannelCase, async: true
|
||||
import API.Gateway.Socket, except: [connect: 3]
|
||||
alias API.Gateway.Socket
|
||||
alias Domain.GatewaysFixtures
|
||||
|
||||
@connlib_version "0.1.1"
|
||||
|
||||
@connect_info %{
|
||||
user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}",
|
||||
peer_data: %{address: {189, 172, 73, 153}}
|
||||
}
|
||||
|
||||
describe "encode_token!/1" do
|
||||
test "returns encoded token" do
|
||||
token = GatewaysFixtures.create_token()
|
||||
assert encrypted_secret = encode_token!(token)
|
||||
|
||||
config = Application.fetch_env!(:api, Socket)
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
|
||||
assert Plug.Crypto.verify(key_base, salt, encrypted_secret) ==
|
||||
{:ok, {token.id, token.value}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "connect/3" do
|
||||
test "returns error when token is missing" do
|
||||
assert connect(Socket, %{}, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "creates a new gateway" do
|
||||
token = GatewaysFixtures.create_token()
|
||||
encrypted_secret = encode_token!(token)
|
||||
|
||||
attrs = connect_attrs(token: encrypted_secret)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert gateway = Map.fetch!(socket.assigns, :gateway)
|
||||
|
||||
assert gateway.external_id == attrs["external_id"]
|
||||
assert gateway.public_key == attrs["public_key"]
|
||||
assert gateway.last_seen_user_agent == @connect_info.user_agent
|
||||
assert gateway.last_seen_remote_ip.address == @connect_info.peer_data.address
|
||||
assert gateway.last_seen_version == @connlib_version
|
||||
end
|
||||
|
||||
test "updates existing gateway" do
|
||||
token = GatewaysFixtures.create_token()
|
||||
existing_gateway = GatewaysFixtures.create_gateway(token: token)
|
||||
encrypted_secret = encode_token!(token)
|
||||
|
||||
attrs = connect_attrs(token: encrypted_secret, external_id: existing_gateway.external_id)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert gateway = Repo.one(Domain.Gateways.Gateway)
|
||||
assert gateway.id == socket.assigns.gateway.id
|
||||
end
|
||||
|
||||
test "returns error when token is invalid" do
|
||||
attrs = connect_attrs(token: "foo")
|
||||
assert connect(Socket, attrs, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
describe "id/1" do
|
||||
test "creates a channel for a gateway" do
|
||||
gateway = %{id: Ecto.UUID.generate()}
|
||||
socket = socket(API.Gateway.Socket, "", %{gateway: gateway})
|
||||
|
||||
assert id(socket) == "gateway:#{gateway.id}"
|
||||
end
|
||||
end
|
||||
|
||||
defp connect_attrs(attrs) do
|
||||
GatewaysFixtures.gateway_attrs()
|
||||
|> Map.take(~w[external_id public_key preshared_key]a)
|
||||
|> Map.merge(Enum.into(attrs, %{}))
|
||||
|> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end)
|
||||
end
|
||||
end
|
||||
@@ -1,29 +1,24 @@
|
||||
defmodule API.Relay.ChannelTest do
|
||||
use API.ChannelCase
|
||||
alias Domain.RelaysFixtures
|
||||
|
||||
setup do
|
||||
relay = %{id: Ecto.UUID.generate()}
|
||||
relay = RelaysFixtures.create_relay()
|
||||
|
||||
stamp_secret = Domain.Crypto.rand_string()
|
||||
|
||||
{:ok, _, socket} =
|
||||
API.Relay.Socket
|
||||
|> socket("relay:#{relay.id}", %{relay: relay})
|
||||
|> subscribe_and_join(API.Relay.Channel, "relay")
|
||||
|> subscribe_and_join(API.Relay.Channel, "relay", %{stamp_secret: stamp_secret})
|
||||
|
||||
%{socket: socket}
|
||||
%{relay: relay, socket: socket}
|
||||
end
|
||||
|
||||
# test "ping replies with status ok", %{socket: socket} do
|
||||
# ref = push(socket, "ping", %{"hello" => "there"})
|
||||
# assert_reply ref, :ok, %{"hello" => "there"}
|
||||
# end
|
||||
test "tracks presence after join", %{relay: relay, socket: socket} do
|
||||
presence = Domain.Relays.Presence.list(socket)
|
||||
|
||||
# test "shout broadcasts to client:lobby", %{socket: socket} do
|
||||
# push(socket, "shout", %{"hello" => "all"})
|
||||
# assert_broadcast "shout", %{"hello" => "all"}
|
||||
# end
|
||||
|
||||
# test "broadcasts are pushed to the client", %{socket: socket} do
|
||||
# broadcast_from!(socket, "broadcast", %{"some" => "data"})
|
||||
# assert_push "broadcast", %{"some" => "data"}
|
||||
# end
|
||||
assert %{metas: [%{online_at: online_at, phx_ref: _ref}]} = Map.fetch!(presence, relay.id)
|
||||
assert is_number(online_at)
|
||||
end
|
||||
end
|
||||
|
||||
82
apps/api/test/api/relay/socket_test.exs
Normal file
82
apps/api/test/api/relay/socket_test.exs
Normal file
@@ -0,0 +1,82 @@
|
||||
defmodule API.Relay.SocketTest do
|
||||
use API.ChannelCase, async: true
|
||||
import API.Relay.Socket, except: [connect: 3]
|
||||
alias API.Relay.Socket
|
||||
alias Domain.RelaysFixtures
|
||||
|
||||
@connlib_version "0.1.1"
|
||||
|
||||
@connect_info %{
|
||||
user_agent: "iOS/12.7 (iPhone) connlib/#{@connlib_version}",
|
||||
peer_data: %{address: {189, 172, 73, 153}}
|
||||
}
|
||||
|
||||
describe "encode_token!/1" do
|
||||
test "returns encoded token" do
|
||||
token = RelaysFixtures.create_token()
|
||||
assert encrypted_secret = encode_token!(token)
|
||||
|
||||
config = Application.fetch_env!(:api, Socket)
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
|
||||
assert Plug.Crypto.verify(key_base, salt, encrypted_secret) ==
|
||||
{:ok, {token.id, token.value}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "connect/3" do
|
||||
test "returns error when token is missing" do
|
||||
assert connect(Socket, %{}, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
|
||||
test "creates a new relay" do
|
||||
token = RelaysFixtures.create_token()
|
||||
encrypted_secret = encode_token!(token)
|
||||
|
||||
attrs = connect_attrs(token: encrypted_secret)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert relay = Map.fetch!(socket.assigns, :relay)
|
||||
|
||||
assert relay.ipv4.address == attrs["ipv4"]
|
||||
assert relay.ipv6.address == attrs["ipv6"]
|
||||
assert relay.last_seen_user_agent == @connect_info.user_agent
|
||||
assert relay.last_seen_remote_ip.address == @connect_info.peer_data.address
|
||||
assert relay.last_seen_version == @connlib_version
|
||||
end
|
||||
|
||||
test "updates existing relay" do
|
||||
token = RelaysFixtures.create_token()
|
||||
existing_relay = RelaysFixtures.create_relay(token: token)
|
||||
encrypted_secret = encode_token!(token)
|
||||
|
||||
attrs = connect_attrs(token: encrypted_secret, ipv4: existing_relay.ipv4)
|
||||
|
||||
assert {:ok, socket} = connect(Socket, attrs, @connect_info)
|
||||
assert relay = Repo.one(Domain.Relays.Relay)
|
||||
assert relay.id == socket.assigns.relay.id
|
||||
end
|
||||
|
||||
test "returns error when token is invalid" do
|
||||
attrs = connect_attrs(token: "foo")
|
||||
assert connect(Socket, attrs, @connect_info) == {:error, :invalid}
|
||||
end
|
||||
end
|
||||
|
||||
describe "id/1" do
|
||||
test "creates a channel for a relay" do
|
||||
relay = %{id: Ecto.UUID.generate()}
|
||||
socket = socket(API.Relay.Socket, "", %{relay: relay})
|
||||
|
||||
assert id(socket) == "relay:#{relay.id}"
|
||||
end
|
||||
end
|
||||
|
||||
defp connect_attrs(attrs) do
|
||||
RelaysFixtures.relay_attrs()
|
||||
|> Map.take(~w[ipv4 ipv6]a)
|
||||
|> Map.merge(Enum.into(attrs, %{}))
|
||||
|> Enum.into(%{}, fn {k, v} -> {to_string(k), v} end)
|
||||
end
|
||||
end
|
||||
@@ -3,9 +3,9 @@ defmodule API.ChannelCase do
|
||||
use Domain.CaseTemplate
|
||||
|
||||
@presences [
|
||||
API.Client.Presence,
|
||||
API.Gateway.Presence,
|
||||
API.Relay.Presence
|
||||
Domain.Clients.Presence,
|
||||
Domain.Gateways.Presence,
|
||||
Domain.Relays.Presence
|
||||
]
|
||||
|
||||
using do
|
||||
@@ -13,6 +13,7 @@ defmodule API.ChannelCase do
|
||||
# Import conveniences for testing with channels
|
||||
import Phoenix.ChannelTest
|
||||
import API.ChannelCase
|
||||
alias Domain.Repo
|
||||
|
||||
# The default endpoint for testing
|
||||
@endpoint API.Endpoint
|
||||
|
||||
9
apps/domain/lib/domain/accounts.ex
Normal file
9
apps/domain/lib/domain/accounts.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule Domain.Accounts do
|
||||
alias Domain.Repo
|
||||
alias Domain.Accounts.Account
|
||||
|
||||
def create_account(attrs) do
|
||||
Account.Changeset.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
9
apps/domain/lib/domain/accounts/account.ex
Normal file
9
apps/domain/lib/domain/accounts/account.ex
Normal file
@@ -0,0 +1,9 @@
|
||||
defmodule Domain.Accounts.Account do
|
||||
use Domain, :schema
|
||||
|
||||
schema "accounts" do
|
||||
field :name, :string
|
||||
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
10
apps/domain/lib/domain/accounts/account/changeset.ex
Normal file
10
apps/domain/lib/domain/accounts/account/changeset.ex
Normal file
@@ -0,0 +1,10 @@
|
||||
defmodule Domain.Accounts.Account.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts.Account
|
||||
|
||||
def create_changeset(attrs) do
|
||||
%Account{}
|
||||
|> cast(attrs, [:name])
|
||||
|> validate_required([:name])
|
||||
end
|
||||
end
|
||||
@@ -19,7 +19,10 @@ defmodule Domain.Application do
|
||||
|
||||
# Application
|
||||
{Domain.Notifications, name: Domain.Notifications},
|
||||
Domain.Auth,
|
||||
# Domain.Auth,
|
||||
Domain.Relays,
|
||||
Domain.Gateways,
|
||||
Domain.Clients,
|
||||
|
||||
# Observability
|
||||
Domain.ConnectivityChecks,
|
||||
|
||||
@@ -57,27 +57,74 @@ defmodule Domain.Auth do
|
||||
end
|
||||
|
||||
def fetch_subject!(%Users.User{} = user, remote_ip, user_agent) do
|
||||
user = Repo.preload(user, :account)
|
||||
role = fetch_user_role!(user)
|
||||
|
||||
%Subject{
|
||||
actor: {:user, user},
|
||||
permissions: role.permissions,
|
||||
account: user.account,
|
||||
context: %Context{remote_ip: remote_ip, user_agent: user_agent}
|
||||
}
|
||||
end
|
||||
|
||||
def fetch_subject!(%ApiTokens.ApiToken{} = api_token, remote_ip, user_agent) do
|
||||
api_token = Repo.preload(api_token, :user)
|
||||
api_token = Repo.preload(api_token, user: [:account])
|
||||
role = fetch_user_role!(api_token.user)
|
||||
|
||||
# XXX: Once we build audit logging here we need to build a different kind of subject
|
||||
%Subject{
|
||||
actor: {:user, api_token.user},
|
||||
permissions: role.permissions,
|
||||
account: api_token.user.account,
|
||||
context: %Context{remote_ip: remote_ip, user_agent: user_agent}
|
||||
}
|
||||
end
|
||||
|
||||
# def sign_in(:userpass, login, password): do, {:ok, session_token}
|
||||
# def sign_in(:api_token, token)
|
||||
# def sign_in(:user_token, token)
|
||||
# def sign_in(:oidc, provider, token)
|
||||
# def sign_in(:saml, provider, token)
|
||||
|
||||
# TODO: for some tokens we want to save remote_ip and invalidate them when the IP changes
|
||||
def create_auth_token(%Subject{} = subject) do
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
Plug.Crypto.sign(key_base, salt, token_body(subject))
|
||||
end
|
||||
|
||||
defp token_body(%Subject{actor: {:user, user}}), do: {:user, user.id}
|
||||
|
||||
def consume_auth_token(token, remote_ip, user_agent) do
|
||||
config = fetch_config!()
|
||||
key_base = Keyword.fetch!(config, :key_base)
|
||||
salt = Keyword.fetch!(config, :salt)
|
||||
max_age = Keyword.fetch!(config, :max_age)
|
||||
|
||||
case Plug.Crypto.verify(key_base, salt, token, max_age: max_age) do
|
||||
{:ok, {:user, user_id}} ->
|
||||
# TODO: we might want to check that user is active in future
|
||||
user = Users.fetch_user_by_id!(user_id)
|
||||
role = fetch_user_role!(user)
|
||||
|
||||
{:ok,
|
||||
%Subject{
|
||||
actor: {:user, user},
|
||||
permissions: role.permissions,
|
||||
context: %Context{remote_ip: remote_ip, user_agent: user_agent}
|
||||
}}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
defp fetch_config! do
|
||||
Config.fetch_env!(:domain, __MODULE__)
|
||||
end
|
||||
|
||||
def fetch_oidc_provider_config(provider_id) do
|
||||
with {:ok, provider} <- fetch_provider(:openid_connect_providers, provider_id) do
|
||||
redirect_uri =
|
||||
|
||||
@@ -14,6 +14,9 @@ defmodule Domain.Auth.Roles do
|
||||
Domain.ApiTokens.Authorizer,
|
||||
Domain.ConnectivityChecks.Authorizer,
|
||||
Domain.Devices.Authorizer,
|
||||
Domain.Clients.Authorizer,
|
||||
Domain.Gateways.Authorizer,
|
||||
Domain.Relays.Authorizer,
|
||||
Domain.Rules.Authorizer,
|
||||
Domain.Users.Authorizer
|
||||
]
|
||||
|
||||
@@ -11,11 +11,13 @@ defmodule Domain.Auth.Subject do
|
||||
@type t :: %__MODULE__{
|
||||
actor: actor(),
|
||||
permissions: MapSet.t(permission),
|
||||
account: %Domain.Accounts.Account{},
|
||||
context: Context.t()
|
||||
}
|
||||
|
||||
defstruct actor: nil,
|
||||
permissions: MapSet.new(),
|
||||
account: nil,
|
||||
context: %Context{}
|
||||
|
||||
def actor_type(%__MODULE__{actor: {actor_type, _}}), do: actor_type
|
||||
|
||||
173
apps/domain/lib/domain/clients.ex
Normal file
173
apps/domain/lib/domain/clients.ex
Normal file
@@ -0,0 +1,173 @@
|
||||
defmodule Domain.Clients do
|
||||
use Supervisor
|
||||
alias Domain.{Repo, Auth, Validator}
|
||||
alias Domain.{Users}
|
||||
alias Domain.Clients.{Client, Authorizer, Presence}
|
||||
|
||||
def start_link(opts) do
|
||||
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_opts) do
|
||||
children = [
|
||||
Presence
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def count_by_account_id(account_id) do
|
||||
Client.Query.by_account_id(account_id)
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
def count_by_user_id(user_id) do
|
||||
Client.Query.by_user_id(user_id)
|
||||
|> Repo.aggregate(:count)
|
||||
end
|
||||
|
||||
def fetch_client_by_id(id, %Auth.Subject{} = subject) do
|
||||
required_permissions =
|
||||
{:one_of,
|
||||
[
|
||||
Authorizer.manage_clients_permission(),
|
||||
Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
|
||||
with :ok <- Auth.ensure_has_permissions(subject, required_permissions),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Client.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_client_by_id!(id, opts \\ []) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Client.Query.by_id(id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
def list_clients(%Auth.Subject{} = subject) do
|
||||
required_permissions =
|
||||
{:one_of,
|
||||
[
|
||||
Authorizer.manage_clients_permission(),
|
||||
Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
|
||||
with :ok <- Auth.ensure_has_permissions(subject, required_permissions) do
|
||||
Client.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def list_clients_for_user(%Users.User{} = user, %Auth.Subject{} = subject) do
|
||||
list_clients_by_user_id(user.id, subject)
|
||||
end
|
||||
|
||||
def list_clients_by_user_id(user_id, %Auth.Subject{} = subject) do
|
||||
required_permissions =
|
||||
{:one_of,
|
||||
[
|
||||
Authorizer.manage_clients_permission(),
|
||||
Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
|
||||
with :ok <- Auth.ensure_has_permissions(subject, required_permissions),
|
||||
true <- Validator.valid_uuid?(user_id) do
|
||||
Client.Query.by_user_id(user_id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def change_client(%Client{} = client, attrs \\ %{}) do
|
||||
Client.Changeset.update_changeset(client, attrs)
|
||||
end
|
||||
|
||||
def upsert_client(attrs \\ %{}, %Auth.Subject{actor: {:user, %Users.User{} = user}} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_own_clients_permission()) do
|
||||
changeset = Client.Changeset.upsert_changeset(user, subject.context, attrs)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:client, changeset,
|
||||
conflict_target: Client.Changeset.upsert_conflict_target(),
|
||||
on_conflict: Client.Changeset.upsert_on_conflict(),
|
||||
returning: true
|
||||
)
|
||||
|> resolve_address_multi(:ipv4)
|
||||
|> resolve_address_multi(:ipv6)
|
||||
|> Ecto.Multi.update(:client_with_address, fn
|
||||
%{client: %Client{} = client, ipv4: ipv4, ipv6: ipv6} ->
|
||||
Client.Changeset.finalize_upsert_changeset(client, ipv4, ipv6)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{client_with_address: client}} -> {:ok, client}
|
||||
{:error, :client, changeset, _effects_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_address_multi(multi, type) do
|
||||
Ecto.Multi.run(multi, type, fn _repo, %{client: %Client{} = client} ->
|
||||
if address = Map.get(client, type) do
|
||||
{:ok, address}
|
||||
else
|
||||
{:ok, Domain.Network.fetch_next_available_address!(client.account_id, type)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def update_client(%Client{} = client, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- authorize_user_client_management(client.user_id, subject) do
|
||||
Client.Query.by_id(client.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(with: &Client.Changeset.update_changeset(&1, attrs))
|
||||
end
|
||||
end
|
||||
|
||||
def delete_client(%Client{} = client, %Auth.Subject{} = subject) do
|
||||
with :ok <- authorize_user_client_management(client.user_id, subject) do
|
||||
Client.Query.by_id(client.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(with: &Client.Changeset.delete_changeset/1)
|
||||
end
|
||||
end
|
||||
|
||||
def authorize_user_client_management(%Users.User{} = user, %Auth.Subject{} = subject) do
|
||||
authorize_user_client_management(user.id, subject)
|
||||
end
|
||||
|
||||
def authorize_user_client_management(user_id, %Auth.Subject{} = subject) do
|
||||
required_permissions =
|
||||
case subject.actor do
|
||||
{:user, %{id: ^user_id}} ->
|
||||
Authorizer.manage_own_clients_permission()
|
||||
|
||||
_other ->
|
||||
Authorizer.manage_clients_permission()
|
||||
end
|
||||
|
||||
Auth.ensure_has_permissions(subject, required_permissions)
|
||||
end
|
||||
|
||||
def connect_client(%Client{} = client, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, client.id, %{
|
||||
online_at: System.system_time(:second)
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
41
apps/domain/lib/domain/clients/authorizer.ex
Normal file
41
apps/domain/lib/domain/clients/authorizer.ex
Normal file
@@ -0,0 +1,41 @@
|
||||
defmodule Domain.Clients.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Clients.Client
|
||||
|
||||
def manage_own_clients_permission, do: build(Client, :manage_own)
|
||||
def manage_clients_permission, do: build(Client, :manage)
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
|
||||
def list_permissions_for_role(:admin) do
|
||||
[
|
||||
manage_own_clients_permission(),
|
||||
manage_clients_permission()
|
||||
]
|
||||
end
|
||||
|
||||
def list_permissions_for_role(:unprivileged) do
|
||||
[
|
||||
manage_own_clients_permission()
|
||||
]
|
||||
end
|
||||
|
||||
def list_permissions_for_role(_) do
|
||||
[]
|
||||
end
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def for_subject(queryable, %Subject{} = subject) when is_user(subject) do
|
||||
cond do
|
||||
has_permission?(subject, manage_clients_permission()) ->
|
||||
Client.Query.by_account_id(queryable, subject.account.id)
|
||||
|
||||
has_permission?(subject, manage_own_clients_permission()) ->
|
||||
{:user, %{id: user_id}} = subject.actor
|
||||
|
||||
queryable
|
||||
|> Client.Query.by_account_id(subject.account.id)
|
||||
|> Client.Query.by_user_id(user_id)
|
||||
end
|
||||
end
|
||||
end
|
||||
26
apps/domain/lib/domain/clients/client.ex
Normal file
26
apps/domain/lib/domain/clients/client.ex
Normal file
@@ -0,0 +1,26 @@
|
||||
defmodule Domain.Clients.Client do
|
||||
use Domain, :schema
|
||||
|
||||
schema "clients" do
|
||||
field :external_id, :string
|
||||
|
||||
field :name, :string
|
||||
|
||||
field :public_key, :string
|
||||
field :preshared_key, Domain.Encrypted.Binary
|
||||
|
||||
field :ipv4, Domain.Types.IP
|
||||
field :ipv6, Domain.Types.IP
|
||||
|
||||
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
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :user, Domain.Users.User
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
97
apps/domain/lib/domain/clients/client/changeset.ex
Normal file
97
apps/domain/lib/domain/clients/client/changeset.ex
Normal file
@@ -0,0 +1,97 @@
|
||||
defmodule Domain.Clients.Client.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.{Version, Auth, Users}
|
||||
alias Domain.Clients
|
||||
|
||||
@upsert_fields ~w[external_id name public_key preshared_key]a
|
||||
@conflict_replace_fields ~w[public_key preshared_key
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
@update_fields ~w[name]a
|
||||
@required_fields @upsert_fields
|
||||
|
||||
# WireGuard base64-encoded string length
|
||||
@key_length 44
|
||||
|
||||
def upsert_conflict_target,
|
||||
do: {:unsafe_fragment, ~s/(account_id, user_id, external_id) WHERE deleted_at IS NULL/}
|
||||
|
||||
def upsert_on_conflict, do: {:replace, @conflict_replace_fields}
|
||||
|
||||
def upsert_changeset(%Users.User{} = user, %Auth.Context{} = context, attrs) do
|
||||
%Clients.Client{}
|
||||
|> cast(attrs, @upsert_fields)
|
||||
|> put_default_value(:name, &generate_name/0)
|
||||
|> put_change(:user_id, user.id)
|
||||
|> put_change(:account_id, user.account_id)
|
||||
|> put_change(:last_seen_user_agent, context.user_agent)
|
||||
|> put_change(:last_seen_remote_ip, %Postgrex.INET{address: context.remote_ip})
|
||||
|> changeset()
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_base64(:public_key)
|
||||
|> validate_base64(:preshared_key)
|
||||
|> validate_length(:public_key, is: @key_length)
|
||||
|> validate_length(:preshared_key, is: @key_length)
|
||||
|> unique_constraint(:ipv4)
|
||||
|> unique_constraint(:ipv6)
|
||||
|> put_change(:last_seen_at, DateTime.utc_now())
|
||||
|> put_client_version()
|
||||
end
|
||||
|
||||
def finalize_upsert_changeset(%Clients.Client{} = client, ipv4, ipv6) do
|
||||
client
|
||||
|> change()
|
||||
|> put_change(:ipv4, ipv4)
|
||||
|> put_change(:ipv6, ipv6)
|
||||
end
|
||||
|
||||
def update_changeset(%Clients.Client{} = client, attrs) do
|
||||
client
|
||||
|> cast(attrs, @update_fields)
|
||||
|> changeset()
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
|
||||
def delete_changeset(%Clients.Client{} = client) do
|
||||
client
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
|
||||
defp changeset(changeset) do
|
||||
changeset
|
||||
|> trim_change(:name)
|
||||
|> validate_length(:name, min: 1, max: 255)
|
||||
|> assoc_constraint(:user)
|
||||
|> unique_constraint([:user_id, :name])
|
||||
|> unique_constraint([:user_id, :public_key])
|
||||
|> unique_constraint(:external_id)
|
||||
end
|
||||
|
||||
defp put_client_version(changeset) do
|
||||
with {_data_or_changes, user_agent} when not is_nil(user_agent) <-
|
||||
fetch_field(changeset, :last_seen_user_agent),
|
||||
{:ok, version} <- Version.fetch_version(user_agent) do
|
||||
put_change(changeset, :last_seen_version, version)
|
||||
else
|
||||
{:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid")
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp generate_name do
|
||||
name = Domain.NameGenerator.generate()
|
||||
|
||||
hash =
|
||||
name
|
||||
|> :erlang.phash2(2 ** 16)
|
||||
|> Integer.to_string(16)
|
||||
|> String.pad_leading(4, "0")
|
||||
|
||||
if String.length(name) > 15 do
|
||||
String.slice(name, 0..10) <> hash
|
||||
else
|
||||
name
|
||||
end
|
||||
end
|
||||
end
|
||||
32
apps/domain/lib/domain/clients/client/query.ex
Normal file
32
apps/domain/lib/domain/clients/client/query.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Domain.Clients.Client.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(clients in Domain.Clients.Client, as: :clients)
|
||||
|> where([clients: clients], is_nil(clients.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [clients: clients], clients.id == ^id)
|
||||
end
|
||||
|
||||
def by_user_id(queryable \\ all(), user_id) do
|
||||
where(queryable, [clients: clients], clients.user_id == ^user_id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [clients: clients], clients.account_id == ^account_id)
|
||||
end
|
||||
|
||||
def returning_all(queryable \\ all()) do
|
||||
select(queryable, [clients: clients], clients)
|
||||
end
|
||||
|
||||
def with_preloaded_user(queryable \\ all()) do
|
||||
with_named_binding(queryable, :user, fn queryable, binding ->
|
||||
queryable
|
||||
|> join(:inner, [clients: clients], user in assoc(clients, ^binding), as: ^binding)
|
||||
|> preload([clients: clients, user: user], user: user)
|
||||
end)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule API.Gateway.Presence do
|
||||
defmodule Domain.Clients.Presence do
|
||||
use Phoenix.Presence,
|
||||
otp_app: :api,
|
||||
otp_app: :domain,
|
||||
pubsub_server: Domain.PubSub
|
||||
end
|
||||
@@ -58,7 +58,7 @@ defmodule Domain.Config.Configuration.SAMLIdentityProvider do
|
||||
changeset
|
||||
|> validate_change(:metadata, fn :metadata, value ->
|
||||
try do
|
||||
Samly.IdpData.from_xml(value, %Samly.IdpData{})
|
||||
# Samly.IdpData.from_xml(value, %Samly.IdpData{})
|
||||
[]
|
||||
catch
|
||||
:exit, e ->
|
||||
|
||||
@@ -2,33 +2,33 @@ defmodule Domain.Devices.Device do
|
||||
use Domain, :schema
|
||||
|
||||
schema "devices" do
|
||||
field(:name, :string)
|
||||
field(:description, :string)
|
||||
field :name, :string
|
||||
field :description, :string
|
||||
|
||||
field(:public_key, :string)
|
||||
field(:preshared_key, Domain.Encrypted.Binary)
|
||||
field :public_key, :string
|
||||
field :preshared_key, Domain.Encrypted.Binary
|
||||
|
||||
field(:use_default_allowed_ips, :boolean, read_after_writes: true, default: true)
|
||||
field(:use_default_dns, :boolean, read_after_writes: true, default: true)
|
||||
field(:use_default_endpoint, :boolean, read_after_writes: true, default: true)
|
||||
field(:use_default_mtu, :boolean, read_after_writes: true, default: true)
|
||||
field(:use_default_persistent_keepalive, :boolean, read_after_writes: true, default: true)
|
||||
field :use_default_allowed_ips, :boolean, read_after_writes: true, default: true
|
||||
field :use_default_dns, :boolean, read_after_writes: true, default: true
|
||||
field :use_default_endpoint, :boolean, read_after_writes: true, default: true
|
||||
field :use_default_mtu, :boolean, read_after_writes: true, default: true
|
||||
field :use_default_persistent_keepalive, :boolean, read_after_writes: true, default: true
|
||||
|
||||
field(:endpoint, :string)
|
||||
field(:mtu, :integer)
|
||||
field(:persistent_keepalive, :integer)
|
||||
field(:allowed_ips, {:array, Domain.Types.INET}, default: [])
|
||||
field(:dns, {:array, :string}, default: [])
|
||||
field :endpoint, :string
|
||||
field :mtu, :integer
|
||||
field :persistent_keepalive, :integer
|
||||
field :allowed_ips, {:array, Domain.Types.INET}, default: []
|
||||
field :dns, {:array, :string}, default: []
|
||||
|
||||
field(:ipv4, Domain.Types.IP)
|
||||
field(:ipv6, Domain.Types.IP)
|
||||
field :ipv4, Domain.Types.IP
|
||||
field :ipv6, Domain.Types.IP
|
||||
|
||||
field(:remote_ip, Domain.Types.IP)
|
||||
field(:rx_bytes, :integer)
|
||||
field(:tx_bytes, :integer)
|
||||
field(:latest_handshake, :utc_datetime_usec)
|
||||
field :remote_ip, Domain.Types.IP
|
||||
field :rx_bytes, :integer
|
||||
field :tx_bytes, :integer
|
||||
field :latest_handshake, :utc_datetime_usec
|
||||
|
||||
belongs_to(:user, Domain.Users.User)
|
||||
belongs_to :user, Domain.Users.User
|
||||
|
||||
timestamps()
|
||||
end
|
||||
|
||||
191
apps/domain/lib/domain/gateways.ex
Normal file
191
apps/domain/lib/domain/gateways.ex
Normal file
@@ -0,0 +1,191 @@
|
||||
defmodule Domain.Gateways do
|
||||
use Supervisor
|
||||
alias Domain.{Repo, Auth, Validator}
|
||||
alias Domain.Gateways.{Authorizer, Gateway, Group, Token, Presence}
|
||||
|
||||
def start_link(opts) do
|
||||
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_opts) do
|
||||
children = [
|
||||
Presence
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def fetch_group_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Group.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def list_groups(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
Group.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def new_group(attrs \\ %{}) do
|
||||
change_group(%Group{}, attrs)
|
||||
end
|
||||
|
||||
def create_group(attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
subject.account
|
||||
|> Group.Changeset.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
def change_group(%Group{} = group, attrs \\ %{}) do
|
||||
group
|
||||
|> Repo.preload(:account)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
end
|
||||
|
||||
def update_group(%Group{} = group, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
group
|
||||
|> Repo.preload(:account)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def delete_group(%Group{} = group, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
Group.Query.by_id(group.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn group ->
|
||||
:ok =
|
||||
Token.Query.by_group_id(group.id)
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn token ->
|
||||
Token.Changeset.delete_changeset(token)
|
||||
|> Repo.update!()
|
||||
end)
|
||||
|
||||
group
|
||||
|> Group.Changeset.delete_changeset()
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def use_token_by_id_and_secret(id, secret) do
|
||||
if Validator.valid_uuid?(id) do
|
||||
Token.Query.by_id(id)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn token ->
|
||||
if Domain.Crypto.equal?(secret, token.hash) do
|
||||
Token.Changeset.use_changeset(token)
|
||||
else
|
||||
:not_found
|
||||
end
|
||||
end
|
||||
)
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_gateway_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Gateway.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_gateway_by_id!(id, opts \\ []) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Gateway.Query.by_id(id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
def list_gateways(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
Gateway.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def change_gateway(%Gateway{} = gateway, attrs \\ %{}) do
|
||||
Gateway.Changeset.update_changeset(gateway, attrs)
|
||||
end
|
||||
|
||||
def upsert_gateway(%Token{} = token, attrs) do
|
||||
changeset = Gateway.Changeset.upsert_changeset(token, attrs)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:gateway, changeset,
|
||||
conflict_target: Gateway.Changeset.upsert_conflict_target(),
|
||||
on_conflict: Gateway.Changeset.upsert_on_conflict(),
|
||||
returning: true
|
||||
)
|
||||
|> resolve_address_multi(:ipv4)
|
||||
|> resolve_address_multi(:ipv6)
|
||||
|> Ecto.Multi.update(:gateway_with_address, fn
|
||||
%{gateway: %Gateway{} = gateway, ipv4: ipv4, ipv6: ipv6} ->
|
||||
Gateway.Changeset.finalize_upsert_changeset(gateway, ipv4, ipv6)
|
||||
end)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{gateway_with_address: gateway}} -> {:ok, gateway}
|
||||
{:error, :gateway, changeset, _effects_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
defp resolve_address_multi(multi, type) do
|
||||
Ecto.Multi.run(multi, type, fn _repo, %{gateway: %Gateway{} = gateway} ->
|
||||
if address = Map.get(gateway, type) do
|
||||
{:ok, address}
|
||||
else
|
||||
{:ok, Domain.Network.fetch_next_available_address!(gateway.account_id, type)}
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
def update_gateway(%Gateway{} = gateway, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
Gateway.Query.by_id(gateway.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(with: &Gateway.Changeset.update_changeset(&1, attrs))
|
||||
end
|
||||
end
|
||||
|
||||
def delete_gateway(%Gateway{} = gateway, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_gateways_permission()) do
|
||||
Gateway.Query.by_id(gateway.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(with: &Gateway.Changeset.delete_changeset/1)
|
||||
end
|
||||
end
|
||||
|
||||
def connect_gateway(%Gateway{} = gateway, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, gateway.id, %{
|
||||
online_at: System.system_time(:second)
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
36
apps/domain/lib/domain/gateways/authorizer.ex
Normal file
36
apps/domain/lib/domain/gateways/authorizer.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Domain.Gateways.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Gateways.{Gateway, Group}
|
||||
|
||||
def manage_gateways_permission, do: build(Gateway, :manage)
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
|
||||
def list_permissions_for_role(:admin) do
|
||||
[
|
||||
manage_gateways_permission()
|
||||
]
|
||||
end
|
||||
|
||||
def list_permissions_for_role(_) do
|
||||
[]
|
||||
end
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def for_subject(queryable, %Subject{} = subject) when is_user(subject) do
|
||||
cond do
|
||||
has_permission?(subject, manage_gateways_permission()) ->
|
||||
by_account_id(queryable, subject)
|
||||
end
|
||||
end
|
||||
|
||||
defp by_account_id(queryable, subject) do
|
||||
cond do
|
||||
Ecto.Query.has_named_binding?(queryable, :groups) ->
|
||||
Group.Query.by_account_id(queryable, subject.account.id)
|
||||
|
||||
Ecto.Query.has_named_binding?(queryable, :gateways) ->
|
||||
Gateway.Query.by_account_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
26
apps/domain/lib/domain/gateways/gateway.ex
Normal file
26
apps/domain/lib/domain/gateways/gateway.ex
Normal file
@@ -0,0 +1,26 @@
|
||||
defmodule Domain.Gateways.Gateway do
|
||||
use Domain, :schema
|
||||
|
||||
schema "gateways" do
|
||||
field :external_id, :string
|
||||
|
||||
field :name_suffix, :string
|
||||
|
||||
field :public_key, :string
|
||||
|
||||
field :ipv4, Domain.Types.IP
|
||||
field :ipv6, Domain.Types.IP
|
||||
|
||||
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
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Gateways.Group
|
||||
belongs_to :token, Domain.Gateways.Token
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
79
apps/domain/lib/domain/gateways/gateway/changeset.ex
Normal file
79
apps/domain/lib/domain/gateways/gateway/changeset.ex
Normal file
@@ -0,0 +1,79 @@
|
||||
defmodule Domain.Gateways.Gateway.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Version
|
||||
alias Domain.Gateways
|
||||
|
||||
@upsert_fields ~w[external_id name_suffix public_key
|
||||
last_seen_user_agent last_seen_remote_ip]a
|
||||
@conflict_replace_fields ~w[public_key
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
@update_fields ~w[name_suffix]a
|
||||
@required_fields @upsert_fields
|
||||
|
||||
# WireGuard base64-encoded string length
|
||||
@key_length 44
|
||||
|
||||
def upsert_conflict_target,
|
||||
do: {:unsafe_fragment, ~s/(account_id, group_id, external_id) WHERE deleted_at IS NULL/}
|
||||
|
||||
def upsert_on_conflict, do: {:replace, @conflict_replace_fields}
|
||||
|
||||
def upsert_changeset(%Gateways.Token{} = token, attrs) do
|
||||
%Gateways.Gateway{}
|
||||
|> cast(attrs, @upsert_fields)
|
||||
|> put_default_value(:name_suffix, fn -> Domain.Crypto.rand_string(5) end)
|
||||
|> changeset()
|
||||
|> validate_required(@required_fields)
|
||||
|> validate_base64(:public_key)
|
||||
|> validate_length(:public_key, is: @key_length)
|
||||
|> unique_constraint(:ipv4)
|
||||
|> unique_constraint(:ipv6)
|
||||
|> put_change(:last_seen_at, DateTime.utc_now())
|
||||
|> put_gateway_version()
|
||||
|> put_change(:account_id, token.account_id)
|
||||
|> put_change(:group_id, token.group_id)
|
||||
|> put_change(:token_id, token.id)
|
||||
|> assoc_constraint(:token)
|
||||
end
|
||||
|
||||
def finalize_upsert_changeset(%Gateways.Gateway{} = gateway, ipv4, ipv6) do
|
||||
gateway
|
||||
|> change()
|
||||
|> put_change(:ipv4, ipv4)
|
||||
|> put_change(:ipv6, ipv6)
|
||||
end
|
||||
|
||||
def update_changeset(%Gateways.Gateway{} = gateway, attrs) do
|
||||
gateway
|
||||
|> cast(attrs, @update_fields)
|
||||
|> changeset()
|
||||
|> validate_required(@required_fields)
|
||||
end
|
||||
|
||||
def delete_changeset(%Gateways.Gateway{} = gateway) do
|
||||
gateway
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
|
||||
defp changeset(changeset) do
|
||||
changeset
|
||||
|> trim_change(:name_suffix)
|
||||
|> validate_length(:name_suffix, min: 1, max: 8)
|
||||
|> unique_constraint(:name_suffix, name: :gateways_group_id_name_suffix_index)
|
||||
|> unique_constraint([:public_key])
|
||||
|> unique_constraint(:external_id)
|
||||
end
|
||||
|
||||
def put_gateway_version(changeset) do
|
||||
with {_data_or_changes, user_agent} when not is_nil(user_agent) <-
|
||||
fetch_field(changeset, :last_seen_user_agent),
|
||||
{:ok, version} <- Version.fetch_version(user_agent) do
|
||||
put_change(changeset, :last_seen_version, version)
|
||||
else
|
||||
{:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid")
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
32
apps/domain/lib/domain/gateways/gateway/query.ex
Normal file
32
apps/domain/lib/domain/gateways/gateway/query.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Domain.Gateways.Gateway.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(gateways in Domain.Gateways.Gateway, as: :gateways)
|
||||
|> where([gateways: gateways], is_nil(gateways.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [gateways: gateways], gateways.id == ^id)
|
||||
end
|
||||
|
||||
def by_user_id(queryable \\ all(), user_id) do
|
||||
where(queryable, [gateways: gateways], gateways.user_id == ^user_id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [gateways: gateways], gateways.account_id == ^account_id)
|
||||
end
|
||||
|
||||
def returning_all(queryable \\ all()) do
|
||||
select(queryable, [gateways: gateways], gateways)
|
||||
end
|
||||
|
||||
def with_preloaded_user(queryable \\ all()) do
|
||||
with_named_binding(queryable, :user, fn queryable, binding ->
|
||||
queryable
|
||||
|> join(:inner, [gateways: gateways], user in assoc(gateways, ^binding), as: ^binding)
|
||||
|> preload([gateways: gateways, user: user], user: user)
|
||||
end)
|
||||
end
|
||||
end
|
||||
15
apps/domain/lib/domain/gateways/group.ex
Normal file
15
apps/domain/lib/domain/gateways/group.ex
Normal file
@@ -0,0 +1,15 @@
|
||||
defmodule Domain.Gateways.Group do
|
||||
use Domain, :schema
|
||||
|
||||
schema "gateway_groups" do
|
||||
field :name_prefix, :string
|
||||
field :tags, {:array, :string}, default: []
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
has_many :gateways, Domain.Gateways.Gateway, foreign_key: :group_id
|
||||
has_many :tokens, Domain.Gateways.Token, foreign_key: :group_id
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
48
apps/domain/lib/domain/gateways/group/changeset.ex
Normal file
48
apps/domain/lib/domain/gateways/group/changeset.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Domain.Gateways.Group.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.Gateways
|
||||
|
||||
@fields ~w[name_prefix tags]a
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
%Gateways.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:account_id, account.id)
|
||||
end
|
||||
|
||||
def update_changeset(%Gateways.Group{} = group, attrs) do
|
||||
changeset(group, attrs)
|
||||
end
|
||||
|
||||
defp changeset(%Gateways.Group{} = group, attrs) do
|
||||
group
|
||||
|> cast(attrs, @fields)
|
||||
|> trim_change(:name_prefix)
|
||||
|> put_default_value(:name_prefix, &Domain.NameGenerator.generate/0)
|
||||
|> validate_length(:name_prefix, min: 1, max: 64)
|
||||
|> validate_length(:tags, min: 0, max: 128)
|
||||
|> validate_no_duplicates(:tags)
|
||||
|> validate_list_elements(:tags, fn key, value ->
|
||||
if String.length(value) > 64 do
|
||||
[{key, "should be at most 64 characters long"}]
|
||||
else
|
||||
[]
|
||||
end
|
||||
end)
|
||||
|> validate_required(@fields)
|
||||
|> unique_constraint(:name_prefix, name: :gateway_groups_account_id_name_prefix_index)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Gateways.Token.Changeset.create_changeset(group.account)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
def delete_changeset(%Gateways.Group{} = group) do
|
||||
group
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
end
|
||||
16
apps/domain/lib/domain/gateways/group/query.ex
Normal file
16
apps/domain/lib/domain/gateways/group/query.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Domain.Gateways.Group.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(groups in Domain.Gateways.Group, as: :groups)
|
||||
|> where([groups: groups], is_nil(groups.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [groups: groups], groups.id == ^id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [groups: groups], groups.account_id == ^account_id)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule API.Relay.Presence do
|
||||
defmodule Domain.Gateways.Presence do
|
||||
use Phoenix.Presence,
|
||||
otp_app: :api,
|
||||
otp_app: :domain,
|
||||
pubsub_server: Domain.PubSub
|
||||
end
|
||||
14
apps/domain/lib/domain/gateways/token.ex
Normal file
14
apps/domain/lib/domain/gateways/token.ex
Normal file
@@ -0,0 +1,14 @@
|
||||
defmodule Domain.Gateways.Token do
|
||||
use Domain, :schema
|
||||
|
||||
schema "gateway_tokens" do
|
||||
field :value, :string, virtual: true
|
||||
field :hash, :string
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Gateways.Group
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
end
|
||||
31
apps/domain/lib/domain/gateways/token/changeset.ex
Normal file
31
apps/domain/lib/domain/gateways/token/changeset.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Domain.Gateways.Token.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.Gateways
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account) do
|
||||
%Gateways.Token{}
|
||||
|> change()
|
||||
|> put_change(:account_id, account.id)
|
||||
|> put_change(:value, Domain.Crypto.rand_string())
|
||||
|> put_hash(:value, to: :hash)
|
||||
|> assoc_constraint(:group)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|
||||
end
|
||||
|
||||
def use_changeset(%Gateways.Token{} = token) do
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# delete_changeset(token)
|
||||
|
||||
token
|
||||
|> change()
|
||||
end
|
||||
|
||||
def delete_changeset(%Gateways.Token{} = token) do
|
||||
token
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
|> put_change(:hash, nil)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "must be blank")
|
||||
end
|
||||
end
|
||||
16
apps/domain/lib/domain/gateways/token/query.ex
Normal file
16
apps/domain/lib/domain/gateways/token/query.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Domain.Gateways.Token.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(token in Domain.Gateways.Token, as: :token)
|
||||
|> where([token: token], is_nil(token.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [token: token], token.id == ^id)
|
||||
end
|
||||
|
||||
def by_group_id(queryable \\ all(), group_id) do
|
||||
where(queryable, [token: token], token.group_id == ^group_id)
|
||||
end
|
||||
end
|
||||
28
apps/domain/lib/domain/network.ex
Normal file
28
apps/domain/lib/domain/network.ex
Normal file
@@ -0,0 +1,28 @@
|
||||
defmodule Domain.Network do
|
||||
alias Domain.Repo
|
||||
alias Domain.Network.Address
|
||||
|
||||
@cidrs %{
|
||||
ipv4: %Postgrex.INET{address: {100, 64, 0, 0}, netmask: 10},
|
||||
ipv6: %Postgrex.INET{address: {64768, 0, 0, 0, 0, 0, 0, 0}, netmask: 106}
|
||||
}
|
||||
|
||||
def fetch_next_available_address!(account_id, type, opts \\ []) do
|
||||
unless Repo.in_transaction?() do
|
||||
raise "fetch_next_available_address/1 must be called inside a transaction"
|
||||
end
|
||||
|
||||
cidrs = Keyword.get(opts, :cidrs, @cidrs)
|
||||
cidr = Map.fetch!(cidrs, type)
|
||||
hosts = Domain.Types.CIDR.count_hosts(cidr)
|
||||
offset = Enum.random(2..max(2, hosts - 2))
|
||||
|
||||
address =
|
||||
Address.Query.next_available_address(account_id, cidr, offset)
|
||||
|> Domain.Repo.one!()
|
||||
|> Address.Changeset.create_changeset(account_id)
|
||||
|> Repo.insert!()
|
||||
|
||||
address.address
|
||||
end
|
||||
end
|
||||
13
apps/domain/lib/domain/network/address.ex
Normal file
13
apps/domain/lib/domain/network/address.ex
Normal file
@@ -0,0 +1,13 @@
|
||||
defmodule Domain.Network.Address do
|
||||
use Domain, :schema
|
||||
|
||||
@primary_key false
|
||||
schema "network_addresses" do
|
||||
field :address, Domain.Types.IP, primary_key: true
|
||||
belongs_to :account, Domain.Accounts.Account, primary_key: true
|
||||
|
||||
field :type, Ecto.Enum, values: [:ipv4, :ipv6]
|
||||
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
end
|
||||
20
apps/domain/lib/domain/network/address/changeset.ex
Normal file
20
apps/domain/lib/domain/network/address/changeset.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule Domain.Network.Address.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Network.Address
|
||||
|
||||
def create_changeset(address, account_id) do
|
||||
%Address{}
|
||||
|> change()
|
||||
|> put_change(:address, address)
|
||||
|> put_change(:account_id, account_id)
|
||||
|> put_default_value(:type, fn changeset ->
|
||||
case fetch_field(changeset, :address) do
|
||||
{_data_or_changes, inet} when tuple_size(inet.address) == 4 -> :ipv4
|
||||
{_data_or_changes, inet} when tuple_size(inet.address) == 8 -> :ipv6
|
||||
_other -> nil
|
||||
end
|
||||
end)
|
||||
|> validate_required([:type, :address])
|
||||
|> validate_inclusion(:type, Ecto.Enum.values(Address, :type))
|
||||
end
|
||||
end
|
||||
140
apps/domain/lib/domain/network/address/query.ex
Normal file
140
apps/domain/lib/domain/network/address/query.ex
Normal file
@@ -0,0 +1,140 @@
|
||||
defmodule Domain.Network.Address.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(addresses in Domain.Network.Address, as: :addresses)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [addresses: addresses], addresses.account_id == ^account_id)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns IP address at given integer offset relative to start of CIDR range.
|
||||
"""
|
||||
defmacro offset_to_ip(field, cidr) do
|
||||
quote do
|
||||
fragment("host(?)::inet + ?", unquote(cidr), unquote(field))
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns index of last IP address available for allocation in CIDR sequence.
|
||||
|
||||
Notice: the very last address in CIDR is typically a broadcast address that we won't allow to use.
|
||||
"""
|
||||
defmacro cidr_end_offset(cidr) do
|
||||
quote do
|
||||
fragment(
|
||||
"host(broadcast(?))::inet - host(?)::inet - 1",
|
||||
unquote(cidr),
|
||||
unquote(cidr)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Acquires a transactional advisory lock for an IP address using "network_addresses" table oid as namespace.
|
||||
|
||||
To fit bigint offset into int lock identifier we rollover at the integer max value.
|
||||
"""
|
||||
defmacro acquire_advisory_lock(field) do
|
||||
quote do
|
||||
fragment(
|
||||
"pg_try_advisory_xact_lock('network_addresses'::regclass::oid::int, mod(?, 2147483647)::int)",
|
||||
unquote(field)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
This function returns a query to fetch next available IP address, it works in 2 steps:
|
||||
|
||||
1. It starts by forward-scanning starting for available addresses at `offset` in a given `network_cidr`
|
||||
up to the end of CIDR range;
|
||||
|
||||
2. If forward-scan failed, scan backwards from the offset (exclusive) to start of CIDR range.
|
||||
|
||||
During the search, occupied addresses are skipped.
|
||||
|
||||
We also exclude first (X.X.X.0) and last (broadcast) address in a CIDR from a search range,
|
||||
to prevent issues with legacy firewalls that consider them "class C" space network addresses.
|
||||
"""
|
||||
def next_available_address(account_id, network_cidr, offset) do
|
||||
forward_search_queryable =
|
||||
series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset)
|
||||
|> select_not_used_ips(account_id, network_cidr)
|
||||
|
||||
reverse_search_queryable =
|
||||
series_from_start_of_cidr_to_offset_exclusive(network_cidr, offset)
|
||||
|> select_not_used_ips(account_id, network_cidr)
|
||||
|
||||
union_all(forward_search_queryable, ^reverse_search_queryable)
|
||||
|> limit(1)
|
||||
end
|
||||
|
||||
# Although sequences can work with inet types, we iterate over the sequence using an
|
||||
# offset relative to start of the given CIDR range.
|
||||
#
|
||||
# This way is chosen because IPv6 cannot be cast to bigint, so by using it directly
|
||||
# we won't be able to increment/decrement it while building a sequence.
|
||||
#
|
||||
# At the same time offset will fit to bigint even for largest CIDR ranges that Firezone supports.
|
||||
#
|
||||
# XXX: We can make this code prettier once https://github.com/elixir-ecto/ecto/commit/8f7bb2665bce30dfab18cfed01585c96495575a6 is released.
|
||||
defp series_from_offset_inclusive_to_end_of_cidr(network_cidr, offset) do
|
||||
from(
|
||||
i in fragment(
|
||||
"SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip",
|
||||
^offset,
|
||||
cidr_end_offset(^network_cidr),
|
||||
1
|
||||
),
|
||||
as: :q
|
||||
)
|
||||
end
|
||||
|
||||
defp series_from_start_of_cidr_to_offset_exclusive(_network_cidr, offset) do
|
||||
from(
|
||||
i in fragment(
|
||||
"SELECT generate_series((?)::bigint, (?)::bigint, ?) AS ip",
|
||||
^(offset - 1),
|
||||
2,
|
||||
-1
|
||||
),
|
||||
as: :q
|
||||
)
|
||||
end
|
||||
|
||||
defp select_not_used_ips(queryable, account_id, network_cidr) do
|
||||
host_as_string = network_cidr.address |> :inet.ntoa() |> List.to_string()
|
||||
|
||||
queryable
|
||||
|> where(
|
||||
[q: q],
|
||||
offset_to_ip(q.ip, ^network_cidr) not in subquery(
|
||||
used_ips_subquery(account_id, network_cidr)
|
||||
)
|
||||
)
|
||||
|> where(
|
||||
[q: q],
|
||||
acquire_advisory_lock(fragment("hashtext(?) + ?", ^host_as_string, q.ip)) ==
|
||||
true
|
||||
)
|
||||
|> select([q: q], offset_to_ip(q.ip, ^network_cidr))
|
||||
end
|
||||
|
||||
defp used_ips_subquery(queryable \\ all(), account_id, cidr) do
|
||||
queryable
|
||||
|> by_type(type(cidr))
|
||||
|> by_account_id(account_id)
|
||||
|> select([addresses: addresses], addresses.address)
|
||||
end
|
||||
|
||||
defp type(%Postgrex.INET{address: address}) when tuple_size(address) == 4, do: :ipv4
|
||||
defp type(%Postgrex.INET{address: address}) when tuple_size(address) == 8, do: :ipv6
|
||||
|
||||
defp by_type(queryable, type) do
|
||||
where(queryable, [addresses: addresses], addresses.type == ^type)
|
||||
end
|
||||
end
|
||||
164
apps/domain/lib/domain/relays.ex
Normal file
164
apps/domain/lib/domain/relays.ex
Normal file
@@ -0,0 +1,164 @@
|
||||
defmodule Domain.Relays do
|
||||
use Supervisor
|
||||
alias Domain.{Repo, Auth, Validator}
|
||||
alias Domain.Relays.{Authorizer, Relay, Group, Token, Presence}
|
||||
|
||||
def start_link(opts) do
|
||||
Supervisor.start_link(__MODULE__, opts, name: __MODULE__)
|
||||
end
|
||||
|
||||
def init(_opts) do
|
||||
children = [
|
||||
Presence
|
||||
]
|
||||
|
||||
Supervisor.init(children, strategy: :one_for_one)
|
||||
end
|
||||
|
||||
def fetch_group_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Group.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def list_groups(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
Group.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def new_group(attrs \\ %{}) do
|
||||
change_group(%Group{}, attrs)
|
||||
end
|
||||
|
||||
def create_group(attrs \\ %{}, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
subject.account
|
||||
|> Group.Changeset.create_changeset(attrs)
|
||||
|> Repo.insert()
|
||||
end
|
||||
end
|
||||
|
||||
def change_group(%Group{} = group, attrs \\ %{}) do
|
||||
group
|
||||
|> Repo.preload(:account)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
end
|
||||
|
||||
def update_group(%Group{} = group, attrs \\ %{}, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
group
|
||||
|> Repo.preload(:account)
|
||||
|> Group.Changeset.update_changeset(attrs)
|
||||
|> Repo.update()
|
||||
end
|
||||
end
|
||||
|
||||
def delete_group(%Group{} = group, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
Group.Query.by_id(group.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn group ->
|
||||
:ok =
|
||||
Token.Query.by_group_id(group.id)
|
||||
|> Repo.all()
|
||||
|> Enum.each(fn token ->
|
||||
Token.Changeset.delete_changeset(token)
|
||||
|> Repo.update!()
|
||||
end)
|
||||
|
||||
group
|
||||
|> Group.Changeset.delete_changeset()
|
||||
end
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
def use_token_by_id_and_secret(id, secret) do
|
||||
if Validator.valid_uuid?(id) do
|
||||
Token.Query.by_id(id)
|
||||
|> Repo.fetch_and_update(
|
||||
with: fn token ->
|
||||
if Domain.Crypto.equal?(secret, token.hash) do
|
||||
Token.Changeset.use_changeset(token)
|
||||
else
|
||||
:not_found
|
||||
end
|
||||
end
|
||||
)
|
||||
else
|
||||
{:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_relay_by_id(id, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()),
|
||||
true <- Validator.valid_uuid?(id) do
|
||||
Relay.Query.by_id(id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch()
|
||||
else
|
||||
false -> {:error, :not_found}
|
||||
other -> other
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_relay_by_id!(id, opts \\ []) do
|
||||
{preload, _opts} = Keyword.pop(opts, :preload, [])
|
||||
|
||||
Relay.Query.by_id(id)
|
||||
|> Repo.one!()
|
||||
|> Repo.preload(preload)
|
||||
end
|
||||
|
||||
def list_relays(%Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
Relay.Query.all()
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.list()
|
||||
end
|
||||
end
|
||||
|
||||
def upsert_relay(%Token{} = token, attrs) do
|
||||
changeset = Relay.Changeset.upsert_changeset(token, attrs)
|
||||
|
||||
Ecto.Multi.new()
|
||||
|> Ecto.Multi.insert(:relay, changeset,
|
||||
conflict_target: Relay.Changeset.upsert_conflict_target(),
|
||||
on_conflict: Relay.Changeset.upsert_on_conflict(),
|
||||
returning: true
|
||||
)
|
||||
|> Repo.transaction()
|
||||
|> case do
|
||||
{:ok, %{relay: relay}} -> {:ok, relay}
|
||||
{:error, :relay, changeset, _effects_so_far} -> {:error, changeset}
|
||||
end
|
||||
end
|
||||
|
||||
def delete_relay(%Relay{} = relay, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_relays_permission()) do
|
||||
Relay.Query.by_id(relay.id)
|
||||
|> Authorizer.for_subject(subject)
|
||||
|> Repo.fetch_and_update(with: &Relay.Changeset.delete_changeset/1)
|
||||
end
|
||||
end
|
||||
|
||||
def connect_relay(%Relay{} = relay, secret, socket) do
|
||||
{:ok, _} =
|
||||
Presence.track(socket, relay.id, %{
|
||||
online_at: System.system_time(:second),
|
||||
secret: secret
|
||||
})
|
||||
|
||||
:ok
|
||||
end
|
||||
end
|
||||
36
apps/domain/lib/domain/relays/authorizer.ex
Normal file
36
apps/domain/lib/domain/relays/authorizer.ex
Normal file
@@ -0,0 +1,36 @@
|
||||
defmodule Domain.Relays.Authorizer do
|
||||
use Domain.Auth.Authorizer
|
||||
alias Domain.Relays.{Group, Relay}
|
||||
|
||||
def manage_relays_permission, do: build(Relay, :manage)
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
|
||||
def list_permissions_for_role(:admin) do
|
||||
[
|
||||
manage_relays_permission()
|
||||
]
|
||||
end
|
||||
|
||||
def list_permissions_for_role(_) do
|
||||
[]
|
||||
end
|
||||
|
||||
@impl Domain.Auth.Authorizer
|
||||
def for_subject(queryable, %Subject{} = subject) when is_user(subject) do
|
||||
cond do
|
||||
has_permission?(subject, manage_relays_permission()) ->
|
||||
by_account_id(queryable, subject)
|
||||
end
|
||||
end
|
||||
|
||||
defp by_account_id(queryable, subject) do
|
||||
cond do
|
||||
Ecto.Query.has_named_binding?(queryable, :groups) ->
|
||||
Group.Query.by_account_id(queryable, subject.account.id)
|
||||
|
||||
Ecto.Query.has_named_binding?(queryable, :relays) ->
|
||||
Relay.Query.by_account_id(queryable, subject.account.id)
|
||||
end
|
||||
end
|
||||
end
|
||||
14
apps/domain/lib/domain/relays/group.ex
Normal file
14
apps/domain/lib/domain/relays/group.ex
Normal file
@@ -0,0 +1,14 @@
|
||||
defmodule Domain.Relays.Group do
|
||||
use Domain, :schema
|
||||
|
||||
schema "relay_groups" do
|
||||
field :name, :string
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
has_many :relays, Domain.Relays.Relay, foreign_key: :group_id
|
||||
has_many :tokens, Domain.Relays.Token, foreign_key: :group_id
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
39
apps/domain/lib/domain/relays/group/changeset.ex
Normal file
39
apps/domain/lib/domain/relays/group/changeset.ex
Normal file
@@ -0,0 +1,39 @@
|
||||
defmodule Domain.Relays.Group.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.Relays
|
||||
|
||||
@fields ~w[name]a
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account, attrs) do
|
||||
%Relays.Group{account: account}
|
||||
|> changeset(attrs)
|
||||
|> put_change(:account_id, account.id)
|
||||
end
|
||||
|
||||
def update_changeset(%Relays.Group{} = group, attrs) do
|
||||
changeset(group, attrs)
|
||||
end
|
||||
|
||||
defp changeset(group, attrs) do
|
||||
group
|
||||
|> cast(attrs, @fields)
|
||||
|> trim_change(:name)
|
||||
|> put_default_value(:name, &Domain.NameGenerator.generate/0)
|
||||
|> validate_length(:name, min: 1, max: 64)
|
||||
|> validate_required(@fields)
|
||||
|> unique_constraint(:name, name: :relay_groups_account_id_name_index)
|
||||
|> cast_assoc(:tokens,
|
||||
with: fn _token, _attrs ->
|
||||
Domain.Relays.Token.Changeset.create_changeset(group.account)
|
||||
end,
|
||||
required: true
|
||||
)
|
||||
end
|
||||
|
||||
def delete_changeset(%Relays.Group{} = group) do
|
||||
group
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
end
|
||||
16
apps/domain/lib/domain/relays/group/query.ex
Normal file
16
apps/domain/lib/domain/relays/group/query.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Domain.Relays.Group.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(groups in Domain.Relays.Group, as: :groups)
|
||||
|> where([groups: groups], is_nil(groups.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [groups: groups], groups.id == ^id)
|
||||
end
|
||||
|
||||
def by_account_id(queryable \\ all(), account_id) do
|
||||
where(queryable, [groups: groups], groups.account_id == ^account_id)
|
||||
end
|
||||
end
|
||||
@@ -1,5 +1,5 @@
|
||||
defmodule API.Client.Presence do
|
||||
defmodule Domain.Relays.Presence do
|
||||
use Phoenix.Presence,
|
||||
otp_app: :api,
|
||||
otp_app: :domain,
|
||||
pubsub_server: Domain.PubSub
|
||||
end
|
||||
20
apps/domain/lib/domain/relays/relay.ex
Normal file
20
apps/domain/lib/domain/relays/relay.ex
Normal file
@@ -0,0 +1,20 @@
|
||||
defmodule Domain.Relays.Relay do
|
||||
use Domain, :schema
|
||||
|
||||
schema "relays" do
|
||||
field :ipv4, Domain.Types.IP
|
||||
field :ipv6, Domain.Types.IP
|
||||
|
||||
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
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Relays.Group
|
||||
belongs_to :token, Domain.Relays.Token
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
end
|
||||
48
apps/domain/lib/domain/relays/relay/changeset.ex
Normal file
48
apps/domain/lib/domain/relays/relay/changeset.ex
Normal file
@@ -0,0 +1,48 @@
|
||||
defmodule Domain.Relays.Relay.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Version
|
||||
alias Domain.Relays
|
||||
|
||||
@upsert_fields ~w[ipv4 ipv6
|
||||
last_seen_user_agent last_seen_remote_ip]a
|
||||
@conflict_replace_fields ~w[ipv4 ipv6
|
||||
last_seen_user_agent last_seen_remote_ip
|
||||
last_seen_version last_seen_at]a
|
||||
|
||||
def upsert_conflict_target,
|
||||
do: {:unsafe_fragment, ~s/(account_id, COALESCE(ipv4, ipv6)) WHERE deleted_at IS NULL/}
|
||||
|
||||
def upsert_on_conflict, do: {:replace, @conflict_replace_fields}
|
||||
|
||||
def upsert_changeset(%Relays.Token{} = token, attrs) do
|
||||
%Relays.Relay{}
|
||||
|> cast(attrs, @upsert_fields)
|
||||
|> validate_required(~w[last_seen_user_agent last_seen_remote_ip]a)
|
||||
|> validate_required_one_of(~w[ipv4 ipv6]a)
|
||||
|> 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())
|
||||
|> put_relay_version()
|
||||
|> put_change(:account_id, token.account_id)
|
||||
|> put_change(:group_id, token.group_id)
|
||||
|> put_change(:token_id, token.id)
|
||||
|> assoc_constraint(:token)
|
||||
end
|
||||
|
||||
def delete_changeset(%Relays.Relay{} = relay) do
|
||||
relay
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
end
|
||||
|
||||
def put_relay_version(changeset) do
|
||||
with {_data_or_changes, user_agent} when not is_nil(user_agent) <-
|
||||
fetch_field(changeset, :last_seen_user_agent),
|
||||
{:ok, version} <- Version.fetch_version(user_agent) do
|
||||
put_change(changeset, :last_seen_version, version)
|
||||
else
|
||||
{:error, :invalid_user_agent} -> add_error(changeset, :last_seen_user_agent, "is invalid")
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
end
|
||||
32
apps/domain/lib/domain/relays/relay/query.ex
Normal file
32
apps/domain/lib/domain/relays/relay/query.ex
Normal file
@@ -0,0 +1,32 @@
|
||||
defmodule Domain.Relays.Relay.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(relays in Domain.Relays.Relay, as: :relays)
|
||||
|> where([relays: relays], is_nil(relays.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [relays: relays], relays.id == ^id)
|
||||
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)
|
||||
end
|
||||
|
||||
def returning_all(queryable \\ all()) do
|
||||
select(queryable, [relays: relays], relays)
|
||||
end
|
||||
|
||||
def with_preloaded_user(queryable \\ all()) do
|
||||
with_named_binding(queryable, :user, fn queryable, binding ->
|
||||
queryable
|
||||
|> join(:inner, [relays: relays], user in assoc(relays, ^binding), as: ^binding)
|
||||
|> preload([relays: relays, user: user], user: user)
|
||||
end)
|
||||
end
|
||||
end
|
||||
14
apps/domain/lib/domain/relays/token.ex
Normal file
14
apps/domain/lib/domain/relays/token.ex
Normal file
@@ -0,0 +1,14 @@
|
||||
defmodule Domain.Relays.Token do
|
||||
use Domain, :schema
|
||||
|
||||
schema "relay_tokens" do
|
||||
field :value, :string, virtual: true
|
||||
field :hash, :string
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
belongs_to :group, Domain.Relays.Group
|
||||
|
||||
field :deleted_at, :utc_datetime_usec
|
||||
timestamps(updated_at: false)
|
||||
end
|
||||
end
|
||||
31
apps/domain/lib/domain/relays/token/changeset.ex
Normal file
31
apps/domain/lib/domain/relays/token/changeset.ex
Normal file
@@ -0,0 +1,31 @@
|
||||
defmodule Domain.Relays.Token.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Accounts
|
||||
alias Domain.Relays
|
||||
|
||||
def create_changeset(%Accounts.Account{} = account) do
|
||||
%Relays.Token{}
|
||||
|> change()
|
||||
|> put_change(:value, Domain.Crypto.rand_string())
|
||||
|> put_hash(:value, to: :hash)
|
||||
|> assoc_constraint(:group)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "can't be blank")
|
||||
|> put_change(:account_id, account.id)
|
||||
end
|
||||
|
||||
def use_changeset(%Relays.Token{} = token) do
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# delete_changeset(token)
|
||||
|
||||
token
|
||||
|> change()
|
||||
end
|
||||
|
||||
def delete_changeset(%Relays.Token{} = token) do
|
||||
token
|
||||
|> change()
|
||||
|> put_default_value(:deleted_at, DateTime.utc_now())
|
||||
|> put_change(:hash, nil)
|
||||
|> check_constraint(:hash, name: :hash_not_null, message: "must be blank")
|
||||
end
|
||||
end
|
||||
16
apps/domain/lib/domain/relays/token/query.ex
Normal file
16
apps/domain/lib/domain/relays/token/query.ex
Normal file
@@ -0,0 +1,16 @@
|
||||
defmodule Domain.Relays.Token.Query do
|
||||
use Domain, :query
|
||||
|
||||
def all do
|
||||
from(token in Domain.Relays.Token, as: :token)
|
||||
|> where([token: token], is_nil(token.deleted_at))
|
||||
end
|
||||
|
||||
def by_id(queryable \\ all(), id) do
|
||||
where(queryable, [token: token], token.id == ^id)
|
||||
end
|
||||
|
||||
def by_group_id(queryable \\ all(), group_id) do
|
||||
where(queryable, [token: token], token.group_id == ^group_id)
|
||||
end
|
||||
end
|
||||
@@ -3,11 +3,14 @@ defmodule Domain.Repo do
|
||||
otp_app: :domain,
|
||||
adapter: Ecto.Adapters.Postgres
|
||||
|
||||
require Ecto.Query
|
||||
|
||||
@doc """
|
||||
Similar to `Ecto.Repo.one/2`, fetches a single result from the query.
|
||||
|
||||
Returns `{:ok, schema}` or `{:error, :not_found}` if no result was found.
|
||||
Raises if there is more than one row matching the query.
|
||||
|
||||
Raises when the query returns more than one row.
|
||||
"""
|
||||
@spec fetch(queryable :: Ecto.Queryable.t(), opts :: Keyword.t()) ::
|
||||
{:ok, Ecto.Schema.t()} | {:error, :not_found}
|
||||
@@ -23,6 +26,39 @@ defmodule Domain.Repo do
|
||||
"""
|
||||
def fetch!(queryable, opts \\ []), do: __MODULE__.one!(queryable, opts)
|
||||
|
||||
@doc """
|
||||
Uses query to fetch a single result from the database, locks it for update and
|
||||
then updates it using a changeset within a database transaction.
|
||||
|
||||
Raises when the query returns more than one row.
|
||||
"""
|
||||
@spec fetch_and_update(
|
||||
queryable :: Ecto.Queryable.t(),
|
||||
[{:with, changeset_fun :: (term() -> Ecto.Changeset.t())}],
|
||||
opts :: Keyword.t()
|
||||
) ::
|
||||
{:ok, Ecto.Schema.t()} | {:error, :not_found} | {:error, Ecto.Changeset.t()}
|
||||
def fetch_and_update(queryable, [with: changeset_fun], opts \\ [])
|
||||
when is_function(changeset_fun, 1) do
|
||||
transaction(fn ->
|
||||
queryable = Ecto.Query.lock(queryable, "FOR UPDATE")
|
||||
|
||||
with {:ok, schema} <- fetch(queryable, opts) do
|
||||
schema
|
||||
|> changeset_fun.()
|
||||
|> case do
|
||||
%Ecto.Changeset{} = changeset -> update(changeset, opts)
|
||||
reason -> {:error, reason}
|
||||
end
|
||||
end
|
||||
end)
|
||||
|> case do
|
||||
{:ok, {:ok, schema}} -> {:ok, schema}
|
||||
{:ok, {:error, reason}} -> {:error, reason}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Similar to `Ecto.Repo.all/2`, fetches all results from the query but return a tuple.
|
||||
"""
|
||||
|
||||
@@ -127,7 +127,7 @@ defmodule Domain.Telemetry do
|
||||
unprivileged_device_configuration: allow_unprivileged_device_configuration,
|
||||
local_authentication: local_auth_enabled,
|
||||
disable_vpn_on_oidc_error: disable_vpn_on_oidc_error,
|
||||
outbound_email: Web.Mailer.active?(),
|
||||
# outbound_email: Web.Mailer.active?(),
|
||||
external_database:
|
||||
external_database?(Map.new(Domain.Config.fetch_env!(:domain, Domain.Repo))),
|
||||
logo_type: Domain.Config.Logo.type(logo)
|
||||
|
||||
@@ -11,6 +11,8 @@ defmodule Domain.Types.IP do
|
||||
|
||||
def equal?(left, right), do: left == right
|
||||
|
||||
def cast(tuple) when tuple_size(tuple) == 4, do: {:ok, %Postgrex.INET{address: tuple}}
|
||||
def cast(tuple) when tuple_size(tuple) == 8, do: {:ok, %Postgrex.INET{address: tuple}}
|
||||
def cast(%Postgrex.INET{} = inet), do: {:ok, inet}
|
||||
|
||||
def cast(binary) when is_binary(binary) do
|
||||
|
||||
@@ -2,9 +2,9 @@ defimpl String.Chars, for: Postgrex.INET do
|
||||
def to_string(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
|
||||
end
|
||||
|
||||
defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
|
||||
def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
|
||||
end
|
||||
# defimpl Phoenix.HTML.Safe, for: Postgrex.INET do
|
||||
# def to_iodata(%Postgrex.INET{} = inet), do: Domain.Types.INET.to_string(inet)
|
||||
# end
|
||||
|
||||
defimpl Jason.Encoder, for: Postgrex.INET do
|
||||
def encode(%Postgrex.INET{} = struct, opts) do
|
||||
@@ -16,6 +16,6 @@ defimpl String.Chars, for: Domain.Types.IPPort do
|
||||
def to_string(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
|
||||
end
|
||||
|
||||
defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do
|
||||
def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
|
||||
end
|
||||
# defimpl Phoenix.HTML.Safe, for: Domain.Types.IPPort do
|
||||
# def to_iodata(%Domain.Types.IPPort{} = ip_port), do: Domain.Types.IPPort.to_string(ip_port)
|
||||
# end
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
defmodule Domain.Users do
|
||||
alias Domain.{Repo, Auth, Validator, Config, Telemetry}
|
||||
alias Domain.Accounts
|
||||
alias Domain.Users.{Authorizer, User}
|
||||
require Ecto.Query
|
||||
|
||||
@@ -100,14 +101,14 @@ defmodule Domain.Users do
|
||||
end
|
||||
end
|
||||
|
||||
def create_user(role, attrs, %Auth.Subject{} = subject) do
|
||||
def create_user(%Accounts.Account{} = account, role, attrs, %Auth.Subject{} = subject) do
|
||||
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.manage_users_permission()) do
|
||||
create_user(role, attrs)
|
||||
create_user(account, role, attrs)
|
||||
end
|
||||
end
|
||||
|
||||
def create_user(role, attrs) do
|
||||
changeset = User.Changeset.create_changeset(role, attrs)
|
||||
def create_user(%Accounts.Account{} = account, role, attrs) when is_atom(role) do
|
||||
changeset = User.Changeset.create_changeset(account, role, attrs)
|
||||
|
||||
with {:ok, user} <- Repo.insert(changeset) do
|
||||
Telemetry.add_user()
|
||||
|
||||
@@ -24,6 +24,8 @@ defmodule Domain.Users.User do
|
||||
has_many :oidc_connections, Domain.Auth.OIDC.Connection
|
||||
has_many :api_tokens, Domain.ApiTokens.ApiToken
|
||||
|
||||
belongs_to :account, Domain.Accounts.Account
|
||||
|
||||
field :disabled_at, :utc_datetime_usec
|
||||
timestamps()
|
||||
end
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
defmodule Domain.Users.User.Changeset do
|
||||
use Domain, :changeset
|
||||
alias Domain.Auth
|
||||
alias Domain.{Accounts, Auth}
|
||||
alias Domain.Users
|
||||
|
||||
@min_password_length 12
|
||||
@max_password_length 64
|
||||
|
||||
def create_changeset(role, attrs) when is_atom(role) do
|
||||
def create_changeset(%Accounts.Account{} = account, role, attrs) when is_atom(role) do
|
||||
%Users.User{}
|
||||
|> cast(attrs, ~w[
|
||||
email
|
||||
@@ -14,6 +14,7 @@ defmodule Domain.Users.User.Changeset do
|
||||
password_confirmation
|
||||
]a)
|
||||
|> put_change(:role, role)
|
||||
|> put_change(:account_id, account.id)
|
||||
|> change_email_changeset()
|
||||
|> validate_if_changed(:password, &change_password_changeset/1)
|
||||
end
|
||||
|
||||
@@ -281,6 +281,35 @@ defmodule Domain.Validator do
|
||||
end
|
||||
end
|
||||
|
||||
def validate_required_one_of(%Ecto.Changeset{} = changeset, fields) do
|
||||
if Enum.any?(fields, &(not empty?(changeset, &1))) do
|
||||
changeset
|
||||
else
|
||||
Enum.reduce(
|
||||
fields,
|
||||
changeset,
|
||||
&add_error(&2, &1, "one of these fields must be present: #{Enum.join(fields, ", ")}",
|
||||
validation: :one_of,
|
||||
one_of: fields
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Applies a validation function for every elements of the list.
|
||||
|
||||
The validation function should take two arguments: field name and element value,
|
||||
and return the same structure as `validate_change/3`.
|
||||
"""
|
||||
def validate_list_elements(%Ecto.Changeset{} = changeset, field, callback) do
|
||||
validate_change(changeset, field, fn _field, values ->
|
||||
values
|
||||
|> Enum.flat_map(&callback.(field, &1))
|
||||
|> Enum.uniq()
|
||||
end)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Removes change for a given field and original value from it from `changeset.params`.
|
||||
|
||||
@@ -304,14 +333,15 @@ defmodule Domain.Validator do
|
||||
|
||||
def put_default_value(changeset, field, value) do
|
||||
case fetch_field(changeset, field) do
|
||||
{:data, nil} -> put_change(changeset, field, maybe_apply(value))
|
||||
:error -> put_change(changeset, field, maybe_apply(value))
|
||||
{:data, nil} -> put_change(changeset, field, maybe_apply(changeset, value))
|
||||
:error -> put_change(changeset, field, maybe_apply(changeset, value))
|
||||
_ -> changeset
|
||||
end
|
||||
end
|
||||
|
||||
defp maybe_apply(fun) when is_function(fun, 0), do: fun.()
|
||||
defp maybe_apply(value), do: value
|
||||
defp maybe_apply(_changeset, fun) when is_function(fun, 0), do: fun.()
|
||||
defp maybe_apply(changeset, fun) when is_function(fun, 1), do: fun.(changeset)
|
||||
defp maybe_apply(_changeset, value), do: value
|
||||
|
||||
def trim_change(changeset, field) do
|
||||
update_change(changeset, field, fn
|
||||
|
||||
18
apps/domain/lib/domain/version.ex
Normal file
18
apps/domain/lib/domain/version.ex
Normal file
@@ -0,0 +1,18 @@
|
||||
defmodule Domain.Version do
|
||||
def fetch_version(user_agent) when is_binary(user_agent) do
|
||||
user_agent
|
||||
|> String.split(" ")
|
||||
|> Enum.find_value(fn
|
||||
"connlib/" <> version -> version
|
||||
_ -> nil
|
||||
end)
|
||||
|> case do
|
||||
nil -> {:error, :invalid_user_agent}
|
||||
version -> {:ok, version}
|
||||
end
|
||||
end
|
||||
|
||||
def fetch_gateway_version(_user_agent) do
|
||||
{:error, :invalid_user_agent}
|
||||
end
|
||||
end
|
||||
@@ -53,7 +53,8 @@ defmodule Domain.MixProject do
|
||||
{:cloak, "~> 1.1"},
|
||||
{:cloak_ecto, "~> 1.2"},
|
||||
|
||||
# PubSub
|
||||
# PubSub and Presence
|
||||
{:phoenix, "~> 1.7", runtime: false},
|
||||
{:phoenix_pubsub, "~> 2.0"},
|
||||
|
||||
# Auth-related deps
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
defmodule Domain.Repo.Migrations.CreateAccounts do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:accounts, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:name, :string, null: false)
|
||||
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
alter table(:users) do
|
||||
add(:account_id, references(:accounts, type: :binary_id, on_delete: :delete_all),
|
||||
null: false
|
||||
)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,13 @@
|
||||
defmodule Domain.Repo.Migrations.CreateNetworkAddresses do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create(table(:network_addresses, primary_key: false)) do
|
||||
add(:type, :string, null: false)
|
||||
add(:address, :inet, null: false, primary_key: true)
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false, primary_key: true)
|
||||
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,71 @@
|
||||
defmodule Domain.Repo.Migrations.CreateClients do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:clients, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:external_id, :string, null: false)
|
||||
|
||||
add(:name, :string, null: false)
|
||||
|
||||
add(:public_key, :string, null: false)
|
||||
add(:preshared_key, :binary, 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(:last_seen_user_agent, :string, null: false)
|
||||
add(:last_seen_remote_ip, :inet, null: false)
|
||||
add(:last_seen_version, :string, null: false)
|
||||
add(:last_seen_at, :utc_datetime_usec, null: false)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
add(:user_id, references(:users, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Used to list clients for a user
|
||||
create(index(:clients, [:user_id], where: "deleted_at IS NULL"))
|
||||
|
||||
# Used for upserts
|
||||
create(
|
||||
index(:clients, [:account_id, :user_id, :external_id],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
# Used to enforce unique IPv4 and IPv6 addresses.
|
||||
create(index(:clients, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL"))
|
||||
create(index(:clients, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL"))
|
||||
|
||||
# Used to enforce unique names and public keys.
|
||||
create(
|
||||
index(:clients, [:account_id, :user_id, :name], unique: true, where: "deleted_at IS NULL")
|
||||
)
|
||||
|
||||
create(
|
||||
index(:clients, [:account_id, :user_id, :public_key],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,29 @@
|
||||
defmodule Domain.Repo.Migrations.CreateGatewayGroups do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:gateway_groups, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
|
||||
add(:name_prefix, :string, null: false)
|
||||
|
||||
add(:tags, {:array, :string}, null: false, default: [])
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Used to match by tags
|
||||
execute("CREATE INDEX gateway_group_tags_idx on gateway_groups USING GIN (tags)")
|
||||
|
||||
# Used to enforce unique names
|
||||
create(
|
||||
index(:gateway_groups, [:account_id, :name_prefix],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Domain.Repo.Migrations.CreateGatewayTokens do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:gateway_tokens, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:hash, :string)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
add(:group_id, references(:gateway_groups, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create(
|
||||
constraint(:gateway_tokens, :hash_not_null,
|
||||
check: """
|
||||
(hash is NOT NULL AND deleted_at IS NULL)
|
||||
OR (hash is NULL AND deleted_at IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
create(index(:gateway_tokens, [:group_id], where: "deleted_at IS NULL"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,68 @@
|
||||
defmodule Domain.Repo.Migrations.CreateGateways do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:gateways, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:external_id, :string, null: false)
|
||||
|
||||
add(:name_suffix, :string, null: false)
|
||||
|
||||
add(:public_key, :string, 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(:last_seen_user_agent, :string, null: false)
|
||||
add(:last_seen_remote_ip, :inet, null: false)
|
||||
add(:last_seen_version, :string, null: false)
|
||||
add(:last_seen_at, :utc_datetime_usec, null: false)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
add(:token_id, references(:gateway_tokens, type: :binary_id), null: false)
|
||||
add(:group_id, references(:gateway_groups, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Used for upserts.
|
||||
create(
|
||||
index(:gateways, [:account_id, :group_id, :external_id],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
# Used to enforce unique IPv4 and IPv6 addresses.
|
||||
create(index(:gateways, [:account_id, :ipv4], unique: true, where: "deleted_at IS NULL"))
|
||||
create(index(:gateways, [:account_id, :ipv6], unique: true, where: "deleted_at IS NULL"))
|
||||
|
||||
# Used to enforce unique names and public keys.
|
||||
create(
|
||||
index(:gateways, [:account_id, :group_id, :name_suffix],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(
|
||||
index(:gateways, [:account_id, :public_key], unique: true, where: "deleted_at IS NULL")
|
||||
)
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.Repo.Migrations.CreateRelayGroups do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:relay_groups, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
|
||||
add(:name, :string, null: false)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
# Used to enforce unique names
|
||||
create(index(:relay_groups, [:account_id, :name], unique: true, where: "deleted_at IS NULL"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,27 @@
|
||||
defmodule Domain.Repo.Migrations.CreateRelayTokens do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:relay_tokens, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
add(:hash, :string)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id), null: false)
|
||||
add(:group_id, references(:relay_groups, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec, updated_at: false)
|
||||
end
|
||||
|
||||
create(
|
||||
constraint(:relay_tokens, :hash_not_null,
|
||||
check: """
|
||||
(hash is NOT NULL AND deleted_at IS NULL)
|
||||
OR (hash is NULL AND deleted_at IS NOT NULL)
|
||||
"""
|
||||
)
|
||||
)
|
||||
|
||||
create(index(:relay_tokens, [:group_id], where: "deleted_at IS NULL"))
|
||||
end
|
||||
end
|
||||
@@ -0,0 +1,45 @@
|
||||
defmodule Domain.Repo.Migrations.CreateRelays do
|
||||
use Ecto.Migration
|
||||
|
||||
def change do
|
||||
create table(:relays, primary_key: false) do
|
||||
add(:id, :uuid, primary_key: true)
|
||||
|
||||
add(:ipv4, :inet)
|
||||
add(:ipv6, :inet)
|
||||
|
||||
add(:last_seen_user_agent, :string, null: false)
|
||||
add(:last_seen_remote_ip, :inet, null: false)
|
||||
add(:last_seen_version, :string, null: false)
|
||||
add(:last_seen_at, :utc_datetime_usec, null: false)
|
||||
|
||||
add(:account_id, references(:accounts, type: :binary_id))
|
||||
add(:token_id, references(:relay_tokens, type: :binary_id), null: false)
|
||||
add(:group_id, references(:relay_groups, type: :binary_id), null: false)
|
||||
|
||||
add(:deleted_at, :utc_datetime_usec)
|
||||
timestamps(type: :utc_datetime_usec)
|
||||
end
|
||||
|
||||
execute("""
|
||||
CREATE UNIQUE INDEX relays_unique_addresses_idx
|
||||
ON relays (account_id, COALESCE(ipv4, ipv6))
|
||||
WHERE deleted_at IS NULL
|
||||
""")
|
||||
|
||||
# Used to enforce unique IPv4 and IPv6 addresses.
|
||||
create(
|
||||
index(:relays, [:account_id, :ipv4],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND ipv4 IS NOT NULL"
|
||||
)
|
||||
)
|
||||
|
||||
create(
|
||||
index(:relays, [:account_id, :ipv6],
|
||||
unique: true,
|
||||
where: "deleted_at IS NULL AND ipv6 IS NOT NULL"
|
||||
)
|
||||
)
|
||||
end
|
||||
end
|
||||
663
apps/domain/test/domain/clients_test.exs
Normal file
663
apps/domain/test/domain/clients_test.exs
Normal file
@@ -0,0 +1,663 @@
|
||||
defmodule Domain.ClientsTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Clients
|
||||
alias Domain.AccountsFixtures
|
||||
alias Domain.{NetworkFixtures, UsersFixtures, SubjectFixtures, ClientsFixtures}
|
||||
alias Domain.Clients
|
||||
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
unprivileged_user = UsersFixtures.create_user_with_role(:unprivileged, account: account)
|
||||
unprivileged_subject = SubjectFixtures.create_subject(unprivileged_user)
|
||||
|
||||
admin_user = UsersFixtures.create_user_with_role(:admin, account: account)
|
||||
admin_subject = SubjectFixtures.create_subject(admin_user)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
unprivileged_user: unprivileged_user,
|
||||
unprivileged_subject: unprivileged_subject,
|
||||
admin_user: admin_user,
|
||||
admin_subject: admin_subject
|
||||
}
|
||||
end
|
||||
|
||||
describe "count_by_account_id/0" do
|
||||
test "counts clients for an account", %{account: account} do
|
||||
ClientsFixtures.create_client(account: account)
|
||||
ClientsFixtures.create_client(account: account)
|
||||
ClientsFixtures.create_client(account: account)
|
||||
ClientsFixtures.create_client()
|
||||
|
||||
assert count_by_account_id(account.id) == 3
|
||||
end
|
||||
end
|
||||
|
||||
describe "count_by_user_id/1" do
|
||||
test "returns 0 if user does not exist" do
|
||||
assert count_by_user_id(Ecto.UUID.generate()) == 0
|
||||
end
|
||||
|
||||
test "returns count of clients for a user" do
|
||||
client = ClientsFixtures.create_client()
|
||||
assert count_by_user_id(client.user_id) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_client_by_id/2" do
|
||||
test "returns error when UUID is invalid", %{unprivileged_subject: subject} do
|
||||
assert fetch_client_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted clients", %{
|
||||
unprivileged_user: user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client =
|
||||
ClientsFixtures.create_client(user: user)
|
||||
|> ClientsFixtures.delete_client()
|
||||
|
||||
assert fetch_client_by_id(client.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns client by id", %{unprivileged_user: user, unprivileged_subject: subject} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
assert fetch_client_by_id(client.id, subject) == {:ok, client}
|
||||
end
|
||||
|
||||
test "returns client that belongs to another user with manage permission", %{
|
||||
account: account,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(account: account)
|
||||
|
||||
subject =
|
||||
subject
|
||||
|> SubjectFixtures.remove_permissions()
|
||||
|> SubjectFixtures.add_permission(Clients.Authorizer.manage_clients_permission())
|
||||
|
||||
assert fetch_client_by_id(client.id, subject) == {:ok, client}
|
||||
end
|
||||
|
||||
test "does not returns client that belongs to another account with manage permission", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
subject =
|
||||
subject
|
||||
|> SubjectFixtures.remove_permissions()
|
||||
|> SubjectFixtures.add_permission(Clients.Authorizer.manage_clients_permission())
|
||||
|
||||
assert fetch_client_by_id(client.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return client that belongs to another user with manage_own permission", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
subject =
|
||||
subject
|
||||
|> SubjectFixtures.remove_permissions()
|
||||
|> SubjectFixtures.add_permission(Clients.Authorizer.manage_own_clients_permission())
|
||||
|
||||
assert fetch_client_by_id(client.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when client does not exist", %{unprivileged_subject: subject} do
|
||||
assert fetch_client_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view clients", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_client_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[
|
||||
missing_permissions: [
|
||||
{:one_of,
|
||||
[
|
||||
Clients.Authorizer.manage_clients_permission(),
|
||||
Clients.Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
]
|
||||
]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_clients/1" do
|
||||
test "returns empty list when there are no clients", %{admin_subject: subject} do
|
||||
assert list_clients(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted clients", %{
|
||||
unprivileged_user: user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
ClientsFixtures.create_client(user: user)
|
||||
|> ClientsFixtures.delete_client()
|
||||
|
||||
assert list_clients(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list clients in other accounts", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
ClientsFixtures.create_client()
|
||||
|
||||
assert list_clients(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "shows all clients owned by a user for unprivileged subject", %{
|
||||
unprivileged_user: user,
|
||||
admin_user: other_user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
ClientsFixtures.create_client(user: other_user)
|
||||
|
||||
assert list_clients(subject) == {:ok, [client]}
|
||||
end
|
||||
|
||||
test "shows all clients for admin subject", %{
|
||||
unprivileged_user: other_user,
|
||||
admin_user: admin_user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
ClientsFixtures.create_client(user: admin_user)
|
||||
ClientsFixtures.create_client(user: other_user)
|
||||
|
||||
assert {:ok, clients} = list_clients(subject)
|
||||
assert length(clients) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage clients", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_clients(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[
|
||||
missing_permissions: [
|
||||
{:one_of,
|
||||
[
|
||||
Clients.Authorizer.manage_clients_permission(),
|
||||
Clients.Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
]
|
||||
]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_clients_by_user_id/2" do
|
||||
test "returns empty list when there are no clients for a given user", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
assert list_clients_by_user_id(Ecto.UUID.generate(), subject) == {:ok, []}
|
||||
assert list_clients_by_user_id(user.id, subject) == {:ok, []}
|
||||
ClientsFixtures.create_client()
|
||||
assert list_clients_by_user_id(user.id, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns error when user id is invalid", %{admin_subject: subject} do
|
||||
assert list_clients_by_user_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not list deleted clients", %{
|
||||
unprivileged_user: user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
ClientsFixtures.create_client(user: user)
|
||||
|> ClientsFixtures.delete_client()
|
||||
|
||||
assert list_clients_by_user_id(user.id, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not deleted clients for users in other accounts", %{
|
||||
unprivileged_subject: unprivileged_subject,
|
||||
admin_subject: admin_subject
|
||||
} do
|
||||
user = UsersFixtures.create_user_with_role(:unprivileged)
|
||||
ClientsFixtures.create_client(user: user)
|
||||
|
||||
assert list_clients_by_user_id(user.id, unprivileged_subject) == {:ok, []}
|
||||
assert list_clients_by_user_id(user.id, admin_subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "shows only clients owned by a user for unprivileged subject", %{
|
||||
unprivileged_user: user,
|
||||
admin_user: other_user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
ClientsFixtures.create_client(user: other_user)
|
||||
|
||||
assert list_clients_by_user_id(user.id, subject) == {:ok, [client]}
|
||||
assert list_clients_by_user_id(other_user.id, subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "shows all clients owned by another user for admin subject", %{
|
||||
unprivileged_user: other_user,
|
||||
admin_user: admin_user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
ClientsFixtures.create_client(user: admin_user)
|
||||
ClientsFixtures.create_client(user: other_user)
|
||||
|
||||
assert {:ok, [_client]} = list_clients_by_user_id(admin_user.id, subject)
|
||||
assert {:ok, [_client]} = list_clients_by_user_id(other_user.id, subject)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage clients", %{
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_clients_by_user_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[
|
||||
missing_permissions: [
|
||||
{:one_of,
|
||||
[
|
||||
Clients.Authorizer.manage_clients_permission(),
|
||||
Clients.Authorizer.manage_own_clients_permission()
|
||||
]}
|
||||
]
|
||||
]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_client/1" do
|
||||
test "returns changeset with given changes", %{admin_user: user} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
client_attrs = ClientsFixtures.client_attrs()
|
||||
|
||||
assert changeset = change_client(client, client_attrs)
|
||||
assert %Ecto.Changeset{data: %Domain.Clients.Client{}} = changeset
|
||||
|
||||
assert changeset.changes == %{name: client_attrs.name}
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_client/2" do
|
||||
test "returns errors on invalid attrs", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
attrs = %{
|
||||
external_id: nil,
|
||||
public_key: "x",
|
||||
preshared_key: "x",
|
||||
ipv4: "1.1.1.256",
|
||||
ipv6: "fd01::10000"
|
||||
}
|
||||
|
||||
assert {:error, changeset} = upsert_client(attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
preshared_key: ["should be 44 character(s)", "must be a base64-encoded string"],
|
||||
public_key: ["should be 44 character(s)", "must be a base64-encoded string"],
|
||||
external_id: ["can't be blank"]
|
||||
}
|
||||
end
|
||||
|
||||
test "allows creating client with just required attributes", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
attrs =
|
||||
ClientsFixtures.client_attrs()
|
||||
|> Map.delete(:name)
|
||||
|
||||
assert {:ok, client} = upsert_client(attrs, subject)
|
||||
|
||||
assert client.name
|
||||
|
||||
assert client.public_key == attrs.public_key
|
||||
assert client.preshared_key == attrs.preshared_key
|
||||
|
||||
assert client.user_id == user.id
|
||||
assert client.account_id == user.account_id
|
||||
|
||||
refute is_nil(client.ipv4)
|
||||
refute is_nil(client.ipv6)
|
||||
|
||||
assert client.last_seen_remote_ip == %Postgrex.INET{address: subject.context.remote_ip}
|
||||
assert client.last_seen_user_agent == subject.context.user_agent
|
||||
assert client.last_seen_version == "0.7.412"
|
||||
assert client.last_seen_at
|
||||
end
|
||||
|
||||
test "updates client when it already exists", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(subject: subject)
|
||||
attrs = ClientsFixtures.client_attrs(external_id: client.external_id)
|
||||
|
||||
subject = %{
|
||||
subject
|
||||
| context: %Domain.Auth.Context{
|
||||
subject.context
|
||||
| remote_ip: {100, 64, 100, 101},
|
||||
user_agent: "iOS/12.5 (iPhone) connlib/0.7.411"
|
||||
}
|
||||
}
|
||||
|
||||
assert {:ok, updated_client} = upsert_client(attrs, subject)
|
||||
|
||||
assert Repo.aggregate(Clients.Client, :count, :id) == 1
|
||||
|
||||
assert updated_client.name
|
||||
assert updated_client.last_seen_remote_ip.address == subject.context.remote_ip
|
||||
assert updated_client.last_seen_remote_ip != client.last_seen_remote_ip
|
||||
assert updated_client.last_seen_user_agent == subject.context.user_agent
|
||||
assert updated_client.last_seen_user_agent != client.last_seen_user_agent
|
||||
assert updated_client.last_seen_version == "0.7.411"
|
||||
assert updated_client.public_key != client.public_key
|
||||
assert updated_client.public_key == attrs.public_key
|
||||
assert updated_client.preshared_key != client.preshared_key
|
||||
assert updated_client.preshared_key == attrs.preshared_key
|
||||
|
||||
assert updated_client.user_id == client.user_id
|
||||
assert updated_client.ipv4 == client.ipv4
|
||||
assert updated_client.ipv6 == client.ipv6
|
||||
assert updated_client.last_seen_at
|
||||
assert updated_client.last_seen_at != client.last_seen_at
|
||||
end
|
||||
|
||||
test "does not reserve additional addresses on update", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(subject: subject)
|
||||
|
||||
attrs =
|
||||
ClientsFixtures.client_attrs(
|
||||
external_id: client.external_id,
|
||||
last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411",
|
||||
last_seen_remote_ip: %Postgrex.INET{address: {100, 64, 100, 100}}
|
||||
)
|
||||
|
||||
assert {:ok, updated_client} = upsert_client(attrs, subject)
|
||||
|
||||
addresses =
|
||||
Domain.Network.Address
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %Domain.Network.Address{address: address, type: type} ->
|
||||
%{address: address, type: type}
|
||||
end)
|
||||
|
||||
assert length(addresses) == 2
|
||||
assert %{address: updated_client.ipv4, type: :ipv4} in addresses
|
||||
assert %{address: updated_client.ipv6, type: :ipv6} in addresses
|
||||
end
|
||||
|
||||
test "allows unprivileged user to create a client for himself", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
attrs =
|
||||
ClientsFixtures.client_attrs()
|
||||
|> Map.delete(:name)
|
||||
|
||||
assert {:ok, _client} = upsert_client(attrs, subject)
|
||||
end
|
||||
|
||||
test "does not allow to reuse IP addresses", %{
|
||||
account: account,
|
||||
admin_subject: subject
|
||||
} do
|
||||
attrs = ClientsFixtures.client_attrs(account: account)
|
||||
assert {:ok, client} = upsert_client(attrs, subject)
|
||||
|
||||
addresses =
|
||||
Domain.Network.Address
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %Domain.Network.Address{address: address, type: type} ->
|
||||
%{address: address, type: type}
|
||||
end)
|
||||
|
||||
assert length(addresses) == 2
|
||||
assert %{address: client.ipv4, type: :ipv4} in addresses
|
||||
assert %{address: client.ipv6, type: :ipv6} in addresses
|
||||
|
||||
assert_raise Ecto.ConstraintError, fn ->
|
||||
NetworkFixtures.create_address(address: client.ipv4, account: account)
|
||||
end
|
||||
end
|
||||
|
||||
test "ip addresses are unique per account", %{
|
||||
account: account,
|
||||
admin_subject: subject
|
||||
} do
|
||||
attrs = ClientsFixtures.client_attrs(account: account)
|
||||
assert {:ok, client} = upsert_client(attrs, subject)
|
||||
|
||||
assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: client.ipv4)
|
||||
assert %Domain.Network.Address{} = NetworkFixtures.create_address(address: client.ipv6)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to create clients", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert upsert_client(%{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_client/3" do
|
||||
test "allows admin user to update own clients", %{admin_user: user, admin_subject: subject} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
attrs = %{name: "new name"}
|
||||
|
||||
assert {:ok, client} = update_client(client, attrs, subject)
|
||||
|
||||
assert client.name == attrs.name
|
||||
end
|
||||
|
||||
test "allows admin user to update other users clients", %{
|
||||
account: account,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(account: account)
|
||||
attrs = %{name: "new name"}
|
||||
|
||||
assert {:ok, client} = update_client(client, attrs, subject)
|
||||
|
||||
assert client.name == attrs.name
|
||||
end
|
||||
|
||||
test "allows unprivileged user to update own clients", %{
|
||||
unprivileged_user: user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
attrs = %{name: "new name"}
|
||||
|
||||
assert {:ok, client} = update_client(client, attrs, subject)
|
||||
|
||||
assert client.name == attrs.name
|
||||
end
|
||||
|
||||
test "does not allow unprivileged user to update other users clients", %{
|
||||
account: account,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(account: account)
|
||||
attrs = %{name: "new name"}
|
||||
|
||||
assert update_client(client, attrs, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
|
||||
end
|
||||
|
||||
test "does not allow admin user to update clients in other accounts", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client()
|
||||
attrs = %{name: "new name"}
|
||||
|
||||
assert update_client(client, attrs, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not allow to reset required fields to empty values", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
attrs = %{name: nil, public_key: nil}
|
||||
|
||||
assert {:error, changeset} = update_client(client, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{name: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{admin_user: user, admin_subject: subject} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
attrs = %{
|
||||
name: String.duplicate("a", 256)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = update_client(client, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["should be at most 255 character(s)"]
|
||||
}
|
||||
end
|
||||
|
||||
test "ignores updates for any field except name", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
fields = Clients.Client.__schema__(:fields) -- [:name]
|
||||
value = -1
|
||||
|
||||
for field <- fields do
|
||||
assert {:ok, updated_client} = update_client(client, %{field => value}, subject)
|
||||
assert updated_client == client
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to update clients", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_client(client, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
|
||||
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
assert update_client(client, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_client/2" do
|
||||
test "returns error on state conflict", %{admin_user: user, admin_subject: subject} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
assert {:ok, deleted} = delete_client(client, subject)
|
||||
assert delete_client(deleted, subject) == {:error, :not_found}
|
||||
assert delete_client(client, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "admin can delete own clients", %{admin_user: user, admin_subject: subject} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
assert {:ok, deleted} = delete_client(client, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "admin can delete other people clients", %{
|
||||
unprivileged_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
assert {:ok, deleted} = delete_client(client, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "admin can not delete clients in other accounts", %{
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
assert delete_client(client, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "unprivileged can delete own clients", %{
|
||||
unprivileged_user: user,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
assert {:ok, deleted} = delete_client(client, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "unprivileged can not delete other people clients", %{
|
||||
account: account,
|
||||
unprivileged_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
assert delete_client(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
|
||||
|
||||
client = ClientsFixtures.create_client(account: account)
|
||||
|
||||
assert delete_client(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
|
||||
|
||||
assert Repo.aggregate(Clients.Client, :count) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete clients", %{
|
||||
admin_user: user,
|
||||
admin_subject: subject
|
||||
} do
|
||||
client = ClientsFixtures.create_client(user: user)
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_client(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_own_clients_permission()]]}}
|
||||
|
||||
client = ClientsFixtures.create_client()
|
||||
|
||||
assert delete_client(client, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Clients.Authorizer.manage_clients_permission()]]}}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -1,179 +0,0 @@
|
||||
defmodule Domain.Devices.Device.QueryTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Devices.Device.Query
|
||||
alias Domain.DevicesFixtures
|
||||
|
||||
describe "next_available_address/3" do
|
||||
test "selects available IPv4 in CIDR range at the offset" do
|
||||
cidr = string_to_cidr("10.3.2.0/29")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.2.0")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 2, 3}}
|
||||
end
|
||||
|
||||
test "skips addresses taken by the gateway" do
|
||||
cidr = string_to_cidr("10.3.3.0/29")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.3.3")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 4}}
|
||||
end
|
||||
|
||||
test "forward scans available address after offset it it's assigned to a device" do
|
||||
cidr = string_to_cidr("10.3.4.0/29")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.4.0")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.4.3"})
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.4.4"})
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 5}}
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.4.5"})
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 6}}
|
||||
end
|
||||
|
||||
test "backward scans available address if forward scan found not available IPs" do
|
||||
cidr = string_to_cidr("10.3.5.0/29")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.5.0")
|
||||
offset = 5
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.5.5"})
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.5.6"})
|
||||
# Notice: end of range is 10.3.5.7
|
||||
# but it's a broadcast address that we don't allow to assign
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 4}}
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.5.4"})
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 3}}
|
||||
end
|
||||
|
||||
test "selects nothing when CIDR range is exhausted" do
|
||||
cidr = string_to_cidr("10.3.6.0/30")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.6.1")
|
||||
offset = 1
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.6.2"})
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
assert is_nil(Repo.one(queryable))
|
||||
|
||||
DevicesFixtures.create_device(%{ipv4: "10.3.6.1"})
|
||||
queryable = next_available_address(cidr, offset, [])
|
||||
assert is_nil(Repo.one(queryable))
|
||||
|
||||
# Notice: real start of range is 10.3.6.0,
|
||||
# but it's a typical gateway address that we don't allow to assign
|
||||
end
|
||||
|
||||
test "prevents two concurrent transactions from acquiring the same address" do
|
||||
cidr = string_to_cidr("10.3.7.0/29")
|
||||
Domain.Config.put_env_override(:wireguard_ipv4_network, cidr)
|
||||
gateway_ip = string_to_ip("10.3.7.3")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
test_pid = self()
|
||||
|
||||
spawn(fn ->
|
||||
Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
|
||||
Repo.transaction(fn ->
|
||||
ip = Repo.one(queryable)
|
||||
send(test_pid, {:ip, ip})
|
||||
Process.sleep(200)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
ip1 = Repo.one(queryable)
|
||||
assert_receive {:ip, ip2}, 1_000
|
||||
|
||||
assert Enum.sort([ip1, ip2]) ==
|
||||
Enum.sort([
|
||||
%Postgrex.INET{address: {10, 3, 7, 4}},
|
||||
%Postgrex.INET{address: {10, 3, 7, 5}}
|
||||
])
|
||||
end
|
||||
|
||||
test "selects available IPv6 in CIDR range at the offset" do
|
||||
cidr = string_to_cidr("fd00::3:3:0/120")
|
||||
Domain.Config.put_env_override(:wireguard_ipv6_network, cidr)
|
||||
gateway_ip = string_to_ip("fd00::3:3:3")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 3, 4}}
|
||||
end
|
||||
|
||||
test "selects available IPv6 at end of CIDR range" do
|
||||
cidr = string_to_cidr("fd00::/106")
|
||||
Domain.Config.put_env_override(:wireguard_ipv6_network, cidr)
|
||||
gateway_ip = string_to_ip("fd00::3:3:3")
|
||||
offset = 4_194_304
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 63, 65_535}}
|
||||
end
|
||||
|
||||
test "works when offset is out of IPv6 CIDR range" do
|
||||
cidr = string_to_cidr("fd00::/106")
|
||||
Domain.Config.put_env_override(:wireguard_ipv6_network, cidr)
|
||||
gateway_ip = string_to_ip("fd00::3:3:3")
|
||||
offset = 4_194_305
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 64, 0}}
|
||||
end
|
||||
|
||||
test "works when netmask allows a large number of devices" do
|
||||
cidr = string_to_cidr("fd00::/70")
|
||||
Domain.Config.put_env_override(:wireguard_ipv6_network, cidr)
|
||||
gateway_ip = string_to_ip("fd00::3:3:3")
|
||||
offset = 9_223_372_036_854_775_807
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{
|
||||
address: {64_768, 0, 0, 0, 32_767, 65_535, 65_535, 65_534}
|
||||
}
|
||||
end
|
||||
|
||||
test "selects nothing when IPv6 CIDR range is exhausted" do
|
||||
cidr = string_to_cidr("fd00::3:2:0/126")
|
||||
Domain.Config.put_env_override(:wireguard_ipv6_network, cidr)
|
||||
gateway_ip = string_to_ip("fd00::3:2:1")
|
||||
offset = 3
|
||||
|
||||
DevicesFixtures.create_device(%{ipv6: "fd00::3:2:2"})
|
||||
|
||||
queryable = next_available_address(cidr, offset, [gateway_ip])
|
||||
assert is_nil(Repo.one(queryable))
|
||||
end
|
||||
end
|
||||
|
||||
defp string_to_cidr(string) do
|
||||
{:ok, inet} = Domain.Types.CIDR.cast(string)
|
||||
inet
|
||||
end
|
||||
|
||||
defp string_to_ip(string) do
|
||||
{:ok, inet} = Domain.Types.IP.cast(string)
|
||||
inet
|
||||
end
|
||||
end
|
||||
683
apps/domain/test/domain/gateways_test.exs
Normal file
683
apps/domain/test/domain/gateways_test.exs
Normal file
@@ -0,0 +1,683 @@
|
||||
defmodule Domain.GatewaysTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Gateways
|
||||
alias Domain.AccountsFixtures
|
||||
alias Domain.{NetworkFixtures, UsersFixtures, SubjectFixtures, GatewaysFixtures}
|
||||
alias Domain.Gateways
|
||||
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
user = UsersFixtures.create_user_with_role(:admin, account: account)
|
||||
subject = SubjectFixtures.create_subject(user)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
user: user,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
describe "fetch_group_by_id/2" do
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_group_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = GatewaysFixtures.create_group()
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group =
|
||||
GatewaysFixtures.create_group(account: account)
|
||||
|> GatewaysFixtures.delete_group()
|
||||
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns group by id", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns group that belongs to another user", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns error when group does not exist", %{subject: subject} do
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_groups/1" do
|
||||
test "returns empty list when there are no groups", %{subject: subject} do
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
GatewaysFixtures.create_group()
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
GatewaysFixtures.create_group(account: account)
|
||||
|> GatewaysFixtures.delete_group()
|
||||
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
GatewaysFixtures.create_group(account: account)
|
||||
GatewaysFixtures.create_group(account: account)
|
||||
GatewaysFixtures.create_group()
|
||||
|
||||
assert {:ok, groups} = list_groups(subject)
|
||||
assert length(groups) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_groups(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "new_group/0" do
|
||||
test "returns group changeset" do
|
||||
assert %Ecto.Changeset{data: %Gateways.Group{}, changes: changes} = new_group()
|
||||
assert Map.has_key?(changes, :name_prefix)
|
||||
assert Enum.count(changes) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_group/2" do
|
||||
test "returns error on empty attrs", %{subject: subject} do
|
||||
assert {:error, changeset} = create_group(%{}, subject)
|
||||
assert errors_on(changeset) == %{tokens: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
attrs = %{
|
||||
name_prefix: String.duplicate("A", 65),
|
||||
tags: Enum.map(1..129, &Integer.to_string/1)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
tokens: ["can't be blank"],
|
||||
name_prefix: ["should be at most 64 character(s)"],
|
||||
tags: ["should have at most 128 item(s)"]
|
||||
}
|
||||
|
||||
attrs = %{tags: ["A", "B", "A"]}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "should not contain duplicates" in errors_on(changeset).tags
|
||||
|
||||
attrs = %{tags: [String.duplicate("A", 65)]}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "should be at most 64 characters long" in errors_on(changeset).tags
|
||||
|
||||
GatewaysFixtures.create_group(account: account, name_prefix: "foo")
|
||||
attrs = %{name_prefix: "foo", tokens: [%{}]}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name_prefix
|
||||
end
|
||||
|
||||
test "creates a group", %{subject: subject} do
|
||||
attrs = %{
|
||||
name_prefix: "foo",
|
||||
tags: ["bar"],
|
||||
tokens: [%{}]
|
||||
}
|
||||
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name_prefix == "foo"
|
||||
assert group.tags == ["bar"]
|
||||
assert [%Gateways.Token{}] = group.tokens
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert create_group(%{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_group/1" do
|
||||
test "returns changeset with given changes" do
|
||||
group = GatewaysFixtures.create_group()
|
||||
|
||||
group_attrs =
|
||||
GatewaysFixtures.group_attrs()
|
||||
|> Map.delete(:tokens)
|
||||
|
||||
assert changeset = change_group(group, group_attrs)
|
||||
assert changeset.valid?
|
||||
assert changeset.changes == %{name_prefix: group_attrs.name_prefix, tags: group_attrs.tags}
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_group/3" do
|
||||
test "does not allow to reset required fields to empty values", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = GatewaysFixtures.create_group()
|
||||
attrs = %{name_prefix: nil}
|
||||
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{name_prefix: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
|
||||
attrs = %{
|
||||
name_prefix: String.duplicate("A", 65),
|
||||
tags: Enum.map(1..129, &Integer.to_string/1)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name_prefix: ["should be at most 64 character(s)"],
|
||||
tags: ["should have at most 128 item(s)"]
|
||||
}
|
||||
|
||||
attrs = %{tags: ["A", "B", "A"]}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert "should not contain duplicates" in errors_on(changeset).tags
|
||||
|
||||
attrs = %{tags: [String.duplicate("A", 65)]}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert "should be at most 64 characters long" in errors_on(changeset).tags
|
||||
|
||||
GatewaysFixtures.create_group(account: account, name_prefix: "foo")
|
||||
attrs = %{name_prefix: "foo"}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name_prefix
|
||||
end
|
||||
|
||||
test "updates a group", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
|
||||
attrs = %{
|
||||
name_prefix: "foo",
|
||||
tags: ["bar"]
|
||||
}
|
||||
|
||||
assert {:ok, group} = update_group(group, attrs, subject)
|
||||
assert group.name_prefix == "foo"
|
||||
assert group.tags == ["bar"]
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_group(group, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_group/2" do
|
||||
test "returns error on state conflict", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert delete_group(deleted, subject) == {:error, :not_found}
|
||||
assert delete_group(group, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "deletes groups", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "deletes all tokens when group is deleted", %{account: account, subject: subject} do
|
||||
group = GatewaysFixtures.create_group(account: account)
|
||||
GatewaysFixtures.create_token(group: group)
|
||||
GatewaysFixtures.create_token(group: [account: account])
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert deleted.deleted_at
|
||||
|
||||
tokens =
|
||||
Gateways.Token
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn token -> token.group_id == group.id end)
|
||||
|
||||
assert Enum.all?(tokens, & &1.deleted_at)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = GatewaysFixtures.create_group()
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_group(group, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "use_token_by_id_and_secret/2" do
|
||||
test "returns token when secret is valid" do
|
||||
token = GatewaysFixtures.create_token()
|
||||
assert {:ok, token} = use_token_by_id_and_secret(token.id, token.value)
|
||||
assert is_nil(token.value)
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# assert is_nil(token.hash)
|
||||
# refute is_nil(token.deleted_at)
|
||||
end
|
||||
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# test "returns error when secret was already used" do
|
||||
# token = GatewaysFixtures.create_token()
|
||||
|
||||
# assert {:ok, _token} = use_token_by_id_and_secret(token.id, token.value)
|
||||
# assert use_token_by_id_and_secret(token.id, token.value) == {:error, :not_found}
|
||||
# end
|
||||
|
||||
test "returns error when id is invalid" do
|
||||
assert use_token_by_id_and_secret("foo", "bar") == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when id is not found" do
|
||||
assert use_token_by_id_and_secret(Ecto.UUID.generate(), "bar") == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when secret is invalid" do
|
||||
token = GatewaysFixtures.create_token()
|
||||
assert use_token_by_id_and_secret(token.id, "bar") == {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_gateway_by_id/2" do
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_gateway_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return gateways from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway()
|
||||
assert fetch_gateway_by_id(gateway.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted gateways", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
gateway =
|
||||
GatewaysFixtures.create_gateway(account: account)
|
||||
|> GatewaysFixtures.delete_gateway()
|
||||
|
||||
assert fetch_gateway_by_id(gateway.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns gateway by id", %{account: account, subject: subject} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
assert fetch_gateway_by_id(gateway.id, subject) == {:ok, gateway}
|
||||
end
|
||||
|
||||
test "returns gateway that belongs to another user", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
assert fetch_gateway_by_id(gateway.id, subject) == {:ok, gateway}
|
||||
end
|
||||
|
||||
test "returns error when gateway does not exist", %{subject: subject} do
|
||||
assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view gateways", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_gateway_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_gateways/1" do
|
||||
test "returns empty list when there are no gateways", %{subject: subject} do
|
||||
assert list_gateways(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted gateways", %{
|
||||
subject: subject
|
||||
} do
|
||||
GatewaysFixtures.create_gateway()
|
||||
|> GatewaysFixtures.delete_gateway()
|
||||
|
||||
assert list_gateways(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all gateways", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
GatewaysFixtures.create_gateway(account: account)
|
||||
GatewaysFixtures.create_gateway(account: account)
|
||||
GatewaysFixtures.create_gateway()
|
||||
|
||||
assert {:ok, gateways} = list_gateways(subject)
|
||||
assert length(gateways) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage gateways", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_gateways(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_gateway/1" do
|
||||
test "returns changeset with given changes" do
|
||||
gateway = GatewaysFixtures.create_gateway()
|
||||
gateway_attrs = GatewaysFixtures.gateway_attrs()
|
||||
|
||||
assert changeset = change_gateway(gateway, gateway_attrs)
|
||||
assert %Ecto.Changeset{data: %Domain.Gateways.Gateway{}} = changeset
|
||||
|
||||
assert changeset.changes == %{name_suffix: gateway_attrs.name_suffix}
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_gateway/3" do
|
||||
setup context do
|
||||
token = GatewaysFixtures.create_token(account: context.account)
|
||||
|
||||
context
|
||||
|> Map.put(:token, token)
|
||||
|> Map.put(:group, token.group)
|
||||
end
|
||||
|
||||
test "returns errors on invalid attrs", %{
|
||||
token: token
|
||||
} do
|
||||
attrs = %{
|
||||
external_id: nil,
|
||||
public_key: "x",
|
||||
last_seen_user_agent: "foo",
|
||||
last_seen_remote_ip: {256, 0, 0, 0}
|
||||
}
|
||||
|
||||
assert {:error, changeset} = upsert_gateway(token, attrs)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
public_key: ["should be 44 character(s)", "must be a base64-encoded string"],
|
||||
external_id: ["can't be blank"],
|
||||
last_seen_user_agent: ["is invalid"]
|
||||
}
|
||||
end
|
||||
|
||||
test "allows creating gateway with just required attributes", %{
|
||||
token: token
|
||||
} do
|
||||
attrs =
|
||||
GatewaysFixtures.gateway_attrs()
|
||||
|> Map.delete(:name)
|
||||
|
||||
assert {:ok, gateway} = upsert_gateway(token, attrs)
|
||||
|
||||
assert gateway.name_suffix
|
||||
assert gateway.public_key == attrs.public_key
|
||||
|
||||
assert gateway.token_id == token.id
|
||||
assert gateway.group_id == token.group_id
|
||||
|
||||
refute is_nil(gateway.ipv4)
|
||||
refute is_nil(gateway.ipv6)
|
||||
|
||||
assert gateway.last_seen_remote_ip == attrs.last_seen_remote_ip
|
||||
assert gateway.last_seen_user_agent == attrs.last_seen_user_agent
|
||||
assert gateway.last_seen_version == "0.7.412"
|
||||
assert gateway.last_seen_at
|
||||
end
|
||||
|
||||
test "updates gateway when it already exists", %{
|
||||
token: token
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway(token: token)
|
||||
|
||||
attrs =
|
||||
GatewaysFixtures.gateway_attrs(
|
||||
external_id: gateway.external_id,
|
||||
last_seen_remote_ip: {100, 64, 100, 101},
|
||||
last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411"
|
||||
)
|
||||
|
||||
assert {:ok, updated_gateway} = upsert_gateway(token, attrs)
|
||||
|
||||
assert Repo.aggregate(Gateways.Gateway, :count, :id) == 1
|
||||
|
||||
assert updated_gateway.name_suffix
|
||||
assert updated_gateway.last_seen_remote_ip.address == attrs.last_seen_remote_ip
|
||||
assert updated_gateway.last_seen_remote_ip != gateway.last_seen_remote_ip
|
||||
assert updated_gateway.last_seen_user_agent == attrs.last_seen_user_agent
|
||||
assert updated_gateway.last_seen_user_agent != gateway.last_seen_user_agent
|
||||
assert updated_gateway.last_seen_version == "0.7.411"
|
||||
assert updated_gateway.last_seen_at
|
||||
assert updated_gateway.last_seen_at != gateway.last_seen_at
|
||||
assert updated_gateway.public_key != gateway.public_key
|
||||
assert updated_gateway.public_key == attrs.public_key
|
||||
|
||||
assert updated_gateway.token_id == token.id
|
||||
assert updated_gateway.group_id == token.group_id
|
||||
|
||||
assert updated_gateway.ipv4 == gateway.ipv4
|
||||
assert updated_gateway.ipv6 == gateway.ipv6
|
||||
end
|
||||
|
||||
test "does not reserve additional addresses on update", %{
|
||||
token: token
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway(token: token)
|
||||
|
||||
attrs =
|
||||
GatewaysFixtures.gateway_attrs(
|
||||
external_id: gateway.external_id,
|
||||
last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411",
|
||||
last_seen_remote_ip: %Postgrex.INET{address: {100, 64, 100, 100}}
|
||||
)
|
||||
|
||||
assert {:ok, updated_gateway} = upsert_gateway(token, attrs)
|
||||
|
||||
addresses =
|
||||
Domain.Network.Address
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %Domain.Network.Address{address: address, type: type} ->
|
||||
%{address: address, type: type}
|
||||
end)
|
||||
|
||||
assert length(addresses) == 2
|
||||
assert %{address: updated_gateway.ipv4, type: :ipv4} in addresses
|
||||
assert %{address: updated_gateway.ipv6, type: :ipv6} in addresses
|
||||
end
|
||||
|
||||
test "does not allow to reuse IP addresses", %{
|
||||
account: account,
|
||||
token: token
|
||||
} do
|
||||
attrs = GatewaysFixtures.gateway_attrs()
|
||||
assert {:ok, gateway} = upsert_gateway(token, attrs)
|
||||
|
||||
addresses =
|
||||
Domain.Network.Address
|
||||
|> Repo.all()
|
||||
|> Enum.map(fn %Domain.Network.Address{address: address, type: type} ->
|
||||
%{address: address, type: type}
|
||||
end)
|
||||
|
||||
assert length(addresses) == 2
|
||||
assert %{address: gateway.ipv4, type: :ipv4} in addresses
|
||||
assert %{address: gateway.ipv6, type: :ipv6} in addresses
|
||||
|
||||
assert_raise Ecto.ConstraintError, fn ->
|
||||
NetworkFixtures.create_address(account: account, address: gateway.ipv4)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_gateway/3" do
|
||||
test "updates gateways", %{account: account, subject: subject} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
attrs = %{name_suffix: "Foo"}
|
||||
|
||||
assert {:ok, gateway} = update_gateway(gateway, attrs, subject)
|
||||
|
||||
assert gateway.name_suffix == attrs.name_suffix
|
||||
end
|
||||
|
||||
test "does not allow to reset required fields to empty values", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
attrs = %{name_suffix: nil}
|
||||
|
||||
assert {:error, changeset} = update_gateway(gateway, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{name_suffix: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
|
||||
attrs = %{
|
||||
name_suffix: String.duplicate("a", 256)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = update_gateway(gateway, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name_suffix: ["should be at most 8 character(s)"]
|
||||
}
|
||||
end
|
||||
|
||||
test "ignores updates for any field except name", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
|
||||
fields = Gateways.Gateway.__schema__(:fields) -- [:name_suffix]
|
||||
value = -1
|
||||
|
||||
for field <- fields do
|
||||
assert {:ok, updated_gateway} = update_gateway(gateway, %{field => value}, subject)
|
||||
assert updated_gateway == gateway
|
||||
end
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to update gateways", %{
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway()
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_gateway(gateway, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_gateway/2" do
|
||||
test "returns error on state conflict", %{account: account, subject: subject} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_gateway(gateway, subject)
|
||||
assert delete_gateway(deleted, subject) == {:error, :not_found}
|
||||
assert delete_gateway(gateway, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "deletes gateways", %{account: account, subject: subject} do
|
||||
gateway = GatewaysFixtures.create_gateway(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_gateway(gateway, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete gateways", %{
|
||||
subject: subject
|
||||
} do
|
||||
gateway = GatewaysFixtures.create_gateway()
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_gateway(gateway, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Gateways.Authorizer.manage_gateways_permission()]]}}
|
||||
end
|
||||
end
|
||||
end
|
||||
171
apps/domain/test/domain/network/address/query_test.exs
Normal file
171
apps/domain/test/domain/network/address/query_test.exs
Normal file
@@ -0,0 +1,171 @@
|
||||
defmodule Domain.Network.Address.QueryTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Network.Address.Query
|
||||
alias Domain.{AccountsFixtures, NetworkFixtures}
|
||||
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
%{account: account}
|
||||
end
|
||||
|
||||
describe "next_available_address/3" do
|
||||
test "selects available IPv4 in CIDR range at the offset", %{account: account} do
|
||||
cidr = string_to_cidr("10.3.2.0/29")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 2, 3}}
|
||||
end
|
||||
|
||||
test "skips addresses that are already taken for an account", %{account: account} do
|
||||
cidr = string_to_cidr("10.3.3.0/29")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.3.3")
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 4}}
|
||||
end
|
||||
|
||||
test "addresses are unique per account", %{account: account} do
|
||||
cidr = string_to_cidr("10.3.3.0/29")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
NetworkFixtures.create_address(address: "10.3.3.3")
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 3, 3}}
|
||||
end
|
||||
|
||||
test "forward scans available address after offset it it's assigned to a device", %{
|
||||
account: account
|
||||
} do
|
||||
cidr = string_to_cidr("10.3.4.0/29")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.4.3")
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.4.4")
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 5}}
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.4.5")
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 4, 6}}
|
||||
end
|
||||
|
||||
test "backward scans available address if forward scan found not available IPs", %{
|
||||
account: account
|
||||
} do
|
||||
cidr = string_to_cidr("10.3.5.0/29")
|
||||
offset = 5
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.5.5")
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.5.6")
|
||||
# Notice: end of range is 10.3.5.7
|
||||
# but it's a broadcast address that we don't allow to assign
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 4}}
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.5.4")
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {10, 3, 5, 3}}
|
||||
end
|
||||
|
||||
test "selects nothing when CIDR range is exhausted", %{account: account} do
|
||||
cidr = string_to_cidr("10.3.6.0/30")
|
||||
offset = 1
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.6.1")
|
||||
NetworkFixtures.create_address(account: account, address: "10.3.6.2")
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
assert is_nil(Repo.one(queryable))
|
||||
|
||||
# Notice: real start of range is 10.3.6.0,
|
||||
# but it's a typical gateway address that we don't allow to assign
|
||||
end
|
||||
|
||||
test "prevents two concurrent transactions from acquiring the same address", %{
|
||||
account: account
|
||||
} do
|
||||
cidr = string_to_cidr("10.3.7.0/29")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
test_pid = self()
|
||||
|
||||
spawn(fn ->
|
||||
Ecto.Adapters.SQL.Sandbox.unboxed_run(Repo, fn ->
|
||||
Repo.transaction(fn ->
|
||||
ip = Repo.one(queryable)
|
||||
send(test_pid, {:ip, ip})
|
||||
Process.sleep(200)
|
||||
end)
|
||||
end)
|
||||
end)
|
||||
|
||||
ip1 = Repo.one(queryable)
|
||||
assert_receive {:ip, ip2}, 1_000
|
||||
|
||||
assert Enum.sort([ip1, ip2]) ==
|
||||
Enum.sort([
|
||||
%Postgrex.INET{address: {10, 3, 7, 3}},
|
||||
%Postgrex.INET{address: {10, 3, 7, 4}}
|
||||
])
|
||||
end
|
||||
|
||||
test "selects available IPv6 in CIDR range at the offset", %{account: account} do
|
||||
cidr = string_to_cidr("fd00::3:3:0/120")
|
||||
offset = 3
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 3, 3, 3}}
|
||||
end
|
||||
|
||||
test "selects available IPv6 at end of CIDR range", %{account: account} do
|
||||
cidr = string_to_cidr("fd00::/106")
|
||||
offset = 4_194_304
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 63, 65_535}}
|
||||
end
|
||||
|
||||
test "works when offset is out of IPv6 CIDR range", %{account: account} do
|
||||
cidr = string_to_cidr("fd00::/106")
|
||||
offset = 4_194_305
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{address: {64_768, 0, 0, 0, 0, 0, 64, 0}}
|
||||
end
|
||||
|
||||
test "works when netmask allows a large number of devices", %{account: account} do
|
||||
cidr = string_to_cidr("fd00::/70")
|
||||
offset = 9_223_372_036_854_775_807
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
|
||||
assert Repo.one(queryable) == %Postgrex.INET{
|
||||
address: {64_768, 0, 0, 0, 32_767, 65_535, 65_535, 65_534}
|
||||
}
|
||||
end
|
||||
|
||||
test "selects nothing when IPv6 CIDR range is exhausted", %{account: account} do
|
||||
cidr = string_to_cidr("fd00::3:2:0/126")
|
||||
offset = 3
|
||||
|
||||
NetworkFixtures.create_address(account: account, address: "fd00::3:2:2")
|
||||
|
||||
queryable = next_available_address(account.id, cidr, offset)
|
||||
assert is_nil(Repo.one(queryable))
|
||||
end
|
||||
end
|
||||
|
||||
defp string_to_cidr(string) do
|
||||
{:ok, inet} = Domain.Types.CIDR.cast(string)
|
||||
inet
|
||||
end
|
||||
end
|
||||
45
apps/domain/test/domain/network_test.exs
Normal file
45
apps/domain/test/domain/network_test.exs
Normal file
@@ -0,0 +1,45 @@
|
||||
defmodule Domain.NetworkTest do
|
||||
use Domain.DataCase, async: true
|
||||
alias Domain.AccountsFixtures
|
||||
import Domain.Network
|
||||
|
||||
describe "fetch_next_available_address!/2" do
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
%{account: account}
|
||||
end
|
||||
|
||||
test "raises when called outside of transaction", %{account: account} do
|
||||
message = "fetch_next_available_address/1 must be called inside a transaction"
|
||||
|
||||
assert_raise RuntimeError, message, fn ->
|
||||
fetch_next_available_address!(account, :ipv4)
|
||||
end
|
||||
end
|
||||
|
||||
test "raises when CIDR range is exhausted", %{account: account} do
|
||||
cidrs = %{
|
||||
test: %Postgrex.INET{address: {101, 64, 0, 0}, netmask: 32}
|
||||
}
|
||||
|
||||
Repo.transaction(fn ->
|
||||
assert_raise Ecto.NoResultsError, fn ->
|
||||
fetch_next_available_address!(account, :test, cidrs: cidrs)
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
test "returns next available IPv4 address", %{account: account} do
|
||||
cidrs = %{
|
||||
test: %Postgrex.INET{address: {102, 64, 0, 0}, netmask: 30}
|
||||
}
|
||||
|
||||
Repo.transaction(fn ->
|
||||
assert %Postgrex.INET{address: {102, 64, 0, last}, netmask: nil} =
|
||||
fetch_next_available_address!(account, :test, cidrs: cidrs)
|
||||
|
||||
assert last in 1..2
|
||||
end)
|
||||
end
|
||||
end
|
||||
end
|
||||
543
apps/domain/test/domain/relays_test.exs
Normal file
543
apps/domain/test/domain/relays_test.exs
Normal file
@@ -0,0 +1,543 @@
|
||||
defmodule Domain.RelaysTest do
|
||||
use Domain.DataCase, async: true
|
||||
import Domain.Relays
|
||||
alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures, RelaysFixtures}
|
||||
alias Domain.Relays
|
||||
|
||||
setup do
|
||||
account = AccountsFixtures.create_account()
|
||||
user = UsersFixtures.create_user_with_role(:admin, account: account)
|
||||
subject = SubjectFixtures.create_subject(user)
|
||||
|
||||
%{
|
||||
account: account,
|
||||
user: user,
|
||||
subject: subject
|
||||
}
|
||||
end
|
||||
|
||||
describe "fetch_group_by_id/2" do
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_group_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = RelaysFixtures.create_group()
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group =
|
||||
RelaysFixtures.create_group(account: account)
|
||||
|> RelaysFixtures.delete_group()
|
||||
|
||||
assert fetch_group_by_id(group.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns group by id", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns group that belongs to another user", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
assert {:ok, fetched_group} = fetch_group_by_id(group.id, subject)
|
||||
assert fetched_group.id == group.id
|
||||
end
|
||||
|
||||
test "returns error when group does not exist", %{subject: subject} do
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_group_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_groups/1" do
|
||||
test "returns empty list when there are no groups", %{subject: subject} do
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list groups from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
RelaysFixtures.create_group()
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
RelaysFixtures.create_group(account: account)
|
||||
|> RelaysFixtures.delete_group()
|
||||
|
||||
assert list_groups(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
RelaysFixtures.create_group(account: account)
|
||||
RelaysFixtures.create_group(account: account)
|
||||
RelaysFixtures.create_group()
|
||||
|
||||
assert {:ok, groups} = list_groups(subject)
|
||||
assert length(groups) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_groups(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "new_group/0" do
|
||||
test "returns group changeset" do
|
||||
assert %Ecto.Changeset{data: %Relays.Group{}, changes: changes} = new_group()
|
||||
assert Map.has_key?(changes, :name)
|
||||
assert Enum.count(changes) == 1
|
||||
end
|
||||
end
|
||||
|
||||
describe "create_group/2" do
|
||||
test "returns error on empty attrs", %{subject: subject} do
|
||||
assert {:error, changeset} = create_group(%{}, subject)
|
||||
assert errors_on(changeset) == %{tokens: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
attrs = %{
|
||||
name: String.duplicate("A", 65)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
tokens: ["can't be blank"],
|
||||
name: ["should be at most 64 character(s)"]
|
||||
}
|
||||
|
||||
RelaysFixtures.create_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo", tokens: [%{}]}
|
||||
assert {:error, changeset} = create_group(attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "creates a group", %{subject: subject} do
|
||||
attrs = %{
|
||||
name: "foo",
|
||||
tokens: [%{}]
|
||||
}
|
||||
|
||||
assert {:ok, group} = create_group(attrs, subject)
|
||||
assert group.id
|
||||
assert group.name == "foo"
|
||||
assert [%Relays.Token{}] = group.tokens
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert create_group(%{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "change_group/1" do
|
||||
test "returns changeset with given changes" do
|
||||
group = RelaysFixtures.create_group()
|
||||
|
||||
group_attrs =
|
||||
RelaysFixtures.group_attrs()
|
||||
|> Map.delete(:tokens)
|
||||
|
||||
assert changeset = change_group(group, group_attrs)
|
||||
assert changeset.valid?
|
||||
assert changeset.changes == %{name: group_attrs.name}
|
||||
end
|
||||
end
|
||||
|
||||
describe "update_group/3" do
|
||||
test "does not allow to reset required fields to empty values", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = RelaysFixtures.create_group()
|
||||
attrs = %{name: nil}
|
||||
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{name: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
|
||||
attrs = %{
|
||||
name: String.duplicate("A", 65)
|
||||
}
|
||||
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
|
||||
assert errors_on(changeset) == %{
|
||||
name: ["should be at most 64 character(s)"]
|
||||
}
|
||||
|
||||
RelaysFixtures.create_group(account: account, name: "foo")
|
||||
attrs = %{name: "foo"}
|
||||
assert {:error, changeset} = update_group(group, attrs, subject)
|
||||
assert "has already been taken" in errors_on(changeset).name
|
||||
end
|
||||
|
||||
test "updates a group", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
|
||||
attrs = %{
|
||||
name: "foo"
|
||||
}
|
||||
|
||||
assert {:ok, group} = update_group(group, attrs, subject)
|
||||
assert group.name == "foo"
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage groups", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert update_group(group, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_group/2" do
|
||||
test "returns error on state conflict", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert delete_group(deleted, subject) == {:error, :not_found}
|
||||
assert delete_group(group, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "deletes groups", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "deletes all tokens when group is deleted", %{account: account, subject: subject} do
|
||||
group = RelaysFixtures.create_group(account: account)
|
||||
RelaysFixtures.create_token(group: group)
|
||||
RelaysFixtures.create_token(group: [account: account])
|
||||
|
||||
assert {:ok, deleted} = delete_group(group, subject)
|
||||
assert deleted.deleted_at
|
||||
|
||||
tokens =
|
||||
Relays.Token
|
||||
|> Repo.all()
|
||||
|> Enum.filter(fn token -> token.group_id == group.id end)
|
||||
|
||||
assert Enum.all?(tokens, & &1.deleted_at)
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete groups", %{
|
||||
subject: subject
|
||||
} do
|
||||
group = RelaysFixtures.create_group()
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_group(group, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "use_token_by_id_and_secret/2" do
|
||||
test "returns token when secret is valid" do
|
||||
token = RelaysFixtures.create_token()
|
||||
assert {:ok, token} = use_token_by_id_and_secret(token.id, token.value)
|
||||
assert is_nil(token.value)
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# assert is_nil(token.hash)
|
||||
# refute is_nil(token.deleted_at)
|
||||
end
|
||||
|
||||
# TODO: While we don't have token rotation implemented, the tokens are all multi-use
|
||||
# test "returns error when secret was already used" do
|
||||
# token = RelaysFixtures.create_token()
|
||||
|
||||
# assert {:ok, _token} = use_token_by_id_and_secret(token.id, token.value)
|
||||
# assert use_token_by_id_and_secret(token.id, token.value) == {:error, :not_found}
|
||||
# end
|
||||
|
||||
test "returns error when id is invalid" do
|
||||
assert use_token_by_id_and_secret("foo", "bar") == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when id is not found" do
|
||||
assert use_token_by_id_and_secret(Ecto.UUID.generate(), "bar") == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when secret is invalid" do
|
||||
token = RelaysFixtures.create_token()
|
||||
assert use_token_by_id_and_secret(token.id, "bar") == {:error, :not_found}
|
||||
end
|
||||
end
|
||||
|
||||
describe "fetch_relay_by_id/2" do
|
||||
test "returns error when UUID is invalid", %{subject: subject} do
|
||||
assert fetch_relay_by_id("foo", subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return relays from other accounts", %{
|
||||
subject: subject
|
||||
} do
|
||||
relay = RelaysFixtures.create_relay()
|
||||
assert fetch_relay_by_id(relay.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "does not return deleted relays", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
relay =
|
||||
RelaysFixtures.create_relay(account: account)
|
||||
|> RelaysFixtures.delete_relay()
|
||||
|
||||
assert fetch_relay_by_id(relay.id, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns relay by id", %{account: account, subject: subject} do
|
||||
relay = RelaysFixtures.create_relay(account: account)
|
||||
assert fetch_relay_by_id(relay.id, subject) == {:ok, relay}
|
||||
end
|
||||
|
||||
test "returns relay that belongs to another user", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
relay = RelaysFixtures.create_relay(account: account)
|
||||
assert fetch_relay_by_id(relay.id, subject) == {:ok, relay}
|
||||
end
|
||||
|
||||
test "returns error when relay does not exist", %{subject: subject} do
|
||||
assert fetch_relay_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error, :not_found}
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to view relays", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert fetch_relay_by_id(Ecto.UUID.generate(), subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "list_relays/1" do
|
||||
test "returns empty list when there are no relays", %{subject: subject} do
|
||||
assert list_relays(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "does not list deleted relays", %{
|
||||
subject: subject
|
||||
} do
|
||||
RelaysFixtures.create_relay()
|
||||
|> RelaysFixtures.delete_relay()
|
||||
|
||||
assert list_relays(subject) == {:ok, []}
|
||||
end
|
||||
|
||||
test "returns all relays", %{
|
||||
account: account,
|
||||
subject: subject
|
||||
} do
|
||||
RelaysFixtures.create_relay(account: account)
|
||||
RelaysFixtures.create_relay(account: account)
|
||||
RelaysFixtures.create_relay()
|
||||
|
||||
assert {:ok, relays} = list_relays(subject)
|
||||
assert length(relays) == 2
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to manage relays", %{
|
||||
subject: subject
|
||||
} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert list_relays(subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
|
||||
describe "upsert_relay/3" do
|
||||
setup context do
|
||||
token = RelaysFixtures.create_token(account: context.account)
|
||||
|
||||
context
|
||||
|> Map.put(:token, token)
|
||||
|> Map.put(:group, token.group)
|
||||
end
|
||||
|
||||
test "returns errors on invalid attrs", %{
|
||||
token: token
|
||||
} do
|
||||
attrs = %{
|
||||
ipv4: "1.1.1.256",
|
||||
ipv6: "fd01::10000",
|
||||
last_seen_user_agent: "foo",
|
||||
last_seen_remote_ip: {256, 0, 0, 0}
|
||||
}
|
||||
|
||||
assert {:error, changeset} = upsert_relay(token, attrs)
|
||||
|
||||
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"]
|
||||
}
|
||||
end
|
||||
|
||||
test "allows creating relay with just required attributes", %{
|
||||
token: token
|
||||
} do
|
||||
attrs =
|
||||
RelaysFixtures.relay_attrs()
|
||||
|> Map.delete(:name)
|
||||
|
||||
assert {:ok, relay} = upsert_relay(token, attrs)
|
||||
|
||||
assert relay.token_id == token.id
|
||||
assert relay.group_id == token.group_id
|
||||
|
||||
assert relay.ipv4.address == attrs.ipv4
|
||||
assert relay.ipv6.address == attrs.ipv6
|
||||
|
||||
assert relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip
|
||||
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 Repo.aggregate(Domain.Network.Address, :count) == 0
|
||||
end
|
||||
|
||||
test "allows creating ipv6-only relays", %{
|
||||
token: token
|
||||
} do
|
||||
attrs =
|
||||
RelaysFixtures.relay_attrs()
|
||||
|> Map.drop([:name, :ipv4])
|
||||
|
||||
assert {:ok, _relay} = upsert_relay(token, attrs)
|
||||
assert {:ok, _relay} = upsert_relay(token, attrs)
|
||||
|
||||
assert Repo.one(Relays.Relay)
|
||||
end
|
||||
|
||||
test "updates relay when it already exists", %{
|
||||
token: token
|
||||
} do
|
||||
relay = RelaysFixtures.create_relay(token: token)
|
||||
|
||||
attrs =
|
||||
RelaysFixtures.relay_attrs(
|
||||
ipv4: relay.ipv4,
|
||||
last_seen_remote_ip: relay.ipv4,
|
||||
last_seen_user_agent: "iOS/12.5 (iPhone) connlib/0.7.411"
|
||||
)
|
||||
|
||||
assert {:ok, updated_relay} = upsert_relay(token, attrs)
|
||||
|
||||
assert Repo.aggregate(Relays.Relay, :count, :id) == 1
|
||||
|
||||
assert updated_relay.last_seen_remote_ip.address == attrs.last_seen_remote_ip.address
|
||||
assert updated_relay.last_seen_user_agent == attrs.last_seen_user_agent
|
||||
assert updated_relay.last_seen_user_agent != relay.last_seen_user_agent
|
||||
assert updated_relay.last_seen_version == "0.7.411"
|
||||
assert updated_relay.last_seen_at
|
||||
assert updated_relay.last_seen_at != relay.last_seen_at
|
||||
|
||||
assert updated_relay.token_id == token.id
|
||||
assert updated_relay.group_id == token.group_id
|
||||
|
||||
assert updated_relay.ipv4 == relay.ipv4
|
||||
assert updated_relay.ipv6.address == attrs.ipv6
|
||||
assert updated_relay.ipv6 != relay.ipv6
|
||||
|
||||
assert Repo.aggregate(Domain.Network.Address, :count) == 0
|
||||
end
|
||||
end
|
||||
|
||||
describe "delete_relay/2" do
|
||||
test "returns error on state conflict", %{account: account, subject: subject} do
|
||||
relay = RelaysFixtures.create_relay(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_relay(relay, subject)
|
||||
assert delete_relay(deleted, subject) == {:error, :not_found}
|
||||
assert delete_relay(relay, subject) == {:error, :not_found}
|
||||
end
|
||||
|
||||
test "deletes relays", %{account: account, subject: subject} do
|
||||
relay = RelaysFixtures.create_relay(account: account)
|
||||
|
||||
assert {:ok, deleted} = delete_relay(relay, subject)
|
||||
assert deleted.deleted_at
|
||||
end
|
||||
|
||||
test "returns error when subject has no permission to delete relays", %{
|
||||
subject: subject
|
||||
} do
|
||||
relay = RelaysFixtures.create_relay()
|
||||
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert delete_relay(relay, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Relays.Authorizer.manage_relays_permission()]]}}
|
||||
end
|
||||
end
|
||||
end
|
||||
@@ -312,19 +312,27 @@ defmodule Domain.UsersTest do
|
||||
describe "create_user/3" do
|
||||
setup do
|
||||
subject = SubjectFixtures.create_subject()
|
||||
%{subject: subject}
|
||||
%{subject: subject, account: subject.account}
|
||||
end
|
||||
|
||||
test "returns changeset error when required attrs are missing", %{subject: subject} do
|
||||
assert {:error, changeset} = create_user(:unprivileged, %{}, subject)
|
||||
test "returns changeset error when required attrs are missing", %{
|
||||
subject: subject,
|
||||
account: account
|
||||
} do
|
||||
assert {:error, changeset} = create_user(account, :unprivileged, %{}, subject)
|
||||
refute changeset.valid?
|
||||
|
||||
assert errors_on(changeset) == %{email: ["can't be blank"]}
|
||||
end
|
||||
|
||||
test "returns error on invalid attrs", %{subject: subject} do
|
||||
test "returns error on invalid attrs", %{subject: subject, account: account} do
|
||||
assert {:error, changeset} =
|
||||
create_user(:unprivileged, %{email: "invalid_email", password: "short"}, subject)
|
||||
create_user(
|
||||
account,
|
||||
:unprivileged,
|
||||
%{email: "invalid_email", password: "short"},
|
||||
subject
|
||||
)
|
||||
|
||||
refute changeset.valid?
|
||||
|
||||
@@ -336,6 +344,7 @@ defmodule Domain.UsersTest do
|
||||
|
||||
assert {:error, changeset} =
|
||||
create_user(
|
||||
account,
|
||||
:unprivileged,
|
||||
%{email: "invalid_email", password: String.duplicate("A", 65)},
|
||||
subject
|
||||
@@ -345,16 +354,20 @@ defmodule Domain.UsersTest do
|
||||
assert "should be at most 64 character(s)" in errors_on(changeset).password
|
||||
|
||||
assert {:error, changeset} =
|
||||
create_user(:unprivileged, %{email: String.duplicate(" ", 18)}, subject)
|
||||
create_user(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} do
|
||||
test "requires password confirmation to match the password", %{
|
||||
subject: subject,
|
||||
account: account
|
||||
} do
|
||||
assert {:error, changeset} =
|
||||
create_user(
|
||||
account,
|
||||
:unprivileged,
|
||||
%{password: "foo", password_confirmation: "bar"},
|
||||
subject
|
||||
@@ -364,6 +377,7 @@ defmodule Domain.UsersTest do
|
||||
|
||||
assert {:error, changeset} =
|
||||
create_user(
|
||||
account,
|
||||
:unprivileged,
|
||||
%{
|
||||
password: "password1234",
|
||||
@@ -375,33 +389,33 @@ defmodule Domain.UsersTest do
|
||||
refute Map.has_key?(errors_on(changeset), :password_confirmation)
|
||||
end
|
||||
|
||||
test "returns error when email is already taken", %{subject: subject} do
|
||||
test "returns error when email is already taken", %{subject: subject, account: account} do
|
||||
attrs = UsersFixtures.user_attrs()
|
||||
assert {:ok, _user} = create_user(:unprivileged, attrs, subject)
|
||||
assert {:error, changeset} = create_user(:unprivileged, attrs, subject)
|
||||
assert {:ok, _user} = create_user(account, :unprivileged, attrs, subject)
|
||||
assert {:error, changeset} = create_user(account, :unprivileged, attrs, subject)
|
||||
refute changeset.valid?
|
||||
assert "has already been taken" in errors_on(changeset).email
|
||||
end
|
||||
|
||||
test "returns error when role is invalid", %{subject: subject} do
|
||||
test "returns error when role is invalid", %{subject: subject, account: account} do
|
||||
attrs = UsersFixtures.user_attrs()
|
||||
|
||||
assert_raise Ecto.ChangeError, fn ->
|
||||
create_user(:foo, attrs, subject)
|
||||
create_user(account, :foo, attrs, subject)
|
||||
end
|
||||
end
|
||||
|
||||
test "creates a user in given role", %{subject: subject} do
|
||||
test "creates a user in given role", %{subject: subject, account: account} do
|
||||
for role <- [:admin, :unprivileged] do
|
||||
attrs = UsersFixtures.user_attrs()
|
||||
assert {:ok, user} = create_user(role, attrs, subject)
|
||||
assert {:ok, user} = create_user(account, role, attrs, subject)
|
||||
assert user.role == role
|
||||
end
|
||||
end
|
||||
|
||||
test "creates an unprivileged user", %{subject: subject} do
|
||||
test "creates an unprivileged user", %{subject: subject, account: account} do
|
||||
attrs = UsersFixtures.user_attrs()
|
||||
assert {:ok, user} = create_user(:unprivileged, attrs, subject)
|
||||
assert {:ok, user} = create_user(account, :unprivileged, attrs, subject)
|
||||
assert user.role == :unprivileged
|
||||
assert user.email == attrs.email
|
||||
|
||||
@@ -416,31 +430,31 @@ defmodule Domain.UsersTest do
|
||||
assert is_nil(user.sign_in_token_created_at)
|
||||
end
|
||||
|
||||
test "allows creating a user without password", %{subject: subject} do
|
||||
test "allows creating a user without password", %{subject: subject, account: account} do
|
||||
email = UsersFixtures.user_attrs().email
|
||||
attrs = %{email: email, password: nil, password_confirmation: nil}
|
||||
assert {:ok, user} = create_user(:unprivileged, attrs, subject)
|
||||
assert {:ok, user} = create_user(account, :unprivileged, attrs, subject)
|
||||
assert is_nil(user.password_hash)
|
||||
|
||||
email = UsersFixtures.user_attrs().email
|
||||
attrs = %{email: email, password: "", password_confirmation: ""}
|
||||
assert {:ok, user} = create_user(:unprivileged, attrs, subject)
|
||||
assert {:ok, user} = create_user(account, :unprivileged, attrs, subject)
|
||||
assert is_nil(user.password_hash)
|
||||
end
|
||||
|
||||
test "trims email", %{subject: subject} do
|
||||
test "trims email", %{subject: subject, account: account} do
|
||||
attrs = UsersFixtures.user_attrs()
|
||||
updated_attrs = Map.put(attrs, :email, " #{attrs.email} ")
|
||||
|
||||
assert {:ok, user} = create_user(:unprivileged, updated_attrs, subject)
|
||||
assert {:ok, user} = create_user(account, :unprivileged, updated_attrs, subject)
|
||||
|
||||
assert user.email == attrs.email
|
||||
end
|
||||
|
||||
test "returns error when subject can not create users", %{subject: subject} do
|
||||
test "returns error when subject can not create users", %{subject: subject, account: account} do
|
||||
subject = SubjectFixtures.remove_permissions(subject)
|
||||
|
||||
assert create_user(:foo, %{}, subject) ==
|
||||
assert create_user(account, :foo, %{}, subject) ==
|
||||
{:error,
|
||||
{:unauthorized,
|
||||
[missing_permissions: [Users.Authorizer.manage_users_permission()]]}}
|
||||
|
||||
19
apps/domain/test/support/fixtures/accounts_fixtures.ex
Normal file
19
apps/domain/test/support/fixtures/accounts_fixtures.ex
Normal file
@@ -0,0 +1,19 @@
|
||||
defmodule Domain.AccountsFixtures do
|
||||
alias Domain.Accounts
|
||||
|
||||
def account_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name: "acc-#{counter()}"
|
||||
})
|
||||
end
|
||||
|
||||
def create_account(attrs \\ %{}) do
|
||||
attrs = account_attrs(attrs)
|
||||
{:ok, account} = Accounts.create_account(attrs)
|
||||
account
|
||||
end
|
||||
|
||||
defp counter do
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
55
apps/domain/test/support/fixtures/clients_fixtures.ex
Normal file
55
apps/domain/test/support/fixtures/clients_fixtures.ex
Normal file
@@ -0,0 +1,55 @@
|
||||
defmodule Domain.ClientsFixtures do
|
||||
alias Domain.Repo
|
||||
alias Domain.Clients
|
||||
alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures}
|
||||
|
||||
def client_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
external_id: Ecto.UUID.generate(),
|
||||
name: "client-#{counter()}",
|
||||
preshared_key: Domain.Crypto.psk(),
|
||||
public_key: public_key()
|
||||
})
|
||||
end
|
||||
|
||||
def create_client(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, _attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{user, attrs} =
|
||||
Map.pop_lazy(attrs, :user, fn ->
|
||||
UsersFixtures.create_user_with_role(:unprivileged, account: account)
|
||||
end)
|
||||
|
||||
{subject, attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
SubjectFixtures.create_subject(user)
|
||||
end)
|
||||
|
||||
attrs = client_attrs(attrs)
|
||||
|
||||
{:ok, client} = Clients.upsert_client(attrs, subject)
|
||||
client
|
||||
end
|
||||
|
||||
def delete_client(client) do
|
||||
client = Repo.preload(client, :account)
|
||||
admin = UsersFixtures.create_user_with_role(:admin, account: client.account)
|
||||
subject = SubjectFixtures.create_subject(admin)
|
||||
{:ok, client} = Clients.delete_client(client, subject)
|
||||
client
|
||||
end
|
||||
|
||||
def public_key do
|
||||
:crypto.strong_rand_bytes(32)
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp counter do
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
@@ -1,8 +1,4 @@
|
||||
defmodule Domain.DevicesFixtures do
|
||||
@moduledoc """
|
||||
This module defines test helpers for creating
|
||||
entities via the `Domain.Devices` context.
|
||||
"""
|
||||
alias Domain.Devices
|
||||
alias Domain.UsersFixtures
|
||||
alias Domain.SubjectFixtures
|
||||
|
||||
122
apps/domain/test/support/fixtures/gateways_fixtures.ex
Normal file
122
apps/domain/test/support/fixtures/gateways_fixtures.ex
Normal file
@@ -0,0 +1,122 @@
|
||||
defmodule Domain.GatewaysFixtures do
|
||||
alias Domain.AccountsFixtures
|
||||
alias Domain.Repo
|
||||
alias Domain.Gateways
|
||||
alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures}
|
||||
|
||||
def group_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name_prefix: "group-#{counter()}",
|
||||
tags: ["aws", "aws-us-east-#{counter()}"],
|
||||
tokens: [%{}]
|
||||
})
|
||||
end
|
||||
|
||||
def create_group(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{subject, attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
UsersFixtures.create_user_with_role(:admin, account: account)
|
||||
|> SubjectFixtures.create_subject()
|
||||
end)
|
||||
|
||||
attrs = group_attrs(attrs)
|
||||
|
||||
{:ok, group} = Gateways.create_group(attrs, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def delete_group(group) do
|
||||
group = Repo.preload(group, :account)
|
||||
admin = UsersFixtures.create_user_with_role(:admin, account: group.account)
|
||||
subject = SubjectFixtures.create_subject(admin)
|
||||
{:ok, group} = Gateways.delete_group(group, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def create_token(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
group =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Gateways.Group{} = group, _attrs} ->
|
||||
group
|
||||
|
||||
{group_attrs, _attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
create_group(group_attrs)
|
||||
end
|
||||
|
||||
Gateways.Token.Changeset.create_changeset(account)
|
||||
|> Ecto.Changeset.put_change(:group_id, group.id)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def gateway_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
external_id: Ecto.UUID.generate(),
|
||||
name_suffix: "gw-#{Domain.Crypto.rand_string(5)}",
|
||||
public_key: public_key(),
|
||||
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
|
||||
last_seen_remote_ip: %Postgrex.INET{address: {189, 172, 73, 153}}
|
||||
})
|
||||
end
|
||||
|
||||
def create_gateway(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{group, attrs} =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Gateways.Group{} = group, attrs} ->
|
||||
{group, attrs}
|
||||
|
||||
{group_attrs, attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
group = create_group(group_attrs)
|
||||
{group, attrs}
|
||||
end
|
||||
|
||||
{token, attrs} =
|
||||
Map.pop_lazy(attrs, :token, fn ->
|
||||
hd(group.tokens)
|
||||
end)
|
||||
|
||||
attrs = gateway_attrs(attrs)
|
||||
|
||||
{:ok, gateway} = Gateways.upsert_gateway(token, attrs)
|
||||
gateway
|
||||
end
|
||||
|
||||
def delete_gateway(gateway) do
|
||||
gateway = Repo.preload(gateway, :account)
|
||||
admin = UsersFixtures.create_user_with_role(:admin, account: gateway.account)
|
||||
subject = SubjectFixtures.create_subject(admin)
|
||||
{:ok, gateway} = Gateways.delete_gateway(gateway, subject)
|
||||
gateway
|
||||
end
|
||||
|
||||
def public_key do
|
||||
:crypto.strong_rand_bytes(32)
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp counter do
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
end
|
||||
27
apps/domain/test/support/fixtures/network_fixtures.ex
Normal file
27
apps/domain/test/support/fixtures/network_fixtures.ex
Normal file
@@ -0,0 +1,27 @@
|
||||
defmodule Domain.NetworkFixtures do
|
||||
alias Domain.Repo
|
||||
alias Domain.Network
|
||||
alias Domain.AccountsFixtures
|
||||
|
||||
def address_attrs(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{account_id: nil, address: nil, type: nil})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{:ok, inet} = Domain.Types.INET.cast(attrs.address)
|
||||
type = type(inet.address)
|
||||
%{attrs | address: inet, type: type, account_id: account.id}
|
||||
end
|
||||
|
||||
defp type(tuple) when tuple_size(tuple) == 4, do: :ipv4
|
||||
defp type(tuple) when tuple_size(tuple) == 8, do: :ipv6
|
||||
|
||||
def create_address(attrs \\ %{}) do
|
||||
%Network.Address{}
|
||||
|> struct(address_attrs(attrs))
|
||||
|> Repo.insert!()
|
||||
end
|
||||
end
|
||||
136
apps/domain/test/support/fixtures/relays_fixtures.ex
Normal file
136
apps/domain/test/support/fixtures/relays_fixtures.ex
Normal file
@@ -0,0 +1,136 @@
|
||||
defmodule Domain.RelaysFixtures do
|
||||
alias Domain.Repo
|
||||
alias Domain.Relays
|
||||
alias Domain.{AccountsFixtures, UsersFixtures, SubjectFixtures}
|
||||
|
||||
def group_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
name: "group-#{counter()}",
|
||||
tokens: [%{}]
|
||||
})
|
||||
end
|
||||
|
||||
def create_group(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{subject, attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
UsersFixtures.create_user_with_role(:admin, account: account)
|
||||
|> SubjectFixtures.create_subject()
|
||||
end)
|
||||
|
||||
attrs = group_attrs(attrs)
|
||||
|
||||
{:ok, group} = Relays.create_group(attrs, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def delete_group(group) do
|
||||
group = Repo.preload(group, :account)
|
||||
admin = UsersFixtures.create_user_with_role(:admin, account: group.account)
|
||||
subject = SubjectFixtures.create_subject(admin)
|
||||
{:ok, group} = Relays.delete_group(group, subject)
|
||||
group
|
||||
end
|
||||
|
||||
def create_token(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
group =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Relays.Group{} = group, _attrs} ->
|
||||
group
|
||||
|
||||
{group_attrs, _attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
create_group(group_attrs)
|
||||
end
|
||||
|
||||
Relays.Token.Changeset.create_changeset(account)
|
||||
|> Ecto.Changeset.put_change(:group_id, group.id)
|
||||
|> Repo.insert!()
|
||||
end
|
||||
|
||||
def relay_attrs(attrs \\ %{}) do
|
||||
ipv4 = random_ipv4()
|
||||
|
||||
Enum.into(attrs, %{
|
||||
ipv4: ipv4,
|
||||
ipv6: random_ipv6(),
|
||||
last_seen_user_agent: "iOS/12.7 (iPhone) connlib/0.7.412",
|
||||
last_seen_remote_ip: ipv4
|
||||
})
|
||||
end
|
||||
|
||||
def create_relay(attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{group, attrs} =
|
||||
case Map.pop(attrs, :group, %{}) do
|
||||
{%Relays.Group{} = group, attrs} ->
|
||||
{group, attrs}
|
||||
|
||||
{group_attrs, attrs} ->
|
||||
group_attrs = Enum.into(group_attrs, %{account: account})
|
||||
group = create_group(group_attrs)
|
||||
{group, attrs}
|
||||
end
|
||||
|
||||
{token, attrs} =
|
||||
Map.pop_lazy(attrs, :token, fn ->
|
||||
hd(group.tokens)
|
||||
end)
|
||||
|
||||
attrs = relay_attrs(attrs)
|
||||
|
||||
{:ok, relay} = Relays.upsert_relay(token, attrs)
|
||||
relay
|
||||
end
|
||||
|
||||
def delete_relay(relay) do
|
||||
relay = Repo.preload(relay, :account)
|
||||
admin = UsersFixtures.create_user_with_role(:admin, account: relay.account)
|
||||
subject = SubjectFixtures.create_subject(admin)
|
||||
{:ok, relay} = Relays.delete_relay(relay, subject)
|
||||
relay
|
||||
end
|
||||
|
||||
def public_key do
|
||||
:crypto.strong_rand_bytes(32)
|
||||
|> Base.encode64()
|
||||
end
|
||||
|
||||
defp counter do
|
||||
System.unique_integer([:positive])
|
||||
end
|
||||
|
||||
defp random_ipv4 do
|
||||
number = counter()
|
||||
<<a::size(8), b::size(8), c::size(8), d::size(8)>> = <<number::32>>
|
||||
{a, b, c, d}
|
||||
end
|
||||
|
||||
defp random_ipv6 do
|
||||
number = counter()
|
||||
|
||||
<<a::size(16), b::size(16), c::size(16), d::size(16), e::size(16), f::size(16), g::size(16),
|
||||
h::size(16)>> = <<number::128>>
|
||||
|
||||
{a, b, c, d, e, f, g, h}
|
||||
end
|
||||
end
|
||||
@@ -1,3 +1,4 @@
|
||||
# TODO: AuthFixtures - name module after context name
|
||||
defmodule Domain.SubjectFixtures do
|
||||
alias Domain.Auth
|
||||
alias Domain.UsersFixtures
|
||||
@@ -10,7 +11,7 @@ defmodule Domain.SubjectFixtures do
|
||||
end
|
||||
|
||||
def create_subject(user \\ UsersFixtures.create_user_with_role(:admin)) do
|
||||
Domain.Auth.fetch_subject!(user, {127, 0, 0, 1}, "DummyAgent (1.0.0)")
|
||||
Domain.Auth.fetch_subject!(user, {100, 64, 100, 58}, "iOS/12.5 (iPhone) connlib/0.7.412")
|
||||
end
|
||||
|
||||
def remove_permissions(%Auth.Subject{} = subject) do
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule Domain.UsersFixtures do
|
||||
alias Domain.Repo
|
||||
alias Domain.Users
|
||||
alias Domain.SubjectFixtures
|
||||
alias Domain.{AccountsFixtures, SubjectFixtures}
|
||||
|
||||
def user_attrs(attrs \\ %{}) do
|
||||
Enum.into(attrs, %{
|
||||
@@ -14,6 +14,11 @@ defmodule Domain.UsersFixtures do
|
||||
def create_user_with_role(role, attrs \\ %{}) do
|
||||
attrs = Enum.into(attrs, %{})
|
||||
|
||||
{account, attrs} =
|
||||
Map.pop_lazy(attrs, :account, fn ->
|
||||
AccountsFixtures.create_account()
|
||||
end)
|
||||
|
||||
{subject, attrs} =
|
||||
Map.pop_lazy(attrs, :subject, fn ->
|
||||
SubjectFixtures.new()
|
||||
@@ -24,7 +29,7 @@ defmodule Domain.UsersFixtures do
|
||||
|
||||
attrs = user_attrs(attrs)
|
||||
|
||||
{:ok, user} = Users.create_user(role, attrs, subject)
|
||||
{:ok, user} = Users.create_user(account, role, attrs, subject)
|
||||
user
|
||||
end
|
||||
|
||||
|
||||
@@ -63,6 +63,11 @@ config :domain,
|
||||
config :domain,
|
||||
max_devices_per_user: 10
|
||||
|
||||
config :domain, Domain.Auth,
|
||||
key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD",
|
||||
salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX",
|
||||
max_age: 30 * 60
|
||||
|
||||
###############################
|
||||
##### Web #####################
|
||||
###############################
|
||||
@@ -129,6 +134,16 @@ config :api,
|
||||
cookie_signing_salt: "WjllcThpb2Y=",
|
||||
cookie_encryption_salt: "M0EzM0R6NEMyaw=="
|
||||
|
||||
config :api, API.Gateway.Socket,
|
||||
key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD",
|
||||
salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX",
|
||||
max_age: 30 * 60
|
||||
|
||||
config :api, API.Relay.Socket,
|
||||
key_base: "5OVYJ83AcoQcPmdKNksuBhJFBhjHD1uUa9mDOHV/6EIdBQ6pXksIhkVeWIzFk5SD",
|
||||
salt: "t01wa0K4lUd7mKa0HAtZdE+jFOPDDejX",
|
||||
max_age: 30 * 60
|
||||
|
||||
###############################
|
||||
##### Third-party configs #####
|
||||
###############################
|
||||
|
||||
@@ -102,6 +102,7 @@ if config_env() == :prod do
|
||||
port: compile_config!(:phoenix_http_port),
|
||||
protocol_options: compile_config!(:phoenix_http_protocol_options)
|
||||
],
|
||||
# TODO: force_ssl: [rewrite_on: [:x_forwarded_proto], hsts: true],
|
||||
url: [
|
||||
scheme: external_url_scheme,
|
||||
host: external_url_host,
|
||||
|
||||
@@ -61,3 +61,6 @@ config :ex_unit,
|
||||
formatters: [JUnitFormatter, ExUnit.CLIFormatter],
|
||||
capture_log: true,
|
||||
exclude: [:acceptance]
|
||||
|
||||
# Initialize plugs at runtime for faster development compilation
|
||||
config :phoenix, :plug_init_mode, :runtime
|
||||
|
||||
Reference in New Issue
Block a user