- 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
491 lines
17 KiB
Elixir
491 lines
17 KiB
Elixir
defmodule ToweropsWeb.AgentLive.Index do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
use Gettext, backend: ToweropsWeb.Gettext
|
|
|
|
import ToweropsWeb.AgentLive.Helpers
|
|
import ToweropsWeb.GettextHelpers
|
|
|
|
alias Towerops.Accounts.Scope
|
|
alias Towerops.Agents
|
|
alias Towerops.Agents.Stats
|
|
alias Towerops.Settings
|
|
|
|
@impl true
|
|
def mount(_params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
current_scope = socket.assigns.current_scope
|
|
is_superuser = Scope.superuser?(current_scope)
|
|
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
|
|
|
|
# Subscribe to agent health updates for real-time status changes
|
|
timer_ref =
|
|
if connected?(socket) do
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agents:health")
|
|
{:ok, ref} = :timer.send_interval(1000, :tick)
|
|
ref
|
|
end
|
|
|
|
# If superadmin (including when impersonating), also load cloud pollers and global default
|
|
{cloud_pollers, global_default_cloud_poller_id} =
|
|
if is_superuser do
|
|
{Agents.list_cloud_pollers(), Settings.get_global_default_cloud_poller()}
|
|
else
|
|
{[], nil}
|
|
end
|
|
|
|
# device counts for each agent (both direct and total with inheritance)
|
|
equipment_counts = calculate_device_counts(agent_tokens)
|
|
|
|
# device counts for cloud pollers (if superuser)
|
|
cloud_poller_counts =
|
|
if is_superuser do
|
|
calculate_device_counts(cloud_pollers)
|
|
else
|
|
%{}
|
|
end
|
|
|
|
# Get organization-wide agent 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)
|
|
|
|
# Get agent image URL from config or use default
|
|
agent_image = Application.get_env(:towerops, :agent_docker_image, "towerops/agent:latest")
|
|
|
|
{:ok,
|
|
socket
|
|
|> assign(:page_title, t("Remote Agents"))
|
|
|> assign(:timezone, socket.assigns.current_scope.timezone)
|
|
|> assign(:is_superuser, is_superuser)
|
|
|> stream(:agent_tokens, agent_tokens)
|
|
|> assign(:has_agents, agent_tokens != [])
|
|
|> stream(:cloud_pollers, cloud_pollers)
|
|
|> 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)
|
|
|> assign(:agent_image, agent_image)
|
|
|> assign(:new_token, nil)
|
|
|> assign(:show_token_modal, false)
|
|
|> assign(:now, DateTime.utc_now())
|
|
|> assign(:timer_ref, timer_ref)
|
|
|> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
# Read modal state from URL
|
|
modal = params["modal"]
|
|
|
|
socket =
|
|
socket
|
|
|> assign(:show_token_modal, modal == "setup")
|
|
|> apply_action(socket.assigns.live_action, params)
|
|
|
|
{:noreply, socket}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("create_agent", params, socket) do
|
|
{name, is_cloud_poller} = parse_agent_params(params)
|
|
|
|
with :ok <- validate_cloud_poller_permission(socket.assigns.current_scope, is_cloud_poller),
|
|
{:ok, agent_token, token} <- create_agent(socket.assigns.current_scope.organization, name, is_cloud_poller) do
|
|
handle_agent_creation_success(socket, agent_token, token, is_cloud_poller)
|
|
else
|
|
{:error, :unauthorized} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Only superadmins can create cloud pollers"))}
|
|
|
|
{:error, _changeset} ->
|
|
error_message =
|
|
if is_cloud_poller,
|
|
do: t_equipment("Failed to create cloud poller"),
|
|
else: t_equipment("Failed to create agent")
|
|
|
|
{:noreply, put_flash(socket, :error, error_message)}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("close_token_modal", _params, socket) do
|
|
{:noreply,
|
|
socket
|
|
|> assign(:new_token, nil)
|
|
|> push_patch(to: ~p"/agents")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("show_setup", %{"id" => id}, socket) do
|
|
agent_token = Agents.get_agent_token!(id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:new_token, %{agent_token: agent_token, token: agent_token.token})
|
|
|> push_patch(to: ~p"/agents?modal=setup")}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("regenerate_token", %{"id" => id}, socket) do
|
|
agent_token = Agents.get_agent_token!(id)
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
# Revoke the old token and create a new one
|
|
case Agents.revoke_agent_token(id) do
|
|
{:ok, _} ->
|
|
case Agents.create_agent_token(organization.id, agent_token.name) do
|
|
{:ok, new_agent_token, token} ->
|
|
agent_tokens = Agents.list_organization_agent_tokens(organization.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> stream(:agent_tokens, agent_tokens, reset: true)
|
|
|> assign(:has_agents, agent_tokens != [])
|
|
|> assign(:new_token, %{agent_token: new_agent_token, token: token})
|
|
|> assign(:show_token_modal, true)
|
|
|> put_flash(:info, t_equipment("New token generated successfully"))}
|
|
|
|
{:error, _changeset} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to generate new token"))}
|
|
end
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to revoke old token"))}
|
|
end
|
|
end
|
|
|
|
@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)
|
|
|
|
if agent_token.organization_id != organization.id do
|
|
{:noreply, put_flash(socket, :error, t_equipment("Agent not found"))}
|
|
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
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("update_selected_global_default", %{"agent_token_id" => agent_token_id}, socket) do
|
|
# Just update the selected value in the dropdown, don't save yet
|
|
{:noreply, assign(socket, :selected_global_default, agent_token_id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save_global_default", _params, socket) do
|
|
current_scope = socket.assigns.current_scope
|
|
|
|
if Scope.superuser?(current_scope) do
|
|
agent_token_id = socket.assigns.selected_global_default
|
|
# Handle empty string as nil
|
|
agent_token_id = if agent_token_id == "", do: nil, else: agent_token_id
|
|
|
|
# Validate that the agent exists if not nil
|
|
case validate_and_save_global_default(agent_token_id) do
|
|
{:ok, validated_id} ->
|
|
handle_global_default_success(socket, validated_id)
|
|
|
|
{:error, :agent_not_found} ->
|
|
{:noreply,
|
|
put_flash(socket, :error, t_equipment("Selected agent no longer exists. Please choose another agent."))}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Failed to update global default cloud poller"))}
|
|
end
|
|
else
|
|
{:noreply, put_flash(socket, :error, t_equipment("Only superadmins can set the global default cloud poller"))}
|
|
end
|
|
end
|
|
|
|
defp apply_action(socket, :index, _params) do
|
|
socket
|
|
end
|
|
|
|
defp parse_agent_params(params) do
|
|
case params do
|
|
%{"agent_form" => %{"name" => n, "is_cloud_poller" => cp}} ->
|
|
{n, cp in ["true", "on", true]}
|
|
|
|
%{"agent_form" => %{"name" => n}} ->
|
|
{n, false}
|
|
|
|
%{"name" => n, "is_cloud_poller" => cp} ->
|
|
{n, cp in ["true", "on", true]}
|
|
|
|
%{"name" => n} ->
|
|
{n, false}
|
|
end
|
|
end
|
|
|
|
defp validate_cloud_poller_permission(scope, is_cloud_poller) do
|
|
if is_cloud_poller && !Scope.superuser?(scope) do
|
|
{:error, :unauthorized}
|
|
else
|
|
:ok
|
|
end
|
|
end
|
|
|
|
defp create_agent(organization, name, is_cloud_poller) do
|
|
if is_cloud_poller do
|
|
Agents.create_cloud_poller(name)
|
|
else
|
|
Agents.create_agent_token(organization.id, name)
|
|
end
|
|
end
|
|
|
|
defp handle_agent_creation_success(socket, agent_token, token, is_cloud_poller) do
|
|
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)
|
|
|
|
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)
|
|
|
|
success_message = get_success_message(is_cloud_poller)
|
|
|
|
{: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(: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)
|
|
|> assign(:new_token, %{agent_token: agent_token, token: token})
|
|
|> assign(:agent_form, to_form(%{"name" => "", "is_cloud_poller" => false}))
|
|
|> put_flash(:info, success_message)
|
|
|> push_patch(to: ~p"/agents?modal=setup")}
|
|
end
|
|
|
|
defp load_cloud_pollers_if_superuser(scope) do
|
|
if Scope.superuser?(scope), do: Agents.list_cloud_pollers(), else: []
|
|
end
|
|
|
|
defp calculate_device_counts(agent_tokens) do
|
|
Map.new(agent_tokens, fn t ->
|
|
direct = Agents.count_assigned_devices(t.id)
|
|
total = length(Agents.list_agent_polling_targets(t.id))
|
|
{t.id, %{direct: direct, total: total}}
|
|
end)
|
|
end
|
|
|
|
defp get_success_message(is_cloud_poller) do
|
|
if is_cloud_poller,
|
|
do: t_equipment("Cloud poller created successfully"),
|
|
else: t_equipment("Agent created successfully")
|
|
end
|
|
|
|
defp validate_and_save_global_default(nil) do
|
|
# Clearing the default is always valid
|
|
case Settings.set_global_default_cloud_poller(nil) do
|
|
{:ok, _} -> {:ok, nil}
|
|
error -> error
|
|
end
|
|
end
|
|
|
|
defp validate_and_save_global_default(agent_token_id) do
|
|
# Verify the agent exists before saving
|
|
%Agents.AgentToken{} = Agents.get_agent_token!(agent_token_id)
|
|
|
|
case Settings.set_global_default_cloud_poller(agent_token_id) do
|
|
{:ok, _} -> {:ok, agent_token_id}
|
|
error -> error
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
{:error, :agent_not_found}
|
|
end
|
|
|
|
defp handle_global_default_success(socket, agent_token_id) do
|
|
message =
|
|
if agent_token_id,
|
|
do: t_equipment("Global default cloud poller set successfully"),
|
|
else: t_equipment("Global default cloud poller cleared")
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:global_default_cloud_poller_id, agent_token_id)
|
|
|> assign(:selected_global_default, agent_token_id || "")
|
|
|> put_flash(:info, message)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info(:tick, socket) do
|
|
{:noreply, assign(socket, :now, DateTime.utc_now())}
|
|
end
|
|
|
|
# Handle real-time agent status updates
|
|
# Only process events for this organization's agents, or cloud pollers if superuser
|
|
@impl true
|
|
def handle_info({:agent_connected, agent_token_id, organization_id}, socket) do
|
|
{:noreply, maybe_update_agent(socket, agent_token_id, organization_id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_disconnected, agent_token_id, organization_id}, socket) do
|
|
{:noreply, maybe_update_agent(socket, agent_token_id, organization_id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agent_heartbeat, agent_token_id, organization_id}, socket) do
|
|
{:noreply, maybe_update_agent(socket, agent_token_id, organization_id)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:agents_stale, _stale_agents}, socket) do
|
|
{:noreply, reload_agent_data(socket)}
|
|
end
|
|
|
|
defp reload_agent_data(socket) do
|
|
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)
|
|
|
|
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)
|
|
|
|
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(: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)
|
|
end
|
|
|
|
defp maybe_update_agent(socket, agent_token_id, organization_id) do
|
|
my_org_id = socket.assigns.current_scope.organization.id
|
|
|
|
agent_token = Agents.get_agent_token!(agent_token_id)
|
|
|
|
cond do
|
|
agent_token.is_cloud_poller ->
|
|
# Cloud pollers only visible to superusers
|
|
if socket.assigns.is_superuser do
|
|
socket
|
|
|> stream_insert(:cloud_pollers, agent_token)
|
|
|> update_health_stats()
|
|
else
|
|
socket
|
|
end
|
|
|
|
organization_id == my_org_id ->
|
|
# Regular agent belonging to this organization
|
|
socket
|
|
|> stream_insert(:agent_tokens, agent_token)
|
|
|> update_health_stats()
|
|
|
|
true ->
|
|
# Agent from another organization, ignore
|
|
socket
|
|
end
|
|
rescue
|
|
Ecto.NoResultsError ->
|
|
# Agent was deleted, just refresh stats
|
|
update_health_stats(socket)
|
|
end
|
|
|
|
defp update_health_stats(socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
agent_health_stats = Stats.get_organization_agent_health(organization.id)
|
|
offline_agents = Stats.get_offline_agents(organization.id)
|
|
|
|
socket
|
|
|> assign(:agent_health_stats, agent_health_stats)
|
|
|> assign(:offline_agents, offline_agents)
|
|
end
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
if timer_ref = socket.assigns[:timer_ref] do
|
|
:timer.cancel(timer_ref)
|
|
end
|
|
|
|
:ok
|
|
end
|
|
end
|