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 require Logger @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 # Create changeset with agent_token_id and validate to populate form fields changeset = device |> Devices.change_device(%{agent_token_id: agent_token_id}) |> 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) |> 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(: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 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 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 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 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