backend: live connection status!

This commit is contained in:
Nikos Papadakis 2023-07-09 19:41:41 +03:00
parent e12e20eb38
commit a8699035d8
Signed by untrusted user who does not match committer: nikos
GPG key ID: 78871F9905ADFF02
9 changed files with 135 additions and 45 deletions

View file

@ -1,6 +1,9 @@
defmodule Prymn.Agents.Connection do defmodule Prymn.Agents.Connection do
@moduledoc false @moduledoc false
# TODO: Disconnect after a while of idling. Disconnect when the healthcheck
# fails too many times.
defstruct [:channel, up?: false] defstruct [:channel, up?: false]
@ping_interval 20000 @ping_interval 20000
@ -46,15 +49,27 @@ defmodule Prymn.Agents.Connection do
end end
@impl true @impl true
def handle_info({:gun_up, _pid, _protocol}, state) do def handle_info({:gun_up, _pid, _protocol}, %{channel: channel} = state) do
Logger.info("regained connection (#{inspect(state.channel)})") Logger.info("[Agent] #{state.channel.host} regained connection")
Phoenix.PubSub.broadcast!(
Prymn.PubSub,
"agent:#{channel.host}",
{:healthcheck, :up, channel.host}
)
{:noreply, %{state | up?: true}} {:noreply, %{state | up?: true}}
end end
@impl true @impl true
def handle_info({:gun_down, _pid, _protocol, reason, _killed_streams}, state) do def handle_info({:gun_down, _pid, _proto, reason, _}, %{channel: channel} = state) do
Logger.info("lost connection (reason: #{inspect(reason)}, #{inspect(state.channel)})") Logger.info("[Agent] #{channel.host} lost connection, reason: #{reason}")
Phoenix.PubSub.broadcast!(
Prymn.PubSub,
"agent:#{channel.host}",
{:healthcheck, :down, channel.host}
)
{:noreply, %{state | up?: false}} {:noreply, %{state | up?: false}}
end end
@ -67,8 +82,6 @@ defmodule Prymn.Agents.Connection do
{:ok, _reply} -> {:ok, _reply} ->
:noop :noop
# IO.inspect(reply)
{:error, error} -> {:error, error} ->
Logger.warning("healthcheck error for server #{channel.host}, reason: #{inspect(error)}") Logger.warning("healthcheck error for server #{channel.host}, reason: #{inspect(error)}")
end end

View file

@ -37,6 +37,12 @@ defmodule Prymn.Servers do
""" """
def get_server!(id), do: Repo.get!(Server, id) def get_server!(id), do: Repo.get!(Server, id)
@doc """
Get a single server by its IP.
"""
@spec get_server_by_ip!(String.t()) :: %Server{}
def get_server_by_ip!(ip), do: Repo.get_by!(Server, public_ip: ip)
@doc """ @doc """
Start a new server connection with the app. Start a new server connection with the app.
@ -52,16 +58,19 @@ defmodule Prymn.Servers do
Registers a server using a registration token. Registers a server using a registration token.
""" """
def register_server(token, public_ip) do def register_server(token, public_ip) do
# TODO: Validate public ip with true <- :inet.is_ip_address(public_ip),
{:ok, token} <- Base.decode64(token) do
public_ip_string =
public_ip
|> :inet.ntoa()
|> to_string()
case Base.decode64(token) do from(s in Server, where: s.registration_token == ^token, select: s)
{:ok, token} -> |> Repo.one!()
from(s in Server, where: s.registration_token == ^token, select: s) |> update_server(%{public_ip: public_ip_string, status: :registered})
|> Repo.one() else
|> update_server(%{public_ip: public_ip}) false -> {:error, :invalid_ip}
:error -> {:error, :bad_token}
:error ->
{:error, "token is not a valid base64 encoded string"}
end end
end end
@ -111,4 +120,26 @@ defmodule Prymn.Servers do
def change_server(%Server{} = server, attrs \\ %{}) do def change_server(%Server{} = server, attrs \\ %{}) do
Server.changeset(server, attrs) Server.changeset(server, attrs)
end end
@doc """
Returns a string containing the command that needs to be executed to the
remote server in order to register it to the backend.
"""
@spec create_setup_command(%Server{}) :: String.t()
def create_setup_command(%Server{registration_token: token}) do
build_command = fn token ->
if Application.get_env(:prymn, :environment) == :prod do
"curl -sSf https://static.prymn.net/get_prymn.sh | sh -s " <> token
else
# On a dev environment we want to download the local version of the agent
agent_path = Path.expand("../agent/target/debug/prymn_agent")
get_prymn_path = Path.expand("../agent/get_prymn.sh")
"GET_PRYMN_ROOT=file://#{agent_path} #{get_prymn_path} #{token}"
end
end
token
|> Base.encode64()
|> then(build_command)
end
end end

View file

@ -8,9 +8,11 @@ defmodule Prymn.Servers.Server do
field :provider, Ecto.Enum, values: [:Hetzner, :Custom] field :provider, Ecto.Enum, values: [:Hetzner, :Custom]
field :registration_token, :binary, redact: true field :registration_token, :binary, redact: true
field :connection_status, Ecto.Enum, field :status, Ecto.Enum,
values: [:awaiting, :connecting, :installing, :connected, :disconnected], values: [:unregistered, :registered],
default: :awaiting default: :unregistered
field :connection_status, :string, virtual: true
timestamps() timestamps()
end end
@ -18,8 +20,9 @@ defmodule Prymn.Servers.Server do
@doc false @doc false
def changeset(server, attrs) do def changeset(server, attrs) do
server server
|> cast(attrs, [:name, :provider, :registration_token]) |> cast(attrs, [:name, :public_ip, :provider, :registration_token, :status])
|> validate_required([:name, :provider]) |> validate_required([:name, :provider])
|> validate_inclusion(:provider, [:Custom], message: "Provider not available (yet)") |> validate_inclusion(:provider, [:Custom], message: "Provider not available (yet)")
|> unique_constraint([:public_ip])
end end
end end

View file

@ -1,19 +1,36 @@
defmodule PrymnWeb.ServerController do defmodule PrymnWeb.ServerController do
use PrymnWeb, :controller
alias Prymn.Servers alias Prymn.Servers
require Logger
use PrymnWeb, :controller
@doc """ @doc """
Used by clients to request a new server connection to the prymn backend Used by clients to request a new server connection to the prymn backend
validating their registration token. validating their registration token.
""" """
def register(conn, %{"token" => token, "ip" => ip}) do def register(conn, %{"token" => token}) do
case Servers.register_server(token, ip) do case Servers.register_server(token, conn.remote_ip) do
{:ok, _server} -> {:ok, _server} ->
json(conn, %{"connected" => true}) json(conn, %{"connected" => true})
{:error, :invalid_ip} ->
Logger.error("could not register a server because we received an invalid ip")
put_status(conn, 500)
|> json(%{"errors" => ["invalid ip received"]})
{:error, :bad_token} ->
put_status(conn, 400)
|> json(%{"errors" => %{"token" => "token is not valid"}})
{:error, %Ecto.Changeset{} = changeset} ->
errors = Ecto.Changeset.traverse_errors(changeset, fn {msg, _} -> msg end)
put_status(conn, 400)
|> json(%{"errors" => errors})
{:error, error} -> {:error, error} ->
raise inspect(error) raise "An unhandled error was received #{inspect(error)}"
end end
end end
end end

View file

@ -1,15 +1,25 @@
defmodule PrymnWeb.ServerLive.Index do defmodule PrymnWeb.ServerLive.Index do
use PrymnWeb, :live_view
alias Prymn.Servers alias Prymn.Servers
use PrymnWeb, :live_view
@impl true @impl true
def mount(_params, _session, socket) do def mount(_params, _session, socket) do
# Run this for every server: servers = Servers.list_servers()
# make sure an agent connection is made (async "cheap" request) pid = self()
# then wait for events
# pubsub will eventually send a connected or a disconnected (and anything else) event for %{public_ip: public_ip} <- servers, public_ip != nil do
{:ok, stream(socket, :servers, Servers.list_servers())} :ok = Phoenix.PubSub.subscribe(Prymn.PubSub, "agent:#{public_ip}")
Task.start_link(fn ->
case Prymn.Agents.ensure_connection("#{public_ip}:50012") do
:ok -> Process.send(pid, {:healthcheck, :up, public_ip}, [])
{:error, _error} -> Process.send(pid, {:healthcheck, :down, public_ip}, [])
end
end)
end
{:ok, stream(socket, :servers, servers)}
end end
@impl true @impl true
@ -29,12 +39,22 @@ defmodule PrymnWeb.ServerLive.Index do
@impl true @impl true
def handle_info({:connect, %Servers.Server{} = server}, socket) do def handle_info({:connect, %Servers.Server{} = server}, socket) do
socket = {:noreply, stream_insert(socket, :servers, server)}
if server.provider == :Custom, end
do: push_navigate(socket, to: ~p"/servers/#{server}"),
else: stream_insert(socket, :servers, server)
{:noreply, socket} @impl true
def handle_info({:healthcheck, status, ip}, socket) do
server = Servers.get_server_by_ip!(ip)
status =
case status do
:up -> "Connected"
:down -> "Disconnected"
end
{:noreply,
socket
|> stream_insert(:servers, Map.put(server, :connection_status, status))}
end end
@impl true @impl true

View file

@ -13,19 +13,23 @@
row_click={fn {_id, server} -> JS.navigate(~p"/servers/#{server}") end} row_click={fn {_id, server} -> JS.navigate(~p"/servers/#{server}") end}
row_indicator={ row_indicator={
fn fn
{_id, %Servers.Server{connection_status: :awaiting}} -> {_id, %Servers.Server{status: :unregistered}} ->
~H(<span class="text-grey-600">Awaiting connection</span>) ~H(<span class="text-grey-600">Awaiting registration</span>)
{_id, %Servers.Server{connection_status: :connecting}} -> {_id, %Servers.Server{connection_status: nil, status: :registered}} ->
~H(<span class="text-purple-600">Connecting</span>) ~H(<span class="text-yellow-600">Connecting...</span>)
{_id, %Servers.Server{connection_status: :connected}} -> {_id, %Servers.Server{connection_status: "Connected"}} ->
~H(<span class="text-green-600">Connected</span>) ~H(<span class="text-green-600">Connected</span>)
{_id, %Servers.Server{connection_status: "Disconnected"}} ->
~H(<span class="text-red-600">Disconnected</span>)
end end
} }
indicator_label="Status" indicator_label="Status"
> >
<:col :let={{_id, server}} label="Name"><%= server.name %></:col> <:col :let={{_id, server}} label="Name"><%= server.name %></:col>
<:col :let={{_id, server}} label="IP"><%= server.public_ip || "N/A" %></:col>
<:action :let={{id, server}}> <:action :let={{id, server}}>
<.link <.link
phx-click={JS.push("delete", value: %{id: server.id}) |> hide("##{id}")} phx-click={JS.push("delete", value: %{id: server.id}) |> hide("##{id}")}

View file

@ -15,6 +15,7 @@ defmodule PrymnWeb.ServerLive.Show do
{:noreply, {:noreply,
socket socket
|> assign(:page_title, server.name) |> assign(:page_title, server.name)
|> assign(:server, server)} |> assign(:server, server)
|> assign(:registration_command, Servers.create_setup_command(server))}
end end
end end

View file

@ -2,7 +2,7 @@
Server <%= @server.name %> Server <%= @server.name %>
</.header> </.header>
<section :if={@server.connection_status == :awaiting} class="my-10"> <section :if={@server.status == :unregistered} class="my-10">
<p class="mb-9"> <p class="mb-9">
Connect to your server using root credentials and execute the following command: Connect to your server using root credentials and execute the following command:
</p> </p>
@ -10,7 +10,7 @@
<code class="flex gap-4"> <code class="flex gap-4">
<span class="select-none text-gray-500">#</span> <span class="select-none text-gray-500">#</span>
<span class="flex-1"> <span class="flex-1">
your command here that contains token: <%= Base.encode64(@server.registration_token) %> <%= @registration_command %>
</span> </span>
</code> </code>
<button type="button" tabindex="-1"> <button type="button" tabindex="-1">

View file

@ -6,12 +6,13 @@ defmodule Prymn.Repo.Migrations.CreateServers do
add :name, :string add :name, :string
add :public_ip, :string add :public_ip, :string
add :provider, :string add :provider, :string
add :connection_status, :string add :status, :string
add :registration_token, :binary add :registration_token, :binary
timestamps() timestamps()
end end
create index("servers", [:registration_token], unique: true) create index("servers", [:registration_token], unique: true)
create index("servers", [:public_ip], unique: true)
end end
end end