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:
Graham McIntire 2026-02-15 15:02:10 -06:00
parent f62e2023c9
commit 6be3910fc4
4 changed files with 231 additions and 47 deletions

View file

@ -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

View file

@ -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"

View file

@ -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"}

View file

@ -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]}