diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 3498c83d..173daa95 120000
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1 +1 @@
-/nix/store/4xlkmdd947h5qlhj2rmbyavq08grkz27-pre-commit-config.json
\ No newline at end of file
+/nix/store/zx636v118rzzqzwafgrw2aybh7l0szrl-pre-commit-config.json
\ No newline at end of file
diff --git a/lib/towerops/url_validator.ex b/lib/towerops/url_validator.ex
index bec70017..1f5214e3 100644
--- a/lib/towerops/url_validator.ex
+++ b/lib/towerops/url_validator.ex
@@ -1,12 +1,12 @@
defmodule Towerops.URLValidator do
- import Bitwise
-
@moduledoc """
Validates URLs to prevent SSRF (Server-Side Request Forgery) attacks.
Blocks requests to internal/private IP ranges, localhost, and non-HTTP(S) schemes.
"""
+ import Bitwise
+
@private_ranges [
# 127.0.0.0/8
{127, 0, 0, 0, 8},
@@ -31,9 +31,8 @@ defmodule Towerops.URLValidator do
def validate(url) when is_binary(url) do
with {:ok, uri} <- parse_url(url),
:ok <- validate_scheme(uri),
- :ok <- validate_host(uri),
- :ok <- validate_ip(uri.host) do
- :ok
+ :ok <- validate_host(uri) do
+ validate_ip(uri.host)
end
end
@@ -77,17 +76,12 @@ defmodule Towerops.URLValidator do
end
defp resolve_host(host) do
- case :inet.parse_address(String.to_charlist(host)) do
- {:ok, ip} -> {:ok, ip}
- {:error, _} ->
- case :inet.getaddr(String.to_charlist(host), :inet) do
- {:ok, ip} -> {:ok, ip}
- {:error, _} ->
- case :inet.getaddr(String.to_charlist(host), :inet6) do
- {:ok, ip} -> {:ok, ip}
- error -> error
- end
- end
+ charlist = String.to_charlist(host)
+
+ with {:error, _} <- :inet.parse_address(charlist),
+ {:error, _} <- :inet.getaddr(charlist, :inet) do
+ error = :inet.getaddr(charlist, :inet6)
+ error
end
end
@@ -95,9 +89,11 @@ defmodule Towerops.URLValidator do
defp private_ip?({a, b, c, d}) do
Enum.any?(@private_ranges, fn
- :ipv6_loopback -> false
+ :ipv6_loopback ->
+ false
+
{net_a, net_b, net_c, net_d, prefix} ->
- mask = bsl(0xFFFFFFFF, 32 - prefix) |> band(0xFFFFFFFF)
+ mask = 0xFFFFFFFF |> bsl(32 - prefix) |> band(0xFFFFFFFF)
ip_int = bsl(a, 24) + bsl(b, 16) + bsl(c, 8) + d
net_int = bsl(net_a, 24) + bsl(net_b, 16) + bsl(net_c, 8) + net_d
band(ip_int, mask) == band(net_int, mask)
@@ -105,6 +101,4 @@ defmodule Towerops.URLValidator do
end
defp private_ip?(_), do: false
-
- import Bitwise
end
diff --git a/lib/towerops_web/live/agent_live/index.ex b/lib/towerops_web/live/agent_live/index.ex
index cc44a7e1..4dd3c36d 100644
--- a/lib/towerops_web/live/agent_live/index.ex
+++ b/lib/towerops_web/live/agent_live/index.ex
@@ -161,77 +161,12 @@ defmodule ToweropsWeb.AgentLive.Index do
@impl true
def handle_event("delete_agent", %{"id" => id}, socket) do
- organization = socket.assigns.current_scope.organization
-
- # Verify the agent token belongs to the current org before deleting
agent_token = Agents.get_agent_token!(id)
- is_superuser = Scope.superuser?(socket.assigns.current_scope)
- is_cloud_poller = is_nil(agent_token.organization_id)
- belongs_to_org = agent_token.organization_id == organization.id
-
- if not belongs_to_org and not (is_superuser and is_cloud_poller) do
- {:noreply, put_flash(socket, :error, t_equipment("Agent not found"))}
+ if agent_authorized_for_deletion?(socket, agent_token) do
+ handle_agent_deletion(socket, id)
else
- case Agents.delete_agent_token(id) do
- {:ok, _} ->
-
- # If the deleted agent was the global default, clear it
- if Scope.superuser?(socket.assigns.current_scope) &&
- socket.assigns.global_default_cloud_poller_id == id do
- Settings.set_global_default_cloud_poller(nil)
- end
-
- agent_tokens = Agents.list_organization_agent_tokens(organization.id)
-
- # Refresh cloud pollers list if superadmin (in case a cloud poller was deleted)
- cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope)
-
- # Reload global default cloud poller ID
- global_default_cloud_poller_id =
- if Scope.superuser?(socket.assigns.current_scope) do
- Settings.get_global_default_cloud_poller()
- else
- socket.assigns.global_default_cloud_poller_id
- end
-
- # Recalculate device counts after deletion
- equipment_counts =
- Map.new(agent_tokens, fn token ->
- direct = Agents.count_assigned_devices(token.id)
- total = length(Agents.list_agent_polling_targets(token.id))
- {token.id, %{direct: direct, total: total}}
- end)
-
- cloud_poller_counts = calculate_device_counts(cloud_pollers)
-
- # Refresh health statistics
- agent_health_stats = Stats.get_organization_agent_health(organization.id)
- assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
- offline_agents = Stats.get_offline_agents(organization.id)
-
- {:noreply,
- socket
- |> stream(:agent_tokens, agent_tokens, reset: true)
- |> assign(:has_agents, agent_tokens != [])
- |> stream(:cloud_pollers, cloud_pollers, reset: true)
- |> assign(:cloud_pollers_list, cloud_pollers)
- |> assign(:has_cloud_pollers, cloud_pollers != [])
- |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id)
- |> assign(:selected_global_default, global_default_cloud_poller_id || "")
- |> assign(:device_counts, equipment_counts)
- |> assign(:cloud_poller_counts, cloud_poller_counts)
- |> assign(:agent_health_stats, agent_health_stats)
- |> assign(:assignment_breakdown, assignment_breakdown)
- |> assign(:offline_agents, offline_agents)
- |> put_flash(
- :info,
- t_equipment("Agent deleted successfully. Devices now fall back to site/org defaults or cloud polling.")
- )}
-
- {:error, _} ->
- {:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))}
- end
+ {:noreply, put_flash(socket, :error, t_equipment("Agent not found"))}
end
end
@@ -484,6 +419,76 @@ defmodule ToweropsWeb.AgentLive.Index do
|> assign(:offline_agents, offline_agents)
end
+ defp agent_authorized_for_deletion?(socket, agent_token) do
+ organization = socket.assigns.current_scope.organization
+ is_superuser = Scope.superuser?(socket.assigns.current_scope)
+ is_cloud_poller = is_nil(agent_token.organization_id)
+ belongs_to_org = agent_token.organization_id == organization.id
+
+ belongs_to_org or (is_superuser and is_cloud_poller)
+ end
+
+ defp handle_agent_deletion(socket, id) do
+ case Agents.delete_agent_token(id) do
+ {:ok, _} ->
+ handle_agent_deletion_success(socket, id)
+
+ {:error, _} ->
+ {:noreply, put_flash(socket, :error, t_equipment("Failed to delete agent"))}
+ end
+ end
+
+ defp handle_agent_deletion_success(socket, deleted_id) do
+ maybe_clear_global_default(socket, deleted_id)
+
+ organization = socket.assigns.current_scope.organization
+ agent_tokens = Agents.list_organization_agent_tokens(organization.id)
+ cloud_pollers = load_cloud_pollers_if_superuser(socket.assigns.current_scope)
+
+ global_default_cloud_poller_id = reload_global_default(socket.assigns.current_scope, socket.assigns)
+
+ equipment_counts = calculate_device_counts(agent_tokens)
+ cloud_poller_counts = calculate_device_counts(cloud_pollers)
+
+ agent_health_stats = Stats.get_organization_agent_health(organization.id)
+ assignment_breakdown = Stats.get_device_assignment_breakdown(organization.id)
+ offline_agents = Stats.get_offline_agents(organization.id)
+
+ {:noreply,
+ socket
+ |> stream(:agent_tokens, agent_tokens, reset: true)
+ |> assign(:has_agents, agent_tokens != [])
+ |> stream(:cloud_pollers, cloud_pollers, reset: true)
+ |> assign(:cloud_pollers_list, cloud_pollers)
+ |> assign(:has_cloud_pollers, cloud_pollers != [])
+ |> assign(:global_default_cloud_poller_id, global_default_cloud_poller_id)
+ |> assign(:selected_global_default, global_default_cloud_poller_id || "")
+ |> assign(:device_counts, equipment_counts)
+ |> assign(:cloud_poller_counts, cloud_poller_counts)
+ |> assign(:agent_health_stats, agent_health_stats)
+ |> assign(:assignment_breakdown, assignment_breakdown)
+ |> assign(:offline_agents, offline_agents)
+ |> put_flash(
+ :info,
+ t_equipment("Agent deleted successfully. Devices now fall back to site/org defaults or cloud polling.")
+ )}
+ end
+
+ defp maybe_clear_global_default(socket, deleted_id) do
+ if Scope.superuser?(socket.assigns.current_scope) &&
+ socket.assigns.global_default_cloud_poller_id == deleted_id do
+ Settings.set_global_default_cloud_poller(nil)
+ end
+ end
+
+ defp reload_global_default(scope, assigns) do
+ if Scope.superuser?(scope) do
+ Settings.get_global_default_cloud_poller()
+ else
+ assigns.global_default_cloud_poller_id
+ end
+ end
+
@impl true
def terminate(_reason, socket) do
if timer_ref = socket.assigns[:timer_ref] do
diff --git a/lib/towerops_web/live/network_map_live.html.heex b/lib/towerops_web/live/network_map_live.html.heex
index 8a6fe435..cc2eb056 100644
--- a/lib/towerops_web/live/network_map_live.html.heex
+++ b/lib/towerops_web/live/network_map_live.html.heex
@@ -286,8 +286,8 @@
data-topology={Jason.encode!(@topology)}
>
-
-
+
+