From 53bfdbd75d7e7ecbe8977c2262f1d03cc47bf478 Mon Sep 17 00:00:00 2001 From: Mike Cifelli Date: Sun, 21 Apr 2024 10:30:56 -0400 Subject: [PATCH] Add gemini connection endpoint --- lib/chronoscope/gemini.ex | 4 +- lib/chronoscope/gemini/client.ex | 2 +- lib/chronoscope/gemini/response.ex | 2 +- .../api/v1/gemini/connection_controller.ex | 67 ++++++++++++++++ lib/chronoscope_web/router.ex | 6 ++ test/chronoscope/gemini/client_test.exs | 78 +++++++++++++++++++ test/chronoscope/nts/client_test.exs | 22 +++--- 7 files changed, 165 insertions(+), 16 deletions(-) create mode 100644 lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex create mode 100644 test/chronoscope/gemini/client_test.exs diff --git a/lib/chronoscope/gemini.ex b/lib/chronoscope/gemini.ex index 4e6bc56..abbd14f 100644 --- a/lib/chronoscope/gemini.ex +++ b/lib/chronoscope/gemini.ex @@ -23,8 +23,8 @@ defmodule Chronoscope.Gemini do |> Enum.map(fn {_, pid, _, _} -> @genserver.call(pid, :list) end) end - def remove(host, port) do - name = client_name(%{host: host, port: port}) + 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)} diff --git a/lib/chronoscope/gemini/client.ex b/lib/chronoscope/gemini/client.ex index 41e4c15..6988553 100644 --- a/lib/chronoscope/gemini/client.ex +++ b/lib/chronoscope/gemini/client.ex @@ -18,7 +18,7 @@ defmodule Chronoscope.Gemini.Client do {:ok, %{ resource: resource, - reponse: {:error, "initializing"}, + response: {:error, "initializing"}, last_request: DateTime.add(now, -@interval_in_seconds, :second) }} end diff --git a/lib/chronoscope/gemini/response.ex b/lib/chronoscope/gemini/response.ex index 26c7e17..9574c9d 100644 --- a/lib/chronoscope/gemini/response.ex +++ b/lib/chronoscope/gemini/response.ex @@ -1,6 +1,6 @@ defmodule Chronoscope.Gemini.Response do def parse(response) do # TODO - %{status: 20, type: "text/gemini", body: response} + %{status: 20, mime_type: "text/gemini", body: to_string(response)} end end diff --git a/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex b/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex new file mode 100644 index 0000000..c4fba0b --- /dev/null +++ b/lib/chronoscope_web/controllers/api/v1/gemini/connection_controller.ex @@ -0,0 +1,67 @@ +defmodule ChronoscopeWeb.API.V1.Gemini.ConnectionController do + use ChronoscopeWeb, :controller + + require Logger + + alias Chronoscope.Gemini + + @default_port 1965 + @default_path "/" + @max_host_length 255 + @gemini Application.compile_env(:chronoscope, :gemini, Gemini) + + def get(conn, %{"host" => host, "port" => port, "path" => path}) do + try do + handle_get(conn, %{host: host, port: String.to_integer(port), path: path}) + rescue + ArgumentError -> bad_request_response(conn, "invalid port") + end + end + + def get(conn, %{"host" => host, "path" => path}) do + handle_get(conn, %{host: host, port: @default_port, path: path}) + end + + def get(conn, %{"host" => host, "port" => port}) do + handle_get(conn, %{host: host, port: port, path: @default_path}) + end + + def get(conn, %{"host" => host}) do + handle_get(conn, %{host: 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: host, port: port, path: path}) when port > 0 and port < 65536 do + case connect(host, port, path) do + {:ok, response} -> + json(conn, %{status: :ok, response: format_response(response)}) + + {:error, error} -> + json(conn, %{status: :error, reason: to_string(error)}) + end + end + + defp handle_get(conn, _params) do + bad_request_response(conn, "port out of range") + end + + defp connect(host, port, path) do + # TODO - max path length + host + |> String.slice(0, @max_host_length) + |> @gemini.connect(port, path) + end + + defp format_response(response) do + Map.take(response, [:status, :mime_type, :body]) + end + + defp bad_request_response(conn, message) do + conn + |> put_status(:bad_request) + |> json(%{error: message}) + end +end diff --git a/lib/chronoscope_web/router.ex b/lib/chronoscope_web/router.ex index 72c4a12..75f0d25 100644 --- a/lib/chronoscope_web/router.ex +++ b/lib/chronoscope_web/router.ex @@ -32,6 +32,12 @@ defmodule ChronoscopeWeb.Router do get "/key-establishment", KeyEstablishmentController, :get end + scope "/api/v1/gemini", ChronoscopeWeb.API.V1.Gemini do + pipe_through :api + + get "/connect", ConnectionController, :get + end + # Enable LiveDashboard and Swoosh mailbox preview in development if Application.compile_env(:chronoscope, :dev_routes) do # If you want to use the LiveDashboard in production, you should put diff --git a/test/chronoscope/gemini/client_test.exs b/test/chronoscope/gemini/client_test.exs new file mode 100644 index 0000000..092ff38 --- /dev/null +++ b/test/chronoscope/gemini/client_test.exs @@ -0,0 +1,78 @@ +defmodule Chronoscope.Gemini.ClientTest do + use Chronoscope.Case, async: true + + alias Chronoscope.Certificate + alias Chronoscope.DateTimeMock + alias Chronoscope.Gemini.Client + alias Chronoscope.SSLMock + + import Mox + + setup :verify_on_exit! + + setup _tags do + DateTimeMock + |> stub(:utc_now, fn -> ~U[2024-04-21 01:23:45Z] end) + + :ok + end + + describe "Chronoscope.Gemini.Client.init()" do + test "initializes successfully" do + assert Client.init(%{host: "localhost", port: 4444, path: "/"}) == + {:ok, + %{ + resource: %{host: "localhost", port: 4444, path: "/"}, + response: {:error, "initializing"}, + last_request: ~U[2024-04-21 01:23:15Z] + }} + end + end + + describe "Chronoscope.Gemini.Client.handle_call()" do + test ":terminate" do + assert Client.handle_call(:terminate, nil, %{state: true}) == {:stop, :normal, self(), %{state: true}} + end + + test ":list" do + assert Client.handle_call(:list, nil, %{state: true}) == {:reply, %{state: true}, %{state: true}} + end + + test ":connect - cached" do + assert Client.handle_call(:connect, nil, %{ + resource: %{host: "localhost", port: 4444, path: "/"}, + response: {:error, "initializing"}, + last_request: ~U[2024-04-21 01:23:45Z] + }) == + {:reply, {:error, "initializing"}, + %{ + resource: %{host: "localhost", port: 4444, path: "/"}, + response: {:error, "initializing"}, + last_request: ~U[2024-04-21 01:23:45Z] + }} + end + + test ":connect - not cached" do + peercert = peercert() + peercert_expiration = Certificate.expiration_date(peercert) + + SSLMock + |> expect(:connect, fn ~c"localhost", 4444, _, _ -> {:ok, :socket} end) + |> expect(:send, fn :socket, _ -> send_ssl_response([]) end) + |> expect(:peercert, fn :socket -> {:ok, peercert} end) + |> expect(:close, fn :socket -> :ok end) + + assert {:reply, {:ok, %{cert_expiration: ^peercert_expiration}}, + %{ + resource: %{host: "localhost", port: 4444, path: "/"}, + response: {:ok, %{cert_expiration: ^peercert_expiration}}, + last_request: ~U[2024-04-21 01:23:45Z] + }} = + Client.handle_call(:connect, nil, %{ + resource: %{host: "localhost", port: 4444, path: "/"}, + response: {:error, "initializing"}, + last_request: ~U[2024-04-21 01:23:00Z] + }) + end + end +end diff --git a/test/chronoscope/nts/client_test.exs b/test/chronoscope/nts/client_test.exs index 5571fd7..f092704 100644 --- a/test/chronoscope/nts/client_test.exs +++ b/test/chronoscope/nts/client_test.exs @@ -3,9 +3,9 @@ defmodule Chronoscope.NTS.ClientTest do alias Chronoscope.Certificate alias Chronoscope.DateTimeMock + alias Chronoscope.NTS.Client alias Chronoscope.SSLMock - import Chronoscope.NTS.Client import Mox setup :verify_on_exit! @@ -19,7 +19,7 @@ defmodule Chronoscope.NTS.ClientTest do describe "Chronoscope.NTS.Client.init()" do test "initializes successfully" do - assert init(%{host: "localhost", port: 3333}) == + assert Client.init(%{host: "localhost", port: 3333}) == {:ok, %{ server: %{host: "localhost", port: 3333}, @@ -31,26 +31,24 @@ defmodule Chronoscope.NTS.ClientTest do describe "Chronoscope.NTS.Client.handle_call()" do test ":terminate" do - assert handle_call(:terminate, nil, %{state: true}) == {:stop, :normal, self(), %{state: true}} + assert Client.handle_call(:terminate, nil, %{state: true}) == {:stop, :normal, self(), %{state: true}} end test ":list" do - assert 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 test ":key_establishment - cached" do - assert handle_call(:key_establishment, nil, %{ - host: "localhost", + assert Client.handle_call(:key_establishment, nil, %{ + server: %{host: "localhost", port: 3333}, key_establishment_response: {:error, "initializing"}, - last_key_establishment: ~U[2024-03-31 01:23:45Z], - port: 3333 + last_key_establishment: ~U[2024-03-31 01:23:45Z] }) == {:reply, {:error, "initializing"}, %{ - host: "localhost", + server: %{host: "localhost", port: 3333}, key_establishment_response: {:error, "initializing"}, - last_key_establishment: ~U[2024-03-31 01:23:45Z], - port: 3333 + last_key_establishment: ~U[2024-03-31 01:23:45Z] }} end @@ -70,7 +68,7 @@ defmodule Chronoscope.NTS.ClientTest do key_establishment_response: {:ok, %{cert_expiration: ^peercert_expiration}}, last_key_establishment: ~U[2024-03-31 01:23:45Z] }} = - handle_call(:key_establishment, nil, %{ + Client.handle_call(:key_establishment, nil, %{ server: %{host: "localhost", port: 3333}, key_establishment_response: {:error, "initializing"}, last_key_establishment: ~U[2024-03-31 01:23:00Z]