- help_live/index.ex: 2,642→173 lines, 15 section modules + sidebar extracted MikroTik now real section, dead if false removed - agent_channel.ex: 2,506→1,751 lines, 3 helper modules extracted (heartbeat/subscriptions/job_builder), decode_and_process/4 eliminates 8 repeated handle_in patterns, guard-based size checks - antenna_catalog.ex: 1,174→47 lines, 107 specs → priv/antennas/catalog.json - topology.ex: unbounded query → batched loading (100/batch), all guard errors fixed, zero if/case/cond conditionals - proto: decoder_macros.ex + field_specs.ex infrastructure for macro-generated protobuf decoders
589 lines
16 KiB
Elixir
589 lines
16 KiB
Elixir
defmodule ToweropsWeb.AgentChannel.JobBuilder do
|
|
@moduledoc """
|
|
Builds protobuf job messages for agent channels.
|
|
|
|
Provides functions to construct `AgentJob` and `CheckList` protobuf messages
|
|
from device and check data, handling SNMP credential resolution, discovery
|
|
vs. polling job selection, and MikroTik backup job construction.
|
|
"""
|
|
|
|
alias Towerops.Agent.AgentJob
|
|
alias Towerops.Agent.Check, as: CheckProto
|
|
alias Towerops.Agent.CheckList
|
|
alias Towerops.Agent.DnsCheckConfig
|
|
alias Towerops.Agent.HttpCheckConfig
|
|
alias Towerops.Agent.MikrotikCommand
|
|
alias Towerops.Agent.MikrotikDevice
|
|
alias Towerops.Agent.SnmpDevice
|
|
alias Towerops.Agent.SnmpQuery
|
|
alias Towerops.Agent.SslCheckConfig
|
|
alias Towerops.Agent.TcpCheckConfig
|
|
alias Towerops.Agents
|
|
alias Towerops.Devices
|
|
alias Towerops.Monitoring
|
|
|
|
require Logger
|
|
|
|
@doc """
|
|
Builds all jobs for an agent based on assigned polling targets.
|
|
"""
|
|
@spec build_jobs_for_agent(Ecto.UUID.t()) :: [AgentJob.t()]
|
|
def build_jobs_for_agent(agent_token_id) do
|
|
agent_token_id
|
|
|> Agents.list_agent_polling_targets()
|
|
|> Enum.flat_map(&build_jobs_for_device/1)
|
|
end
|
|
|
|
@doc """
|
|
Builds all jobs for a single device (SNMP discovery/polling, MikroTik, ping).
|
|
"""
|
|
@spec build_jobs_for_device(map()) :: [AgentJob.t()]
|
|
def build_jobs_for_device(device) do
|
|
[]
|
|
|> maybe_put_snmp_job(device)
|
|
|> maybe_put_mikrotik_job(device)
|
|
|> maybe_put_ping_job(device)
|
|
|> Enum.reject(&is_nil/1)
|
|
end
|
|
|
|
defp maybe_put_snmp_job(jobs, %{snmp_enabled: true} = device) do
|
|
[snmp_job_for_device(device) | jobs]
|
|
end
|
|
|
|
defp maybe_put_snmp_job(jobs, _device), do: jobs
|
|
|
|
defp snmp_job_for_device(device) do
|
|
do_snmp_job(needs_discovery?(device), device)
|
|
end
|
|
|
|
defp do_snmp_job(true, device), do: build_discovery_job(device)
|
|
defp do_snmp_job(false, device), do: build_polling_job(device)
|
|
|
|
defp maybe_put_mikrotik_job(jobs, %{snmp_enabled: true} = device) do
|
|
[mikrotik_conditional_job(device) | jobs]
|
|
end
|
|
|
|
defp maybe_put_mikrotik_job(jobs, _device), do: jobs
|
|
|
|
defp mikrotik_conditional_job(device) do
|
|
do_mikrotik_job(needs_discovery?(device), mikrotik_device?(device), device)
|
|
end
|
|
|
|
defp do_mikrotik_job(true, true, device), do: build_mikrotik_job(device)
|
|
defp do_mikrotik_job(_, _, _device), do: nil
|
|
|
|
defp maybe_put_ping_job(jobs, %{monitoring_enabled: true} = device) do
|
|
[build_ping_job(device) | jobs]
|
|
end
|
|
|
|
defp maybe_put_ping_job(jobs, _device), do: jobs
|
|
|
|
@doc """
|
|
Determines if a device needs SNMP discovery.
|
|
Returns `true` if no SNMP device, no previous discovery, or last discovery >24h ago.
|
|
"""
|
|
@spec needs_discovery?(map()) :: boolean()
|
|
def needs_discovery?(device) do
|
|
is_nil(device.snmp_device) or
|
|
is_nil(device.last_discovery_at) or
|
|
max(DateTime.diff(DateTime.utc_now(), device.last_discovery_at, :hour), 0) > 24
|
|
end
|
|
|
|
@doc """
|
|
Checks if a device is a MikroTik device based on SNMP discovery data.
|
|
"""
|
|
@spec mikrotik_device?(map()) :: boolean()
|
|
def mikrotik_device?(device) do
|
|
device.snmp_device &&
|
|
(String.contains?(device.snmp_device.manufacturer || "", "MikroTik") ||
|
|
String.contains?(device.snmp_device.sys_descr || "", "RouterOS"))
|
|
end
|
|
|
|
@doc """
|
|
Resolves SNMP credentials for a device.
|
|
|
|
For SNMPv3, delegates to `Devices.get_snmpv3_config/1` for credential cascade.
|
|
For v1/v2c, returns community string and version.
|
|
"""
|
|
@spec resolve_snmp_credentials(map()) :: map()
|
|
def resolve_snmp_credentials(device) do
|
|
do_resolve_snmp_credentials(device.snmp_version, device)
|
|
end
|
|
|
|
defp do_resolve_snmp_credentials("3", device), do: Devices.get_snmpv3_config(device)
|
|
|
|
defp do_resolve_snmp_credentials(_version, device) do
|
|
%{
|
|
community: Devices.resolve_snmp_community(device),
|
|
version: device.snmp_version
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Checks if SNMP credentials are present (not nil/empty).
|
|
"""
|
|
@spec credentials_present?(map()) :: boolean()
|
|
def credentials_present?(%{community: community}) when is_binary(community), do: community != ""
|
|
def credentials_present?(%{username: username}) when is_binary(username), do: username != ""
|
|
def credentials_present?(_), do: false
|
|
|
|
@doc """
|
|
Builds an `SnmpDevice` protobuf message with appropriate credentials for the given
|
|
device and SNMP config.
|
|
"""
|
|
@spec build_snmp_device_message(map(), map()) :: SnmpDevice.t()
|
|
def build_snmp_device_message(device, snmp_config) do
|
|
do_build_snmp_device_message(device.snmp_version, device, snmp_config)
|
|
end
|
|
|
|
defp do_build_snmp_device_message("3", device, snmp_config) do
|
|
log_v3_device_build(device, snmp_config)
|
|
|
|
%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 do_build_snmp_device_message(_version, device, snmp_config) do
|
|
community = snmp_config.community || ""
|
|
version = effective_snmp_version(device.snmp_version)
|
|
|
|
%SnmpDevice{
|
|
ip: device.ip_address,
|
|
version: version,
|
|
port: device.snmp_port || 161,
|
|
community: community
|
|
}
|
|
end
|
|
|
|
defp effective_snmp_version("1"), do: "1"
|
|
defp effective_snmp_version(_), do: "2c"
|
|
|
|
defp log_v3_device_build(device, snmp_config) do
|
|
auth_password_present =
|
|
is_binary(snmp_config.auth_password) and snmp_config.auth_password != ""
|
|
|
|
priv_password_present =
|
|
is_binary(snmp_config.priv_password) and snmp_config.priv_password != ""
|
|
|
|
Logger.info(
|
|
"Building SNMPv3 device message",
|
|
device_id: device.id,
|
|
device_name: device.name,
|
|
device_ip: device.ip_address,
|
|
security_level: snmp_config.security_level || "",
|
|
username: snmp_config.username || "",
|
|
auth_protocol: snmp_config.auth_protocol || "",
|
|
auth_password_present: auth_password_present,
|
|
priv_protocol: snmp_config.priv_protocol || "",
|
|
priv_password_present: priv_password_present
|
|
)
|
|
end
|
|
|
|
@doc """
|
|
Builds a discovery job for a device.
|
|
"""
|
|
@spec build_discovery_job(map()) :: AgentJob.t()
|
|
def build_discovery_job(device) do
|
|
snmp_credentials = resolve_snmp_credentials(device)
|
|
|
|
%AgentJob{
|
|
job_id: "discover:#{device.id}",
|
|
job_type: :DISCOVER,
|
|
device_id: device.id,
|
|
snmp_device: build_snmp_device_message(device, snmp_credentials),
|
|
queries: build_discovery_queries()
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Builds a polling job for a device.
|
|
"""
|
|
@spec build_polling_job(map()) :: AgentJob.t()
|
|
def build_polling_job(device) do
|
|
snmp_credentials = resolve_snmp_credentials(device)
|
|
|
|
%AgentJob{
|
|
job_id: "poll:#{device.id}",
|
|
job_type: :POLL,
|
|
device_id: device.id,
|
|
snmp_device: build_snmp_device_message(device, snmp_credentials),
|
|
queries: build_polling_queries(device)
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Builds a ping job for a device.
|
|
"""
|
|
@spec build_ping_job(map()) :: AgentJob.t()
|
|
def build_ping_job(device) do
|
|
%AgentJob{
|
|
job_id: "ping:#{device.id}",
|
|
job_type: :PING,
|
|
device_id: device.id,
|
|
snmp_device: %SnmpDevice{
|
|
ip: to_string(device.ip_address),
|
|
port: 0,
|
|
version: "",
|
|
community: ""
|
|
},
|
|
queries: []
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Builds a live polling job with only requested sensor OIDs.
|
|
"""
|
|
@spec build_live_polling_job(map(), [String.t()], String.t()) :: AgentJob.t()
|
|
def build_live_polling_job(device, sensor_oids, reply_topic) do
|
|
snmp_credentials = resolve_snmp_credentials(device)
|
|
|
|
%AgentJob{
|
|
job_id: "live_poll:#{device.id}:#{reply_topic}",
|
|
job_type: :POLL,
|
|
device_id: device.id,
|
|
snmp_device: build_snmp_device_message(device, snmp_credentials),
|
|
queries: [
|
|
%SnmpQuery{
|
|
query_type: :GET,
|
|
oids: sensor_oids
|
|
}
|
|
]
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Builds the list of SNMP queries for device discovery.
|
|
"""
|
|
@spec build_discovery_queries() :: [SnmpQuery.t()]
|
|
def build_discovery_queries do
|
|
[
|
|
%SnmpQuery{
|
|
query_type: :GET,
|
|
oids: [
|
|
"1.3.6.1.2.1.1.1.0",
|
|
"1.3.6.1.2.1.1.2.0",
|
|
"1.3.6.1.2.1.1.3.0",
|
|
"1.3.6.1.2.1.1.4.0",
|
|
"1.3.6.1.2.1.1.5.0",
|
|
"1.3.6.1.2.1.1.6.0"
|
|
]
|
|
},
|
|
%SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]},
|
|
%SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.31.1.1.1"]},
|
|
%SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.99.1.1.1",
|
|
"1.3.6.1.2.1.47.1.1.1.1.2",
|
|
"1.3.6.1.2.1.47.1.1.1.1.7",
|
|
"1.3.6.1.2.1.47.1.1.1.1.5"
|
|
]
|
|
},
|
|
%SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.131.1.1.1.1"]},
|
|
%SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.25.3.3",
|
|
"1.3.6.1.2.1.25.2.3",
|
|
"1.3.6.1.2.1.25.3.2"
|
|
]
|
|
},
|
|
%SnmpQuery{
|
|
query_type: :GET,
|
|
oids: [
|
|
"1.3.6.1.4.1.2021.11.9.0",
|
|
"1.3.6.1.4.1.2021.11.10.0",
|
|
"1.3.6.1.4.1.2021.11.11.0"
|
|
]
|
|
},
|
|
%SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.4.1.9.9.109",
|
|
"1.3.6.1.4.1.9.9.13",
|
|
"1.3.6.1.4.1.9.9.23",
|
|
"1.3.6.1.4.1.9.9.618",
|
|
"1.3.6.1.4.1.14988",
|
|
"1.3.6.1.4.1.41112",
|
|
"1.3.6.1.4.1.2636",
|
|
"1.3.6.1.4.1.25506",
|
|
"1.3.6.1.4.1.12356",
|
|
"1.3.6.1.4.1.17713"
|
|
]
|
|
},
|
|
%SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.0.8802.1.1.2.1.4.1.1",
|
|
"1.3.6.1.4.1.9.9.23"
|
|
]
|
|
},
|
|
%SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.4.20",
|
|
"1.3.6.1.2.1.4.34"
|
|
]
|
|
}
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Builds the list of SNMP queries for regular polling of a device.
|
|
"""
|
|
@spec build_polling_queries(map()) :: [SnmpQuery.t()]
|
|
def build_polling_queries(device) do
|
|
sensor_oids = Enum.map(device.snmp_device.sensors, & &1.sensor_oid)
|
|
|
|
interface_oids =
|
|
Enum.flat_map(device.snmp_device.interfaces, fn iface ->
|
|
idx = iface.if_index
|
|
|
|
[
|
|
"1.3.6.1.2.1.31.1.1.1.6.#{idx}",
|
|
"1.3.6.1.2.1.31.1.1.1.10.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.10.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.16.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.14.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.20.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.13.#{idx}",
|
|
"1.3.6.1.2.1.2.2.1.19.#{idx}"
|
|
]
|
|
end)
|
|
|
|
base_queries = [
|
|
%SnmpQuery{
|
|
query_type: :GET,
|
|
oids: sensor_oids ++ interface_oids
|
|
}
|
|
]
|
|
|
|
neighbor_query = %SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.0.8802.1.1.2.1.4.1.1",
|
|
"1.3.6.1.4.1.9.9.23"
|
|
]
|
|
}
|
|
|
|
arp_query = %SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.4.22",
|
|
"1.3.6.1.2.1.4.35"
|
|
]
|
|
}
|
|
|
|
mac_query = %SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.17.4.3"
|
|
]
|
|
}
|
|
|
|
ip_query = %SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.4.20",
|
|
"1.3.6.1.2.1.4.34"
|
|
]
|
|
}
|
|
|
|
host_resources_query = %SnmpQuery{
|
|
query_type: :WALK,
|
|
oids: [
|
|
"1.3.6.1.2.1.25.3.3",
|
|
"1.3.6.1.2.1.25.2.3"
|
|
]
|
|
}
|
|
|
|
base_queries ++ [neighbor_query, arp_query, mac_query, ip_query, host_resources_query]
|
|
end
|
|
|
|
@doc """
|
|
Builds a MikroTik job for a device (only when enabled and has credentials).
|
|
Returns `nil` if MikroTik is not configured.
|
|
"""
|
|
@spec build_mikrotik_job(map()) :: AgentJob.t() | nil
|
|
def build_mikrotik_job(device) do
|
|
do_build_mikrotik_job(Devices.get_mikrotik_config(device), device)
|
|
end
|
|
|
|
defp do_build_mikrotik_job(%{enabled: true, username: username}, device) when is_binary(username) do
|
|
config = Devices.get_mikrotik_config(device)
|
|
|
|
%AgentJob{
|
|
job_id: "mikrotik:#{device.id}",
|
|
job_type: :MIKROTIK,
|
|
device_id: device.id,
|
|
mikrotik_device: %MikrotikDevice{
|
|
ip: device.ip_address,
|
|
username: config.username,
|
|
password: config.password,
|
|
port: config.port,
|
|
use_ssl: config.use_ssl
|
|
},
|
|
mikrotik_commands: build_mikrotik_commands()
|
|
}
|
|
end
|
|
|
|
defp do_build_mikrotik_job(_config, _device), do: nil
|
|
|
|
@doc """
|
|
Builds the list of MikroTik commands for device interrogation.
|
|
"""
|
|
@spec build_mikrotik_commands() :: [MikrotikCommand.t()]
|
|
def build_mikrotik_commands do
|
|
[
|
|
%MikrotikCommand{
|
|
command: "/system/identity/print",
|
|
args: %{}
|
|
},
|
|
%MikrotikCommand{
|
|
command: "/system/resource/print",
|
|
args: %{}
|
|
}
|
|
]
|
|
end
|
|
|
|
@doc """
|
|
Builds a MikroTik backup job for a device.
|
|
"""
|
|
@spec build_backup_job(map(), String.t()) :: AgentJob.t()
|
|
def build_backup_job(device, job_id) do
|
|
mikrotik_config = Devices.get_mikrotik_config(device)
|
|
backup_filename = "towerops-backup-#{DateTime.to_unix(DateTime.utc_now())}"
|
|
chunk_size = 32_768
|
|
num_chunks = 10
|
|
|
|
read_commands =
|
|
for i <- 0..(num_chunks - 1) do
|
|
%MikrotikCommand{
|
|
command: "/file/read",
|
|
args: %{
|
|
"file" => "#{backup_filename}.rsc",
|
|
"offset" => to_string(i * chunk_size),
|
|
"chunk-size" => to_string(chunk_size)
|
|
}
|
|
}
|
|
end
|
|
|
|
%AgentJob{
|
|
job_id: job_id,
|
|
job_type: :MIKROTIK,
|
|
device_id: device.id,
|
|
mikrotik_device: %MikrotikDevice{
|
|
ip: device.ip_address || "",
|
|
username: mikrotik_config.username || "",
|
|
password: mikrotik_config.password || "",
|
|
port: mikrotik_config.port || 8729,
|
|
ssh_port: mikrotik_config.ssh_port || 22,
|
|
use_ssl: mikrotik_config.use_ssl || false
|
|
},
|
|
mikrotik_commands:
|
|
[
|
|
%MikrotikCommand{
|
|
command: "/export",
|
|
args: %{"file" => backup_filename, "compact" => "true"}
|
|
}
|
|
] ++
|
|
read_commands ++
|
|
[
|
|
%MikrotikCommand{
|
|
command: "/file/remove",
|
|
args: %{"numbers" => "#{backup_filename}.rsc"}
|
|
}
|
|
]
|
|
}
|
|
end
|
|
|
|
@doc """
|
|
Builds a `CheckProto` protobuf message from a monitoring check.
|
|
"""
|
|
@spec build_check_protobuf(map()) :: CheckProto.t()
|
|
def build_check_protobuf(check) do
|
|
config = check.config || %{}
|
|
|
|
base_fields = [
|
|
id: check.id,
|
|
check_type: check.check_type,
|
|
interval_seconds: check.interval_seconds,
|
|
timeout_ms: check.timeout_ms
|
|
]
|
|
|
|
struct!(CheckProto, base_fields ++ check_type_config(check.check_type, config))
|
|
end
|
|
|
|
defp check_type_config("http", config) do
|
|
[
|
|
http: %HttpCheckConfig{
|
|
url: config["url"] || "",
|
|
method: config["method"] || "GET",
|
|
expected_status: config["expected_status"] || 200,
|
|
verify_ssl: config["verify_ssl"] != false,
|
|
regex: config["regex"] || "",
|
|
follow_redirects: config["follow_redirects"] != false
|
|
}
|
|
]
|
|
end
|
|
|
|
defp check_type_config("tcp", config) do
|
|
[
|
|
tcp: %TcpCheckConfig{
|
|
host: config["host"] || "",
|
|
port: config["port"] || 0,
|
|
send: config["send"] || "",
|
|
expect: config["expect"] || ""
|
|
}
|
|
]
|
|
end
|
|
|
|
defp check_type_config("dns", config) do
|
|
[
|
|
dns: %DnsCheckConfig{
|
|
hostname: config["hostname"] || "",
|
|
server: config["server"] || "",
|
|
record_type: config["record_type"] || "A",
|
|
expected: config["expected"] || ""
|
|
}
|
|
]
|
|
end
|
|
|
|
defp check_type_config("ssl", config) do
|
|
[
|
|
ssl: %SslCheckConfig{
|
|
host: config["host"] || "",
|
|
port: config["port"] || 443,
|
|
warning_days: config["warning_days"] || 30
|
|
}
|
|
]
|
|
end
|
|
|
|
defp check_type_config(_, _config), do: []
|
|
|
|
@doc """
|
|
Lists enabled checks for an agent.
|
|
"""
|
|
@spec list_checks_for_agent(Ecto.UUID.t()) :: [map()]
|
|
def list_checks_for_agent(agent_token_id) do
|
|
Monitoring.list_checks_for_agent(agent_token_id, enabled: true)
|
|
end
|
|
|
|
@doc """
|
|
Builds a `CheckList` protobuf message from a list of checks.
|
|
"""
|
|
@spec build_check_list([map()]) :: CheckList.t()
|
|
def build_check_list(checks) do
|
|
%CheckList{checks: Enum.map(checks, &build_check_protobuf/1)}
|
|
end
|
|
end
|