Promoted pure presentation and utility helpers from `defp` to `def @doc false` across ~20 LiveViews, Oban workers, and sync modules so they're reachable from unit tests. Refactored several `cond` blocks into idiomatic function heads with guards. Added ~250 new test cases in new files under test/towerops and test/towerops_web, including DB-backed tests for CnMaestro.Sync and AlertNotificationWorker, and removed dead LiveView tab components and CapacityLive (no callers anywhere in lib/test). Configured mix.exs test_coverage.ignore_modules to exclude vendored third-party code (SnmpKit, protobuf-generated Towerops.Agent.*, Absinthe GraphQL types, Phoenix HTML modules, Inspect protocol impls) from coverage calculations — these are not our project code. Coverage: 66.93% → 70.09%. Full suite: 10,127 tests, 0 failures.
861 lines
28 KiB
Elixir
861 lines
28 KiB
Elixir
defmodule ToweropsWeb.DeviceLive.Form do
|
|
@moduledoc false
|
|
use ToweropsWeb, :live_view
|
|
|
|
alias Towerops.Admin.AuditLogger
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
alias Towerops.Devices.Device, as: DeviceSchema
|
|
alias Towerops.OnCall
|
|
alias Towerops.Organizations.SubscriptionLimits
|
|
alias Towerops.Sites
|
|
alias Towerops.Workers.DiscoveryWorker
|
|
alias ToweropsWeb.Live.Helpers.AccessControl
|
|
|
|
@impl true
|
|
def mount(params, _session, socket) do
|
|
organization = socket.assigns.current_scope.organization
|
|
sites = Sites.list_organization_sites(organization.id)
|
|
agents = Agents.list_organization_agent_tokens(organization.id)
|
|
|
|
# Include cloud pollers in the available agents list
|
|
cloud_pollers = Agents.list_cloud_pollers()
|
|
all_agents = agents ++ cloud_pollers
|
|
|
|
# Load escalation policies for this organization
|
|
escalation_policies = OnCall.list_escalation_policies(organization.id)
|
|
|
|
# Load device quota
|
|
{current_devices, device_limit} =
|
|
SubscriptionLimits.device_quota(organization)
|
|
|
|
is_at_limit =
|
|
case device_limit do
|
|
:unlimited -> false
|
|
limit -> current_devices >= limit
|
|
end
|
|
|
|
# Sites are now optional - devices can belong directly to organization
|
|
{:ok,
|
|
socket
|
|
|> assign(:organization, organization)
|
|
|> assign(:available_sites, sites)
|
|
|> assign(:available_agents, all_agents)
|
|
|> assign(:escalation_policies, escalation_policies)
|
|
|> assign(:preselected_site_id, params["site_id"])
|
|
|> assign(:snmp_test_result, nil)
|
|
|> assign(:duplicate_device, nil)
|
|
|> assign(:non_routable_ip_error, false)
|
|
|> assign(:device_quota, %{
|
|
current: current_devices,
|
|
limit: device_limit,
|
|
at_limit: is_at_limit
|
|
})}
|
|
end
|
|
|
|
@impl true
|
|
def handle_params(params, _url, socket) do
|
|
{:noreply, apply_action(socket, socket.assigns.live_action, params)}
|
|
end
|
|
|
|
defp apply_action(socket, :new, params) do
|
|
equipment_attrs = %{
|
|
monitoring_enabled: true,
|
|
check_interval_seconds: 300,
|
|
snmp_enabled: true,
|
|
snmp_version: "2c",
|
|
snmp_port: 161
|
|
}
|
|
|
|
# Pre-fill from query params if available (e.g., from discovered devices)
|
|
equipment_attrs =
|
|
equipment_attrs
|
|
|> merge_param(:name, params["name"])
|
|
|> merge_param(:ip_address, params["ip_address"])
|
|
|> merge_bool_param(:snmp_enabled, params["snmp_enabled"])
|
|
|
|
equipment_attrs =
|
|
maybe_preselect_site(
|
|
equipment_attrs,
|
|
socket.assigns.preselected_site_id,
|
|
socket.assigns.available_sites
|
|
)
|
|
|
|
changeset = Devices.change_device(%DeviceSchema{}, equipment_attrs)
|
|
|
|
socket
|
|
|> assign(:page_title, t("New Device"))
|
|
|> assign(:device, %DeviceSchema{})
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign(:monitoring_mode, "snmp_and_icmp")
|
|
end
|
|
|
|
defp apply_action(socket, :edit, %{"id" => id}) do
|
|
organization = socket.assigns.current_scope.organization
|
|
|
|
case AccessControl.verify_device_access(id, organization.id) do
|
|
{:ok, device} ->
|
|
# Preload necessary associations for agent resolution
|
|
device = Devices.get_device_with_agent_info(device)
|
|
apply_edit_action(socket, device)
|
|
|
|
{:error, :not_found} ->
|
|
socket
|
|
|> put_flash(:error, t_equipment("Device not found"))
|
|
|> push_navigate(to: ~p"/devices")
|
|
|
|
{:error, :unauthorized} ->
|
|
socket
|
|
|> put_flash(:error, t_equipment("You don't have access to this device"))
|
|
|> push_navigate(to: ~p"/devices")
|
|
end
|
|
end
|
|
|
|
defp apply_edit_action(socket, device) do
|
|
# Get current agent assignment if any
|
|
current_assignment = Agents.get_device_assignment(device.id)
|
|
|
|
# Get the effective agent and where it comes from
|
|
{effective_agent_id, agent_source} = Agents.get_effective_agent_token_with_source(device)
|
|
|
|
# For the form, we only want the device-specific assignment
|
|
agent_token_id =
|
|
if current_assignment do
|
|
current_assignment.agent_token_id
|
|
end
|
|
|
|
# Get agent name for display
|
|
effective_agent_name =
|
|
if effective_agent_id do
|
|
agent = Enum.find(socket.assigns.available_agents, &(&1.id == effective_agent_id))
|
|
agent && agent.name
|
|
end
|
|
|
|
# Add agent_token_id to the changeset data
|
|
device_with_agent = Map.put(device, :agent_token_id, agent_token_id)
|
|
|
|
# Create changeset and validate to populate form.params for conditional rendering
|
|
changeset =
|
|
device_with_agent
|
|
|> Devices.change_device(%{})
|
|
|> Map.put(:action, :validate)
|
|
|
|
# Get effective SNMP configuration and source
|
|
snmp_config = Devices.get_snmp_config(device)
|
|
|
|
# Get effective MikroTik configuration and source
|
|
mikrotik_config = Devices.get_mikrotik_config(device)
|
|
|
|
# Check if device is MikroTik (based on SNMP discovery)
|
|
is_mikrotik = Devices.mikrotik_device?(device)
|
|
|
|
# Determine monitoring mode based on current snmp_enabled value
|
|
monitoring_mode = if device.snmp_enabled, do: "snmp_and_icmp", else: "icmp_only"
|
|
|
|
socket
|
|
|> assign(:page_title, t("Edit Device"))
|
|
|> assign(:device, device_with_agent)
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign(:agent_source, agent_source)
|
|
|> assign(:effective_agent_name, effective_agent_name)
|
|
|> assign(:snmp_config_source, snmp_config.source)
|
|
|> assign(:effective_snmp_version, snmp_config.version)
|
|
|> assign(:effective_snmp_community, snmp_config.community || "(not set)")
|
|
|> assign(:mikrotik_config_source, mikrotik_config.source)
|
|
|> assign(:effective_mikrotik_username, mikrotik_config.username || "(not set)")
|
|
|> assign(:is_mikrotik_device, is_mikrotik)
|
|
|> assign(:mikrotik_test_result, nil)
|
|
|> assign(:monitoring_mode, monitoring_mode)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("switch_monitoring_mode", %{"mode" => mode}, socket) do
|
|
# Update snmp_enabled based on the selected mode
|
|
snmp_enabled = mode == "snmp_and_icmp"
|
|
|
|
# Get current form params
|
|
current_params = socket.assigns.form.params
|
|
|
|
# Update params with new snmp_enabled value
|
|
updated_params = Map.put(current_params, "snmp_enabled", snmp_enabled)
|
|
|
|
# Create new changeset with updated params
|
|
changeset =
|
|
socket.assigns.device
|
|
|> Devices.change_device(updated_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
socket
|
|
|> assign(:monitoring_mode, mode)
|
|
|> assign(:form, to_form(changeset))
|
|
|> then(&{:noreply, &1})
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("validate", %{"device" => device_params}, socket) do
|
|
require Logger
|
|
|
|
device_params = sanitize_device_params(device_params)
|
|
|
|
changeset =
|
|
socket.assigns.device
|
|
|> Devices.change_device(device_params)
|
|
|> Map.put(:action, :validate)
|
|
|
|
# Check for duplicate IP address within the same site
|
|
ip_address = device_params["ip_address"]
|
|
site_id = if device_params["site_id"] == "", do: nil, else: device_params["site_id"]
|
|
exclude_id = if socket.assigns.live_action == :edit, do: socket.assigns.device.id
|
|
|
|
duplicate_device =
|
|
if ip_address && String.trim(ip_address) != "" && site_id do
|
|
Devices.get_device_by_ip(String.trim(ip_address), site_id, exclude_id)
|
|
end
|
|
|
|
# Check for non-routable IP with cloud poller
|
|
non_routable_ip_error = check_non_routable_ip_cloud_error(device_params, socket.assigns)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:form, to_form(changeset))
|
|
|> assign(:duplicate_device, duplicate_device)
|
|
|> assign(:non_routable_ip_error, non_routable_ip_error)}
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("save", %{"device" => device_params}, socket) do
|
|
device_params = sanitize_device_params(device_params)
|
|
|
|
# Inject organization_id from current context
|
|
organization_id = socket.assigns.organization.id
|
|
device_params = Map.put(device_params, "organization_id", organization_id)
|
|
|
|
save_device(socket, socket.assigns.live_action, device_params)
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("delete", _params, socket) do
|
|
device = socket.assigns.device
|
|
|
|
case Devices.delete_device(device) do
|
|
{:ok, _} ->
|
|
# Log device deletion
|
|
user = socket.assigns.current_scope.user
|
|
AuditLogger.log_device_deleted(nil, user.id, device.id, device.name)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Device deleted successfully"))
|
|
|> push_navigate(to: ~p"/devices")}
|
|
|
|
{:error, _} ->
|
|
{:noreply, put_flash(socket, :error, t_equipment("Unable to delete device"))}
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("test_snmp", _params, socket) do
|
|
require Logger
|
|
|
|
changeset = socket.assigns.form.source
|
|
form_data = Ecto.Changeset.apply_changes(changeset)
|
|
|
|
# Validate IP address is present
|
|
if is_nil(form_data.ip_address) || to_string(form_data.ip_address) == "" do
|
|
{:noreply,
|
|
assign(socket, :snmp_test_result, %{
|
|
success: false,
|
|
message: "IP address is required to test SNMP connection"
|
|
})}
|
|
else
|
|
# Resolve credentials with inheritance from site/org
|
|
resolved_credentials = resolve_snmp_credentials_for_test(form_data, changeset, socket.assigns)
|
|
|
|
device_map = build_device_map_with_credentials(form_data, resolved_credentials)
|
|
effective_agent_id = determine_effective_agent_id(device_map, socket.assigns)
|
|
|
|
# Debug logging for SNMPv3 credential testing
|
|
Logger.debug(
|
|
"SNMPv3 test credentials: version=#{device_map["snmp_version"]}, " <>
|
|
"security_level=#{inspect(device_map["snmpv3_security_level"])}, " <>
|
|
"username=#{inspect(device_map["snmpv3_username"])}, " <>
|
|
"auth_protocol=#{inspect(device_map["snmpv3_auth_protocol"])}, " <>
|
|
"auth_password_present=#{!is_nil(device_map["snmpv3_auth_password"])}, " <>
|
|
"priv_protocol=#{inspect(device_map["snmpv3_priv_protocol"])}, " <>
|
|
"priv_password_present=#{!is_nil(device_map["snmpv3_priv_password"])}"
|
|
)
|
|
|
|
handle_snmp_test(socket, device_map, effective_agent_id)
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_event("trigger_discovery", _params, socket) do
|
|
device = socket.assigns.device
|
|
|
|
if device.snmp_enabled do
|
|
_ = enqueue_discovery(device.id)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, t_equipment("Discovery started..."))
|
|
|> push_navigate(to: ~p"/devices/#{device.id}")}
|
|
else
|
|
{:noreply, put_flash(socket, :error, t_equipment("SNMP is not enabled for this device"))}
|
|
end
|
|
end
|
|
|
|
# Resolve all SNMP credentials with inheritance from site/org
|
|
defp resolve_snmp_credentials_for_test(form_data, changeset, assigns) do
|
|
snmp_version = form_data.snmp_version || "2c"
|
|
|
|
if snmp_version == "3" do
|
|
resolve_snmpv3_credentials_for_test(form_data, changeset, assigns)
|
|
else
|
|
resolve_snmp_community_for_test(form_data, changeset, assigns)
|
|
end
|
|
end
|
|
|
|
# Resolve SNMPv3 credentials with site/org inheritance
|
|
defp resolve_snmpv3_credentials_for_test(form_data, changeset, assigns) do
|
|
cond do
|
|
# Use device-level credentials if available
|
|
form_data.snmpv3_username && form_data.snmpv3_username != "" ->
|
|
build_device_snmpv3_creds(form_data, changeset.changes)
|
|
|
|
# Inherit from site if available
|
|
site_has_snmpv3_creds?(form_data, assigns) ->
|
|
site = find_site(form_data.site_id, assigns.available_sites)
|
|
build_snmpv3_creds(site)
|
|
|
|
# Fall back to organization credentials
|
|
assigns.organization.snmpv3_username ->
|
|
build_snmpv3_creds(assigns.organization)
|
|
|
|
# No credentials available
|
|
true ->
|
|
%{version: "3"}
|
|
end
|
|
end
|
|
|
|
defp site_has_snmpv3_creds?(form_data, assigns) do
|
|
site = find_site(form_data.site_id, assigns.available_sites)
|
|
site && site.snmpv3_username
|
|
end
|
|
|
|
defp find_site(nil, _sites), do: nil
|
|
defp find_site(site_id, sites), do: Enum.find(sites, &(&1.id == site_id))
|
|
|
|
defp build_device_snmpv3_creds(form_data, changes) do
|
|
%{
|
|
version: "3",
|
|
security_level: form_data.snmpv3_security_level,
|
|
username: form_data.snmpv3_username,
|
|
auth_protocol: form_data.snmpv3_auth_protocol,
|
|
auth_password: Map.get(changes, :snmpv3_auth_password) || form_data.snmpv3_auth_password,
|
|
priv_protocol: form_data.snmpv3_priv_protocol,
|
|
priv_password: Map.get(changes, :snmpv3_priv_password) || form_data.snmpv3_priv_password
|
|
}
|
|
end
|
|
|
|
defp build_snmpv3_creds(source) do
|
|
%{
|
|
version: "3",
|
|
security_level: source.snmpv3_security_level,
|
|
username: source.snmpv3_username,
|
|
auth_protocol: source.snmpv3_auth_protocol,
|
|
auth_password: source.snmpv3_auth_password,
|
|
priv_protocol: source.snmpv3_priv_protocol,
|
|
priv_password: source.snmpv3_priv_password
|
|
}
|
|
end
|
|
|
|
# Resolve SNMP community with site/org inheritance
|
|
defp resolve_snmp_community_for_test(form_data, changeset, assigns) do
|
|
version = form_data.snmp_version || "2c"
|
|
community = get_inherited_community(form_data, changeset, assigns)
|
|
|
|
if community do
|
|
%{version: version, community: community}
|
|
else
|
|
%{version: version}
|
|
end
|
|
end
|
|
|
|
defp get_inherited_community(form_data, changeset, assigns) do
|
|
changes = changeset.changes
|
|
|
|
# Check if community is explicitly set in form
|
|
if Map.has_key?(changes, :snmp_community) && form_data.snmp_community do
|
|
form_data.snmp_community
|
|
else
|
|
# Try site-level, then org-level community
|
|
site = form_data.site_id && Enum.find(assigns.available_sites, &(&1.id == form_data.site_id))
|
|
|
|
if site && site.snmp_community do
|
|
site.snmp_community
|
|
else
|
|
assigns.organization.snmp_community
|
|
end
|
|
end
|
|
end
|
|
|
|
defp build_device_map_with_credentials(form_data, credentials) do
|
|
base_map = %{
|
|
"agent_token_id" => Map.get(form_data, :agent_token_id),
|
|
"site_id" => form_data.site_id,
|
|
"ip_address" => form_data.ip_address,
|
|
"snmp_port" => form_data.snmp_port || 161,
|
|
"snmp_version" => credentials.version
|
|
}
|
|
|
|
if credentials.version == "3" do
|
|
Map.merge(base_map, %{
|
|
"snmpv3_security_level" => credentials[:security_level],
|
|
"snmpv3_username" => credentials[:username],
|
|
"snmpv3_auth_protocol" => credentials[:auth_protocol],
|
|
"snmpv3_auth_password" => credentials[:auth_password],
|
|
"snmpv3_priv_protocol" => credentials[:priv_protocol],
|
|
"snmpv3_priv_password" => credentials[:priv_password]
|
|
})
|
|
else
|
|
Map.put(base_map, "snmp_community", credentials[:community])
|
|
end
|
|
end
|
|
|
|
defp handle_snmp_test(socket, _device_map, nil) do
|
|
{:noreply,
|
|
assign(socket, :snmp_test_result, %{
|
|
success: false,
|
|
message: "No agent available for testing. Please assign an agent or configure organization defaults."
|
|
})}
|
|
end
|
|
|
|
defp handle_snmp_test(socket, device_map, effective_agent_id) do
|
|
test_id = Ecto.UUID.generate()
|
|
|
|
case send_credential_test_to_agent(effective_agent_id, device_map, test_id) do
|
|
:ok ->
|
|
_ = Phoenix.PubSub.subscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
|
|
|
{:noreply,
|
|
socket
|
|
|> assign(:snmp_test_result, %{testing: true})
|
|
|> assign(:test_id, test_id)}
|
|
|
|
{:error, reason} ->
|
|
{:noreply,
|
|
assign(socket, :snmp_test_result, %{
|
|
success: false,
|
|
message: "Failed to send test: #{reason}"
|
|
})}
|
|
end
|
|
end
|
|
|
|
defp save_device(socket, :new, device_params) do
|
|
# Extract agent_token_id before creating device
|
|
agent_token_id = Map.get(device_params, "agent_token_id")
|
|
device_params = Map.delete(device_params, "agent_token_id")
|
|
|
|
# Add snmp_enabled based on monitoring mode
|
|
snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp"
|
|
device_params = Map.put(device_params, "snmp_enabled", snmp_enabled)
|
|
|
|
# Check if user is superuser and bypass limits if so
|
|
is_superuser = socket.assigns.current_scope.user.is_superuser
|
|
opts = if is_superuser, do: [bypass_limits: true], else: []
|
|
|
|
case Devices.create_device(device_params, opts) do
|
|
{:ok, device} ->
|
|
# Log device creation
|
|
user = socket.assigns.current_scope.user
|
|
AuditLogger.log_device_created(nil, user.id, device.id, device.name)
|
|
|
|
# Handle agent assignment after device creation
|
|
_ = handle_agent_assignment(device.id, agent_token_id)
|
|
|
|
flash_message = handle_device_creation(device)
|
|
|
|
# Reload quota after device creation
|
|
organization = socket.assigns.organization
|
|
{current, limit} = SubscriptionLimits.device_quota(organization)
|
|
|
|
is_at_limit =
|
|
case limit do
|
|
:unlimited -> false
|
|
l -> current >= l
|
|
end
|
|
|
|
# Reset form for next device while staying on the add page
|
|
fresh_attrs = %{
|
|
monitoring_enabled: true,
|
|
check_interval_seconds: 300,
|
|
snmp_enabled: snmp_enabled,
|
|
snmp_version: "2c",
|
|
snmp_port: 161,
|
|
# Keep the same site selected
|
|
site_id: device.site_id
|
|
}
|
|
|
|
fresh_changeset = Devices.change_device(%DeviceSchema{}, fresh_attrs)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, flash_message)
|
|
|> assign(:form, to_form(fresh_changeset))
|
|
|> assign(:device_quota, %{current: current, limit: limit, at_limit: is_at_limit})
|
|
|> assign(:snmp_test_result, nil)
|
|
|> push_event("scroll_to_top", %{})}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
defp save_device(socket, :edit, device_params) do
|
|
old_device = socket.assigns.device
|
|
|
|
# Extract agent_token_id before updating device
|
|
agent_token_id = Map.get(device_params, "agent_token_id")
|
|
device_params = Map.delete(device_params, "agent_token_id")
|
|
|
|
# Add snmp_enabled based on monitoring mode
|
|
snmp_enabled = socket.assigns.monitoring_mode == "snmp_and_icmp"
|
|
device_params = Map.put(device_params, "snmp_enabled", snmp_enabled)
|
|
|
|
case Devices.update_device(old_device, device_params) do
|
|
{:ok, device} ->
|
|
# Log device update
|
|
user = socket.assigns.current_scope.user
|
|
fields_changed = Map.keys(device_params)
|
|
AuditLogger.log_device_updated(nil, user.id, device.id, fields_changed)
|
|
|
|
# device update
|
|
_ = handle_agent_assignment(device.id, agent_token_id)
|
|
|
|
flash_message = handle_device_update(old_device, device)
|
|
|
|
{:noreply,
|
|
socket
|
|
|> put_flash(:info, flash_message)
|
|
|> push_navigate(to: ~p"/devices/#{device.id}")}
|
|
|
|
{:error, %Ecto.Changeset{} = changeset} ->
|
|
{:noreply, assign(socket, :form, to_form(changeset))}
|
|
end
|
|
end
|
|
|
|
defp handle_device_creation(device) do
|
|
device_label = device.name || device.ip_address
|
|
|
|
base_message =
|
|
if device.snmp_enabled do
|
|
t_equipment("Device created. SNMP discovery started. ")
|
|
else
|
|
t_equipment("Device created. ")
|
|
end
|
|
|
|
_ =
|
|
if device.snmp_enabled do
|
|
enqueue_discovery(device.id)
|
|
end
|
|
|
|
%{
|
|
text: base_message,
|
|
link_text: device_label,
|
|
link_url: "/devices/#{device.id}"
|
|
}
|
|
end
|
|
|
|
defp handle_device_update(old_device, device) do
|
|
if should_trigger_snmp_discovery?(old_device, device) do
|
|
_ = enqueue_discovery(device.id)
|
|
t_equipment("Device updated successfully. SNMP discovery started in background.")
|
|
else
|
|
t_equipment("Device updated successfully")
|
|
end
|
|
end
|
|
|
|
# Enqueue discovery job - safe to call in test environment
|
|
defp enqueue_discovery(device_id) do
|
|
if Application.get_env(:towerops, :env) == :test do
|
|
# In test, skip discovery (tests can call Snmp.discover_device directly if needed)
|
|
:ok
|
|
else
|
|
# In dev/prod, enqueue to Oban
|
|
DiscoveryWorker.enqueue(device_id)
|
|
end
|
|
end
|
|
|
|
defp should_trigger_snmp_discovery?(old_device, device) do
|
|
device.snmp_enabled and
|
|
(!old_device.snmp_enabled or
|
|
snmp_config_changed?(old_device, device))
|
|
end
|
|
|
|
defp snmp_config_changed?(old_device, device) do
|
|
device.snmp_community != old_device.snmp_community or
|
|
device.snmp_version != old_device.snmp_version or
|
|
device.snmp_port != old_device.snmp_port or
|
|
device.ip_address != old_device.ip_address
|
|
end
|
|
|
|
defp handle_agent_assignment(device_id, agent_token_id) when is_binary(agent_token_id) do
|
|
# Only assign if agent_token_id is not empty
|
|
if agent_token_id == "" do
|
|
Agents.update_device_assignment(device_id, nil)
|
|
else
|
|
Agents.update_device_assignment(device_id, agent_token_id)
|
|
end
|
|
end
|
|
|
|
defp handle_agent_assignment(device_id, nil) do
|
|
# No agent selected, remove any assignment
|
|
Agents.update_device_assignment(device_id, nil)
|
|
end
|
|
|
|
defp handle_agent_assignment(_device_id, _), do: :ok
|
|
|
|
# Sanitize device params - trim whitespace from IP address and handle device role source
|
|
@doc false
|
|
def sanitize_device_params(params) do
|
|
params
|
|
|> sanitize_ip_address()
|
|
|> sanitize_device_role()
|
|
end
|
|
|
|
@doc false
|
|
def sanitize_ip_address(params) do
|
|
case Map.get(params, "ip_address") do
|
|
ip when is_binary(ip) ->
|
|
Map.put(params, "ip_address", String.trim(ip))
|
|
|
|
_ ->
|
|
params
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def sanitize_device_role(params) do
|
|
case Map.get(params, "device_role") do
|
|
role when is_binary(role) and role != "" ->
|
|
params
|
|
|
|
_ ->
|
|
params
|
|
end
|
|
end
|
|
|
|
@impl true
|
|
def handle_info({:credential_test_result, result}, socket) do
|
|
require Logger
|
|
|
|
Logger.info(
|
|
"LiveView received credential test result: test_id=#{result.test_id} success=#{result.success} system_description=#{inspect(result.system_description)} error=#{inspect(result.error_message)}"
|
|
)
|
|
|
|
# Unsubscribe from this specific test topic since we got the result
|
|
if test_id = socket.assigns[:test_id] do
|
|
Phoenix.PubSub.unsubscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
|
end
|
|
|
|
test_result =
|
|
if result.success do
|
|
%{
|
|
success: true,
|
|
message: "Connection successful! System: #{result.system_description}"
|
|
}
|
|
else
|
|
%{
|
|
success: false,
|
|
message: result.error_message
|
|
}
|
|
end
|
|
|
|
Logger.info("Setting snmp_test_result assign: #{inspect(test_result)}")
|
|
|
|
{:noreply, socket |> assign(:snmp_test_result, test_result) |> assign(:test_id, nil)}
|
|
end
|
|
|
|
# Catch-all for unexpected messages
|
|
@impl true
|
|
def handle_info(_msg, socket), do: {:noreply, socket}
|
|
|
|
# Determine effective agent ID considering inheritance chain
|
|
defp determine_effective_agent_id(device_params, assigns) do
|
|
with nil <- get_device_agent(device_params),
|
|
nil <- get_site_agent(device_params, assigns),
|
|
nil <- get_org_agent(assigns) do
|
|
nil
|
|
else
|
|
agent_id when is_binary(agent_id) and agent_id != "" -> agent_id
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
@doc false
|
|
def get_device_agent(%{"agent_token_id" => id}) when is_binary(id) and id != "", do: id
|
|
def get_device_agent(_), do: nil
|
|
|
|
@doc false
|
|
def get_site_agent(%{"site_id" => site_id}, %{available_sites: sites}) when is_binary(site_id) do
|
|
case Enum.find(sites, &(&1.id == site_id)) do
|
|
%{agent_token_id: id} when not is_nil(id) -> id
|
|
_ -> nil
|
|
end
|
|
end
|
|
|
|
def get_site_agent(_, _), do: nil
|
|
|
|
@doc false
|
|
def get_org_agent(%{organization: %{default_agent_token_id: id}}) when not is_nil(id), do: id
|
|
def get_org_agent(_), do: nil
|
|
|
|
defp send_credential_test_to_agent(agent_token_id, device_map, test_id) do
|
|
require Logger
|
|
|
|
snmp_config = build_snmp_test_config(device_map)
|
|
|
|
Logger.info("Broadcasting credential test request",
|
|
agent_token_id: agent_token_id,
|
|
test_id: test_id,
|
|
topic: "agent:#{agent_token_id}:credential_test",
|
|
ip: snmp_config[:ip],
|
|
version: snmp_config[:version]
|
|
)
|
|
|
|
Phoenix.PubSub.broadcast(
|
|
Towerops.PubSub,
|
|
"agent:#{agent_token_id}:credential_test",
|
|
{:credential_test_requested, test_id, snmp_config}
|
|
)
|
|
end
|
|
|
|
defp build_snmp_test_config(device_map) do
|
|
%{
|
|
ip: get_value(device_map, "ip_address", ""),
|
|
port: normalize_port(device_map["snmp_port"]),
|
|
version: get_value(device_map, "snmp_version", "2c"),
|
|
community: get_value(device_map, "snmp_community", ""),
|
|
v3_security_level: get_value(device_map, "snmpv3_security_level", ""),
|
|
v3_username: get_value(device_map, "snmpv3_username", ""),
|
|
v3_auth_protocol: get_value(device_map, "snmpv3_auth_protocol", ""),
|
|
v3_auth_password: get_value(device_map, "snmpv3_auth_password", ""),
|
|
v3_priv_protocol: get_value(device_map, "snmpv3_priv_protocol", ""),
|
|
v3_priv_password: get_value(device_map, "snmpv3_priv_password", "")
|
|
}
|
|
end
|
|
|
|
@doc false
|
|
def get_value(map, key, default), do: map[key] || default
|
|
|
|
@doc false
|
|
def normalize_port(port) when is_binary(port), do: String.to_integer(port)
|
|
def normalize_port(port) when is_integer(port), do: port
|
|
def normalize_port(_), do: 161
|
|
|
|
# Check if a non-routable IP is being used with cloud poller
|
|
# Skip this check in dev mode or for specific users
|
|
defp check_non_routable_ip_cloud_error(device_params, assigns) do
|
|
# Skip check in dev mode or for specific users
|
|
if skip_non_routable_check?(assigns) do
|
|
false
|
|
else
|
|
ip_address = device_params["ip_address"]
|
|
|
|
# Only check if we have a valid IP address
|
|
with true <- is_binary(ip_address) and String.trim(ip_address) != "",
|
|
trimmed_ip = String.trim(ip_address),
|
|
true <- non_routable_ip?(trimmed_ip),
|
|
true <- using_cloud_poller?(device_params, assigns) do
|
|
true
|
|
else
|
|
_ -> false
|
|
end
|
|
end
|
|
end
|
|
|
|
defp skip_non_routable_check?(assigns) do
|
|
# Skip in dev mode or for specific users
|
|
Application.get_env(:towerops, :env) == :dev ||
|
|
(assigns[:current_scope] && assigns.current_scope.user &&
|
|
assigns.current_scope.user.email == "graham@mcintire.me")
|
|
end
|
|
|
|
# Determine if the device would use cloud poller (no agent at any level)
|
|
defp using_cloud_poller?(device_params, assigns) do
|
|
# Check if agent is directly assigned in form
|
|
form_agent_id = device_params["agent_token_id"]
|
|
|
|
if form_agent_id && form_agent_id != "" do
|
|
false
|
|
else
|
|
# Check if site has a default agent
|
|
site_id = device_params["site_id"]
|
|
site = site_id && Enum.find(assigns.available_sites, &(&1.id == site_id))
|
|
site_agent_id = site && site.agent_token_id
|
|
|
|
if site_agent_id do
|
|
false
|
|
else
|
|
# Check if organization has a default agent
|
|
org = assigns.organization
|
|
org_agent_id = org && org.default_agent_token_id
|
|
|
|
# Cloud poller if no agent at any level
|
|
is_nil(org_agent_id)
|
|
end
|
|
end
|
|
end
|
|
|
|
# Merge a query param into attrs if present and non-empty
|
|
defp merge_param(attrs, _key, nil), do: attrs
|
|
defp merge_param(attrs, _key, ""), do: attrs
|
|
|
|
defp merge_param(attrs, key, value) when is_binary(value) do
|
|
Map.put(attrs, key, value)
|
|
end
|
|
|
|
# Merge a boolean query param into attrs if present
|
|
defp merge_bool_param(attrs, _key, nil), do: attrs
|
|
defp merge_bool_param(attrs, key, "true"), do: Map.put(attrs, key, true)
|
|
defp merge_bool_param(attrs, key, "false"), do: Map.put(attrs, key, false)
|
|
defp merge_bool_param(attrs, _key, _), do: attrs
|
|
|
|
# Check if IP is non-routable (RFC1918 private or RFC6598 CGNAT)
|
|
defp non_routable_ip?(ip_string) do
|
|
case ip_string |> String.to_charlist() |> :inet.parse_address() do
|
|
{:ok, {a, b, _c, _d}} -> non_routable_ipv4_range?(a, b)
|
|
# IPv6 or invalid - don't flag as non-routable
|
|
_ -> false
|
|
end
|
|
end
|
|
|
|
# 10.0.0.0/8 (RFC1918)
|
|
defp non_routable_ipv4_range?(10, _b), do: true
|
|
# 172.16.0.0/12 (RFC1918)
|
|
defp non_routable_ipv4_range?(172, b) when b >= 16 and b <= 31, do: true
|
|
# 192.168.0.0/16 (RFC1918)
|
|
defp non_routable_ipv4_range?(192, 168), do: true
|
|
# 100.64.0.0/10 (RFC6598 CGNAT)
|
|
defp non_routable_ipv4_range?(100, b) when b >= 64 and b <= 127, do: true
|
|
defp non_routable_ipv4_range?(_a, _b), do: false
|
|
|
|
# Auto-select site if preselected or if there's only one option
|
|
defp maybe_preselect_site(attrs, preselected_id, _sites) when not is_nil(preselected_id),
|
|
do: Map.put(attrs, :site_id, preselected_id)
|
|
|
|
defp maybe_preselect_site(attrs, nil, [site]), do: Map.put(attrs, :site_id, site.id)
|
|
|
|
defp maybe_preselect_site(attrs, nil, _sites), do: attrs
|
|
|
|
@impl true
|
|
def terminate(_reason, socket) do
|
|
# Unsubscribe from credential test topic if we're still subscribed
|
|
if test_id = socket.assigns[:test_id] do
|
|
Phoenix.PubSub.unsubscribe(Towerops.PubSub, "credential_test:#{test_id}")
|
|
end
|
|
|
|
:ok
|
|
end
|
|
end
|