Add strict protobuf validation for agent messages

Implements comprehensive validation layer on top of protobuf schema to
prevent malformed data, DoS attacks, and business logic violations.

Changes:
- Created Towerops.Agent.Validator module with strict validation for all
  agent message types (AgentHeartbeat, MetricBatch, SnmpResult, AgentError,
  CredentialTestResult, MikrotikResult)
- Added validation limits to prevent memory exhaustion and DoS attacks
  (max string lengths, collection sizes, numeric ranges)
- Added format validation for UUIDs, IP addresses, versions, hostnames
- Added enum validation for status and protocol fields
- Modified AgentChannel handlers to validate all incoming messages before
  processing
- Added comprehensive typespecs to AgentChannel for better static analysis
- Created safe_base64_decode/1 helper with error handling
- Added 32 comprehensive tests covering valid cases, invalid inputs, and
  edge cases

Security improvements:
- Prevents memory exhaustion via oversized strings and collections
- Validates all numeric fields are within expected ranges
- Checks UUID format for all ID fields
- Validates IP addresses and hostnames against RFC standards
- Rejects future timestamps and excessive values

Backward compatibility:
- Empty version and hostname strings allowed for older agents
This commit is contained in:
Graham McIntire 2026-02-06 09:26:04 -06:00 committed by Graham McIntire
parent 27950de26e
commit 8044a6d140
No known key found for this signature in database
3 changed files with 1204 additions and 73 deletions

View file

@ -0,0 +1,496 @@
defmodule Towerops.Agent.Validator do
@moduledoc """
Strict validation for Protocol Buffer messages from agents.
Provides validation on top of protobuf schema to catch malformed data,
injection attempts, and business logic violations before processing.
## Philosophy
Protobuf provides schema-level validation (types, required fields) but
doesn't validate business constraints. This module adds:
- String length limits (prevent memory exhaustion)
- Format validation (IP addresses, OIDs, UUIDs)
- Range validation (ports, intervals, counters)
- Enum validation (strict whitelisting)
- Content validation (prevent injection)
## Usage
# Validate heartbeat
{:ok, heartbeat} = Validator.validate_heartbeat(binary)
# Validate metric batch
{:ok, metrics} = Validator.validate_metric_batch(binary)
# Validate SNMP result
{:ok, result} = Validator.validate_snmp_result(binary)
"""
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.Metric
alias Towerops.Agent.MetricBatch
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence
alias Towerops.Agent.MonitoringCheck
alias Towerops.Agent.NeighborDiscovery
alias Towerops.Agent.SensorReading
alias Towerops.Agent.SnmpResult
require Logger
# String length limits (prevent memory exhaustion attacks)
@max_string_length 10_000
@max_short_string_length 255
@max_oid_length 128
@max_error_message_length 1_000
# Numeric limits
@max_port 65_535
@max_interval_seconds 86_400 # 24 hours
@max_uptime_seconds 4_294_967_295 # uint64 max (136 years)
@max_response_time_ms 60_000 # 1 minute
@max_timestamp 2_147_483_647 # 2038-01-19 (int32 limit)
# Collection limits (prevent DoS via large batches)
@max_metrics_per_batch 10_000
@max_oid_values 1_000
@max_mikrotik_sentences 1_000
@max_capabilities 20
# Valid enums
@valid_statuses ["success", "failure", "up", "down", "ok", "error", "warning"]
@valid_protocols ["lldp", "cdp"]
@type validation_error :: {:error, {atom(), binary()}}
@type validation_result(t) :: {:ok, t} | validation_error()
## Public API - Message Validation
@doc """
Validate AgentHeartbeat message.
Checks version format, hostname validity, and uptime range.
"""
@spec validate_heartbeat(binary()) :: validation_result(AgentHeartbeat.t())
def validate_heartbeat(binary) when is_binary(binary) do
with {:ok, heartbeat} <- safe_decode(AgentHeartbeat, binary),
:ok <- validate_version(heartbeat.version),
:ok <- validate_hostname(heartbeat.hostname),
:ok <- validate_uptime(heartbeat.uptime_seconds),
:ok <- validate_optional_ip(heartbeat.ip_address) do
{:ok, heartbeat}
end
end
@doc """
Validate MetricBatch message.
Checks batch size limits and validates each metric individually.
"""
@spec validate_metric_batch(binary()) :: validation_result(MetricBatch.t())
def validate_metric_batch(binary) when is_binary(binary) do
with {:ok, batch} <- safe_decode(MetricBatch, binary),
:ok <- validate_batch_size(batch.metrics),
:ok <- validate_all_metrics(batch.metrics) do
{:ok, batch}
end
end
@doc """
Validate SnmpResult message.
Checks device_id format, OID values, and timestamp.
"""
@spec validate_snmp_result(binary()) :: validation_result(SnmpResult.t())
def validate_snmp_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(SnmpResult, binary),
:ok <- validate_device_id(result.device_id),
:ok <- validate_oid_values(result.oid_values),
:ok <- validate_timestamp(result.timestamp) do
{:ok, result}
end
end
@doc """
Validate AgentError message.
Checks device_id, job_id, and error message length.
"""
@spec validate_agent_error(binary()) :: validation_result(AgentError.t())
def validate_agent_error(binary) when is_binary(binary) do
with {:ok, error} <- safe_decode(AgentError, binary),
:ok <- validate_device_id(error.device_id),
:ok <- validate_job_id(error.job_id),
:ok <- validate_error_message(error.message) do
{:ok, error}
end
end
@doc """
Validate CredentialTestResult message.
Checks test_id, error message, and system description.
"""
@spec validate_credential_test_result(binary()) :: validation_result(CredentialTestResult.t())
def validate_credential_test_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(CredentialTestResult, binary),
:ok <- validate_test_id(result.test_id),
:ok <- validate_error_message(result.error_message),
:ok <- validate_system_description(result.system_description),
:ok <- validate_timestamp(result.timestamp) do
{:ok, result}
end
end
@doc """
Validate MikrotikResult message.
Checks device_id, job_id, sentences count, and error message.
"""
@spec validate_mikrotik_result(binary()) :: validation_result(MikrotikResult.t())
def validate_mikrotik_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(MikrotikResult, binary),
:ok <- validate_device_id(result.device_id),
:ok <- validate_job_id(result.job_id),
:ok <- validate_mikrotik_sentences(result.sentences),
:ok <- validate_error_message(result.error),
:ok <- validate_timestamp(result.timestamp) do
{:ok, result}
end
end
## Private Validation Functions
# Safe decode with error handling
defp safe_decode(module, binary) do
{:ok, module.decode(binary)}
rescue
e in [Protobuf.DecodeError, ArgumentError] ->
Logger.error("Protobuf decode error: #{Exception.message(e)}")
{:error, {:decode_error, "Invalid protobuf message format"}}
end
# Validate semantic version format (empty string allowed for backward compat)
defp validate_version(""), do: :ok
defp validate_version(version) when is_binary(version) do
if String.match?(version, ~r/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/) and
String.length(version) <= @max_short_string_length do
:ok
else
{:error, {:invalid_version, "Version must be semver format (e.g., 1.2.3)"}}
end
end
defp validate_version(_), do: {:error, {:invalid_version, "Version must be a string"}}
# Validate hostname (RFC 1123, empty string allowed for backward compat)
defp validate_hostname(""), do: :ok
defp validate_hostname(hostname) when is_binary(hostname) do
cond do
String.length(hostname) > @max_short_string_length ->
{:error, {:invalid_hostname, "Hostname too long"}}
String.match?(hostname, ~r/^[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$/) ->
:ok
true ->
{:error, {:invalid_hostname, "Hostname must be valid RFC 1123 format"}}
end
end
defp validate_hostname(""), do: :ok
defp validate_hostname(_), do: {:error, {:invalid_hostname, "Hostname must be a string"}}
# Validate uptime seconds
defp validate_uptime(uptime) when is_integer(uptime) and uptime >= 0 and uptime <= @max_uptime_seconds do
:ok
end
defp validate_uptime(_), do: {:error, {:invalid_uptime, "Uptime must be 0 to #{@max_uptime_seconds}"}}
# Validate optional IP address
defp validate_optional_ip(""), do: :ok
defp validate_optional_ip(nil), do: :ok
defp validate_optional_ip(ip) when is_binary(ip) do
case :inet.parse_address(String.to_charlist(ip)) do
{:ok, _} -> :ok
{:error, _} -> {:error, {:invalid_ip, "IP address must be valid IPv4 or IPv6"}}
end
end
# Validate batch size
defp validate_batch_size(metrics) when is_list(metrics) do
if length(metrics) <= @max_metrics_per_batch do
:ok
else
{:error, {:batch_too_large, "Batch cannot exceed #{@max_metrics_per_batch} metrics"}}
end
end
# Validate all metrics in batch
defp validate_all_metrics(metrics) do
Enum.reduce_while(metrics, :ok, fn metric, _acc ->
case validate_metric(metric) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
# Validate individual metric
defp validate_metric(%Metric{metric_type: {:sensor_reading, reading}}) do
validate_sensor_reading(reading)
end
defp validate_metric(%Metric{metric_type: {:interface_stat, stat}}) do
validate_interface_stat(stat)
end
defp validate_metric(%Metric{metric_type: {:neighbor_discovery, discovery}}) do
validate_neighbor_discovery(discovery)
end
defp validate_metric(%Metric{metric_type: {:monitoring_check, check}}) do
validate_monitoring_check(check)
end
defp validate_metric(_), do: {:error, {:invalid_metric, "Unknown metric type"}}
# Validate SensorReading
defp validate_sensor_reading(%SensorReading{} = reading) do
with :ok <- validate_sensor_id(reading.sensor_id),
:ok <- validate_sensor_value(reading.value),
:ok <- validate_status(reading.status),
:ok <- validate_timestamp(reading.timestamp) do
:ok
end
end
# Validate InterfaceStat
defp validate_interface_stat(%InterfaceStat{} = stat) do
with :ok <- validate_interface_id(stat.interface_id),
:ok <- validate_counter(stat.if_in_octets, "if_in_octets"),
:ok <- validate_counter(stat.if_out_octets, "if_out_octets"),
:ok <- validate_counter(stat.if_in_errors, "if_in_errors"),
:ok <- validate_counter(stat.if_out_errors, "if_out_errors"),
:ok <- validate_counter(stat.if_in_discards, "if_in_discards"),
:ok <- validate_counter(stat.if_out_discards, "if_out_discards"),
:ok <- validate_timestamp(stat.timestamp) do
:ok
end
end
# Validate NeighborDiscovery
defp validate_neighbor_discovery(%NeighborDiscovery{} = discovery) do
with :ok <- validate_interface_id(discovery.interface_id),
:ok <- validate_protocol(discovery.protocol),
:ok <- validate_short_string(discovery.remote_chassis_id, "remote_chassis_id"),
:ok <- validate_short_string(discovery.remote_system_name, "remote_system_name"),
:ok <- validate_string(discovery.remote_system_description, "remote_system_description"),
:ok <- validate_short_string(discovery.remote_platform, "remote_platform"),
:ok <- validate_short_string(discovery.remote_port_id, "remote_port_id"),
:ok <- validate_short_string(discovery.remote_port_description, "remote_port_description"),
:ok <- validate_optional_ip(discovery.remote_address),
:ok <- validate_capabilities(discovery.remote_capabilities),
:ok <- validate_timestamp(discovery.timestamp) do
:ok
end
end
# Validate MonitoringCheck
defp validate_monitoring_check(%MonitoringCheck{} = check) do
with :ok <- validate_device_id(check.device_id),
:ok <- validate_status(check.status),
:ok <- validate_response_time(check.response_time_ms),
:ok <- validate_timestamp(check.timestamp) do
:ok
end
end
# Validate device ID (UUID format)
defp validate_device_id(id) when is_binary(id) do
if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do
:ok
else
{:error, {:invalid_device_id, "Device ID must be valid UUID"}}
end
end
defp validate_device_id(_), do: {:error, {:invalid_device_id, "Device ID must be a string"}}
# Validate sensor ID
defp validate_sensor_id(id) when is_binary(id) do
if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do
:ok
else
{:error, {:invalid_sensor_id, "Sensor ID must be valid UUID"}}
end
end
defp validate_sensor_id(_), do: {:error, {:invalid_sensor_id, "Sensor ID must be a string"}}
# Validate interface ID
defp validate_interface_id(id) when is_binary(id) do
if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do
:ok
else
{:error, {:invalid_interface_id, "Interface ID must be valid UUID"}}
end
end
defp validate_interface_id(_), do: {:error, {:invalid_interface_id, "Interface ID must be a string"}}
# Validate test ID
defp validate_test_id(id) when is_binary(id) do
if String.match?(id, ~r/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i) do
:ok
else
{:error, {:invalid_test_id, "Test ID must be valid UUID"}}
end
end
defp validate_test_id(_), do: {:error, {:invalid_test_id, "Test ID must be a string"}}
# Validate job ID
defp validate_job_id(id) when is_binary(id) and byte_size(id) > 0 and byte_size(id) <= @max_short_string_length do
:ok
end
defp validate_job_id(_), do: {:error, {:invalid_job_id, "Job ID must be non-empty string"}}
# Validate sensor value (must be finite float/int)
defp validate_sensor_value(value) when is_float(value) or is_integer(value) do
if is_float(value) and (value == :nan or value == :infinity or value == :neg_infinity) do
{:error, {:invalid_sensor_value, "Sensor value must be finite number"}}
else
:ok
end
end
defp validate_sensor_value(_), do: {:error, {:invalid_sensor_value, "Sensor value must be number"}}
# Validate counter (must be non-negative int64)
defp validate_counter(value, field) when is_integer(value) and value >= 0 do
:ok
end
defp validate_counter(_, field), do: {:error, {:invalid_counter, "#{field} must be non-negative integer"}}
# Validate status enum
defp validate_status(status) when status in @valid_statuses, do: :ok
defp validate_status(""), do: :ok
defp validate_status(status), do: {:error, {:invalid_status, "Status '#{status}' not in #{inspect(@valid_statuses)}"}}
# Validate protocol enum
defp validate_protocol(protocol) when protocol in @valid_protocols, do: :ok
defp validate_protocol(protocol), do: {:error, {:invalid_protocol, "Protocol '#{protocol}' not in #{inspect(@valid_protocols)}"}}
# Validate response time
defp validate_response_time(ms) when is_float(ms) and ms >= 0 and ms <= @max_response_time_ms do
:ok
end
defp validate_response_time(0.0), do: :ok
defp validate_response_time(_), do: {:error, {:invalid_response_time, "Response time must be 0 to #{@max_response_time_ms}ms"}}
# Validate timestamp (Unix epoch seconds)
defp validate_timestamp(ts) when is_integer(ts) and ts >= 0 and ts <= @max_timestamp do
:ok
end
defp validate_timestamp(_), do: {:error, {:invalid_timestamp, "Timestamp must be valid Unix epoch (0 to #{@max_timestamp})"}}
# Validate short string (hostnames, IDs, etc.)
defp validate_short_string(str, field) when is_binary(str) do
if String.length(str) <= @max_short_string_length do
:ok
else
{:error, {:string_too_long, "#{field} exceeds #{@max_short_string_length} characters"}}
end
end
defp validate_short_string("", _field), do: :ok
defp validate_short_string(_, field), do: {:error, {:invalid_string, "#{field} must be a string"}}
# Validate general string (descriptions, etc.)
defp validate_string(str, field) when is_binary(str) do
if String.length(str) <= @max_string_length do
:ok
else
{:error, {:string_too_long, "#{field} exceeds #{@max_string_length} characters"}}
end
end
defp validate_string("", _field), do: :ok
defp validate_string(_, field), do: {:error, {:invalid_string, "#{field} must be a string"}}
# Validate error message
defp validate_error_message(msg) when is_binary(msg) do
if String.length(msg) <= @max_error_message_length do
:ok
else
{:error, {:string_too_long, "Error message exceeds #{@max_error_message_length} characters"}}
end
end
defp validate_error_message(""), do: :ok
defp validate_error_message(_), do: {:error, {:invalid_error_message, "Error message must be a string"}}
# Validate system description
defp validate_system_description(desc) when is_binary(desc) do
if String.length(desc) <= @max_string_length do
:ok
else
{:error, {:string_too_long, "System description exceeds #{@max_string_length} characters"}}
end
end
defp validate_system_description(""), do: :ok
defp validate_system_description(_), do: {:error, {:invalid_system_description, "System description must be a string"}}
# Validate capabilities list
defp validate_capabilities(caps) when is_list(caps) do
cond do
length(caps) > @max_capabilities ->
{:error, {:too_many_capabilities, "Capabilities list exceeds #{@max_capabilities} items"}}
Enum.all?(caps, &is_binary/1) ->
:ok
true ->
{:error, {:invalid_capabilities, "All capabilities must be strings"}}
end
end
defp validate_capabilities(_), do: {:error, {:invalid_capabilities, "Capabilities must be a list"}}
# Validate OID values map
defp validate_oid_values(oid_values) when is_map(oid_values) do
if map_size(oid_values) <= @max_oid_values do
:ok
else
{:error, {:too_many_oids, "OID values map exceeds #{@max_oid_values} entries"}}
end
end
defp validate_oid_values(_), do: {:error, {:invalid_oid_values, "OID values must be a map"}}
# Validate MikroTik sentences
defp validate_mikrotik_sentences(sentences) when is_list(sentences) do
if length(sentences) <= @max_mikrotik_sentences do
:ok
else
{:error, {:too_many_sentences, "Sentences list exceeds #{@max_mikrotik_sentences} items"}}
end
end
defp validate_mikrotik_sentences(_), do: {:error, {:invalid_sentences, "Sentences must be a list"}}
end

View file

@ -32,6 +32,7 @@ defmodule ToweropsWeb.AgentChannel do
alias Towerops.Agent.SnmpDevice alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult alias Towerops.Agent.SnmpResult
alias Towerops.Agent.Validator
alias Towerops.Agents alias Towerops.Agents
alias Towerops.Devices alias Towerops.Devices
alias Towerops.Devices.BackupRequests alias Towerops.Devices.BackupRequests
@ -41,6 +42,13 @@ defmodule ToweropsWeb.AgentChannel do
require Logger require Logger
@typep socket :: Phoenix.Socket.t()
@typep base64_string :: String.t()
@typep protobuf_binary :: binary()
@typep device_id :: String.t()
@typep job_id :: String.t()
@typep agent_token_id :: String.t()
@impl true @impl true
@spec join(String.t(), map(), Phoenix.Socket.t()) :: @spec join(String.t(), map(), Phoenix.Socket.t()) ::
{:ok, Phoenix.Socket.t()} | {:error, map()} {:ok, Phoenix.Socket.t()} | {:error, map()}
@ -231,72 +239,116 @@ defmodule ToweropsWeb.AgentChannel do
end end
@impl true @impl true
@spec handle_in(String.t(), map(), Phoenix.Socket.t()) :: {:noreply, Phoenix.Socket.t()} @spec handle_in(String.t(), map(), socket()) :: {:noreply, socket()}
def handle_in("result", %{"binary" => binary_b64}, socket) do def handle_in("result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
binary = Base.decode64!(binary_b64) with {:ok, binary} <- safe_base64_decode(binary_b64),
result = SnmpResult.decode(binary) {:ok, result} <- Validator.validate_snmp_result(binary) do
maybe_debug_log(socket, "Received SNMP result from agent",
maybe_debug_log(socket, "Received SNMP result from agent", device_id: result.device_id,
device_id: result.device_id, job_type: result.job_type,
job_type: result.job_type, job_id: result.job_id,
job_id: result.job_id, binary_size: byte_size(binary_b64),
binary_size: byte_size(binary_b64), oid_count: map_size(result.oid_values)
oid_count: map_size(result.oid_values)
)
# Check if this is a live poll result
if String.starts_with?(result.job_id, "live_poll:") do
handle_live_poll_result(result, socket)
else
_ = process_snmp_result(socket.assigns.organization_id, result, socket)
end
{:noreply, socket}
end
def handle_in("heartbeat", %{"binary" => binary_b64}, socket) do
binary = Base.decode64!(binary_b64)
heartbeat = AgentHeartbeat.decode(binary)
metadata = %{
"version" => heartbeat.version,
"hostname" => heartbeat.hostname,
"uptime_seconds" => heartbeat.uptime_seconds
}
_ =
Agents.update_agent_token_heartbeat(
socket.assigns.agent_token_id,
heartbeat.ip_address,
metadata
) )
{:noreply, socket} # Check if this is a live poll result
if String.starts_with?(result.job_id, "live_poll:") do
handle_live_poll_result(result, socket)
else
_ = process_snmp_result(socket.assigns.organization_id, result, socket)
end
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid SNMP result from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end end
@impl true @spec handle_in(String.t(), %{required(String.t()) => base64_string()}, socket()) ::
def handle_in("error", %{"binary" => binary_b64}, socket) do {:noreply, socket()}
binary = Base.decode64!(binary_b64) def handle_in("heartbeat", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
error = AgentError.decode(binary) with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, heartbeat} <- Validator.validate_heartbeat(binary) do
metadata = %{
"version" => heartbeat.version,
"hostname" => heartbeat.hostname,
"uptime_seconds" => heartbeat.uptime_seconds
}
maybe_debug_log(socket, "Agent job error", _ =
device_id: error.device_id, Agents.update_agent_token_heartbeat(
error_message: error.message, socket.assigns.agent_token_id,
binary_size: byte_size(binary_b64) heartbeat.ip_address,
) metadata
)
Logger.error("Agent job error", {:noreply, socket}
agent_token_id: socket.assigns.agent_token_id, else
device_id: error.device_id, {:error, {type, message}} ->
error: error.message Logger.error("Invalid heartbeat from agent",
) agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket} {:noreply, socket}
end
end end
def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) do def handle_in("error", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
binary = Base.decode64!(binary_b64) with {:ok, binary} <- safe_base64_decode(binary_b64),
result = MikrotikResult.decode(binary) {:ok, error} <- Validator.validate_agent_error(binary) do
maybe_debug_log(socket, "Agent job error",
device_id: error.device_id,
error_message: error.error_message,
binary_size: byte_size(binary_b64)
)
Logger.error("Agent job error",
agent_token_id: socket.assigns.agent_token_id,
device_id: error.device_id,
error: error.error_message
)
{:noreply, socket}
else
{:error, {type, message}} ->
Logger.error("Invalid error message from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end
def handle_in("mikrotik_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_mikrotik_result(binary) do
handle_validated_mikrotik_result(result, socket)
else
{:error, {type, message}} ->
Logger.error("Invalid MikroTik result from agent",
agent_token_id: socket.assigns.agent_token_id,
error_type: type,
error_message: message
)
{:noreply, socket}
end
end
@spec handle_validated_mikrotik_result(MikrotikResult.t(), socket()) :: {:noreply, socket()}
defp handle_validated_mikrotik_result(result, socket) do
binary = MikrotikResult.encode(result)
maybe_debug_log(socket, "Received MikroTik result from agent", maybe_debug_log(socket, "Received MikroTik result from agent",
device_id: result.device_id, device_id: result.device_id,
@ -316,25 +368,35 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket} {:noreply, socket}
end end
def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) do def handle_in("credential_test_result", %{"binary" => binary_b64}, socket)
binary = Base.decode64!(binary_b64) when is_binary(binary_b64) do
result = CredentialTestResult.decode(binary) with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_credential_test_result(binary) do
maybe_debug_log(socket, "Received credential test result from agent",
test_id: result.test_id,
success: result.success,
has_error: result.error_message != ""
)
maybe_debug_log(socket, "Received credential test result from agent", # Broadcast result to the requesting LiveView via PubSub
test_id: result.test_id, # The test_id contains the device_id, so we can broadcast to that topic
success: result.success, Phoenix.PubSub.broadcast(
has_error: result.error_message != "" Towerops.PubSub,
) "credential_test:#{result.test_id}",
{:credential_test_result, result}
)
# Broadcast result to the requesting LiveView via PubSub {:noreply, socket}
# The test_id contains the device_id, so we can broadcast to that topic else
Phoenix.PubSub.broadcast( {:error, {type, message}} ->
Towerops.PubSub, Logger.error("Invalid credential test result from agent",
"credential_test:#{result.test_id}", agent_token_id: socket.assigns.agent_token_id,
{:credential_test_result, result} error_type: type,
) error_message: message
)
{:noreply, socket} {:noreply, socket}
end
end end
# Private helpers # Private helpers
@ -1042,4 +1104,41 @@ defmodule ToweropsWeb.AgentChannel do
end end
defp routeros_config?(_), do: false defp routeros_config?(_), do: false
## Safe Encoding/Decoding Helpers
@doc """
Safely decode Base64-encoded protobuf binary.
Returns {:ok, binary} on success or {:error, {type, message}} on failure.
"""
@spec safe_base64_decode(base64_string()) :: {:ok, protobuf_binary()} | {:error, {atom(), String.t()}}
defp safe_base64_decode(base64_string) when is_binary(base64_string) do
case Base.decode64(base64_string) do
{:ok, binary} ->
{:ok, binary}
:error ->
{:error, {:invalid_base64, "Message is not valid Base64-encoded data"}}
end
rescue
e in ArgumentError ->
Logger.error("Base64 decode error: #{Exception.message(e)}")
{:error, {:invalid_base64, "Failed to decode Base64 data"}}
end
@doc """
Safely encode protobuf struct to Base64.
Returns {:ok, base64_string} on success or {:error, {type, message}} on failure.
"""
@spec safe_base64_encode(struct()) :: {:ok, base64_string()} | {:error, {atom(), String.t()}}
defp safe_base64_encode(protobuf_struct) do
binary = protobuf_struct.__struct__.encode(protobuf_struct)
{:ok, Base.encode64(binary)}
rescue
e in [Protobuf.EncodeError, ArgumentError] ->
Logger.error("Protobuf encode error: #{Exception.message(e)}")
{:error, {:encode_error, "Failed to encode protobuf message"}}
end
end end

View file

@ -0,0 +1,536 @@
defmodule Towerops.Agent.ValidatorTest do
use ExUnit.Case, async: true
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.Metric
alias Towerops.Agent.MetricBatch
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MikrotikSentence
alias Towerops.Agent.MonitoringCheck
alias Towerops.Agent.NeighborDiscovery
alias Towerops.Agent.SensorReading
alias Towerops.Agent.SnmpResult
alias Towerops.Agent.Validator
describe "validate_heartbeat/1" do
test "validates valid heartbeat" do
heartbeat = %AgentHeartbeat{
version: "1.2.3",
hostname: "agent01.example.com",
uptime_seconds: 12_345,
ip_address: "192.168.1.100"
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, validated} = Validator.validate_heartbeat(binary)
assert validated.version == "1.2.3"
assert validated.hostname == "agent01.example.com"
end
test "validates heartbeat with IPv6 address" do
heartbeat = %AgentHeartbeat{
version: "2.0.0",
hostname: "agent02",
uptime_seconds: 100,
ip_address: "2001:db8::1"
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "rejects invalid version format" do
heartbeat = %AgentHeartbeat{
version: "not-semver",
hostname: "agent03",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_version, _}} = Validator.validate_heartbeat(binary)
end
test "rejects invalid hostname" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "invalid..hostname",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_hostname, _}} = Validator.validate_heartbeat(binary)
end
test "rejects excessive uptime" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent04",
uptime_seconds: 5_000_000_000,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_uptime, _}} = Validator.validate_heartbeat(binary)
end
test "rejects invalid IP address" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent05",
uptime_seconds: 100,
ip_address: "999.999.999.999"
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:error, {:invalid_ip, _}} = Validator.validate_heartbeat(binary)
end
test "accepts empty IP address" do
heartbeat = %AgentHeartbeat{
version: "1.0.0",
hostname: "agent06",
uptime_seconds: 100,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "rejects malformed protobuf" do
invalid_binary = <<0, 1, 2, 3, 4, 5>>
assert {:error, {:decode_error, _}} = Validator.validate_heartbeat(invalid_binary)
end
end
describe "validate_metric_batch/1" do
test "validates batch with sensor readings" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "ok",
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, reading}}
]
}
binary = MetricBatch.encode(batch)
assert {:ok, validated} = Validator.validate_metric_batch(binary)
assert length(validated.metrics) == 1
end
test "validates batch with interface stats" do
stat = %InterfaceStat{
interface_id: "550e8400-e29b-41d4-a716-446655440001",
if_in_octets: 1000,
if_out_octets: 2000,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:interface_stat, stat}}
]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "validates batch with neighbor discovery" do
discovery = %NeighborDiscovery{
interface_id: "550e8400-e29b-41d4-a716-446655440002",
protocol: "lldp",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "switch01",
remote_system_description: "Cisco IOS",
remote_platform: "C9300",
remote_port_id: "Gi1/0/1",
remote_port_description: "uplink",
remote_address: "192.168.1.1",
remote_capabilities: ["bridge", "router"],
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:neighbor_discovery, discovery}}
]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "validates batch with monitoring check" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "success",
response_time_ms: 12.5,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:monitoring_check, check}}
]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "rejects invalid sensor UUID" do
reading = %SensorReading{
sensor_id: "not-a-uuid",
value: 23.5,
status: "ok",
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, reading}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_sensor_id, _}} = Validator.validate_metric_batch(binary)
end
test "rejects invalid status" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "invalid_status",
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, reading}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_status, _}} = Validator.validate_metric_batch(binary)
end
test "rejects negative counters" do
stat = %InterfaceStat{
interface_id: "550e8400-e29b-41d4-a716-446655440001",
if_in_octets: -1000,
if_out_octets: 2000,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:interface_stat, stat}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_counter, _}} = Validator.validate_metric_batch(binary)
end
test "rejects invalid protocol" do
discovery = %NeighborDiscovery{
interface_id: "550e8400-e29b-41d4-a716-446655440002",
protocol: "invalid",
remote_chassis_id: "00:11:22:33:44:55",
remote_system_name: "switch01",
remote_system_description: "",
remote_platform: "",
remote_port_id: "",
remote_port_description: "",
remote_address: "",
remote_capabilities: [],
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:neighbor_discovery, discovery}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_protocol, _}} = Validator.validate_metric_batch(binary)
end
test "rejects excessive response time" do
check = %MonitoringCheck{
device_id: "550e8400-e29b-41d4-a716-446655440003",
status: "success",
response_time_ms: 99_999.0,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:monitoring_check, check}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_response_time, _}} = Validator.validate_metric_batch(binary)
end
test "rejects future timestamp" do
reading = %SensorReading{
sensor_id: "550e8400-e29b-41d4-a716-446655440000",
value: 23.5,
status: "ok",
timestamp: 9_999_999_999
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:sensor_reading, reading}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:invalid_timestamp, _}} = Validator.validate_metric_batch(binary)
end
test "rejects too many capabilities" do
discovery = %NeighborDiscovery{
interface_id: "550e8400-e29b-41d4-a716-446655440002",
protocol: "lldp",
remote_chassis_id: "",
remote_system_name: "",
remote_system_description: "",
remote_platform: "",
remote_port_id: "",
remote_port_description: "",
remote_address: "",
remote_capabilities: Enum.map(1..25, &"cap#{&1}"),
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [
%Metric{metric_type: {:neighbor_discovery, discovery}}
]
}
binary = MetricBatch.encode(batch)
assert {:error, {:too_many_capabilities, _}} = Validator.validate_metric_batch(binary)
end
end
describe "validate_snmp_result/1" do
test "validates valid SNMP result" do
result = %SnmpResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_type: 1,
oid_values: %{"1.3.6.1.2.1.1.1.0" => "Test Device"},
timestamp: 1_700_000_000
}
binary = SnmpResult.encode(result)
assert {:ok, validated} = Validator.validate_snmp_result(binary)
assert validated.device_id == result.device_id
end
test "rejects invalid device UUID" do
result = %SnmpResult{
device_id: "not-a-uuid",
job_type: 1,
oid_values: %{},
timestamp: 1_700_000_000
}
binary = SnmpResult.encode(result)
assert {:error, {:invalid_device_id, _}} = Validator.validate_snmp_result(binary)
end
test "rejects too many OID values" do
# Create map with more than max allowed OIDs
oid_values = Map.new(1..1100, fn i -> {"1.3.6.1.#{i}", "value"} end)
result = %SnmpResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_type: 1,
oid_values: oid_values,
timestamp: 1_700_000_000
}
binary = SnmpResult.encode(result)
assert {:error, {:too_many_oids, _}} = Validator.validate_snmp_result(binary)
end
end
describe "validate_agent_error/1" do
test "validates valid agent error" do
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
message: "Connection timeout"
}
binary = AgentError.encode(error)
assert {:ok, validated} = Validator.validate_agent_error(binary)
assert validated.message == "Connection timeout"
end
test "rejects excessively long error message" do
long_message = String.duplicate("error ", 250)
error = %AgentError{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
message: long_message
}
binary = AgentError.encode(error)
assert {:error, {:string_too_long, _}} = Validator.validate_agent_error(binary)
end
end
describe "validate_credential_test_result/1" do
test "validates successful credential test" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: true,
error_message: "",
system_description: "Cisco IOS Software",
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:ok, validated} = Validator.validate_credential_test_result(binary)
assert validated.success == true
end
test "validates failed credential test" do
result = %CredentialTestResult{
test_id: "550e8400-e29b-41d4-a716-446655440000",
success: false,
error_message: "Authentication failed",
system_description: "",
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:ok, validated} = Validator.validate_credential_test_result(binary)
assert validated.success == false
end
test "rejects invalid test ID" do
result = %CredentialTestResult{
test_id: "invalid-id",
success: true,
error_message: "",
system_description: "",
timestamp: 1_700_000_000
}
binary = CredentialTestResult.encode(result)
assert {:error, {:invalid_test_id, _}} = Validator.validate_credential_test_result(binary)
end
end
describe "validate_mikrotik_result/1" do
test "validates valid MikroTik result" do
sentence = %MikrotikSentence{
attributes: %{"name" => "ether1", "running" => "true"}
}
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
sentences: [sentence],
error: "",
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:ok, validated} = Validator.validate_mikrotik_result(binary)
assert length(validated.sentences) == 1
end
test "rejects too many sentences" do
sentences = Enum.map(1..1100, fn _ ->
%MikrotikSentence{attributes: %{}}
end)
result = %MikrotikResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_id: "job123",
sentences: sentences,
error: "",
timestamp: 1_700_000_000
}
binary = MikrotikResult.encode(result)
assert {:error, {:too_many_sentences, _}} = Validator.validate_mikrotik_result(binary)
end
end
describe "edge cases" do
test "handles empty strings gracefully" do
heartbeat = %AgentHeartbeat{
version: "",
hostname: "",
uptime_seconds: 0,
ip_address: ""
}
binary = AgentHeartbeat.encode(heartbeat)
# Empty version should be accepted (backward compat)
assert {:ok, _} = Validator.validate_heartbeat(binary)
end
test "handles zero values" do
stat = %InterfaceStat{
interface_id: "550e8400-e29b-41d4-a716-446655440001",
if_in_octets: 0,
if_out_octets: 0,
if_in_errors: 0,
if_out_errors: 0,
if_in_discards: 0,
if_out_discards: 0,
timestamp: 1_700_000_000
}
batch = %MetricBatch{
metrics: [%Metric{metric_type: {:interface_stat, stat}}]
}
binary = MetricBatch.encode(batch)
assert {:ok, _} = Validator.validate_metric_batch(binary)
end
test "handles empty collections" do
result = %SnmpResult{
device_id: "550e8400-e29b-41d4-a716-446655440000",
job_type: 1,
oid_values: %{},
timestamp: 1_700_000_000
}
binary = SnmpResult.encode(result)
assert {:ok, _} = Validator.validate_snmp_result(binary)
end
end
end