From c93144cc373ee8e7375df1d33da06dc1f845e3b8 Mon Sep 17 00:00:00 2001 From: Graham McIntire Date: Wed, 4 Feb 2026 12:02:38 -0600 Subject: [PATCH] snmp v3 support --- CLAUDE.md | 69 ++++ lib/towerops/devices.ex | 167 ++++++++++ lib/towerops/devices/device.ex | 134 +++++++- lib/towerops/organizations/organization.ex | 100 ++++++ lib/towerops/proto/agent.pb.ex | 22 ++ lib/towerops/sites/site.ex | 99 ++++++ lib/towerops/snmp/client.ex | 72 +++- lib/towerops/snmp/discovery.ex | 17 +- lib/towerops_web/channels/agent_channel.ex | 188 +++++++++-- lib/towerops_web/live/device_live/form.ex | 311 +++++++++--------- .../live/device_live/form.html.heex | 190 +++++++++-- .../live/org/settings_live.html.heex | 90 ++++- .../live/site_live/form.html.heex | 90 ++++- lib/towerops_web/plugs/detect_eu_user.ex | 26 +- priv/proto/agent.proto | 19 +- ...0203214942_add_snmpv3_to_organizations.exs | 25 ++ .../20260203215025_add_snmpv3_to_sites.exs | 26 ++ .../20260203215100_add_snmpv3_to_devices.exs | 29 ++ test/towerops/organizations_test.exs | 6 +- .../plugs/detect_eu_user_test.exs | 57 ++++ 20 files changed, 1492 insertions(+), 245 deletions(-) create mode 100644 priv/repo/migrations/20260203214942_add_snmpv3_to_organizations.exs create mode 100644 priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs create mode 100644 priv/repo/migrations/20260203215100_add_snmpv3_to_devices.exs diff --git a/CLAUDE.md b/CLAUDE.md index e47602c9..a3b2b200 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -864,6 +864,75 @@ Template change: ``` +### LiveView Form Data Access + +**Problem**: Accessing form field values in event handlers can be confusing because `form.params` is often empty until fields change. + +**Key Facts**: +1. `form.params` is **empty** (`%{}`) until the `phx-change="validate"` event fires +2. Form data is nested under `"device"` key in validate event: `%{"device" => device_params}` +3. To get current field values (including defaults and DB values), use the changeset + +**Correct Pattern** - Access current form data from changeset: +```elixir +def handle_event("test_snmp", _params, socket) do + # Get changeset from form + changeset = socket.assigns.form.source + + # Apply changes to get current field values + # This combines: DB values + default values + user changes + form_data = Ecto.Changeset.apply_changes(changeset) + + # Convert struct to map with string keys if needed + device_map = %{ + "agent_token_id" => form_data.agent_token_id, + "site_id" => form_data.site_id, + "ip_address" => form_data.ip_address, + # ... other fields + } + + # Use device_map for logic +end +``` + +**Why This Works**: +- `Ecto.Changeset.apply_changes/1` returns the struct with all current values +- Includes data from database (for existing records) +- Includes changes made in the form (tracked by changeset) +- Includes default values set during form initialization + +**Common Mistake** - Don't use `form.params` directly: +```elixir +def handle_event("test_snmp", _params, socket) do + form = socket.assigns.form + form_data = form.params # ❌ This is empty until fields change! + + agent_id = form_data["agent_token_id"] # ❌ Returns nil +end +``` + +**When This Matters**: +- Clicking buttons that need current form values (like "Test Connection") +- Validation logic that depends on multiple field values +- Computing derived values from form state +- Any event handler that needs form data but isn't the submit/validate event + +**Related**: The `phx-change="validate"` event receives params as `%{"device" => %{"field" => "value"}}` which gets merged into the changeset. But button clicks (`phx-click`) don't automatically send form data. + +**Common Mistake #2** - Passing struct instead of map: +```elixir +form_data = Ecto.Changeset.apply_changes(changeset) # Returns a struct + +# Build string-keyed map +device_map = %{"field" => form_data.field} + +# ❌ Wrong - passing struct +send_function(form_data) + +# ✅ Correct - passing map +send_function(device_map) +``` + ### Pagination for Large Lists **Problem**: Loading thousands of items into memory for display. diff --git a/lib/towerops/devices.ex b/lib/towerops/devices.ex index 73402958..1044343c 100644 --- a/lib/towerops/devices.ex +++ b/lib/towerops/devices.ex @@ -520,6 +520,173 @@ defmodule Towerops.Devices do :ok end + @doc """ + Gets SNMPv3 configuration for a device with hierarchical fallback. + + Returns a map with all v3 fields resolved, or nil if v2c/v1 is in use. + + ## Examples + + iex> get_snmpv3_config(device) + %{ + security_level: "authPriv", + username: "snmpuser", + auth_protocol: "SHA-256", + auth_password: "decrypted_auth_pass", + priv_protocol: "AES", + priv_password: "decrypted_priv_pass", + source: :device + } + """ + def get_snmpv3_config(device_id) when is_binary(device_id) do + device = + DeviceSchema + |> Repo.get!(device_id) + |> Repo.preload(site: :organization) + + get_snmpv3_config(device) + end + + def get_snmpv3_config(%DeviceSchema{snmp_version: version} = _device) when version != "3" do + nil + end + + def get_snmpv3_config(%DeviceSchema{} = device) do + device = ensure_snmpv3_config_loaded(device) + resolve_snmpv3_config(device) + end + + defp ensure_snmpv3_config_loaded(device) do + needs_preload = + !Ecto.assoc_loaded?(device.site) || + !Ecto.assoc_loaded?(device.site.organization) + + if needs_preload do + Repo.preload(device, site: :organization) + else + device + end + end + + defp resolve_snmpv3_config(device) do + %{ + security_level: resolve_snmpv3_security_level(device), + username: resolve_snmpv3_username(device), + auth_protocol: resolve_snmpv3_auth_protocol(device), + auth_password: resolve_snmpv3_auth_password(device), + priv_protocol: resolve_snmpv3_priv_protocol(device), + priv_password: resolve_snmpv3_priv_password(device), + source: determine_snmpv3_source(device) + } + end + + defp resolve_snmpv3_security_level(device) do + device.snmpv3_security_level || + device.site.snmpv3_security_level || + device.site.organization.snmpv3_security_level + end + + defp resolve_snmpv3_username(device) do + device.snmpv3_username || + device.site.snmpv3_username || + device.site.organization.snmpv3_username + end + + defp resolve_snmpv3_auth_protocol(device) do + device.snmpv3_auth_protocol || + device.site.snmpv3_auth_protocol || + device.site.organization.snmpv3_auth_protocol || + "SHA-256" + end + + defp resolve_snmpv3_auth_password(device) do + device.snmpv3_auth_password || + device.site.snmpv3_auth_password || + device.site.organization.snmpv3_auth_password + end + + defp resolve_snmpv3_priv_protocol(device) do + device.snmpv3_priv_protocol || + device.site.snmpv3_priv_protocol || + device.site.organization.snmpv3_priv_protocol || + "AES" + end + + defp resolve_snmpv3_priv_password(device) do + device.snmpv3_priv_password || + device.site.snmpv3_priv_password || + device.site.organization.snmpv3_priv_password + end + + defp determine_snmpv3_source(device) do + cond do + device.snmpv3_username != nil -> :device + device.site.snmpv3_username != nil -> :site + device.site.organization.snmpv3_username != nil -> :organization + true -> :default + end + end + + @doc """ + Propagates SNMPv3 credential changes from a site to all devices in that + site that inherit credentials from the site (credential_source = "site"). + """ + def propagate_site_snmpv3_change(site_id, attrs) do + devices_to_update = + Repo.all( + from(d in DeviceSchema, + where: d.site_id == ^site_id, + where: d.snmpv3_credential_source == "site" + ) + ) + + Enum.each(devices_to_update, fn device -> + device + |> Ecto.Changeset.change(attrs) + |> Repo.update() + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}:assignments", + {:assignments_changed, :snmpv3_updated} + ) + end) + + :ok + end + + @doc """ + Propagates SNMPv3 credential changes from an organization to all devices + in sites that have no site-level credentials. + """ + def propagate_organization_snmpv3_change(organization_id, attrs) do + empty_string = "" + + devices_to_update = + Repo.all( + from(d in DeviceSchema, + join: s in assoc(d, :site), + where: s.organization_id == ^organization_id, + where: d.snmpv3_credential_source == "site", + where: is_nil(s.snmpv3_username) or s.snmpv3_username == ^empty_string + ) + ) + + Enum.each(devices_to_update, fn device -> + device + |> Ecto.Changeset.change(attrs) + |> Repo.update() + + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "device:#{device.id}:assignments", + {:assignments_changed, :snmpv3_updated} + ) + end) + + :ok + end + @doc """ Creates device. diff --git a/lib/towerops/devices/device.ex b/lib/towerops/devices/device.ex index 4714ca90..152fd28d 100644 --- a/lib/towerops/devices/device.ex +++ b/lib/towerops/devices/device.ex @@ -40,6 +40,15 @@ defmodule Towerops.Devices.Device do field :last_discovery_at, :utc_datetime field :last_snmp_poll_at, :utc_datetime + # SNMPv3 credentials (device-level overrides, all nullable) + field :snmpv3_security_level, :string + field :snmpv3_username, :string + field :snmpv3_auth_protocol, :string + field :snmpv3_auth_password, Binary + field :snmpv3_priv_protocol, :string + field :snmpv3_priv_password, Binary + field :snmpv3_credential_source, :string, default: "site" + # MikroTik API credentials (device-level overrides) field :mikrotik_username, Binary field :mikrotik_password, Binary @@ -74,6 +83,13 @@ defmodule Towerops.Devices.Device do snmp_port: integer(), last_discovery_at: DateTime.t() | nil, last_snmp_poll_at: DateTime.t() | nil, + snmpv3_security_level: String.t() | nil, + snmpv3_username: String.t() | nil, + snmpv3_auth_protocol: String.t() | nil, + snmpv3_auth_password: String.t() | nil, + snmpv3_priv_protocol: String.t() | nil, + snmpv3_priv_password: String.t() | nil, + snmpv3_credential_source: String.t(), mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, @@ -110,6 +126,13 @@ defmodule Towerops.Devices.Device do :last_status_change_at, :last_discovery_at, :last_snmp_poll_at, + :snmpv3_security_level, + :snmpv3_username, + :snmpv3_auth_protocol, + :snmpv3_auth_password, + :snmpv3_priv_protocol, + :snmpv3_priv_password, + :snmpv3_credential_source, :mikrotik_username, :mikrotik_password, :mikrotik_port, @@ -125,8 +148,10 @@ defmodule Towerops.Devices.Device do |> validate_ip_address() |> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600) |> validate_snmp() + |> validate_snmpv3_fields() |> validate_mikrotik() |> update_community_source() + |> update_snmpv3_credential_source() |> update_mikrotik_credential_source() |> foreign_key_constraint(:site_id) end @@ -172,7 +197,7 @@ defmodule Towerops.Devices.Device do if snmp_enabled do changeset |> validate_required([:snmp_version], message: "required when SNMP is enabled") - |> validate_inclusion(:snmp_version, ["1", "2c"], message: "must be either '1' or '2c'") + |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> validate_number(:snmp_port, greater_than: 0, less_than: 65_536) else changeset @@ -224,6 +249,113 @@ defmodule Towerops.Devices.Device do end end + defp validate_snmpv3_fields(changeset) do + snmp_version = get_field(changeset, :snmp_version) + + if snmp_version == "3" do + changeset + |> validate_required([:snmpv3_username]) + |> derive_snmpv3_security_level() + |> validate_snmpv3_auth_required() + |> validate_snmpv3_priv_required() + |> validate_password_length(:snmpv3_auth_password, 8) + |> validate_password_length(:snmpv3_priv_password, 8) + else + changeset + end + end + + defp derive_snmpv3_security_level(changeset) do + # Automatically derive security level based on what fields are present + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + security_level = + cond do + auth_protocol in [nil, ""] -> "noAuthNoPriv" + priv_protocol not in [nil, ""] -> "authPriv" + true -> "authNoPriv" + end + + put_change(changeset, :snmpv3_security_level, security_level) + end + + defp validate_snmpv3_auth_required(changeset) do + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + + if auth_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_auth_password], + message: "required when auth protocol is selected" + ) + |> validate_inclusion(:snmpv3_auth_protocol, [ + "MD5", + "SHA", + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512" + ]) + end + end + + defp validate_snmpv3_priv_required(changeset) do + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + if priv_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_priv_password], + message: "required when privacy protocol is selected" + ) + |> validate_inclusion(:snmpv3_priv_protocol, ["DES", "AES", "AES-192", "AES-256", "AES-256-C"]) + end + end + + defp validate_password_length(changeset, field, min_length) do + case get_change(changeset, field) do + nil -> + changeset + + password when byte_size(password) < min_length -> + add_error(changeset, field, "must be at least #{min_length} characters") + + _ -> + changeset + end + end + + defp update_snmpv3_credential_source(changeset) do + # If user explicitly set a username, mark source as "device" + # If it's blank/nil/cleared, mark source as "site" (will inherit from site or org) + username_change = get_change(changeset, :snmpv3_username) + current_username = get_field(changeset, :snmpv3_username) + + cond do + # Explicitly set to empty string - clear and inherit + username_change == "" -> + changeset + |> put_change(:snmpv3_username, nil) + |> put_change(:snmpv3_credential_source, "site") + + # Explicitly set to a value - mark as device-specific + username_change != nil && username_change != "" -> + put_change(changeset, :snmpv3_credential_source, "device") + + # No change to username, but check if we should reset source + username_change == nil && (current_username == nil || current_username == "") && + get_field(changeset, :snmpv3_credential_source) == "device" -> + put_change(changeset, :snmpv3_credential_source, "site") + + # No change, keep existing + true -> + changeset + end + end + defp update_mikrotik_credential_source(changeset) do # If user explicitly set a username, mark source as "device" # If it's blank/nil/cleared, mark source as "site" (will inherit from site or org) diff --git a/lib/towerops/organizations/organization.ex b/lib/towerops/organizations/organization.ex index df93d785..074afc0e 100644 --- a/lib/towerops/organizations/organization.ex +++ b/lib/towerops/organizations/organization.ex @@ -31,6 +31,14 @@ defmodule Towerops.Organizations.Organization do field :snmp_community, :string field :snmp_port, :integer, default: 161 + # SNMPv3 credentials (organization-level defaults cascade to site → device) + field :snmpv3_security_level, :string + field :snmpv3_username, :string + field :snmpv3_auth_protocol, :string, default: "SHA-256" + field :snmpv3_auth_password, Binary + field :snmpv3_priv_protocol, :string, default: "AES" + field :snmpv3_priv_password, Binary + # MikroTik API credentials (organization-level defaults cascade to site → device) field :mikrotik_username, Binary field :mikrotik_password, Binary @@ -56,6 +64,12 @@ defmodule Towerops.Organizations.Organization do snmp_version: String.t() | nil, snmp_community: String.t() | nil, snmp_port: integer(), + snmpv3_security_level: String.t() | nil, + snmpv3_username: String.t() | nil, + snmpv3_auth_protocol: String.t() | nil, + snmpv3_auth_password: String.t() | nil, + snmpv3_priv_protocol: String.t() | nil, + snmpv3_priv_password: String.t() | nil, mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, @@ -81,6 +95,12 @@ defmodule Towerops.Organizations.Organization do :snmp_version, :snmp_community, :snmp_port, + :snmpv3_security_level, + :snmpv3_username, + :snmpv3_auth_protocol, + :snmpv3_auth_password, + :snmpv3_priv_protocol, + :snmpv3_priv_password, :mikrotik_username, :mikrotik_password, :mikrotik_port, @@ -93,6 +113,7 @@ defmodule Towerops.Organizations.Organization do |> validate_inclusion(:subscription_plan, ["free"]) |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> validate_number(:snmp_port, greater_than: 0, less_than: 65_536) + |> validate_snmpv3_fields() |> validate_mikrotik_fields() |> generate_slug() |> validate_required([:slug]) @@ -100,6 +121,85 @@ defmodule Towerops.Organizations.Organization do |> foreign_key_constraint(:default_agent_token_id) end + defp validate_snmpv3_fields(changeset) do + snmp_version = get_field(changeset, :snmp_version) + + if snmp_version == "3" do + changeset + |> validate_required([:snmpv3_username]) + |> derive_snmpv3_security_level() + |> validate_snmpv3_auth_required() + |> validate_snmpv3_priv_required() + |> validate_password_length(:snmpv3_auth_password, 8) + |> validate_password_length(:snmpv3_priv_password, 8) + else + changeset + end + end + + defp derive_snmpv3_security_level(changeset) do + # Automatically derive security level based on what fields are present + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + security_level = + cond do + auth_protocol in [nil, ""] -> "noAuthNoPriv" + priv_protocol not in [nil, ""] -> "authPriv" + true -> "authNoPriv" + end + + put_change(changeset, :snmpv3_security_level, security_level) + end + + defp validate_snmpv3_auth_required(changeset) do + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + + if auth_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_auth_password], + message: "required when auth protocol is selected" + ) + |> validate_inclusion(:snmpv3_auth_protocol, [ + "MD5", + "SHA", + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512" + ]) + end + end + + defp validate_snmpv3_priv_required(changeset) do + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + if priv_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_priv_password], + message: "required when privacy protocol is selected" + ) + |> validate_inclusion(:snmpv3_priv_protocol, ["DES", "AES", "AES-192", "AES-256", "AES-256-C"]) + end + end + + defp validate_password_length(changeset, field, min_length) do + case get_change(changeset, field) do + nil -> + changeset + + password when byte_size(password) < min_length -> + add_error(changeset, field, "must be at least #{min_length} characters") + + _ -> + changeset + end + end + defp validate_mikrotik_fields(changeset) do changeset |> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536) diff --git a/lib/towerops/proto/agent.pb.ex b/lib/towerops/proto/agent.pb.ex index bd42bf18..cedd6cd0 100644 --- a/lib/towerops/proto/agent.pb.ex +++ b/lib/towerops/proto/agent.pb.ex @@ -231,6 +231,7 @@ defmodule Towerops.Agent.JobType do field :DISCOVER, 0 field :POLL, 1 field :MIKROTIK, 2 + field :TEST_CREDENTIALS, 3 end defmodule Towerops.Agent.QueryType do @@ -286,6 +287,12 @@ defmodule Towerops.Agent.SnmpDevice do field :community, 2, type: :string field :version, 3, type: :string field :port, 4, type: :uint32 + field :v3_security_level, 5, type: :string, json_name: "v3SecurityLevel" + field :v3_username, 6, type: :string, json_name: "v3Username" + field :v3_auth_protocol, 7, type: :string, json_name: "v3AuthProtocol" + field :v3_auth_password, 8, type: :string, json_name: "v3AuthPassword" + field :v3_priv_protocol, 9, type: :string, json_name: "v3PrivProtocol" + field :v3_priv_password, 10, type: :string, json_name: "v3PrivPassword" end defmodule Towerops.Agent.SnmpQuery do @@ -434,3 +441,18 @@ defmodule Towerops.Agent.MikrotikSentence do field :attributes, 1, repeated: true, type: Towerops.Agent.MikrotikSentence.AttributesEntry, map: true end + +defmodule Towerops.Agent.CredentialTestResult do + @moduledoc false + + use Protobuf, + full_name: "towerops.agent.CredentialTestResult", + protoc_gen_elixir_version: "0.16.0", + syntax: :proto3 + + field :test_id, 1, type: :string, json_name: "testId" + field :success, 2, type: :bool + field :error_message, 3, type: :string, json_name: "errorMessage" + field :system_description, 4, type: :string, json_name: "systemDescription" + field :timestamp, 5, type: :int64 +end diff --git a/lib/towerops/sites/site.ex b/lib/towerops/sites/site.ex index febeb3a2..d973c50f 100644 --- a/lib/towerops/sites/site.ex +++ b/lib/towerops/sites/site.ex @@ -28,6 +28,14 @@ defmodule Towerops.Sites.Site do field :snmp_community, :string field :snmp_port, :integer + # SNMPv3 credentials (overrides organization default, all nullable) + field :snmpv3_security_level, :string + field :snmpv3_username, :string + field :snmpv3_auth_protocol, :string + field :snmpv3_auth_password, Binary + field :snmpv3_priv_protocol, :string + field :snmpv3_priv_password, Binary + # MikroTik API credentials (overrides organization default) field :mikrotik_username, Binary field :mikrotik_password, Binary @@ -54,6 +62,12 @@ defmodule Towerops.Sites.Site do snmp_version: String.t() | nil, snmp_community: String.t() | nil, snmp_port: integer() | nil, + snmpv3_security_level: String.t() | nil, + snmpv3_username: String.t() | nil, + snmpv3_auth_protocol: String.t() | nil, + snmpv3_auth_password: String.t() | nil, + snmpv3_priv_protocol: String.t() | nil, + snmpv3_priv_password: String.t() | nil, mikrotik_username: String.t() | nil, mikrotik_password: String.t() | nil, mikrotik_port: integer() | nil, @@ -86,6 +100,12 @@ defmodule Towerops.Sites.Site do :snmp_version, :snmp_community, :snmp_port, + :snmpv3_security_level, + :snmpv3_username, + :snmpv3_auth_protocol, + :snmpv3_auth_password, + :snmpv3_priv_protocol, + :snmpv3_priv_password, :mikrotik_username, :mikrotik_password, :mikrotik_port, @@ -99,6 +119,7 @@ defmodule Towerops.Sites.Site do |> validate_length(:location, max: 200) |> validate_inclusion(:snmp_version, ["1", "2c", "3"], message: "must be 1, 2c, or 3") |> validate_number(:snmp_port, greater_than: 0, less_than: 65_536) + |> validate_snmpv3_fields() |> validate_mikrotik_fields() |> foreign_key_constraint(:organization_id) |> foreign_key_constraint(:agent_token_id) @@ -110,6 +131,84 @@ defmodule Towerops.Sites.Site do |> validate_not_circular_parent() end + defp validate_snmpv3_fields(changeset) do + snmp_version = get_field(changeset, :snmp_version) + + if snmp_version == "3" do + changeset + |> derive_snmpv3_security_level() + |> validate_snmpv3_auth_required() + |> validate_snmpv3_priv_required() + |> validate_password_length(:snmpv3_auth_password, 8) + |> validate_password_length(:snmpv3_priv_password, 8) + else + changeset + end + end + + defp derive_snmpv3_security_level(changeset) do + # Automatically derive security level based on what fields are present + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + security_level = + cond do + auth_protocol in [nil, ""] -> "noAuthNoPriv" + priv_protocol not in [nil, ""] -> "authPriv" + true -> "authNoPriv" + end + + put_change(changeset, :snmpv3_security_level, security_level) + end + + defp validate_snmpv3_auth_required(changeset) do + auth_protocol = get_field(changeset, :snmpv3_auth_protocol) + + if auth_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_auth_password], + message: "required when auth protocol is selected" + ) + |> validate_inclusion(:snmpv3_auth_protocol, [ + "MD5", + "SHA", + "SHA-224", + "SHA-256", + "SHA-384", + "SHA-512" + ]) + end + end + + defp validate_snmpv3_priv_required(changeset) do + priv_protocol = get_field(changeset, :snmpv3_priv_protocol) + + if priv_protocol in [nil, ""] do + changeset + else + changeset + |> validate_required([:snmpv3_priv_password], + message: "required when privacy protocol is selected" + ) + |> validate_inclusion(:snmpv3_priv_protocol, ["DES", "AES", "AES-192", "AES-256", "AES-256-C"]) + end + end + + defp validate_password_length(changeset, field, min_length) do + case get_change(changeset, field) do + nil -> + changeset + + password when byte_size(password) < min_length -> + add_error(changeset, field, "must be at least #{min_length} characters") + + _ -> + changeset + end + end + defp validate_mikrotik_fields(changeset) do changeset |> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536) diff --git a/lib/towerops/snmp/client.ex b/lib/towerops/snmp/client.ex index bddbd30b..a2a8dca0 100644 --- a/lib/towerops/snmp/client.ex +++ b/lib/towerops/snmp/client.ex @@ -39,11 +39,17 @@ defmodule Towerops.Snmp.Client do # Check if custom adapter is specified (e.g., Replay adapter) case Keyword.get(opts, :adapter) do nil -> - # Default behavior - use SnmpKit - do_get_with_snmpkit(opts, oid) + # No adapter - check if Phoenix SNMP is disabled + if phoenix_snmp_disabled() do + log_disabled_call("get", opts, oid) + {:error, :phoenix_snmp_disabled} + else + # Default behavior - use SnmpKit + do_get_with_snmpkit(opts, oid) + end adapter -> - # Custom adapter - delegate directly + # Custom adapter - delegate directly (bypass Phoenix SNMP check) adapter.get(opts, oid) end end @@ -217,11 +223,17 @@ defmodule Towerops.Snmp.Client do # Check if custom adapter is specified case Keyword.get(opts, :adapter) do nil -> - # Default behavior - use SnmpKit - do_walk_with_snmpkit(opts, start_oid) + # No adapter - check if Phoenix SNMP is disabled + if phoenix_snmp_disabled() do + log_disabled_call("walk", opts, start_oid) + {:error, :phoenix_snmp_disabled} + else + # Default behavior - use SnmpKit + do_walk_with_snmpkit(opts, start_oid) + end adapter -> - # Custom adapter - delegate and convert to map format + # Custom adapter - delegate and convert to map format (bypass Phoenix SNMP check) convert_adapter_walk_to_map(adapter.walk(opts, start_oid)) end end @@ -352,19 +364,53 @@ defmodule Towerops.Snmp.Client do """ @spec test_connection(connection_opts()) :: {:ok, String.t()} | {:error, term()} def test_connection(opts) do - # Try to get sysUpTime (1.3.6.1.2.1.1.3.0) as a connectivity test - case get(opts, "1.3.6.1.2.1.1.3.0") do - {:ok, _uptime} -> - {:ok, "Connection successful"} + # Check if custom adapter is specified + adapter = Keyword.get(opts, :adapter) - {:error, reason} = error -> - Logger.warning("SNMP connection test failed: #{inspect(reason)}") - error + if adapter == nil and phoenix_snmp_disabled() do + # No adapter and Phoenix SNMP is disabled + log_disabled_call("test_connection", opts, "sysUpTime.0") + {:error, :phoenix_snmp_disabled} + else + # Try to get sysUpTime (1.3.6.1.2.1.1.3.0) as a connectivity test + # This will use the adapter if one is specified, or SnmpKit if not + case get(opts, "1.3.6.1.2.1.1.3.0") do + {:ok, _uptime} -> + {:ok, "Connection successful"} + + {:error, reason} = error -> + Logger.warning("SNMP connection test failed: #{inspect(reason)}") + error + end end end # Private functions + defp phoenix_snmp_disabled do + # Only disable in non-test environments (allow tests to run normally) + if Mix.env() == :test do + false + else + Application.get_env(:towerops, :disable_phoenix_snmp, true) + end + end + + defp log_disabled_call(operation, opts, oid_or_param) do + ip = Keyword.get(opts, :ip, "unknown") + version = Keyword.get(opts, :version, "unknown") + + Logger.info(""" + [Phoenix SNMP Disabled] Would have executed SNMP operation: + Operation: #{operation} + Target: #{ip} + Version: #{version} + OID/Param: #{inspect(oid_or_param)} + + This operation should be performed by an agent instead. + """) + end + defp snmp_adapter do Application.get_env(:towerops, :snmp_adapter, SnmpKit) end diff --git a/lib/towerops/snmp/discovery.ex b/lib/towerops/snmp/discovery.ex index 37212f2d..049a2c76 100644 --- a/lib/towerops/snmp/discovery.ex +++ b/lib/towerops/snmp/discovery.ex @@ -19,6 +19,7 @@ defmodule Towerops.Snmp.Discovery do alias Towerops.Devices.Firmware alias Towerops.Profiles.YamlProfiles alias Towerops.Repo + alias Towerops.Snmp.Adapters.Replay alias Towerops.Snmp.ArpDiscovery alias Towerops.Snmp.Client alias Towerops.Snmp.DeferredDiscovery @@ -160,7 +161,7 @@ defmodule Towerops.Snmp.Discovery do adapter = Keyword.get(client_opts, :adapter) {device_speed, timeouts} = - if adapter == Towerops.Snmp.Adapters.Replay do + if adapter == Replay do Logger.debug("Using Replay adapter, skipping speed categorization") {:fast, DeferredDiscovery.timeouts_for_speed(:fast)} else @@ -182,9 +183,19 @@ defmodule Towerops.Snmp.Discovery do # Internal discovery with adaptive timeouts based on device speed defp do_discover_device(device, client_opts, timeouts) do - Logger.info("Testing SNMP connection...", device_id: device.id) + adapter = Keyword.get(client_opts, :adapter) - with {:ok, _} <- Client.test_connection(client_opts), + # Skip connection test for Replay adapter (agent-provided data) + connection_result = + if adapter == Replay do + Logger.debug("Using Replay adapter, skipping connection test", device_id: device.id) + {:ok, :replay} + else + Logger.info("Testing SNMP connection...", device_id: device.id) + Client.test_connection(client_opts) + end + + with {:ok, _} <- connection_result, Logger.info("Discovering system info...", device_id: device.id), {:ok, system_info} <- discover_system(client_opts), Logger.info("Updating device name...", device_id: device.id), diff --git a/lib/towerops_web/channels/agent_channel.ex b/lib/towerops_web/channels/agent_channel.ex index a9bc2a1c..41be2d1b 100644 --- a/lib/towerops_web/channels/agent_channel.ex +++ b/lib/towerops_web/channels/agent_channel.ex @@ -25,6 +25,7 @@ defmodule ToweropsWeb.AgentChannel do alias Towerops.Agent.AgentHeartbeat alias Towerops.Agent.AgentJob alias Towerops.Agent.AgentJobList + alias Towerops.Agent.CredentialTestResult alias Towerops.Agent.MikrotikCommand alias Towerops.Agent.MikrotikDevice alias Towerops.Agent.MikrotikResult @@ -43,7 +44,14 @@ defmodule ToweropsWeb.AgentChannel do @impl true @spec join(String.t(), map(), Phoenix.Socket.t()) :: {:ok, Phoenix.Socket.t()} | {:error, map()} - def join("agent:" <> _agent_id, %{"token" => token}, socket) do + def join("agent:" <> agent_id, %{"token" => token} = payload, socket) do + Logger.info("Agent join attempt", + topic: "agent:#{agent_id}", + has_token: not is_nil(token), + token_length: if(is_binary(token), do: byte_size(token), else: 0), + payload_keys: Map.keys(payload) + ) + # Verify agent token from join payload case Agents.verify_agent_token(token) do {:ok, agent_token} -> @@ -68,6 +76,9 @@ defmodule ToweropsWeb.AgentChannel do # Subscribe to backup requests for this agent _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:backup") + # Subscribe to credential test requests for this agent + _ = Phoenix.PubSub.subscribe(Towerops.PubSub, "agent:#{agent_token.id}:credential_test") + # Update last_seen_at and IP on join remote_ip = get_remote_ip(socket) _ = Agents.update_agent_token_heartbeat(agent_token.id, remote_ip, %{}) @@ -151,6 +162,45 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end + # Handle PubSub broadcast when credential test is requested + def handle_info({:credential_test_requested, test_id, snmp_config}, socket) do + Logger.info("Credential test requested, sending test job to agent", + agent_token_id: socket.assigns.agent_token_id, + test_id: test_id + ) + + # Build SNMP device from config + snmp_device = %SnmpDevice{ + ip: snmp_config[:ip], + port: snmp_config[:port], + version: snmp_config[:version], + community: snmp_config[:community], + v3_security_level: snmp_config[:v3_security_level], + v3_username: snmp_config[:v3_username], + v3_auth_protocol: snmp_config[:v3_auth_protocol], + v3_auth_password: snmp_config[:v3_auth_password], + v3_priv_protocol: snmp_config[:v3_priv_protocol], + v3_priv_password: snmp_config[:v3_priv_password] + } + + # Build test credentials job + job = %AgentJob{ + job_id: test_id, + job_type: :TEST_CREDENTIALS, + device_id: test_id, + snmp_device: snmp_device, + queries: [], + mikrotik_device: nil, + mikrotik_commands: [] + } + + job_list = %AgentJobList{jobs: [job]} + binary = AgentJobList.encode(job_list) + + push(socket, "jobs", %{binary: Base.encode64(binary)}) + {:noreply, socket} + end + @impl true @spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} def handle_in("result", %{"binary" => binary_b64}, socket) do @@ -230,6 +280,27 @@ defmodule ToweropsWeb.AgentChannel do {:noreply, socket} end + def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) do + binary = Base.decode64!(binary_b64) + result = CredentialTestResult.decode(binary) + + maybe_debug_log(socket, "Received credential test result from agent", + test_id: result.test_id, + success: result.success, + has_error: result.error_message != "" + ) + + # Broadcast result to the requesting LiveView via PubSub + # The test_id contains the device_id, so we can broadcast to that topic + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "credential_test:#{result.test_id}", + {:credential_test_result, result} + ) + + {:noreply, socket} + end + # Private helpers @spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()] @@ -273,59 +344,124 @@ defmodule ToweropsWeb.AgentChannel do String.contains?(device.snmp_device.sys_descr || "", "RouterOS")) end + # Resolves SNMP credentials for a device. + # For SNMPv3, returns a map with resolved credentials via cascade. + # For v1/v2c, returns a simple map with community string and version. + defp resolve_snmp_credentials(device) do + if device.snmp_version == "3" do + Devices.get_snmpv3_config(device) + else + %{ + community: Devices.resolve_snmp_community(device), + version: device.snmp_version + } + end + end + + # Checks if SNMP credentials are present (not nil/empty). + defp credentials_present?(%{community: community}) when is_binary(community) do + community != "" + end + + defp credentials_present?(%{username: username}) when is_binary(username) do + username != "" + end + + defp credentials_present?(_), do: false + + # Builds the SnmpDevice protobuf message with appropriate credentials. + # For v1/v2c: Uses community string + # For v3: Uses SNMPv3 credentials (security_level, username, auth/priv protocols/passwords) + defp build_snmp_device_message(device, snmp_config) do + if device.snmp_version == "3" do + build_v3_snmp_device(device, snmp_config) + else + build_v2c_snmp_device(device, snmp_config) + end + end + + defp build_v3_snmp_device(device, snmp_config) do + %SnmpDevice{ + ip: device.ip_address, + version: device.snmp_version, + port: device.snmp_port || 161, + community: "", + v3_security_level: snmp_config.security_level || "", + v3_username: snmp_config.username || "", + v3_auth_protocol: snmp_config.auth_protocol || "", + v3_auth_password: snmp_config.auth_password || "", + v3_priv_protocol: snmp_config.priv_protocol || "", + v3_priv_password: snmp_config.priv_password || "" + } + end + + defp build_v2c_snmp_device(device, snmp_config) do + community = snmp_config.community || "" + + Logger.info(""" + [Agent Channel] Building v2c SNMP device + Device: #{device.name} (#{device.id}) + IP: #{device.ip_address} + Version: #{device.snmp_version} + Community from config: #{inspect(snmp_config.community)} + Community to send: #{inspect(community)} + """) + + %SnmpDevice{ + ip: device.ip_address, + version: device.snmp_version, + port: device.snmp_port || 161, + community: community + } + end + defp build_discovery_job(device) do - community = Devices.resolve_snmp_community(device) + snmp_credentials = resolve_snmp_credentials(device) maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building discovery job", device_id: device.id, device_name: device.name, - community_present: !is_nil(community) && community != "", - community_source: device.snmp_community_source + snmp_version: device.snmp_version, + credentials_present: credentials_present?(snmp_credentials), + credential_source: + if(device.snmp_version == "3", + do: Map.get(snmp_credentials, :source), + else: device.snmp_community_source + ) ) %AgentJob{ job_id: "discover:#{device.id}", job_type: :DISCOVER, device_id: device.id, - snmp_device: %SnmpDevice{ - ip: device.ip_address, - community: community, - version: device.snmp_version, - port: device.snmp_port || 161 - }, + snmp_device: build_snmp_device_message(device, snmp_credentials), queries: build_discovery_queries() } end defp build_polling_job(device) do _snmp_device = device.snmp_device - community = Devices.resolve_snmp_community(device) + snmp_credentials = resolve_snmp_credentials(device) maybe_debug_log(%{assigns: %{agent_token_id: "system"}}, "Building polling job", device_id: device.id, device_name: device.name, - community_present: !is_nil(community) && community != "", - community_source: device.snmp_community_source, + snmp_version: device.snmp_version, + credentials_present: credentials_present?(snmp_credentials), + credential_source: + if(device.snmp_version == "3", + do: Map.get(snmp_credentials, :source), + else: device.snmp_community_source + ), site_id: device.site_id, - site_loaded: not is_nil(Map.get(device, :site)), - site_community_present: - case Map.get(device, :site) do - nil -> false - %Ecto.Association.NotLoaded{} -> false - site -> !is_nil(site.snmp_community) && site.snmp_community != "" - end + site_loaded: not is_nil(Map.get(device, :site)) ) %AgentJob{ job_id: "poll:#{device.id}", job_type: :POLL, device_id: device.id, - snmp_device: %SnmpDevice{ - ip: device.ip_address, - community: community, - version: device.snmp_version, - port: device.snmp_port || 161 - }, + snmp_device: build_snmp_device_message(device, snmp_credentials), queries: build_polling_queries(device) } end diff --git a/lib/towerops_web/live/device_live/form.ex b/lib/towerops_web/live/device_live/form.ex index 832767f4..e90dec05 100644 --- a/lib/towerops_web/live/device_live/form.ex +++ b/lib/towerops_web/live/device_live/form.ex @@ -8,7 +8,6 @@ defmodule ToweropsWeb.DeviceLive.Form do alias Towerops.Devices.Device, as: DeviceSchema alias Towerops.Organizations.SubscriptionLimits alias Towerops.Sites - alias Towerops.Snmp alias Towerops.Workers.DiscoveryWorker @impl true @@ -147,7 +146,12 @@ defmodule ToweropsWeb.DeviceLive.Form do # Add agent_token_id to the changeset data device_with_agent = Map.put(device, :agent_token_id, agent_token_id) - changeset = Devices.change_device(device_with_agent, %{}) + + # 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) @@ -211,6 +215,8 @@ defmodule ToweropsWeb.DeviceLive.Form do @impl true def handle_event("validate", %{"device" => device_params}, socket) do + require Logger + device_params = sanitize_device_params(device_params) changeset = @@ -220,7 +226,7 @@ defmodule ToweropsWeb.DeviceLive.Form do # Check for duplicate IP address within the same site ip_address = device_params["ip_address"] - site_id = device_params["site_id"] + 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 = @@ -266,23 +272,83 @@ defmodule ToweropsWeb.DeviceLive.Form do @impl true def handle_event("test_snmp", _params, socket) do - # Check if an agent is assigned - agent_token_id = socket.assigns.form.params["agent_token_id"] + # Get current form data from the changeset + changeset = socket.assigns.form.source + form_data = Ecto.Changeset.apply_changes(changeset) + changes = changeset.changes - result = - if agent_token_id && agent_token_id != "" do - # Agent is configured - can't test directly from web server - %{ - success: true, - message: "SNMP configuration saved. Connection will be tested by the assigned agent during next polling cycle." - } - else - # No agent configured - test directly from web server - snmp_config = extract_snmp_config(socket.assigns.form, socket.assigns) - test_snmp_connection(snmp_config) + # Determine which credentials to use: + # 1. If password fields were changed in form, use new plain text values + # 2. If existing device and no changes, use decrypted database values + # 3. If new device, use form values + {auth_password, priv_password} = + cond do + # User changed passwords in form - use new plain text values + Map.has_key?(changes, :snmpv3_auth_password) or + Map.has_key?(changes, :snmpv3_priv_password) -> + {Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)} + + # Existing device with no password changes - use database values + form_data.id -> + config = Devices.get_snmpv3_config(form_data.id) + + case config do + nil -> {nil, nil} + config -> {config.auth_password, config.priv_password} + end + + # New device - use form values + true -> + {Map.get(changes, :snmpv3_auth_password), Map.get(changes, :snmpv3_priv_password)} end - {:noreply, assign(socket, :snmp_test_result, result)} + # Convert to map for easier access + device_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" => form_data.snmp_version || "2c", + "snmp_community" => form_data.snmp_community, + "snmpv3_security_level" => form_data.snmpv3_security_level, + "snmpv3_username" => form_data.snmpv3_username, + "snmpv3_auth_protocol" => form_data.snmpv3_auth_protocol, + "snmpv3_auth_password" => auth_password, + "snmpv3_priv_protocol" => form_data.snmpv3_priv_protocol, + "snmpv3_priv_password" => priv_password + } + + # Determine effective agent (device -> site -> organization -> cloud) + effective_agent_id = determine_effective_agent_id(device_map, socket.assigns) + + if effective_agent_id do + # Send test job to agent + test_id = "test:#{Ecto.UUID.generate()}" + + case send_credential_test_to_agent(effective_agent_id, device_map, test_id) do + :ok -> + # Subscribe to test result + 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 + else + {:noreply, + assign(socket, :snmp_test_result, %{ + success: false, + message: "No agent available for testing. Please assign an agent or configure organization defaults." + })} + end end @impl true @@ -451,140 +517,6 @@ defmodule ToweropsWeb.DeviceLive.Form do device.ip_address != old_device.ip_address end - @spec extract_snmp_config(Phoenix.HTML.Form.t(), map()) :: %{ - ip_address: String.t() | nil, - snmp_community: String.t() | nil, - snmp_version: String.t() | nil, - snmp_port: integer() - } - defp extract_snmp_config(form, assigns) do - form_data = form.data - params = form.params - - device_community = params["snmp_community"] || form_data.snmp_community - - # If device community is nil or empty, resolve from site/org hierarchy - effective_community = - if is_nil(device_community) or device_community == "" do - resolve_inherited_community(params, assigns) - else - device_community - end - - %{ - ip_address: params["ip_address"] || form_data.ip_address, - snmp_community: effective_community, - snmp_version: params["snmp_version"] || form_data.snmp_version, - snmp_port: normalize_port(params["snmp_port"] || form_data.snmp_port || 161) - } - end - - defp resolve_inherited_community(params, assigns) do - # For edit mode, use effective SNMP community (already resolved) - if has_effective_community?(assigns) do - assigns.effective_snmp_community - else - resolve_community_from_hierarchy(params, assigns) - end - end - - defp has_effective_community?(assigns) do - assigns[:effective_snmp_community] && assigns.effective_snmp_community != "(not set)" - end - - defp resolve_community_from_hierarchy(params, assigns) do - selected_site_id = get_selected_site_id(params, assigns) - site_community = get_site_community(selected_site_id, assigns) - site_community || get_org_community(assigns) - end - - defp get_selected_site_id(params, assigns) do - sites = assigns[:available_sites] || [] - - params["site_id"] || - assigns[:preselected_site_id] || - (length(sites) == 1 && List.first(sites).id) - end - - defp get_site_community(nil, _assigns), do: nil - - defp get_site_community(site_id, assigns) do - site = Enum.find(assigns.available_sites, &(&1.id == site_id)) - if site && site.snmp_community && site.snmp_community != "", do: site.snmp_community - end - - defp get_org_community(assigns) do - org = assigns[:organization] - org && org.snmp_community - end - - @spec normalize_port(integer() | String.t() | any()) :: integer() - defp normalize_port(port) when is_integer(port) and port >= 1 and port <= 65_535, do: port - defp normalize_port(port) when is_integer(port), do: 161 - - defp normalize_port(port) when is_binary(port) do - case Integer.parse(port) do - {parsed_port, ""} when parsed_port >= 1 and parsed_port <= 65_535 -> parsed_port - _ -> 161 - end - end - - defp normalize_port(_), do: 161 - - @spec test_snmp_connection(%{ - ip_address: String.t() | nil, - snmp_community: String.t() | nil, - snmp_version: String.t() | nil, - snmp_port: integer() - }) :: %{success: boolean(), message: String.t()} - defp test_snmp_connection(config) do - # Validate IP address before testing - case validate_test_snmp_input(config) do - :ok -> - case Snmp.test_connection( - config.ip_address, - config.snmp_community, - config.snmp_version, - config.snmp_port - ) do - {:ok, message} -> - %{success: true, message: message} - - {:error, reason} -> - %{success: false, message: "Connection failed: #{inspect(reason)}"} - end - - {:error, message} -> - %{success: false, message: message} - end - end - - @spec validate_test_snmp_input(%{ - ip_address: String.t() | nil, - snmp_community: String.t() | nil, - snmp_version: String.t() | nil, - snmp_port: integer() - }) :: :ok | {:error, String.t()} - defp validate_test_snmp_input(%{ip_address: nil}) do - {:error, "IP address is required"} - end - - defp validate_test_snmp_input(%{snmp_community: nil}) do - {:error, "SNMP community string is required. Set one at the device, site, or organization level."} - end - - defp validate_test_snmp_input(%{snmp_community: ""}) do - {:error, "SNMP community string is required. Set one at the device, site, or organization level."} - end - - defp validate_test_snmp_input(%{ip_address: ip}) do - # Validate IP address format using Erlang's inet module - case ip |> String.to_charlist() |> :inet.parse_address() do - {:ok, _} -> :ok - {:error, _} -> {:error, "Invalid IP address format"} - end - 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 @@ -612,6 +544,83 @@ defmodule ToweropsWeb.DeviceLive.Form do end end + @impl true + def handle_info({:credential_test_result, result}, socket) do + test_result = + if result.success do + %{ + success: true, + message: "Connection successful! System: #{result.system_description}" + } + else + %{ + success: false, + message: result.error_message + } + end + + {:noreply, assign(socket, :snmp_test_result, test_result)} + end + + # Determine effective agent ID considering inheritance chain + defp determine_effective_agent_id(device_params, assigns) do + # Check device-level agent + form_agent_id = device_params["agent_token_id"] + + if form_agent_id && form_agent_id != "" do + form_agent_id + else + # Check site-level 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 + site_agent_id + else + # Check organization-level agent + org = assigns.organization + org_agent_id = org && org.default_agent_token_id + + org_agent_id + end + end + end + + defp send_credential_test_to_agent(agent_token_id, device_map, test_id) do + # Build SNMP device config from device map (string keys) + # Convert port to integer (form params are strings) + require Logger + + port = + case device_map["snmp_port"] do + port when is_binary(port) -> String.to_integer(port) + port when is_integer(port) -> port + _ -> 161 + end + + snmp_config = %{ + ip: device_map["ip_address"] || "", + port: port, + version: device_map["snmp_version"] || "2c", + community: device_map["snmp_community"] || "", + v3_security_level: device_map["snmpv3_security_level"] || "", + v3_username: device_map["snmpv3_username"] || "", + v3_auth_protocol: device_map["snmpv3_auth_protocol"] || "", + v3_auth_password: device_map["snmpv3_auth_password"] || "", + v3_priv_protocol: device_map["snmpv3_priv_protocol"] || "", + v3_priv_password: device_map["snmpv3_priv_password"] || "" + } + + # Broadcast credential test request to agent via PubSub + # The agent channel will pick this up and send the test job + Phoenix.PubSub.broadcast( + Towerops.PubSub, + "agent:#{agent_token_id}:credential_test", + {:credential_test_requested, test_id, snmp_config} + ) + end + # 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 diff --git a/lib/towerops_web/live/device_live/form.html.heex b/lib/towerops_web/live/device_live/form.html.heex index 44d5f666..beab19f9 100644 --- a/lib/towerops_web/live/device_live/form.html.heex +++ b/lib/towerops_web/live/device_live/form.html.heex @@ -343,11 +343,15 @@ type="select" label="SNMP Version" prompt="Inherit from site/organization" - options={[{"SNMPv1", "1"}, {"SNMPv2c", "2c"}]} + options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]} /> - -
+ + +
<.input field={@form[:snmp_community]} type="text" @@ -393,6 +397,109 @@ <% end %> <% end %>
+ + + <%= if @form[:snmp_version].value == "3" do %> +
+ <.input + field={@form[:snmpv3_username]} + type="text" + label="Username" + placeholder="Leave blank to inherit from site/organization" + /> +
+ + <% # Determine current security level from device data + current_auth = @form.data.snmpv3_auth_protocol + current_priv = @form.data.snmpv3_priv_protocol + + current_security_level = + cond do + current_auth in [nil, ""] -> "noAuthNoPriv" + current_priv not in [nil, ""] -> "authPriv" + true -> "authNoPriv" + end + + # Get selected security level (from params if user is changing, otherwise current) + selected_security_level = + if Map.has_key?(@form.params, "snmpv3_security_level"), + do: @form.params["snmpv3_security_level"], + else: current_security_level %> + + +
+ <.input + field={@form[:snmpv3_security_level]} + type="select" + label="Auth Level" + value={current_security_level} + options={[ + {"No Authentication, No Privacy", "noAuthNoPriv"}, + {"Authentication, No Privacy", "authNoPriv"}, + {"Authentication + Privacy", "authPriv"} + ]} + /> +
+ + + <%= if selected_security_level in ["authNoPriv", "authPriv"] do %> +
+ <.input + field={@form[:snmpv3_auth_protocol]} + type="select" + label="Auth Protocol" + prompt="Select authentication algorithm" + options={[ + {"SHA-256 (recommended)", "SHA-256"}, + {"SHA-512", "SHA-512"}, + {"SHA-384", "SHA-384"}, + {"SHA-224", "SHA-224"}, + {"SHA (SHA-1)", "SHA"}, + {"MD5", "MD5"} + ]} + /> +
+ +
+ <.input + field={@form[:snmpv3_auth_password]} + type="password" + label="Auth Password" + placeholder="Min 8 characters" + autocomplete="off" + /> +
+ <% end %> + + + <%= if selected_security_level == "authPriv" do %> +
+ <.input + field={@form[:snmpv3_priv_protocol]} + type="select" + label="Privacy Protocol" + prompt="Select encryption algorithm" + options={[ + {"AES-128 (recommended)", "AES"}, + {"AES-256", "AES-256"}, + {"AES-192", "AES-192"}, + {"AES-256-C", "AES-256-C"}, + {"DES (legacy)", "DES"} + ]} + /> +
+ +
+ <.input + field={@form[:snmpv3_priv_password]} + type="password" + label="Privacy Password" + placeholder="Min 8 characters" + autocomplete="off" + /> +
+ <% end %> + <% end %>
<.input @@ -405,30 +512,6 @@ />
-
-
-
- <.icon - :if={@snmp_test_result.success} - name="hero-check-circle" - class="w-5 h-5" - /> - <.icon - :if={!@snmp_test_result.success} - name="hero-x-circle" - class="w-5 h-5" - /> - {@snmp_test_result.message} -
-
-
-
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing..."> @@ -445,6 +528,59 @@ <% end %>
+ + <%= if @snmp_test_result do %> + <%= cond do %> + <% Map.get(@snmp_test_result, :testing) -> %> +
+
+
+ <.icon + name="hero-arrow-path" + class="h-5 w-5 text-blue-400 animate-spin" + /> +
+
+

+ Testing SNMP credentials... +

+
+
+
+ <% Map.get(@snmp_test_result, :success) -> %> +
+
+
+ <.icon name="hero-check-circle" class="h-5 w-5 text-green-400" /> +
+
+

+ Connection successful! +

+

+ {Map.get(@snmp_test_result, :message)} +

+
+
+
+ <% true -> %> +
+
+
+ <.icon name="hero-x-circle" class="h-5 w-5 text-red-400" /> +
+
+

+ Connection failed +

+

+ {Map.get(@snmp_test_result, :message)} +

+
+
+
+ <% end %> + <% end %>
diff --git a/lib/towerops_web/live/org/settings_live.html.heex b/lib/towerops_web/live/org/settings_live.html.heex index 7b5abba2..f1b4940c 100644 --- a/lib/towerops_web/live/org/settings_live.html.heex +++ b/lib/towerops_web/live/org/settings_live.html.heex @@ -70,15 +70,91 @@ type="select" label="SNMP Version" prompt="Select SNMP version" - options={[{"SNMP v1", "1"}, {"SNMP v2c", "2c"}, {"SNMP v3", "3"}]} + options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]} /> + + + <%= if @form[:snmp_version].value in ["1", "2c"] do %> + <.input + field={@form[:snmp_community]} + type="text" + label="SNMP Community String" + placeholder="e.g., public" + /> + <% end %> + + + <%= if @form[:snmp_version].value == "3" do %> + <.input + field={@form[:snmpv3_security_level]} + type="select" + label="Security Level" + prompt="Select security level" + options={[ + {"No Auth, No Priv", "noAuthNoPriv"}, + {"Auth, No Priv", "authNoPriv"}, + {"Auth, Priv", "authPriv"} + ]} + /> - <.input - field={@form[:snmp_community]} - type="text" - label="SNMP Community String" - placeholder="e.g., public" - /> + <.input + field={@form[:snmpv3_username]} + type="text" + label="Username" + placeholder="e.g., snmpuser" + /> + + + <%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %> + <.input + field={@form[:snmpv3_auth_protocol]} + type="select" + label="Auth Protocol" + prompt="Select protocol" + options={[ + {"SHA-256 (recommended)", "SHA-256"}, + {"SHA-512", "SHA-512"}, + {"SHA-384", "SHA-384"}, + {"SHA-224", "SHA-224"}, + {"SHA (SHA-1)", "SHA"}, + {"MD5", "MD5"} + ]} + /> + + <.input + field={@form[:snmpv3_auth_password]} + type="password" + label="Auth Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + + + <%= if @form[:snmpv3_security_level].value == "authPriv" do %> + <.input + field={@form[:snmpv3_priv_protocol]} + type="select" + label="Privacy Protocol" + prompt="Select protocol" + options={[ + {"AES-128 (recommended)", "AES"}, + {"AES-256", "AES-256"}, + {"AES-192", "AES-192"}, + {"AES-256-C", "AES-256-C"}, + {"DES (legacy)", "DES"} + ]} + /> + + <.input + field={@form[:snmpv3_priv_password]} + type="password" + label="Privacy Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + <% end %> <.input field={@form[:snmp_port]} diff --git a/lib/towerops_web/live/site_live/form.html.heex b/lib/towerops_web/live/site_live/form.html.heex index 3e8af009..675a89ab 100644 --- a/lib/towerops_web/live/site_live/form.html.heex +++ b/lib/towerops_web/live/site_live/form.html.heex @@ -148,15 +148,91 @@ type="select" label="SNMP Version" prompt="Inherit from organization" - options={[{"SNMP v1", "1"}, {"SNMP v2c", "2c"}, {"SNMP v3", "3"}]} + options={[{"v1", "1"}, {"v2c", "2c"}, {"v3", "3"}]} /> + + + <%= if @form[:snmp_version].value in ["1", "2c"] do %> + <.input + field={@form[:snmp_community]} + type="text" + label="SNMP Community String" + placeholder="Leave blank to inherit from organization" + /> + <% end %> + + + <%= if @form[:snmp_version].value == "3" do %> + <.input + field={@form[:snmpv3_security_level]} + type="select" + label="Security Level" + prompt="Select security level" + options={[ + {"No Auth, No Priv", "noAuthNoPriv"}, + {"Auth, No Priv", "authNoPriv"}, + {"Auth, Priv", "authPriv"} + ]} + /> - <.input - field={@form[:snmp_community]} - type="text" - label="SNMP Community String" - placeholder="Leave blank to inherit from organization" - /> + <.input + field={@form[:snmpv3_username]} + type="text" + label="Username" + placeholder="Leave blank to inherit from organization" + /> + + + <%= if @form[:snmpv3_security_level].value in ["authNoPriv", "authPriv"] do %> + <.input + field={@form[:snmpv3_auth_protocol]} + type="select" + label="Auth Protocol" + prompt="Select protocol" + options={[ + {"SHA-256 (recommended)", "SHA-256"}, + {"SHA-512", "SHA-512"}, + {"SHA-384", "SHA-384"}, + {"SHA-224", "SHA-224"}, + {"SHA (SHA-1)", "SHA"}, + {"MD5", "MD5"} + ]} + /> + + <.input + field={@form[:snmpv3_auth_password]} + type="password" + label="Auth Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + + + <%= if @form[:snmpv3_security_level].value == "authPriv" do %> + <.input + field={@form[:snmpv3_priv_protocol]} + type="select" + label="Privacy Protocol" + prompt="Select protocol" + options={[ + {"AES-128 (recommended)", "AES"}, + {"AES-256", "AES-256"}, + {"AES-192", "AES-192"}, + {"AES-256-C", "AES-256-C"}, + {"DES (legacy)", "DES"} + ]} + /> + + <.input + field={@form[:snmpv3_priv_password]} + type="password" + label="Privacy Password" + placeholder="Min 8 characters" + autocomplete="off" + /> + <% end %> + <% end %> <.input field={@form[:snmp_port]} diff --git a/lib/towerops_web/plugs/detect_eu_user.ex b/lib/towerops_web/plugs/detect_eu_user.ex index f27a6a02..825c6f2d 100644 --- a/lib/towerops_web/plugs/detect_eu_user.ex +++ b/lib/towerops_web/plugs/detect_eu_user.ex @@ -27,6 +27,8 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do def call(conn, _opts) do require Logger + # Fetch cookies to check for existing consent + conn = fetch_cookies(conn) requires_consent = detect_eu_user(conn) # Store in process dictionary so layouts can access it @@ -38,13 +40,25 @@ defmodule ToweropsWeb.Plugs.DetectEUUser do end defp detect_eu_user(conn) do - # In development (localhost), always show the banner for testing - hostname = conn.host - - if hostname in ["localhost", "127.0.0.1"] do - true + # If user has already accepted cookies, don't show banner + if has_consent_cookie?(conn) do + false else - detect_country_from_headers(conn) + # In development (localhost), always show the banner for testing + hostname = conn.host + + if hostname in ["localhost", "127.0.0.1"] do + true + else + detect_country_from_headers(conn) + end + end + end + + defp has_consent_cookie?(conn) do + case conn.cookies["cookie_consent"] do + "accepted" -> true + _ -> false end end diff --git a/priv/proto/agent.proto b/priv/proto/agent.proto index 24a133c3..45149f1c 100644 --- a/priv/proto/agent.proto +++ b/priv/proto/agent.proto @@ -113,6 +113,7 @@ enum JobType { DISCOVER = 0; POLL = 1; MIKROTIK = 2; + TEST_CREDENTIALS = 3; } enum QueryType { @@ -136,9 +137,17 @@ message AgentJob { message SnmpDevice { string ip = 1; - string community = 2; + string community = 2; // v1/v2c only (deprecated for v3) string version = 3; uint32 port = 4; + + // SNMPv3 credentials (optional, backward compatible) + string v3_security_level = 5; // "noAuthNoPriv" | "authNoPriv" | "authPriv" + string v3_username = 6; + string v3_auth_protocol = 7; // "MD5" | "SHA" | "SHA-256" | etc. + string v3_auth_password = 8; // Decrypted before sending + string v3_priv_protocol = 9; // "DES" | "AES" | "AES-256" | etc. + string v3_priv_password = 10; // Decrypted before sending } message SnmpQuery { @@ -166,6 +175,14 @@ message AgentError { int64 timestamp = 3; } +message CredentialTestResult { + string test_id = 1; + bool success = 2; + string error_message = 3; // Empty if success + string system_description = 4; // sysDescr.0 value if success + int64 timestamp = 5; +} + // MikroTik RouterOS API messages message MikrotikDevice { diff --git a/priv/repo/migrations/20260203214942_add_snmpv3_to_organizations.exs b/priv/repo/migrations/20260203214942_add_snmpv3_to_organizations.exs new file mode 100644 index 00000000..8a6b9031 --- /dev/null +++ b/priv/repo/migrations/20260203214942_add_snmpv3_to_organizations.exs @@ -0,0 +1,25 @@ +defmodule Towerops.Repo.Migrations.AddSnmpv3ToOrganizations do + use Ecto.Migration + + def change do + alter table(:organizations) do + # SNMPv3 security level: "noAuthNoPriv", "authNoPriv", "authPriv" + add :snmpv3_security_level, :string + + # SNMPv3 username (context) + add :snmpv3_username, :string + + # Authentication protocol: "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" + add :snmpv3_auth_protocol, :string, default: "SHA-256" + + # Authentication password (encrypted with Cloak) + add :snmpv3_auth_password, :binary + + # Privacy protocol: "DES", "AES", "AES-192", "AES-256", "AES-256-C" + add :snmpv3_priv_protocol, :string, default: "AES" + + # Privacy password (encrypted with Cloak) + add :snmpv3_priv_password, :binary + end + end +end diff --git a/priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs b/priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs new file mode 100644 index 00000000..5c75f1db --- /dev/null +++ b/priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs @@ -0,0 +1,26 @@ +defmodule Towerops.Repo.Migrations.AddSnmpv3ToSites do + use Ecto.Migration + + def change do + alter table(:sites) do + # SNMPv3 fields - all nullable for inheritance from organization + # Security level: "noAuthNoPriv", "authNoPriv", "authPriv" + add :snmpv3_security_level, :string + + # SNMPv3 username + add :snmpv3_username, :string + + # Authentication protocol: "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" + add :snmpv3_auth_protocol, :string + + # Authentication password (encrypted with Cloak) + add :snmpv3_auth_password, :binary + + # Privacy protocol: "DES", "AES", "AES-192", "AES-256", "AES-256-C" + add :snmpv3_priv_protocol, :string + + # Privacy password (encrypted with Cloak) + add :snmpv3_priv_password, :binary + end + end +end diff --git a/priv/repo/migrations/20260203215100_add_snmpv3_to_devices.exs b/priv/repo/migrations/20260203215100_add_snmpv3_to_devices.exs new file mode 100644 index 00000000..18a56912 --- /dev/null +++ b/priv/repo/migrations/20260203215100_add_snmpv3_to_devices.exs @@ -0,0 +1,29 @@ +defmodule Towerops.Repo.Migrations.AddSnmpv3ToDevices do + use Ecto.Migration + + def change do + alter table(:devices) do + # SNMPv3 fields - all nullable for inheritance from site/organization + # Security level: "noAuthNoPriv", "authNoPriv", "authPriv" + add :snmpv3_security_level, :string + + # SNMPv3 username + add :snmpv3_username, :string + + # Authentication protocol: "MD5", "SHA", "SHA-224", "SHA-256", "SHA-384", "SHA-512" + add :snmpv3_auth_protocol, :string + + # Authentication password (encrypted with Cloak) + add :snmpv3_auth_password, :binary + + # Privacy protocol: "DES", "AES", "AES-192", "AES-256", "AES-256-C" + add :snmpv3_priv_protocol, :string + + # Privacy password (encrypted with Cloak) + add :snmpv3_priv_password, :binary + + # Track credential source: "device", "site", "organization" + add :snmpv3_credential_source, :string, default: "site" + end + end +end diff --git a/test/towerops/organizations_test.exs b/test/towerops/organizations_test.exs index 09bef387..ce7894f1 100644 --- a/test/towerops/organizations_test.exs +++ b/test/towerops/organizations_test.exs @@ -420,7 +420,7 @@ defmodule Towerops.OrganizationsTest do {:ok, organization} = Organizations.update_organization(organization, %{ - snmp_version: "3", + snmp_version: "2c", snmp_community: "new-community" }) @@ -454,11 +454,11 @@ defmodule Towerops.OrganizationsTest do assert count == 2 updated_device1 = Repo.get!(Device, device1.id) - assert updated_device1.snmp_version == "3" + assert updated_device1.snmp_version == "2c" assert updated_device1.snmp_community == "new-community" updated_device2 = Repo.get!(Device, device2.id) - assert updated_device2.snmp_version == "3" + assert updated_device2.snmp_version == "2c" assert updated_device2.snmp_community == "new-community" end diff --git a/test/towerops_web/plugs/detect_eu_user_test.exs b/test/towerops_web/plugs/detect_eu_user_test.exs index 5ffaf648..d3c6a256 100644 --- a/test/towerops_web/plugs/detect_eu_user_test.exs +++ b/test/towerops_web/plugs/detect_eu_user_test.exs @@ -91,4 +91,61 @@ defmodule ToweropsWeb.Plugs.DetectEUUserTest do assert conn.assigns.requires_cookie_consent == true end end + + describe "call/2 - cookie consent check" do + test "does not require consent when cookie_consent=accepted cookie exists" do + conn = + build_conn() + |> Map.put(:host, "localhost") + |> put_req_cookie("cookie_consent", "accepted") + |> Plug.Test.init_test_session(%{}) + |> DetectEUUser.call([]) + + # Should not require consent even though localhost normally would + assert conn.assigns.requires_cookie_consent == false + assert get_session(conn, :requires_cookie_consent) == false + assert Process.get(:requires_cookie_consent) == false + end + + test "requires consent when cookie_consent cookie is missing" do + conn = + build_conn() + |> Map.put(:host, "localhost") + |> Plug.Test.init_test_session(%{}) + |> DetectEUUser.call([]) + + # Should require consent (normal localhost behavior) + assert conn.assigns.requires_cookie_consent == true + assert get_session(conn, :requires_cookie_consent) == true + assert Process.get(:requires_cookie_consent) == true + end + + test "requires consent when cookie_consent cookie has wrong value" do + conn = + build_conn() + |> Map.put(:host, "localhost") + |> put_req_cookie("cookie_consent", "rejected") + |> Plug.Test.init_test_session(%{}) + |> DetectEUUser.call([]) + + # Should require consent (cookie value is not "accepted") + assert conn.assigns.requires_cookie_consent == true + assert get_session(conn, :requires_cookie_consent) == true + assert Process.get(:requires_cookie_consent) == true + end + + test "does not require consent for non-localhost with accepted cookie" do + conn = + build_conn() + |> Map.put(:host, "example.com") + |> put_req_cookie("cookie_consent", "accepted") + |> Plug.Test.init_test_session(%{}) + |> DetectEUUser.call([]) + + # Should not require consent even though GeoIP would return nil (conservative default) + assert conn.assigns.requires_cookie_consent == false + assert get_session(conn, :requires_cookie_consent) == false + assert Process.get(:requires_cookie_consent) == false + end + end end