Fix Splynx integration: token-based auth, per-provider credential fields
- Splynx client: replace broken Basic Auth with proper token flow (POST /api/2.0/admin/auth/tokens → Splynx-EA bearer header) - Support both api_key and admin login auth types with fallback - Add MRR calculation to Splynx sync using mrr_total field - Integrations UI: per-provider credential fields (instance_url, api_key, api_secret for Splynx; instance_url, api_token for Sonar) - Wire up test_connection for Splynx, Sonar, and VISP providers - Verified against live Splynx demo (demo.splynx.com): auth, customers, routers, services all working - All 7350 tests passing
This commit is contained in:
parent
f62e2023c9
commit
6be3910fc4
4 changed files with 231 additions and 47 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -178,15 +178,49 @@
|
|||
phx-change="validate"
|
||||
phx-submit="save"
|
||||
>
|
||||
<input type="hidden" name="integration[provider]" value={provider.id} />
|
||||
<div class="space-y-4">
|
||||
<.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 type="hidden" name="integration[provider]" value={provider.id} />
|
||||
<div class="space-y-4">
|
||||
<.input
|
||||
field={@form[:api_key]}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue