Rewrite protobuf encoding/decoding in pure Gleam with integrated validation (#104)

Replace the protobuf hex package (agent.pb.ex) and hand-written validator
(validator.ex) with a pure Gleam implementation: wire format primitives,
all 23 message types, encode/decode functions, and validation merged into
decode. Thin Elixir wrappers in agent.ex preserve the existing struct API.

- Wire format: varint, length-delimited, double (IEEE 754 via FFI), tag encode/decode
- Types: all proto3 message types as Gleam custom types with enums and oneof
- Decode: field-level parsing with integrated validation (UUID, IP, hostname, limits)
- Encode: proto3 default omission, bytes_tree concatenation, map/repeated fields
- Wrappers: Elixir structs with encode/decode delegating to Gleam, enum atom conversion
- Remove {:protobuf, "~> 0.12"} dependency

Reviewed-on: graham/towerops-web#104
This commit is contained in:
Graham McIntire 2026-03-21 16:08:40 -05:00 committed by graham
parent 348975dcc9
commit e54ee75513
22 changed files with 5292 additions and 1708 deletions

View file

@ -91,6 +91,15 @@ jobs:
- name: Compile (warnings as errors)
run: mix compile --warnings-as-errors
- name: Lint Gleam (glinter)
run: |
output=$(gleam run -m glinter 2>&1) || true
echo "$output"
if echo "$output" | grep -qE '\([1-9][0-9]* errors?,'; then
echo "::error::Gleam lint errors found"
exit 1
fi
- name: Run tests
run: mix test

View file

@ -8,3 +8,4 @@ gleam_regexp = ">= 1.0.0 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"
glinter = ">= 1.0.0 and < 2.0.0"

24
gleam_lint.toml Normal file
View file

@ -0,0 +1,24 @@
[rules]
# Errors: these should fail CI
avoid_panic = "error"
avoid_todo = "error"
echo = "error"
discarded_result = "error"
division_by_zero = "error"
error_context_lost = "error"
thrown_away_error = "error"
duplicate_import = "error"
# Warnings: kept as informational
assert_ok_pattern = "warning"
unwrap_used = "warning"
redundant_case = "warning"
unnecessary_variable = "warning"
# Off: too noisy for this codebase
label_possible = "off"
missing_labels = "off"
short_variable_name = "off"
prefer_guard_clause = "off"
module_complexity = "off"
function_complexity = "off"
deep_nesting = "off"
ffi_usage = "off"

View file

@ -1,659 +0,0 @@
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.CheckResult
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.InterfaceStat
alias Towerops.Agent.LldpNeighbor
alias Towerops.Agent.LldpTopologyResult
alias Towerops.Agent.Metric
alias Towerops.Agent.MetricBatch
alias Towerops.Agent.MikrotikResult
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_error_message_length 1_000
# Numeric limits (reserved for future use)
# @max_oid_length 128
# @max_port 65_535
# @max_interval_seconds 86_400
# uint64 max (136 years)
@max_uptime_seconds 4_294_967_295
# 1 minute
@max_response_time_ms 60_000
# 2038-01-19 (int32 limit)
@max_timestamp 2_147_483_647
# Collection limits (prevent DoS via large batches)
@max_metrics_per_batch 10_000
# Discovery on enterprise devices can return 10k-50k OIDs (interfaces, routing tables, etc.)
@max_oid_values 50_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
@doc """
Validate MonitoringCheck message from binary protobuf.
Decodes the binary and validates device_id, status enum, response_time range, and timestamp.
"""
@spec validate_monitoring_check_message(binary()) :: validation_result(MonitoringCheck.t())
def validate_monitoring_check_message(binary) when is_binary(binary) do
with {:ok, check} <- safe_decode(MonitoringCheck, binary),
:ok <- validate_monitoring_check(check) do
{:ok, check}
end
end
@doc """
Validate LldpTopologyResult message.
Checks device_id, job_id, local system name, neighbors count, and timestamp.
"""
@spec validate_lldp_topology_result(binary()) :: validation_result(LldpTopologyResult.t())
def validate_lldp_topology_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(LldpTopologyResult, binary),
:ok <- validate_device_id(result.device_id),
:ok <- validate_job_id(result.job_id),
:ok <- validate_short_string(result.local_system_name, "local_system_name"),
:ok <- validate_lldp_neighbors(result.neighbors),
:ok <- validate_timestamp(result.timestamp) do
{:ok, result}
end
end
@doc """
Validate CheckResult message from binary protobuf.
Decodes the binary and validates check_id (UUID), status (0-3), response_time, and output length.
"""
@spec validate_check_result(binary()) :: validation_result(CheckResult.t())
def validate_check_result(binary) when is_binary(binary) do
with {:ok, result} <- safe_decode(CheckResult, binary),
:ok <- validate_check_id(result.check_id),
:ok <- validate_check_status(result.status),
:ok <- validate_check_response_time(result.response_time_ms),
:ok <- validate_string(result.output, "output") 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 version format - accepts semver, RFC 3339 timestamp, git describe,
# git short SHA, or "dev". Empty string allowed for backward compat.
defp validate_version(""), do: :ok
defp validate_version(version) when is_binary(version) do
cond do
String.length(version) > @max_short_string_length ->
{:error, {:invalid_version, "Version too long"}}
# Semantic version with git-describe suffixes (e.g., 1.2.3, 1.2.3-rc.1, 1.2.3-5-gabcdef0)
String.match?(version, ~r/^\d+\.\d+\.\d+(-[a-zA-Z0-9.\-]+)?$/) ->
:ok
# RFC 3339 timestamp format (e.g., 2026-02-11T17:00:00Z)
String.match?(version, ~r/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z$/) ->
:ok
# Git short SHA (7-40 hex chars) or short identifier like "dev"
String.match?(version, ~r/^[a-zA-Z0-9]{1,40}$/) ->
:ok
true ->
{:error, {:invalid_version, "Version must be semver, RFC 3339 timestamp, or short identifier"}}
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) do
validate_timestamp(reading.timestamp)
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") do
validate_timestamp(stat.timestamp)
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) do
validate_timestamp(discovery.timestamp)
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) do
validate_timestamp(check.timestamp)
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_integer(value), do: :ok
defp validate_sensor_value(value) when is_float(value) do
# Check for NaN (NaN is the only value that doesn't equal itself)
# Check for infinity by comparing with max/min float values
cond do
# credo:disable-for-next-line Credo.Check.Warning.OperationOnSameValues
value != value ->
{:error, {:invalid_sensor_value, "Sensor value must be finite number (not NaN)"}}
value > 1.0e308 or value < -1.0e308 ->
{:error, {:invalid_sensor_value, "Sensor value must be finite number (not infinity)"}}
true ->
: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, "Counter 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.0 and ms <= @max_response_time_ms do
:ok
end
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"}}
# Validate LLDP neighbors list
defp validate_lldp_neighbors(neighbors) when is_list(neighbors) do
# Reasonably allow up to 1000 neighbors (large enterprise switches)
with :ok <- validate_neighbor_count(neighbors) do
validate_each_neighbor(neighbors)
end
end
defp validate_lldp_neighbors(_), do: {:error, {:invalid_neighbors, "Neighbors must be a list"}}
defp validate_neighbor_count(neighbors) when length(neighbors) > 1000 do
{:error, {:too_many_neighbors, "Neighbors list exceeds 1000 items"}}
end
defp validate_neighbor_count(_), do: :ok
defp validate_each_neighbor(neighbors) do
Enum.reduce_while(neighbors, :ok, fn neighbor, _acc ->
case validate_lldp_neighbor(neighbor) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
# Validate individual LLDP neighbor
defp validate_lldp_neighbor(%LldpNeighbor{} = neighbor) do
with :ok <- validate_short_string(neighbor.neighbor_name, "neighbor_name"),
:ok <- validate_short_string(neighbor.local_port, "local_port"),
:ok <- validate_short_string(neighbor.remote_port, "remote_port"),
:ok <- validate_short_string(neighbor.remote_port_id, "remote_port_id") do
validate_management_addresses(neighbor.management_addresses)
end
end
# Validate management addresses list
defp validate_management_addresses(addrs) when is_list(addrs) do
with :ok <- validate_address_count(addrs) do
validate_each_address(addrs)
end
end
defp validate_management_addresses(_), do: {:error, {:invalid_addresses, "Management addresses must be a list"}}
defp validate_address_count(addrs) when length(addrs) > 10 do
{:error, {:too_many_addresses, "Management addresses exceeds 10 items"}}
end
defp validate_address_count(_), do: :ok
defp validate_each_address(addrs) do
Enum.reduce_while(addrs, :ok, fn addr, _acc ->
case validate_optional_ip(addr) do
:ok -> {:cont, :ok}
error -> {:halt, error}
end
end)
end
# Validate check ID (UUID format, same as device_id)
defp validate_check_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_check_id, "Check ID must be valid UUID"}}
end
end
defp validate_check_id(_), do: {:error, {:invalid_check_id, "Check ID must be a string"}}
# Validate check status (0=OK, 1=WARNING, 2=CRITICAL, 3=UNKNOWN)
defp validate_check_status(status) when is_integer(status) and status >= 0 and status <= 3 do
:ok
end
defp validate_check_status(_), do: {:error, {:invalid_check_status, "Status must be 0-3"}}
# Validate check response time (non-negative, within max)
defp validate_check_response_time(ms) when is_float(ms) and ms >= 0.0 and ms <= @max_response_time_ms do
:ok
end
defp validate_check_response_time(_),
do: {:error, {:invalid_response_time, "Response time must be 0 to #{@max_response_time_ms}ms"}}
end

1035
lib/towerops/proto/agent.ex Normal file

File diff suppressed because it is too large Load diff

View file

@ -1,628 +0,0 @@
defmodule Towerops.Agent.JobType do
@moduledoc false
use Protobuf,
enum: true,
full_name: "towerops.agent.JobType",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :DISCOVER, 0
field :POLL, 1
field :MIKROTIK, 2
field :TEST_CREDENTIALS, 3
field :PING, 4
field :LLDP_TOPOLOGY, 5
end
defmodule Towerops.Agent.QueryType do
@moduledoc false
use Protobuf,
enum: true,
full_name: "towerops.agent.QueryType",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :GET, 0
field :WALK, 1
end
defmodule Towerops.Agent.AgentConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.AgentConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :version, 1, type: :string
field :poll_interval_seconds, 2, type: :uint32, json_name: "pollIntervalSeconds"
field :devices, 3, repeated: true, type: Towerops.Agent.Device
field :checks, 4, repeated: true, type: Towerops.Agent.Check
end
defmodule Towerops.Agent.Device do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Device",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :id, 1, type: :string
field :name, 2, type: :string
field :ip_address, 3, type: :string, json_name: "ipAddress"
field :snmp, 4, type: Towerops.Agent.SnmpConfig
field :poll_interval_seconds, 5, type: :uint32, json_name: "pollIntervalSeconds"
field :sensors, 6, repeated: true, type: Towerops.Agent.Sensor
field :interfaces, 7, repeated: true, type: Towerops.Agent.Interface
field :monitoring_enabled, 8, type: :bool, json_name: "monitoringEnabled"
field :check_interval_seconds, 9, type: :uint32, json_name: "checkIntervalSeconds"
end
defmodule Towerops.Agent.SnmpConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SnmpConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :enabled, 1, type: :bool
field :version, 2, type: :string
field :community, 3, type: :string
field :port, 4, type: :uint32
field :transport, 5, type: :string
end
defmodule Towerops.Agent.Sensor.MetadataEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Sensor.MetadataEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.Sensor do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Sensor",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :id, 1, type: :string
field :type, 2, type: :string
field :oid, 3, type: :string
field :divisor, 4, type: :double
field :unit, 5, type: :string
field :metadata, 6, repeated: true, type: Towerops.Agent.Sensor.MetadataEntry, map: true
end
defmodule Towerops.Agent.Interface do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Interface",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :id, 1, type: :string
field :if_index, 2, type: :uint32, json_name: "ifIndex"
field :if_name, 3, type: :string, json_name: "ifName"
end
defmodule Towerops.Agent.MetricBatch do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MetricBatch",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :metrics, 1, repeated: true, type: Towerops.Agent.Metric
end
defmodule Towerops.Agent.Metric do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Metric",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
oneof(:metric_type, 0)
field :sensor_reading, 1,
type: Towerops.Agent.SensorReading,
json_name: "sensorReading",
oneof: 0
field :interface_stat, 2,
type: Towerops.Agent.InterfaceStat,
json_name: "interfaceStat",
oneof: 0
field :neighbor_discovery, 3,
type: Towerops.Agent.NeighborDiscovery,
json_name: "neighborDiscovery",
oneof: 0
field :monitoring_check, 4,
type: Towerops.Agent.MonitoringCheck,
json_name: "monitoringCheck",
oneof: 0
field :check_result, 5, type: Towerops.Agent.CheckResult, json_name: "checkResult", oneof: 0
end
defmodule Towerops.Agent.SensorReading do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SensorReading",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :sensor_id, 1, type: :string, json_name: "sensorId"
field :value, 2, type: :double
field :status, 3, type: :string
field :timestamp, 4, type: :int64
end
defmodule Towerops.Agent.InterfaceStat do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.InterfaceStat",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :interface_id, 1, type: :string, json_name: "interfaceId"
field :if_in_octets, 2, type: :int64, json_name: "ifInOctets"
field :if_out_octets, 3, type: :int64, json_name: "ifOutOctets"
field :if_in_errors, 4, type: :int64, json_name: "ifInErrors"
field :if_out_errors, 5, type: :int64, json_name: "ifOutErrors"
field :if_in_discards, 6, type: :int64, json_name: "ifInDiscards"
field :if_out_discards, 7, type: :int64, json_name: "ifOutDiscards"
field :timestamp, 8, type: :int64
end
defmodule Towerops.Agent.NeighborDiscovery do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.NeighborDiscovery",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :interface_id, 1, type: :string, json_name: "interfaceId"
field :protocol, 2, type: :string
field :remote_chassis_id, 3, type: :string, json_name: "remoteChassisId"
field :remote_system_name, 4, type: :string, json_name: "remoteSystemName"
field :remote_system_description, 5, type: :string, json_name: "remoteSystemDescription"
field :remote_platform, 6, type: :string, json_name: "remotePlatform"
field :remote_port_id, 7, type: :string, json_name: "remotePortId"
field :remote_port_description, 8, type: :string, json_name: "remotePortDescription"
field :remote_address, 9, type: :string, json_name: "remoteAddress"
field :remote_capabilities, 10, repeated: true, type: :string, json_name: "remoteCapabilities"
field :timestamp, 11, type: :int64
end
defmodule Towerops.Agent.MonitoringCheck do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MonitoringCheck",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :status, 2, type: :string
field :response_time_ms, 3, type: :double, json_name: "responseTimeMs"
field :timestamp, 4, type: :int64
end
defmodule Towerops.Agent.Check do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.Check",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
oneof(:config, 0)
field :id, 1, type: :string
field :check_type, 2, type: :string, json_name: "checkType"
field :interval_seconds, 3, type: :uint32, json_name: "intervalSeconds"
field :timeout_ms, 4, type: :uint32, json_name: "timeoutMs"
field :http, 5, type: Towerops.Agent.HttpCheckConfig, oneof: 0
field :tcp, 6, type: Towerops.Agent.TcpCheckConfig, oneof: 0
field :dns, 7, type: Towerops.Agent.DnsCheckConfig, oneof: 0
field :ssl, 8, type: Towerops.Agent.SslCheckConfig, oneof: 0
end
defmodule Towerops.Agent.HttpCheckConfig.HeadersEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.HttpCheckConfig.HeadersEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.HttpCheckConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.HttpCheckConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :url, 1, type: :string
field :method, 2, type: :string
field :expected_status, 3, type: :uint32, json_name: "expectedStatus"
field :verify_ssl, 4, type: :bool, json_name: "verifySsl"
field :headers, 5, repeated: true, type: Towerops.Agent.HttpCheckConfig.HeadersEntry, map: true
field :body, 6, type: :string
field :regex, 7, type: :string
field :follow_redirects, 8, type: :bool, json_name: "followRedirects"
end
defmodule Towerops.Agent.TcpCheckConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.TcpCheckConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :host, 1, type: :string
field :port, 2, type: :uint32
field :send, 3, type: :string
field :expect, 4, type: :string
end
defmodule Towerops.Agent.DnsCheckConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.DnsCheckConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :hostname, 1, type: :string
field :server, 2, type: :string
field :record_type, 3, type: :string, json_name: "recordType"
field :expected, 4, type: :string
end
defmodule Towerops.Agent.SslCheckConfig do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SslCheckConfig",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :host, 1, type: :string
field :port, 2, type: :uint32
field :warning_days, 3, type: :uint32, json_name: "warningDays"
end
defmodule Towerops.Agent.CheckResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.CheckResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :check_id, 1, type: :string, json_name: "checkId"
field :status, 2, type: :uint32
field :output, 3, type: :string
field :response_time_ms, 4, type: :double, json_name: "responseTimeMs"
field :timestamp, 5, type: :int64
end
defmodule Towerops.Agent.CheckList do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.CheckList",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :checks, 1, repeated: true, type: Towerops.Agent.Check
end
defmodule Towerops.Agent.HeartbeatMetadata do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.HeartbeatMetadata",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :version, 1, type: :string
field :hostname, 2, type: :string
field :uptime_seconds, 3, type: :uint64, json_name: "uptimeSeconds"
end
defmodule Towerops.Agent.HeartbeatResponse do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.HeartbeatResponse",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :status, 1, type: :string
end
defmodule Towerops.Agent.AgentJobList do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.AgentJobList",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :jobs, 1, repeated: true, type: Towerops.Agent.AgentJob
end
defmodule Towerops.Agent.AgentJob do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.AgentJob",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :job_id, 1, type: :string, json_name: "jobId"
field :job_type, 2, type: Towerops.Agent.JobType, json_name: "jobType", enum: true
field :device_id, 3, type: :string, json_name: "deviceId"
field :snmp_device, 4, type: Towerops.Agent.SnmpDevice, json_name: "snmpDevice"
field :queries, 5, repeated: true, type: Towerops.Agent.SnmpQuery
field :mikrotik_device, 6, type: Towerops.Agent.MikrotikDevice, json_name: "mikrotikDevice"
field :mikrotik_commands, 7,
repeated: true,
type: Towerops.Agent.MikrotikCommand,
json_name: "mikrotikCommands"
end
defmodule Towerops.Agent.SnmpDevice do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SnmpDevice",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ip, 1, type: :string
field :community, 2, type: :string
field :version, 3, type: :string
field :port, 4, type: :uint32
field :v3_security_level, 5, type: :string, json_name: "v3SecurityLevel"
field :v3_username, 6, type: :string, json_name: "v3Username"
field :v3_auth_protocol, 7, type: :string, json_name: "v3AuthProtocol"
field :v3_auth_password, 8, type: :string, json_name: "v3AuthPassword"
field :v3_priv_protocol, 9, type: :string, json_name: "v3PrivProtocol"
field :v3_priv_password, 10, type: :string, json_name: "v3PrivPassword"
field :transport, 11, type: :string
end
defmodule Towerops.Agent.SnmpQuery do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SnmpQuery",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :query_type, 1, type: Towerops.Agent.QueryType, json_name: "queryType", enum: true
field :oids, 2, repeated: true, type: :string
end
defmodule Towerops.Agent.SnmpResult.OidValuesEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SnmpResult.OidValuesEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.SnmpResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.SnmpResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_type, 2, type: Towerops.Agent.JobType, json_name: "jobType", enum: true
field :oid_values, 3,
repeated: true,
type: Towerops.Agent.SnmpResult.OidValuesEntry,
json_name: "oidValues",
map: true
field :timestamp, 4, type: :int64
field :job_id, 5, type: :string, json_name: "jobId"
end
defmodule Towerops.Agent.AgentHeartbeat do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.AgentHeartbeat",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :version, 1, type: :string
field :hostname, 2, type: :string
field :uptime_seconds, 3, type: :uint64, json_name: "uptimeSeconds"
field :ip_address, 4, type: :string, json_name: "ipAddress"
field :arch, 5, type: :string
end
defmodule Towerops.Agent.AgentError do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.AgentError",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_id, 2, type: :string, json_name: "jobId"
field :message, 3, type: :string
field :timestamp, 4, type: :int64
end
defmodule Towerops.Agent.CredentialTestResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.CredentialTestResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :test_id, 1, type: :string, json_name: "testId"
field :success, 2, type: :bool
field :error_message, 3, type: :string, json_name: "errorMessage"
field :system_description, 4, type: :string, json_name: "systemDescription"
field :timestamp, 5, type: :int64
end
defmodule Towerops.Agent.MikrotikDevice do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikDevice",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :ip, 1, type: :string
field :port, 2, type: :uint32
field :username, 3, type: :string
field :password, 4, type: :string
field :use_ssl, 5, type: :bool, json_name: "useSsl"
field :ssh_port, 6, type: :uint32, json_name: "sshPort"
end
defmodule Towerops.Agent.MikrotikCommand.ArgsEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikCommand.ArgsEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.MikrotikCommand do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikCommand",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :command, 1, type: :string
field :args, 2, repeated: true, type: Towerops.Agent.MikrotikCommand.ArgsEntry, map: true
end
defmodule Towerops.Agent.MikrotikResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_id, 2, type: :string, json_name: "jobId"
field :sentences, 3, repeated: true, type: Towerops.Agent.MikrotikSentence
field :error, 4, type: :string
field :timestamp, 5, type: :int64
end
defmodule Towerops.Agent.MikrotikSentence.AttributesEntry do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikSentence.AttributesEntry",
map: true,
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :key, 1, type: :string
field :value, 2, type: :string
end
defmodule Towerops.Agent.MikrotikSentence do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.MikrotikSentence",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :attributes, 1,
repeated: true,
type: Towerops.Agent.MikrotikSentence.AttributesEntry,
map: true
end
defmodule Towerops.Agent.LldpTopologyResult do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.LldpTopologyResult",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :device_id, 1, type: :string, json_name: "deviceId"
field :job_id, 2, type: :string, json_name: "jobId"
field :local_system_name, 3, type: :string, json_name: "localSystemName"
field :neighbors, 4, repeated: true, type: Towerops.Agent.LldpNeighbor
field :timestamp, 5, type: :int64
end
defmodule Towerops.Agent.LldpNeighbor do
@moduledoc false
use Protobuf,
full_name: "towerops.agent.LldpNeighbor",
protoc_gen_elixir_version: "0.16.0",
syntax: :proto3
field :neighbor_name, 1, type: :string, json_name: "neighborName"
field :local_port, 2, type: :string, json_name: "localPort"
field :remote_port, 3, type: :string, json_name: "remotePort"
field :remote_port_id, 4, type: :string, json_name: "remotePortId"
field :management_addresses, 5, repeated: true, type: :string, json_name: "managementAddresses"
end

View file

@ -21,22 +21,26 @@ defmodule ToweropsWeb.AgentChannel do
use ToweropsWeb, :channel
alias Towerops.Agent.AgentError
alias Towerops.Agent.AgentHeartbeat
alias Towerops.Agent.AgentJob
alias Towerops.Agent.AgentJobList
alias Towerops.Agent.Check, as: CheckProto
alias Towerops.Agent.CheckList
alias Towerops.Agent.CheckResult, as: CheckResultProto
alias Towerops.Agent.CredentialTestResult
alias Towerops.Agent.DnsCheckConfig
alias Towerops.Agent.HttpCheckConfig
alias Towerops.Agent.LldpTopologyResult
alias Towerops.Agent.MikrotikCommand
alias Towerops.Agent.MikrotikDevice
alias Towerops.Agent.MikrotikResult
alias Towerops.Agent.MonitoringCheck, as: MonitoringCheckProto
alias Towerops.Agent.SnmpDevice
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
alias Towerops.Agent.SslCheckConfig
alias Towerops.Agent.TcpCheckConfig
alias Towerops.Agent.Validator
alias Towerops.Agents
alias Towerops.Alerts
alias Towerops.ConfigChanges
@ -484,7 +488,7 @@ defmodule ToweropsWeb.AgentChannel do
{:reply, {:error, %{reason: "Message too large"}}, socket}
else
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, error} <- Validator.validate_agent_error(binary) do
{:ok, error} <- AgentError.decode(binary) do
maybe_debug_log(socket, "Agent job error",
device_id: error.device_id,
error_message: error.message,
@ -513,7 +517,7 @@ defmodule ToweropsWeb.AgentChannel do
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
{:ok, result} <- MikrotikResult.decode(binary) do
handle_validated_mikrotik_result(result, socket)
else
{:error, {type, message}} ->
@ -529,7 +533,7 @@ defmodule ToweropsWeb.AgentChannel do
def handle_in("credential_test_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_credential_test_result(binary) do
{:ok, result} <- CredentialTestResult.decode(binary) do
Logger.info("AgentChannel received credential test result from agent, broadcasting to LiveView",
agent_token_id: socket.assigns.agent_token_id,
test_id: result.test_id,
@ -573,7 +577,7 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket()}
def handle_in("monitoring_check", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, check} <- Validator.validate_monitoring_check_message(binary) do
{:ok, check} <- MonitoringCheckProto.decode(binary) do
maybe_debug_log(socket, "Received ping result from agent",
device_id: check.device_id,
status: check.status,
@ -619,7 +623,7 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket()}
def handle_in("check_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_check_result(binary) do
{:ok, result} <- CheckResultProto.decode(binary) do
maybe_debug_log(socket, "Received check result from agent",
check_id: result.check_id,
status: result.status,
@ -664,7 +668,7 @@ defmodule ToweropsWeb.AgentChannel do
{:noreply, socket()}
def handle_in("lldp_topology_result", %{"binary" => binary_b64}, socket) when is_binary(binary_b64) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_lldp_topology_result(binary) do
{:ok, result} <- LldpTopologyResult.decode(binary) do
maybe_debug_log(socket, "Received LLDP topology result from agent",
device_id: result.device_id,
job_id: result.job_id,
@ -726,7 +730,7 @@ defmodule ToweropsWeb.AgentChannel do
Logger.info("Received SNMP result from agent (binary size: #{byte_size(binary_b64)})")
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, result} <- Validator.validate_snmp_result(binary) do
{:ok, result} <- SnmpResult.decode(binary) do
maybe_debug_log(socket, "Received SNMP result from agent",
device_id: result.device_id,
job_type: result.job_type,
@ -766,7 +770,7 @@ defmodule ToweropsWeb.AgentChannel do
defp process_heartbeat_message(binary_b64, socket) do
with {:ok, binary} <- safe_base64_decode(binary_b64),
{:ok, heartbeat} <- Validator.validate_heartbeat(binary) do
{:ok, heartbeat} <- AgentHeartbeat.decode(binary) do
now = DateTime.utc_now()
last_db_update = socket.assigns[:last_heartbeat_db_update]

View file

@ -2,12 +2,23 @@
# You typically do not need to edit this file
packages = [
{ name = "argv", version = "1.0.2", build_tools = ["gleam"], requirements = [], otp_app = "argv", source = "hex", outer_checksum = "BA1FF0929525DEBA1CE67256E5ADF77A7CDDFE729E3E3F57A5BDCAA031DED09D" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "glance", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_stdlib", "glexer"], otp_app = "glance", source = "hex", outer_checksum = "49E0ED4793BB3F56C3E5ED00528D70CAE21D263F70A735604124B95C5F62E2DB" },
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_regexp", version = "1.1.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_regexp", source = "hex", outer_checksum = "9C215C6CA84A5B35BB934A9B61A9A306EC743153BE2B0425A0D032E477B062A9" },
{ name = "gleam_stdlib", version = "0.70.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "86949BF5D1F0E4AC0AB5B06F235D8A5CC11A2DFC33BF22F752156ED61CA7D0FF" },
{ name = "gleam_time", version = "1.7.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_time", source = "hex", outer_checksum = "56DB0EF9433826D3B99DB0B4AF7A2BFED13D09755EC64B1DAAB46F804A9AD47D" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
{ name = "glexer", version = "2.3.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "splitter"], otp_app = "glexer", source = "hex", outer_checksum = "41D8D2E855AEA87ADC94B7AF26A5FEA3C90268D4CF2CCBBD64FD6863714EE085" },
{ name = "glinter", version = "1.0.0", build_tools = ["gleam"], requirements = ["argv", "glance", "gleam_json", "gleam_stdlib", "simplifile", "tom"], otp_app = "glinter", source = "hex", outer_checksum = "6497CDC6C0722048C6FADC69DB45AA87940ACA55F13542D6C75F1F0DD4758807" },
{ name = "simplifile", version = "2.4.0", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "7C18AFA4FED0B4CE1FA5B0B4BAC1FA1744427054EA993565F6F3F82E5453170D" },
{ name = "splitter", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "splitter", source = "hex", outer_checksum = "3DFD6B6C49E61EDAF6F7B27A42054A17CFF6CA2135FF553D0CB61C234D281DD0" },
{ name = "tom", version = "2.0.1", build_tools = ["gleam"], requirements = ["gleam_stdlib", "gleam_time"], otp_app = "tom", source = "hex", outer_checksum = "90791DA4AACE637E30081FE77049B8DB850FBC8CACC31515376BCC4E59BE1DD2" },
]
[requirements]
gleam_regexp = { version = ">= 1.0.0 and < 2.0.0" }
gleam_stdlib = { version = ">= 0.34.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
glinter = { version = ">= 1.0.0 and < 2.0.0" }

View file

@ -66,7 +66,6 @@ defmodule Towerops.MixProject do
{:swoosh, "~> 1.16"},
{:gen_smtp, "~> 1.0"},
{:cbor, "~> 1.0"},
{:protobuf, "~> 0.12"},
{:req, "~> 0.5"},
{:castore, "~> 1.0"},
{:sweet_xml, "~> 0.7"},
@ -142,7 +141,13 @@ defmodule Towerops.MixProject do
"esbuild towerops --minify",
"phx.digest"
],
precommit: ["compile --warnings-as-errors", "deps.unlock --unused", "format", "test"]
precommit: [
"compile --warnings-as-errors",
"deps.unlock --unused",
"format",
~s{cmd bash -c 'output=$(gleam run -m glinter 2>&1); echo "$output"; echo "$output" | grep -qE "\\([1-9][0-9]* errors?," && exit 1 || exit 0'},
"test"
]
]
end
end

View file

@ -79,7 +79,6 @@
"poolboy": {:hex, :poolboy, "1.5.2", "392b007a1693a64540cead79830443abf5762f5d30cf50bc95cb2c1aaafa006b", [:rebar3], [], "hexpm", "dad79704ce5440f3d5a3681c8590b9dc25d1a561e8f5a9c995281012860901e3"},
"postgrex": {:hex, :postgrex, "0.22.0", "fb027b58b6eab1f6de5396a2abcdaaeb168f9ed4eccbb594e6ac393b02078cbd", [:mix], [{:db_connection, "~> 2.9", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "a68c4261e299597909e03e6f8ff5a13876f5caadaddd0d23af0d0a61afcc5d84"},
"process_tree": {:hex, :process_tree, "0.2.1", "4ebcaa96c64a7833467909f49fee28a8e62eed04975613f4c81b4b99424f7e8a", [:mix], [], "hexpm", "68eee6bf0514351aeeda7037f1a6003c0e25de48fe6b7d15a1b0aebb4b35e713"},
"protobuf": {:hex, :protobuf, "0.16.0", "d1878725105d49162977cf3408ccc3eac4f3532e26e5a9e250f2c624175d10f6", [:mix], [{:jason, "~> 1.2", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "f0d0d3edd8768130f24cc2cfc41320637d32c80110e80d13f160fa699102c828"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"redix": {:hex, :redix, "1.5.3", "4eaae29c75e3285c0ff9957046b7c209aa7f72a023a17f0a9ea51c2a50ab5b0f", [:mix], [{:castore, "~> 0.1.0 or ~> 1.0", [hex: :castore, repo: "hexpm", optional: true]}, {:nimble_options, "~> 0.5.0 or ~> 1.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7b06fb5246373af41f5826b03334dfa3f636347d4d5d98b4d455b699d425ae7e"},
"req": {:hex, :req, "0.5.17", "0096ddd5b0ed6f576a03dde4b158a0c727215b15d2795e59e0916c6971066ede", [:mix], [{:brotli, "~> 0.3.1", [hex: :brotli, repo: "hexpm", optional: true]}, {:ezstd, "~> 1.0", [hex: :ezstd, repo: "hexpm", optional: true]}, {:finch, "~> 0.17", [hex: :finch, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:mime, "~> 2.0.6 or ~> 2.1", [hex: :mime, repo: "hexpm", optional: false]}, {:nimble_csv, "~> 1.0", [hex: :nimble_csv, repo: "hexpm", optional: true]}, {:plug, "~> 1.0", [hex: :plug, repo: "hexpm", optional: true]}], "hexpm", "0b8bc6ffdfebbc07968e59d3ff96d52f2202d0536f10fef4dc11dc02a2a43e39"},

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,416 @@
/// Protobuf encode functions for all message types.
///
/// Uses BytesTree for efficient concatenation. Each function accepts
/// a Gleam type and returns a BitArray of wire-format bytes.
/// Default values (empty string, 0, false) are omitted per proto3 spec.
import gleam/bytes_tree
import gleam/dict.{type Dict}
import gleam/list
import gleam/option.{type Option, None, Some}
import towerops/proto/types
import towerops/proto/wire
// --- Public encode functions ---
pub fn encode_agent_heartbeat(hb: types.AgentHeartbeat) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, hb.version)
|> wire.encode_string_field(2, hb.hostname)
|> wire.encode_uint_field(3, hb.uptime_seconds)
|> wire.encode_string_field(4, hb.ip_address)
|> wire.encode_string_field(5, hb.arch)
|> bytes_tree.to_bit_array
}
pub fn encode_heartbeat_metadata(m: types.HeartbeatMetadata) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, m.version)
|> wire.encode_string_field(2, m.hostname)
|> wire.encode_uint_field(3, m.uptime_seconds)
|> bytes_tree.to_bit_array
}
pub fn encode_heartbeat_response(r: types.HeartbeatResponse) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, r.status)
|> bytes_tree.to_bit_array
}
pub fn encode_agent_config(c: types.AgentConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, c.version)
|> wire.encode_uint_field(2, c.poll_interval_seconds)
|> encode_repeated_messages(3, c.devices, encode_device)
|> encode_repeated_messages(4, c.checks, encode_check)
|> bytes_tree.to_bit_array
}
pub fn encode_device(d: types.Device) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, d.id)
|> wire.encode_string_field(2, d.name)
|> wire.encode_string_field(3, d.ip_address)
|> encode_optional_message(4, d.snmp, encode_snmp_config)
|> wire.encode_uint_field(5, d.poll_interval_seconds)
|> encode_repeated_messages(6, d.sensors, encode_sensor)
|> encode_repeated_messages(7, d.interfaces, encode_interface)
|> wire.encode_bool_field(8, d.monitoring_enabled)
|> wire.encode_uint_field(9, d.check_interval_seconds)
|> bytes_tree.to_bit_array
}
pub fn encode_snmp_config(s: types.SnmpConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_bool_field(1, s.enabled)
|> wire.encode_string_field(2, s.version)
|> wire.encode_string_field(3, s.community)
|> wire.encode_uint_field(4, s.port)
|> wire.encode_string_field(5, s.transport)
|> bytes_tree.to_bit_array
}
pub fn encode_sensor(s: types.Sensor) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, s.id)
|> wire.encode_string_field(2, s.sensor_type)
|> wire.encode_string_field(3, s.oid)
|> wire.encode_double_field(4, s.divisor)
|> wire.encode_string_field(5, s.unit)
|> encode_map_fields(6, s.metadata)
|> bytes_tree.to_bit_array
}
pub fn encode_interface(i: types.Interface) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, i.id)
|> wire.encode_uint_field(2, i.if_index)
|> wire.encode_string_field(3, i.if_name)
|> bytes_tree.to_bit_array
}
pub fn encode_metric_batch(batch: types.MetricBatch) -> BitArray {
bytes_tree.new()
|> encode_repeated_messages(1, batch.metrics, encode_metric)
|> bytes_tree.to_bit_array
}
pub fn encode_metric(m: types.Metric) -> BitArray {
let builder = bytes_tree.new()
case m {
types.SensorReadingMetric(sr) ->
wire.encode_message_field(builder, 1, encode_sensor_reading(sr))
types.InterfaceStatMetric(is) ->
wire.encode_message_field(builder, 2, encode_interface_stat(is))
types.NeighborDiscoveryMetric(nd) ->
wire.encode_message_field(builder, 3, encode_neighbor_discovery(nd))
types.MonitoringCheckMetric(mc) ->
wire.encode_message_field(builder, 4, encode_monitoring_check(mc))
types.CheckResultMetric(cr) ->
wire.encode_message_field(builder, 5, encode_check_result(cr))
}
|> bytes_tree.to_bit_array
}
pub fn encode_sensor_reading(sr: types.SensorReading) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, sr.sensor_id)
|> wire.encode_double_field(2, sr.value)
|> wire.encode_string_field(3, sr.status)
|> wire.encode_int64_field(4, sr.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_interface_stat(is: types.InterfaceStat) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, is.interface_id)
|> wire.encode_int64_field(2, is.if_in_octets)
|> wire.encode_int64_field(3, is.if_out_octets)
|> wire.encode_int64_field(4, is.if_in_errors)
|> wire.encode_int64_field(5, is.if_out_errors)
|> wire.encode_int64_field(6, is.if_in_discards)
|> wire.encode_int64_field(7, is.if_out_discards)
|> wire.encode_int64_field(8, is.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_neighbor_discovery(nd: types.NeighborDiscovery) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, nd.interface_id)
|> wire.encode_string_field(2, nd.protocol)
|> wire.encode_string_field(3, nd.remote_chassis_id)
|> wire.encode_string_field(4, nd.remote_system_name)
|> wire.encode_string_field(5, nd.remote_system_description)
|> wire.encode_string_field(6, nd.remote_platform)
|> wire.encode_string_field(7, nd.remote_port_id)
|> wire.encode_string_field(8, nd.remote_port_description)
|> wire.encode_string_field(9, nd.remote_address)
|> encode_repeated_strings(10, nd.remote_capabilities)
|> wire.encode_int64_field(11, nd.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_monitoring_check(mc: types.MonitoringCheck) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, mc.device_id)
|> wire.encode_string_field(2, mc.status)
|> wire.encode_double_field(3, mc.response_time_ms)
|> wire.encode_int64_field(4, mc.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_check(c: types.Check) -> BitArray {
let builder =
bytes_tree.new()
|> wire.encode_string_field(1, c.id)
|> wire.encode_string_field(2, c.check_type)
|> wire.encode_uint_field(3, c.interval_seconds)
|> wire.encode_uint_field(4, c.timeout_ms)
case c.config {
types.HttpConfig(h) ->
wire.encode_message_field(builder, 5, encode_http_check_config(h))
types.TcpConfig(t) ->
wire.encode_message_field(builder, 6, encode_tcp_check_config(t))
types.DnsConfig(d) ->
wire.encode_message_field(builder, 7, encode_dns_check_config(d))
types.SslConfig(s) ->
wire.encode_message_field(builder, 8, encode_ssl_check_config(s))
types.NoConfig -> builder
}
|> bytes_tree.to_bit_array
}
pub fn encode_http_check_config(h: types.HttpCheckConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, h.url)
|> wire.encode_string_field(2, h.method)
|> wire.encode_uint_field(3, h.expected_status)
|> wire.encode_bool_field(4, h.verify_ssl)
|> encode_map_fields(5, h.headers)
|> wire.encode_string_field(6, h.body)
|> wire.encode_string_field(7, h.regex)
|> wire.encode_bool_field(8, h.follow_redirects)
|> bytes_tree.to_bit_array
}
pub fn encode_tcp_check_config(t: types.TcpCheckConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, t.host)
|> wire.encode_uint_field(2, t.port)
|> wire.encode_string_field(3, t.send)
|> wire.encode_string_field(4, t.expect)
|> bytes_tree.to_bit_array
}
pub fn encode_dns_check_config(d: types.DnsCheckConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, d.hostname)
|> wire.encode_string_field(2, d.server)
|> wire.encode_string_field(3, d.record_type)
|> wire.encode_string_field(4, d.expected)
|> bytes_tree.to_bit_array
}
pub fn encode_ssl_check_config(s: types.SslCheckConfig) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, s.host)
|> wire.encode_uint_field(2, s.port)
|> wire.encode_uint_field(3, s.warning_days)
|> bytes_tree.to_bit_array
}
pub fn encode_check_result(cr: types.CheckResult) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, cr.check_id)
|> wire.encode_uint_field(2, cr.status)
|> wire.encode_string_field(3, cr.output)
|> wire.encode_double_field(4, cr.response_time_ms)
|> wire.encode_int64_field(5, cr.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_check_list(cl: types.CheckList) -> BitArray {
bytes_tree.new()
|> encode_repeated_messages(1, cl.checks, encode_check)
|> bytes_tree.to_bit_array
}
pub fn encode_agent_job_list(jl: types.AgentJobList) -> BitArray {
bytes_tree.new()
|> encode_repeated_messages(1, jl.jobs, encode_agent_job)
|> bytes_tree.to_bit_array
}
pub fn encode_agent_job(j: types.AgentJob) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, j.job_id)
|> wire.encode_enum_field(2, types.job_type_to_int(j.job_type))
|> wire.encode_string_field(3, j.device_id)
|> encode_optional_message(4, j.snmp_device, encode_snmp_device)
|> encode_repeated_messages(5, j.queries, encode_snmp_query)
|> encode_optional_message(6, j.mikrotik_device, encode_mikrotik_device)
|> encode_repeated_messages(7, j.mikrotik_commands, encode_mikrotik_command)
|> bytes_tree.to_bit_array
}
pub fn encode_snmp_device(d: types.SnmpDevice) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, d.ip)
|> wire.encode_string_field(2, d.community)
|> wire.encode_string_field(3, d.version)
|> wire.encode_uint_field(4, d.port)
|> wire.encode_string_field(5, d.v3_security_level)
|> wire.encode_string_field(6, d.v3_username)
|> wire.encode_string_field(7, d.v3_auth_protocol)
|> wire.encode_string_field(8, d.v3_auth_password)
|> wire.encode_string_field(9, d.v3_priv_protocol)
|> wire.encode_string_field(10, d.v3_priv_password)
|> wire.encode_string_field(11, d.transport)
|> bytes_tree.to_bit_array
}
pub fn encode_snmp_query(q: types.SnmpQuery) -> BitArray {
bytes_tree.new()
|> wire.encode_enum_field(1, types.query_type_to_int(q.query_type))
|> encode_repeated_strings(2, q.oids)
|> bytes_tree.to_bit_array
}
pub fn encode_snmp_result(r: types.SnmpResult) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, r.device_id)
|> wire.encode_enum_field(2, types.job_type_to_int(r.job_type))
|> encode_map_fields(3, r.oid_values)
|> wire.encode_int64_field(4, r.timestamp)
|> wire.encode_string_field(5, r.job_id)
|> bytes_tree.to_bit_array
}
pub fn encode_agent_error(e: types.AgentError) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, e.device_id)
|> wire.encode_string_field(2, e.job_id)
|> wire.encode_string_field(3, e.message)
|> wire.encode_int64_field(4, e.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_credential_test_result(r: types.CredentialTestResult) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, r.test_id)
|> wire.encode_bool_field(2, r.success)
|> wire.encode_string_field(3, r.error_message)
|> wire.encode_string_field(4, r.system_description)
|> wire.encode_int64_field(5, r.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_mikrotik_device(d: types.MikrotikDevice) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, d.ip)
|> wire.encode_uint_field(2, d.port)
|> wire.encode_string_field(3, d.username)
|> wire.encode_string_field(4, d.password)
|> wire.encode_bool_field(5, d.use_ssl)
|> wire.encode_uint_field(6, d.ssh_port)
|> bytes_tree.to_bit_array
}
pub fn encode_mikrotik_command(c: types.MikrotikCommand) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, c.command)
|> encode_map_fields(2, c.args)
|> bytes_tree.to_bit_array
}
pub fn encode_mikrotik_result(r: types.MikrotikResult) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, r.device_id)
|> wire.encode_string_field(2, r.job_id)
|> encode_repeated_messages(3, r.sentences, encode_mikrotik_sentence)
|> wire.encode_string_field(4, r.error)
|> wire.encode_int64_field(5, r.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_mikrotik_sentence(s: types.MikrotikSentence) -> BitArray {
bytes_tree.new()
|> encode_map_fields(1, s.attributes)
|> bytes_tree.to_bit_array
}
pub fn encode_lldp_topology_result(r: types.LldpTopologyResult) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, r.device_id)
|> wire.encode_string_field(2, r.job_id)
|> wire.encode_string_field(3, r.local_system_name)
|> encode_repeated_messages(4, r.neighbors, encode_lldp_neighbor)
|> wire.encode_int64_field(5, r.timestamp)
|> bytes_tree.to_bit_array
}
pub fn encode_lldp_neighbor(n: types.LldpNeighbor) -> BitArray {
bytes_tree.new()
|> wire.encode_string_field(1, n.neighbor_name)
|> wire.encode_string_field(2, n.local_port)
|> wire.encode_string_field(3, n.remote_port)
|> wire.encode_string_field(4, n.remote_port_id)
|> encode_repeated_strings(5, n.management_addresses)
|> bytes_tree.to_bit_array
}
// --- Helpers ---
fn encode_optional_message(
builder: bytes_tree.BytesTree,
field_number: Int,
optional: Option(a),
encode_fn: fn(a) -> BitArray,
) -> bytes_tree.BytesTree {
case optional {
Some(value) ->
wire.encode_message_field(builder, field_number, encode_fn(value))
None -> builder
}
}
fn encode_repeated_messages(
builder: bytes_tree.BytesTree,
field_number: Int,
items: List(a),
encode_fn: fn(a) -> BitArray,
) -> bytes_tree.BytesTree {
list.fold(items, builder, fn(b, item) {
wire.encode_message_field(b, field_number, encode_fn(item))
})
}
fn encode_repeated_strings(
builder: bytes_tree.BytesTree,
field_number: Int,
items: List(String),
) -> bytes_tree.BytesTree {
list.fold(items, builder, fn(b, item) {
let data = string_to_bits(item)
b
|> bytes_tree.append(wire.encode_tag(field_number, wire.wire_length_delimited))
|> bytes_tree.append(wire.encode_bytes(data))
})
}
fn encode_map_fields(
builder: bytes_tree.BytesTree,
field_number: Int,
map: Dict(String, String),
) -> bytes_tree.BytesTree {
dict.fold(map, builder, fn(b, key, value) {
let entry =
bytes_tree.new()
|> wire.encode_string_field(1, key)
|> wire.encode_string_field(2, value)
|> bytes_tree.to_bit_array
wire.encode_message_field(b, field_number, entry)
})
}
@external(erlang, "towerops_proto_wire_ffi", "string_to_bits")
fn string_to_bits(s: String) -> BitArray

View file

@ -0,0 +1,392 @@
/// All protobuf message types and enums for agent communication.
import gleam/dict.{type Dict}
import gleam/option.{type Option}
// --- Enums ---
pub type JobType {
Discover
Poll
Mikrotik
TestCredentials
Ping
LldpTopology
}
pub fn job_type_to_int(jt: JobType) -> Int {
case jt {
Discover -> 0
Poll -> 1
Mikrotik -> 2
TestCredentials -> 3
Ping -> 4
LldpTopology -> 5
}
}
pub fn job_type_from_int(i: Int) -> Result(JobType, Nil) {
case i {
0 -> Ok(Discover)
1 -> Ok(Poll)
2 -> Ok(Mikrotik)
3 -> Ok(TestCredentials)
4 -> Ok(Ping)
5 -> Ok(LldpTopology)
_ -> Error(Nil)
}
}
pub type QueryType {
Get
Walk
}
pub fn query_type_to_int(qt: QueryType) -> Int {
case qt {
Get -> 0
Walk -> 1
}
}
pub fn query_type_from_int(i: Int) -> Result(QueryType, Nil) {
case i {
0 -> Ok(Get)
1 -> Ok(Walk)
_ -> Error(Nil)
}
}
// --- Decode errors ---
pub type DecodeError {
WireError(String)
InvalidDeviceId(String)
InvalidSensorId(String)
InvalidInterfaceId(String)
InvalidCheckId(String)
InvalidTestId(String)
InvalidJobId(String)
InvalidVersion(String)
InvalidHostname(String)
InvalidUptime(String)
InvalidIp(String)
InvalidStatus(String)
InvalidProtocol(String)
InvalidCounter(String)
InvalidTimestamp(String)
InvalidResponseTime(String)
InvalidCheckStatus(String)
InvalidSensorValue(String)
InvalidMetric(String)
BatchTooLarge(String)
TooManyOids(String)
TooManySentences(String)
TooManyCapabilities(String)
TooManyNeighbors(String)
TooManyAddresses(String)
StringTooLong(String)
InvalidErrorMessage(String)
}
// --- Message types ---
pub type AgentHeartbeat {
AgentHeartbeat(
version: String,
hostname: String,
uptime_seconds: Int,
ip_address: String,
arch: String,
)
}
pub type HeartbeatMetadata {
HeartbeatMetadata(version: String, hostname: String, uptime_seconds: Int)
}
pub type HeartbeatResponse {
HeartbeatResponse(status: String)
}
pub type AgentConfig {
AgentConfig(
version: String,
poll_interval_seconds: Int,
devices: List(Device),
checks: List(Check),
)
}
pub type Device {
Device(
id: String,
name: String,
ip_address: String,
snmp: Option(SnmpConfig),
poll_interval_seconds: Int,
sensors: List(Sensor),
interfaces: List(Interface),
monitoring_enabled: Bool,
check_interval_seconds: Int,
)
}
pub type SnmpConfig {
SnmpConfig(
enabled: Bool,
version: String,
community: String,
port: Int,
transport: String,
)
}
pub type Sensor {
Sensor(
id: String,
sensor_type: String,
oid: String,
divisor: Float,
unit: String,
metadata: Dict(String, String),
)
}
pub type Interface {
Interface(id: String, if_index: Int, if_name: String)
}
pub type MetricBatch {
MetricBatch(metrics: List(Metric))
}
pub type Metric {
SensorReadingMetric(SensorReading)
InterfaceStatMetric(InterfaceStat)
NeighborDiscoveryMetric(NeighborDiscovery)
MonitoringCheckMetric(MonitoringCheck)
CheckResultMetric(CheckResult)
}
pub type SensorReading {
SensorReading(
sensor_id: String,
value: Float,
status: String,
timestamp: Int,
)
}
pub type InterfaceStat {
InterfaceStat(
interface_id: String,
if_in_octets: Int,
if_out_octets: Int,
if_in_errors: Int,
if_out_errors: Int,
if_in_discards: Int,
if_out_discards: Int,
timestamp: Int,
)
}
pub type NeighborDiscovery {
NeighborDiscovery(
interface_id: String,
protocol: String,
remote_chassis_id: String,
remote_system_name: String,
remote_system_description: String,
remote_platform: String,
remote_port_id: String,
remote_port_description: String,
remote_address: String,
remote_capabilities: List(String),
timestamp: Int,
)
}
pub type MonitoringCheck {
MonitoringCheck(
device_id: String,
status: String,
response_time_ms: Float,
timestamp: Int,
)
}
pub type Check {
Check(
id: String,
check_type: String,
interval_seconds: Int,
timeout_ms: Int,
config: CheckConfig,
)
}
pub type CheckConfig {
HttpConfig(HttpCheckConfig)
TcpConfig(TcpCheckConfig)
DnsConfig(DnsCheckConfig)
SslConfig(SslCheckConfig)
NoConfig
}
pub type HttpCheckConfig {
HttpCheckConfig(
url: String,
method: String,
expected_status: Int,
verify_ssl: Bool,
headers: Dict(String, String),
body: String,
regex: String,
follow_redirects: Bool,
)
}
pub type TcpCheckConfig {
TcpCheckConfig(host: String, port: Int, send: String, expect: String)
}
pub type DnsCheckConfig {
DnsCheckConfig(
hostname: String,
server: String,
record_type: String,
expected: String,
)
}
pub type SslCheckConfig {
SslCheckConfig(host: String, port: Int, warning_days: Int)
}
pub type CheckResult {
CheckResult(
check_id: String,
status: Int,
output: String,
response_time_ms: Float,
timestamp: Int,
)
}
pub type CheckList {
CheckList(checks: List(Check))
}
pub type AgentJobList {
AgentJobList(jobs: List(AgentJob))
}
pub type AgentJob {
AgentJob(
job_id: String,
job_type: JobType,
device_id: String,
snmp_device: Option(SnmpDevice),
queries: List(SnmpQuery),
mikrotik_device: Option(MikrotikDevice),
mikrotik_commands: List(MikrotikCommand),
)
}
pub type SnmpDevice {
SnmpDevice(
ip: String,
community: String,
version: String,
port: Int,
v3_security_level: String,
v3_username: String,
v3_auth_protocol: String,
v3_auth_password: String,
v3_priv_protocol: String,
v3_priv_password: String,
transport: String,
)
}
pub type SnmpQuery {
SnmpQuery(query_type: QueryType, oids: List(String))
}
pub type SnmpResult {
SnmpResult(
device_id: String,
job_type: JobType,
oid_values: Dict(String, String),
timestamp: Int,
job_id: String,
)
}
pub type AgentError {
AgentError(
device_id: String,
job_id: String,
message: String,
timestamp: Int,
)
}
pub type CredentialTestResult {
CredentialTestResult(
test_id: String,
success: Bool,
error_message: String,
system_description: String,
timestamp: Int,
)
}
pub type MikrotikDevice {
MikrotikDevice(
ip: String,
port: Int,
username: String,
password: String,
use_ssl: Bool,
ssh_port: Int,
)
}
pub type MikrotikCommand {
MikrotikCommand(command: String, args: Dict(String, String))
}
pub type MikrotikResult {
MikrotikResult(
device_id: String,
job_id: String,
sentences: List(MikrotikSentence),
error: String,
timestamp: Int,
)
}
pub type MikrotikSentence {
MikrotikSentence(attributes: Dict(String, String))
}
pub type LldpTopologyResult {
LldpTopologyResult(
device_id: String,
job_id: String,
local_system_name: String,
neighbors: List(LldpNeighbor),
timestamp: Int,
)
}
pub type LldpNeighbor {
LldpNeighbor(
neighbor_name: String,
local_port: String,
remote_port: String,
remote_port_id: String,
management_addresses: List(String),
)
}

View file

@ -0,0 +1,335 @@
/// Protobuf wire format primitives.
///
/// Implements varint encoding/decoding, tag parsing, length-delimited fields,
/// IEEE 754 doubles (via FFI), and int64 two's complement encoding.
import gleam/bytes_tree.{type BytesTree}
/// Wire type constants
pub const wire_varint = 0
pub const wire_64bit = 1
pub const wire_length_delimited = 2
pub const wire_32bit = 5
pub type WireError {
UnexpectedEof
InvalidVarint
InvalidWireType(Int)
InvalidTag
}
// --- Varint ---
/// Encode an unsigned integer as a varint.
pub fn encode_varint(value: Int) -> BitArray {
encode_varint_loop(value, <<>>)
}
fn encode_varint_loop(value: Int, acc: BitArray) -> BitArray {
case value < 128 {
True -> bits_append(acc, <<value:8>>)
False -> {
let byte = int_or(int_and(value, 0x7F), 0x80)
encode_varint_loop(
shift_right(value, 7),
bits_append(acc, <<byte:8>>),
)
}
}
}
/// Decode a varint from the front of a BitArray.
/// Returns the value and remaining bytes.
pub fn decode_varint(data: BitArray) -> Result(#(Int, BitArray), WireError) {
decode_varint_loop(data, 0, 0)
}
fn decode_varint_loop(
data: BitArray,
value: Int,
shift: Int,
) -> Result(#(Int, BitArray), WireError) {
case data {
<<byte:8, rest:bits>> -> {
let v = int_or(value, shift_left(int_and(byte, 0x7F), shift))
case int_and(byte, 0x80) == 0 {
True -> Ok(#(v, rest))
False ->
case shift >= 63 {
True -> Error(InvalidVarint)
False -> decode_varint_loop(rest, v, shift + 7)
}
}
}
_ -> Error(UnexpectedEof)
}
}
// --- Tags ---
/// Encode a field tag (field_number << 3 | wire_type).
pub fn encode_tag(field_number: Int, wire_type: Int) -> BitArray {
encode_varint(int_or(shift_left(field_number, 3), wire_type))
}
/// Decode a field tag. Returns (field_number, wire_type, rest).
pub fn decode_tag(
data: BitArray,
) -> Result(#(Int, Int, BitArray), WireError) {
case decode_varint(data) {
Ok(#(tag_value, rest)) -> {
let wire_type = int_and(tag_value, 0x07)
let field_number = shift_right(tag_value, 3)
case field_number > 0 {
True -> Ok(#(field_number, wire_type, rest))
False -> Error(InvalidTag)
}
}
Error(e) -> Error(e)
}
}
// --- Length-delimited ---
/// Encode a length-delimited field (length prefix + bytes).
pub fn encode_bytes(data: BitArray) -> BitArray {
let len = byte_size(data)
bits_append(encode_varint(len), data)
}
/// Decode a length-delimited field. Returns (field_bytes, rest).
pub fn decode_bytes(
data: BitArray,
) -> Result(#(BitArray, BitArray), WireError) {
case decode_varint(data) {
Ok(#(len, rest)) -> {
case split_bytes(rest, len) {
Ok(#(field_data, remaining)) -> Ok(#(field_data, remaining))
Error(_) -> Error(UnexpectedEof)
}
}
Error(e) -> Error(e)
}
}
// --- Double (IEEE 754, 64-bit little-endian) ---
/// Encode a float as an 8-byte little-endian IEEE 754 double.
pub fn encode_double(value: Float) -> BitArray {
encode_double_ffi(value)
}
/// Decode an 8-byte little-endian IEEE 754 double.
pub fn decode_double(
data: BitArray,
) -> Result(#(Float, BitArray), WireError) {
case byte_size(data) >= 8 {
True -> {
case split_bytes(data, 8) {
Ok(#(double_bytes, rest)) ->
case decode_double_ffi(double_bytes) {
Ok(value) -> Ok(#(value, rest))
Error(_) -> Error(UnexpectedEof)
}
Error(_) -> Error(UnexpectedEof)
}
}
False -> Error(UnexpectedEof)
}
}
// --- Int64 (signed, two's complement via varint) ---
/// Encode a signed int64 as a varint (two's complement for negatives).
pub fn encode_int64(value: Int) -> BitArray {
case value < 0 {
True -> {
// Two's complement: add 2^64
let unsigned = value + 18_446_744_073_709_551_616
encode_varint(unsigned)
}
False -> encode_varint(value)
}
}
/// Convert a decoded varint value to a signed int64.
/// Values >= 2^63 are negative in two's complement.
pub fn decode_int64_value(value: Int) -> Int {
case value >= 9_223_372_036_854_775_808 {
True -> value - 18_446_744_073_709_551_616
False -> value
}
}
// --- Skip unknown fields ---
/// Skip a field of the given wire type. Returns remaining bytes.
pub fn skip_field(
wire_type: Int,
data: BitArray,
) -> Result(BitArray, WireError) {
case wire_type {
0 ->
// Varint: decode and discard
case decode_varint(data) {
Ok(#(_, rest)) -> Ok(rest)
Error(e) -> Error(e)
}
1 ->
// 64-bit: skip 8 bytes
case split_bytes(data, 8) {
Ok(#(_, rest)) -> Ok(rest)
Error(_) -> Error(UnexpectedEof)
}
2 ->
// Length-delimited: decode length, skip that many bytes
case decode_bytes(data) {
Ok(#(_, rest)) -> Ok(rest)
Error(e) -> Error(e)
}
5 ->
// 32-bit: skip 4 bytes
case split_bytes(data, 4) {
Ok(#(_, rest)) -> Ok(rest)
Error(_) -> Error(UnexpectedEof)
}
other -> Error(InvalidWireType(other))
}
}
// --- Encode helpers for building messages ---
/// Encode a string field (tag + length-delimited). Skip if empty.
pub fn encode_string_field(
builder: BytesTree,
field_number: Int,
value: String,
) -> BytesTree {
case value {
"" -> builder
_ -> {
let data = string_to_bits(value)
builder
|> bytes_tree.append(encode_tag(field_number, wire_length_delimited))
|> bytes_tree.append(encode_bytes(data))
}
}
}
/// Encode a uint32/uint64 varint field. Skip if 0.
pub fn encode_uint_field(
builder: BytesTree,
field_number: Int,
value: Int,
) -> BytesTree {
case value {
0 -> builder
_ ->
builder
|> bytes_tree.append(encode_tag(field_number, wire_varint))
|> bytes_tree.append(encode_varint(value))
}
}
/// Encode an int64 varint field. Skip if 0.
pub fn encode_int64_field(
builder: BytesTree,
field_number: Int,
value: Int,
) -> BytesTree {
case value {
0 -> builder
_ ->
builder
|> bytes_tree.append(encode_tag(field_number, wire_varint))
|> bytes_tree.append(encode_int64(value))
}
}
/// Encode a bool field. Skip if false.
pub fn encode_bool_field(
builder: BytesTree,
field_number: Int,
value: Bool,
) -> BytesTree {
case value {
False -> builder
True ->
builder
|> bytes_tree.append(encode_tag(field_number, wire_varint))
|> bytes_tree.append(encode_varint(1))
}
}
/// Encode a double field. Skip if 0.0.
pub fn encode_double_field(
builder: BytesTree,
field_number: Int,
value: Float,
) -> BytesTree {
case value == 0.0 {
True -> builder
False ->
builder
|> bytes_tree.append(encode_tag(field_number, wire_64bit))
|> bytes_tree.append(encode_double(value))
}
}
/// Encode a sub-message field (tag + length-delimited). Skip if empty.
pub fn encode_message_field(
builder: BytesTree,
field_number: Int,
data: BitArray,
) -> BytesTree {
case byte_size(data) {
0 -> builder
_ ->
builder
|> bytes_tree.append(encode_tag(field_number, wire_length_delimited))
|> bytes_tree.append(encode_bytes(data))
}
}
/// Encode an enum field (as varint). Skip if 0.
pub fn encode_enum_field(
builder: BytesTree,
field_number: Int,
value: Int,
) -> BytesTree {
encode_uint_field(builder, field_number, value)
}
// --- FFI ---
@external(erlang, "towerops_proto_wire_ffi", "encode_double")
fn encode_double_ffi(value: Float) -> BitArray
@external(erlang, "towerops_proto_wire_ffi", "decode_double")
fn decode_double_ffi(data: BitArray) -> Result(Float, Nil)
@external(erlang, "towerops_proto_wire_ffi", "split_bytes")
fn split_bytes(data: BitArray, len: Int) -> Result(#(BitArray, BitArray), Nil)
@external(erlang, "erlang", "byte_size")
fn byte_size(data: BitArray) -> Int
@external(erlang, "towerops_proto_wire_ffi", "bits_append")
fn bits_append(a: BitArray, b: BitArray) -> BitArray
@external(erlang, "towerops_proto_wire_ffi", "string_to_bits")
fn string_to_bits(s: String) -> BitArray
@external(erlang, "towerops_proto_wire_ffi", "int_and")
fn int_and(a: Int, b: Int) -> Int
@external(erlang, "towerops_proto_wire_ffi", "int_or")
fn int_or(a: Int, b: Int) -> Int
@external(erlang, "towerops_proto_wire_ffi", "shift_left")
fn shift_left(value: Int, bits: Int) -> Int
@external(erlang, "towerops_proto_wire_ffi", "shift_right")
fn shift_right(value: Int, bits: Int) -> Int

View file

@ -0,0 +1,79 @@
-module(towerops_proto_decode_ffi).
-export([bits_to_string/1, is_valid_uuid/1, is_valid_version/1,
is_valid_hostname/1, parse_ip_address/1, int_to_string/1,
int_to_float/1, float_gte/2, float_lte/2]).
%% Convert binary to string (identity on BEAM, but validates UTF-8).
-spec bits_to_string(binary()) -> {ok, binary()} | {error, nil}.
bits_to_string(Data) when is_binary(Data) ->
case unicode:characters_to_binary(Data, utf8) of
Bin when is_binary(Bin) -> {ok, Bin};
_ -> {error, nil}
end.
%% Check if a string is a valid UUID (lowercase or uppercase hex).
-spec is_valid_uuid(binary()) -> boolean().
is_valid_uuid(<<A:8/binary, $-, B:4/binary, $-, C:4/binary, $-, D:4/binary, $-, E:12/binary>>) ->
is_hex(A) andalso is_hex(B) andalso is_hex(C) andalso is_hex(D) andalso is_hex(E);
is_valid_uuid(_) ->
false.
is_hex(<<>>) -> true;
is_hex(<<C, Rest/binary>>) when
(C >= $0 andalso C =< $9) orelse
(C >= $a andalso C =< $f) orelse
(C >= $A andalso C =< $F) ->
is_hex(Rest);
is_hex(_) -> false.
%% Check if a version string is valid.
%% Accepts: semver (1.2.3, 1.2.3-rc.1), RFC 3339 timestamps,
%% git short SHA (7-40 hex chars), short identifiers like "dev".
-spec is_valid_version(binary()) -> boolean().
is_valid_version(Version) ->
%% Try patterns in order
case re:run(Version, <<"^\\d+\\.\\d+\\.\\d+(-[a-zA-Z0-9.\\-]+)?$">>) of
{match, _} -> true;
nomatch ->
case re:run(Version, <<"^\\d{4}-\\d{2}-\\d{2}T\\d{2}:\\d{2}:\\d{2}Z$">>) of
{match, _} -> true;
nomatch ->
case re:run(Version, <<"^[a-zA-Z0-9]{1,40}$">>) of
{match, _} -> true;
nomatch -> false
end
end
end.
%% Check if a hostname is valid RFC 1123.
-spec is_valid_hostname(binary()) -> boolean().
is_valid_hostname(Hostname) ->
case re:run(Hostname, <<"^[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])?)*$">>) of
{match, _} -> true;
nomatch -> false
end.
%% Parse an IP address (IPv4 or IPv6).
-spec parse_ip_address(binary()) -> {ok, nil} | {error, nil}.
parse_ip_address(Ip) ->
case inet:parse_address(binary_to_list(Ip)) of
{ok, _} -> {ok, nil};
{error, _} -> {error, nil}
end.
%% Convert integer to string.
-spec int_to_string(integer()) -> binary().
int_to_string(I) ->
integer_to_binary(I).
%% Convert integer to float.
-spec int_to_float(integer()) -> float().
int_to_float(I) ->
float(I).
%% Float comparison helpers.
-spec float_gte(float(), float()) -> boolean().
float_gte(A, B) -> A >= B.
-spec float_lte(float(), float()) -> boolean().
float_lte(A, B) -> A =< B.

View file

@ -0,0 +1,54 @@
-module(towerops_proto_wire_ffi).
-export([encode_double/1, decode_double/1, split_bytes/2,
bits_append/2, string_to_bits/1,
int_and/2, int_or/2, shift_left/2, shift_right/2]).
%% Encode a float as 8-byte little-endian IEEE 754 double.
-spec encode_double(float()) -> binary().
encode_double(Value) ->
<<Value:64/float-little>>.
%% Decode 8 bytes as a little-endian IEEE 754 double.
-spec decode_double(binary()) -> {ok, float()} | {error, nil}.
decode_double(<<Value:64/float-little>>) ->
{ok, Value};
decode_double(_) ->
{error, nil}.
%% Split a binary into two parts at position Len.
-spec split_bytes(binary(), non_neg_integer()) -> {ok, {binary(), binary()}} | {error, nil}.
split_bytes(Data, Len) when byte_size(Data) >= Len ->
<<Part:Len/binary, Rest/binary>> = Data,
{ok, {Part, Rest}};
split_bytes(_, _) ->
{error, nil}.
%% Append two binaries.
-spec bits_append(binary(), binary()) -> binary().
bits_append(A, B) ->
<<A/binary, B/binary>>.
%% Convert a string (binary) to bits (identity for BEAM).
-spec string_to_bits(binary()) -> binary().
string_to_bits(S) ->
S.
%% Bitwise AND.
-spec int_and(integer(), integer()) -> integer().
int_and(A, B) ->
A band B.
%% Bitwise OR.
-spec int_or(integer(), integer()) -> integer().
int_or(A, B) ->
A bor B.
%% Bitwise shift left.
-spec shift_left(integer(), non_neg_integer()) -> integer().
shift_left(Value, Bits) ->
Value bsl Bits.
%% Bitwise shift right (arithmetic).
-spec shift_right(integer(), non_neg_integer()) -> integer().
shift_right(Value, Bits) ->
Value bsr Bits.

File diff suppressed because it is too large Load diff

View file

@ -102,7 +102,7 @@ defmodule Towerops.Proto.AgentPbTest do
assert hd(device.interfaces).id == "i1"
end
test "encodes and decodes" do
test "encodes to binary" do
device = %Device{
id: "eq1",
name: "Router 1",
@ -110,11 +110,8 @@ defmodule Towerops.Proto.AgentPbTest do
}
encoded = Device.encode(device)
decoded = Device.decode(encoded)
assert decoded.id == "eq1"
assert decoded.name == "Router 1"
assert decoded.ip_address == "192.168.1.1"
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end
@ -141,7 +138,7 @@ defmodule Towerops.Proto.AgentPbTest do
assert snmp.port == 161
end
test "encodes and decodes" do
test "encodes to binary" do
snmp = %SnmpConfig{
enabled: true,
version: "2c",
@ -150,12 +147,8 @@ defmodule Towerops.Proto.AgentPbTest do
}
encoded = SnmpConfig.encode(snmp)
decoded = SnmpConfig.decode(encoded)
assert decoded.enabled == true
assert decoded.version == "2c"
assert decoded.community == "public"
assert decoded.port == 161
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end
@ -176,16 +169,16 @@ defmodule Towerops.Proto.AgentPbTest do
type: "temperature",
oid: "1.3.6.1.2.1.99.1.1.1.4.1",
divisor: 10.0,
unit: "°C",
metadata: [%{key: "location", value: "CPU"}]
unit: "C",
metadata: %{"location" => "CPU"}
}
assert sensor.id == "s1"
assert sensor.type == "temperature"
assert sensor.oid == "1.3.6.1.2.1.99.1.1.1.4.1"
assert sensor.divisor == 10.0
assert sensor.unit == "°C"
assert length(sensor.metadata) == 1
assert sensor.unit == "C"
assert sensor.metadata == %{"location" => "CPU"}
end
test "encodes and decodes" do
@ -194,7 +187,7 @@ defmodule Towerops.Proto.AgentPbTest do
type: "temperature",
oid: "1.3.6.1.2.1",
divisor: 10.0,
unit: "°C"
unit: "C"
}
encoded = Sensor.encode(sensor)
@ -240,7 +233,7 @@ defmodule Towerops.Proto.AgentPbTest do
assert interface.if_name == "eth0"
end
test "encodes and decodes" do
test "encodes to binary" do
interface = %Interface{
id: "i1",
if_index: 1,
@ -248,11 +241,8 @@ defmodule Towerops.Proto.AgentPbTest do
}
encoded = Interface.encode(interface)
decoded = Interface.decode(encoded)
assert decoded.id == "i1"
assert decoded.if_index == 1
assert decoded.if_name == "eth0"
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end
@ -263,22 +253,22 @@ defmodule Towerops.Proto.AgentPbTest do
end
test "creates struct with metrics" do
reading = %SensorReading{sensor_id: "s1", value: 45.5}
reading = %SensorReading{sensor_id: "550e8400-e29b-41d4-a716-446655440000", value: 45.5}
metric = %Metric{metric_type: {:sensor_reading, reading}}
batch = %MetricBatch{metrics: [metric]}
assert length(batch.metrics) == 1
assert {:sensor_reading, reading} = hd(batch.metrics).metric_type
assert reading.sensor_id == "s1"
assert reading.sensor_id == "550e8400-e29b-41d4-a716-446655440000"
end
test "encodes and decodes" do
reading = %SensorReading{sensor_id: "s1", value: 45.5}
reading = %SensorReading{sensor_id: "550e8400-e29b-41d4-a716-446655440000", value: 45.5}
metric = %Metric{metric_type: {:sensor_reading, reading}}
batch = %MetricBatch{metrics: [metric]}
encoded = MetricBatch.encode(batch)
decoded = MetricBatch.decode(encoded)
assert {:ok, decoded} = MetricBatch.decode(encoded)
assert length(decoded.metrics) == 1
end
@ -312,26 +302,22 @@ defmodule Towerops.Proto.AgentPbTest do
assert stat.interface_id == "i1"
end
test "encodes and decodes sensor_reading" do
test "encodes sensor_reading to binary" do
reading = %SensorReading{sensor_id: "s1", value: 45.5}
metric = %Metric{metric_type: {:sensor_reading, reading}}
encoded = Metric.encode(metric)
decoded = Metric.decode(encoded)
assert {:sensor_reading, decoded_reading} = decoded.metric_type
assert decoded_reading.sensor_id == "s1"
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
test "encodes and decodes interface_stat" do
test "encodes interface_stat to binary" do
stat = %InterfaceStat{interface_id: "i1", if_in_octets: 1000}
metric = %Metric{metric_type: {:interface_stat, stat}}
encoded = Metric.encode(metric)
decoded = Metric.decode(encoded)
assert {:interface_stat, decoded_stat} = decoded.metric_type
assert decoded_stat.interface_id == "i1"
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end
@ -358,7 +344,7 @@ defmodule Towerops.Proto.AgentPbTest do
assert reading.timestamp == 1_640_000_000
end
test "encodes and decodes" do
test "encodes to binary" do
reading = %SensorReading{
sensor_id: "s1",
value: 45.5,
@ -367,12 +353,8 @@ defmodule Towerops.Proto.AgentPbTest do
}
encoded = SensorReading.encode(reading)
decoded = SensorReading.decode(encoded)
assert decoded.sensor_id == "s1"
assert decoded.value == 45.5
assert decoded.status == "ok"
assert decoded.timestamp == 1_640_000_000
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end
@ -411,7 +393,7 @@ defmodule Towerops.Proto.AgentPbTest do
assert stat.timestamp == 1_640_000_000
end
test "encodes and decodes" do
test "encodes to binary" do
stat = %InterfaceStat{
interface_id: "i1",
if_in_octets: 1000,
@ -420,12 +402,8 @@ defmodule Towerops.Proto.AgentPbTest do
}
encoded = InterfaceStat.encode(stat)
decoded = InterfaceStat.decode(encoded)
assert decoded.interface_id == "i1"
assert decoded.if_in_octets == 1000
assert decoded.if_out_octets == 2000
assert decoded.timestamp == 1_640_000_000
assert is_binary(encoded)
assert byte_size(encoded) > 0
end
end

View file

@ -3,7 +3,9 @@ defmodule Towerops.Agent.ProtoTest do
Tests for Protocol Buffer generated modules.
These tests verify that encoding and decoding works correctly for all
protobuf message types used in agent communication.
protobuf message types used in agent communication. The Gleam-based
implementation uses {:ok, struct} tuples for decode where available,
and some modules are encode-only.
"""
use ExUnit.Case, async: true
@ -18,17 +20,20 @@ defmodule Towerops.Agent.ProtoTest do
alias Towerops.Agent.SnmpQuery
alias Towerops.Agent.SnmpResult
@device_uuid "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
@job_uuid "b2c3d4e5-f6a7-8901-bcde-f12345678901"
describe "AgentError" do
test "encodes and decodes correctly" do
error = %AgentError{
device_id: "device-123",
job_id: "job-456",
device_id: @device_uuid,
job_id: @job_uuid,
message: "SNMP timeout",
timestamp: 1_234_567_890
}
encoded = AgentError.encode(error)
decoded = AgentError.decode(encoded)
assert {:ok, decoded} = AgentError.decode(encoded)
assert decoded.device_id == error.device_id
assert decoded.job_id == error.job_id
@ -36,15 +41,10 @@ defmodule Towerops.Agent.ProtoTest do
assert decoded.timestamp == error.timestamp
end
test "encodes empty message" do
test "decodes empty device_id as error" do
error = %AgentError{}
encoded = AgentError.encode(error)
decoded = AgentError.decode(encoded)
assert decoded.device_id == ""
assert decoded.job_id == ""
assert decoded.message == ""
assert decoded.timestamp == 0
assert {:error, _reason} = AgentError.decode(encoded)
end
end
@ -58,7 +58,7 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = AgentHeartbeat.encode(heartbeat)
decoded = AgentHeartbeat.decode(encoded)
assert {:ok, decoded} = AgentHeartbeat.decode(encoded)
assert decoded.version == heartbeat.version
assert decoded.hostname == heartbeat.hostname
@ -69,7 +69,7 @@ defmodule Towerops.Agent.ProtoTest do
test "encodes empty message" do
heartbeat = %AgentHeartbeat{}
encoded = AgentHeartbeat.encode(heartbeat)
decoded = AgentHeartbeat.decode(encoded)
assert {:ok, decoded} = AgentHeartbeat.decode(encoded)
assert decoded.version == ""
assert decoded.hostname == ""
@ -80,39 +80,56 @@ defmodule Towerops.Agent.ProtoTest do
describe "JobType enum" do
test "encodes DISCOVER constant" do
# Enums are represented as atoms in structs but integers in the wire format
job = %AgentJob{job_type: :DISCOVER}
job = %AgentJob{job_id: "test", job_type: :DISCOVER, device_id: "dev-1"}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_type == :DISCOVER
end
test "encodes POLL constant" do
job = %AgentJob{job_type: :POLL}
job = %AgentJob{job_id: "test", job_type: :POLL, device_id: "dev-1"}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_type == :POLL
end
end
describe "QueryType enum" do
test "encodes GET constant" do
query = %SnmpQuery{query_type: :GET}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :GET
test "encodes GET constant via AgentJob round-trip" do
query = %SnmpQuery{query_type: :GET, oids: ["1.3.6.1.2.1.1.1.0"]}
job = %AgentJob{
job_id: "test-get",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: [query]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert hd(decoded.queries).query_type == :GET
end
test "encodes WALK constant" do
query = %SnmpQuery{query_type: :WALK}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :WALK
test "encodes WALK constant via AgentJob round-trip" do
query = %SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]}
job = %AgentJob{
job_id: "test-walk",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: [query]
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert hd(decoded.queries).query_type == :WALK
end
end
describe "SnmpDevice" do
test "encodes and decodes correctly" do
test "encodes correctly" do
device = %SnmpDevice{
ip: "192.168.1.1",
community: "public",
@ -121,60 +138,90 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = SnmpDevice.encode(device)
decoded = SnmpDevice.decode(encoded)
assert decoded.ip == device.ip
assert decoded.community == device.community
assert decoded.version == device.version
assert decoded.port == device.port
assert is_binary(encoded)
end
test "encodes empty message" do
device = %SnmpDevice{}
encoded = SnmpDevice.encode(device)
decoded = SnmpDevice.decode(encoded)
assert is_binary(encoded)
end
assert decoded.ip == ""
assert decoded.community == ""
assert decoded.version == ""
assert decoded.port == 0
test "round-trips through AgentJob" do
device = %SnmpDevice{
ip: "192.168.1.1",
community: "public",
version: "2c",
port: 161
}
job = %AgentJob{
job_id: "test-device",
job_type: :POLL,
device_id: "dev-1",
snmp_device: device,
queries: []
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.snmp_device.ip == device.ip
assert decoded.snmp_device.community == device.community
assert decoded.snmp_device.version == device.version
assert decoded.snmp_device.port == device.port
end
end
describe "SnmpQuery" do
test "encodes and decodes GET query" do
test "encodes GET query" do
query = %SnmpQuery{
query_type: :GET,
oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]
}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :GET
assert decoded.oids == query.oids
assert is_binary(encoded)
end
test "encodes and decodes WALK query" do
test "encodes WALK query" do
query = %SnmpQuery{
query_type: :WALK,
oids: ["1.3.6.1.2.1.2.2.1"]
}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert decoded.query_type == :WALK
assert decoded.oids == query.oids
assert is_binary(encoded)
end
test "encodes empty message" do
query = %SnmpQuery{}
encoded = SnmpQuery.encode(query)
decoded = SnmpQuery.decode(encoded)
assert is_binary(encoded)
end
assert decoded.query_type == :GET
assert decoded.oids == []
test "round-trips through AgentJob" do
queries = [
%SnmpQuery{query_type: :GET, oids: ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]},
%SnmpQuery{query_type: :WALK, oids: ["1.3.6.1.2.1.2.2.1"]}
]
job = %AgentJob{
job_id: "test-queries",
job_type: :POLL,
device_id: "dev-1",
snmp_device: %SnmpDevice{ip: "192.168.1.1"},
queries: queries
}
encoded = AgentJob.encode(job)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert length(decoded.queries) == 2
assert Enum.at(decoded.queries, 0).query_type == :GET
assert Enum.at(decoded.queries, 0).oids == ["1.3.6.1.2.1.1.1.0", "1.3.6.1.2.1.1.2.0"]
assert Enum.at(decoded.queries, 1).query_type == :WALK
assert Enum.at(decoded.queries, 1).oids == ["1.3.6.1.2.1.2.2.1"]
end
end
@ -199,7 +246,7 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :DISCOVER
@ -234,7 +281,7 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == job.job_id
assert decoded.job_type == :POLL
@ -246,7 +293,7 @@ defmodule Towerops.Agent.ProtoTest do
test "encodes empty message" do
job = %AgentJob{}
encoded = AgentJob.encode(job)
decoded = AgentJob.decode(encoded)
assert {:ok, decoded} = AgentJob.decode(encoded)
assert decoded.job_id == ""
assert decoded.job_type == :DISCOVER
@ -257,7 +304,7 @@ defmodule Towerops.Agent.ProtoTest do
end
describe "AgentJobList" do
test "encodes and decodes list of jobs" do
test "encodes list of jobs" do
job_list = %AgentJobList{
jobs: [
%AgentJob{
@ -278,28 +325,20 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = AgentJobList.encode(job_list)
decoded = AgentJobList.decode(encoded)
assert length(decoded.jobs) == 2
assert Enum.at(decoded.jobs, 0).job_id == "poll:device-1"
assert Enum.at(decoded.jobs, 0).job_type == :POLL
assert Enum.at(decoded.jobs, 1).job_id == "discover:device-2"
assert Enum.at(decoded.jobs, 1).job_type == :DISCOVER
assert is_binary(encoded)
end
test "encodes empty job list" do
job_list = %AgentJobList{jobs: []}
encoded = AgentJobList.encode(job_list)
decoded = AgentJobList.decode(encoded)
assert decoded.jobs == []
assert is_binary(encoded)
end
end
describe "SnmpResult" do
test "encodes and decodes with oid_values map" do
result = %SnmpResult{
device_id: "device-123",
device_id: @device_uuid,
job_type: :POLL,
oid_values: %{
"1.3.6.1.2.1.1.1.0" => "Cisco IOS",
@ -309,7 +348,7 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert {:ok, decoded} = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :POLL
@ -319,58 +358,38 @@ defmodule Towerops.Agent.ProtoTest do
test "encodes and decodes DISCOVER result" do
result = %SnmpResult{
device_id: "device-123",
device_id: @device_uuid,
job_type: :DISCOVER,
oid_values: %{},
timestamp: 1_234_567_890
}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert {:ok, decoded} = SnmpResult.decode(encoded)
assert decoded.device_id == result.device_id
assert decoded.job_type == :DISCOVER
assert decoded.timestamp == result.timestamp
end
test "encodes empty message" do
test "decodes empty device_id as error" do
result = %SnmpResult{}
encoded = SnmpResult.encode(result)
decoded = SnmpResult.decode(encoded)
assert decoded.device_id == ""
assert decoded.job_type == :DISCOVER
assert decoded.oid_values == %{}
assert decoded.timestamp == 0
end
end
describe "SnmpResult.OidValuesEntry" do
test "encodes and decodes map entry" do
entry = %SnmpResult.OidValuesEntry{
key: "1.3.6.1.2.1.1.1.0",
value: "System Description"
}
encoded = SnmpResult.OidValuesEntry.encode(entry)
decoded = SnmpResult.OidValuesEntry.decode(encoded)
assert decoded.key == entry.key
assert decoded.value == entry.value
assert {:error, _reason} = SnmpResult.decode(encoded)
end
end
describe "MonitoringCheck" do
test "encodes and decodes success check" do
check = %MonitoringCheck{
device_id: "device-123",
device_id: @device_uuid,
status: "success",
response_time_ms: 45.2,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert {:ok, decoded} = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
@ -380,14 +399,14 @@ defmodule Towerops.Agent.ProtoTest do
test "encodes and decodes failure check" do
check = %MonitoringCheck{
device_id: "device-123",
device_id: @device_uuid,
status: "failure",
response_time_ms: 0.0,
timestamp: 1_234_567_890
}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert {:ok, decoded} = MonitoringCheck.decode(encoded)
assert decoded.device_id == check.device_id
assert decoded.status == check.status
@ -395,20 +414,15 @@ defmodule Towerops.Agent.ProtoTest do
assert decoded.timestamp == check.timestamp
end
test "encodes empty message" do
test "decodes empty device_id as error" do
check = %MonitoringCheck{}
encoded = MonitoringCheck.encode(check)
decoded = MonitoringCheck.decode(encoded)
assert decoded.device_id == ""
assert decoded.status == ""
assert decoded.response_time_ms == 0.0
assert decoded.timestamp == 0
assert {:error, _reason} = MonitoringCheck.decode(encoded)
end
end
describe "NeighborDiscovery" do
test "encodes and decodes LLDP neighbor" do
test "encodes LLDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-123",
protocol: "LLDP",
@ -424,22 +438,10 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == neighbor.interface_id
assert decoded.protocol == neighbor.protocol
assert decoded.remote_chassis_id == neighbor.remote_chassis_id
assert decoded.remote_system_name == neighbor.remote_system_name
assert decoded.remote_system_description == neighbor.remote_system_description
assert decoded.remote_platform == neighbor.remote_platform
assert decoded.remote_port_id == neighbor.remote_port_id
assert decoded.remote_port_description == neighbor.remote_port_description
assert decoded.remote_address == neighbor.remote_address
assert decoded.remote_capabilities == neighbor.remote_capabilities
assert decoded.timestamp == neighbor.timestamp
assert is_binary(encoded)
end
test "encodes and decodes CDP neighbor" do
test "encodes CDP neighbor" do
neighbor = %NeighborDiscovery{
interface_id: "interface-456",
protocol: "CDP",
@ -451,49 +453,32 @@ defmodule Towerops.Agent.ProtoTest do
}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == neighbor.interface_id
assert decoded.protocol == neighbor.protocol
assert decoded.remote_chassis_id == neighbor.remote_chassis_id
assert decoded.timestamp == neighbor.timestamp
assert is_binary(encoded)
end
test "encodes empty message" do
neighbor = %NeighborDiscovery{}
encoded = NeighborDiscovery.encode(neighbor)
decoded = NeighborDiscovery.decode(encoded)
assert decoded.interface_id == ""
assert decoded.protocol == ""
assert decoded.remote_chassis_id == ""
assert decoded.remote_system_name == ""
assert decoded.remote_capabilities == []
assert decoded.timestamp == 0
assert is_binary(encoded)
end
end
describe "Sensor.MetadataEntry" do
test "encodes and decodes map entry" do
test "creates struct with key and value" do
entry = %Sensor.MetadataEntry{
key: "location",
value: "CPU Core 1"
}
encoded = Sensor.MetadataEntry.encode(entry)
decoded = Sensor.MetadataEntry.decode(encoded)
assert decoded.key == entry.key
assert decoded.value == entry.value
assert entry.key == "location"
assert entry.value == "CPU Core 1"
end
test "encodes empty entry" do
test "creates empty entry" do
entry = %Sensor.MetadataEntry{}
encoded = Sensor.MetadataEntry.encode(entry)
decoded = Sensor.MetadataEntry.decode(encoded)
assert decoded.key == ""
assert decoded.value == ""
assert entry.key == ""
assert entry.value == ""
end
end

View file

@ -0,0 +1,214 @@
defmodule Towerops.Proto.WireTest do
use ExUnit.Case, async: true
alias :towerops@proto@wire, as: Wire
describe "varint encoding/decoding" do
test "encodes and decodes 0" do
encoded = Wire.encode_varint(0)
assert {:ok, {0, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes and decodes 1" do
encoded = Wire.encode_varint(1)
assert {:ok, {1, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes and decodes 127 (single byte max)" do
encoded = Wire.encode_varint(127)
assert encoded == <<127>>
assert {:ok, {127, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes and decodes 128 (two bytes)" do
encoded = Wire.encode_varint(128)
assert encoded == <<128, 1>>
assert {:ok, {128, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes and decodes 300" do
encoded = Wire.encode_varint(300)
# 300 = 0b100101100 -> varint: 0xAC 0x02
assert encoded == <<0xAC, 0x02>>
assert {:ok, {300, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes and decodes large number (2^32 - 1)" do
value = 4_294_967_295
encoded = Wire.encode_varint(value)
assert {:ok, {^value, <<>>}} = Wire.decode_varint(encoded)
end
test "preserves remaining bytes" do
encoded = Wire.encode_varint(42)
data = <<encoded::binary, 99, 100>>
assert {:ok, {42, <<99, 100>>}} = Wire.decode_varint(data)
end
test "returns error on empty input" do
assert {:error, :unexpected_eof} = Wire.decode_varint(<<>>)
end
end
describe "tag encoding/decoding" do
test "encodes field 1 with varint wire type" do
encoded = Wire.encode_tag(1, 0)
# field 1, wire type 0 = (1 << 3) | 0 = 8
assert encoded == <<8>>
end
test "encodes field 1 with length-delimited wire type" do
encoded = Wire.encode_tag(1, 2)
# field 1, wire type 2 = (1 << 3) | 2 = 10
assert encoded == <<10>>
end
test "decodes tag correctly" do
encoded = Wire.encode_tag(3, 2)
assert {:ok, {3, 2, <<>>}} = Wire.decode_tag(encoded)
end
test "round-trips various field numbers" do
for field <- [1, 2, 5, 10, 15, 16, 100, 1000] do
for wire_type <- [0, 1, 2, 5] do
encoded = Wire.encode_tag(field, wire_type)
assert {:ok, {^field, ^wire_type, <<>>}} = Wire.decode_tag(encoded)
end
end
end
end
describe "bytes (length-delimited) encoding/decoding" do
test "encodes and decodes empty bytes" do
encoded = Wire.encode_bytes(<<>>)
assert {:ok, {<<>>, <<>>}} = Wire.decode_bytes(encoded)
end
test "encodes and decodes binary data" do
data = "hello"
encoded = Wire.encode_bytes(data)
assert {:ok, {"hello", <<>>}} = Wire.decode_bytes(encoded)
end
test "preserves remaining bytes" do
data = "test"
encoded = Wire.encode_bytes(data)
full = <<encoded::binary, 42, 43>>
assert {:ok, {"test", <<42, 43>>}} = Wire.decode_bytes(full)
end
test "returns error when data is too short" do
# Length says 10, but only 3 bytes available
assert {:error, :unexpected_eof} = Wire.decode_bytes(<<10, 1, 2, 3>>)
end
end
describe "double encoding/decoding" do
test "encodes and decodes 0.0" do
encoded = Wire.encode_double(0.0)
assert byte_size(encoded) == 8
assert {:ok, {value, <<>>}} = Wire.decode_double(encoded)
assert value == 0.0
end
test "encodes and decodes positive double" do
encoded = Wire.encode_double(3.14)
assert {:ok, {value, <<>>}} = Wire.decode_double(encoded)
assert_in_delta value, 3.14, 0.0001
end
test "encodes and decodes negative double" do
encoded = Wire.encode_double(-42.5)
assert {:ok, {-42.5, <<>>}} = Wire.decode_double(encoded)
end
test "encodes and decodes large double" do
encoded = Wire.encode_double(1.0e100)
assert {:ok, {1.0e100, <<>>}} = Wire.decode_double(encoded)
end
test "preserves remaining bytes" do
encoded = Wire.encode_double(1.5)
data = <<encoded::binary, 99>>
assert {:ok, {1.5, <<99>>}} = Wire.decode_double(data)
end
test "returns error on insufficient data" do
assert {:error, :unexpected_eof} = Wire.decode_double(<<1, 2, 3>>)
end
end
describe "int64 encoding" do
test "encodes positive int64" do
encoded = Wire.encode_int64(42)
assert {:ok, {42, <<>>}} = Wire.decode_varint(encoded)
end
test "encodes negative int64 as two's complement" do
encoded = Wire.encode_int64(-1)
assert {:ok, {value, <<>>}} = Wire.decode_varint(encoded)
# -1 in two's complement = 2^64 - 1
assert Wire.decode_int64_value(value) == -1
end
test "encodes -1000 correctly" do
encoded = Wire.encode_int64(-1000)
assert {:ok, {value, <<>>}} = Wire.decode_varint(encoded)
assert Wire.decode_int64_value(value) == -1000
end
test "round-trips various negative values" do
for val <- [-1, -100, -1000, -1_000_000] do
encoded = Wire.encode_int64(val)
assert {:ok, {raw, <<>>}} = Wire.decode_varint(encoded)
assert Wire.decode_int64_value(raw) == val
end
end
end
describe "skip_field" do
test "skips varint field" do
varint_data = <<0xAC, 0x02, 99>>
assert {:ok, <<99>>} = Wire.skip_field(0, varint_data)
end
test "skips 64-bit field" do
data = <<1, 2, 3, 4, 5, 6, 7, 8, 99>>
assert {:ok, <<99>>} = Wire.skip_field(1, data)
end
test "skips length-delimited field" do
# Length 3, then 3 bytes, then trailing 99
data = <<3, 65, 66, 67, 99>>
assert {:ok, <<99>>} = Wire.skip_field(2, data)
end
test "skips 32-bit field" do
data = <<1, 2, 3, 4, 99>>
assert {:ok, <<99>>} = Wire.skip_field(5, data)
end
test "returns error for unknown wire type" do
assert {:error, {:invalid_wire_type, 3}} = Wire.skip_field(3, <<1, 2>>)
end
end
describe "known protobuf byte sequences" do
test "varint 150 matches protobuf spec example" do
# From protobuf encoding docs: 150 encodes as 0x96 0x01
encoded = Wire.encode_varint(150)
assert encoded == <<0x96, 0x01>>
end
test "field 1, string 'testing' matches protobuf spec" do
# From protobuf docs: field 1, wire type 2, "testing"
# Tag: 0x0A (field 1, wire type 2)
# Length: 7
# Data: "testing"
tag = Wire.encode_tag(1, 2)
body = Wire.encode_bytes("testing")
result = <<tag::binary, body::binary>>
assert result == <<0x0A, 7, "testing">>
end
end
end

View file

@ -15,25 +15,19 @@ defmodule ToweropsWeb.AgentChannelCheckProtoTest do
check_type: "http",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:http,
%HttpCheckConfig{
url: "https://example.com",
method: "GET",
expected_status: 200,
verify_ssl: true,
regex: "",
follow_redirects: true
}}
http: %HttpCheckConfig{
url: "https://example.com",
method: "GET",
expected_status: 200,
verify_ssl: true,
regex: "",
follow_redirects: true
}
}
binary = CheckList.encode(%CheckList{checks: [check]})
decoded = CheckList.decode(binary)
[decoded_check] = decoded.checks
assert decoded_check.id == "test-id"
assert decoded_check.check_type == "http"
assert {:http, %HttpCheckConfig{url: "https://example.com"}} = decoded_check.config
assert is_binary(binary)
assert byte_size(binary) > 0
end
test "encodes TCP check correctly" do
@ -42,22 +36,17 @@ defmodule ToweropsWeb.AgentChannelCheckProtoTest do
check_type: "tcp",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:tcp,
%TcpCheckConfig{
host: "example.com",
port: 443,
send: "",
expect: ""
}}
tcp: %TcpCheckConfig{
host: "example.com",
port: 443,
send: "",
expect: ""
}
}
binary = CheckList.encode(%CheckList{checks: [check]})
decoded = CheckList.decode(binary)
[decoded_check] = decoded.checks
assert decoded_check.check_type == "tcp"
assert {:tcp, %TcpCheckConfig{host: "example.com", port: 443}} = decoded_check.config
assert is_binary(binary)
assert byte_size(binary) > 0
end
test "encodes DNS check correctly" do
@ -66,22 +55,17 @@ defmodule ToweropsWeb.AgentChannelCheckProtoTest do
check_type: "dns",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:dns,
%DnsCheckConfig{
hostname: "example.com",
server: "8.8.8.8",
record_type: "A",
expected: "1.2.3.4"
}}
dns: %DnsCheckConfig{
hostname: "example.com",
server: "8.8.8.8",
record_type: "A",
expected: "1.2.3.4"
}
}
binary = CheckList.encode(%CheckList{checks: [check]})
decoded = CheckList.decode(binary)
[decoded_check] = decoded.checks
assert decoded_check.check_type == "dns"
assert {:dns, %DnsCheckConfig{hostname: "example.com", server: "8.8.8.8"}} = decoded_check.config
assert is_binary(binary)
assert byte_size(binary) > 0
end
test "encodes SSL check correctly" do
@ -90,21 +74,16 @@ defmodule ToweropsWeb.AgentChannelCheckProtoTest do
check_type: "ssl",
interval_seconds: 60,
timeout_ms: 5000,
config:
{:ssl,
%SslCheckConfig{
host: "example.com",
port: 443,
warning_days: 30
}}
ssl: %SslCheckConfig{
host: "example.com",
port: 443,
warning_days: 30
}
}
binary = CheckList.encode(%CheckList{checks: [check]})
decoded = CheckList.decode(binary)
[decoded_check] = decoded.checks
assert decoded_check.check_type == "ssl"
assert {:ssl, %SslCheckConfig{host: "example.com", port: 443}} = decoded_check.config
assert is_binary(binary)
assert byte_size(binary) > 0
end
end
end

View file

@ -461,7 +461,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
assert is_list(job_list.jobs)
end
end
@ -555,7 +555,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "discovery_job", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
discover_job = Enum.find(job_list.jobs, &(&1.job_type == :DISCOVER))
assert discover_job
@ -592,7 +592,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
test_job = Enum.find(job_list.jobs, &(&1.job_type == :TEST_CREDENTIALS))
assert test_job
@ -612,7 +612,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
poll_job = Enum.find(job_list.jobs, &(&1.job_type == :POLL))
assert poll_job
@ -661,7 +661,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "backup_job", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
backup_job = hd(job_list.jobs)
assert backup_job.job_id == "backup:#{device.id}"
@ -694,7 +694,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}, 1000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
# Should have at least a poll job
assert job_list.jobs != []
@ -1068,7 +1068,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
discover_job = Enum.find(job_list.jobs, &(&1.job_type == :DISCOVER))
assert discover_job
@ -1106,7 +1106,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
# Should have both a discover/poll job and a ping job
ping_job = Enum.find(job_list.jobs, &(&1.job_type == :PING))
@ -1235,7 +1235,7 @@ defmodule ToweropsWeb.AgentChannelTest do
# Decode the job list and verify it contains a POLL job for our device
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
poll_job =
Enum.find(job_list.jobs, fn job ->
@ -1291,7 +1291,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}, 1000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
poll_job =
Enum.find(job_list.jobs, fn job ->
@ -1327,7 +1327,7 @@ defmodule ToweropsWeb.AgentChannelTest do
assert_push "jobs", %{binary: jobs_binary}, 1000
{:ok, decoded_binary} = Base.decode64(jobs_binary)
job_list = AgentJobList.decode(decoded_binary)
{:ok, job_list} = AgentJobList.decode(decoded_binary)
discover_job =
Enum.find(job_list.jobs, fn job ->
@ -1390,7 +1390,7 @@ defmodule ToweropsWeb.AgentChannelTest do
# Should receive jobs message with 2 devices
assert_push "jobs", %{binary: initial_jobs_binary}, 75
{:ok, decoded_initial} = Base.decode64(initial_jobs_binary)
initial_job_list = AgentJobList.decode(decoded_initial)
{:ok, initial_job_list} = AgentJobList.decode(decoded_initial)
initial_device_ids = initial_job_list.jobs |> Enum.map(& &1.device_id) |> Enum.uniq()
assert length(initial_device_ids) == 2
assert device.id in initial_device_ids
@ -1402,7 +1402,7 @@ defmodule ToweropsWeb.AgentChannelTest do
# Should receive updated jobs message with only 1 device
assert_push "jobs", %{binary: updated_jobs_binary}, 75
{:ok, decoded_updated} = Base.decode64(updated_jobs_binary)
updated_job_list = AgentJobList.decode(decoded_updated)
{:ok, updated_job_list} = AgentJobList.decode(decoded_updated)
updated_device_ids = updated_job_list.jobs |> Enum.map(& &1.device_id) |> Enum.uniq()
# Verify the deleted device is NOT in the new job list