diff --git a/.gitignore b/.gitignore index c1b92e1..ca8fdd9 100644 --- a/.gitignore +++ b/.gitignore @@ -37,3 +37,6 @@ npm-debug.log # Ignore IDE files. .elixir_ls/ + +# Ignore server lists. +/priv/nts.txt diff --git a/config/config.exs b/config/config.exs index f4905e5..a76f30a 100644 --- a/config/config.exs +++ b/config/config.exs @@ -30,6 +30,9 @@ config :chronoscope, ChronoscopeWeb.Endpoint, # at the `config/runtime.exs`. 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) config :esbuild, version: "0.17.11", diff --git a/config/test.exs b/config/test.exs index e445d94..79eae7f 100644 --- a/config/test.exs +++ b/config/test.exs @@ -10,6 +10,8 @@ config :chronoscope, ChronoscopeWeb.Endpoint, # In test we don't send emails. config :chronoscope, Chronoscope.Mailer, adapter: Swoosh.Adapters.Test +config :chronoscope, :nts_file, "test/priv/nts.txt" + # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false diff --git a/lib/chronoscope/application.ex b/lib/chronoscope/application.ex index 770f140..f297711 100644 --- a/lib/chronoscope/application.ex +++ b/lib/chronoscope/application.ex @@ -22,7 +22,8 @@ defmodule Chronoscope.Application do {Task.Supervisor, name: Chronoscope.Gemini.TaskSupervisor}, {Registry, [keys: :unique, name: Chronoscope.Gemini.Registry]}, # Start to serve requests, typically the last entry - ChronoscopeWeb.Endpoint + ChronoscopeWeb.Endpoint, + Chronoscope.Monitor ] # See https://hexdocs.pm/elixir/Supervisor.html diff --git a/lib/chronoscope/gemini.ex b/lib/chronoscope/gemini.ex index abbd14f..e858a06 100644 --- a/lib/chronoscope/gemini.ex +++ b/lib/chronoscope/gemini.ex @@ -9,6 +9,8 @@ defmodule Chronoscope.Gemini do alias Chronoscope.Gemini + @timeout_in_milliseconds 10_000 + @registry Application.compile_env(:chronoscope, :registry, Registry) @genserver Application.compile_env(:chronoscope, :gen_server, GenServer) @dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor) @@ -20,14 +22,14 @@ defmodule Chronoscope.Gemini do def list() do Gemini.DynamicSupervisor |> @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 def remove(host, port, path) do name = client_name(%{host: host, port: port, path: path}) 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} end end @@ -36,7 +38,7 @@ defmodule Chronoscope.Gemini do def connect(host, port, path) do %{host: host, port: port, path: path} |> client_pid() - |> @genserver.call(:connect) + |> @genserver.call(:connect, @timeout_in_milliseconds) end defp client_pid(resource) do diff --git a/lib/chronoscope/gemini/client.ex b/lib/chronoscope/gemini/client.ex index 13fd46c..bcfe11e 100644 --- a/lib/chronoscope/gemini/client.ex +++ b/lib/chronoscope/gemini/client.ex @@ -5,6 +5,7 @@ defmodule Chronoscope.Gemini.Client do alias Chronoscope.Gemini.ConnectionClient @interval_in_seconds 30 + @timeout_in_milliseconds 10_000 @date_time Application.compile_env(:chronoscope, :date_time, DateTime) @@ -61,7 +62,7 @@ defmodule Chronoscope.Gemini.Client do defp server_response(%{resource: resource}) do Gemini.TaskSupervisor |> Task.Supervisor.async(fn -> ConnectionClient.connect(resource) end) - |> Task.await() + |> Task.await(@timeout_in_milliseconds) end defp interval_surpassed?(now, last_request) do diff --git a/lib/chronoscope/monitor.ex b/lib/chronoscope/monitor.ex new file mode 100644 index 0000000..9ed2392 --- /dev/null +++ b/lib/chronoscope/monitor.ex @@ -0,0 +1,40 @@ +defmodule Chronoscope.Monitor do + use GenServer + + require Logger + + alias Chronoscope.NTS + alias Chronoscope.NTS.Parse + + @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 + File.touch(@nts_file) + activate_nts_clients() + {:ok, %{nts_servers: activate_nts_clients()}} + end + + @impl true + def handle_info(:activate_clients, _state) do + {:noreply, %{nts_servers: activate_nts_clients()}} + end + + @impl true + def handle_call(:get_nts_servers, _from, state) do + {:reply, state.nts_servers, state} + end + + defp activate_nts_clients() do + @nts_file + |> File.stream!() + |> Stream.map(&String.trim/1) + |> Stream.map(&Parse.parse_nts_server/1) + |> Enum.to_list() + |> tap(fn server -> Enum.each(server, &NTS.auto_refresh/1) end) + end +end diff --git a/lib/chronoscope/nts.ex b/lib/chronoscope/nts.ex index 09193a7..4e4995d 100644 --- a/lib/chronoscope/nts.ex +++ b/lib/chronoscope/nts.ex @@ -9,10 +9,11 @@ defmodule Chronoscope.NTS do alias Chronoscope.NTS + @timeout_in_milliseconds 10_000 + @registry Application.compile_env(:chronoscope, :registry, Registry) @genserver Application.compile_env(:chronoscope, :gen_server, GenServer) @dynamic_supervisor Application.compile_env(:chronoscope, :dynamic_supervisor, DynamicSupervisor) - @topic "nts-servers" def healthy?() do true @@ -24,11 +25,29 @@ defmodule Chronoscope.NTS do |> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) end) end + def list_clients(clients) do + clients + |> Enum.map(&client_pid/1) + |> Enum.map(fn pid -> @genserver.call(pid, :list, @timeout_in_milliseconds) end) + 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 name = client_name(%{host: host, port: port}) 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} end end @@ -37,8 +56,7 @@ defmodule Chronoscope.NTS do def key_establishment(host, port) do %{host: host, port: port} |> client_pid() - |> @genserver.call(:key_establishment) - |> tap(fn _ -> ChronoscopeWeb.Endpoint.broadcast(@topic, "", "") end) + |> @genserver.call(:key_establishment, @timeout_in_milliseconds) end defp client_pid(server) do diff --git a/lib/chronoscope/nts/client.ex b/lib/chronoscope/nts/client.ex index d5b3a84..2500e6e 100644 --- a/lib/chronoscope/nts/client.ex +++ b/lib/chronoscope/nts/client.ex @@ -5,7 +5,10 @@ defmodule Chronoscope.NTS.Client do alias Chronoscope.NTS.KeyEstablishmentClient @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) def start_link(server: server, name: name) do @@ -34,6 +37,28 @@ defmodule Chronoscope.NTS.Client do {:reply, state, state} 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 + {: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) + {: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 def handle_call(:key_establishment, _from, state) do new_state = update_state(state) @@ -41,11 +66,18 @@ defmodule Chronoscope.NTS.Client do {:reply, new_state.key_establishment_response, new_state} end + @impl true + def handle_info(:key_establishment, state) do + {:noreply, update_state(state)} + end + defp update_state(state) do now = utc_now() if interval_surpassed?(now, state.last_key_establishment) do - Map.merge(state, current_data(state, now)) + state + |> Map.merge(current_data(state, now)) + |> tap(fn _ -> ChronoscopeWeb.Endpoint.broadcast(@topic, "", "") end) else state end @@ -61,7 +93,7 @@ defmodule Chronoscope.NTS.Client do defp server_response(%{server: server}) do NTS.TaskSupervisor |> Task.Supervisor.async(fn -> KeyEstablishmentClient.key_establishment(server) end) - |> Task.await() + |> Task.await(@timeout_in_milliseconds) end defp interval_surpassed?(now, last_key_establishment) do diff --git a/lib/chronoscope/nts/key_establishment_client.ex b/lib/chronoscope/nts/key_establishment_client.ex index 102a62d..1ab989e 100644 --- a/lib/chronoscope/nts/key_establishment_client.ex +++ b/lib/chronoscope/nts/key_establishment_client.ex @@ -5,7 +5,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentClient do alias Chronoscope.NTS.KeyEstablishmentRequest alias Chronoscope.NTS.KeyEstablishmentResponse - @timeout_in_milliseconds 3000 + @timeout_in_milliseconds 3500 @ssl Application.compile_env(:chronoscope, :ssl, :ssl) diff --git a/lib/chronoscope/nts/parse.ex b/lib/chronoscope/nts/parse.ex new file mode 100644 index 0000000..d4bb794 --- /dev/null +++ b/lib/chronoscope/nts/parse.ex @@ -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 diff --git a/lib/chronoscope_web/components/layouts/app.html.heex b/lib/chronoscope_web/components/layouts/app.html.heex index 8223b45..eda1e69 100644 --- a/lib/chronoscope_web/components/layouts/app.html.heex +++ b/lib/chronoscope_web/components/layouts/app.html.heex @@ -1,4 +1,4 @@ -
+
diff --git a/lib/chronoscope_web/live/index_live.ex b/lib/chronoscope_web/live/index_live.ex index 0497db8..b385b06 100644 --- a/lib/chronoscope_web/live/index_live.ex +++ b/lib/chronoscope_web/live/index_live.ex @@ -4,14 +4,20 @@ defmodule ChronoscopeWeb.IndexLive do alias Chronoscope.NTS alias Chronoscope.NTS.KeyEstablishmentResponse - @topic "nts-servers" + @topic Application.compile_env(:chronoscope, :nts_topic) def mount(_params, _session, socket) do ChronoscopeWeb.Endpoint.subscribe(@topic) - {:ok, assign(socket, %{servers: NTS.list()})} + {:ok, assign(socket, %{servers: server_list()})} end def handle_info(%{topic: @topic}, socket) do - {:noreply, assign(socket, %{servers: NTS.list()})} + {:noreply, assign(socket, %{servers: server_list()})} + end + + defp server_list() do + Chronoscope.Monitor + |> GenServer.call(:get_nts_servers) + |> NTS.list_clients() end end diff --git a/lib/chronoscope_web/live/index_live.html.heex b/lib/chronoscope_web/live/index_live.html.heex index c7c5e58..9ca85be 100644 --- a/lib/chronoscope_web/live/index_live.html.heex +++ b/lib/chronoscope_web/live/index_live.html.heex @@ -2,31 +2,31 @@ - - - - - - - - - @@ -36,13 +36,13 @@ <% {status, response} = server.key_establishment_response %> <%= if (status == :ok) do %> - - - - - - - - - <% else %> - - - - - - - - - + + + + + +
+ NTS-KE Server + Status + Algorithm + Cookies + Cookie Length + NTP Host + NTP Port + Certificate Expiration + Last Check
- <%= server.server.host %>:<%= server.server.port %> + + <%= server.server.host %>:<%= server.server.port %> + <%= status %> + <% aead_algorithm = Enum.at(response.aead_algorithms, 0) %> @@ -52,30 +52,30 @@ + <%= length(response.cookies) %> + <%= response.cookie_length %> + <%= Map.get(response, :server, "-") %> + <%= Map.get(response, :port, "-") %> + <%= response.cert_expiration |> DateTime.from_iso8601 |> then(fn {:ok, dt, _} -> Calendar.strftime(dt, "%Y-%m-%d %H:%M:%SZ") end)%> + <%= server.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") %> - <%= server.server.host %>:<%= server.server.port %> + + <%= server.server.host %>:<%= server.server.port %> + <%= status %> @@ -83,13 +83,13 @@ - - - - - - + - - - - - - <%= server.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") %> diff --git a/mix.exs b/mix.exs index 99b1d72..ac249e9 100644 --- a/mix.exs +++ b/mix.exs @@ -4,7 +4,7 @@ defmodule Chronoscope.MixProject do def project do [ app: :chronoscope, - version: "0.1.0", + version: "1.0.0", elixir: "~> 1.16", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, diff --git a/test/chronoscope/nts/parse_test.exs b/test/chronoscope/nts/parse_test.exs new file mode 100644 index 0000000..bfcfaf2 --- /dev/null +++ b/test/chronoscope/nts/parse_test.exs @@ -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 diff --git a/test/chronoscope/nts_test.exs b/test/chronoscope/nts_test.exs index 67c75b7..a5bb6aa 100644 --- a/test/chronoscope/nts_test.exs +++ b/test/chronoscope/nts_test.exs @@ -36,6 +36,37 @@ defmodule Chronoscope.NTSTest do 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 -> :one end) + |> expect(:call, fn 2, :list -> :two end) + + assert NTS.list_clients([%{host: "test", port: 1}, %{host: "test2", port: 2}]) == [:one, :two] + end + end + describe "Chronoscope.NTS.remove()" do test "does nothing if the client doesn't exist" do RegistryMock @@ -74,7 +105,7 @@ defmodule Chronoscope.NTSTest do ) 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 end @@ -84,7 +115,7 @@ defmodule Chronoscope.NTSTest do |> expect(:lookup, fn _, _ -> [{1, 2}] end) 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 end diff --git a/test/chronoscope_web/controllers/page_controller_test.exs b/test/chronoscope_web/controllers/page_controller_test.exs index 6b920f1..ad4c0cf 100644 --- a/test/chronoscope_web/controllers/page_controller_test.exs +++ b/test/chronoscope_web/controllers/page_controller_test.exs @@ -1,16 +1,11 @@ defmodule ChronoscopeWeb.PageControllerTest do use ChronoscopeWeb.ConnCase, async: true - alias Chronoscope.DynamicSupervisorMock - import Mox setup :verify_on_exit! test "GET /", %{conn: conn} do - DynamicSupervisorMock - |> expect(:which_children, fn _ -> [] end) - conn = get(conn, ~p"/") assert html_response(conn, 200) =~ "Chronoscope" end diff --git a/test/priv/nts.txt b/test/priv/nts.txt new file mode 100644 index 0000000..e69de29 diff --git a/test/support/behaviours.ex b/test/support/behaviours.ex index b5003f6..4856f5a 100644 --- a/test/support/behaviours.ex +++ b/test/support/behaviours.ex @@ -19,5 +19,6 @@ defmodule Chronoscope.DynamicSupervisor.Behaviour do end defmodule Chronoscope.GenServer.Behaviour do + @callback call(pid(), any(), any()) :: any() @callback call(pid(), any()) :: any() end