towerops/lib/towerops_web/live/device_live/index.ex
Graham McIntie 1590f78bdc fix: input validation, SSRF, API hardening, and cookie security
- LIKE wildcard injection: sanitize %, _ in search queries (devices, sites, gaiia)
- Jason.decode! → Jason.decode with error handling for untrusted input
- inspect() leak: replace with generic error messages, log details server-side
- SSRF protection: URL validator blocks private IPs, localhost, non-HTTP schemes
- SSRF validation added to HTTP monitoring executor and integration credentials
- GraphQL complexity limits: always applied, not just in prod
- GraphQL introspection: also check GET query params, not just body
- Stripe webhook: explicit nil/empty checks for signature and body
- Cookie security: secure flag for session (prod), http_only+secure for remember_me
- Honeybadger API key: read from env var with fallback
- String.to_integer → Integer.parse with fallback for URL params
- String.to_atom → whitelist map for HTTP methods
- Gaiia webhook: remove secret_len and expected signature from log
- Admin API: add rate limiting pipeline
- to_atom_keys: per-key fallback instead of all-or-nothing rescue
2026-03-14 14:48:59 -05:00

594 lines
20 KiB
Elixir

defmodule ToweropsWeb.DeviceLive.Index do
@moduledoc false
use ToweropsWeb, :live_view
alias Towerops.Devices
alias Towerops.Gaiia
alias Towerops.Monitoring
alias Towerops.Organizations.SubscriptionLimits
alias Towerops.Sites
alias Towerops.Snmp
alias Towerops.Workers.DiscoveryWorker
alias ToweropsWeb.Live.Helpers.AccessControl
@impl true
def mount(_params, _session, socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
sites = Sites.list_organization_sites(organization.id)
if connected?(socket) do
Phoenix.PubSub.subscribe(Towerops.PubSub, "devices:org:#{organization.id}")
schedule_tick()
end
# Load device quota
{current, limit} = SubscriptionLimits.device_quota(organization)
site_subscribers = load_site_subscribers(devices)
{:ok,
socket
|> assign(:page_title, t_equipment("Devices"))
|> assign(:timezone, socket.assigns.current_scope.timezone)
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices))
|> assign(:sites_enabled, organization.use_sites)
|> assign(:has_sites, sites != [])
|> assign(:reorder_mode, false)
|> assign(:total_devices, length(devices))
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, site_subscribers)
|> assign(:search_query, "")
|> assign(:status_filter, "all")
|> assign(:pending_reload_ref, nil)}
end
@impl true
def handle_params(params, _url, socket) do
tab = Map.get(params, "tab", "existing")
{:noreply, apply_action(socket, socket.assigns.live_action, params, tab)}
end
defp apply_action(socket, :index, params, tab) do
organization = socket.assigns.current_scope.organization
case tab do
"discovered" ->
page = safe_to_integer(Map.get(params, "page", "1"), 1)
per_page = 20
# Load all discovered devices - this is temporarily kept in memory for slicing
# For very large datasets, consider database-level pagination
all_discovered = Snmp.list_discovered_devices_for_organization(organization.id)
total_count = length(all_discovered)
total_pages = ceil(total_count / per_page)
# Ensure page is within valid range
page = max(1, min(page, max(1, total_pages)))
offset = (page - 1) * per_page
# Only keep the current page in assigns - don't store all_discovered
discovered_devices = Enum.slice(all_discovered, offset, per_page)
socket
|> assign(:active_tab, "discovered")
# Use regular assign to prevent state bleeding between tab switches
|> assign(:discovered_devices, discovered_devices)
|> assign(:pagination, %{
page: page,
per_page: per_page,
total_count: total_count,
total_pages: total_pages
})
_ ->
# Reload device stream to ensure immediate rendering when switching back to existing tab.
# Without this, the table won't appear until the next PubSub update or tick event.
devices = Devices.list_organization_devices(organization.id)
socket
|> assign(:active_tab, "existing")
|> assign(:discovered_devices, [])
|> assign(:pagination, nil)
|> stream(:device_rows, build_device_rows(devices), reset: true)
end
end
@impl true
def handle_event("search", %{"search_query" => query}, socket) do
{:noreply,
socket
|> assign(:search_query, query)
|> apply_filters()}
end
@impl true
def handle_event("filter_status", %{"status" => status}, socket) do
new_status = if socket.assigns.status_filter == status, do: "all", else: status
{:noreply,
socket
|> assign(:status_filter, new_status)
|> apply_filters()}
end
@impl true
def handle_event("force_rediscover_all", _params, socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
snmp_devices = Enum.filter(devices, & &1.snmp_enabled)
if Enum.empty?(snmp_devices) do
{:noreply, put_flash(socket, :error, t_equipment("No SNMP-enabled devices found"))}
else
Enum.each(snmp_devices, fn device ->
enqueue_discovery(device.id)
end)
count = length(snmp_devices)
message = t_equipment("Discovery started for %{count} device", count: count) <> if count == 1, do: "", else: "s"
{:noreply, put_flash(socket, :info, message)}
end
end
@impl true
def handle_event("reorder_site", %{"site_id" => site_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_scope.organization
# Verify site belongs to current organization
case AccessControl.verify_site_access(site_id, organization.id) do
{:ok, _site} ->
perform_site_reorder(socket, site_id, new_position, organization.id)
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, t_equipment("Site not found"))}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this site"))}
end
end
@impl true
def handle_event("reorder_device", %{"device_id" => device_id, "new_position" => new_position}, socket) do
organization = socket.assigns.current_scope.organization
# Verify device belongs to current organization
case AccessControl.verify_device_access(device_id, organization.id) do
{:ok, _device} ->
perform_device_reorder(socket, device_id, new_position, organization.id)
{:error, :not_found} ->
{:noreply, put_flash(socket, :error, t_equipment("Device not found"))}
{:error, :unauthorized} ->
{:noreply, put_flash(socket, :error, t_equipment("You don't have access to this device"))}
end
end
@impl true
def handle_event("toggle_reorder_mode", _params, socket) do
{:noreply, assign(socket, :reorder_mode, !socket.assigns.reorder_mode)}
end
@impl true
def handle_event("reset_order", _params, socket) do
organization_id = socket.assigns.current_scope.organization.id
Sites.reset_site_order(organization_id)
Devices.reset_organization_device_order(organization_id)
# Reload devices with alphabetical order
devices = Devices.list_organization_devices(organization_id)
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, t_equipment("Order reset to alphabetical"))}
end
def handle_event("add_discovered_device", params, socket) do
identifier = Jason.decode!(params["identifier"])
discovered = Enum.find(socket.assigns.discovered_devices, &(&1.identifier == identifier))
prefill_params = %{
"name" => discovered.hostname || "",
"ip_address" => List.first(discovered.ip_addresses) || "",
"snmp_enabled" => "true"
}
{:noreply, push_navigate(socket, to: ~p"/devices/new?#{prefill_params}")}
end
defp perform_site_reorder(socket, site_id, new_position, organization_id) do
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
result = Sites.reorder_site(site_id, position)
perform_reorder(socket, result, "site", organization_id)
end
defp perform_device_reorder(socket, device_id, new_position, organization_id) do
position = if is_integer(new_position), do: new_position, else: String.to_integer(new_position)
result = Devices.reorder_device(device_id, position)
perform_reorder(socket, result, "device", organization_id)
end
defp perform_reorder(socket, reorder_result, resource_name, organization_id) do
case reorder_result do
{:ok, _} ->
# Reload devices with updated order
devices = Devices.list_organization_devices(organization_id)
success_message =
case resource_name do
"site" -> t_equipment("Site order updated")
"device" -> t_equipment("Device order updated")
end
{:noreply,
socket
|> assign(:has_devices, devices != [])
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> put_flash(:info, success_message)}
{:error, _changeset} ->
error_message =
case resource_name do
"site" -> t_equipment("Failed to reorder site")
"device" -> t_equipment("Failed to reorder device")
end
{:noreply, put_flash(socket, :error, error_message)}
end
end
# Periodic tick to refresh "time ago" timestamps between poll events
@impl true
def handle_info(:tick, socket) do
schedule_tick()
if socket.assigns.active_tab == "existing" do
{:noreply, schedule_debounced_reload(socket, :tick)}
else
{:noreply, socket}
end
end
# Structural changes (create/delete) need full reload with quota recalculation
@impl true
def handle_info({event, _org_id}, socket) when event in [:device_created, :device_deleted] do
{:noreply, reload_devices(socket)}
end
# Status changes and updates are debounced to avoid redundant reloads
# when many devices change status simultaneously (e.g., network event)
@impl true
def handle_info({event, _org_id}, socket) when event in [:device_status_changed, :device_updated] do
{:noreply, schedule_debounced_reload(socket, event)}
end
@impl true
def handle_info(:debounced_device_reload, socket) do
{:noreply, reload_device_stream(socket)}
end
defp tick_interval_ms do
if Application.get_env(:towerops, :env) == :test, do: :infinity, else: to_timeout(second: 15)
end
defp schedule_tick do
case tick_interval_ms() do
:infinity -> :ok
interval -> Process.send_after(self(), :tick, interval)
end
end
defp debounce_ms do
if Application.get_env(:towerops, :env) == :test, do: 0, else: 100
end
defp schedule_debounced_reload(socket, event) do
# Cancel any pending debounced reload
if ref = socket.assigns.pending_reload_ref do
Process.cancel_timer(ref)
end
# For status changes, skip reload entirely if viewing the discovered tab
if event == :device_status_changed && socket.assigns.active_tab == "discovered" do
assign(socket, :pending_reload_ref, nil)
else
case debounce_ms() do
0 ->
# In test: reload immediately so render(view) sees the update
reload_device_stream(socket)
ms ->
ref = Process.send_after(self(), :debounced_device_reload, ms)
assign(socket, :pending_reload_ref, ref)
end
end
end
# Full reload: stream + quota (for structural changes like create/delete)
defp reload_devices(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
{current, limit} = SubscriptionLimits.device_quota(organization)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:device_quota, %{current: current, limit: limit})
|> assign(:site_subscribers, load_site_subscribers(devices))
|> assign(:total_devices, length(devices))
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
end
# Lightweight reload: stream only, skip quota query (for status/update changes)
defp reload_device_stream(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
socket
|> stream(:device_rows, build_device_rows(devices), reset: true)
|> assign(:has_devices, devices != [])
|> assign(:pending_reload_ref, nil)
|> assign(:total_devices, length(devices))
|> assign(:total_up, Enum.count(devices, &(&1.status == :up)))
|> assign(:total_down, Enum.count(devices, &(&1.status == :down)))
|> assign(:total_unknown, Enum.count(devices, &(&1.status == :unknown)))
end
defp apply_filters(socket) do
organization = socket.assigns.current_scope.organization
devices = Devices.list_organization_devices(organization.id)
filtered = filter_devices(devices, socket.assigns.search_query, socket.assigns.status_filter)
socket
|> stream(:device_rows, build_device_rows(filtered), reset: true)
|> assign(:has_devices, devices != [])
end
defp filter_devices(devices, query, status_filter) do
devices
|> filter_by_search(query)
|> filter_by_status(status_filter)
end
defp filter_by_search(devices, ""), do: devices
defp filter_by_search(devices, nil), do: devices
defp filter_by_search(devices, query) do
q = String.downcase(query)
Enum.filter(devices, fn device ->
String.contains?(String.downcase(device.name || ""), q) ||
String.contains?(String.downcase(to_string(device.ip_address)), q)
end)
end
defp filter_by_status(devices, "all"), do: devices
defp filter_by_status(devices, "up"), do: Enum.filter(devices, &(&1.status == :up))
defp filter_by_status(devices, "down"), do: Enum.filter(devices, &(&1.status == :down))
defp filter_by_status(devices, "unknown"), do: Enum.filter(devices, &(&1.status == :unknown))
defp filter_by_status(devices, _), do: devices
defp enqueue_discovery(device_id) do
if Application.get_env(:towerops, :env) == :test do
_ = Task.start(fn -> Snmp.discover_device(Devices.get_device!(device_id)) end)
else
DiscoveryWorker.enqueue(device_id)
end
end
defp build_device_rows(devices) do
device_ids = Enum.map(devices, & &1.id)
health_data = Monitoring.get_device_health_summary(device_ids)
response_data = Monitoring.get_device_latest_response_times(device_ids)
subscriber_data = Gaiia.get_device_subscriber_counts(device_ids)
devices
|> group_devices_by_site()
|> Enum.flat_map(fn {site, site_devices, stats} ->
site_id = if site, do: site.id, else: "no-site"
site_row = %{
id: "site-header-#{site_id}",
type: :site_header,
site: site,
stats: stats,
devices: site_devices
}
device_rows =
Enum.with_index(site_devices, fn device, idx ->
%{
id: "device-#{device.id}",
type: :device,
device: device,
site: site,
device_index: idx,
health: Map.get(health_data, device.id),
response: Map.get(response_data, device.id),
subscribers: Map.get(subscriber_data, device.id)
}
end)
[site_row | device_rows]
end)
end
defp group_devices_by_site(devices) do
devices
|> Enum.group_by(& &1.site)
|> Enum.map(fn {site, site_devices} ->
{site, site_devices, calculate_site_stats(site_devices)}
end)
|> Enum.sort_by(
fn {site, _devices, _stats} ->
if site do
{site.display_order || 999_999, site.name}
else
{0, ""}
end
end,
:asc
)
end
defp calculate_site_stats(devices) do
status_counts = Enum.frequencies_by(devices, & &1.status)
%{
total: length(devices),
up: Map.get(status_counts, :up, 0),
down: Map.get(status_counts, :down, 0),
unknown: Map.get(status_counts, :unknown, 0)
}
end
defp device_type_icon(:router), do: "hero-signal"
defp device_type_icon(:switch), do: "hero-squares-2x2"
defp device_type_icon(:wireless), do: "hero-wifi"
defp device_type_icon(:server), do: "hero-server"
defp device_type_icon(:workstation), do: "hero-computer-desktop"
defp device_type_icon(_), do: "hero-question-mark-circle"
defp device_type_label(type) do
type |> to_string() |> String.capitalize()
end
defp load_site_subscribers(devices) do
devices
|> Enum.map(& &1.site_id)
|> Enum.uniq()
|> Enum.reject(&is_nil/1)
|> Map.new(fn site_id -> {site_id, Gaiia.get_site_subscriber_summary(site_id)} end)
end
defp device_row_title(device) do
{last_seen, _color} = last_seen_text(device.last_checked_at)
snmp = if device.snmp_enabled, do: "SNMP: #{device.snmp_version || "v2c"}", else: "SNMP: off"
type_label = (device.device_role || "unknown") |> to_string() |> String.capitalize()
"#{type_label} | Last Poll: #{last_seen} | #{snmp}"
end
# Health indicator helpers
defp health_dot_color(nil), do: "bg-gray-400"
defp health_dot_color(%{worst_status: 0}), do: "bg-green-500"
defp health_dot_color(%{worst_status: 1}), do: "bg-yellow-500"
defp health_dot_color(%{worst_status: 2}), do: "bg-red-500"
defp health_dot_color(%{worst_status: 3}), do: "bg-gray-400"
defp health_dot_color(_), do: "bg-gray-400"
defp health_dot_title(nil), do: "No check data"
defp health_dot_title(%{worst_status: 0}), do: "All checks passing"
defp health_dot_title(%{worst_status: 1}), do: "Warning"
defp health_dot_title(%{worst_status: 2}), do: "Critical"
defp health_dot_title(%{worst_status: 3}), do: "Unknown"
defp health_dot_title(_), do: "No check data"
defp last_seen_text(nil), do: {"Never", "text-gray-400 dark:text-gray-600"}
defp last_seen_text(datetime) do
diff_seconds = DateTime.diff(DateTime.utc_now(), datetime, :second)
cond do
diff_seconds < 0 -> {"just now", "text-green-600 dark:text-green-400"}
diff_seconds < 60 -> {"#{diff_seconds}s ago", "text-green-600 dark:text-green-400"}
diff_seconds < 600 -> {"#{div(diff_seconds, 60)}m ago", "text-green-600 dark:text-green-400"}
diff_seconds < 3600 -> {"#{div(diff_seconds, 60)}m ago", "text-yellow-600 dark:text-yellow-400"}
diff_seconds < 86_400 -> {"#{div(diff_seconds, 3600)}h ago", "text-red-600 dark:text-red-400"}
true -> {"#{div(diff_seconds, 86_400)}d ago", "text-red-600 dark:text-red-400"}
end
end
defp response_time_badge(nil), do: {"-", "text-gray-400"}
defp response_time_badge(%{response_time_ms: nil}), do: {"-", "text-gray-400"}
defp response_time_badge(%{response_time_ms: ms}) do
formatted =
cond do
ms < 1 -> "<1ms"
ms < 1000 -> "#{round(ms)}ms"
true -> "#{Float.round(ms / 1000, 1)}s"
end
color =
cond do
ms < 20 -> "text-green-600 dark:text-green-400"
ms <= 100 -> "text-yellow-600 dark:text-yellow-400"
true -> "text-red-600 dark:text-red-400"
end
{formatted, color}
end
defp response_time_bar_width(nil), do: 0
defp response_time_bar_width(%{response_time_ms: nil}), do: 0
defp response_time_bar_width(%{response_time_ms: ms}) do
# Scale: 0-200ms maps to 0-100% width, capped at 100
min(100, round(ms / 2))
end
defp response_time_bar_color(nil), do: "bg-gray-300"
defp response_time_bar_color(%{response_time_ms: nil}), do: "bg-gray-300"
defp response_time_bar_color(%{response_time_ms: ms}) do
cond do
ms < 20 -> "bg-green-500"
ms <= 100 -> "bg-yellow-500"
true -> "bg-red-500"
end
end
defp format_subscriber_count(nil), do: nil
defp format_subscriber_count(%{subscriber_count: count}) when count > 0 do
"#{count} subs"
end
defp format_subscriber_count(_), do: nil
# Generate pagination range with ellipsis for large page counts
# Shows: [1] ... [4] [5] [6] ... [20] when on page 5 of 20
defp pagination_range(current_page, total_pages) do
cond do
total_pages <= 7 ->
# Show all pages if 7 or fewer
Enum.to_list(1..total_pages)
current_page <= 4 ->
# Near the start
[1, 2, 3, 4, 5, :ellipsis, total_pages]
current_page >= total_pages - 3 ->
# Near the end
[1, :ellipsis, total_pages - 4, total_pages - 3, total_pages - 2, total_pages - 1, total_pages]
true ->
# In the middle
[1, :ellipsis, current_page - 1, current_page, current_page + 1, :ellipsis, total_pages]
end
end
defp safe_to_integer(value, _default) when is_integer(value), do: value
defp safe_to_integer(value, default) when is_binary(value) do
case Integer.parse(value) do
{int, _} -> int
:error -> default
end
end
defp safe_to_integer(_, default), do: default
end