From 5917190efe5aceeaac3da7f285b657b68ef819d2 Mon Sep 17 00:00:00 2001
From: Graham McIntie
Date: Sat, 14 Feb 2026 14:09:00 -0600
Subject: [PATCH] feat: add NetBox integration with sync direction, entity
selection, and filtering
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Three sync modes: NetBox→TowerOps (pull), TowerOps→NetBox (push), Bidirectional
- Configurable entity sync: devices, sites, IP addresses, interfaces
- Optional filters by device role, site, and tag (comma-separated)
- NetBox client with test connection, CRUD operations
- Radio card UI for sync direction, checkbox cards for entity selection
- Credentials stored encrypted (URL, API token, sync config)
---
lib/towerops/integrations/integration.ex | 2 +-
lib/towerops/netbox/client.ex | 145 +++++
lib/towerops_web/live/org/settings_live.ex | 80 ++-
.../live/org/settings_live.html.heex | 567 +++++++++++++++---
4 files changed, 697 insertions(+), 97 deletions(-)
create mode 100644 lib/towerops/netbox/client.ex
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 %>
- <.form
- for={@integration_form}
- id={"#{provider.id}-form"}
- phx-change="validate_integration"
- phx-submit="save_integration"
- >
-
- <.input
- field={@integration_form[:api_key]}
- type="password"
- label={
- if(provider.id == "pagerduty",
- do: "Integration Key (Routing Key)",
- else: "API Key"
- )
- }
- placeholder={
- if(provider.id == "pagerduty",
- do: "Enter your PagerDuty integration key",
- else: "Enter your #{provider.name} API key"
- )
- }
- autocomplete="off"
- value={get_credential(@integrations[provider.id], "api_key")}
- />
-
- <%= if provider.id == "pagerduty" do %>
-
-
- Where to find your Integration Key
+ <%= if provider.id == "netbox" do %>
+ <.form
+ for={@integration_form}
+ id="netbox-form"
+ phx-change="validate_integration"
+ phx-submit="save_integration"
+ >
+
+ <%!-- Connection Section --%>
+
+
+ Connection
-
-
- In PagerDuty, go to Services
- → select your service (or create one)
-
- Click the Integrations tab
-
- Click Add Integration
- → select Events API v2
-
- Copy the Integration Key and paste it above
-
-
- 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.
+
+ <.input
+ field={@integration_form[:url]}
+ type="url"
+ label="NetBox URL"
+ placeholder="https://netbox.example.com"
+ autocomplete="off"
+ value={get_credential(@integrations["netbox"], "url")}
+ />
+ <.input
+ field={@integration_form[:api_token]}
+ type="password"
+ label="API Token"
+ placeholder="Enter your NetBox API token"
+ autocomplete="off"
+ value={get_credential(@integrations["netbox"], "api_token")}
+ />
+
- <% end %>
- <%= if provider.id != "pagerduty" do %>
- <.input
- field={@integration_form[:sync_interval_minutes]}
- type="number"
- label="Sync interval (minutes)"
- min="5"
- value={
- if(@integrations[provider.id],
- do: @integrations[provider.id].sync_interval_minutes,
- else: if(provider.id == "gaiia", do: 15, else: 10)
- )
- }
- />
- <% end %>
+ <%!-- Sync Direction Section --%>
+
+
+ Sync Direction
+
+
+ Choose how data flows between TowerOps and NetBox. You can change this later.
+
+
+
- <%= if @test_result do %>
-
"bg-green-50 dark:bg-green-900/20"
- {:error, _} -> "bg-red-50 dark:bg-red-900/20"
- end
- ]}>
-
+
+
+ What to Sync
+
+
+ Choose which NetBox objects participate in sync. Devices and sites are recommended at minimum.
+
+
+
+
+
+
+
+ Devices
+
+
+ Name, IP, role, platform, status, serial number
+
+
+
+
+
+
+
+
+ Sites
+
+
+ Site names, locations, addresses, and coordinates
+
+
+
+
+
+
+
+
+ IP Addresses
+
+
+ IPAM data — assigned IPs, prefixes, VRFs
+
+
+
+
+
+
+
+
+ Interfaces
+
+
+ Physical and virtual interfaces, LAGs, connections
+
+
+
+
+
+
+ <%!-- Filtering Section --%>
+
+
+ Filters
+
+ (optional)
+
+
+
+ Narrow the sync scope. Leave blank to sync everything. Comma-separated for multiple values.
+
+
+
+
+ <%!-- Sync Interval --%>
+
+
+ <.input
+ field={@integration_form[:sync_interval_minutes]}
+ type="number"
+ label="Sync interval (minutes)"
+ min="5"
+ value={
+ if(@integrations["netbox"],
+ do: @integrations["netbox"].sync_interval_minutes,
+ else: 30
+ )
+ }
+ />
+
+ How often TowerOps checks NetBox for changes. 30 minutes is recommended for most setups.
+
+
+
+
+ <%!-- Test & Save --%>
+ <%= if @test_result do %>
+
"text-green-800 dark:text-green-200"
- {:error, _} -> "text-red-800 dark:text-red-200"
+ {:ok, _} -> "bg-green-50 dark:bg-green-900/20"
+ {:error, _} -> "bg-red-50 dark:bg-red-900/20"
end
]}>
- {elem(@test_result, 1)}
-
-
- <% end %>
+
"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 %>
+
+
+ <% end %>
-
-
- Test Connection
-
-
-
+
- Cancel
+ Test Connection
-
-
+
+ <% else %>
+ <.form
+ for={@integration_form}
+ id={"#{provider.id}-form"}
+ phx-change="validate_integration"
+ phx-submit="save_integration"
+ >
+
+ <.input
+ field={@integration_form[:api_key]}
+ type="password"
+ label={
+ if(provider.id == "pagerduty",
+ do: "Integration Key (Routing Key)",
+ else: "API Key"
+ )
+ }
+ placeholder={
+ if(provider.id == "pagerduty",
+ do: "Enter your PagerDuty integration key",
+ else: "Enter your #{provider.name} API key"
+ )
+ }
+ autocomplete="off"
+ value={get_credential(@integrations[provider.id], "api_key")}
+ />
+
+ <%= if provider.id == "pagerduty" do %>
+
+
+ Where to find your Integration Key
+
+
+
+ In PagerDuty, go to Services
+ → select your service (or create one)
+
+ Click the Integrations tab
+
+ Click Add Integration
+ → select Events API v2
+
+ Copy the Integration Key and paste it above
+
+
+ When connected, TowerOps will automatically trigger, acknowledge, and resolve PagerDuty incidents in sync with your alerts.
+
+
+ <% end %>
+
+ <%= if provider.id != "pagerduty" do %>
+ <.input
+ field={@integration_form[:sync_interval_minutes]}
+ type="number"
+ label="Sync interval (minutes)"
+ min="5"
+ value={
+ if(@integrations[provider.id],
+ do: @integrations[provider.id].sync_interval_minutes,
+ else: if(provider.id == "gaiia", do: 15, else: 10)
+ )
+ }
+ />
+ <% end %>
+
+ <%= if @test_result do %>
+
"bg-green-50 dark:bg-green-900/20"
+ {:error, _} -> "bg-red-50 dark:bg-red-900/20"
+ end
+ ]}>
+
"text-green-800 dark:text-green-200"
+ {:error, _} -> "text-red-800 dark:text-red-200"
+ end
+ ]}>
+ {elem(@test_result, 1)}
+
+
+ <% end %>
+
+
+
+ Test Connection
+
+
+
+
+ Cancel
+
+
+ Save
+
+
+
+
+
+ <% end %>
<% end %>