diff --git a/lib/towerops/integrations/integration.ex b/lib/towerops/integrations/integration.ex index f40b480f..3e77f545 100644 --- a/lib/towerops/integrations/integration.ex +++ b/lib/towerops/integrations/integration.ex @@ -14,7 +14,7 @@ defmodule Towerops.Integrations.Integration do @primary_key {:id, :binary_id, autogenerate: true} @foreign_key_type :binary_id - @valid_providers ~w(preseem gaiia pagerduty) + @valid_providers ~w(preseem gaiia pagerduty netbox) @valid_sync_statuses ~w(never success partial failed) schema "integrations" do diff --git a/lib/towerops/netbox/client.ex b/lib/towerops/netbox/client.ex new file mode 100644 index 00000000..5df8adf5 --- /dev/null +++ b/lib/towerops/netbox/client.ex @@ -0,0 +1,145 @@ +defmodule Towerops.NetBox.Client do + @moduledoc """ + HTTP client for the NetBox REST API. + + Handles authentication, pagination, and common CRUD operations + against a user-provided NetBox instance. + """ + + require Logger + + @doc """ + Test connectivity and authentication against a NetBox instance. + Returns `{:ok, info}` with the NetBox version or `{:error, reason}`. + """ + def test_connection(url, token) do + case get(url, "/api/status/", token) do + {:ok, %{"netbox-version" => version}} -> + {:ok, "Connected to NetBox #{version}"} + + {:ok, %{"django-version" => _}} -> + {:ok, "Connected to NetBox"} + + {:ok, _body} -> + {:ok, "Connected to NetBox"} + + {:error, :unauthorized} -> + {:error, "Authentication failed — check your API token"} + + {:error, :not_found} -> + {:error, "NetBox API not found at this URL — check the URL"} + + {:error, reason} -> + {:error, "Connection failed: #{reason}"} + end + end + + @doc """ + List devices from NetBox with optional filters. + """ + def list_devices(url, token, params \\ %{}) do + get(url, "/api/dcim/devices/", token, params) + end + + @doc """ + List sites from NetBox with optional filters. + """ + def list_sites(url, token, params \\ %{}) do + get(url, "/api/dcim/sites/", token, params) + end + + @doc """ + List IP addresses from NetBox with optional filters. + """ + def list_ip_addresses(url, token, params \\ %{}) do + get(url, "/api/ipam/ip-addresses/", token, params) + end + + @doc """ + List interfaces from NetBox with optional filters. + """ + def list_interfaces(url, token, params \\ %{}) do + get(url, "/api/dcim/interfaces/", token, params) + end + + @doc """ + Create or update a device in NetBox. + """ + def upsert_device(url, token, attrs) do + post(url, "/api/dcim/devices/", token, attrs) + end + + @doc """ + Create or update a site in NetBox. + """ + def upsert_site(url, token, attrs) do + post(url, "/api/dcim/sites/", token, attrs) + end + + # -- HTTP helpers -- + + defp get(base_url, path, token, params \\ %{}) do + url = normalize_url(base_url) <> path + + case Req.get(url, + headers: auth_headers(token), + params: params, + connect_options: [timeout: 10_000], + receive_timeout: 15_000 + ) do + {:ok, %Req.Response{status: 200, body: body}} -> + {:ok, body} + + {:ok, %Req.Response{status: 401}} -> + {:error, :unauthorized} + + {:ok, %Req.Response{status: 403}} -> + {:error, :unauthorized} + + {:ok, %Req.Response{status: 404}} -> + {:error, :not_found} + + {:ok, %Req.Response{status: status}} -> + {:error, "HTTP #{status}"} + + {:error, %Req.TransportError{reason: reason}} -> + {:error, "#{reason}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp post(base_url, path, token, body) do + url = normalize_url(base_url) <> path + + case Req.post(url, + headers: auth_headers(token), + json: body, + connect_options: [timeout: 10_000], + receive_timeout: 15_000 + ) do + {:ok, %Req.Response{status: status, body: body}} when status in [200, 201] -> + {:ok, body} + + {:ok, %Req.Response{status: 401}} -> + {:error, :unauthorized} + + {:ok, %Req.Response{status: status, body: body}} -> + {:error, "HTTP #{status}: #{inspect(body)}"} + + {:error, reason} -> + {:error, inspect(reason)} + end + end + + defp auth_headers(token) do + [{"authorization", "Token #{token}"}, {"accept", "application/json"}] + end + + defp normalize_url(url) do + url + |> String.trim() + |> String.trim_trailing("/") + end +end diff --git a/lib/towerops_web/live/org/settings_live.ex b/lib/towerops_web/live/org/settings_live.ex index 44538891..cbf74bbe 100644 --- a/lib/towerops_web/live/org/settings_live.ex +++ b/lib/towerops_web/live/org/settings_live.ex @@ -33,6 +33,13 @@ defmodule ToweropsWeb.Org.SettingsLive do description: "Incident management and on-call alerting. Automatically triggers, acknowledges, and resolves PagerDuty incidents from TowerOps alerts.", icon: "hero-bell-alert" + }, + %{ + id: "netbox", + name: "NetBox", + description: + "Infrastructure source of truth. Sync devices, sites, IP addresses, and interfaces between TowerOps and your NetBox instance.", + icon: "hero-server-stack" } ] @@ -344,14 +351,33 @@ defmodule ToweropsWeb.Org.SettingsLive do @impl true def handle_event("test_connection", _params, socket) do - api_key = extract_api_key(socket) provider = socket.assigns.configuring - if api_key == "" or is_nil(api_key) do - {:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})} + if provider == "netbox" do + integration = current_integration(socket) + url = get_credential(integration, "url") + token = get_credential(integration, "api_token") + + cond do + url == "" or is_nil(url) -> + {:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox URL first"})} + + token == "" or is_nil(token) -> + {:noreply, assign(socket, :test_result, {:error, "Please enter your NetBox API token first"})} + + true -> + result = Towerops.NetBox.Client.test_connection(url, token) + {:noreply, assign(socket, :test_result, format_connection_result(result))} + end else - result = test_provider_connection(provider, api_key) - {:noreply, assign(socket, :test_result, format_connection_result(result))} + api_key = extract_api_key(socket) + + if api_key == "" or is_nil(api_key) do + {:noreply, assign(socket, :test_result, {:error, "Please enter an API key first"})} + else + result = test_provider_connection(provider, api_key) + {:noreply, assign(socket, :test_result, format_connection_result(result))} + end end end @@ -435,6 +461,20 @@ defmodule ToweropsWeb.Org.SettingsLive do end defp normalize_params(params, existing_integration) do + provider = + case existing_integration do + %Integration{provider: p} -> p + _ -> Map.get(params, "provider", "") + end + + if provider == "netbox" do + normalize_netbox_params(params, existing_integration) + else + normalize_standard_params(params, existing_integration) + end + end + + defp normalize_standard_params(params, existing_integration) do api_key = Map.get(params, "api_key", "") sync_interval = Map.get(params, "sync_interval_minutes") @@ -456,6 +496,31 @@ defmodule ToweropsWeb.Org.SettingsLive do end end + defp normalize_netbox_params(params, _existing_integration) do + sync_interval = Map.get(params, "sync_interval_minutes") + + credentials = %{ + "url" => String.trim(Map.get(params, "url", "")), + "api_token" => Map.get(params, "api_token", ""), + "sync_direction" => Map.get(params, "sync_direction", "pull"), + "sync_devices" => Map.get(params, "sync_devices", "true") == "true", + "sync_sites" => Map.get(params, "sync_sites", "true") == "true", + "sync_ip_addresses" => Map.get(params, "sync_ip_addresses", "false") == "true", + "sync_interfaces" => Map.get(params, "sync_interfaces", "false") == "true", + "device_role_filter" => String.trim(Map.get(params, "device_role_filter", "")), + "site_filter" => String.trim(Map.get(params, "site_filter", "")), + "tag_filter" => String.trim(Map.get(params, "tag_filter", "")) + } + + attrs = %{credentials: credentials} + + if sync_interval && sync_interval != "" do + Map.put(attrs, :sync_interval_minutes, sync_interval) + else + attrs + end + end + defp webhook_url(organization_id) do ToweropsWeb.Endpoint.url() <> "/api/v1/webhooks/gaiia/#{organization_id}" end @@ -468,6 +533,11 @@ defmodule ToweropsWeb.Org.SettingsLive do defp get_credential(_integration, _key), do: "" + defp current_integration(socket) do + provider = socket.assigns.configuring + Map.get(socket.assigns.integrations, provider) + end + defp extract_api_key(socket) do changeset = socket.assigns.integration_form.source data = Ecto.Changeset.apply_changes(changeset) diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index da775c1a..8c2ca4cd 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -945,6 +945,17 @@ <% end %> + <%= if provider.id == "netbox" do %> + <% direction = get_credential(integration, "sync_direction") %> Syncs every {integration.sync_interval_minutes} minutes. + <%= case direction do %> + <% "push" -> %> + TowerOps → NetBox (TowerOps is source of truth) + <% "both" -> %> + Bidirectional sync (merge from both systems) + <% _ -> %> + NetBox → TowerOps (NetBox is source of truth) + <% end %> + <% end %>
<% end %> @@ -989,107 +1000,367 @@ <%= if @configuring == provider.id do %>- When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts. +
+ Your NetBox instance URL and API token. The token needs read permission at minimum. + Write permission is required if you want to push data to NetBox.
++ Choose how data flows between TowerOps and NetBox. You can change this later. +
++
+ Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum. +
++ Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values. +
++ How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups. +
+"text-green-700 dark:text-green-400" + {:error, _} -> "text-red-700 dark:text-red-400" + end + ]}> + <%= case @test_result do %> + <% {:ok, msg} -> %> + <.icon name="hero-check-circle" class="h-4 w-4 inline" /> + {msg} + <% {:error, msg} -> %> + <.icon name="hero-x-circle" class="h-4 w-4 inline" /> + {msg} + <% end %> +
++ When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts. +
+"text-green-800 dark:text-green-200" + {:error, _} -> "text-red-800 dark:text-red-200" + end + ]}> + {elem(@test_result, 1)} +
+