Merge pull request 'Show a dynamically updated list of NTS servers' (#1) from liveview into main

Reviewed-on: #1
This commit is contained in:
Mike Cifelli 2024-07-26 09:35:19 -04:00
commit fff1eab6fa
35 changed files with 629 additions and 61 deletions

View File

@ -14,7 +14,7 @@ module.exports = {
theme: { theme: {
extend: { extend: {
colors: { colors: {
brand: "#FD4F00", brand: "#DC7556",
} }
}, },
}, },

View File

@ -30,6 +30,9 @@ config :chronoscope, ChronoscopeWeb.Endpoint,
# at the `config/runtime.exs`. # at the `config/runtime.exs`.
config :chronoscope, Chronoscope.Mailer, adapter: Swoosh.Adapters.Local config :chronoscope, Chronoscope.Mailer, adapter: Swoosh.Adapters.Local
config :chronoscope, :nts_topic, "nts-servers"
config :chronoscope, :nts_file, "priv/nts.txt"
# Configure esbuild (the version is required) # Configure esbuild (the version is required)
config :esbuild, config :esbuild,
version: "0.17.11", version: "0.17.11",

View File

@ -33,13 +33,15 @@ if config_env() == :prod do
You can generate one by calling: mix phx.gen.secret You can generate one by calling: mix phx.gen.secret
""" """
host = System.get_env("PHX_HOST") || "example.com" host = System.get_env("PHX_HOST") || "localhost"
aux_host = System.get_env("PHX_AUX_HOST") || host
port = String.to_integer(System.get_env("PORT") || "4000") port = String.to_integer(System.get_env("PORT") || "4000")
config :chronoscope, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :chronoscope, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY")
config :chronoscope, ChronoscopeWeb.Endpoint, config :chronoscope, ChronoscopeWeb.Endpoint,
url: [host: host, port: 443, scheme: "https"], url: [host: host, port: 443, scheme: "https"],
check_origin: ["https://" <> host, "https://" <> aux_host],
http: [ http: [
# Enable IPv6 and bind on all interfaces. # Enable IPv6 and bind on all interfaces.
# Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access.

View File

@ -10,6 +10,8 @@ config :chronoscope, ChronoscopeWeb.Endpoint,
# In test we don't send emails. # In test we don't send emails.
config :chronoscope, Chronoscope.Mailer, adapter: Swoosh.Adapters.Test config :chronoscope, Chronoscope.Mailer, adapter: Swoosh.Adapters.Test
config :chronoscope, :nts_file, "priv/nts-test.txt"
# Disable swoosh api client as it is only required for production adapters. # Disable swoosh api client as it is only required for production adapters.
config :swoosh, :api_client, false config :swoosh, :api_client, false
@ -26,4 +28,5 @@ config :chronoscope,
registry: Chronoscope.RegistryMock, registry: Chronoscope.RegistryMock,
gen_server: Chronoscope.GenServerMock, gen_server: Chronoscope.GenServerMock,
nts: Chronoscope.NTSMock, nts: Chronoscope.NTSMock,
gemini: Chronoscope.GeminiMock gemini: Chronoscope.GeminiMock,
endpoint: Chronoscope.EndpointMock

View File

@ -22,7 +22,9 @@ defmodule Chronoscope.Application do
{Task.Supervisor, name: Chronoscope.Gemini.TaskSupervisor}, {Task.Supervisor, name: Chronoscope.Gemini.TaskSupervisor},
{Registry, [keys: :unique, name: Chronoscope.Gemini.Registry]}, {Registry, [keys: :unique, name: Chronoscope.Gemini.Registry]},
# Start to serve requests, typically the last entry # Start to serve requests, typically the last entry
ChronoscopeWeb.Endpoint ChronoscopeWeb.Endpoint,
# Initialize clients used in the main LiveView
ChronoscopeWeb.ClientActivator
] ]
# See https://hexdocs.pm/elixir/Supervisor.html # See https://hexdocs.pm/elixir/Supervisor.html

View File

@ -9,6 +9,8 @@ defmodule Chronoscope.Gemini do
alias Chronoscope.Gemini alias Chronoscope.Gemini
@timeout_in_milliseconds 10_000
@registry Application.compile_env(:chronoscope, :registry, Registry) @registry Application.compile_env(:chronoscope, :registry, Registry)
@genserver Application.compile_env(:chronoscope, :gen_server, GenServer) @genserver Application.compile_env(:chronoscope, :gen_server, GenServer)
@dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor) @dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor)
@ -20,14 +22,14 @@ defmodule Chronoscope.Gemini do
def list() do def list() do
Gemini.DynamicSupervisor Gemini.DynamicSupervisor
|> @dynamic_supervisor.which_children() |> @dynamic_supervisor.which_children()
|> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) end) |> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list, @timeout_in_milliseconds) end)
end end
def remove(host, port, path) do def remove(host, port, path) do
name = client_name(%{host: host, port: port, path: path}) name = client_name(%{host: host, port: port, path: path})
case @registry.lookup(Gemini.Registry, name) do case @registry.lookup(Gemini.Registry, name) do
[{pid, _}] -> {:ok, @genserver.call(pid, :terminate)} [{pid, _}] -> {:ok, @genserver.call(pid, :terminate, @timeout_in_milliseconds)}
[] -> {:error, :not_found} [] -> {:error, :not_found}
end end
end end
@ -36,7 +38,7 @@ defmodule Chronoscope.Gemini do
def connect(host, port, path) do def connect(host, port, path) do
%{host: host, port: port, path: path} %{host: host, port: port, path: path}
|> client_pid() |> client_pid()
|> @genserver.call(:connect) |> @genserver.call(:connect, @timeout_in_milliseconds)
end end
defp client_pid(resource) do defp client_pid(resource) do

View File

@ -5,6 +5,7 @@ defmodule Chronoscope.Gemini.Client do
alias Chronoscope.Gemini.ConnectionClient alias Chronoscope.Gemini.ConnectionClient
@interval_in_seconds 30 @interval_in_seconds 30
@timeout_in_milliseconds 10_000
@date_time Application.compile_env(:chronoscope, :date_time, DateTime) @date_time Application.compile_env(:chronoscope, :date_time, DateTime)
@ -61,7 +62,7 @@ defmodule Chronoscope.Gemini.Client do
defp server_response(%{resource: resource}) do defp server_response(%{resource: resource}) do
Gemini.TaskSupervisor Gemini.TaskSupervisor
|> Task.Supervisor.async(fn -> ConnectionClient.connect(resource) end) |> Task.Supervisor.async(fn -> ConnectionClient.connect(resource) end)
|> Task.await() |> Task.await(@timeout_in_milliseconds)
end end
defp interval_surpassed?(now, last_request) do defp interval_surpassed?(now, last_request) do

View File

@ -9,6 +9,8 @@ defmodule Chronoscope.NTS do
alias Chronoscope.NTS alias Chronoscope.NTS
@timeout_in_milliseconds 10_000
@registry Application.compile_env(:chronoscope, :registry, Registry) @registry Application.compile_env(:chronoscope, :registry, Registry)
@genserver Application.compile_env(:chronoscope, :gen_server, GenServer) @genserver Application.compile_env(:chronoscope, :gen_server, GenServer)
@dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor) @dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor)
@ -23,11 +25,33 @@ defmodule Chronoscope.NTS do
|> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) end) |> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) end)
end end
def list_clients(servers) do
servers
|> Enum.map(&client_pid/1)
|> Enum.map(fn pid -> @genserver.call(pid, :list, @timeout_in_milliseconds) end)
end
def start_client(server) do
client_pid(server)
end
def auto_refresh(server) do
server
|> client_pid()
|> @genserver.call(:auto_refresh, @timeout_in_milliseconds)
end
def cancel_auto_refresh(server) do
server
|> client_pid()
|> @genserver.call(:cancel_auto_refresh, @timeout_in_milliseconds)
end
def remove(host, port) do def remove(host, port) do
name = client_name(%{host: host, port: port}) name = client_name(%{host: host, port: port})
case @registry.lookup(NTS.Registry, name) do case @registry.lookup(NTS.Registry, name) do
[{pid, _}] -> {:ok, @genserver.call(pid, :terminate)} [{pid, _}] -> {:ok, @genserver.call(pid, :terminate, @timeout_in_milliseconds)}
[] -> {:error, :not_found} [] -> {:error, :not_found}
end end
end end
@ -36,7 +60,7 @@ defmodule Chronoscope.NTS do
def key_establishment(host, port) do def key_establishment(host, port) do
%{host: host, port: port} %{host: host, port: port}
|> client_pid() |> client_pid()
|> @genserver.call(:key_establishment) |> @genserver.call(:key_establishment, @timeout_in_milliseconds)
end end
defp client_pid(server) do defp client_pid(server) do

View File

@ -5,8 +5,12 @@ defmodule Chronoscope.NTS.Client do
alias Chronoscope.NTS.KeyEstablishmentClient alias Chronoscope.NTS.KeyEstablishmentClient
@interval_in_seconds 30 @interval_in_seconds 30
@timeout_in_milliseconds 10_000
@refresh_interval_in_milliseconds :timer.minutes(1)
@topic Application.compile_env(:chronoscope, :nts_topic)
@date_time Application.compile_env(:chronoscope, :date_time, DateTime) @date_time Application.compile_env(:chronoscope, :date_time, DateTime)
@endpoint Application.compile_env(:chronoscope, :endpoint, ChronoscopeWeb.Endpoint)
def start_link(server: server, name: name) do def start_link(server: server, name: name) do
GenServer.start_link(__MODULE__, server, name: name) GenServer.start_link(__MODULE__, server, name: name)
@ -14,13 +18,13 @@ defmodule Chronoscope.NTS.Client do
@impl true @impl true
def init(server) do def init(server) do
now = utc_now() @endpoint.broadcast(@topic, "initializing", server)
{:ok, {:ok,
%{ %{
server: server, server: server,
key_establishment_response: {:error, "initializing"}, key_establishment_response: {:error, "initializing"},
last_key_establishment: DateTime.add(now, -@interval_in_seconds, :second) last_key_establishment: DateTime.add(utc_now(), -@interval_in_seconds, :second)
}} }}
end end
@ -34,6 +38,32 @@ defmodule Chronoscope.NTS.Client do
{:reply, state, state} {:reply, state, state}
end end
@impl true
def handle_call(:auto_refresh, _from, %{timer: _timer} = state) do
{:reply, :already_started, state}
end
@impl true
def handle_call(:auto_refresh, _from, state) do
:timer.send_after(1_000, :key_establishment)
{:ok, timer} = :timer.send_interval(@refresh_interval_in_milliseconds, :key_establishment)
{:reply, :ok, Map.put(state, :timer, timer)}
end
@impl true
def handle_call(:cancel_auto_refresh, _from, %{timer: timer} = state) do
:timer.cancel(timer)
@endpoint.broadcast(@topic, "cancel-auto-refresh", state.server)
{:reply, :ok, Map.delete(state, :timer)}
end
@impl true
def handle_call(:cancel_auto_refresh, _from, state) do
{:reply, :already_cancelled, state}
end
@impl true @impl true
def handle_call(:key_establishment, _from, state) do def handle_call(:key_establishment, _from, state) do
new_state = update_state(state) new_state = update_state(state)
@ -41,11 +71,18 @@ defmodule Chronoscope.NTS.Client do
{:reply, new_state.key_establishment_response, new_state} {:reply, new_state.key_establishment_response, new_state}
end end
@impl true
def handle_info(:key_establishment, state) do
{:noreply, update_state(state)}
end
defp update_state(state) do defp update_state(state) do
now = utc_now() now = utc_now()
if interval_surpassed?(now, state.last_key_establishment) do if interval_surpassed?(now, state.last_key_establishment) do
Map.merge(state, current_data(state, now)) state
|> Map.merge(current_data(state, now))
|> tap(&@endpoint.broadcast(@topic, "key-establishment", Map.delete(&1, :timer)))
else else
state state
end end
@ -61,7 +98,7 @@ defmodule Chronoscope.NTS.Client do
defp server_response(%{server: server}) do defp server_response(%{server: server}) do
NTS.TaskSupervisor NTS.TaskSupervisor
|> Task.Supervisor.async(fn -> KeyEstablishmentClient.key_establishment(server) end) |> Task.Supervisor.async(fn -> KeyEstablishmentClient.key_establishment(server) end)
|> Task.await() |> Task.await(@timeout_in_milliseconds)
end end
defp interval_surpassed?(now, last_key_establishment) do defp interval_surpassed?(now, last_key_establishment) do

View File

@ -5,7 +5,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentClient do
alias Chronoscope.NTS.KeyEstablishmentRequest alias Chronoscope.NTS.KeyEstablishmentRequest
alias Chronoscope.NTS.KeyEstablishmentResponse alias Chronoscope.NTS.KeyEstablishmentResponse
@timeout_in_milliseconds 3000 @timeout_in_milliseconds 3500
@ssl Application.compile_env(:chronoscope, :ssl, :ssl) @ssl Application.compile_env(:chronoscope, :ssl, :ssl)
@ -33,12 +33,16 @@ defmodule Chronoscope.NTS.KeyEstablishmentClient do
end end
defp perform_key_establishment(socket) do defp perform_key_establishment(socket) do
:ok = @ssl.send(socket, KeyEstablishmentRequest.create()) with :ok <- @ssl.send(socket, KeyEstablishmentRequest.create()),
{:ok, peercert} = @ssl.peercert(socket) {:ok, peercert} <- @ssl.peercert(socket) do
peercert peercert
|> await_response() |> await_response()
|> tap(fn _ -> @ssl.close(socket) end) |> tap(fn _ -> @ssl.close(socket) end)
else
e ->
@ssl.close(socket)
{:error, e}
end
end end
defp await_response(peercert) do defp await_response(peercert) do

View File

@ -1,7 +1,7 @@
defmodule Chronoscope.NTS.KeyEstablishmentResponse do defmodule Chronoscope.NTS.KeyEstablishmentResponse do
import Bitwise import Bitwise
@aead_alogorithms %{ @aead_algorithms %{
15 => "AEAD_AES_SIV_CMAC_256", 15 => "AEAD_AES_SIV_CMAC_256",
30 => "AEAD_AES_128_GCM_SIV" 30 => "AEAD_AES_128_GCM_SIV"
} }
@ -16,6 +16,12 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponse do
2 => "Internal Server Error" 2 => "Internal Server Error"
} }
def aead_algorithm_to_id(algorithm) do
@aead_algorithms
|> Map.new(fn {k, v} -> {v, k} end)
|> Map.get(algorithm)
end
def parse(response) do def parse(response) do
parse_response(response, %{}) parse_response(response, %{})
end end
@ -127,7 +133,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponse do
end end
defp do_parse_aead_algorithm_list([high, low | rest], acc) do defp do_parse_aead_algorithm_list([high, low | rest], acc) do
@aead_alogorithms @aead_algorithms
|> Map.get(combine_octets(high, low), "UNKNOWN") |> Map.get(combine_octets(high, low), "UNKNOWN")
|> then(&do_parse_aead_algorithm_list(rest, [&1 | acc])) |> then(&do_parse_aead_algorithm_list(rest, [&1 | acc]))
end end

View File

@ -0,0 +1,10 @@
defmodule Chronoscope.NTS.Parse do
@default_port 4460
def parse_nts_server(server) do
case String.split(server, ":") do
[host, port] -> %{host: host, port: String.to_integer(port)}
[host] -> %{host: host, port: @default_port}
end
end
end

View File

@ -0,0 +1,67 @@
defmodule ChronoscopeWeb.ClientActivator do
use GenServer
require Logger
alias Chronoscope.NTS
alias Chronoscope.NTS.Parse
alias ChronoscopeWeb.Endpoint
@topic Application.compile_env(:chronoscope, :nts_topic)
@nts_file Application.compile_env(:chronoscope, :nts_file)
def start_link(_) do
GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
end
@impl true
def init(_) do
Endpoint.subscribe(@topic)
{:ok, %{nts_servers: nts_servers() |> tap(&start_clients/1)}}
end
@impl true
def handle_info(%{topic: @topic, event: "initializing", payload: server}, state) do
if server in state.nts_servers do
Logger.info("activating #{inspect(server)}")
NTS.auto_refresh(server)
end
{:noreply, state}
end
@impl true
def handle_info(%{topic: @topic, event: "cancel-auto-refresh", payload: server}, state) do
if server in state.nts_servers do
Logger.info("#{inspect(server)} was deactivated")
end
{:noreply, state}
end
@impl true
def handle_info(_, state) do
{:noreply, state}
end
@impl true
def handle_call(:get_nts_servers, _from, state) do
{:reply, state.nts_servers, state}
end
defp start_clients(servers) do
Enum.each(servers, &NTS.start_client/1)
end
defp nts_servers() do
File.touch(Application.app_dir(:chronoscope, @nts_file))
Application.app_dir(:chronoscope, @nts_file)
|> File.stream!()
|> Stream.map(&String.trim/1)
|> Stream.filter(&(&1 != ""))
|> Stream.map(&Parse.parse_nts_server/1)
|> Enum.to_list()
end
end

View File

@ -1,28 +1,22 @@
<header class="px-4 sm:px-6 lg:px-8"> <header class="px-4 sm:px-6 lg:px-8 sticky top-0 z-10 bg-white dark:bg-zinc-800">
<div class="flex items-center justify-between border-b border-zinc-100 py-3 text-sm"> <div class="flex items-center justify-between border-b border-zinc-100 dark:border-zinc-700 py-3 text-sm">
<div class="flex items-center gap-4"> <div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900 dark:text-zinc-300 pe-4">
<a href="/"> <a href="/" class="hover:text-zinc-700 dark:hover:text-zinc-400">
<img src={~p"/images/logo.svg"} width="36" /> Chronoscope
</a> </a>
<p class="bg-brand/5 text-brand rounded-full px-2 font-medium leading-6"> <p class="bg-brand/10 dark:bg-brand text-brand dark:text-zinc-900 rounded-full px-2 font-medium leading-6">
v<%= Application.spec(:phoenix, :vsn) %> v<%= Application.spec(:chronoscope, :vsn) %>
</p> </p>
</div> </div>
<div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900"> <div class="flex items-center gap-4 font-semibold leading-6 text-zinc-900 dark:text-zinc-300">
<a href="https://twitter.com/elixirphoenix" class="hover:text-zinc-700"> <a href="https://codeberg.org/mike-cifelli" class="hover:text-zinc-700 dark:hover:text-zinc-400">
@elixirphoenix Codeberg
</a>
<a href="https://github.com/phoenixframework/phoenix" class="hover:text-zinc-700">
GitHub
</a>
<a href="https://hexdocs.pm/phoenix/overview.html" class="rounded-lg bg-zinc-100 px-2 py-1 hover:bg-zinc-200/80">
Get Started <span aria-hidden="true">&rarr;</span>
</a> </a>
</div> </div>
</div> </div>
</header> </header>
<main class="px-4 py-20 sm:px-6 lg:px-8"> <main class="px-4 py-20 sm:px-6 lg:px-8">
<div class="mx-auto max-w-2xl"> <div class="mx-auto max-w-fit">
<.flash_group flash={@flash} /> <.flash_group flash={@flash} />
<%= @inner_content %> <%= @inner_content %>
</div> </div>

View File

@ -11,7 +11,7 @@
<script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}> <script defer phx-track-static type="text/javascript" src={~p"/assets/app.js"}>
</script> </script>
</head> </head>
<body class="bg-white antialiased"> <body class="bg-white dark:bg-zinc-800 dark:text-zinc-300 antialiased font-sans tracking-wide">
<%= @inner_content %> <%= @inner_content %>
</body> </body>
</html> </html>

View File

@ -13,7 +13,7 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionController do
def get(conn, %{"host" => host, "port" => port, "path" => path}) do def get(conn, %{"host" => host, "port" => port, "path" => path}) do
try do try do
handle_get(conn, %{host: host, port: String.to_integer(port), path: path}) handle_get(conn, %{host: String.trim(host), port: String.to_integer(port), path: path})
rescue rescue
ArgumentError -> bad_request_response(conn, "invalid port") ArgumentError -> bad_request_response(conn, "invalid port")
end end
@ -25,20 +25,24 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionController do
def get(conn, %{"host" => host, "port" => port}) do def get(conn, %{"host" => host, "port" => port}) do
try do try do
handle_get(conn, %{host: host, port: String.to_integer(port), path: @default_path}) handle_get(conn, %{host: String.trim(host), port: String.to_integer(port), path: @default_path})
rescue rescue
ArgumentError -> bad_request_response(conn, "invalid port") ArgumentError -> bad_request_response(conn, "invalid port")
end end
end end
def get(conn, %{"host" => host}) do def get(conn, %{"host" => host}) do
handle_get(conn, %{host: host, port: @default_port, path: @default_path}) handle_get(conn, %{host: String.trim(host), port: @default_port, path: @default_path})
end end
def get(conn, _params) do def get(conn, _params) do
bad_request_response(conn, "missing host") bad_request_response(conn, "missing host")
end end
defp handle_get(conn, %{host: ""}) do
bad_request_response(conn, "empty host")
end
defp handle_get(conn, %{host: host, port: port, path: path}) when port > 0 and port < 65536 do defp handle_get(conn, %{host: host, port: port, path: path}) when port > 0 and port < 65536 do
case connect(host, port, path) do case connect(host, port, path) do
{:ok, response} -> {:ok, response} ->

View File

@ -12,20 +12,24 @@ defmodule ChronoscopeWeb.API.V1.NTS.KeyEstablishmentController do
def get(conn, %{"host" => host, "port" => port}) do def get(conn, %{"host" => host, "port" => port}) do
try do try do
handle_get(conn, %{host: host, port: String.to_integer(port)}) handle_get(conn, %{host: String.trim(host), port: String.to_integer(port)})
rescue rescue
ArgumentError -> bad_request_response(conn, "invalid port") ArgumentError -> bad_request_response(conn, "invalid port")
end end
end end
def get(conn, %{"host" => host}) do def get(conn, %{"host" => host}) do
handle_get(conn, %{host: host, port: @default_port}) handle_get(conn, %{host: String.trim(host), port: @default_port})
end end
def get(conn, _params) do def get(conn, _params) do
bad_request_response(conn, "missing host") bad_request_response(conn, "missing host")
end end
defp handle_get(conn, %{host: ""}) do
bad_request_response(conn, "empty host")
end
defp handle_get(conn, %{host: host, port: port}) when port > 0 and port < 65536 do defp handle_get(conn, %{host: host, port: port}) when port > 0 and port < 65536 do
case key_establishment_response(host, port) do case key_establishment_response(host, port) do
{:ok, response} -> {:ok, response} ->

View File

@ -0,0 +1,42 @@
defmodule ChronoscopeWeb.IndexLive do
use ChronoscopeWeb, :live_view
alias Chronoscope.NTS
alias Chronoscope.NTS.KeyEstablishmentResponse
alias ChronoscopeWeb.ClientActivator
alias ChronoscopeWeb.Endpoint
@topic Application.compile_env(:chronoscope, :nts_topic)
@impl true
def mount(_params, _session, socket) do
Endpoint.subscribe(@topic)
{:ok, assign(socket, %{servers: server_list(), clients: client_list()})}
end
@impl true
def handle_info(%{topic: @topic, event: "key-establishment", payload: client}, socket) do
if client.server in socket.assigns.servers do
{:noreply, update(socket, :clients, &update_client(&1, client))}
else
{:noreply, socket}
end
end
@impl true
def handle_info(_, socket) do
{:noreply, socket}
end
defp update_client(client_list, client) do
Enum.map(client_list, &if(client.server == &1.server, do: client, else: &1))
end
defp server_list() do
GenServer.call(ClientActivator, :get_nts_servers)
end
defp client_list() do
server_list() |> NTS.list_clients()
end
end

View File

@ -0,0 +1,101 @@
<div class="overflow-x-auto">
<table class="mx-auto border-collapse table-auto min-w-full divide-y divide-zinc-200 dark:divide-zinc-700 text-left">
<thead>
<tr>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
NTS-KE Server
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Status
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Algorithm
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Cookies
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Cookie Length
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
NTP Host
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
NTP Port
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Certificate Expiration
</th>
<th scope="col" class="py-2 px-6 whitespace-nowrap">
Last Check
</th>
</tr>
</thead>
<tbody>
<tr :for={client <- @clients} class="hover:bg-zinc-100 dark:hover:bg-zinc-700">
<% {status, response} = client.key_establishment_response %>
<%= if (status == :ok) do %>
<td class="py-2 px-6 whitespace-nowrap">
<%= client.server.host %><span :if={client.server.port != 4460}>:<%= client.server.port %></span>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= status %>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<% aead_algorithm = Enum.at(response.aead_algorithms, 0) %>
<span class="group relative">
<%= KeyEstablishmentResponse.aead_algorithm_to_id(aead_algorithm) %>
<span class="pointer-events-none absolute -top-9 left-0 w-max p-1 rounded-lg bg-zinc-300 dark:bg-zinc-600 opacity-0 transition-opacity group-hover:opacity-100">
<%= aead_algorithm %>
</span>
</span>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= length(response.cookies) %>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= response.cookie_length %>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= Map.get(response, :server, "-") %>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= Map.get(response, :port, "-") %>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= response.cert_expiration |> DateTime.from_iso8601 |> then(fn {:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%SZ") end)%>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<%= client.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") %>
</td>
<% else %>
<td class="py-2 px-6 whitespace-nowrap">
<%= client.server.host %><span :if={client.server.port != 4460}>:<%= client.server.port %></span>
</td>
<td class="py-2 px-6 whitespace-nowrap">
<span class="group relative">
<%= status %>
<span class="pointer-events-none absolute -top-9 left-0 w-max p-1 rounded-lg bg-zinc-300 dark:bg-zinc-600 opacity-0 transition-opacity group-hover:opacity-100">
<%= response %>
</span>
</span>
</td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap"> - </td>
<td class="py-2 px-6 whitespace-nowrap">
<%=
client.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ")
%>
</td>
<% end %>
</tr>
</tbody>
</table>
</div>

View File

@ -17,7 +17,7 @@ defmodule ChronoscopeWeb.Router do
scope "/", ChronoscopeWeb do scope "/", ChronoscopeWeb do
pipe_through :browser pipe_through :browser
get "/", PageController, :home live "/", IndexLive
end end
scope "/api/v1", ChronoscopeWeb.API.V1 do scope "/api/v1", ChronoscopeWeb.API.V1 do

View File

@ -4,7 +4,7 @@ defmodule Chronoscope.MixProject do
def project do def project do
[ [
app: :chronoscope, app: :chronoscope,
version: "0.1.0", version: "1.0.0",
elixir: "~> 1.16", elixir: "~> 1.16",
elixirc_paths: elixirc_paths(Mix.env()), elixirc_paths: elixirc_paths(Mix.env()),
start_permanent: Mix.env() == :prod, start_permanent: Mix.env() == :prod,

0
priv/nts-test.txt Normal file
View File

9
priv/nts.txt Normal file
View File

@ -0,0 +1,9 @@
time.cifelli.xyz
stratum1.time.cifelli.xyz
virginia.time.system76.com
paris.time.system76.com
oregon.time.system76.com
ohio.time.system76.com
brazil.time.system76.com
time.cloudflare.com
nts.netnod.se

View File

@ -8,8 +8,6 @@ defmodule Chronoscope.GeminiTest do
import Mox import Mox
setup :verify_on_exit!
describe "Chronoscope.Gemini.healthy?()" do describe "Chronoscope.Gemini.healthy?()" do
test "is healthy" do test "is healthy" do
assert Gemini.healthy?() == true assert Gemini.healthy?() == true
@ -29,8 +27,8 @@ defmodule Chronoscope.GeminiTest do
|> expect(:which_children, fn _ -> [{1, 2, 3, 4}, {5, 6, 7, 8}] end) |> expect(:which_children, fn _ -> [{1, 2, 3, 4}, {5, 6, 7, 8}] end)
GenServerMock GenServerMock
|> expect(:call, fn 2, :list -> :one end) |> expect(:call, fn 2, :list, 10_000 -> :one end)
|> expect(:call, fn 6, :list -> :two end) |> expect(:call, fn 6, :list, 10_000 -> :two end)
assert Gemini.list() == [:one, :two] assert Gemini.list() == [:one, :two]
end end
@ -49,7 +47,7 @@ defmodule Chronoscope.GeminiTest do
|> expect(:lookup, fn _, _ -> [{1, 2}] end) |> expect(:lookup, fn _, _ -> [{1, 2}] end)
GenServerMock GenServerMock
|> expect(:call, fn 1, :terminate -> :terminating end) |> expect(:call, fn 1, :terminate, 10_000 -> :terminating end)
assert Gemini.remove("localhost", 1965, "/") == {:ok, :terminating} assert Gemini.remove("localhost", 1965, "/") == {:ok, :terminating}
end end
@ -74,7 +72,7 @@ defmodule Chronoscope.GeminiTest do
) )
GenServerMock GenServerMock
|> expect(:call, fn 1, :connect -> :result end) |> expect(:call, fn 1, :connect, 10_000 -> :result end)
assert Gemini.connect("localhost", 1965, "/") == :result assert Gemini.connect("localhost", 1965, "/") == :result
end end
@ -84,7 +82,7 @@ defmodule Chronoscope.GeminiTest do
|> expect(:lookup, fn _, _ -> [{1, 2}] end) |> expect(:lookup, fn _, _ -> [{1, 2}] end)
GenServerMock GenServerMock
|> expect(:call, fn 1, :connect -> :result end) |> expect(:call, fn 1, :connect, 10_000 -> :result end)
assert Gemini.connect("localhost", 1965, "/") == :result assert Gemini.connect("localhost", 1965, "/") == :result
end end

View File

@ -3,6 +3,7 @@ defmodule Chronoscope.NTS.ClientTest do
alias Chronoscope.Certificate alias Chronoscope.Certificate
alias Chronoscope.DateTimeMock alias Chronoscope.DateTimeMock
alias Chronoscope.EndpointMock
alias Chronoscope.NTS.Client alias Chronoscope.NTS.Client
alias Chronoscope.SSLMock alias Chronoscope.SSLMock
@ -19,6 +20,9 @@ defmodule Chronoscope.NTS.ClientTest do
describe "Chronoscope.NTS.Client.init()" do describe "Chronoscope.NTS.Client.init()" do
test "initializes successfully" do test "initializes successfully" do
EndpointMock
|> expect(:broadcast, fn "nts-servers", "initializing", %{host: "localhost", port: 3333} -> :ok end)
assert Client.init(%{host: "localhost", port: 3333}) == assert Client.init(%{host: "localhost", port: 3333}) ==
{:ok, {:ok,
%{ %{
@ -38,6 +42,26 @@ defmodule Chronoscope.NTS.ClientTest do
assert Client.handle_call(:list, nil, %{state: true}) == {:reply, %{state: true}, %{state: true}} assert Client.handle_call(:list, nil, %{state: true}) == {:reply, %{state: true}, %{state: true}}
end end
test ":auto_refresh" do
assert {:reply, :ok, %{server: true, timer: _timer}} = Client.handle_call(:auto_refresh, nil, %{server: true})
end
test ":auto_refresh - already activated" do
assert Client.handle_call(:auto_refresh, nil, %{server: true, timer: true}) ==
{:reply, :already_started, %{server: true, timer: true}}
end
test ":cancel_auto_refresh" do
EndpointMock
|> expect(:broadcast, fn "nts-servers", "cancel-auto-refresh", _ -> :ok end)
assert Client.handle_call(:cancel_auto_refresh, nil, %{server: true, timer: true}) == {:reply, :ok, %{server: true}}
end
test ":cancel_auto_refresh - already cancelled" do
assert Client.handle_call(:cancel_auto_refresh, nil, %{server: true}) == {:reply, :already_cancelled, %{server: true}}
end
test ":key_establishment - cached" do test ":key_establishment - cached" do
assert Client.handle_call(:key_establishment, nil, %{ assert Client.handle_call(:key_establishment, nil, %{
server: %{host: "localhost", port: 3333}, server: %{host: "localhost", port: 3333},
@ -62,6 +86,9 @@ defmodule Chronoscope.NTS.ClientTest do
|> expect(:peercert, fn :socket -> {:ok, peercert} end) |> expect(:peercert, fn :socket -> {:ok, peercert} end)
|> expect(:close, fn :socket -> :ok end) |> expect(:close, fn :socket -> :ok end)
EndpointMock
|> expect(:broadcast, fn "nts-servers", "key-establishment", _ -> :ok end)
assert {:reply, {:ok, %{cert_expiration: ^peercert_expiration}}, assert {:reply, {:ok, %{cert_expiration: ^peercert_expiration}},
%{ %{
server: %{host: "localhost", port: 3333}, server: %{host: "localhost", port: 3333},

View File

@ -10,7 +10,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentClientTest do
setup :verify_on_exit! setup :verify_on_exit!
@timeout 3000 @timeout 3500
describe "Chronoscope.NTS.KeyEstablishmentClient.key_establishment()" do describe "Chronoscope.NTS.KeyEstablishmentClient.key_establishment()" do
test "sends the correct TLS options" do test "sends the correct TLS options" do

View File

@ -3,6 +3,13 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponseTest do
import Chronoscope.NTS.KeyEstablishmentResponse import Chronoscope.NTS.KeyEstablishmentResponse
describe "Chronoscope.NTS.KeyEstablishmentResponse.aead_algorithm_to_id()" do
test "maps names to a numeric identifiers" do
assert aead_algorithm_to_id("AEAD_AES_SIV_CMAC_256") == 15
assert aead_algorithm_to_id("AEAD_AES_128_GCM_SIV") == 30
end
end
describe "Chronoscope.NTS.KeyEstablishmentResponse.parse()" do describe "Chronoscope.NTS.KeyEstablishmentResponse.parse()" do
test "handles empty response" do test "handles empty response" do
assert parse([]) == %{} assert parse([]) == %{}

View File

@ -0,0 +1,15 @@
defmodule Chronoscope.NTS.ParseTest do
use Chronoscope.Case, async: true
import Chronoscope.NTS.Parse
describe "Chronoscope.NTS.Parse.parse_nts_server()" do
test "parses a server with a port" do
assert parse_nts_server("test.example.com:1234") == %{host: "test.example.com", port: 1234}
end
test "parses a server without a port" do
assert parse_nts_server("test.example.com") == %{host: "test.example.com", port: 4460}
end
end
end

View File

@ -36,6 +36,136 @@ defmodule Chronoscope.NTSTest do
end end
end end
describe "Chronoscope.NTS.list_clients()" do
test "shows empty client list" do
assert NTS.list_clients([]) == []
end
test "lists the given clients and starts them if they don't exist" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [{1, nil}] end)
|> expect(:lookup, fn _, "test2:2" -> [] end)
DynamicSupervisorMock
|> expect(
:start_child,
fn Chronoscope.NTS.DynamicSupervisor,
{Chronoscope.NTS.Client,
[
server: %{host: "test2", port: 2},
name: {:via, RegistryMock, {Chronoscope.NTS.Registry, "test2:2"}}
]} ->
{:ok, 2}
end
)
GenServerMock
|> expect(:call, fn 1, :list, 10_000 -> :one end)
|> expect(:call, fn 2, :list, 10_000 -> :two end)
assert NTS.list_clients([%{host: "test", port: 1}, %{host: "test2", port: 2}]) == [:one, :two]
end
end
describe "Chronoscope.NTS.start_client()" do
test "starts a client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [] end)
DynamicSupervisorMock
|> expect(
:start_child,
fn Chronoscope.NTS.DynamicSupervisor,
{Chronoscope.NTS.Client,
[
server: %{host: "test", port: 1},
name: {:via, RegistryMock, {Chronoscope.NTS.Registry, "test:1"}}
]} ->
{:ok, 2}
end
)
assert NTS.start_client(%{host: "test", port: 1}) == 2
end
test "returns an existing client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [{1, nil}] end)
assert NTS.start_client(%{host: "test", port: 1}) == 1
end
end
describe "Chronoscope.NTS.auto_refresh()" do
test "sets up auto refresh for a new client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [] end)
DynamicSupervisorMock
|> expect(
:start_child,
fn Chronoscope.NTS.DynamicSupervisor,
{Chronoscope.NTS.Client,
[
server: %{host: "test", port: 1},
name: {:via, RegistryMock, {Chronoscope.NTS.Registry, "test:1"}}
]} ->
{:ok, 2}
end
)
GenServerMock
|> expect(:call, fn 2, :auto_refresh, 10_000 -> :ok end)
assert NTS.auto_refresh(%{host: "test", port: 1}) == :ok
end
test "sets up auto refresh for an existing client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [{2, nil}] end)
GenServerMock
|> expect(:call, fn 2, :auto_refresh, 10_000 -> :ok end)
assert NTS.auto_refresh(%{host: "test", port: 1}) == :ok
end
end
describe "Chronoscope.NTS.cancel_auto_refresh()" do
test "cancels auto refresh for a new client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [] end)
DynamicSupervisorMock
|> expect(
:start_child,
fn Chronoscope.NTS.DynamicSupervisor,
{Chronoscope.NTS.Client,
[
server: %{host: "test", port: 1},
name: {:via, RegistryMock, {Chronoscope.NTS.Registry, "test:1"}}
]} ->
{:ok, 2}
end
)
GenServerMock
|> expect(:call, fn 2, :cancel_auto_refresh, 10_000 -> :ok end)
assert NTS.cancel_auto_refresh(%{host: "test", port: 1}) == :ok
end
test "cancels auto refresh for an existing client" do
RegistryMock
|> expect(:lookup, fn _, "test:1" -> [{2, nil}] end)
GenServerMock
|> expect(:call, fn 2, :cancel_auto_refresh, 10_000 -> :ok end)
assert NTS.cancel_auto_refresh(%{host: "test", port: 1}) == :ok
end
end
describe "Chronoscope.NTS.remove()" do describe "Chronoscope.NTS.remove()" do
test "does nothing if the client doesn't exist" do test "does nothing if the client doesn't exist" do
RegistryMock RegistryMock
@ -49,7 +179,7 @@ defmodule Chronoscope.NTSTest do
|> expect(:lookup, fn _, _ -> [{1, 2}] end) |> expect(:lookup, fn _, _ -> [{1, 2}] end)
GenServerMock GenServerMock
|> expect(:call, fn 1, :terminate -> :terminating end) |> expect(:call, fn 1, :terminate, 10_000 -> :terminating end)
assert NTS.remove("localhost", 1111) == {:ok, :terminating} assert NTS.remove("localhost", 1111) == {:ok, :terminating}
end end
@ -74,7 +204,7 @@ defmodule Chronoscope.NTSTest do
) )
GenServerMock GenServerMock
|> expect(:call, fn 1, :key_establishment -> :result end) |> expect(:call, fn 1, :key_establishment, 10_000 -> :result end)
assert NTS.key_establishment("localhost", 1111) == :result assert NTS.key_establishment("localhost", 1111) == :result
end end
@ -84,7 +214,7 @@ defmodule Chronoscope.NTSTest do
|> expect(:lookup, fn _, _ -> [{1, 2}] end) |> expect(:lookup, fn _, _ -> [{1, 2}] end)
GenServerMock GenServerMock
|> expect(:call, fn 1, :key_establishment -> :result end) |> expect(:call, fn 1, :key_establishment, 10_000 -> :result end)
assert NTS.key_establishment("localhost", 1111) == :result assert NTS.key_establishment("localhost", 1111) == :result
end end

View File

@ -17,6 +17,42 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionControllerTest do
assert %{"error" => "missing host"} == response assert %{"error" => "missing host"} == response
end end
test "requires a host name value", %{conn: conn} do
response1 =
conn
|> get(~p"/api/v1/gemini/connect?host=")
|> json_response(400)
response2 =
conn
|> get(~p"/api/v1/gemini/connect?host=&port=1966")
|> json_response(400)
assert %{"error" => "empty host"} == response1
assert %{"error" => "empty host"} == response2
end
test "requires a non-blank host name", %{conn: conn} do
response1 =
conn
|> get(~p"/api/v1/gemini/connect?host=%20%20")
|> json_response(400)
response2 =
conn
|> get(~p"/api/v1/gemini/connect?host=%20%20&port=1966")
|> json_response(400)
response3 =
conn
|> get(~p"/api/v1/gemini/connect?host=%20%20&port=1966&path=/test")
|> json_response(400)
assert %{"error" => "empty host"} == response1
assert %{"error" => "empty host"} == response2
assert %{"error" => "empty host"} == response3
end
test "uses the given host name", %{conn: conn} do test "uses the given host name", %{conn: conn} do
GeminiMock GeminiMock
|> expect(:connect, fn "localhost", 1965, "/" -> {:ok, %{status: :ok}} end) |> expect(:connect, fn "localhost", 1965, "/" -> {:ok, %{status: :ok}} end)

View File

@ -17,6 +17,36 @@ defmodule ChronoscopeWeb.API.V1.NTS.KeyEstablishmentControllerTest do
assert %{"error" => "missing host"} == response assert %{"error" => "missing host"} == response
end end
test "requires a host name value", %{conn: conn} do
response1 =
conn
|> get(~p"/api/v1/nts/key-establishment?host=")
|> json_response(400)
response2 =
conn
|> get(~p"/api/v1/nts/key-establishment?host=&port=4444")
|> json_response(400)
assert %{"error" => "empty host"} == response1
assert %{"error" => "empty host"} == response2
end
test "requires a non-blank host name", %{conn: conn} do
response1 =
conn
|> get(~p"/api/v1/nts/key-establishment?host=%20%20")
|> json_response(400)
response2 =
conn
|> get(~p"/api/v1/nts/key-establishment?host=%20%20&port=4444")
|> json_response(400)
assert %{"error" => "empty host"} == response1
assert %{"error" => "empty host"} == response2
end
test "uses the given host name", %{conn: conn} do test "uses the given host name", %{conn: conn} do
NTSMock NTSMock
|> expect(:key_establishment, fn "localhost", 4460 -> {:ok, %{status: :ok}} end) |> expect(:key_establishment, fn "localhost", 4460 -> {:ok, %{status: :ok}} end)

View File

@ -1,8 +1,12 @@
defmodule ChronoscopeWeb.PageControllerTest do defmodule ChronoscopeWeb.PageControllerTest do
use ChronoscopeWeb.ConnCase, async: true use ChronoscopeWeb.ConnCase, async: true
import Mox
setup :verify_on_exit!
test "GET /", %{conn: conn} do test "GET /", %{conn: conn} do
conn = get(conn, ~p"/") conn = get(conn, ~p"/")
assert html_response(conn, 200) =~ "Peace of mind from prototype to production" assert html_response(conn, 200) =~ "Chronoscope"
end end
end end

View File

@ -19,5 +19,10 @@ defmodule Chronoscope.DynamicSupervisor.Behaviour do
end end
defmodule Chronoscope.GenServer.Behaviour do defmodule Chronoscope.GenServer.Behaviour do
@callback call(pid(), any(), any()) :: any()
@callback call(pid(), any()) :: any() @callback call(pid(), any()) :: any()
end end
defmodule ChronoscopeWeb.Endpoint.Behaviour do
@callback broadcast(any(), any(), any()) :: any()
end

View File

@ -5,3 +5,4 @@ Mox.defmock(Chronoscope.DynamicSupervisorMock, for: Chronoscope.DynamicSuperviso
Mox.defmock(Chronoscope.GenServerMock, for: Chronoscope.GenServer.Behaviour) Mox.defmock(Chronoscope.GenServerMock, for: Chronoscope.GenServer.Behaviour)
Mox.defmock(Chronoscope.NTSMock, for: Chronoscope.NTS.Behaviour) Mox.defmock(Chronoscope.NTSMock, for: Chronoscope.NTS.Behaviour)
Mox.defmock(Chronoscope.GeminiMock, for: Chronoscope.Gemini.Behaviour) Mox.defmock(Chronoscope.GeminiMock, for: Chronoscope.Gemini.Behaviour)
Mox.defmock(Chronoscope.EndpointMock, for: ChronoscopeWeb.Endpoint.Behaviour)