snmp v3 support

This commit is contained in:
Graham McIntire 2026-02-04 12:02:38 -06:00
parent 3583624e0d
commit c93144cc37
No known key found for this signature in database
20 changed files with 1492 additions and 245 deletions

View file

@ -864,6 +864,75 @@ Template change:
</div>
```
### 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.

View file

@ -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.

View file

@ -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)

View file

@ -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)

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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),

View file

@ -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

View file

@ -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

View file

@ -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"}]}
/>
</div>
<div class="col-span-full">
<!-- v1/v2c Community String -->
<div
:if={@form[:snmp_version].value in ["1", "2c"]}
class="col-span-full"
>
<.input
field={@form[:snmp_community]}
type="text"
@ -393,6 +397,109 @@
<% end %>
<% end %>
</div>
<!-- v3 Credentials -->
<%= if @form[:snmp_version].value == "3" do %>
<div class="col-span-full">
<.input
field={@form[:snmpv3_username]}
type="text"
label="Username"
placeholder="Leave blank to inherit from site/organization"
/>
</div>
<% # 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 %>
<!-- Auth Level Selector (ALWAYS VISIBLE) -->
<div class="col-span-full">
<.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"}
]}
/>
</div>
<!-- Auth fields (shown for authNoPriv and authPriv) -->
<%= if selected_security_level in ["authNoPriv", "authPriv"] do %>
<div class="col-span-full">
<.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"}
]}
/>
</div>
<div class="col-span-full">
<.input
field={@form[:snmpv3_auth_password]}
type="password"
label="Auth Password"
placeholder="Min 8 characters"
autocomplete="off"
/>
</div>
<% end %>
<!-- Privacy fields (shown for authPriv only) -->
<%= if selected_security_level == "authPriv" do %>
<div class="col-span-full">
<.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"}
]}
/>
</div>
<div class="col-span-full">
<.input
field={@form[:snmpv3_priv_password]}
type="password"
label="Privacy Password"
placeholder="Min 8 characters"
autocomplete="off"
/>
</div>
<% end %>
<% end %>
<div class="col-span-full">
<.input
@ -405,30 +512,6 @@
/>
</div>
<div :if={@snmp_test_result} class="col-span-full">
<div class={[
"rounded-md p-3",
@snmp_test_result.success &&
"bg-green-50 text-green-800 dark:bg-green-900/20 dark:text-green-400",
!@snmp_test_result.success &&
"bg-red-50 text-red-800 dark:bg-red-900/20 dark:text-red-400"
]}>
<div class="flex items-center gap-2">
<.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"
/>
<span class="text-sm font-medium">{@snmp_test_result.message}</span>
</div>
</div>
</div>
<div class="col-span-full">
<div class="flex gap-3">
<.button type="button" phx-click="test_snmp" phx-disable-with="Testing...">
@ -445,6 +528,59 @@
</.button>
<% end %>
</div>
<%= if @snmp_test_result do %>
<%= cond do %>
<% Map.get(@snmp_test_result, :testing) -> %>
<div class="mt-3 rounded-md bg-blue-50 p-4 dark:bg-blue-900/20">
<div class="flex items-center">
<div class="shrink-0">
<.icon
name="hero-arrow-path"
class="h-5 w-5 text-blue-400 animate-spin"
/>
</div>
<div class="ml-3">
<p class="text-sm text-blue-700 dark:text-blue-400">
Testing SNMP credentials...
</p>
</div>
</div>
</div>
<% Map.get(@snmp_test_result, :success) -> %>
<div class="mt-3 rounded-md bg-green-50 p-4 dark:bg-green-900/20">
<div class="flex items-start">
<div class="shrink-0">
<.icon name="hero-check-circle" class="h-5 w-5 text-green-400" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-green-800 dark:text-green-400">
Connection successful!
</p>
<p class="mt-1 text-sm text-green-700 dark:text-green-300">
{Map.get(@snmp_test_result, :message)}
</p>
</div>
</div>
</div>
<% true -> %>
<div class="mt-3 rounded-md bg-red-50 p-4 dark:bg-red-900/20">
<div class="flex items-start">
<div class="shrink-0">
<.icon name="hero-x-circle" class="h-5 w-5 text-red-400" />
</div>
<div class="ml-3">
<p class="text-sm font-medium text-red-800 dark:text-red-400">
Connection failed
</p>
<p class="mt-1 text-sm text-red-700 dark:text-red-300">
{Map.get(@snmp_test_result, :message)}
</p>
</div>
</div>
</div>
<% end %>
<% end %>
</div>
</div>
</div>

View file

@ -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"}]}
/>
<!-- v1/v2c Community String -->
<%= 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 %>
<!-- v3 Credentials -->
<%= 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"
/>
<!-- Auth fields (shown for authNoPriv and authPriv) -->
<%= 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 %>
<!-- Priv fields (shown only for authPriv) -->
<%= 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]}

View file

@ -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"}]}
/>
<!-- v1/v2c Community String -->
<%= 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 %>
<!-- v3 Credentials -->
<%= 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"
/>
<!-- Auth fields (shown for authNoPriv and authPriv) -->
<%= 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 %>
<!-- Priv fields (shown only for authPriv) -->
<%= 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]}

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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