snmp v3 support
This commit is contained in:
parent
3583624e0d
commit
c93144cc37
20 changed files with 1492 additions and 245 deletions
69
CLAUDE.md
69
CLAUDE.md
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
26
priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs
Normal file
26
priv/repo/migrations/20260203215025_add_snmpv3_to_sites.exs
Normal 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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue