Flows activity/metrics (#2176)

Charts library could be better, I did not find a way to configure
time-series min/max value or step, formatting Y axis is not trivial too,
but for an early feature this should do the job:

<img width="1728" alt="Screenshot 2023-09-27 at 20 00 10"
src="https://github.com/firezone/firezone/assets/1877644/8e4bef6b-2937-4dc2-ac31-3c61e31bffc6">
This commit is contained in:
Andrew Dryga
2023-09-30 10:04:33 -06:00
committed by GitHub
parent 64d9d0421a
commit 2f78be155f
32 changed files with 962 additions and 84 deletions

View File

@@ -221,7 +221,7 @@ defmodule API.Client.Channel do
OpenTelemetry.Tracer.with_span "client.reuse_connection", attrs do
with {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject),
{:ok, resource, _flow} <-
{:ok, resource, flow} <-
Flows.authorize_flow(
socket.assigns.client,
gateway,
@@ -239,6 +239,7 @@ defmodule API.Client.Channel do
%{
client_id: socket.assigns.client.id,
resource_id: resource.id,
flow_id: flow.id,
authorization_expires_at: socket.assigns.subject.expires_at
}, {opentelemetry_ctx, opentelemetry_span_ctx}}
)
@@ -273,7 +274,7 @@ defmodule API.Client.Channel do
OpenTelemetry.Tracer.with_span "client.request_connection", ctx_attrs do
with {:ok, gateway} <- Gateways.fetch_gateway_by_id(gateway_id, socket.assigns.subject),
{:ok, resource, _flow} <-
{:ok, resource, flow} <-
Flows.authorize_flow(
socket.assigns.client,
gateway,
@@ -291,6 +292,7 @@ defmodule API.Client.Channel do
%{
client_id: socket.assigns.client.id,
resource_id: resource.id,
flow_id: flow.id,
authorization_expires_at: socket.assigns.subject.expires_at,
client_rtc_session_description: client_rtc_session_description,
client_preshared_key: preshared_key

View File

@@ -1,7 +1,7 @@
defmodule API.Gateway.Channel do
use API, :channel
alias API.Gateway.Views
alias Domain.{Clients, Resources, Relays, Gateways}
alias Domain.{Clients, Resources, Relays, Gateways, Flows}
require Logger
require OpenTelemetry.Tracer
@@ -83,6 +83,7 @@ defmodule API.Gateway.Channel do
%{
client_id: client_id,
resource_id: resource_id,
flow_id: flow_id,
authorization_expires_at: authorization_expires_at
} = attrs
@@ -90,6 +91,7 @@ defmodule API.Gateway.Channel do
push(socket, "allow_access", %{
client_id: client_id,
flow_id: flow_id,
resource: Views.Resource.render(resource),
expires_at: DateTime.to_unix(authorization_expires_at, :second)
})
@@ -130,6 +132,7 @@ defmodule API.Gateway.Channel do
%{
client_id: client_id,
resource_id: resource_id,
flow_id: flow_id,
authorization_expires_at: authorization_expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
@@ -148,6 +151,7 @@ defmodule API.Gateway.Channel do
push(socket, "request_connection", %{
ref: ref,
flow_id: flow_id,
actor: Views.Actor.render(client.actor),
relays: Views.Relay.render_many(relays, authorization_expires_at),
resource: Views.Resource.render(resource),
@@ -158,6 +162,7 @@ defmodule API.Gateway.Channel do
Logger.debug("Awaiting gateway connection_ready message",
client_id: client_id,
resource_id: resource_id,
flow_id: flow_id,
ref: ref
)
@@ -236,21 +241,45 @@ defmodule API.Gateway.Channel do
end
end
# def handle_in("metrics", params, socket) do
# %{
# "started_at" => started_at,
# "ended_at" => ended_at,
# "metrics" => [
# %{
# "client_id" => client_id,
# "resource_id" => resource_id,
# "rx_bytes" => 0,
# "tx_packets" => 0
# }
# ]
# }
def handle_in(
"metrics",
%{
"started_at" => started_at,
"ended_at" => ended_at,
"metrics" => metrics
},
socket
) do
OpenTelemetry.Ctx.attach(socket.assigns.opentelemetry_ctx)
OpenTelemetry.Tracer.set_current_span(socket.assigns.opentelemetry_span_ctx)
# :ok = Gateways.update_metrics(socket.assigns.relay, metrics)
# {:noreply, socket}
# end
OpenTelemetry.Tracer.with_span "gateway.metrics" do
window_started_at = DateTime.from_unix!(started_at, :second)
window_ended_at = DateTime.from_unix!(ended_at, :second)
activities =
Enum.map(metrics, fn metric ->
%{
"flow_id" => flow_id,
"destination" => destination,
"rx_bytes" => rx_bytes,
"tx_bytes" => tx_bytes
} = metric
%{
window_started_at: window_started_at,
window_ended_at: window_ended_at,
destination: destination,
rx_bytes: rx_bytes,
tx_bytes: tx_bytes,
flow_id: flow_id,
account_id: socket.assigns.gateway.account_id
}
end)
{:ok, _num} = Flows.upsert_activities(activities)
{:reply, :ok, socket}
end
end
end

View File

@@ -72,6 +72,7 @@ defmodule API.Gateway.ChannelTest do
} do
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
flow_id = Ecto.UUID.generate()
stamp_secret = Ecto.UUID.generate()
:ok = Domain.Relays.connect_relay(relay, stamp_secret)
@@ -82,6 +83,7 @@ defmodule API.Gateway.ChannelTest do
%{
client_id: client.id,
resource_id: resource.id,
flow_id: flow_id,
authorization_expires_at: expires_at
}, otel_ctx}
)
@@ -102,6 +104,7 @@ defmodule API.Gateway.ChannelTest do
]
}
assert payload.flow_id == flow_id
assert payload.client_id == client.id
assert DateTime.from_unix!(payload.expires_at) == DateTime.truncate(expires_at, :second)
end
@@ -142,6 +145,7 @@ defmodule API.Gateway.ChannelTest do
expires_at = DateTime.utc_now() |> DateTime.add(30, :second)
preshared_key = "PSK"
rtc_session_description = "RTC_SD"
flow_id = Ecto.UUID.generate()
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
@@ -154,6 +158,7 @@ defmodule API.Gateway.ChannelTest do
%{
client_id: client.id,
resource_id: resource.id,
flow_id: flow_id,
authorization_expires_at: expires_at,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
@@ -163,6 +168,7 @@ defmodule API.Gateway.ChannelTest do
assert_push "request_connection", payload
assert is_binary(payload.ref)
assert payload.flow_id == flow_id
assert payload.actor == %{id: client.actor_id}
ipv4_stun_uri = "stun:#{relay.ipv4}:#{relay.port}"
@@ -246,6 +252,7 @@ defmodule API.Gateway.ChannelTest do
preshared_key = "PSK"
gateway_public_key = gateway.public_key
rtc_session_description = "RTC_SD"
flow_id = Ecto.UUID.generate()
otel_ctx = {OpenTelemetry.Ctx.new(), OpenTelemetry.Tracer.start_span("connect")}
@@ -259,12 +266,13 @@ defmodule API.Gateway.ChannelTest do
client_id: client.id,
resource_id: resource.id,
authorization_expires_at: expires_at,
flow_id: flow_id,
client_rtc_session_description: rtc_session_description,
client_preshared_key: preshared_key
}, otel_ctx}
)
assert_push "request_connection", %{ref: ref}
assert_push "request_connection", %{ref: ref, flow_id: ^flow_id}
push_ref =
push(socket, "connection_ready", %{
@@ -317,4 +325,55 @@ defmodule API.Gateway.ChannelTest do
assert gateway.id == gateway_id
end
end
describe "handle_in/3 metrics" do
test "inserts activities", %{
account: account,
subject: subject,
client: client,
gateway: gateway,
resource: resource,
socket: socket
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
resource: resource,
gateway: gateway
)
now = DateTime.utc_now() |> DateTime.truncate(:second)
one_minute_ago = DateTime.add(now, -1, :minute)
{:ok, destination} = Domain.Types.IPPort.cast("127.0.0.1:80")
attrs =
%{
"started_at" => DateTime.to_unix(one_minute_ago),
"ended_at" => DateTime.to_unix(now),
"metrics" => [
%{
"flow_id" => flow.id,
"destination" => destination,
"rx_bytes" => 100,
"tx_bytes" => 200
}
]
}
push_ref = push(socket, "metrics", attrs)
assert_reply push_ref, :ok
assert upserted_activity = Repo.one(Domain.Flows.Activity)
assert upserted_activity.window_started_at == one_minute_ago
assert upserted_activity.window_ended_at == now
assert upserted_activity.destination == destination
assert upserted_activity.rx_bytes == 100
assert upserted_activity.tx_bytes == 200
assert upserted_activity.flow_id == flow.id
assert upserted_activity.account_id == account.id
end
end
end

View File

@@ -1,7 +1,7 @@
defmodule Domain.Flows do
alias Domain.Repo
alias Domain.{Auth, Clients, Gateways, Resources, Policies}
alias Domain.Flows.{Authorizer, Flow}
alias Domain.{Repo, Validator}
alias Domain.{Auth, Accounts, Clients, Gateways, Resources, Policies}
alias Domain.Flows.{Authorizer, Flow, Activity}
require Ecto.Query
def authorize_flow(
@@ -49,35 +49,53 @@ defmodule Domain.Flows do
end
end
def fetch_flow_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_flows_permission()),
true <- Validator.valid_uuid?(id) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
Flow.Query.by_id(id)
|> Authorizer.for_subject(Flow, subject)
|> Repo.fetch()
|> case do
{:ok, resource} -> {:ok, Repo.preload(resource, preload)}
{:error, reason} -> {:error, reason}
end
else
false -> {:error, :not_found}
other -> other
end
end
def list_flows_for(assoc, subject, opts \\ [])
def list_flows_for(%Policies.Policy{} = policy, %Auth.Subject{} = subject, opts) do
Flow.Query.by_policy_id(policy.id)
|> list(subject, opts)
|> list_flows(subject, opts)
end
def list_flows_for(%Resources.Resource{} = resource, %Auth.Subject{} = subject, opts) do
Flow.Query.by_resource_id(resource.id)
|> list(subject, opts)
|> list_flows(subject, opts)
end
def list_flows_for(%Clients.Client{} = client, %Auth.Subject{} = subject, opts) do
Flow.Query.by_client_id(client.id)
|> list(subject, opts)
|> list_flows(subject, opts)
end
def list_flows_for(%Gateways.Gateway{} = gateway, %Auth.Subject{} = subject, opts) do
Flow.Query.by_gateway_id(gateway.id)
|> list(subject, opts)
|> list_flows(subject, opts)
end
defp list(queryable, subject, opts) do
defp list_flows(queryable, subject, opts) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_flows_permission()) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
{:ok, flows} =
queryable
|> Authorizer.for_subject(subject)
|> Authorizer.for_subject(Flow, subject)
|> Ecto.Query.order_by([flows: flows], desc: flows.inserted_at, desc: flows.id)
|> Ecto.Query.limit(50)
|> Repo.list()
@@ -85,4 +103,42 @@ defmodule Domain.Flows do
{:ok, Repo.preload(flows, preload)}
end
end
def upsert_activities(activities) do
{num, _} =
Repo.insert_all(Activity, activities, on_conflict: :nothing)
{:ok, num}
end
def list_flow_activities_for(
%Flow{} = flow,
ended_after,
started_before,
%Auth.Subject{} = subject
) do
Activity.Query.by_flow_id(flow.id)
|> list_activities(ended_after, started_before, subject)
end
def list_flow_activities_for(
%Accounts.Account{} = account,
ended_after,
started_before,
%Auth.Subject{} = subject
) do
Activity.Query.by_account_id(account.id)
|> list_activities(ended_after, started_before, subject)
end
defp list_activities(queryable, ended_after, started_before, subject) do
with :ok <- Auth.ensure_has_permissions(subject, Authorizer.view_flows_permission()) do
queryable
|> Activity.Query.by_window_ended_at({:greater_than, ended_after})
|> Activity.Query.by_window_started_at({:less_than, started_before})
|> Authorizer.for_subject(Activity, subject)
|> Ecto.Query.order_by([activities: activities], asc: activities.window_started_at)
|> Repo.list()
end
end
end

View File

@@ -0,0 +1,15 @@
defmodule Domain.Flows.Activity do
use Domain, :schema
schema "flow_activities" do
field :window_started_at, :utc_datetime
field :window_ended_at, :utc_datetime
field :destination, Domain.Types.IPPort
field :rx_bytes, :integer
field :tx_bytes, :integer
belongs_to :flow, Domain.Flows.Flow
belongs_to :account, Domain.Accounts.Account
end
end

View File

@@ -0,0 +1,23 @@
defmodule Domain.Flows.Activity.Query do
use Domain, :query
def all do
from(activities in Domain.Flows.Activity, as: :activities)
end
def by_account_id(queryable \\ all(), account_id) do
where(queryable, [activities: activities], activities.account_id == ^account_id)
end
def by_flow_id(queryable \\ all(), flow_id) do
where(queryable, [activities: activities], activities.flow_id == ^flow_id)
end
def by_window_started_at(queryable \\ all(), {:less_than, datetime}) do
where(queryable, [activities: activities], activities.window_started_at < ^datetime)
end
def by_window_ended_at(queryable \\ all(), {:greater_than, datetime}) do
where(queryable, [activities: activities], activities.window_ended_at > ^datetime)
end
end

View File

@@ -1,6 +1,6 @@
defmodule Domain.Flows.Authorizer do
use Domain.Auth.Authorizer
alias Domain.Flows.Flow
alias Domain.Flows.{Flow, Activity}
def view_flows_permission, do: build(Flow, :view)
def create_flows_permission, do: build(Flow, :create)
@@ -23,11 +23,17 @@ defmodule Domain.Flows.Authorizer do
[]
end
@impl Domain.Auth.Authorizer
def for_subject(queryable, %Subject{} = subject) do
def for_subject(queryable, Flow, %Subject{} = subject) do
cond do
has_permission?(subject, view_flows_permission()) ->
Flow.Query.by_account_id(queryable, subject.account.id)
end
end
def for_subject(queryable, Activity, %Subject{} = subject) do
cond do
has_permission?(subject, view_flows_permission()) ->
Activity.Query.by_account_id(queryable, subject.account.id)
end
end
end

View File

@@ -29,11 +29,11 @@ defmodule Domain.Resources do
end
def fetch_and_authorize_resource_by_id(id, %Auth.Subject{} = subject, opts \\ []) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
with :ok <-
Auth.ensure_has_permissions(subject, Authorizer.view_available_resources_permission()),
true <- Validator.valid_uuid?(id) do
{preload, _opts} = Keyword.pop(opts, :preload, [])
Resource.Query.by_id(id)
|> Resource.Query.by_account_id(subject.account.id)
|> Resource.Query.by_authorized_actor_id(subject.actor.id)

View File

@@ -65,8 +65,12 @@ defmodule Domain.Types.IPPort do
def dump(_), do: :error
def load(%__MODULE__{} = ip) do
{:ok, ip}
def load(binary) when is_binary(binary) do
cast(binary)
end
def load(%__MODULE__{} = struct) do
{:ok, struct}
end
def load(_), do: :error

View File

@@ -0,0 +1,36 @@
defmodule Domain.Repo.Migrations.AddFlowActivities do
use Ecto.Migration
@assoc_opts [type: :binary_id, on_delete: :delete_all]
def change do
create table(:flow_activities, primary_key: false) do
add(:id, :uuid, primary_key: true)
add(:window_started_at, :utc_datetime_usec, null: false)
add(:window_ended_at, :utc_datetime_usec, null: false)
add(:destination, :string, null: false)
add(:rx_bytes, :bigint, null: false)
add(:tx_bytes, :bigint, null: false)
add(:flow_id, references(:flows, @assoc_opts), null: false)
add(:account_id, references(:accounts, @assoc_opts), null: false)
end
execute("""
CREATE UNIQUE INDEX flow_activities_account_id_flow_id_window_destination_index ON flow_activities
USING BTREE (account_id, flow_id, window_started_at, window_ended_at, destination);
""")
execute("""
CREATE INDEX flow_activities_account_id_flow_id_window_index ON flow_activities
USING BTREE (account_id, flow_id, window_started_at ASC);
""")
execute("""
CREATE INDEX flow_activities_account_id_window_index ON flow_activities
USING BTREE (account_id, window_started_at ASC);
""")
end
end

View File

@@ -457,9 +457,45 @@ IO.puts("Created client tokens:")
IO.puts(" #{unprivileged_actor_email} token: #{unprivileged_subject_client_token}")
IO.puts("")
Flows.authorize_flow(
user_iphone,
gateway1,
cidr_resource.id,
unprivileged_subject
)
{:ok, _resource, flow} =
Flows.authorize_flow(
user_iphone,
gateway1,
cidr_resource.id,
unprivileged_subject
)
started_at =
DateTime.utc_now()
|> DateTime.truncate(:second)
|> DateTime.add(5, :minute)
{:ok, destination1} = Domain.Types.IPPort.cast("142.250.217.142:443")
{:ok, destination2} = Domain.Types.IPPort.cast("142.250.217.142:80")
random_integer = fn ->
:math.pow(10, 10)
|> round()
|> :rand.uniform()
|> floor()
|> Kernel.-(1)
end
activities =
for i <- 1..200 do
offset = i * 15
started_at = DateTime.add(started_at, offset, :minute)
ended_at = DateTime.add(started_at, 15, :minute)
%{
window_started_at: started_at,
window_ended_at: ended_at,
destination: Enum.random([destination1, destination2]),
rx_bytes: random_integer.(),
tx_bytes: random_integer.(),
flow_id: flow.id,
account_id: account.id
}
end
{:ok, 200} = Flows.upsert_activities(activities)

View File

@@ -1222,7 +1222,7 @@ defmodule Domain.ActorsTest do
{:ok, actor} = fetch_actor_by_id(actor.id, subject, preload: :identities)
assert Ecto.assoc_loaded?(actor.identities) == true
assert Ecto.assoc_loaded?(actor.identities)
end
end
@@ -1317,7 +1317,7 @@ defmodule Domain.ActorsTest do
{:ok, actors} = list_actors(subject, preload: :identities)
assert length(actors) == 2
assert Enum.all?(actors, fn a -> Ecto.assoc_loaded?(a.identities) end) == true
assert Enum.all?(actors, fn a -> Ecto.assoc_loaded?(a.identities) end)
end
end

View File

@@ -2,6 +2,7 @@ defmodule Domain.FlowsTest do
use Domain.DataCase, async: true
import Domain.Flows
alias Domain.Flows
alias Domain.Flows.Authorizer
setup do
account = Fixtures.Accounts.create_account()
@@ -181,11 +182,95 @@ defmodule Domain.FlowsTest do
assert {:ok, resource, _flow} =
authorize_flow(client, gateway, resource.id, subject, preload: :connections)
assert Ecto.assoc_loaded?(resource.connections) == true
assert Ecto.assoc_loaded?(resource.connections)
assert Ecto.assoc_loaded?(resource.connections)
assert Ecto.assoc_loaded?(resource.connections)
assert Ecto.assoc_loaded?(resource.connections)
assert length(resource.connections) == 1
end
end
describe "fetch_flow_by_id/2" do
test "returns error when flow does not exist", %{subject: subject} do
assert fetch_flow_by_id(Ecto.UUID.generate(), subject) == {:error, :not_found}
end
test "returns error when UUID is invalid", %{subject: subject} do
assert fetch_flow_by_id("foo", subject) == {:error, :not_found}
end
test "returns flow", %{
account: account,
client: client,
gateway: gateway,
resource: resource,
policy: policy,
subject: subject
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
policy: policy,
resource: resource,
gateway: gateway
)
assert {:ok, fetched_flow} = fetch_flow_by_id(flow.id, subject)
assert fetched_flow.id == flow.id
end
test "does not return flows in other accounts", %{subject: subject} do
flow = Fixtures.Flows.create_flow()
assert fetch_flow_by_id(flow.id, subject) == {:error, :not_found}
end
test "returns error when subject has no permission to view flows", %{subject: subject} do
subject = Fixtures.Auth.remove_permissions(subject)
assert fetch_flow_by_id(Ecto.UUID.generate(), subject) ==
{:error,
{:unauthorized, [missing_permissions: [Authorizer.view_flows_permission()]]}}
end
test "associations are preloaded when opts given", %{
account: account,
client: client,
gateway: gateway,
resource: resource,
policy: policy,
subject: subject
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
policy: policy,
resource: resource,
gateway: gateway
)
assert {:ok, flow} =
fetch_flow_by_id(flow.id, subject,
preload: [
:policy,
:client,
:gateway,
:resource,
:account
]
)
assert Ecto.assoc_loaded?(flow.policy)
assert Ecto.assoc_loaded?(flow.client)
assert Ecto.assoc_loaded?(flow.gateway)
assert Ecto.assoc_loaded?(flow.resource)
assert Ecto.assoc_loaded?(flow.account)
end
end
describe "list_flows_for/2" do
test "returns empty list when there are no flows", %{
client: client,
@@ -215,7 +300,7 @@ defmodule Domain.FlowsTest do
assert list_flows_for(gateway, subject) == {:ok, []}
end
test "returns all authorized resources for account user subject", %{
test "returns all authorized flows", %{
account: account,
client: client,
gateway: gateway,
@@ -258,4 +343,232 @@ defmodule Domain.FlowsTest do
assert list_flows_for(gateway, subject) == expected_error
end
end
describe "upsert_activities/1" do
test "inserts new activities", %{
account: account,
client: client,
gateway: gateway,
resource: resource,
policy: policy,
subject: subject
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
policy: policy,
resource: resource,
gateway: gateway
)
now = DateTime.utc_now() |> DateTime.truncate(:second)
{:ok, destination} = Domain.Types.IPPort.cast("127.0.0.1:80")
activity =
%{
window_started_at: DateTime.add(now, -1, :minute),
window_ended_at: now,
destination: destination,
rx_bytes: 100,
tx_bytes: 200,
flow_id: flow.id,
account_id: account.id
}
assert upsert_activities([activity]) == {:ok, 1}
assert upserted_activity = Repo.one(Flows.Activity)
assert upserted_activity.window_started_at == activity.window_started_at
assert upserted_activity.window_ended_at == activity.window_ended_at
assert upserted_activity.destination == destination
assert upserted_activity.rx_bytes == activity.rx_bytes
assert upserted_activity.tx_bytes == activity.tx_bytes
assert upserted_activity.flow_id == flow.id
assert upserted_activity.account_id == account.id
end
test "ignores upsert conflicts", %{
account: account,
client: client,
gateway: gateway,
resource: resource,
policy: policy,
subject: subject
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
policy: policy,
resource: resource,
gateway: gateway
)
activity = Fixtures.Flows.activity_attrs(flow_id: flow.id, account_id: account.id)
assert upsert_activities([activity]) == {:ok, 1}
assert upsert_activities([activity]) == {:ok, 0}
assert Repo.one(Flows.Activity)
end
end
describe "list_flow_activities_for/4" do
setup %{
account: account,
client: client,
gateway: gateway,
resource: resource,
policy: policy,
subject: subject
} do
flow =
Fixtures.Flows.create_flow(
account: account,
subject: subject,
client: client,
policy: policy,
resource: resource,
gateway: gateway
)
%{flow: flow}
end
test "returns empty list when there are no flow activities", %{
account: account,
flow: flow,
subject: subject
} do
now = DateTime.utc_now()
ended_after = DateTime.add(now, -30, :minute)
started_before = DateTime.add(now, 30, :minute)
assert list_flow_activities_for(account, ended_after, started_before, subject) == {:ok, []}
assert list_flow_activities_for(flow, ended_after, started_before, subject) == {:ok, []}
end
test "does not list flow activities from other accounts", %{
account: account,
subject: subject
} do
flow = Fixtures.Flows.create_flow()
Fixtures.Flows.create_activity(flow: flow)
now = DateTime.utc_now()
ended_after = DateTime.add(now, -30, :minute)
started_before = DateTime.add(now, 30, :minute)
assert list_flow_activities_for(account, ended_after, started_before, subject) == {:ok, []}
assert list_flow_activities_for(flow, ended_after, started_before, subject) == {:ok, []}
end
test "returns ordered by window start time flow activities within a time window", %{
account: account,
flow: flow,
subject: subject
} do
now = DateTime.utc_now() |> DateTime.truncate(:second)
thirty_minutes_ago = DateTime.add(now, -30, :minute)
five_minutes_ago = DateTime.add(now, -5, :minute)
four_minutes_ago = DateTime.add(now, -4, :minute)
three_minutes_ago = DateTime.add(now, -4, :minute)
thirty_minutes_in_future = DateTime.add(now, 30, :minute)
sixty_minutes_in_future = DateTime.add(now, 60, :minute)
activity1 =
Fixtures.Flows.create_activity(
flow: flow,
window_started_at: four_minutes_ago,
window_ended_at: three_minutes_ago
)
assert list_flow_activities_for(
account,
thirty_minutes_in_future,
sixty_minutes_in_future,
subject
) == {:ok, []}
assert list_flow_activities_for(
flow,
thirty_minutes_in_future,
sixty_minutes_in_future,
subject
) == {:ok, []}
assert list_flow_activities_for(
account,
thirty_minutes_ago,
five_minutes_ago,
subject
) == {:ok, []}
assert list_flow_activities_for(
flow,
thirty_minutes_ago,
five_minutes_ago,
subject
) == {:ok, []}
assert list_flow_activities_for(
account,
five_minutes_ago,
now,
subject
) == {:ok, [activity1]}
assert list_flow_activities_for(
flow,
five_minutes_ago,
now,
subject
) == {:ok, [activity1]}
activity2 =
Fixtures.Flows.create_activity(
flow: flow,
window_started_at: five_minutes_ago,
window_ended_at: four_minutes_ago
)
assert list_flow_activities_for(
account,
thirty_minutes_ago,
now,
subject
) == {:ok, [activity2, activity1]}
assert list_flow_activities_for(
flow,
thirty_minutes_ago,
now,
subject
) == {:ok, [activity2, activity1]}
end
test "returns error when subject has no permission to view flows", %{
account: account,
flow: flow,
subject: subject
} do
now = DateTime.utc_now()
ended_after = DateTime.add(now, -30, :minute)
started_before = DateTime.add(now, 30, :minute)
subject = Fixtures.Auth.remove_permissions(subject)
assert list_flow_activities_for(account, ended_after, started_before, subject) ==
{:error,
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
assert list_flow_activities_for(flow, ended_after, started_before, subject) ==
{:error,
{:unauthorized, [missing_permissions: [Flows.Authorizer.view_flows_permission()]]}}
end
end
end

View File

@@ -418,8 +418,8 @@ defmodule Domain.GatewaysTest do
gateway = Fixtures.Gateways.create_gateway(account: account)
{:ok, gateway} = fetch_gateway_by_id(gateway.id, subject, preload: [:group, :account])
assert Ecto.assoc_loaded?(gateway.group) == true
assert Ecto.assoc_loaded?(gateway.account) == true
assert Ecto.assoc_loaded?(gateway.group)
assert Ecto.assoc_loaded?(gateway.account)
end
end

View File

@@ -99,7 +99,7 @@ defmodule Domain.ResourcesTest do
)
assert {:ok, resource} = fetch_resource_by_id(resource.id, subject, preload: :connections)
assert Ecto.assoc_loaded?(resource.connections) == true
assert Ecto.assoc_loaded?(resource.connections)
assert length(resource.connections) == 1
end
end
@@ -222,7 +222,7 @@ defmodule Domain.ResourcesTest do
assert {:ok, resource} =
fetch_and_authorize_resource_by_id(resource.id, subject, preload: :connections)
assert Ecto.assoc_loaded?(resource.connections) == true
assert Ecto.assoc_loaded?(resource.connections)
assert length(resource.connections) == 1
end
end

View File

@@ -84,4 +84,46 @@ defmodule Domain.Fixtures.Flows do
})
|> Repo.insert!()
end
def activity_attrs(attrs \\ %{}) do
now = DateTime.utc_now() |> DateTime.truncate(:second)
unique_ipv4 = :inet.ntoa(unique_ipv4())
{:ok, destination} = Domain.Types.IPPort.cast("#{unique_ipv4}:80")
Enum.into(attrs, %{
window_started_at: DateTime.add(now, -1, :minute),
window_ended_at: now,
destination: destination,
rx_bytes: 100,
tx_bytes: 200
})
end
def create_activity(attrs) do
attrs = activity_attrs(attrs)
{account, attrs} =
pop_assoc_fixture(attrs, :account, fn assoc_attrs ->
if relation = attrs[:flow] do
Repo.get!(Domain.Accounts.Account, relation.account_id)
else
Fixtures.Accounts.create_account(assoc_attrs)
end
end)
{flow, attrs} =
pop_assoc_fixture(attrs, :flow, fn assoc_attrs ->
assoc_attrs
|> Enum.into(%{account: account})
|> create_flow()
end)
attrs =
attrs
|> Map.put(:flow_id, flow.id)
|> Map.put(:account_id, account.id)
struct(Flows.Activity, attrs)
|> Repo.insert!()
end
end

View File

@@ -1,30 +1,28 @@
@layer utilities {
@variants responsive {
.no-scrollbar::-webkit-scrollbar {
display: block;
height: 0px;
background-color: initial;
border-radius: 10px;
transition: all 2s linear;
}
.no-scrollbar::-webkit-scrollbar {
display: block;
height: 0px;
background-color: initial;
border-radius: 10px;
transition: all 2s linear;
}
.no-scrollbar:hover::-webkit-scrollbar {
height: .5rem;
}
.no-scrollbar:hover::-webkit-scrollbar {
height: .5rem;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar {
-ms-overflow-style: none;
scrollbar-width: none;
}
.no-scrollbar::-webkit-scrollbar-thumb {
background-color: rgb(228 228 231/var(--tw-bg-opacity));
border-radius: 10px;
}
.no-scrollbar::-webkit-scrollbar-thumb {
background-color: rgb(228 228 231/var(--tw-bg-opacity));
border-radius: 10px;
}
.no-scrollbar::-webkit-scrollbar-track {
background-color: rgb(249 250 251);
border-radius: 5px;
}
}
.no-scrollbar::-webkit-scrollbar-track {
background-color: rgb(249 250 251);
border-radius: 5px;
}
}

View File

@@ -22,6 +22,7 @@ Hooks.Copy = {
},
}
// Update status indicator when sidebar is mounted or updated
let statusIndicatorClassNames = {
none: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300",

View File

@@ -1,6 +1,6 @@
{
"dependencies": {
"@fontsource/source-sans-pro": "^4.5.11",
"flowbite": "^1.6.5"
"flowbite": "^1.8.1"
}
}

View File

@@ -9,8 +9,8 @@ dependencies:
specifier: ^4.5.11
version: 4.5.11
flowbite:
specifier: ^1.6.5
version: 1.6.5
specifier: ^1.8.1
version: 1.8.1
packages:
@@ -22,8 +22,12 @@ packages:
resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==}
dev: false
/flowbite@1.6.5:
resolution: {integrity: sha512-eI4h3pIRI9d7grlYq14r0A01KUtw7189sPLLx/O2i7JyPEWpbleScfYuEc48XTeNjk1xxm/JHgZkD9kjyOWAlA==}
/@yr/monotone-cubic-spline@1.0.3:
resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==}
dev: false
/flowbite@1.8.1:
resolution: {integrity: sha512-lXTcO8a6dRTPFpINyOLcATCN/pK1Of/jY4PryklPllAiqH64tSDUsOdQpar3TO59ZXWwugm2e92oaqwH6X90Xg==}
dependencies:
'@popperjs/core': 2.11.8
mini-svg-data-uri: 1.4.4
@@ -33,3 +37,57 @@ packages:
resolution: {integrity: sha512-r9deDe9p5FJUPZAk3A59wGH7Ii9YrjjWw0jmw/liSbHl2CHiyXj6FcDXDu2K3TjVAXqiJdaw3xxwlZZr9E6nHg==}
hasBin: true
dev: false
/svg.draggable.js@2.2.2:
resolution: {integrity: sha512-JzNHBc2fLQMzYCZ90KZHN2ohXL0BQJGQimK1kGk6AvSeibuKcIdDX9Kr0dT9+UJ5O8nYA0RB839Lhvk4CY4MZw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.easing.js@2.0.0:
resolution: {integrity: sha512-//ctPdJMGy22YoYGV+3HEfHbm6/69LJUTAqI2/5qBvaNHZ9uUFVC82B0Pl299HzgH13rKrBgi4+XyXXyVWWthA==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.filter.js@2.0.2:
resolution: {integrity: sha512-xkGBwU+dKBzqg5PtilaTb0EYPqPfJ9Q6saVldX+5vCRy31P6TlRCP3U9NxH3HEufkKkpNgdTLBJnmhDHeTqAkw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.js@2.7.1:
resolution: {integrity: sha512-ycbxpizEQktk3FYvn/8BH+6/EuWXg7ZpQREJvgacqn46gIddG24tNNe4Son6omdXCnSOaApnpZw6MPCBA1dODA==}
dev: false
/svg.pathmorphing.js@0.1.3:
resolution: {integrity: sha512-49HWI9X4XQR/JG1qXkSDV8xViuTLIWm/B/7YuQELV5KMOPtXjiwH4XPJvr/ghEDibmLQ9Oc22dpWpG0vUDDNww==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.resize.js@1.4.3:
resolution: {integrity: sha512-9k5sXJuPKp+mVzXNvxz7U0uC9oVMQrrf7cFsETznzUDDm0x8+77dtZkWdMfRlmbkEEYvUn9btKuZ3n41oNA+uw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
svg.select.js: 2.1.2
dev: false
/svg.select.js@2.1.2:
resolution: {integrity: sha512-tH6ABEyJsAOVAhwcCjF8mw4crjXSI1aa7j2VQR8ZuJ37H2MBUbyeqYr5nEO7sSN3cy9AR9DUwNg0t/962HlDbQ==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false
/svg.select.js@3.0.1:
resolution: {integrity: sha512-h5IS/hKkuVCbKSieR9uQCj9w+zLHoPh+ce19bBYyqF53g6mnPB8sAtIbe1s9dh2S2fCmYX2xel1Ln3PJBbK4kw==}
engines: {node: '>= 0.8.0'}
dependencies:
svg.js: 2.7.1
dev: false

View File

@@ -6,7 +6,6 @@ const fs = require("fs")
const path = require("path")
const defaultTheme = require("tailwindcss/defaultTheme")
const firezoneColors = {
// See our brand palette in Figma.
// These have been reversed to match Tailwind's default order.
@@ -70,7 +69,7 @@ module.exports = {
extend: {
colors: {
brand: "#FD4F00",
primary: firezoneColors["heat-wave"],
primary: firezoneColors["heat-wave"],
accent: firezoneColors["electric-violet"],
neutral: firezoneColors["night-rider"]
//primary: {
@@ -89,7 +88,9 @@ module.exports = {
},
},
plugins: [
require("flowbite/plugin"),
require('flowbite/plugin')({
charts: true,
}),
require("@tailwindcss/forms"),
plugin(({ addVariant }) => addVariant("phx-no-feedback", [".phx-no-feedback&", ".phx-no-feedback &"])),
plugin(({ addVariant }) => addVariant("phx-click-loading", [".phx-click-loading&", ".phx-click-loading &"])),

View File

@@ -264,7 +264,7 @@ defmodule Web.NavigationComponents do
Renders a single breadcrumb entry. should be wrapped in <.breadcrumbs> component.
"""
slot :inner_block, required: true, doc: "The label for the breadcrumb entry."
attr :path, :string, required: true, doc: "The path for the breadcrumb entry."
attr :path, :string, default: nil, doc: "The path for the breadcrumb entry."
def breadcrumb(assigns) do
~H"""
@@ -272,11 +272,19 @@ defmodule Web.NavigationComponents do
<div class="flex items-center text-gray-700 dark:text-gray-300">
<.icon name="hero-chevron-right-solid" class="w-6 h-6" />
<.link
:if={not is_nil(@path)}
navigate={@path}
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
>
<%= render_slot(@inner_block) %>
</.link>
<span
:if={is_nil(@path)}
class="ml-1 text-sm font-medium text-gray-700 hover:text-gray-900 md:ml-2 dark:text-gray-300 dark:hover:text-white"
>
<%= render_slot(@inner_block) %>
</span>
</div>
</li>
"""

View File

@@ -0,0 +1 @@
NimbleCSV.define(Web.CSV, separator: "\t", escape: "\"")

View File

@@ -125,6 +125,14 @@ defmodule Web.Clients.Show do
</.link>
(<%= flow.gateway_remote_ip %>)
</:col>
<:col :let={flow} label="ACTIVITY">
<.link
navigate={~p"/#{@account}/flows/#{flow.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Show
</.link>
</:col>
</.table>
</div>

View File

@@ -0,0 +1,38 @@
defmodule Web.Flows.DownloadActivities do
use Web, :controller
alias Domain.Flows
def download(conn, %{"id" => id}) do
with {:ok, flow} <- Flows.fetch_flow_by_id(id, conn.assigns.subject),
{:ok, activities} <-
Flows.list_flow_activities_for(
flow,
flow.inserted_at,
flow.expires_at,
conn.assigns.subject
) do
fields = ~w[window_started_at window_ended_at destination rx_bytes tx_bytes]
rows =
Enum.map(activities, fn activity ->
[
to_string(activity.window_started_at),
to_string(activity.window_ended_at),
to_string(activity.destination),
activity.rx_bytes,
activity.tx_bytes
]
end)
iodata = Web.CSV.dump_to_iodata(dbg([fields] ++ rows))
conn
|> put_resp_content_type("text/csv")
|> put_resp_header("content-disposition", "attachment; filename=\"export.csv\"")
|> put_root_layout(false)
|> send_resp(200, iodata)
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end
end
end

View File

@@ -0,0 +1,111 @@
defmodule Web.Flows.Show do
use Web, :live_view
import Web.Policies.Components
alias Domain.{Flows, Flows}
def mount(%{"id" => id}, _session, socket) do
with {:ok, flow} <-
Flows.fetch_flow_by_id(id, socket.assigns.subject,
preload: [
policy: [:resource, :actor_group],
client: [],
gateway: [:group],
resource: []
]
) do
{:ok, socket, temporary_assigns: [flow: flow]}
else
{:error, _reason} -> raise Web.LiveErrors.NotFoundError
end
end
def render(assigns) do
~H"""
<.breadcrumbs account={@account}>
<.breadcrumb>Flows</.breadcrumb>
<.breadcrumb path={~p"/#{@account}/flows/#{@flow.id}"}>
<%= @flow.client.name %> flow
</.breadcrumb>
</.breadcrumbs>
<.page>
<:title>
Flow for: <code><%= @flow.client.name %></code>
</:title>
<:action
navigate={~p"/#{@account}/flows/#{@flow}/activities.csv"}
icon="hero-arrow-down-on-square"
>
Export to CSV
</:action>
<:content flash={@flash}>
<.vertical_table id="flow">
<.vertical_table_row>
<:label>Authorized At</:label>
<:value>
<.relative_datetime datetime={@flow.inserted_at} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Expires At</:label>
<:value>
<.relative_datetime datetime={@flow.expires_at} />
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Policy</:label>
<:value>
<.link
navigate={~p"/#{@account}/policies/#{@flow.policy_id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<.policy_name policy={@flow.policy} />
</.link>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Client</:label>
<:value>
<.link
navigate={~p"/#{@account}/clients/#{@flow.client_id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= @flow.client.name %>
</.link>
<div>Remote IP: <%= @flow.client_remote_ip %></div>
<div>User Agent: <%= @flow.client_user_agent %></div>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Gateway</:label>
<:value>
<.link
navigate={~p"/#{@account}/gateways/#{@flow.gateway_id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= @flow.gateway.group.name_prefix %>-<%= @flow.gateway.name_suffix %>
</.link>
<div>
Remote IP: <%= @flow.gateway_remote_ip %>
</div>
</:value>
</.vertical_table_row>
<.vertical_table_row>
<:label>Resource</:label>
<:value>
<.link
navigate={~p"/#{@account}/resources/#{@flow.resource_id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
<%= @flow.resource.name %>
</.link>
</:value>
</.vertical_table_row>
</.vertical_table>
</:content>
</.page>
"""
end
end

View File

@@ -163,6 +163,14 @@ defmodule Web.Gateways.Show do
</.link>
(<%= flow.client_remote_ip %>)
</:col>
<:col :let={flow} label="ACTIVITY">
<.link
navigate={~p"/#{@account}/flows/#{flow.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Show
</.link>
</:col>
</.table>
</div>

View File

@@ -134,6 +134,14 @@ defmodule Web.Policies.Show do
</.link>
(<%= flow.gateway_remote_ip %>)
</:col>
<:col :let={flow} label="ACTIVITY">
<.link
navigate={~p"/#{@account}/flows/#{flow.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Show
</.link>
</:col>
</.table>
<.header>

View File

@@ -159,6 +159,14 @@ defmodule Web.Resources.Show do
</.link>
(<%= flow.gateway_remote_ip %>)
</:col>
<:col :let={flow} label="ACTIVITY">
<.link
navigate={~p"/#{@account}/flows/#{flow.id}"}
class="font-medium text-blue-600 dark:text-blue-500 hover:underline"
>
Show
</.link>
</:col>
</.table>
</div>

View File

@@ -169,6 +169,11 @@ defmodule Web.Router do
live "/:id", Show
end
scope "/flows", Flows do
live "/:id", Show
get "/:id/activities.csv", DownloadActivities, :download
end
scope "/settings", Settings do
live "/account", Account

View File

@@ -50,9 +50,10 @@ defmodule Web.MixProject do
{:gettext, "~> 0.20"},
{:remote_ip, "~> 1.0"},
# CLDR
# CLDR and unit conversions
{:ex_cldr_dates_times, "~> 2.13"},
{:ex_cldr_numbers, "~> 2.31"},
{:sizeable, "~> 1.0"},
# Asset pipeline deps
{:esbuild, "~> 0.7", runtime: Mix.env() == :dev},
@@ -77,6 +78,7 @@ defmodule Web.MixProject do
# Other deps
{:jason, "~> 1.2"},
{:file_size, "~> 3.0.1"},
{:nimble_csv, "~> 1.2"},
# Test deps
{:floki, ">= 0.30.0", only: :test},

View File

@@ -65,6 +65,7 @@
"mimerl": {:hex, :mimerl, "1.2.0", "67e2d3f571088d5cfd3e550c383094b47159f3eee8ffa08e64106cdf5e981be3", [:rebar3], [], "hexpm", "f278585650aa581986264638ebf698f8bb19df297f66ad91b18910dfc6e19323"},
"mint": {:hex, :mint, "1.5.1", "8db5239e56738552d85af398798c80648db0e90f343c8469f6c6d8898944fb6f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:hpax, "~> 0.1.1", [hex: :hpax, repo: "hexpm", optional: false]}], "hexpm", "4a63e1e76a7c3956abd2c72f370a0d0aecddc3976dea5c27eccbecfa5e7d5b1e"},
"mix_audit": {:hex, :mix_audit, "2.1.1", "653aa6d8f291fc4b017aa82bdb79a4017903902ebba57960ef199cbbc8c008a1", [:make, :mix], [{:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: false]}, {:yaml_elixir, "~> 2.9", [hex: :yaml_elixir, repo: "hexpm", optional: false]}], "hexpm", "541990c3ab3a7bb8c4aaa2ce2732a4ae160ad6237e5dcd5ad1564f4f85354db1"},
"nimble_csv": {:hex, :nimble_csv, "1.2.0", "4e26385d260c61eba9d4412c71cea34421f296d5353f914afe3f2e71cce97722", [:mix], [], "hexpm", "d0628117fcc2148178b034044c55359b26966c6eaa8e2ce15777be3bbc91b12a"},
"nimble_options": {:hex, :nimble_options, "1.0.2", "92098a74df0072ff37d0c12ace58574d26880e522c22801437151a159392270e", [:mix], [], "hexpm", "fd12a8db2021036ce12a309f26f564ec367373265b53e25403f0ee697380f1b8"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
"nimble_pool": {:hex, :nimble_pool, "1.0.0", "5eb82705d138f4dd4423f69ceb19ac667b3b492ae570c9f5c900bb3d2f50a847", [:mix], [], "hexpm", "80be3b882d2d351882256087078e1b1952a28bf98d0a287be87e4a24a710b67a"},
@@ -104,6 +105,7 @@
"remote_ip": {:hex, :remote_ip, "1.1.0", "cb308841595d15df3f9073b7c39243a1dd6ca56e5020295cb012c76fbec50f2d", [:mix], [{:combine, "~> 0.10", [hex: :combine, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "616ffdf66aaad6a72fc546dabf42eed87e2a99e97b09cbd92b10cc180d02ed74"},
"rustler_precompiled": {:hex, :rustler_precompiled, "0.5.5", "a075a92c8e748ce5c4f7b2cf573a072d206a6d8d99c53f627e81d3f2b10616a3", [:mix], [{:castore, "~> 0.1", [hex: :castore, repo: "hexpm", optional: false]}, {:rustler, "~> 0.23", [hex: :rustler, repo: "hexpm", optional: true]}], "hexpm", "e8a7f1abfec8d68683bb25d14efc88496f091ef113f7f4c45d39f3606f7223f6"},
"samly": {:git, "https://github.com/firezone/samly.git", "4603438ed4a95ed74d6c0232676c24d097e2feec", []},
"sizeable": {:hex, :sizeable, "1.0.2", "625fe06a5dad188b52121a140286f1a6ae1adf350a942cf419499ecd8a11ee29", [:mix], [], "hexpm", "4bab548e6dfba777b400ca50830a9e3a4128e73df77ab1582540cf5860601762"},
"sobelow": {:hex, :sobelow, "0.13.0", "218afe9075904793f5c64b8837cc356e493d88fddde126a463839351870b8d1e", [:mix], [{:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "cd6e9026b85fc35d7529da14f95e85a078d9dd1907a9097b3ba6ac7ebbe34a0d"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"sweet_xml": {:hex, :sweet_xml, "0.7.3", "debb256781c75ff6a8c5cbf7981146312b66f044a2898f453709a53e5031b45b", [:mix], [], "hexpm", "e110c867a1b3fe74bfc7dd9893aa851f0eed5518d0d7cad76d7baafd30e4f5ba"},