diff --git a/assets/tailwind.config.js b/assets/tailwind.config.js index bd5c376..01a7c57 100644 --- a/assets/tailwind.config.js +++ b/assets/tailwind.config.js @@ -14,7 +14,7 @@ module.exports = { theme: { extend: { colors: { - brand: "#FD4F00", + brand: "#DC7556", } }, }, 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/runtime.exs b/config/runtime.exs index 4f5bfd6..7f2dca0 100644 --- a/config/runtime.exs +++ b/config/runtime.exs @@ -33,13 +33,15 @@ if config_env() == :prod do 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") config :chronoscope, :dns_cluster_query, System.get_env("DNS_CLUSTER_QUERY") config :chronoscope, ChronoscopeWeb.Endpoint, url: [host: host, port: 443, scheme: "https"], + check_origin: ["https://" <> host, "https://" <> aux_host], http: [ # Enable IPv6 and bind on all interfaces. # Set it to {0, 0, 0, 0, 0, 0, 0, 1} for local network only access. diff --git a/config/test.exs b/config/test.exs index e445d94..1f482af 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, "priv/nts-test.txt" + # Disable swoosh api client as it is only required for production adapters. config :swoosh, :api_client, false @@ -26,4 +28,5 @@ config :chronoscope, registry: Chronoscope.RegistryMock, gen_server: Chronoscope.GenServerMock, nts: Chronoscope.NTSMock, - gemini: Chronoscope.GeminiMock + gemini: Chronoscope.GeminiMock, + endpoint: Chronoscope.EndpointMock diff --git a/lib/chronoscope/application.ex b/lib/chronoscope/application.ex index 770f140..2ed3a3a 100644 --- a/lib/chronoscope/application.ex +++ b/lib/chronoscope/application.ex @@ -22,7 +22,9 @@ 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, + # Initialize clients used in the main LiveView + ChronoscopeWeb.ClientActivator ] # 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/nts.ex b/lib/chronoscope/nts.ex index 5866dde..e9a1686 100644 --- a/lib/chronoscope/nts.ex +++ b/lib/chronoscope/nts.ex @@ -9,6 +9,8 @@ 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) @@ -23,11 +25,33 @@ defmodule Chronoscope.NTS do |> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) 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 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 @@ -36,7 +60,7 @@ defmodule Chronoscope.NTS do def key_establishment(host, port) do %{host: host, port: port} |> client_pid() - |> @genserver.call(:key_establishment) + |> @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..a1083c3 100644 --- a/lib/chronoscope/nts/client.ex +++ b/lib/chronoscope/nts/client.ex @@ -5,8 +5,12 @@ 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) + @endpoint Application.compile_env(:chronoscope, :endpoint, ChronoscopeWeb.Endpoint) def start_link(server: server, name: name) do GenServer.start_link(__MODULE__, server, name: name) @@ -14,13 +18,13 @@ defmodule Chronoscope.NTS.Client do @impl true def init(server) do - now = utc_now() + @endpoint.broadcast(@topic, "initializing", server) {:ok, %{ server: server, 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 @@ -34,6 +38,32 @@ 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 + :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 def handle_call(:key_establishment, _from, state) do new_state = update_state(state) @@ -41,11 +71,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(&@endpoint.broadcast(@topic, "key-establishment", Map.delete(&1, :timer))) else state end @@ -61,7 +98,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..8391e06 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) @@ -33,12 +33,16 @@ defmodule Chronoscope.NTS.KeyEstablishmentClient do end defp perform_key_establishment(socket) do - :ok = @ssl.send(socket, KeyEstablishmentRequest.create()) - {:ok, peercert} = @ssl.peercert(socket) - - peercert - |> await_response() - |> tap(fn _ -> @ssl.close(socket) end) + with :ok <- @ssl.send(socket, KeyEstablishmentRequest.create()), + {:ok, peercert} <- @ssl.peercert(socket) do + peercert + |> await_response() + |> tap(fn _ -> @ssl.close(socket) end) + else + e -> + @ssl.close(socket) + {:error, e} + end end defp await_response(peercert) do diff --git a/lib/chronoscope/nts/key_establishment_response.ex b/lib/chronoscope/nts/key_establishment_response.ex index 5a4bff6..5130b07 100644 --- a/lib/chronoscope/nts/key_establishment_response.ex +++ b/lib/chronoscope/nts/key_establishment_response.ex @@ -1,7 +1,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponse do import Bitwise - @aead_alogorithms %{ + @aead_algorithms %{ 15 => "AEAD_AES_SIV_CMAC_256", 30 => "AEAD_AES_128_GCM_SIV" } @@ -16,6 +16,12 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponse do 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 parse_response(response, %{}) end @@ -127,7 +133,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponse do end defp do_parse_aead_algorithm_list([high, low | rest], acc) do - @aead_alogorithms + @aead_algorithms |> Map.get(combine_octets(high, low), "UNKNOWN") |> then(&do_parse_aead_algorithm_list(rest, [&1 | acc])) end 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/client_activator.ex b/lib/chronoscope_web/client_activator.ex new file mode 100644 index 0000000..32ee207 --- /dev/null +++ b/lib/chronoscope_web/client_activator.ex @@ -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 diff --git a/lib/chronoscope_web/components/layouts/app.html.heex b/lib/chronoscope_web/components/layouts/app.html.heex index 0997ae9..bc49530 100644 --- a/lib/chronoscope_web/components/layouts/app.html.heex +++ b/lib/chronoscope_web/components/layouts/app.html.heex @@ -1,28 +1,22 @@ -
-
-
- - +
+
+
+ + Chronoscope -

- v<%= Application.spec(:phoenix, :vsn) %> +

+ v<%= Application.spec(:chronoscope, :vsn) %>

-
-
+
<.flash_group flash={@flash} /> <%= @inner_content %>
diff --git a/lib/chronoscope_web/components/layouts/root.html.heex b/lib/chronoscope_web/components/layouts/root.html.heex index 9032ef4..156dd6f 100644 --- a/lib/chronoscope_web/components/layouts/root.html.heex +++ b/lib/chronoscope_web/components/layouts/root.html.heex @@ -11,7 +11,7 @@ - + <%= @inner_content %> diff --git a/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex b/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex index 39d8861..208b4b0 100644 --- a/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex +++ b/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex @@ -13,7 +13,7 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionController do def get(conn, %{"host" => host, "port" => port, "path" => path}) 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 ArgumentError -> bad_request_response(conn, "invalid port") end @@ -25,20 +25,24 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionController do def get(conn, %{"host" => host, "port" => port}) 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 ArgumentError -> bad_request_response(conn, "invalid port") end end 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 def get(conn, _params) do bad_request_response(conn, "missing host") 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 case connect(host, port, path) do {:ok, response} -> diff --git a/lib/chronoscope_web/controllers/api/v1/nts/key_establishment_controller.ex b/lib/chronoscope_web/controllers/api/v1/nts/key_establishment_controller.ex index 7fa4944..3c5cc5c 100644 --- a/lib/chronoscope_web/controllers/api/v1/nts/key_establishment_controller.ex +++ b/lib/chronoscope_web/controllers/api/v1/nts/key_establishment_controller.ex @@ -12,20 +12,24 @@ defmodule ChronoscopeWeb.API.V1.NTS.KeyEstablishmentController do def get(conn, %{"host" => host, "port" => port}) 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 ArgumentError -> bad_request_response(conn, "invalid port") end end 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 def get(conn, _params) do bad_request_response(conn, "missing host") 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 case key_establishment_response(host, port) do {:ok, response} -> diff --git a/lib/chronoscope_web/live/index_live.ex b/lib/chronoscope_web/live/index_live.ex new file mode 100644 index 0000000..685b16d --- /dev/null +++ b/lib/chronoscope_web/live/index_live.ex @@ -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 diff --git a/lib/chronoscope_web/live/index_live.html.heex b/lib/chronoscope_web/live/index_live.html.heex new file mode 100644 index 0000000..de4f785 --- /dev/null +++ b/lib/chronoscope_web/live/index_live.html.heex @@ -0,0 +1,101 @@ +
+ + + + + + + + + + + + + + + + + <% {status, response} = client.key_establishment_response %> + + <%= if (status == :ok) do %> + + + + + + + + + + + <% else %> + + + + + + + + + + <% end %> + + +
+ NTS-KE Server + + Status + + Algorithm + + Cookies + + Cookie Length + + NTP Host + + NTP Port + + Certificate Expiration + + Last Check +
+ <%= client.server.host %>:<%= client.server.port %> + + <%= status %> + + <% aead_algorithm = Enum.at(response.aead_algorithms, 0) %> + + + <%= KeyEstablishmentResponse.aead_algorithm_to_id(aead_algorithm) %> + + <%= aead_algorithm %> + + + + <%= 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)%> + + <%= client.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") %> + + <%= client.server.host %>:<%= client.server.port %> + + + <%= status %> + + <%= response %> + + + - - - - - - + <%= + client.last_key_establishment |> Calendar.strftime("%Y-%m-%d %H:%M:%SZ") + %> +
+
diff --git a/lib/chronoscope_web/router.ex b/lib/chronoscope_web/router.ex index 75f0d25..d6cac2d 100644 --- a/lib/chronoscope_web/router.ex +++ b/lib/chronoscope_web/router.ex @@ -17,7 +17,7 @@ defmodule ChronoscopeWeb.Router do scope "/", ChronoscopeWeb do pipe_through :browser - get "/", PageController, :home + live "/", IndexLive end scope "/api/v1", ChronoscopeWeb.API.V1 do 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/priv/nts-test.txt b/priv/nts-test.txt new file mode 100644 index 0000000..e69de29 diff --git a/priv/nts.txt b/priv/nts.txt new file mode 100644 index 0000000..562c952 --- /dev/null +++ b/priv/nts.txt @@ -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 diff --git a/test/chronoscope/gemini/client_test.exs b/test/chronoscope/gemini/client_test.exs index 76b373f..a728f62 100644 --- a/test/chronoscope/gemini/client_test.exs +++ b/test/chronoscope/gemini/client_test.exs @@ -53,7 +53,7 @@ defmodule Chronoscope.Gemini.ClientTest do end test ":connect - not cached" do - response ="20 text/gemini\r\nHello!" |> to_charlist() + response = "20 text/gemini\r\nHello!" |> to_charlist() peercert = peercert() peercert_expiration = Certificate.expiration_date(peercert) diff --git a/test/chronoscope/gemini_test.exs b/test/chronoscope/gemini_test.exs index 9cb0a79..cb94b4f 100644 --- a/test/chronoscope/gemini_test.exs +++ b/test/chronoscope/gemini_test.exs @@ -8,8 +8,6 @@ defmodule Chronoscope.GeminiTest do import Mox - setup :verify_on_exit! - describe "Chronoscope.Gemini.healthy?()" do test "is healthy" do 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) GenServerMock - |> expect(:call, fn 2, :list -> :one end) - |> expect(:call, fn 6, :list -> :two end) + |> expect(:call, fn 2, :list, 10_000 -> :one end) + |> expect(:call, fn 6, :list, 10_000 -> :two end) assert Gemini.list() == [:one, :two] end @@ -49,7 +47,7 @@ defmodule Chronoscope.GeminiTest do |> expect(:lookup, fn _, _ -> [{1, 2}] end) GenServerMock - |> expect(:call, fn 1, :terminate -> :terminating end) + |> expect(:call, fn 1, :terminate, 10_000 -> :terminating end) assert Gemini.remove("localhost", 1965, "/") == {:ok, :terminating} end @@ -74,7 +72,7 @@ defmodule Chronoscope.GeminiTest do ) GenServerMock - |> expect(:call, fn 1, :connect -> :result end) + |> expect(:call, fn 1, :connect, 10_000 -> :result end) assert Gemini.connect("localhost", 1965, "/") == :result end @@ -84,7 +82,7 @@ defmodule Chronoscope.GeminiTest do |> expect(:lookup, fn _, _ -> [{1, 2}] end) GenServerMock - |> expect(:call, fn 1, :connect -> :result end) + |> expect(:call, fn 1, :connect, 10_000 -> :result end) assert Gemini.connect("localhost", 1965, "/") == :result end diff --git a/test/chronoscope/nts/client_test.exs b/test/chronoscope/nts/client_test.exs index f092704..94a870c 100644 --- a/test/chronoscope/nts/client_test.exs +++ b/test/chronoscope/nts/client_test.exs @@ -3,6 +3,7 @@ defmodule Chronoscope.NTS.ClientTest do alias Chronoscope.Certificate alias Chronoscope.DateTimeMock + alias Chronoscope.EndpointMock alias Chronoscope.NTS.Client alias Chronoscope.SSLMock @@ -19,6 +20,9 @@ defmodule Chronoscope.NTS.ClientTest do describe "Chronoscope.NTS.Client.init()" 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}) == {:ok, %{ @@ -38,6 +42,26 @@ defmodule Chronoscope.NTS.ClientTest do assert Client.handle_call(:list, nil, %{state: true}) == {:reply, %{state: true}, %{state: true}} 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 assert Client.handle_call(:key_establishment, nil, %{ server: %{host: "localhost", port: 3333}, @@ -62,6 +86,9 @@ defmodule Chronoscope.NTS.ClientTest do |> expect(:peercert, fn :socket -> {:ok, peercert} end) |> expect(:close, fn :socket -> :ok end) + EndpointMock + |> expect(:broadcast, fn "nts-servers", "key-establishment", _ -> :ok end) + assert {:reply, {:ok, %{cert_expiration: ^peercert_expiration}}, %{ server: %{host: "localhost", port: 3333}, diff --git a/test/chronoscope/nts/key_establishment_client_test.exs b/test/chronoscope/nts/key_establishment_client_test.exs index 4a5758f..ae9b64c 100644 --- a/test/chronoscope/nts/key_establishment_client_test.exs +++ b/test/chronoscope/nts/key_establishment_client_test.exs @@ -10,7 +10,7 @@ defmodule Chronoscope.NTS.KeyEstablishmentClientTest do setup :verify_on_exit! - @timeout 3000 + @timeout 3500 describe "Chronoscope.NTS.KeyEstablishmentClient.key_establishment()" do test "sends the correct TLS options" do diff --git a/test/chronoscope/nts/key_establishment_response_test.exs b/test/chronoscope/nts/key_establishment_response_test.exs index 03b2a3f..53f392a 100644 --- a/test/chronoscope/nts/key_establishment_response_test.exs +++ b/test/chronoscope/nts/key_establishment_response_test.exs @@ -3,6 +3,13 @@ defmodule Chronoscope.NTS.KeyEstablishmentResponseTest do 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 test "handles empty response" do assert parse([]) == %{} 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..29ae414 100644 --- a/test/chronoscope/nts_test.exs +++ b/test/chronoscope/nts_test.exs @@ -36,6 +36,136 @@ 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, 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 test "does nothing if the client doesn't exist" do RegistryMock @@ -49,7 +179,7 @@ defmodule Chronoscope.NTSTest do |> expect(:lookup, fn _, _ -> [{1, 2}] end) GenServerMock - |> expect(:call, fn 1, :terminate -> :terminating end) + |> expect(:call, fn 1, :terminate, 10_000 -> :terminating end) assert NTS.remove("localhost", 1111) == {:ok, :terminating} end @@ -74,7 +204,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 +214,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/api/v1/gemini/connection_controller_test.exs b/test/chronoscope_web/controllers/api/v1/gemini/connection_controller_test.exs index e932087..c0a00d3 100644 --- a/test/chronoscope_web/controllers/api/v1/gemini/connection_controller_test.exs +++ b/test/chronoscope_web/controllers/api/v1/gemini/connection_controller_test.exs @@ -17,6 +17,42 @@ defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionControllerTest do assert %{"error" => "missing host"} == response 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 GeminiMock |> expect(:connect, fn "localhost", 1965, "/" -> {:ok, %{status: :ok}} end) diff --git a/test/chronoscope_web/controllers/api/v1/nts/key_establishment_controller_test.exs b/test/chronoscope_web/controllers/api/v1/nts/key_establishment_controller_test.exs index 0c88fc6..a360003 100644 --- a/test/chronoscope_web/controllers/api/v1/nts/key_establishment_controller_test.exs +++ b/test/chronoscope_web/controllers/api/v1/nts/key_establishment_controller_test.exs @@ -17,6 +17,36 @@ defmodule ChronoscopeWeb.API.V1.NTS.KeyEstablishmentControllerTest do assert %{"error" => "missing host"} == response 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 NTSMock |> expect(:key_establishment, fn "localhost", 4460 -> {:ok, %{status: :ok}} end) diff --git a/test/chronoscope_web/controllers/page_controller_test.exs b/test/chronoscope_web/controllers/page_controller_test.exs index e11c7c9..ad4c0cf 100644 --- a/test/chronoscope_web/controllers/page_controller_test.exs +++ b/test/chronoscope_web/controllers/page_controller_test.exs @@ -1,8 +1,12 @@ defmodule ChronoscopeWeb.PageControllerTest do use ChronoscopeWeb.ConnCase, async: true + import Mox + + setup :verify_on_exit! + test "GET /", %{conn: conn} do conn = get(conn, ~p"/") - assert html_response(conn, 200) =~ "Peace of mind from prototype to production" + assert html_response(conn, 200) =~ "Chronoscope" end end diff --git a/test/support/behaviours.ex b/test/support/behaviours.ex index b5003f6..b3e1e65 100644 --- a/test/support/behaviours.ex +++ b/test/support/behaviours.ex @@ -19,5 +19,10 @@ defmodule Chronoscope.DynamicSupervisor.Behaviour do end defmodule Chronoscope.GenServer.Behaviour do + @callback call(pid(), any(), any()) :: any() @callback call(pid(), any()) :: any() end + +defmodule ChronoscopeWeb.Endpoint.Behaviour do + @callback broadcast(any(), any(), any()) :: any() +end diff --git a/test/support/mocks.ex b/test/support/mocks.ex index dcc3552..8a56fdf 100644 --- a/test/support/mocks.ex +++ b/test/support/mocks.ex @@ -5,3 +5,4 @@ Mox.defmock(Chronoscope.DynamicSupervisorMock, for: Chronoscope.DynamicSuperviso Mox.defmock(Chronoscope.GenServerMock, for: Chronoscope.GenServer.Behaviour) Mox.defmock(Chronoscope.NTSMock, for: Chronoscope.NTS.Behaviour) Mox.defmock(Chronoscope.GeminiMock, for: Chronoscope.Gemini.Behaviour) +Mox.defmock(Chronoscope.EndpointMock, for: ChronoscopeWeb.Endpoint.Behaviour)