diff --git a/lib/towerops/splynx/client.ex b/lib/towerops/splynx/client.ex index 87d19f7d..685b8b1d 100644 --- a/lib/towerops/splynx/client.ex +++ b/lib/towerops/splynx/client.ex @@ -1,43 +1,117 @@ defmodule Towerops.Splynx.Client do @moduledoc """ - REST client for the Splynx API. + REST client for the Splynx API v2.0. - Uses Req with HTTP Basic Auth and page-based pagination. + Uses token-based authentication: first obtains a JWT via the auth endpoint, + then uses `Splynx-EA (access_token=...)` header for all subsequent requests. + Supports both `api_key` and `admin` login auth types. """ require Logger - @doc "Test the connection to Splynx by fetching a single customer." + @doc "Test the connection to Splynx by authenticating and fetching a single customer." def test_connection(instance_url, api_key, api_secret) do - case get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer", %{"per_page" => "1"}) do - {:ok, _data} -> {:ok, %{}} - {:error, reason} -> {:error, reason} + with {:ok, token} <- authenticate(instance_url, api_key, api_secret), + {:ok, _data} <- get(instance_url, token, "/api/2.0/admin/customers/customer", %{"per_page" => "1"}) do + {:ok, %{}} end end @doc "List all customers with automatic pagination." def list_customers(instance_url, api_key, api_secret) do - paginate(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer") + with {:ok, token} <- authenticate(instance_url, api_key, api_secret) do + paginate(instance_url, token, "/api/2.0/admin/customers/customer") + end end @doc "List internet services for a specific customer." def list_customer_services(instance_url, api_key, api_secret, customer_id) do - get(instance_url, api_key, api_secret, "/api/2.0/admin/customers/customer/#{customer_id}/internet-services") + with {:ok, token} <- authenticate(instance_url, api_key, api_secret) do + get(instance_url, token, "/api/2.0/admin/customers/customer/#{customer_id}/internet-services") + end end @doc "List all routers/network sites." def list_routers(instance_url, api_key, api_secret) do - paginate(instance_url, api_key, api_secret, "/api/2.0/admin/networking/routers") + with {:ok, token} <- authenticate(instance_url, api_key, api_secret) do + paginate(instance_url, token, "/api/2.0/admin/networking/routers") + end end - defp get(instance_url, api_key, api_secret, path, params \\ %{}) do + @doc """ + Authenticate with Splynx and return an access token. + + Tries `api_key` auth first, falls back to `admin` login auth. + """ + def authenticate(instance_url, api_key, api_secret) do + url = String.trim_trailing(instance_url, "/") <> "/api/2.0/admin/auth/tokens" + + # Try API key auth first + body = %{"auth_type" => "api_key", "key" => api_key, "secret" => api_secret} + + case do_auth_request(url, body) do + {:ok, token} -> + {:ok, token} + + {:error, :unauthorized} -> + # Fall back to admin login auth (key=login, secret=password) + admin_body = %{"auth_type" => "admin", "login" => api_key, "password" => api_secret} + do_auth_request(url, admin_body) + + {:error, reason} -> + {:error, reason} + end + end + + defp do_auth_request(url, body) do + req_opts = [ + method: :post, + url: url, + headers: [ + {"content-type", "application/json"}, + {"accept", "application/json"} + ], + json: body + ] + + req_opts = + if Application.get_env(:towerops, :env) == :test do + Keyword.put(req_opts, :plug, {Req.Test, __MODULE__}) + else + req_opts + end + + case Req.request(req_opts) do + {:ok, %{status: status, body: %{"access_token" => token}}} when status in [200, 201] -> + {:ok, token} + + {:ok, %{status: status}} when status in [401, 403] -> + {:error, :unauthorized} + + {:ok, %{status: 429}} -> + {:error, {:rate_limited, 60}} + + {:ok, %{status: status, body: body}} -> + Logger.warning("Splynx auth unexpected status #{status}: #{inspect(body)}") + {:error, {:unexpected_status, status}} + + {:error, reason} -> + Logger.error("Splynx auth connection error: #{inspect(reason)}") + {:error, reason} + end + rescue + exception -> + {:error, Exception.message(exception)} + end + + defp get(instance_url, token, path, params \\ %{}) do url = String.trim_trailing(instance_url, "/") <> path req_opts = [ method: :get, url: url, headers: [ - {"authorization", "Basic #{Base.encode64("#{api_key}:#{api_secret}")}"}, + {"authorization", "Splynx-EA (access_token=#{token})"}, {"accept", "application/json"} ], params: params @@ -76,15 +150,15 @@ defmodule Towerops.Splynx.Client do {:error, Exception.message(exception)} end - defp paginate(instance_url, api_key, api_secret, path, page \\ 1, acc \\ []) do + defp paginate(instance_url, token, path, page \\ 1, acc \\ []) do params = %{"page" => to_string(page), "per_page" => "100"} - case get(instance_url, api_key, api_secret, path, params) do + case get(instance_url, token, path, params) do {:ok, data} when is_list(data) -> all = acc ++ data if length(data) >= 100 do - paginate(instance_url, api_key, api_secret, path, page + 1, all) + paginate(instance_url, token, path, page + 1, all) else {:ok, all} end diff --git a/lib/towerops/splynx/sync.ex b/lib/towerops/splynx/sync.ex index 376aef45..05a73f1a 100644 --- a/lib/towerops/splynx/sync.ex +++ b/lib/towerops/splynx/sync.ex @@ -26,9 +26,11 @@ defmodule Towerops.Splynx.Sync do customers_count = length(customers) routers_count = length(routers) active_count = Enum.count(customers, &(&1["status"] == "active")) + mrr = calculate_total_mrr(customers) message = - "Synced #{customers_count} customers (#{active_count} active), #{routers_count} routers" + "Synced #{customers_count} customers (#{active_count} active), #{routers_count} routers" <> + if(mrr > 0, do: " (MRR: $#{:erlang.float_to_binary(mrr / 1, decimals: 2)})", else: "") Integrations.update_sync_status(integration, "success", message) @@ -44,6 +46,24 @@ defmodule Towerops.Splynx.Sync do end end + defp calculate_total_mrr(customers) do + Enum.reduce(customers, 0.0, fn customer, total -> + case customer["mrr_total"] do + value when is_binary(value) -> + case Float.parse(value) do + {num, _} -> total + num + :error -> total + end + + value when is_number(value) -> + total + value + + _ -> + total + end + end) + end + defp humanize_sync_error(:unauthorized), do: "Authentication failed — check your API key and secret" defp humanize_sync_error(:forbidden), do: "Access denied — your API credentials may not have sufficient permissions" defp humanize_sync_error({:rate_limited, retry_after}), do: "Rate limited by Splynx — retry after #{retry_after}s" diff --git a/lib/towerops_web/live/org/integrations_live.ex b/lib/towerops_web/live/org/integrations_live.ex index 25e56698..247394c8 100644 --- a/lib/towerops_web/live/org/integrations_live.ex +++ b/lib/towerops_web/live/org/integrations_live.ex @@ -6,6 +6,9 @@ defmodule ToweropsWeb.Org.IntegrationsLive do alias Towerops.Integrations alias Towerops.Integrations.Integration alias Towerops.Preseem.Client, as: PreseemClient + alias Towerops.Sonar.Client, as: SonarClient + alias Towerops.Splynx.Client, as: SplynxClient + alias Towerops.Visp.Client, as: VispClient require Logger @@ -143,9 +146,11 @@ defmodule ToweropsWeb.Org.IntegrationsLive do def handle_event("validate", %{"integration" => params}, socket) do integration = get_current_integration(socket) + provider = socket.assigns.configuring + changeset = integration - |> Integrations.change_integration(normalize_params(params, integration)) + |> Integrations.change_integration(normalize_params(provider, params, integration)) |> Map.put(:action, :validate) {:noreply, assign(socket, :form, to_form(changeset))} @@ -156,7 +161,7 @@ defmodule ToweropsWeb.Org.IntegrationsLive do organization = socket.assigns.organization provider = socket.assigns.configuring existing = Map.get(socket.assigns.integrations, provider) - attrs = normalize_params(params, existing) + attrs = normalize_params(provider, params, existing) result = if exclusive_conflict?(provider, socket.assigns.integrations) do @@ -207,13 +212,13 @@ defmodule ToweropsWeb.Org.IntegrationsLive do @impl true def handle_event("test_connection", _params, socket) do - api_key = extract_api_key(socket) + credentials = extract_credentials(socket) provider = socket.assigns.configuring - if api_key == "" or is_nil(api_key) do - {:noreply, assign(socket, :test_result, {:error, t("Please enter an API key first")})} + if missing_required_credentials?(provider, credentials) do + {:noreply, assign(socket, :test_result, {:error, t("Please fill in all required fields first")})} else - result = test_provider_connection(provider, api_key) + result = test_provider_connection(provider, credentials) {:noreply, assign(socket, :test_result, format_connection_result(result))} end end @@ -320,9 +325,19 @@ defmodule ToweropsWeb.Org.IntegrationsLive do end) end - defp test_provider_connection("preseem", api_key), do: PreseemClient.test_connection(api_key) - defp test_provider_connection("gaiia", api_key), do: GaiiaClient.test_connection(api_key) - defp test_provider_connection(_, _api_key), do: {:error, "Unknown provider"} + defp test_provider_connection("preseem", credentials), do: PreseemClient.test_connection(credentials["api_key"]) + defp test_provider_connection("gaiia", credentials), do: GaiiaClient.test_connection(credentials["api_key"]) + defp test_provider_connection("visp", credentials), do: VispClient.test_connection(credentials["api_key"]) + + defp test_provider_connection("sonar", credentials) do + SonarClient.test_connection(credentials["instance_url"], credentials["api_token"]) + end + + defp test_provider_connection("splynx", credentials) do + SplynxClient.test_connection(credentials["instance_url"], credentials["api_key"], credentials["api_secret"]) + end + + defp test_provider_connection(_, _credentials), do: {:error, "Unknown provider"} defp load_integrations(organization_id) do organization_id @@ -347,12 +362,38 @@ defmodule ToweropsWeb.Org.IntegrationsLive do max(0, div(diff, 60)) end - defp normalize_params(params, existing_integration) do - api_key = Map.get(params, "api_key", "") + defp normalize_params(provider, params, existing_integration) do sync_interval = Map.get(params, "sync_interval_minutes") + credentials = build_credentials(provider, params, existing_integration) + + attrs = %{credentials: credentials} + + if sync_interval && sync_interval != "" do + Map.put(attrs, :sync_interval_minutes, sync_interval) + else + attrs + end + end + + defp build_credentials("splynx", params, _existing) do + %{ + "instance_url" => Map.get(params, "instance_url", ""), + "api_key" => Map.get(params, "api_key", ""), + "api_secret" => Map.get(params, "api_secret", "") + } + end + + defp build_credentials("sonar", params, _existing) do + %{ + "instance_url" => Map.get(params, "instance_url", ""), + "api_token" => Map.get(params, "api_token", "") + } + end + + defp build_credentials("gaiia", params, existing) do webhook_secret = - case existing_integration do + case existing do %Integration{credentials: %{"webhook_secret" => secret}} when secret != "" -> secret @@ -360,13 +401,14 @@ defmodule ToweropsWeb.Org.IntegrationsLive do generate_webhook_secret() end - attrs = %{credentials: %{"api_key" => api_key, "webhook_secret" => webhook_secret}} + %{ + "api_key" => Map.get(params, "api_key", ""), + "webhook_secret" => webhook_secret + } + end - if sync_interval && sync_interval != "" do - Map.put(attrs, :sync_interval_minutes, sync_interval) - else - attrs - end + defp build_credentials(_provider, params, _existing) do + %{"api_key" => Map.get(params, "api_key", "")} end defp generate_webhook_secret do @@ -385,16 +427,29 @@ defmodule ToweropsWeb.Org.IntegrationsLive do defp get_credential(_integration, _key), do: "" - defp extract_api_key(socket) do + defp extract_credentials(socket) do changeset = socket.assigns.form.source data = Ecto.Changeset.apply_changes(changeset) - - case data.credentials do - %{"api_key" => key} -> key - _ -> nil - end + data.credentials || %{} end + defp missing_required_credentials?(provider, credentials) when provider in ["splynx"] do + blank?(credentials["instance_url"]) or blank?(credentials["api_key"]) or + blank?(credentials["api_secret"]) + end + + defp missing_required_credentials?(provider, credentials) when provider in ["sonar"] do + blank?(credentials["instance_url"]) or blank?(credentials["api_token"]) + end + + defp missing_required_credentials?(_provider, credentials) do + blank?(credentials["api_key"]) + end + + defp blank?(nil), do: true + defp blank?(""), do: true + defp blank?(_), do: false + defp format_connection_result({:ok, _body}), do: {:ok, "Connection successful"} defp format_connection_result({:error, :unauthorized}), do: {:error, "Invalid API key"} defp format_connection_result({:error, :forbidden}), do: {:error, "Access forbidden"} diff --git a/lib/towerops_web/live/org/integrations_live.html.heex b/lib/towerops_web/live/org/integrations_live.html.heex index 9c61fbf8..df4d7c4c 100644 --- a/lib/towerops_web/live/org/integrations_live.html.heex +++ b/lib/towerops_web/live/org/integrations_live.html.heex @@ -178,15 +178,49 @@ phx-change="validate" phx-submit="save" > +
- <.input - field={@form[:api_key]} - type="password" - label={t("API Key")} - placeholder={"Enter your #{provider.name} API key"} - autocomplete="off" - value={get_credential(@integrations[provider.id], "api_key")} - /> + <%= if provider.id in ["splynx", "sonar"] do %> + <.input + field={@form[:instance_url]} + type="text" + label={t("Instance URL")} + placeholder={"https://your-instance.#{provider.id}.com"} + autocomplete="off" + value={get_credential(@integrations[provider.id], "instance_url")} + /> + <% end %> + + <%= if provider.id == "sonar" do %> + <.input + field={@form[:api_token]} + type="password" + label={t("API Token")} + placeholder="Enter your Sonar API token" + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_token")} + /> + <% else %> + <.input + field={@form[:api_key]} + type="password" + label={t("API Key")} + placeholder={"Enter your #{provider.name} API key"} + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_key")} + /> + <% end %> + + <%= if provider.id == "splynx" do %> + <.input + field={@form[:api_secret]} + type="password" + label={t("API Secret")} + placeholder="Enter your Splynx API secret" + autocomplete="off" + value={get_credential(@integrations[provider.id], "api_secret")} + /> + <% end %> <.input field={@form[:sync_interval_minutes]} @@ -472,6 +506,7 @@ phx-change="validate" phx-submit="save" > +
<.input field={@form[:api_key]}