towerops/lib/towerops_web/channels/agent_channel/job_builder.ex
Graham McInitre 9d4a5f6d81 refactor: comprehensive simplification and functional programming improvements
- 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
2026-07-21 17:03:47 -05:00

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