Ecto schemas for persistent topology model. DeviceLink tracks connections between devices with confidence scores. DeviceLinkEvidence records why each link is believed to exist. Device schema gets device_role and device_role_source fields.
451 lines
15 KiB
Elixir
451 lines
15 KiB
Elixir
defmodule Towerops.Devices.Device do
|
|
@moduledoc """
|
|
Device schema for network devices being monitored.
|
|
|
|
Devices belong to a site and organization, and can have:
|
|
- SNMP configuration (community strings, polling settings)
|
|
- Associated SNMP device record (interfaces, sensors)
|
|
- Agent assignments for remote polling
|
|
- Monitoring checks and alerts
|
|
"""
|
|
use Ecto.Schema
|
|
|
|
import Ecto.Changeset
|
|
|
|
alias Ecto.Association.NotLoaded
|
|
alias Towerops.Agents.AgentAssignment
|
|
alias Towerops.Encrypted.Binary
|
|
alias Towerops.Organizations.Organization
|
|
alias Towerops.Sites.Site
|
|
alias Towerops.Snmp.Device, as: SnmpDevice
|
|
alias Towerops.Topology.DeviceLink
|
|
|
|
@primary_key {:id, :binary_id, autogenerate: true}
|
|
@foreign_key_type :binary_id
|
|
schema "devices" do
|
|
field :name, :string
|
|
field :ip_address, :string
|
|
field :description, :string
|
|
field :display_order, :integer
|
|
field :status, Ecto.Enum, values: [:up, :down, :unknown], default: :unknown
|
|
field :last_checked_at, :utc_datetime
|
|
field :last_status_change_at, :utc_datetime
|
|
field :monitoring_enabled, :boolean, default: true
|
|
field :check_interval_seconds, :integer, default: 300
|
|
|
|
# SNMP fields
|
|
field :snmp_enabled, :boolean, default: true
|
|
field :snmp_version, :string, default: "2c"
|
|
field :snmp_community, :string
|
|
field :snmp_community_source, :string, default: "organization"
|
|
field :snmp_port, :integer, default: 161
|
|
field :snmp_transport, :string, default: "udp"
|
|
field :snmp_transport_source, :string, default: "organization"
|
|
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, :string
|
|
field :mikrotik_password, Binary
|
|
field :mikrotik_port, :integer
|
|
field :mikrotik_ssh_port, :integer
|
|
field :mikrotik_use_ssl, :boolean
|
|
field :mikrotik_enabled, :boolean
|
|
field :mikrotik_credential_source, :string, default: "site"
|
|
|
|
# Topology role (auto-inferred or manually set)
|
|
field :device_role, :string
|
|
field :device_role_source, :string, default: "inferred"
|
|
|
|
belongs_to :site, Site
|
|
belongs_to :organization, Organization
|
|
|
|
has_one :snmp_device, SnmpDevice
|
|
has_many :agent_assignments, AgentAssignment
|
|
has_many :outbound_links, DeviceLink, foreign_key: :source_device_id
|
|
has_many :inbound_links, DeviceLink, foreign_key: :target_device_id
|
|
|
|
timestamps(type: :utc_datetime)
|
|
end
|
|
|
|
@type t :: %__MODULE__{
|
|
id: Ecto.UUID.t(),
|
|
name: String.t() | nil,
|
|
ip_address: String.t(),
|
|
description: String.t() | nil,
|
|
display_order: integer() | nil,
|
|
status: :up | :down | :unknown,
|
|
last_checked_at: DateTime.t() | nil,
|
|
last_status_change_at: DateTime.t() | nil,
|
|
monitoring_enabled: boolean(),
|
|
check_interval_seconds: integer(),
|
|
snmp_enabled: boolean(),
|
|
snmp_version: String.t(),
|
|
snmp_community: String.t() | nil,
|
|
snmp_community_source: String.t(),
|
|
snmp_port: integer(),
|
|
snmp_transport: String.t(),
|
|
snmp_transport_source: String.t(),
|
|
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,
|
|
mikrotik_ssh_port: integer() | nil,
|
|
mikrotik_use_ssl: boolean() | nil,
|
|
mikrotik_enabled: boolean() | nil,
|
|
mikrotik_credential_source: String.t() | nil,
|
|
device_role: String.t() | nil,
|
|
device_role_source: String.t(),
|
|
site_id: Ecto.UUID.t() | nil,
|
|
site: NotLoaded.t() | Site.t() | nil,
|
|
organization_id: Ecto.UUID.t(),
|
|
organization: NotLoaded.t() | Organization.t() | nil,
|
|
snmp_device: NotLoaded.t() | SnmpDevice.t() | nil,
|
|
agent_assignments: NotLoaded.t() | [AgentAssignment.t()],
|
|
outbound_links: NotLoaded.t() | [DeviceLink.t()],
|
|
inbound_links: NotLoaded.t() | [DeviceLink.t()],
|
|
inserted_at: DateTime.t(),
|
|
updated_at: DateTime.t()
|
|
}
|
|
|
|
@doc false
|
|
def changeset(device, attrs) do
|
|
device
|
|
|> cast(attrs, [
|
|
:name,
|
|
:ip_address,
|
|
:description,
|
|
:display_order,
|
|
:site_id,
|
|
:organization_id,
|
|
:monitoring_enabled,
|
|
:check_interval_seconds,
|
|
:snmp_enabled,
|
|
:snmp_version,
|
|
:snmp_community,
|
|
:snmp_community_source,
|
|
:snmp_port,
|
|
:snmp_transport,
|
|
:snmp_transport_source,
|
|
:status,
|
|
:last_checked_at,
|
|
: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,
|
|
:mikrotik_ssh_port,
|
|
:mikrotik_use_ssl,
|
|
:mikrotik_enabled,
|
|
:mikrotik_credential_source,
|
|
:device_role,
|
|
:device_role_source
|
|
])
|
|
|> validate_required([:ip_address, :organization_id])
|
|
|> validate_name()
|
|
|> validate_length(:name, min: 2, max: 200)
|
|
|> validate_length(:description, max: 1000)
|
|
|> validate_ip_address()
|
|
|> validate_number(:check_interval_seconds, greater_than: 0, less_than_or_equal_to: 3600)
|
|
|> validate_snmp()
|
|
|> validate_snmpv3_fields()
|
|
|> validate_mikrotik()
|
|
|> validate_site_belongs_to_organization()
|
|
|> update_community_source()
|
|
|> update_transport_source()
|
|
|> update_snmpv3_credential_source()
|
|
|> update_mikrotik_credential_source()
|
|
|> foreign_key_constraint(:site_id)
|
|
|> foreign_key_constraint(:organization_id)
|
|
end
|
|
|
|
defp validate_name(changeset) do
|
|
snmp_enabled = get_field(changeset, :snmp_enabled, false)
|
|
name = get_field(changeset, :name)
|
|
|
|
# Name is required only when SNMP is not enabled (ICMP only mode)
|
|
# When SNMP is enabled, name can be empty and will be populated from sysName during discovery
|
|
if !snmp_enabled and (is_nil(name) or name == "") do
|
|
add_error(changeset, :name, "can't be blank (required when SNMP is disabled)")
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_ip_address(changeset) do
|
|
case get_change(changeset, :ip_address) do
|
|
nil ->
|
|
changeset
|
|
|
|
ip_string ->
|
|
case parse_ip(ip_string) do
|
|
{:ok, _} ->
|
|
changeset
|
|
|
|
{:error, _} ->
|
|
add_error(changeset, :ip_address, "must be a valid IPv4 or IPv6 address")
|
|
end
|
|
end
|
|
end
|
|
|
|
defp parse_ip(ip_string) do
|
|
ip_string
|
|
|> String.to_charlist()
|
|
|> :inet.parse_address()
|
|
end
|
|
|
|
defp validate_site_belongs_to_organization(changeset) do
|
|
site_id = get_field(changeset, :site_id)
|
|
org_id = get_field(changeset, :organization_id)
|
|
|
|
# If no site_id or org_id, no validation needed
|
|
if !site_id || !org_id do
|
|
changeset
|
|
else
|
|
# Verify that the site belongs to the same organization
|
|
case Towerops.Repo.get(Site, site_id) do
|
|
nil -> add_error(changeset, :site_id, "does not exist")
|
|
%{organization_id: ^org_id} -> changeset
|
|
_site -> add_error(changeset, :site_id, "must belong to the same organization")
|
|
end
|
|
end
|
|
end
|
|
|
|
defp validate_snmp(changeset) do
|
|
snmp_enabled = get_field(changeset, :snmp_enabled)
|
|
|
|
if snmp_enabled do
|
|
changeset
|
|
|> validate_required([:snmp_version], message: "required when SNMP is enabled")
|
|
|> 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_inclusion(:snmp_transport, ["udp", "tcp"], message: "must be udp or tcp")
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp update_community_source(changeset) do
|
|
# If user explicitly set a community string, mark source as "device"
|
|
# If it's blank/nil, mark source as "site" (will inherit from site or org)
|
|
case get_change(changeset, :snmp_community) do
|
|
nil ->
|
|
# No change to community, keep existing source
|
|
changeset
|
|
|
|
"" ->
|
|
# Explicitly cleared, set to inherit from site
|
|
put_change(changeset, :snmp_community_source, "site")
|
|
|
|
_value ->
|
|
# Explicitly set a value, mark as device-specific
|
|
put_change(changeset, :snmp_community_source, "device")
|
|
end
|
|
end
|
|
|
|
defp update_transport_source(changeset) do
|
|
# If user explicitly set a transport, mark source as "device"
|
|
# If it's blank/nil, mark source as "organization" (will inherit from site or org)
|
|
case get_change(changeset, :snmp_transport) do
|
|
nil ->
|
|
# No change to transport, keep existing source
|
|
changeset
|
|
|
|
"" ->
|
|
# Explicitly cleared, set to inherit from organization
|
|
put_change(changeset, :snmp_transport_source, "organization")
|
|
|
|
_value ->
|
|
# Explicitly set a value, mark as device-specific
|
|
put_change(changeset, :snmp_transport_source, "device")
|
|
end
|
|
end
|
|
|
|
defp validate_mikrotik(changeset) do
|
|
mikrotik_enabled = get_field(changeset, :mikrotik_enabled)
|
|
|
|
if mikrotik_enabled do
|
|
changeset
|
|
|> validate_number(:mikrotik_port, greater_than: 0, less_than: 65_536)
|
|
|> validate_mikrotik_ssl_for_cloud()
|
|
else
|
|
changeset
|
|
end
|
|
end
|
|
|
|
defp validate_mikrotik_ssl_for_cloud(changeset) do
|
|
# If explicitly setting use_ssl to false at device level, ensure not using cloud poller
|
|
use_ssl = get_field(changeset, :mikrotik_use_ssl)
|
|
|
|
# Only validate if explicitly disabling SSL (nil means inherit)
|
|
if use_ssl == false do
|
|
# Note: We can't check effective agent here during changeset validation
|
|
# as it requires database queries. This validation happens at the context level.
|
|
# Here we just ensure the field is set if provided.
|
|
changeset
|
|
else
|
|
changeset
|
|
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)
|
|
username_change = get_change(changeset, :mikrotik_username)
|
|
current_username = get_field(changeset, :mikrotik_username)
|
|
|
|
cond do
|
|
# Explicitly set to empty string - clear and inherit
|
|
username_change == "" ->
|
|
changeset
|
|
|> put_change(:mikrotik_username, nil)
|
|
|> put_change(:mikrotik_credential_source, "site")
|
|
|
|
# Explicitly set to a value - mark as device-specific
|
|
username_change != nil && username_change != "" ->
|
|
put_change(changeset, :mikrotik_credential_source, "device")
|
|
|
|
# No change to username, but check if we should reset source
|
|
# (This handles cases where other MikroTik fields are cleared)
|
|
username_change == nil && (current_username == nil || current_username == "") &&
|
|
get_field(changeset, :mikrotik_credential_source) == "device" ->
|
|
put_change(changeset, :mikrotik_credential_source, "site")
|
|
|
|
# No change, keep existing
|
|
true ->
|
|
changeset
|
|
end
|
|
end
|
|
end
|