towerops/lib/towerops/devices/device.ex
Graham McIntire 29abb688d2
feat: add DeviceLink and DeviceLinkEvidence schemas
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.
2026-02-12 12:49:11 -06:00

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