Rewrite polling_offset, changelog_parser, and user_agent_parser in Gleam (#103)
Migrate three pure-function modules from Elixir to Gleam: - polling_offset: deterministic hash-based polling offset (pure Gleam, no FFI) - changelog_parser: text parsing logic in Gleam, thin Elixir wrapper for File I/O - user_agent_parser: regex-based UA parsing in Gleam with Erlang FFI for atom-keyed map conversion Add gleam_regexp dependency for regex support in Gleam modules. Update all call sites and tests to use Gleam module atoms. Reviewed-on: graham/towerops-web#103
This commit is contained in:
parent
6859525a1b
commit
348975dcc9
55 changed files with 2849 additions and 1958 deletions
|
|
@ -4,6 +4,7 @@ target = "erlang"
|
|||
|
||||
[dependencies]
|
||||
gleam_stdlib = ">= 0.34.0 and < 2.0.0"
|
||||
gleam_regexp = ">= 1.0.0 and < 2.0.0"
|
||||
|
||||
[dev-dependencies]
|
||||
gleeunit = ">= 1.0.0 and < 2.0.0"
|
||||
|
|
|
|||
|
|
@ -2,44 +2,14 @@ defmodule SnmpKit.SnmpLib.Error do
|
|||
@moduledoc """
|
||||
Standard SNMP error handling and error code utilities.
|
||||
|
||||
Provides standardized error codes, error handling utilities, and error response
|
||||
generation for SNMP operations. This module centralizes all SNMP-specific error
|
||||
handling to ensure consistent error reporting across the library.
|
||||
|
||||
## SNMP Error Codes
|
||||
|
||||
Standard SNMP error status values as defined in RFC 1157 and RFC 3416:
|
||||
|
||||
- `no_error` (0) - No error occurred
|
||||
- `too_big` (1) - Response message would be too large
|
||||
- `no_such_name` (2) - Requested OID does not exist
|
||||
- `bad_value` (3) - Invalid value for SET operation
|
||||
- `read_only` (4) - Attempted to set read-only variable
|
||||
- `gen_err` (5) - General error
|
||||
|
||||
## Usage Examples
|
||||
|
||||
### Basic Error Handling
|
||||
|
||||
# Check if an error is retriable
|
||||
if SnmpKit.SnmpLib.Error.retriable_error?(error_code) do
|
||||
retry_operation()
|
||||
end
|
||||
|
||||
# Format error for logging
|
||||
error_msg = SnmpKit.SnmpLib.Error.format_error(3, 2, varbinds)
|
||||
Logger.error(error_msg)
|
||||
|
||||
### Error Response Generation
|
||||
|
||||
# Create error response for invalid request
|
||||
{:ok, error_response} = SnmpKit.SnmpLib.Error.create_error_response(
|
||||
request_pdu,
|
||||
:no_such_name,
|
||||
error_index
|
||||
)
|
||||
Core logic is implemented in Gleam (`:snmpkit@snmp_lib@error`).
|
||||
This wrapper provides the idiomatic Elixir API that accepts atoms,
|
||||
integers, and strings as error status identifiers.
|
||||
"""
|
||||
|
||||
@gleam :snmpkit@snmp_lib@error
|
||||
@ffi :snmpkit_error_ffi
|
||||
|
||||
@type error_status ::
|
||||
:no_error
|
||||
| :too_big
|
||||
|
|
@ -53,338 +23,71 @@ defmodule SnmpKit.SnmpLib.Error do
|
|||
@type varbind :: {list(), any()}
|
||||
@type varbinds :: [varbind()]
|
||||
|
||||
# Standard SNMP error status codes (RFC 1157, RFC 3416)
|
||||
@no_error 0
|
||||
@too_big 1
|
||||
@no_such_name 2
|
||||
@bad_value 3
|
||||
@read_only 4
|
||||
@gen_err 5
|
||||
## Standard Error Code Constants
|
||||
|
||||
# Additional SNMPv2c error codes
|
||||
@no_access 6
|
||||
@wrong_type 7
|
||||
@wrong_length 8
|
||||
@wrong_encoding 9
|
||||
@wrong_value 10
|
||||
@no_creation 11
|
||||
@inconsistent_value 12
|
||||
@resource_unavailable 13
|
||||
@commit_failed 14
|
||||
@undo_failed 15
|
||||
@authorization_error 16
|
||||
@not_writable 17
|
||||
@inconsistent_name 18
|
||||
|
||||
# Errors that are typically transient and worth retrying
|
||||
@retriable_errors MapSet.new([:too_big, :gen_err, :resource_unavailable])
|
||||
|
||||
## Standard Error Codes
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'no error' status.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.no_error()
|
||||
0
|
||||
"""
|
||||
@spec no_error() :: 0
|
||||
def no_error, do: @no_error
|
||||
def no_error, do: 0
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'too big' error status.
|
||||
|
||||
The response message would be too large to fit in a single SNMP message.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.too_big()
|
||||
1
|
||||
"""
|
||||
@spec too_big() :: 1
|
||||
def too_big, do: @too_big
|
||||
def too_big, do: 1
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'no such name' error status.
|
||||
|
||||
The requested OID does not exist on the agent.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.no_such_name()
|
||||
2
|
||||
"""
|
||||
@spec no_such_name() :: 2
|
||||
def no_such_name, do: @no_such_name
|
||||
def no_such_name, do: 2
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'bad value' error status.
|
||||
|
||||
The value provided in a SET operation is invalid for the variable.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.bad_value()
|
||||
3
|
||||
"""
|
||||
@spec bad_value() :: 3
|
||||
def bad_value, do: @bad_value
|
||||
def bad_value, do: 3
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'read only' error status.
|
||||
|
||||
Attempted to set a read-only variable.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.read_only()
|
||||
4
|
||||
"""
|
||||
@spec read_only() :: 4
|
||||
def read_only, do: @read_only
|
||||
def read_only, do: 4
|
||||
|
||||
@doc """
|
||||
Returns the numeric code for 'general error' status.
|
||||
|
||||
A general error occurred that doesn't fit other categories.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.gen_err()
|
||||
5
|
||||
"""
|
||||
@spec gen_err() :: 5
|
||||
def gen_err, do: @gen_err
|
||||
def gen_err, do: 5
|
||||
|
||||
## Error Utilities
|
||||
|
||||
@doc """
|
||||
Returns the human-readable name for an error status code.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `code`: Numeric error status code or atom
|
||||
|
||||
## Returns
|
||||
|
||||
- String name of the error status
|
||||
- "unknown_error" for unrecognized codes
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_name(0)
|
||||
"no_error"
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_name(:too_big)
|
||||
"too_big"
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_name(999)
|
||||
"unknown_error"
|
||||
"""
|
||||
@spec error_name(error_status()) :: String.t()
|
||||
def error_name(0), do: "no_error"
|
||||
def error_name(:no_error), do: "no_error"
|
||||
def error_name(code) when is_integer(code) do
|
||||
code |> @gleam.from_code() |> @gleam.error_name()
|
||||
end
|
||||
|
||||
def error_name(1), do: "too_big"
|
||||
def error_name(:too_big), do: "too_big"
|
||||
def error_name(atom) when is_atom(atom) do
|
||||
atom |> @ffi.atom_to_error_status() |> @gleam.error_name()
|
||||
end
|
||||
|
||||
def error_name(2), do: "no_such_name"
|
||||
def error_name(:no_such_name), do: "no_such_name"
|
||||
|
||||
def error_name(3), do: "bad_value"
|
||||
def error_name(:bad_value), do: "bad_value"
|
||||
|
||||
def error_name(4), do: "read_only"
|
||||
def error_name(:read_only), do: "read_only"
|
||||
|
||||
def error_name(5), do: "gen_err"
|
||||
def error_name(:gen_err), do: "gen_err"
|
||||
|
||||
# SNMPv2c additional error codes
|
||||
def error_name(6), do: "no_access"
|
||||
def error_name(:no_access), do: "no_access"
|
||||
def error_name(7), do: "wrong_type"
|
||||
def error_name(:wrong_type), do: "wrong_type"
|
||||
def error_name(8), do: "wrong_length"
|
||||
def error_name(:wrong_length), do: "wrong_length"
|
||||
def error_name(9), do: "wrong_encoding"
|
||||
def error_name(:wrong_encoding), do: "wrong_encoding"
|
||||
def error_name(10), do: "wrong_value"
|
||||
def error_name(:wrong_value), do: "wrong_value"
|
||||
def error_name(11), do: "no_creation"
|
||||
def error_name(:no_creation), do: "no_creation"
|
||||
def error_name(12), do: "inconsistent_value"
|
||||
def error_name(:inconsistent_value), do: "inconsistent_value"
|
||||
def error_name(13), do: "resource_unavailable"
|
||||
def error_name(:resource_unavailable), do: "resource_unavailable"
|
||||
def error_name(14), do: "commit_failed"
|
||||
def error_name(:commit_failed), do: "commit_failed"
|
||||
def error_name(15), do: "undo_failed"
|
||||
def error_name(:undo_failed), do: "undo_failed"
|
||||
def error_name(16), do: "authorization_error"
|
||||
def error_name(:authorization_error), do: "authorization_error"
|
||||
def error_name(17), do: "not_writable"
|
||||
def error_name(:not_writable), do: "not_writable"
|
||||
def error_name(18), do: "inconsistent_name"
|
||||
def error_name(:inconsistent_name), do: "inconsistent_name"
|
||||
|
||||
def error_name(_), do: "unknown_error"
|
||||
|
||||
@doc """
|
||||
Converts error status code to atom representation.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_atom(2)
|
||||
:no_such_name
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_atom(999)
|
||||
:unknown_error
|
||||
"""
|
||||
@spec error_atom(error_status()) :: atom()
|
||||
def error_atom(code) when is_integer(code) do
|
||||
code |> error_name() |> String.to_atom()
|
||||
code |> @gleam.from_code() |> @ffi.error_status_to_atom()
|
||||
end
|
||||
|
||||
def error_atom(atom) when is_atom(atom), do: atom
|
||||
|
||||
@doc """
|
||||
Converts error atom or name to numeric code.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_code(:no_such_name)
|
||||
2
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_code("bad_value")
|
||||
3
|
||||
"""
|
||||
@spec error_code(atom() | String.t()) :: non_neg_integer()
|
||||
def error_code(:no_error), do: @no_error
|
||||
def error_code(:too_big), do: @too_big
|
||||
def error_code(:no_such_name), do: @no_such_name
|
||||
def error_code(:bad_value), do: @bad_value
|
||||
def error_code(:read_only), do: @read_only
|
||||
def error_code(:gen_err), do: @gen_err
|
||||
def error_code(:no_access), do: @no_access
|
||||
def error_code(:wrong_type), do: @wrong_type
|
||||
def error_code(:wrong_length), do: @wrong_length
|
||||
def error_code(:wrong_encoding), do: @wrong_encoding
|
||||
def error_code(:wrong_value), do: @wrong_value
|
||||
def error_code(:no_creation), do: @no_creation
|
||||
def error_code(:inconsistent_value), do: @inconsistent_value
|
||||
def error_code(:resource_unavailable), do: @resource_unavailable
|
||||
def error_code(:commit_failed), do: @commit_failed
|
||||
def error_code(:undo_failed), do: @undo_failed
|
||||
def error_code(:authorization_error), do: @authorization_error
|
||||
def error_code(:not_writable), do: @not_writable
|
||||
def error_code(:inconsistent_name), do: @inconsistent_name
|
||||
def error_code(atom) when is_atom(atom) do
|
||||
status = @ffi.atom_to_error_status(atom)
|
||||
code = @gleam.error_code(status)
|
||||
# UnknownError returns -1 from Gleam; map to gen_err (5) for compatibility
|
||||
if code == -1, do: 5, else: code
|
||||
end
|
||||
|
||||
def error_code(name) when is_binary(name) do
|
||||
name |> String.to_atom() |> error_code()
|
||||
status = @gleam.from_name(name)
|
||||
code = @gleam.error_code(status)
|
||||
if code == -1, do: 5, else: code
|
||||
end
|
||||
|
||||
def error_code(_), do: @gen_err
|
||||
def error_code(_), do: 5
|
||||
|
||||
@doc """
|
||||
Formats an SNMP error for human-readable display.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `error_status`: Error status code (integer or atom)
|
||||
- `error_index`: Index of the varbind that caused the error (1-based)
|
||||
- `varbinds`: List of varbinds from the request (optional)
|
||||
|
||||
## Returns
|
||||
|
||||
Formatted error string suitable for logging or display.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.format_error(2, 1, [])
|
||||
"SNMP Error: no_such_name (2) at index 1"
|
||||
|
||||
iex> varbinds = [{[1,3,6,1,2,1,1,1,0], "test"}]
|
||||
iex> SnmpKit.SnmpLib.Error.format_error(:bad_value, 1, varbinds)
|
||||
"SNMP Error: bad_value (3) at index 1 - OID: 1.3.6.1.2.1.1.1.0"
|
||||
"""
|
||||
@spec format_error(error_status(), error_index(), varbinds()) :: String.t()
|
||||
def format_error(error_status, error_index, varbinds \\ []) do
|
||||
error_name_str = error_name(error_status)
|
||||
error_code_num = if is_integer(error_status), do: error_status, else: error_code(error_status)
|
||||
|
||||
base_msg = "SNMP Error: #{error_name_str} (#{error_code_num}) at index #{error_index}"
|
||||
|
||||
case get_error_varbind(varbinds, error_index) do
|
||||
nil ->
|
||||
base_msg
|
||||
|
||||
{oid, _value} ->
|
||||
oid_str = Enum.join(oid, ".")
|
||||
"#{base_msg} - OID: #{oid_str}"
|
||||
end
|
||||
status = to_gleam_status(error_status)
|
||||
@gleam.format_error(status, error_index, varbinds)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Determines if an error status indicates a retriable condition.
|
||||
|
||||
Some SNMP errors are temporary and operations can be retried, while others
|
||||
indicate permanent failures.
|
||||
|
||||
## Retriable Errors
|
||||
- `too_big` - Can retry with smaller request
|
||||
- `gen_err` - General error, may be temporary
|
||||
- `resource_unavailable` - Temporary resource constraint
|
||||
|
||||
## Non-Retriable Errors
|
||||
- `no_such_name` - OID doesn't exist
|
||||
- `bad_value` - Invalid value provided
|
||||
- `read_only` - Attempted to write read-only variable
|
||||
- `no_access` - Access denied
|
||||
- Most SNMPv2c specific errors
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.retriable_error?(:too_big)
|
||||
true
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.retriable_error?(:no_such_name)
|
||||
false
|
||||
"""
|
||||
@spec retriable_error?(error_status()) :: boolean()
|
||||
def retriable_error?(error_status) do
|
||||
error_status
|
||||
|> error_atom()
|
||||
|> then(&MapSet.member?(@retriable_errors, &1))
|
||||
error_status |> to_gleam_status() |> @gleam.is_retriable()
|
||||
end
|
||||
|
||||
@doc """
|
||||
Creates an SNMP error response PDU.
|
||||
|
||||
Generates a properly formatted error response based on the original request
|
||||
and the error condition that occurred.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `request_pdu`: Original request PDU
|
||||
- `error_status`: Error status code or atom
|
||||
- `error_index`: Index of the varbind that caused the error (1-based)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, error_pdu}`: Successfully created error response
|
||||
- `{:error, reason}`: Failed to create error response
|
||||
|
||||
## Examples
|
||||
|
||||
request = %{type: :get_request, request_id: 123, varbinds: [...]}
|
||||
{:ok, error_response} = SnmpKit.SnmpLib.Error.create_error_response(
|
||||
request,
|
||||
:no_such_name,
|
||||
1
|
||||
)
|
||||
"""
|
||||
@spec create_error_response(map(), error_status(), error_index()) ::
|
||||
{:ok, map()} | {:error, atom()}
|
||||
def create_error_response(request_pdu, error_status, error_index) do
|
||||
|
|
@ -408,23 +111,9 @@ defmodule SnmpKit.SnmpLib.Error do
|
|||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Validates an error status code.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.valid_error_status?(2)
|
||||
true
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.valid_error_status?(:no_such_name)
|
||||
true
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.valid_error_status?(999)
|
||||
false
|
||||
"""
|
||||
@spec valid_error_status?(any()) :: boolean()
|
||||
def valid_error_status?(status) when is_integer(status) do
|
||||
status >= 0 and status <= 18
|
||||
@gleam.is_valid_code(status)
|
||||
end
|
||||
|
||||
def valid_error_status?(status) when is_atom(status) do
|
||||
|
|
@ -433,54 +122,10 @@ defmodule SnmpKit.SnmpLib.Error do
|
|||
|
||||
def valid_error_status?(_), do: false
|
||||
|
||||
@doc """
|
||||
Returns a list of all standard SNMP error codes.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> codes = SnmpKit.SnmpLib.Error.all_error_codes()
|
||||
iex> 0 in codes
|
||||
true
|
||||
iex> 5 in codes
|
||||
true
|
||||
"""
|
||||
@spec all_error_codes() :: [non_neg_integer()]
|
||||
def all_error_codes do
|
||||
Enum.to_list(0..18)
|
||||
end
|
||||
def all_error_codes, do: @gleam.all_codes()
|
||||
|
||||
@doc """
|
||||
Returns a list of all standard SNMP error atoms.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> atoms = SnmpKit.SnmpLib.Error.all_error_atoms()
|
||||
iex> :no_error in atoms
|
||||
true
|
||||
iex> :gen_err in atoms
|
||||
true
|
||||
"""
|
||||
@spec all_error_atoms() :: [
|
||||
:no_error
|
||||
| :too_big
|
||||
| :no_such_name
|
||||
| :bad_value
|
||||
| :read_only
|
||||
| :gen_err
|
||||
| :no_access
|
||||
| :wrong_type
|
||||
| :wrong_length
|
||||
| :wrong_encoding
|
||||
| :wrong_value
|
||||
| :no_creation
|
||||
| :inconsistent_value
|
||||
| :resource_unavailable
|
||||
| :commit_failed
|
||||
| :undo_failed
|
||||
| :authorization_error
|
||||
| :not_writable
|
||||
| :inconsistent_name
|
||||
]
|
||||
@spec all_error_atoms() :: [atom()]
|
||||
def all_error_atoms do
|
||||
[
|
||||
:no_error,
|
||||
|
|
@ -505,49 +150,16 @@ defmodule SnmpKit.SnmpLib.Error do
|
|||
]
|
||||
end
|
||||
|
||||
@doc """
|
||||
Categorizes error by severity level.
|
||||
|
||||
## Returns
|
||||
|
||||
- `:info` - No error
|
||||
- `:warning` - Retriable errors
|
||||
- `:error` - Non-retriable errors
|
||||
|
||||
## Examples
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_severity(:no_error)
|
||||
:info
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_severity(:too_big)
|
||||
:warning
|
||||
|
||||
iex> SnmpKit.SnmpLib.Error.error_severity(:no_such_name)
|
||||
:error
|
||||
"""
|
||||
@spec error_severity(error_status()) :: :info | :warning | :error
|
||||
def error_severity(error_status) do
|
||||
case error_atom(error_status) do
|
||||
:no_error -> :info
|
||||
status when status in [:too_big, :gen_err, :resource_unavailable] -> :warning
|
||||
_ -> :error
|
||||
end
|
||||
error_status |> to_gleam_status() |> @gleam.severity() |> String.to_atom()
|
||||
end
|
||||
|
||||
## Private Helper Functions
|
||||
## Private Helpers
|
||||
|
||||
defp validate_request_pdu(request_pdu) when is_map(request_pdu) do
|
||||
:ok
|
||||
end
|
||||
defp to_gleam_status(code) when is_integer(code), do: @gleam.from_code(code)
|
||||
defp to_gleam_status(atom) when is_atom(atom), do: @ffi.atom_to_error_status(atom)
|
||||
|
||||
defp validate_request_pdu(request_pdu) when is_map(request_pdu), do: :ok
|
||||
defp validate_request_pdu(_), do: {:error, :invalid_request_pdu}
|
||||
|
||||
defp get_error_varbind(varbinds, error_index) when is_list(varbinds) do
|
||||
# SNMP error_index is 1-based
|
||||
if error_index > 0 and error_index <= length(varbinds) do
|
||||
Enum.at(varbinds, error_index - 1)
|
||||
end
|
||||
end
|
||||
|
||||
defp get_error_varbind(_, _), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -2,8 +2,8 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@moduledoc """
|
||||
Comprehensive OID (Object Identifier) manipulation utilities for SNMP operations.
|
||||
|
||||
Provides string/list conversions, tree operations, table utilities, and validation
|
||||
functions needed by both SNMP managers and simulators.
|
||||
Core logic implemented in Gleam (`src/snmpkit/snmp_lib/oid.gleam`).
|
||||
This module provides the Elixir API with type guards and polymorphic dispatch.
|
||||
|
||||
## Features
|
||||
|
||||
|
|
@ -13,84 +13,41 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
- SNMP table index parsing and construction
|
||||
- OID comparison and sorting
|
||||
- Enterprise OID utilities
|
||||
- Performance-optimized operations
|
||||
|
||||
## OID Format Support
|
||||
|
||||
SnmpKit supports both common OID string formats:
|
||||
- **Traditional format**: "1.3.6.1.2.1.1.1.0" (Elixir/Erlang style)
|
||||
- **Standard format**: ".1.3.6.1.2.1.1.1.0" (RFC standard with leading dot)
|
||||
|
||||
Both formats parse to identical internal representations and can be used
|
||||
interchangeably throughout the library.
|
||||
|
||||
## Examples
|
||||
|
||||
# Basic conversions - both formats supported
|
||||
{:ok, oid_list} = SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.2.1.1.1.0")
|
||||
{:ok, same_list} = SnmpKit.SnmpLib.OID.string_to_list(".1.3.6.1.2.1.1.1.0")
|
||||
{:ok, oid_string} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
|
||||
# Tree operations
|
||||
true = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
|
||||
{:ok, parent} = SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
|
||||
# Comparison
|
||||
:lt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
|
||||
|
||||
# Table operations
|
||||
{:ok, index} = SnmpKit.SnmpLib.OID.extract_table_index([1, 3, 6, 1, 2, 1, 2, 2, 1, 1], [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1])
|
||||
"""
|
||||
|
||||
@gleam :snmpkit@snmp_lib@oid
|
||||
|
||||
@type oid :: [non_neg_integer()]
|
||||
@type oid_string :: String.t()
|
||||
@type table_oid :: oid()
|
||||
@type index :: [non_neg_integer()]
|
||||
|
||||
# Standard SNMP OID prefixes
|
||||
@iso_org_dod_internet [1, 3, 6, 1]
|
||||
@mgmt [1, 3, 6, 1, 2]
|
||||
@mib_2 [1, 3, 6, 1, 2, 1]
|
||||
@enterprises [1, 3, 6, 1, 4, 1]
|
||||
@experimental [1, 3, 6, 1, 3]
|
||||
@private [1, 3, 6, 1, 4]
|
||||
|
||||
## String/List Conversions
|
||||
|
||||
@doc """
|
||||
Converts an OID string to a list of integers.
|
||||
|
||||
Parses a dot-separated OID string into a list of non-negative integers.
|
||||
Validates each component and ensures the OID format is correct.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `oid_string`: Dot-separated OID string (e.g., "1.3.6.1.2.1.1.1.0" or ".1.3.6.1.2.1.1.1.0")
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, oid_list}` on success
|
||||
- `{:error, reason}` on failure
|
||||
|
||||
## Examples
|
||||
|
||||
# Standard SNMP OIDs
|
||||
iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6.1.2.1.1.1.0")
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 1, 1, 0]}
|
||||
|
||||
# OIDs with leading dot (standard format)
|
||||
iex> SnmpKit.SnmpLib.OID.string_to_list(".1.3.6.1.2.1.1.1.0")
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 1, 1, 0]}
|
||||
|
||||
# Short OIDs
|
||||
iex> SnmpKit.SnmpLib.OID.string_to_list("1.3.6")
|
||||
{:ok, [1, 3, 6]}
|
||||
|
||||
# Short OIDs with leading dot
|
||||
iex> SnmpKit.SnmpLib.OID.string_to_list(".1.3.6")
|
||||
{:ok, [1, 3, 6]}
|
||||
|
||||
# Error cases
|
||||
iex> SnmpKit.SnmpLib.OID.string_to_list("")
|
||||
{:error, :empty_oid}
|
||||
|
||||
|
|
@ -102,60 +59,22 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
"""
|
||||
@spec string_to_list(oid_string()) :: {:ok, oid()} | {:error, atom()}
|
||||
def string_to_list(oid_string) when is_binary(oid_string) do
|
||||
with {:ok, normalized} <- normalize_oid_string(oid_string),
|
||||
parts = String.split(normalized, "."),
|
||||
{:ok, oid_list} <- parse_oid_components(parts),
|
||||
:ok <- validate_oid_list(oid_list) do
|
||||
{:ok, oid_list}
|
||||
end
|
||||
@gleam.string_to_list(oid_string)
|
||||
end
|
||||
|
||||
def string_to_list(_), do: {:error, :invalid_input}
|
||||
|
||||
defp normalize_oid_string(oid_string) do
|
||||
trimmed = String.trim(oid_string)
|
||||
|
||||
cond do
|
||||
trimmed == "" ->
|
||||
{:error, :empty_oid}
|
||||
|
||||
String.starts_with?(trimmed, ".") ->
|
||||
normalized = String.slice(trimmed, 1..-1//1)
|
||||
if normalized == "", do: {:error, :empty_oid}, else: {:ok, normalized}
|
||||
|
||||
true ->
|
||||
{:ok, trimmed}
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts an OID list to a dot-separated string.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `oid_list`: List of non-negative integers
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, oid_string}` on success
|
||||
- `{:error, reason}` on failure
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, "1.3.6.1.2.1.1.1.0"} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
{:error, :invalid_oid_list} = SnmpKit.SnmpLib.OID.list_to_string([1, 3, -1, 4])
|
||||
{:error, :empty_oid} = SnmpKit.SnmpLib.OID.list_to_string([])
|
||||
iex> SnmpKit.SnmpLib.OID.list_to_string([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
{:ok, "1.3.6.1.2.1.1.1.0"}
|
||||
"""
|
||||
@spec list_to_string(oid()) :: {:ok, oid_string()} | {:error, atom()}
|
||||
def list_to_string(oid_list) when is_list(oid_list) do
|
||||
case validate_oid_list(oid_list) do
|
||||
:ok ->
|
||||
oid_string = Enum.join(oid_list, ".")
|
||||
{:ok, oid_string}
|
||||
|
||||
{:error, reason} ->
|
||||
{:error, reason}
|
||||
end
|
||||
@gleam.list_to_string(oid_list)
|
||||
end
|
||||
|
||||
def list_to_string(_), do: {:error, :invalid_input}
|
||||
|
|
@ -165,29 +84,14 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@doc """
|
||||
Checks if one OID is a child of another.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `child_oid`: Potential child OID
|
||||
- `parent_oid`: Potential parent OID
|
||||
|
||||
## Returns
|
||||
|
||||
- `true` if child_oid is a child of parent_oid
|
||||
- `false` otherwise
|
||||
|
||||
## Examples
|
||||
|
||||
true = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
|
||||
false = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1], [1, 3, 6, 1, 2, 1])
|
||||
false = SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 2], [1, 3, 6, 1])
|
||||
iex> SnmpKit.SnmpLib.OID.child_of?([1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1])
|
||||
true
|
||||
"""
|
||||
@spec child_of?(oid(), oid()) :: boolean()
|
||||
def child_of?(child_oid, parent_oid) when is_list(child_oid) and is_list(parent_oid) do
|
||||
child_length = length(child_oid)
|
||||
parent_length = length(parent_oid)
|
||||
|
||||
child_length > parent_length and
|
||||
Enum.take(child_oid, parent_length) == parent_oid
|
||||
@gleam.child_of(child_oid, parent_oid)
|
||||
end
|
||||
|
||||
def child_of?(_, _), do: false
|
||||
|
|
@ -203,44 +107,25 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
|
||||
## Examples
|
||||
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 1, 1]} = SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
{:error, :root_oid} = SnmpKit.SnmpLib.OID.get_parent([])
|
||||
{:error, :root_oid} = SnmpKit.SnmpLib.OID.get_parent([1])
|
||||
iex> SnmpKit.SnmpLib.OID.get_parent([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 1, 1]}
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.get_parent([])
|
||||
{:error, :root_oid}
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.get_parent([1])
|
||||
{:error, :root_oid}
|
||||
"""
|
||||
@spec get_parent(oid()) :: {:ok, oid()} | {:error, atom()}
|
||||
def get_parent(oid) when is_list(oid) and length(oid) > 1 do
|
||||
parent = Enum.drop(oid, -1)
|
||||
{:ok, parent}
|
||||
end
|
||||
|
||||
def get_parent(oid) when is_list(oid), do: {:error, :root_oid}
|
||||
def get_parent(oid) when is_list(oid), do: @gleam.get_parent(oid)
|
||||
def get_parent(_), do: {:error, :invalid_input}
|
||||
|
||||
@doc """
|
||||
Gets the immediate children prefix for an OID in a given set.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `parent_oid`: The parent OID
|
||||
- `oid_set`: Set of OIDs to search
|
||||
|
||||
## Returns
|
||||
|
||||
- List of immediate child OIDs
|
||||
|
||||
## Examples
|
||||
|
||||
children = SnmpKit.SnmpLib.OID.get_children([1, 3, 6, 1], [[1, 3, 6, 1, 2], [1, 3, 6, 1, 4], [1, 3, 6, 1, 2, 1]])
|
||||
# Returns [[1, 3, 6, 1, 2], [1, 3, 6, 1, 4]]
|
||||
"""
|
||||
@spec get_children(oid(), [oid()]) :: [oid()]
|
||||
def get_children(parent_oid, oid_set) when is_list(parent_oid) and is_list(oid_set) do
|
||||
parent_length = length(parent_oid)
|
||||
|
||||
oid_set
|
||||
|> Enum.filter(&child_of?(&1, parent_oid))
|
||||
|> Enum.map(&Enum.take(&1, parent_length + 1))
|
||||
|> Enum.uniq()
|
||||
@gleam.get_children(parent_oid, oid_set)
|
||||
end
|
||||
|
||||
def get_children(_, _), do: []
|
||||
|
|
@ -257,38 +142,20 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
[1, 3, 6, 1, 2]
|
||||
"""
|
||||
@spec standard_prefix(atom()) :: oid() | nil
|
||||
def standard_prefix(:internet), do: @iso_org_dod_internet
|
||||
def standard_prefix(:mgmt), do: @mgmt
|
||||
def standard_prefix(:mib_2), do: @mib_2
|
||||
def standard_prefix(:enterprises), do: @enterprises
|
||||
def standard_prefix(:experimental), do: @experimental
|
||||
def standard_prefix(:private), do: @private
|
||||
def standard_prefix(:internet), do: [1, 3, 6, 1]
|
||||
def standard_prefix(:mgmt), do: [1, 3, 6, 1, 2]
|
||||
def standard_prefix(:mib_2), do: [1, 3, 6, 1, 2, 1]
|
||||
def standard_prefix(:enterprises), do: [1, 3, 6, 1, 4, 1]
|
||||
def standard_prefix(:experimental), do: [1, 3, 6, 1, 3]
|
||||
def standard_prefix(:private), do: [1, 3, 6, 1, 4]
|
||||
def standard_prefix(_), do: nil
|
||||
|
||||
@doc """
|
||||
Gets the next OID in lexicographic order from a given set.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `current_oid`: The current OID
|
||||
- `oid_set`: Set of OIDs to search (must be sorted)
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, next_oid}` if found
|
||||
- `{:error, :end_of_mib}` if no next OID exists
|
||||
|
||||
## Examples
|
||||
|
||||
oid_set = [[1, 3, 6, 1, 2, 1, 1, 1, 0], [1, 3, 6, 1, 2, 1, 1, 2, 0], [1, 3, 6, 1, 2, 1, 1, 3, 0]]
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 1, 2, 0]} = SnmpKit.SnmpLib.OID.get_next_oid([1, 3, 6, 1, 2, 1, 1, 1, 0], oid_set)
|
||||
"""
|
||||
@spec get_next_oid(oid(), [oid()]) :: {:ok, oid()} | {:error, atom()}
|
||||
def get_next_oid(current_oid, oid_set) when is_list(current_oid) and is_list(oid_set) do
|
||||
case Enum.find(oid_set, &(compare(&1, current_oid) == :gt)) do
|
||||
nil -> {:error, :end_of_mib}
|
||||
next_oid -> {:ok, next_oid}
|
||||
end
|
||||
@gleam.get_next_oid(current_oid, oid_set)
|
||||
end
|
||||
|
||||
def get_next_oid(_, _), do: {:error, :invalid_input}
|
||||
|
|
@ -298,36 +165,27 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@doc """
|
||||
Compares two OIDs lexicographically.
|
||||
|
||||
## Returns
|
||||
|
||||
- `:lt` if oid1 < oid2
|
||||
- `:eq` if oid1 == oid2
|
||||
- `:gt` if oid1 > oid2
|
||||
|
||||
## Examples
|
||||
|
||||
:lt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
|
||||
:eq = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 1])
|
||||
:gt = SnmpKit.SnmpLib.OID.compare([1, 3, 6, 2], [1, 3, 6, 1])
|
||||
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 2])
|
||||
:lt
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 1], [1, 3, 6, 1])
|
||||
:eq
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.compare([1, 3, 6, 2], [1, 3, 6, 1])
|
||||
:gt
|
||||
"""
|
||||
@spec compare(oid(), oid()) :: :lt | :eq | :gt
|
||||
def compare(oid1, oid2) when is_list(oid1) and is_list(oid2) do
|
||||
compare_components(oid1, oid2)
|
||||
@gleam.compare_oids(oid1, oid2)
|
||||
end
|
||||
|
||||
@doc """
|
||||
Sorts a list of OIDs in lexicographic order.
|
||||
|
||||
## Examples
|
||||
|
||||
sorted = SnmpKit.SnmpLib.OID.sort([[1, 3, 6, 2], [1, 3, 6, 1, 2], [1, 3, 6, 1]])
|
||||
# Returns [[1, 3, 6, 1], [1, 3, 6, 1, 2], [1, 3, 6, 2]]
|
||||
"""
|
||||
@spec sort([oid()]) :: [oid()]
|
||||
def sort(oid_list) when is_list(oid_list) do
|
||||
Enum.sort(oid_list, &(compare(&1, &2) != :gt))
|
||||
end
|
||||
|
||||
def sort(oid_list) when is_list(oid_list), do: @gleam.sort_oids(oid_list)
|
||||
def sort(_), do: []
|
||||
|
||||
## Table Operations
|
||||
|
|
@ -335,70 +193,24 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@doc """
|
||||
Extracts table index from an instance OID given the table column OID.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `table_oid`: Base table column OID
|
||||
- `instance_oid`: Full instance OID including index
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, index}` if successful
|
||||
- `{:error, reason}` if extraction fails
|
||||
|
||||
## Examples
|
||||
|
||||
table_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1]
|
||||
instance_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1]
|
||||
{:ok, [1]} = SnmpKit.SnmpLib.OID.extract_table_index(table_oid, instance_oid)
|
||||
iex> SnmpKit.SnmpLib.OID.extract_table_index([1, 3, 6, 1, 2, 1, 2, 2, 1, 1], [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1])
|
||||
{:ok, [1]}
|
||||
"""
|
||||
@spec extract_table_index(table_oid(), oid()) :: {:ok, index()} | {:error, atom()}
|
||||
def extract_table_index(table_oid, instance_oid) when is_list(table_oid) and is_list(instance_oid) do
|
||||
table_length = length(table_oid)
|
||||
instance_length = length(instance_oid)
|
||||
|
||||
if instance_length > table_length and
|
||||
Enum.take(instance_oid, table_length) == table_oid do
|
||||
index = Enum.drop(instance_oid, table_length)
|
||||
{:ok, index}
|
||||
else
|
||||
{:error, :invalid_table_instance}
|
||||
end
|
||||
@gleam.extract_table_index(table_oid, instance_oid)
|
||||
end
|
||||
|
||||
def extract_table_index(_, _), do: {:error, :invalid_input}
|
||||
|
||||
@doc """
|
||||
Builds a table instance OID from table OID and index.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `table_oid`: Base table column OID
|
||||
- `index`: Table index as list of integers
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, instance_oid}` if successful
|
||||
- `{:error, reason}` if construction fails
|
||||
|
||||
## Examples
|
||||
|
||||
table_oid = [1, 3, 6, 1, 2, 1, 2, 2, 1, 1]
|
||||
index = [1]
|
||||
{:ok, [1, 3, 6, 1, 2, 1, 2, 2, 1, 1, 1]} = SnmpKit.SnmpLib.OID.build_table_instance(table_oid, index)
|
||||
"""
|
||||
@spec build_table_instance(table_oid(), index()) :: {:ok, oid()} | {:error, atom()}
|
||||
def build_table_instance(table_oid, index) when is_list(table_oid) and is_list(index) do
|
||||
case {validate_oid_list(table_oid), validate_oid_list(index)} do
|
||||
{:ok, :ok} ->
|
||||
instance_oid = table_oid ++ index
|
||||
{:ok, instance_oid}
|
||||
|
||||
{{:error, reason}, _} ->
|
||||
{:error, reason}
|
||||
|
||||
{_, {:error, reason}} ->
|
||||
{:error, reason}
|
||||
end
|
||||
@gleam.build_table_instance(table_oid, index)
|
||||
end
|
||||
|
||||
def build_table_instance(_, _), do: {:error, :invalid_input}
|
||||
|
|
@ -406,48 +218,28 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@doc """
|
||||
Parses a table index according to index syntax definition.
|
||||
|
||||
## Parameters
|
||||
|
||||
- `index`: Raw index from OID
|
||||
- `syntax`: Index syntax specification
|
||||
|
||||
## Returns
|
||||
|
||||
- `{:ok, parsed_index}` if successful
|
||||
- `{:error, reason}` if parsing fails
|
||||
|
||||
## Index Syntax Examples
|
||||
|
||||
- `:integer` - Single integer index
|
||||
- `{:string, length}` - Fixed-length string
|
||||
- `{:variable_string}` - Length-prefixed string
|
||||
- `[:integer, :integer]` - Multiple integer indices
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, 42} = SnmpKit.SnmpLib.OID.parse_table_index([42], :integer)
|
||||
{:ok, "test"} = SnmpKit.SnmpLib.OID.parse_table_index([4, 116, 101, 115, 116], {:variable_string})
|
||||
iex> SnmpKit.SnmpLib.OID.parse_table_index([42], :integer)
|
||||
{:ok, 42}
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.parse_table_index([4, 116, 101, 115, 116], {:variable_string})
|
||||
{:ok, "test"}
|
||||
"""
|
||||
@spec parse_table_index(index(), term()) :: {:ok, term()} | {:error, atom()}
|
||||
def parse_table_index(index, :integer) when is_list(index) and length(index) == 1 do
|
||||
{:ok, hd(index)}
|
||||
def parse_table_index(index, :integer) when is_list(index) do
|
||||
@gleam.parse_integer_index(index)
|
||||
end
|
||||
|
||||
def parse_table_index(index, {:string, length}) when is_list(index) and length(index) == length do
|
||||
case build_string_from_bytes(index) do
|
||||
{:ok, string} -> {:ok, string}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
def parse_table_index(index, {:string, length}) when is_list(index) do
|
||||
@gleam.parse_fixed_string_index(index, length)
|
||||
end
|
||||
|
||||
def parse_table_index([length | rest], {:variable_string}) when length(rest) == length do
|
||||
case build_string_from_bytes(rest) do
|
||||
{:ok, string} -> {:ok, string}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
def parse_table_index(index, {:variable_string}) when is_list(index) do
|
||||
@gleam.parse_variable_string_index(index)
|
||||
end
|
||||
|
||||
def parse_table_index(index, syntax_list) when is_list(syntax_list) do
|
||||
def parse_table_index(index, syntax_list) when is_list(index) and is_list(syntax_list) do
|
||||
case parse_index_components(index, syntax_list, []) do
|
||||
{:ok, {parsed, _remaining}} -> {:ok, parsed}
|
||||
{:error, reason} -> {:error, reason}
|
||||
|
|
@ -461,38 +253,31 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
|
||||
## Examples
|
||||
|
||||
{:ok, [42]} = SnmpKit.SnmpLib.OID.build_table_index(42, :integer)
|
||||
{:ok, [4, 116, 101, 115, 116]} = SnmpKit.SnmpLib.OID.build_table_index("test", {:variable_string})
|
||||
iex> SnmpKit.SnmpLib.OID.build_table_index(42, :integer)
|
||||
{:ok, [42]}
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.build_table_index("test", {:variable_string})
|
||||
{:ok, [4, 116, 101, 115, 116]}
|
||||
"""
|
||||
@spec build_table_index(term(), term()) :: {:ok, index()} | {:error, atom()}
|
||||
def build_table_index(values, syntax_list) when is_list(values) and is_list(syntax_list) do
|
||||
if length(values) == length(syntax_list) do
|
||||
case build_compound_index(values, syntax_list) do
|
||||
{:ok, index} -> {:ok, index}
|
||||
{:error, reason} -> {:error, reason}
|
||||
end
|
||||
build_compound_index(values, syntax_list)
|
||||
else
|
||||
{:error, :syntax_value_mismatch}
|
||||
end
|
||||
end
|
||||
|
||||
def build_table_index(value, :integer) when is_integer(value) and value >= 0 do
|
||||
{:ok, [value]}
|
||||
@gleam.build_integer_index(value)
|
||||
end
|
||||
|
||||
def build_table_index(value, {:string, length}) when is_binary(value) do
|
||||
if String.length(value) == length do
|
||||
index = String.to_charlist(value)
|
||||
{:ok, index}
|
||||
else
|
||||
{:error, :invalid_string_length}
|
||||
end
|
||||
@gleam.build_fixed_string_index(value, length)
|
||||
end
|
||||
|
||||
def build_table_index(value, {:variable_string}) when is_binary(value) do
|
||||
char_list = String.to_charlist(value)
|
||||
index = [length(char_list) | char_list]
|
||||
{:ok, index}
|
||||
@gleam.build_variable_string_index(value)
|
||||
end
|
||||
|
||||
def build_table_index(_, _), do: {:error, :unsupported_syntax}
|
||||
|
|
@ -502,148 +287,79 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
@doc """
|
||||
Validates an OID list for correctness.
|
||||
|
||||
## Returns
|
||||
|
||||
- `:ok` if valid
|
||||
- `{:error, reason}` if invalid
|
||||
|
||||
## Examples
|
||||
|
||||
:ok = SnmpKit.SnmpLib.OID.valid_oid?([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
{:error, :empty_oid} = SnmpKit.SnmpLib.OID.valid_oid?([])
|
||||
{:error, :invalid_component} = SnmpKit.SnmpLib.OID.valid_oid?([1, 3, -1, 4])
|
||||
iex> SnmpKit.SnmpLib.OID.valid_oid?([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
:ok
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.valid_oid?([])
|
||||
{:error, :empty_oid}
|
||||
"""
|
||||
@spec valid_oid?(oid()) :: :ok | {:error, atom()}
|
||||
def valid_oid?(oid) when is_list(oid), do: validate_oid_list(oid)
|
||||
def valid_oid?(_), do: {:error, :invalid_input}
|
||||
|
||||
@doc """
|
||||
Normalizes an OID to a consistent format.
|
||||
|
||||
Accepts either string or list format and returns a list.
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, [1, 3, 6, 1]} = SnmpKit.SnmpLib.OID.normalize("1.3.6.1")
|
||||
{:ok, [1, 3, 6, 1]} = SnmpKit.SnmpLib.OID.normalize([1, 3, 6, 1])
|
||||
"""
|
||||
@spec normalize(oid() | oid_string()) :: {:ok, oid()} | {:error, atom()}
|
||||
def normalize(oid) when is_list(oid) do
|
||||
case valid_oid?(oid) do
|
||||
:ok -> {:ok, oid}
|
||||
def valid_oid?(oid) when is_list(oid) do
|
||||
case @gleam.validate(oid) do
|
||||
{:ok, _} -> :ok
|
||||
error -> error
|
||||
end
|
||||
end
|
||||
|
||||
def normalize(oid) when is_binary(oid), do: string_to_list(oid)
|
||||
def normalize(_), do: {:error, :invalid_input}
|
||||
|
||||
## Utility Functions
|
||||
def valid_oid?(_), do: {:error, :invalid_input}
|
||||
|
||||
@doc """
|
||||
Returns standard SNMP OID prefixes.
|
||||
"""
|
||||
@spec mib_2() :: oid()
|
||||
def mib_2, do: @mib_2
|
||||
|
||||
@spec enterprises() :: oid()
|
||||
def enterprises, do: @enterprises
|
||||
|
||||
@spec experimental() :: oid()
|
||||
def experimental, do: @experimental
|
||||
|
||||
@spec private() :: oid()
|
||||
def private, do: @private
|
||||
|
||||
@doc """
|
||||
Checks if an OID is under a specific standard tree.
|
||||
Normalizes an OID to a consistent format (list).
|
||||
|
||||
## Examples
|
||||
|
||||
true = SnmpKit.SnmpLib.OID.mib_2?([1, 3, 6, 1, 2, 1, 1, 1, 0])
|
||||
true = SnmpKit.SnmpLib.OID.enterprise?([1, 3, 6, 1, 4, 1, 9, 1, 1])
|
||||
iex> SnmpKit.SnmpLib.OID.normalize("1.3.6.1")
|
||||
{:ok, [1, 3, 6, 1]}
|
||||
|
||||
iex> SnmpKit.SnmpLib.OID.normalize([1, 3, 6, 1])
|
||||
{:ok, [1, 3, 6, 1]}
|
||||
"""
|
||||
@spec normalize(oid() | oid_string()) :: {:ok, oid()} | {:error, atom()}
|
||||
def normalize(oid) when is_list(oid), do: @gleam.normalize_list(oid)
|
||||
def normalize(oid) when is_binary(oid), do: @gleam.normalize_string(oid)
|
||||
def normalize(_), do: {:error, :invalid_input}
|
||||
|
||||
## Standard OID Utilities
|
||||
|
||||
@spec mib_2() :: oid()
|
||||
def mib_2, do: @gleam.mib_2()
|
||||
|
||||
@spec enterprises() :: oid()
|
||||
def enterprises, do: @gleam.enterprises()
|
||||
|
||||
@spec experimental() :: oid()
|
||||
def experimental, do: @gleam.experimental()
|
||||
|
||||
@spec private() :: oid()
|
||||
def private, do: @gleam.private()
|
||||
|
||||
@spec mib_2?(oid()) :: boolean()
|
||||
def mib_2?(oid), do: child_of?(oid, @mib_2) or oid == @mib_2
|
||||
def mib_2?(oid), do: @gleam.is_mib_2(oid)
|
||||
|
||||
@spec enterprise?(oid()) :: boolean()
|
||||
def enterprise?(oid), do: child_of?(oid, @enterprises)
|
||||
def enterprise?(oid), do: @gleam.is_enterprise(oid)
|
||||
|
||||
@spec experimental?(oid()) :: boolean()
|
||||
def experimental?(oid), do: child_of?(oid, @experimental)
|
||||
def experimental?(oid), do: @gleam.is_experimental(oid)
|
||||
|
||||
@spec private?(oid()) :: boolean()
|
||||
def private?(oid), do: child_of?(oid, @private)
|
||||
def private?(oid), do: @gleam.is_private(oid)
|
||||
|
||||
@doc """
|
||||
Gets the enterprise number from an enterprise OID.
|
||||
|
||||
## Examples
|
||||
|
||||
{:ok, 9} = SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 4, 1, 9, 1, 1])
|
||||
{:error, :not_enterprise_oid} = SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 2, 1])
|
||||
iex> SnmpKit.SnmpLib.OID.get_enterprise_number([1, 3, 6, 1, 4, 1, 9, 1, 1])
|
||||
{:ok, 9}
|
||||
"""
|
||||
@spec get_enterprise_number(oid()) :: {:ok, non_neg_integer()} | {:error, atom()}
|
||||
def get_enterprise_number(oid) when is_list(oid) do
|
||||
if enterprise?(oid) and length(oid) > length(@enterprises) do
|
||||
enterprise_num = Enum.at(oid, length(@enterprises))
|
||||
{:ok, enterprise_num}
|
||||
else
|
||||
{:error, :not_enterprise_oid}
|
||||
end
|
||||
end
|
||||
|
||||
def get_enterprise_number(oid) when is_list(oid), do: @gleam.get_enterprise_number(oid)
|
||||
def get_enterprise_number(_), do: {:error, :invalid_input}
|
||||
|
||||
## Private Helper Functions
|
||||
|
||||
defp parse_oid_components(parts) do
|
||||
oid_list =
|
||||
Enum.map(parts, fn part ->
|
||||
case parse_oid_component(part) do
|
||||
{:error, reason} -> throw(reason)
|
||||
num -> num
|
||||
end
|
||||
end)
|
||||
|
||||
{:ok, oid_list}
|
||||
catch
|
||||
reason -> {:error, reason}
|
||||
end
|
||||
|
||||
defp parse_oid_component(component) when is_binary(component) do
|
||||
case Integer.parse(component) do
|
||||
{num, ""} when num >= 0 -> num
|
||||
_ -> {:error, :invalid_oid_string}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_oid_list([]), do: {:error, :empty_oid}
|
||||
|
||||
defp validate_oid_list(oid_list) when is_list(oid_list) do
|
||||
if Enum.all?(oid_list, &valid_oid_component?/1) do
|
||||
:ok
|
||||
else
|
||||
{:error, :invalid_component}
|
||||
end
|
||||
end
|
||||
|
||||
defp validate_oid_list(_), do: {:error, :invalid_input}
|
||||
|
||||
defp valid_oid_component?(component) when is_integer(component) and component >= 0, do: true
|
||||
defp valid_oid_component?(_), do: false
|
||||
|
||||
defp compare_components([], []), do: :eq
|
||||
defp compare_components([], _), do: :lt
|
||||
defp compare_components(_, []), do: :gt
|
||||
|
||||
defp compare_components([h1 | t1], [h2 | t2]) do
|
||||
cond do
|
||||
h1 < h2 -> :lt
|
||||
h1 > h2 -> :gt
|
||||
true -> compare_components(t1, t2)
|
||||
end
|
||||
end
|
||||
## Private Helpers for compound table index operations
|
||||
|
||||
defp parse_index_components(index, [], acc) do
|
||||
{:ok, {Enum.reverse(acc), index}}
|
||||
|
|
@ -652,7 +368,6 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
defp parse_index_components(index, [syntax | rest_syntax], acc) do
|
||||
case parse_table_index(index, syntax) do
|
||||
{:ok, value} ->
|
||||
# Calculate how many components were consumed
|
||||
case build_table_index(value, syntax) do
|
||||
{:ok, consumed_index} ->
|
||||
consumed_length = length(consumed_index)
|
||||
|
|
@ -668,13 +383,6 @@ defmodule SnmpKit.SnmpLib.OID do
|
|||
end
|
||||
end
|
||||
|
||||
defp build_string_from_bytes(bytes) do
|
||||
string = Enum.map_join(bytes, "", &<<&1>>)
|
||||
{:ok, string}
|
||||
rescue
|
||||
_ -> {:error, :invalid_string_index}
|
||||
end
|
||||
|
||||
defp build_compound_index(values, syntax_list) do
|
||||
index_parts =
|
||||
values
|
||||
|
|
|
|||
|
|
@ -2,11 +2,12 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
|
|||
@moduledoc """
|
||||
Constants and type definitions for SNMP PDU operations.
|
||||
|
||||
This module contains all ASN.1 tags, error codes, type definitions,
|
||||
and utility functions used throughout the SNMP PDU system.
|
||||
Pure computation (version normalization, OID parsing, message flag encoding)
|
||||
is implemented in Gleam. This module provides the Elixir API with type
|
||||
definitions, atom-based lookups, and binary encodings.
|
||||
"""
|
||||
|
||||
import Bitwise
|
||||
@gleam :snmpkit@snmp_lib@pdu@constants
|
||||
|
||||
# Type definitions
|
||||
@type snmp_type ::
|
||||
|
|
@ -152,31 +153,11 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
|
|||
def no_such_instance, do: @no_such_instance
|
||||
def end_of_mib_view, do: @end_of_mib_view
|
||||
|
||||
# Utility functions moved from main module
|
||||
def normalize_version(:v1), do: 0
|
||||
def normalize_version(:v2c), do: 1
|
||||
def normalize_version(:v2), do: 1
|
||||
def normalize_version(:v3), do: 3
|
||||
def normalize_version(v) when is_integer(v), do: v
|
||||
def normalize_version(_), do: 0
|
||||
# Delegate computation functions to Gleam
|
||||
defdelegate normalize_version(version), to: @gleam
|
||||
|
||||
def normalize_oid(oid) when is_list(oid), do: oid
|
||||
|
||||
def normalize_oid(oid) when is_binary(oid) do
|
||||
oid
|
||||
|> String.split(".")
|
||||
|> Enum.map(fn part ->
|
||||
case Integer.parse(part) do
|
||||
{num, ""} when num >= 0 -> num
|
||||
_ -> throw(:invalid_oid)
|
||||
end
|
||||
end)
|
||||
catch
|
||||
# Safe default for invalid OID strings
|
||||
:invalid_oid -> [1, 3, 6, 1]
|
||||
end
|
||||
|
||||
# Safe default for invalid types
|
||||
def normalize_oid(oid) when is_binary(oid), do: @gleam.normalize_oid_string(oid)
|
||||
def normalize_oid(_), do: [1, 3, 6, 1]
|
||||
|
||||
# New utility functions for type conversion
|
||||
|
|
@ -258,11 +239,7 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
|
|||
"""
|
||||
@spec encode_msg_flags(msg_flags()) :: binary()
|
||||
def encode_msg_flags(%{auth: auth, priv: priv, reportable: reportable}) do
|
||||
flags = 0
|
||||
flags = if auth, do: flags ||| 0x01, else: flags
|
||||
flags = if priv, do: flags ||| 0x02, else: flags
|
||||
flags = if reportable, do: flags ||| 0x04, else: flags
|
||||
<<flags>>
|
||||
<<@gleam.encode_msg_flags(auth, priv, reportable)>>
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -270,11 +247,8 @@ defmodule SnmpKit.SnmpLib.PDU.Constants do
|
|||
"""
|
||||
@spec decode_msg_flags(binary()) :: msg_flags()
|
||||
def decode_msg_flags(<<flags::8>>) do
|
||||
%{
|
||||
auth: (flags &&& 0x01) != 0,
|
||||
priv: (flags &&& 0x02) != 0,
|
||||
reportable: (flags &&& 0x04) != 0
|
||||
}
|
||||
{auth, priv, reportable} = @gleam.decode_msg_flags(flags)
|
||||
%{auth: auth, priv: priv, reportable: reportable}
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
|
|||
|
|
@ -59,6 +59,8 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
|
||||
alias SnmpKit.SnmpLib.OID
|
||||
|
||||
@formatting :snmpkit@formatting
|
||||
|
||||
@type snmp_type ::
|
||||
:integer
|
||||
| :string
|
||||
|
|
@ -515,10 +517,7 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
"""
|
||||
@spec format_timeticks_uptime(non_neg_integer()) :: binary()
|
||||
def format_timeticks_uptime(centiseconds) when is_integer(centiseconds) and centiseconds >= 0 do
|
||||
total_seconds = div(centiseconds, 100)
|
||||
remaining_centiseconds = rem(centiseconds, 100)
|
||||
|
||||
format_time_components(total_seconds, remaining_centiseconds)
|
||||
@formatting.format_timeticks_uptime(centiseconds)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -531,7 +530,7 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
"""
|
||||
@spec format_counter64(non_neg_integer()) :: binary()
|
||||
def format_counter64(value) when is_integer(value) and value >= 0 do
|
||||
format_large_number(value)
|
||||
@formatting.format_number(value)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -559,22 +558,7 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
"""
|
||||
@spec format_bytes(non_neg_integer()) :: binary()
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
|
||||
cond do
|
||||
bytes < 1024 ->
|
||||
"#{bytes} B"
|
||||
|
||||
bytes < 1024 * 1024 ->
|
||||
kb = Float.round(bytes / 1024, 1)
|
||||
"#{kb} KB"
|
||||
|
||||
bytes < 1024 * 1024 * 1024 ->
|
||||
mb = Float.round(bytes / (1024 * 1024), 1)
|
||||
"#{mb} MB"
|
||||
|
||||
true ->
|
||||
gb = Float.round(bytes / (1024 * 1024 * 1024), 1)
|
||||
"#{gb} GB"
|
||||
end
|
||||
@formatting.format_bytes(bytes)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -586,22 +570,16 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
"1.5 Mbps" = SnmpKit.SnmpLib.Types.format_rate(1500000, "bps")
|
||||
"""
|
||||
@spec format_rate(number(), binary()) :: binary()
|
||||
def format_rate(value, unit) when is_number(value) and is_binary(unit) do
|
||||
def format_rate(value, unit) when is_integer(value) and is_binary(unit) do
|
||||
@formatting.format_rate(value, unit)
|
||||
end
|
||||
|
||||
def format_rate(value, unit) when is_float(value) and is_binary(unit) do
|
||||
cond do
|
||||
value < 1_000 ->
|
||||
"#{value} #{unit}"
|
||||
|
||||
value < 1_000_000 ->
|
||||
k_value = Float.round(value / 1_000, 1)
|
||||
"#{k_value} K#{unit}"
|
||||
|
||||
value < 1_000_000_000 ->
|
||||
m_value = Float.round(value / 1_000_000, 1)
|
||||
"#{m_value} M#{unit}"
|
||||
|
||||
true ->
|
||||
g_value = Float.round(value / 1_000_000_000, 1)
|
||||
"#{g_value} G#{unit}"
|
||||
value < 1_000 -> "#{value} #{unit}"
|
||||
value < 1_000_000 -> "#{Float.round(value / 1_000, 1)} K#{unit}"
|
||||
value < 1_000_000_000 -> "#{Float.round(value / 1_000_000, 1)} M#{unit}"
|
||||
true -> "#{Float.round(value / 1_000_000_000, 1)} G#{unit}"
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -614,17 +592,8 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
"hello..." = SnmpKit.SnmpLib.Types.truncate_string("hello world", 8)
|
||||
"""
|
||||
@spec truncate_string(binary(), pos_integer()) :: binary()
|
||||
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) and max_length > 3 do
|
||||
if String.length(string) <= max_length do
|
||||
string
|
||||
else
|
||||
truncated = String.slice(string, 0, max_length - 3)
|
||||
"#{truncated}..."
|
||||
end
|
||||
end
|
||||
|
||||
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) do
|
||||
String.slice(string, 0, max(max_length, 0))
|
||||
@formatting.truncate_string(string, max_length)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -1055,48 +1024,4 @@ defmodule SnmpKit.SnmpLib.Types do
|
|||
end
|
||||
|
||||
defp oid_list?(_), do: false
|
||||
|
||||
## Private Helper Functions
|
||||
|
||||
defp format_time_components(0, 0), do: "0 centiseconds"
|
||||
defp format_time_components(0, centiseconds), do: "#{centiseconds} centiseconds"
|
||||
|
||||
defp format_time_components(total_seconds, centiseconds) do
|
||||
days = div(total_seconds, 86_400)
|
||||
remaining_seconds = rem(total_seconds, 86_400)
|
||||
hours = div(remaining_seconds, 3600)
|
||||
remaining_seconds = rem(remaining_seconds, 3600)
|
||||
minutes = div(remaining_seconds, 60)
|
||||
seconds = rem(remaining_seconds, 60)
|
||||
|
||||
parts = []
|
||||
parts = if days > 0, do: ["#{days} day#{plural(days)}" | parts], else: parts
|
||||
parts = if hours > 0, do: ["#{hours} hour#{plural(hours)}" | parts], else: parts
|
||||
parts = if minutes > 0, do: ["#{minutes} minute#{plural(minutes)}" | parts], else: parts
|
||||
parts = if seconds > 0, do: ["#{seconds} second#{plural(seconds)}" | parts], else: parts
|
||||
|
||||
parts =
|
||||
if centiseconds > 0,
|
||||
do: ["#{centiseconds} centisecond#{plural(centiseconds)}" | parts],
|
||||
else: parts
|
||||
|
||||
case Enum.reverse(parts) do
|
||||
[] -> "0 centiseconds"
|
||||
[single] -> single
|
||||
parts -> Enum.join(parts, " ")
|
||||
end
|
||||
end
|
||||
|
||||
defp plural(1), do: ""
|
||||
defp plural(_), do: "s"
|
||||
|
||||
defp format_large_number(number) when is_integer(number) do
|
||||
number
|
||||
|> Integer.to_string()
|
||||
|> String.graphemes()
|
||||
|> Enum.reverse()
|
||||
|> Enum.chunk_every(3)
|
||||
|> Enum.map_join(",", &Enum.join(&1, ""))
|
||||
|> String.reverse()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
|
||||
require Logger
|
||||
|
||||
@formatting :snmpkit@formatting
|
||||
|
||||
@type oid :: [non_neg_integer()]
|
||||
@type snmp_value :: any()
|
||||
@type varbind :: {oid(), snmp_value()}
|
||||
|
|
@ -225,12 +227,7 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
"""
|
||||
@spec format_bytes(non_neg_integer()) :: String.t()
|
||||
def format_bytes(bytes) when is_integer(bytes) and bytes >= 0 do
|
||||
cond do
|
||||
bytes >= 1_073_741_824 -> "#{Float.round(bytes / 1_073_741_824, 1)} GB"
|
||||
bytes >= 1_048_576 -> "#{Float.round(bytes / 1_048_576, 1)} MB"
|
||||
bytes >= 1_024 -> "#{Float.round(bytes / 1_024, 1)} KB"
|
||||
true -> "#{bytes} B"
|
||||
end
|
||||
@formatting.format_bytes(bytes)
|
||||
end
|
||||
|
||||
def format_bytes(_), do: "Invalid byte count"
|
||||
|
|
@ -247,7 +244,11 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
"45 pps"
|
||||
"""
|
||||
@spec format_rate(number(), String.t()) :: String.t()
|
||||
def format_rate(value, unit) when is_number(value) and is_binary(unit) do
|
||||
def format_rate(value, unit) when is_integer(value) and is_binary(unit) do
|
||||
@formatting.format_rate(value, unit)
|
||||
end
|
||||
|
||||
def format_rate(value, unit) when is_float(value) and is_binary(unit) do
|
||||
cond do
|
||||
value >= 1_000_000_000 -> "#{Float.round(value / 1_000_000_000, 1)} G#{unit}"
|
||||
value >= 1_000_000 -> "#{Float.round(value / 1_000_000, 1)} M#{unit}"
|
||||
|
|
@ -271,11 +272,7 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
"""
|
||||
@spec truncate_string(String.t(), pos_integer()) :: String.t()
|
||||
def truncate_string(string, max_length) when is_binary(string) and is_integer(max_length) and max_length > 3 do
|
||||
if String.length(string) <= max_length do
|
||||
string
|
||||
else
|
||||
String.slice(string, 0, max_length - 3) <> "..."
|
||||
end
|
||||
@formatting.truncate_string(string, max_length)
|
||||
end
|
||||
|
||||
def truncate_string(string, _) when is_binary(string), do: string
|
||||
|
|
@ -322,11 +319,7 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
"""
|
||||
@spec format_number(integer()) :: String.t()
|
||||
def format_number(number) when is_integer(number) do
|
||||
number
|
||||
|> Integer.to_string()
|
||||
|> String.reverse()
|
||||
|> String.replace(~r/(\d{3})(?=\d)/, "\\1,")
|
||||
|> String.reverse()
|
||||
@formatting.format_number(number)
|
||||
end
|
||||
|
||||
def format_number(_), do: "Invalid number"
|
||||
|
|
@ -381,16 +374,7 @@ defmodule SnmpKit.SnmpLib.Utils do
|
|||
"""
|
||||
@spec format_response_time(non_neg_integer()) :: String.t()
|
||||
def format_response_time(microseconds) when is_integer(microseconds) and microseconds >= 0 do
|
||||
cond do
|
||||
microseconds >= 1_000_000 ->
|
||||
"#{:erlang.float_to_binary(microseconds / 1_000_000, [{:decimals, 2}])}s"
|
||||
|
||||
microseconds >= 1_000 ->
|
||||
"#{:erlang.float_to_binary(microseconds / 1_000, [{:decimals, 2}])}ms"
|
||||
|
||||
true ->
|
||||
"#{microseconds}μs"
|
||||
end
|
||||
@formatting.format_response_time(microseconds)
|
||||
end
|
||||
|
||||
def format_response_time(_), do: "Invalid time"
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ defmodule Towerops.Accounts do
|
|||
alias Towerops.Accounts.LoginAttempt
|
||||
alias Towerops.Accounts.PolicyVersion
|
||||
alias Towerops.Accounts.User
|
||||
alias Towerops.Accounts.UserAgentParser
|
||||
alias Towerops.Accounts.UserConsent
|
||||
alias Towerops.Accounts.UserNotifier
|
||||
alias Towerops.Accounts.UserRecoveryCode
|
||||
|
|
@ -1396,7 +1395,7 @@ defmodule Towerops.Accounts do
|
|||
def create_browser_session(attrs) do
|
||||
# Parse User-Agent for device metadata
|
||||
user_agent = Map.get(attrs, :user_agent)
|
||||
device_info = UserAgentParser.parse(user_agent)
|
||||
device_info = :towerops@accounts@user_agent_parser.parse(user_agent)
|
||||
|
||||
# Enrich with GeoIP data
|
||||
ip_address = Map.get(attrs, :ip_address)
|
||||
|
|
|
|||
|
|
@ -1,195 +0,0 @@
|
|||
defmodule Towerops.Accounts.UserAgentParser do
|
||||
@moduledoc """
|
||||
Simple regex-based User-Agent parser for extracting browser, OS, and device information.
|
||||
|
||||
This module provides basic device identification without external dependencies.
|
||||
It covers ~95% of common user agents but may not detect obscure browsers or devices.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36")
|
||||
%{
|
||||
browser_name: "Chrome",
|
||||
browser_version: "120.0",
|
||||
os_name: "macOS",
|
||||
os_version: "10.15.7",
|
||||
device_type: "desktop",
|
||||
device_name: "Chrome on macOS"
|
||||
}
|
||||
|
||||
iex> Towerops.Accounts.UserAgentParser.parse("Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1")
|
||||
%{
|
||||
browser_name: "Safari",
|
||||
browser_version: "17.2",
|
||||
os_name: "iOS",
|
||||
os_version: "17.2",
|
||||
device_type: "mobile",
|
||||
device_name: "Safari on iOS"
|
||||
}
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Parses a User-Agent string and returns device metadata.
|
||||
|
||||
Returns a map with the following fields:
|
||||
- `:browser_name` - Browser name (Chrome, Safari, Firefox, Edge, Opera, or "Unknown")
|
||||
- `:browser_version` - Browser version string (or nil)
|
||||
- `:os_name` - Operating system name (macOS, Windows, Linux, iOS, Android, or "Unknown")
|
||||
- `:os_version` - OS version string (or nil)
|
||||
- `:device_type` - "desktop", "mobile", or "tablet"
|
||||
- `:device_name` - Formatted string like "Chrome on macOS"
|
||||
|
||||
If the User-Agent is nil or cannot be parsed, returns a map with "Unknown" values.
|
||||
"""
|
||||
def parse(nil), do: unknown_device()
|
||||
def parse(""), do: unknown_device()
|
||||
|
||||
def parse(user_agent) when is_binary(user_agent) do
|
||||
browser = detect_browser(user_agent)
|
||||
os = detect_os(user_agent)
|
||||
device_type = detect_device_type(user_agent, os)
|
||||
|
||||
%{
|
||||
browser_name: browser.name,
|
||||
browser_version: browser.version,
|
||||
os_name: os.name,
|
||||
os_version: os.version,
|
||||
device_type: device_type,
|
||||
device_name: format_device_name(browser.name, os.name)
|
||||
}
|
||||
end
|
||||
|
||||
# Browser detection
|
||||
|
||||
defp detect_browser(ua) do
|
||||
cond do
|
||||
# Opera must be checked before Chrome (Opera includes Chrome in UA)
|
||||
Regex.match?(~r/OPR\/(\d+\.\d+)/, ua) || Regex.match?(~r/Opera\/(\d+\.\d+)/, ua) ->
|
||||
version = extract_version(ua, ~r/(?:OPR|Opera)\/(\d+\.\d+)/)
|
||||
%{name: "Opera", version: version}
|
||||
|
||||
# Edge must be checked before Chrome (Edge includes Chrome in UA)
|
||||
Regex.match?(~r/Edg(e|A|iOS)?\/(\d+\.\d+)/, ua) ->
|
||||
version = extract_version(ua, ~r/Edg(?:e|A|iOS)?\/(\d+\.\d+)/)
|
||||
%{name: "Edge", version: version}
|
||||
|
||||
# Chrome must be checked before Safari (Chrome includes Safari in UA)
|
||||
Regex.match?(~r/Chrome\/(\d+\.\d+)/, ua) ->
|
||||
version = extract_version(ua, ~r/Chrome\/(\d+\.\d+)/)
|
||||
%{name: "Chrome", version: version}
|
||||
|
||||
# Firefox
|
||||
Regex.match?(~r/Firefox\/(\d+\.\d+)/, ua) ->
|
||||
version = extract_version(ua, ~r/Firefox\/(\d+\.\d+)/)
|
||||
%{name: "Firefox", version: version}
|
||||
|
||||
# Safari (check last because many browsers include Safari in UA)
|
||||
Regex.match?(~r/Safari\//, ua) && Regex.match?(~r/Version\/(\d+\.\d+)/, ua) ->
|
||||
version = extract_version(ua, ~r/Version\/(\d+\.\d+)/)
|
||||
%{name: "Safari", version: version}
|
||||
|
||||
true ->
|
||||
%{name: "Unknown", version: nil}
|
||||
end
|
||||
end
|
||||
|
||||
# OS detection
|
||||
|
||||
defp detect_os(ua) do
|
||||
cond do
|
||||
# iOS (must check before macOS because iOS UA contains "Mac OS X")
|
||||
Regex.match?(~r/iPhone|iPad|iPod/, ua) ->
|
||||
version = extract_version(ua, ~r/OS (\d+[_\d]*)/)
|
||||
%{name: "iOS", version: format_ios_version(version)}
|
||||
|
||||
# macOS
|
||||
Regex.match?(~r/Macintosh|Mac OS X/, ua) ->
|
||||
version = extract_version(ua, ~r/Mac OS X (\d+[_\d]*)/)
|
||||
%{name: "macOS", version: format_mac_version(version)}
|
||||
|
||||
# Android
|
||||
Regex.match?(~r/Android/, ua) ->
|
||||
version = extract_version(ua, ~r/Android (\d+\.\d+)/)
|
||||
%{name: "Android", version: version}
|
||||
|
||||
# Windows
|
||||
Regex.match?(~r/Windows/, ua) ->
|
||||
version = detect_windows_version(ua)
|
||||
%{name: "Windows", version: version}
|
||||
|
||||
# Linux
|
||||
Regex.match?(~r/Linux/, ua) ->
|
||||
%{name: "Linux", version: nil}
|
||||
|
||||
true ->
|
||||
%{name: "Unknown", version: nil}
|
||||
end
|
||||
end
|
||||
|
||||
# Device type detection
|
||||
|
||||
defp detect_device_type(ua, os) do
|
||||
cond do
|
||||
# Tablets
|
||||
Regex.match?(~r/iPad/, ua) || Regex.match?(~r/Android.*Tablet/, ua) ->
|
||||
"tablet"
|
||||
|
||||
# Mobile devices
|
||||
Regex.match?(~r/iPhone|iPod|Android.*Mobile|Mobile/, ua) || os.name == "iOS" ->
|
||||
"mobile"
|
||||
|
||||
# Default to desktop
|
||||
true ->
|
||||
"desktop"
|
||||
end
|
||||
end
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp extract_version(ua, regex) do
|
||||
case Regex.run(regex, ua) do
|
||||
[_, version] -> version
|
||||
_ -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp format_ios_version(nil), do: nil
|
||||
|
||||
defp format_ios_version(version) do
|
||||
# Convert iOS version from "17_2_1" to "17.2.1"
|
||||
String.replace(version, "_", ".")
|
||||
end
|
||||
|
||||
defp format_mac_version(nil), do: nil
|
||||
|
||||
defp format_mac_version(version) do
|
||||
# Convert macOS version from "10_15_7" to "10.15.7"
|
||||
String.replace(version, "_", ".")
|
||||
end
|
||||
|
||||
defp detect_windows_version(ua) do
|
||||
cond do
|
||||
Regex.match?(~r/Windows NT 10\.0/, ua) -> "10"
|
||||
Regex.match?(~r/Windows NT 6\.3/, ua) -> "8.1"
|
||||
Regex.match?(~r/Windows NT 6\.2/, ua) -> "8"
|
||||
Regex.match?(~r/Windows NT 6\.1/, ua) -> "7"
|
||||
Regex.match?(~r/Windows NT/, ua) -> extract_version(ua, ~r/Windows NT (\d+\.\d+)/)
|
||||
true -> nil
|
||||
end
|
||||
end
|
||||
|
||||
defp format_device_name(browser, os) do
|
||||
"#{browser} on #{os}"
|
||||
end
|
||||
|
||||
defp unknown_device do
|
||||
%{
|
||||
browser_name: "Unknown",
|
||||
browser_version: nil,
|
||||
os_name: "Unknown",
|
||||
os_version: nil,
|
||||
device_type: "desktop",
|
||||
device_name: "Unknown on Unknown"
|
||||
}
|
||||
end
|
||||
end
|
||||
|
|
@ -2,8 +2,13 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
|
|||
@moduledoc """
|
||||
Provides functionality for generating diffs between MikroTik configuration backups
|
||||
and masking sensitive data in configurations.
|
||||
|
||||
Sensitive-data masking and diff statistics are implemented in Gleam.
|
||||
Unified diff generation (I/O-bound) remains in Elixir.
|
||||
"""
|
||||
|
||||
@gleam :towerops@devices@mikrotik_backups@differ
|
||||
|
||||
@doc """
|
||||
Masks sensitive data in a MikroTik configuration.
|
||||
|
||||
|
|
@ -21,13 +26,7 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
|
|||
iex> Differ.mask_sensitive_data("/user set admin password=secret123")
|
||||
"/user set admin password=***MASKED:a1b2c3d4***"
|
||||
"""
|
||||
def mask_sensitive_data(config_text) when is_binary(config_text) do
|
||||
config_text
|
||||
|> mask_passwords()
|
||||
|> mask_snmp_communities()
|
||||
|> mask_ipsec_secrets()
|
||||
|> mask_wireless_keys()
|
||||
end
|
||||
defdelegate mask_sensitive_data(config_text), to: @gleam
|
||||
|
||||
@doc """
|
||||
Generates a unified diff between two configurations.
|
||||
|
|
@ -92,111 +91,7 @@ defmodule Towerops.Devices.MikrotikBackups.Differ do
|
|||
%{additions: 1, deletions: 1, changes: 1}
|
||||
"""
|
||||
def calculate_diff_stats(diff) when is_binary(diff) do
|
||||
lines = String.split(diff, "\n")
|
||||
|
||||
additions = Enum.count(lines, &(String.starts_with?(&1, "+") && !String.starts_with?(&1, "+++")))
|
||||
deletions = Enum.count(lines, &(String.starts_with?(&1, "-") && !String.starts_with?(&1, "---")))
|
||||
|
||||
# Changes represent modified lines (paired additions/deletions)
|
||||
changes = min(additions, deletions)
|
||||
|
||||
%{
|
||||
additions: additions,
|
||||
deletions: deletions,
|
||||
changes: changes
|
||||
}
|
||||
end
|
||||
|
||||
# Private functions for masking different types of sensitive data
|
||||
|
||||
defp mask_passwords(config) do
|
||||
# Match password= followed by non-whitespace characters
|
||||
# Supports both "password=" and variations with quotes
|
||||
Regex.replace(
|
||||
~r/(password=)([^\s]+)/i,
|
||||
config,
|
||||
fn _, prefix, value ->
|
||||
hash = hash_value(value)
|
||||
"#{prefix}***MASKED:#{hash}***"
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp mask_snmp_communities(config) do
|
||||
# SNMP community strings in RouterOS
|
||||
# Mask both the community identifier and name= parameter
|
||||
config
|
||||
|> mask_snmp_set_identifier()
|
||||
|> mask_snmp_name_parameter()
|
||||
end
|
||||
|
||||
defp mask_snmp_set_identifier(config) do
|
||||
# Pattern: /snmp community set [name] ...
|
||||
Regex.replace(
|
||||
~r/(\/snmp\s+community\s+set\s+)([^\s]+)/,
|
||||
config,
|
||||
fn _, prefix, value ->
|
||||
# Don't mask common default names, but mask custom ones
|
||||
if value in ["public", "private"] do
|
||||
prefix <> value
|
||||
else
|
||||
hash = hash_value(value)
|
||||
"#{prefix}***MASKED:#{hash}***"
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp mask_snmp_name_parameter(config) do
|
||||
# Pattern: name=xxx in SNMP community commands
|
||||
Regex.replace(
|
||||
~r/(\/snmp\s+community.*?name=)([^\s]+)/,
|
||||
config,
|
||||
fn _, prefix, value ->
|
||||
# Don't mask common default names
|
||||
if value in ["public", "private"] do
|
||||
prefix <> value
|
||||
else
|
||||
hash = hash_value(value)
|
||||
"#{prefix}***MASKED:#{hash}***"
|
||||
end
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp mask_ipsec_secrets(config) do
|
||||
# IPsec secrets: secret=xxx
|
||||
Regex.replace(
|
||||
~r/(secret=)([^\s]+)/i,
|
||||
config,
|
||||
fn _, prefix, value ->
|
||||
hash = hash_value(value)
|
||||
"#{prefix}***MASKED:#{hash}***"
|
||||
end
|
||||
)
|
||||
end
|
||||
|
||||
defp mask_wireless_keys(config) do
|
||||
# Wireless WPA/WPA2 pre-shared keys
|
||||
config
|
||||
|> mask_wpa_key("wpa-pre-shared-key")
|
||||
|> mask_wpa_key("wpa2-pre-shared-key")
|
||||
end
|
||||
|
||||
defp mask_wpa_key(config, key_name) do
|
||||
pattern = ~r/(#{key_name}=)([^\s]+)/i
|
||||
|
||||
Regex.replace(pattern, config, fn _, prefix, value ->
|
||||
hash = hash_value(value)
|
||||
"#{prefix}***MASKED:#{hash}***"
|
||||
end)
|
||||
end
|
||||
|
||||
# Generate a short hash of a value for change detection
|
||||
defp hash_value(value) do
|
||||
:sha256
|
||||
|> :crypto.hash(value)
|
||||
|> Base.encode16(case: :lower)
|
||||
|> String.slice(0..7)
|
||||
{additions, deletions, changes} = @gleam.calculate_diff_stats(diff)
|
||||
%{additions: additions, deletions: deletions, changes: changes}
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -1,148 +0,0 @@
|
|||
defmodule Towerops.Devices.VersionComparator do
|
||||
@moduledoc """
|
||||
Compares semantic version strings.
|
||||
|
||||
Supports versions with 0-3 parts (e.g., "7", "7.14", "7.14.1").
|
||||
Versions with 4+ parts are considered invalid and fall back to {0, 0, 0}.
|
||||
|
||||
## Parsing Rules
|
||||
|
||||
- Strips "v" or "V" prefix (case-insensitive)
|
||||
- Trims whitespace
|
||||
- Splits on "-" or " " and ignores suffixes (e.g., "7.14.1-beta" → "7.14.1")
|
||||
- Pads missing parts with 0 (e.g., "7.14" → {7, 14, 0})
|
||||
- 0-3 parts: VALID
|
||||
- 4+ parts: INVALID → fallback to {0, 0, 0}
|
||||
- Non-numeric parts: INVALID
|
||||
- Negative numbers: INVALID
|
||||
- Leading zeros: VALID (e.g., "007.014.001" → {7, 14, 1})
|
||||
|
||||
## Examples
|
||||
|
||||
iex> VersionComparator.compare("7.14.1", "7.14.2")
|
||||
:lt
|
||||
|
||||
iex> VersionComparator.compare("8.0.0", "7.14.1")
|
||||
:gt
|
||||
|
||||
iex> VersionComparator.compare("7.14.1", "7.14.1")
|
||||
:eq
|
||||
|
||||
iex> VersionComparator.newer?("7.14.1", "7.14.2")
|
||||
true
|
||||
|
||||
iex> VersionComparator.newer?("7.14.2", "7.14.1")
|
||||
false
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Compares two version strings.
|
||||
|
||||
Returns `:lt` if the first version is older, `:gt` if newer, or `:eq` if equal.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> VersionComparator.compare("7.14.1", "7.14.2")
|
||||
:lt
|
||||
|
||||
iex> VersionComparator.compare("8.0.0", "7.14.1")
|
||||
:gt
|
||||
|
||||
iex> VersionComparator.compare("v7.14.1", "V7.14.1")
|
||||
:eq
|
||||
"""
|
||||
def compare(version1, version2) do
|
||||
v1_tuple = parse_version(version1)
|
||||
v2_tuple = parse_version(version2)
|
||||
|
||||
cond do
|
||||
v1_tuple < v2_tuple -> :lt
|
||||
v1_tuple > v2_tuple -> :gt
|
||||
true -> :eq
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Returns true if the second version is newer than the first.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> VersionComparator.newer?("7.14.1", "7.14.2")
|
||||
true
|
||||
|
||||
iex> VersionComparator.newer?("7.14.2", "7.14.1")
|
||||
false
|
||||
|
||||
iex> VersionComparator.newer?("7.14.1", "7.14.1")
|
||||
false
|
||||
"""
|
||||
def newer?(current_version, new_version) do
|
||||
compare(current_version, new_version) == :lt
|
||||
end
|
||||
|
||||
# Private helper to parse version string into {major, minor, patch} tuple
|
||||
defp parse_version(version) when is_binary(version) do
|
||||
version
|
||||
|> String.trim()
|
||||
|> strip_prefix()
|
||||
|> strip_suffix()
|
||||
|> parse_parts()
|
||||
end
|
||||
|
||||
# Strip "v" or "V" prefix
|
||||
defp strip_prefix("v" <> rest), do: rest
|
||||
defp strip_prefix("V" <> rest), do: rest
|
||||
defp strip_prefix(version), do: version
|
||||
|
||||
# Strip suffixes after "-" or " "
|
||||
defp strip_suffix(version) do
|
||||
version
|
||||
|> String.split(["-", " "], parts: 2)
|
||||
|> List.first()
|
||||
end
|
||||
|
||||
# Parse version parts into {major, minor, patch} tuple
|
||||
defp parse_parts(""), do: {0, 0, 0}
|
||||
|
||||
defp parse_parts(version) do
|
||||
parts = String.split(version, ".")
|
||||
|
||||
# 4+ parts is invalid
|
||||
if length(parts) > 3 do
|
||||
{0, 0, 0}
|
||||
else
|
||||
parse_numeric_parts(parts)
|
||||
end
|
||||
end
|
||||
|
||||
# Convert parts to integers, pad with 0s, validate
|
||||
defp parse_numeric_parts(parts) do
|
||||
case parse_and_validate(parts) do
|
||||
{:ok, numbers} -> pad_to_three(numbers)
|
||||
:error -> {0, 0, 0}
|
||||
end
|
||||
end
|
||||
|
||||
# Parse each part as integer and validate (no negatives, must be numeric)
|
||||
defp parse_and_validate(parts) do
|
||||
results =
|
||||
Enum.map(parts, fn part ->
|
||||
case Integer.parse(part) do
|
||||
{num, ""} when num >= 0 -> {:ok, num}
|
||||
_ -> :error
|
||||
end
|
||||
end)
|
||||
|
||||
if Enum.all?(results, &match?({:ok, _}, &1)) do
|
||||
{:ok, Enum.map(results, fn {:ok, num} -> num end)}
|
||||
else
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
# Pad list to 3 elements with 0s, then convert to tuple
|
||||
defp pad_to_three(numbers) do
|
||||
padded = numbers ++ List.duplicate(0, 3 - length(numbers))
|
||||
List.to_tuple(Enum.take(padded, 3))
|
||||
end
|
||||
end
|
||||
|
|
@ -224,9 +224,8 @@ defmodule Towerops.Monitoring do
|
|||
"""
|
||||
def schedule_check(%Check{} = check) do
|
||||
alias Towerops.Workers.CheckExecutorWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(check.id, check.interval_seconds)
|
||||
|
||||
%{check_id: check.id}
|
||||
|> CheckExecutorWorker.new(schedule_in: offset)
|
||||
|
|
|
|||
|
|
@ -2,22 +2,14 @@ defmodule Towerops.Snmp.MibParser do
|
|||
@moduledoc """
|
||||
Simple MIB file parser for extracting OID definitions.
|
||||
|
||||
This parser extracts OID assignments from MIB files for validation purposes.
|
||||
It's not a full ASN.1 parser - just enough to validate our hardcoded OIDs
|
||||
against the official MIB definitions.
|
||||
|
||||
## Usage
|
||||
|
||||
iex> MibParser.parse_mib_file("priv/mibs/standard/IF-MIB")
|
||||
{:ok, %{
|
||||
"ifIndex" => "1.3.6.1.2.1.2.2.1.1",
|
||||
"ifDescr" => "1.3.6.1.2.1.2.2.1.2",
|
||||
...
|
||||
}}
|
||||
Core parsing logic implemented in Gleam (`src/towerops/snmp/mib_parser.gleam`).
|
||||
This module handles file I/O and delegates content parsing to the Gleam implementation.
|
||||
"""
|
||||
|
||||
require Logger
|
||||
|
||||
@gleam :towerops@snmp@mib_parser
|
||||
|
||||
@doc """
|
||||
Parse a MIB file and return a map of object names to OID strings.
|
||||
"""
|
||||
|
|
@ -39,11 +31,7 @@ defmodule Towerops.Snmp.MibParser do
|
|||
"""
|
||||
@spec parse_mib_content(String.t()) :: %{String.t() => String.t()}
|
||||
def parse_mib_content(content) do
|
||||
# First, build a map of object assignments (e.g., "ifTable ::= { interfaces 2 }")
|
||||
assignments = extract_assignments(content)
|
||||
|
||||
# Then resolve all OIDs by following the assignment chain
|
||||
resolve_oids(assignments)
|
||||
@gleam.parse_mib_content(content)
|
||||
end
|
||||
|
||||
@doc """
|
||||
|
|
@ -69,77 +57,6 @@ defmodule Towerops.Snmp.MibParser do
|
|||
end
|
||||
end
|
||||
|
||||
# Private functions
|
||||
|
||||
defp extract_assignments(content) do
|
||||
# Match patterns like:
|
||||
# objectName OBJECT-TYPE ::= { parent index }
|
||||
# objectName OBJECT IDENTIFIER ::= { parent index }
|
||||
# We'll extract: objectName, parent, index
|
||||
|
||||
# Remove comments (lines starting with --)
|
||||
content = Regex.replace(~r/--.*$/m, content, "")
|
||||
|
||||
# Match assignment patterns
|
||||
# This regex captures: name, parent, and index
|
||||
~r/(\w+)\s+(?:OBJECT-TYPE|OBJECT\s+IDENTIFIER)[^:]*::=\s*\{\s*(\w+)\s+(\d+)\s*\}/
|
||||
|> Regex.scan(content)
|
||||
|> Map.new(fn [_, name, parent, index] ->
|
||||
{name, {parent, index}}
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_oids(assignments) do
|
||||
# Well-known root OIDs
|
||||
roots = %{
|
||||
"iso" => "1",
|
||||
"org" => "1.3",
|
||||
"dod" => "1.3.6",
|
||||
"internet" => "1.3.6.1",
|
||||
"directory" => "1.3.6.1.1",
|
||||
"mgmt" => "1.3.6.1.2",
|
||||
"mib-2" => "1.3.6.1.2.1",
|
||||
"experimental" => "1.3.6.1.3",
|
||||
"private" => "1.3.6.1.4",
|
||||
"enterprises" => "1.3.6.1.4.1",
|
||||
"security" => "1.3.6.1.5",
|
||||
"snmpV2" => "1.3.6.1.6",
|
||||
"snmpModules" => "1.3.6.1.6.3"
|
||||
}
|
||||
|
||||
# Build OID map by recursively resolving assignments
|
||||
Enum.reduce(assignments, roots, fn {name, {parent, index}}, acc ->
|
||||
case resolve_oid(parent, index, acc, assignments) do
|
||||
{:ok, oid} -> Map.put(acc, name, oid)
|
||||
:error -> acc
|
||||
end
|
||||
end)
|
||||
end
|
||||
|
||||
defp resolve_oid(parent, index, resolved, assignments) do
|
||||
cond do
|
||||
# Parent already resolved
|
||||
Map.has_key?(resolved, parent) ->
|
||||
{:ok, "#{resolved[parent]}.#{index}"}
|
||||
|
||||
# Parent needs to be resolved from assignments
|
||||
Map.has_key?(assignments, parent) ->
|
||||
{grandparent, parent_index} = assignments[parent]
|
||||
|
||||
case resolve_oid(grandparent, parent_index, resolved, assignments) do
|
||||
{:ok, parent_oid} ->
|
||||
{:ok, "#{parent_oid}.#{index}"}
|
||||
|
||||
:error ->
|
||||
:error
|
||||
end
|
||||
|
||||
# Can't resolve
|
||||
true ->
|
||||
:error
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
List all MIB files in the priv/mibs directory.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -2,165 +2,17 @@ defmodule Towerops.Snmp.Sanitizer do
|
|||
@moduledoc """
|
||||
Sanitizes SNMP data to ensure it's safe to store in the database.
|
||||
|
||||
SNMP devices may return:
|
||||
- Non-UTF8 binary data
|
||||
- Non-printable characters
|
||||
- Malformed strings
|
||||
- Raw binary values (MAC addresses, IP addresses)
|
||||
|
||||
This module provides sanitization functions to convert these to safe,
|
||||
printable strings suitable for PostgreSQL text/varchar columns.
|
||||
Core sanitization logic is implemented in Gleam (`:towerops@snmp@sanitizer`).
|
||||
This module provides `sanitize_map/1` which must remain in Elixir for
|
||||
recursive map traversal with arbitrary keys.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Sanitizes a string field from SNMP data.
|
||||
@gleam :towerops@snmp@sanitizer
|
||||
|
||||
- Returns nil for nil input
|
||||
- Returns printable strings as-is (trimmed)
|
||||
- Converts non-printable binaries to colon-separated hex format
|
||||
- Converts other values to strings
|
||||
|
||||
## Examples
|
||||
|
||||
iex> Sanitizer.sanitize_string(nil)
|
||||
nil
|
||||
|
||||
iex> Sanitizer.sanitize_string("Hello World")
|
||||
"Hello World"
|
||||
|
||||
iex> Sanitizer.sanitize_string(<<255, 255, 255, 0>>)
|
||||
"ff:ff:ff:00"
|
||||
|
||||
"""
|
||||
@spec sanitize_string(any()) :: String.t() | nil
|
||||
def sanitize_string(nil), do: nil
|
||||
|
||||
def sanitize_string(value) when is_binary(value) do
|
||||
if String.valid?(value) and String.printable?(value) do
|
||||
String.trim(value)
|
||||
else
|
||||
# Convert non-printable binary to hex string
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_string(value) when is_list(value) do
|
||||
# Erlang charlists from SNMP
|
||||
value
|
||||
|> List.to_string()
|
||||
|> sanitize_string()
|
||||
rescue
|
||||
_ -> inspect(value)
|
||||
end
|
||||
|
||||
def sanitize_string(value), do: to_string(value)
|
||||
|
||||
@doc """
|
||||
Sanitizes an IPv4 address from various SNMP formats.
|
||||
|
||||
Handles:
|
||||
- Tuple format: {a, b, c, d}
|
||||
- Raw 4-byte binary: <<a, b, c, d>>
|
||||
- String format: "a.b.c.d"
|
||||
- Longer binary (takes first 4 bytes as IP)
|
||||
|
||||
Returns dotted-decimal string or nil if invalid.
|
||||
"""
|
||||
@spec sanitize_ipv4(any()) :: String.t() | nil
|
||||
def sanitize_ipv4({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
def sanitize_ipv4(<<a, b, c, d>>) do
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
end
|
||||
|
||||
def sanitize_ipv4(value) when is_binary(value) do
|
||||
cond do
|
||||
# Already formatted string - valid IP
|
||||
String.printable?(value) and String.match?(value, ~r/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$/) ->
|
||||
value
|
||||
|
||||
# Printable string that doesn't match IP format - invalid
|
||||
String.printable?(value) ->
|
||||
nil
|
||||
|
||||
# Raw bytes - try to convert (only for non-printable binary data)
|
||||
byte_size(value) >= 4 ->
|
||||
<<a, b, c, d, _rest::binary>> = value
|
||||
"#{a}.#{b}.#{c}.#{d}"
|
||||
|
||||
true ->
|
||||
nil
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_ipv4(_), do: nil
|
||||
|
||||
@doc """
|
||||
Sanitizes an IPv6 address from various SNMP formats.
|
||||
|
||||
Handles:
|
||||
- Raw 16-byte binary
|
||||
- String format (already formatted)
|
||||
- Tuple format (8 16-bit values)
|
||||
|
||||
Returns formatted IPv6 string or nil if invalid.
|
||||
"""
|
||||
@spec sanitize_ipv6(any()) :: String.t() | nil
|
||||
def sanitize_ipv6(value) when is_binary(value) and byte_size(value) == 16 do
|
||||
# Raw 16-byte IPv6 address
|
||||
bytes = :binary.bin_to_list(value)
|
||||
|
||||
bytes
|
||||
|> Enum.chunk_every(2)
|
||||
|> Enum.map_join(":", fn [high, low] ->
|
||||
(high * 256 + low)
|
||||
|> Integer.to_string(16)
|
||||
|> String.downcase()
|
||||
end)
|
||||
end
|
||||
|
||||
def sanitize_ipv6(value) when is_binary(value) do
|
||||
# Already formatted - validate and return
|
||||
if String.printable?(value) and String.contains?(value, ":") do
|
||||
String.trim(value)
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_ipv6(_), do: nil
|
||||
|
||||
@doc """
|
||||
Sanitizes a MAC address from various SNMP formats.
|
||||
|
||||
Handles:
|
||||
- Raw 6-byte binary
|
||||
- Colon-separated hex string
|
||||
- Dash-separated hex string
|
||||
|
||||
Returns colon-separated lowercase hex format or nil if invalid.
|
||||
"""
|
||||
@spec sanitize_mac(any()) :: String.t() | nil
|
||||
def sanitize_mac(value) when is_binary(value) and byte_size(value) == 6 do
|
||||
value
|
||||
|> :binary.bin_to_list()
|
||||
|> Enum.map_join(":", &String.pad_leading(Integer.to_string(&1, 16), 2, "0"))
|
||||
|> String.downcase()
|
||||
end
|
||||
|
||||
def sanitize_mac(value) when is_binary(value) do
|
||||
# Already formatted - validate pattern
|
||||
cleaned = String.replace(value, "-", ":")
|
||||
|
||||
if String.match?(cleaned, ~r/^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$/) do
|
||||
String.downcase(cleaned)
|
||||
end
|
||||
end
|
||||
|
||||
def sanitize_mac(_), do: nil
|
||||
defdelegate sanitize_string(value), to: @gleam
|
||||
defdelegate sanitize_ipv4(value), to: @gleam
|
||||
defdelegate sanitize_ipv6(value), to: @gleam
|
||||
defdelegate sanitize_mac(value), to: @gleam
|
||||
|
||||
@doc """
|
||||
Sanitizes all string fields in a map recursively.
|
||||
|
|
|
|||
|
|
@ -1,28 +1,25 @@
|
|||
defmodule Towerops.Topology.Identifier do
|
||||
@moduledoc """
|
||||
Identifier normalization helpers for topology discovery and matching.
|
||||
|
||||
Core logic (MAC formatting, name normalization, candidate names) is
|
||||
implemented in Gleam. This module provides the Elixir API with nil
|
||||
handling and binary-size dispatch.
|
||||
"""
|
||||
|
||||
@gleam :towerops@topology@identifier
|
||||
|
||||
@spec normalize_mac(term()) :: String.t() | nil
|
||||
def normalize_mac(nil), do: nil
|
||||
|
||||
def normalize_mac(<<a, b, c, d, e, f>>) when is_integer(a) do
|
||||
[a, b, c, d, e, f]
|
||||
|> Enum.map_join(":", &(&1 |> Integer.to_string(16) |> String.pad_leading(2, "0")))
|
||||
|> String.downcase()
|
||||
@gleam.format_mac_bytes([a, b, c, d, e, f])
|
||||
end
|
||||
|
||||
def normalize_mac(mac) when is_binary(mac) do
|
||||
mac
|
||||
|> String.trim()
|
||||
|> String.downcase()
|
||||
|> String.replace(~r/[^0-9a-f]/, "")
|
||||
|> case do
|
||||
<<a1, a2, b1, b2, c1, c2, d1, d2, e1, e2, f1, f2>> ->
|
||||
"#{<<a1, a2>>}:#{<<b1, b2>>}:#{<<c1, c2>>}:#{<<d1, d2>>}:#{<<e1, e2>>}:#{<<f1, f2>>}"
|
||||
|
||||
_ ->
|
||||
nil
|
||||
case @gleam.normalize_mac_string(mac) do
|
||||
{:ok, formatted} -> formatted
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -32,24 +29,9 @@ defmodule Towerops.Topology.Identifier do
|
|||
def normalize_ip(nil), do: nil
|
||||
|
||||
def normalize_ip(ip) when is_binary(ip) do
|
||||
ip
|
||||
|> String.trim()
|
||||
|> strip_ipv6_zone()
|
||||
|> case do
|
||||
"" ->
|
||||
nil
|
||||
|
||||
candidate ->
|
||||
case :inet.parse_address(String.to_charlist(candidate)) do
|
||||
{:ok, tuple} ->
|
||||
tuple
|
||||
|> :inet.ntoa()
|
||||
|> to_string()
|
||||
|> String.downcase()
|
||||
|
||||
_ ->
|
||||
String.downcase(candidate)
|
||||
end
|
||||
case @gleam.normalize_ip_string(ip) do
|
||||
{:ok, normalized} -> normalized
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -59,9 +41,9 @@ defmodule Towerops.Topology.Identifier do
|
|||
def normalize_name(nil), do: nil
|
||||
|
||||
def normalize_name(name) when is_binary(name) do
|
||||
case name |> String.trim() |> String.trim_trailing(".") |> String.downcase() do
|
||||
"" -> nil
|
||||
normalized -> normalized
|
||||
case @gleam.normalize_name_string(name) do
|
||||
{:ok, normalized} -> normalized
|
||||
{:error, _} -> nil
|
||||
end
|
||||
end
|
||||
|
||||
|
|
@ -70,24 +52,8 @@ defmodule Towerops.Topology.Identifier do
|
|||
@spec candidate_names(term()) :: [String.t()]
|
||||
def candidate_names(name) do
|
||||
case normalize_name(name) do
|
||||
nil ->
|
||||
[]
|
||||
|
||||
normalized ->
|
||||
short_name =
|
||||
normalized
|
||||
|> String.split(".", parts: 2)
|
||||
|> List.first()
|
||||
|
||||
[normalized, short_name]
|
||||
|> Enum.reject(&is_nil/1)
|
||||
|> Enum.uniq()
|
||||
nil -> []
|
||||
normalized -> @gleam.candidate_names_from_normalized(normalized)
|
||||
end
|
||||
end
|
||||
|
||||
defp strip_ipv6_zone(ip) do
|
||||
ip
|
||||
|> String.split("%", parts: 2)
|
||||
|> List.first()
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -35,7 +35,6 @@ defmodule Towerops.Workers.CheckExecutorWorker do
|
|||
alias Towerops.Monitoring.Executors.SslExecutor
|
||||
alias Towerops.Monitoring.Executors.TcpExecutor
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -219,7 +218,7 @@ defmodule Towerops.Workers.CheckExecutorWorker do
|
|||
|
||||
defp schedule_next_check(check) do
|
||||
# Calculate staggered offset based on check ID
|
||||
offset = PollingOffset.calculate_offset(check.id, check.interval_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(check.id, check.interval_seconds)
|
||||
|
||||
%{check_id: check.id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
|
|||
|
|
@ -18,7 +18,6 @@ defmodule Towerops.Workers.CheckWorker do
|
|||
alias Towerops.Monitoring
|
||||
alias Towerops.Monitoring.Executor
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -103,7 +102,7 @@ defmodule Towerops.Workers.CheckWorker do
|
|||
Starts executing a check (schedules first execution).
|
||||
"""
|
||||
def start_check(check_id, interval_seconds \\ 60) do
|
||||
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(check_id, interval_seconds)
|
||||
|
||||
%{check_id: check_id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
@ -239,7 +238,7 @@ defmodule Towerops.Workers.CheckWorker do
|
|||
end
|
||||
|
||||
defp schedule_next_check(check_id, interval_seconds) do
|
||||
offset = PollingOffset.calculate_offset(check_id, interval_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(check_id, interval_seconds)
|
||||
|
||||
%{check_id: check_id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
|
|||
|
||||
alias Towerops.CnMaestro.Sync
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -22,7 +21,7 @@ defmodule Towerops.Workers.CnMaestroSyncWorker do
|
|||
|
||||
enqueued =
|
||||
Enum.count(integrations, fn integration ->
|
||||
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
scheduled_at = DateTime.add(now, offset, :second)
|
||||
|
||||
case %{"integration_id" => integration.id}
|
||||
|
|
|
|||
|
|
@ -26,7 +26,6 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
alias Towerops.Monitoring
|
||||
alias Towerops.Snmp.Client
|
||||
alias Towerops.Workers.AlertNotificationWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -94,7 +93,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
Starts monitoring for a device.
|
||||
"""
|
||||
def start_monitoring(device_id) do
|
||||
offset = PollingOffset.calculate_offset(device_id, @monitor_interval)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, @monitor_interval)
|
||||
|
||||
%{device_id: device_id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
@ -253,7 +252,7 @@ defmodule Towerops.Workers.DeviceMonitorWorker do
|
|||
end
|
||||
|
||||
defp schedule_next_check(device_id) do
|
||||
offset = PollingOffset.calculate_offset(device_id, @monitor_interval)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, @monitor_interval)
|
||||
|
||||
%{device_id: device_id}
|
||||
|> new(schedule_in: @monitor_interval + offset)
|
||||
|
|
|
|||
|
|
@ -36,7 +36,6 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
alias Towerops.Snmp.NeighborDiscovery
|
||||
alias Towerops.Snmp.SensorChangeDetector
|
||||
alias Towerops.Snmp.WirelessClientDiscovery
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -116,7 +115,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
def start_polling(device_id) do
|
||||
device = Devices.get_device!(device_id)
|
||||
interval = get_poll_interval(device)
|
||||
offset = PollingOffset.calculate_offset(device_id, interval)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval)
|
||||
|
||||
%{device_id: device_id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
@ -1432,7 +1431,7 @@ defmodule Towerops.Workers.DevicePollerWorker do
|
|||
# Use same offset calculation as start_polling to maintain consistent intervals
|
||||
# Offset is deterministic hash-based value from 0 to interval_seconds
|
||||
# This spreads polls evenly across the interval window
|
||||
offset = PollingOffset.calculate_offset(device_id, interval_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval_seconds)
|
||||
|
||||
%{device_id: device_id}
|
||||
|> new(schedule_in: offset)
|
||||
|
|
|
|||
|
|
@ -1,58 +0,0 @@
|
|||
defmodule Towerops.Workers.PollingOffset do
|
||||
@moduledoc """
|
||||
Calculates deterministic polling offsets to distribute load across time.
|
||||
|
||||
Uses erlang's phash2 to generate stable offsets from device IDs,
|
||||
ensuring each device polls at a different time within the interval.
|
||||
|
||||
This prevents the "thundering herd" problem where all devices poll
|
||||
at synchronized clock intervals (e.g., 1:00, 1:05, 1:10), creating
|
||||
load spikes on the server.
|
||||
|
||||
## Examples
|
||||
|
||||
# Device with ID "abc-123" and 300-second interval
|
||||
iex> calculate_offset("abc-123", 300)
|
||||
94 # Device polls at :01:34, :06:34, :11:34, etc.
|
||||
|
||||
# Same device always gets same offset
|
||||
iex> calculate_offset("abc-123", 300)
|
||||
94
|
||||
|
||||
# Different device gets different offset
|
||||
iex> calculate_offset("def-456", 300)
|
||||
171 # Device polls at :02:51, :07:51, :12:51, etc.
|
||||
"""
|
||||
|
||||
@doc """
|
||||
Calculate polling offset for a device.
|
||||
|
||||
Returns an offset in seconds between 0 and interval_seconds-1.
|
||||
Same device_id always returns same offset for a given interval.
|
||||
|
||||
## Parameters
|
||||
|
||||
* `device_id` - The device UUID (binary or string format)
|
||||
* `interval_seconds` - The polling interval in seconds
|
||||
|
||||
## Returns
|
||||
|
||||
An integer offset in the range [0, interval_seconds)
|
||||
|
||||
## Examples
|
||||
|
||||
iex> calculate_offset("abc-123", 300)
|
||||
94
|
||||
|
||||
iex> calculate_offset("abc-123", 300)
|
||||
94 # Always returns same value for same device
|
||||
|
||||
iex> offset = calculate_offset("some-uuid", 60)
|
||||
iex> offset >= 0 and offset < 60
|
||||
true
|
||||
"""
|
||||
@spec calculate_offset(binary(), pos_integer()) :: non_neg_integer()
|
||||
def calculate_offset(device_id, interval_seconds) do
|
||||
device_id |> :erlang.phash2() |> rem(interval_seconds)
|
||||
end
|
||||
end
|
||||
|
|
@ -14,7 +14,6 @@ defmodule Towerops.Workers.PreseemSyncWorker do
|
|||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Preseem.Sync
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ defmodule Towerops.Workers.PreseemSyncWorker do
|
|||
|
||||
enqueued =
|
||||
Enum.count(eligible, fn integration ->
|
||||
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
scheduled_at = DateTime.add(now, offset, :second)
|
||||
|
||||
case %{"integration_id" => integration.id}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,6 @@ defmodule Towerops.Workers.UispSyncWorker do
|
|||
|
||||
alias Towerops.Integrations
|
||||
alias Towerops.Uisp.Sync
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
require Logger
|
||||
|
||||
|
|
@ -31,7 +30,7 @@ defmodule Towerops.Workers.UispSyncWorker do
|
|||
|
||||
enqueued =
|
||||
Enum.count(eligible, fn integration ->
|
||||
offset = PollingOffset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(integration.organization_id, @window_seconds)
|
||||
scheduled_at = DateTime.add(now, offset, :second)
|
||||
|
||||
case %{"integration_id" => integration.id}
|
||||
|
|
|
|||
|
|
@ -1,46 +1,33 @@
|
|||
defmodule ToweropsWeb.ChangelogParser do
|
||||
@moduledoc "Parses priv/static/changelog.txt into structured entries."
|
||||
@moduledoc """
|
||||
Reads and parses priv/static/changelog.txt into structured entries.
|
||||
|
||||
@doc "Returns a list of %{date: Date.t(), title: String.t(), items: [String.t()]}"
|
||||
File I/O and Date conversion happen here; pure parsing logic is in Gleam.
|
||||
"""
|
||||
|
||||
@doc "Returns a list of %{date: Date.t(), title: String.t() | nil, items: [String.t()]}"
|
||||
def parse do
|
||||
path = Application.app_dir(:towerops, "priv/static/changelog.txt")
|
||||
|
||||
case File.read(path) do
|
||||
{:ok, content} -> parse_content(content)
|
||||
{:error, _} -> []
|
||||
{:ok, content} ->
|
||||
content
|
||||
|> :towerops_web@changelog_parser.parse_content()
|
||||
|> Enum.map(&convert_entry/1)
|
||||
|
||||
{:error, _} ->
|
||||
[]
|
||||
end
|
||||
end
|
||||
|
||||
defp parse_content(content) do
|
||||
content
|
||||
|> String.split("\n")
|
||||
|> Enum.reduce([], fn line, acc ->
|
||||
cond do
|
||||
# Date header with title: "2026-02-13 — Title"
|
||||
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s+[—–]\s+.+/, line) ->
|
||||
[date_str | rest] = String.split(line, ~r/\s+[—–]\s+/, parts: 2)
|
||||
title = List.first(rest, "")
|
||||
entry = %{date: Date.from_iso8601!(String.trim(date_str)), title: String.trim(title), items: []}
|
||||
[entry | acc]
|
||||
|
||||
# Date header without title: "2026-02-13"
|
||||
Regex.match?(~r/^\d{4}-\d{2}-\d{2}\s*$/, line) ->
|
||||
date = Date.from_iso8601!(String.trim(line))
|
||||
entry = %{date: date, title: nil, items: []}
|
||||
[entry | acc]
|
||||
|
||||
# Bullet point
|
||||
String.starts_with?(String.trim(line), "* ") ->
|
||||
add_item_to_current(acc, line |> String.trim() |> String.trim_leading("* "))
|
||||
|
||||
true ->
|
||||
acc
|
||||
end
|
||||
end)
|
||||
|> Enum.reverse()
|
||||
defp convert_entry({:changelog_entry, date_str, title_opt, items}) do
|
||||
%{
|
||||
date: Date.from_iso8601!(date_str),
|
||||
title: unwrap_option(title_opt),
|
||||
items: items
|
||||
}
|
||||
end
|
||||
|
||||
defp add_item_to_current([current | rest], item), do: [%{current | items: current.items ++ [item]} | rest]
|
||||
|
||||
defp add_item_to_current([], _item), do: []
|
||||
defp unwrap_option({:some, value}), do: value
|
||||
defp unwrap_option(:none), do: nil
|
||||
end
|
||||
|
|
|
|||
|
|
@ -8,7 +8,6 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
alias Towerops.Devices
|
||||
alias Towerops.Devices.Firmware
|
||||
alias Towerops.Devices.MikrotikBackups
|
||||
alias Towerops.Devices.VersionComparator
|
||||
alias Towerops.Gaiia
|
||||
alias Towerops.Monitoring
|
||||
alias Towerops.Repo
|
||||
|
|
@ -718,7 +717,8 @@ defmodule ToweropsWeb.DeviceLive.Show do
|
|||
current_clean = extract_version_number(current)
|
||||
available_clean = extract_version_number(available)
|
||||
|
||||
current_clean && available_clean && VersionComparator.newer?(current_clean, available_clean)
|
||||
current_clean && available_clean &&
|
||||
:towerops@devices@version_comparator.newer(current_clean, available_clean)
|
||||
end
|
||||
|
||||
# Extract semantic version number from version string
|
||||
|
|
|
|||
13
manifest.toml
Normal file
13
manifest.toml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# This file was generated by Gleam
|
||||
# You typically do not need to edit this file
|
||||
|
||||
packages = [
|
||||
{ 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 = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
|
||||
]
|
||||
|
||||
[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" }
|
||||
1
mix.exs
1
mix.exs
|
|
@ -105,6 +105,7 @@ defmodule Towerops.MixProject do
|
|||
{:logger_backends, "~> 1.0", only: :dev},
|
||||
{:tidewave, "~> 0.5", only: :dev},
|
||||
{:gleam_stdlib, "~> 0.34 or ~> 1.0"},
|
||||
{:gleam_regexp, "~> 1.0"},
|
||||
{:gleeunit, "~> 1.0", only: [:dev, :test], runtime: false}
|
||||
]
|
||||
end
|
||||
|
|
|
|||
1
mix.lock
1
mix.lock
|
|
@ -33,6 +33,7 @@
|
|||
"fine": {:hex, :fine, "0.1.4", "b19a89c1476c7c57afb5f9314aed5960b5bc95d5277de4cb5ee8e1d1616ce379", [:mix], [], "hexpm", "be3324cc454a42d80951cf6023b9954e9ff27c6daa255483b3e8d608670303f5"},
|
||||
"gen_smtp": {:hex, :gen_smtp, "1.3.0", "62c3d91f0dcf6ce9db71bcb6881d7ad0d1d834c7f38c13fa8e952f4104a8442e", [:rebar3], [{:ranch, ">= 1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "0b73fbf069864ecbce02fe653b16d3f35fd889d0fdd4e14527675565c39d84e6"},
|
||||
"gettext": {:hex, :gettext, "1.0.2", "5457e1fd3f4abe47b0e13ff85086aabae760497a3497909b8473e0acee57673b", [:mix], [{:expo, "~> 0.5.1 or ~> 1.0", [hex: :expo, repo: "hexpm", optional: false]}], "hexpm", "eab805501886802071ad290714515c8c4a17196ea76e5afc9d06ca85fb1bfeb3"},
|
||||
"gleam_regexp": {:hex, :gleam_regexp, "1.1.1", "3d69d7e3dd2833aaf2b14971b1e1fb74b3874d54c4a7bb2ccde3ec4f20c59151", [:gleam], [{:gleam_stdlib, ">= 0.34.0 and < 2.0.0", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "9c215c6ca84a5b35bb934a9b61a9a306ec743153be2b0425a0d032e477b062a9"},
|
||||
"gleam_stdlib": {:hex, :gleam_stdlib, "0.70.0", "e4875abd417a1a58949047dbefe60c9ef65235dae7ae697edba831689998f74e", [:gleam], [], "hexpm", "86949bf5d1f0e4ac0ab5b06f235d8a5cc11a2dfc33bf22f752156ed61ca7d0ff"},
|
||||
"gleeunit": {:hex, :gleeunit, "1.9.0", "643b5099e5891d67c806a3228c846b1e4f600a733358fb3adf45eb616bb29d6f", [:gleam], [{:gleam_stdlib, ">= 0.60.0 and < 1.0.0", [hex: :gleam_stdlib, repo: "hexpm", optional: false]}], "hexpm", "da9553ce58b67924b3c631f96fe3370c49eb6d6dc6b384ec4862cc4aaa718f3c"},
|
||||
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
|
||||
|
|
|
|||
174
src/snmpkit/formatting.gleam
Normal file
174
src/snmpkit/formatting.gleam
Normal file
|
|
@ -0,0 +1,174 @@
|
|||
/// Pure formatting functions shared by SnmpKit.SnmpLib.Types and Utils.
|
||||
///
|
||||
/// Provides number formatting, byte size formatting, rate formatting,
|
||||
/// response time formatting, string truncation, and SNMP TimeTicks uptime.
|
||||
///
|
||||
/// Compiles to `:snmpkit@formatting`.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a float to a string with N decimal places.
|
||||
@external(erlang, "snmpkit_formatting_ffi", "format_float")
|
||||
fn format_float(value: Float, decimals: Int) -> String
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a non-negative integer with comma thousand-separators.
|
||||
///
|
||||
/// Example: `1234567` → `"1,234,567"`
|
||||
pub fn format_number(number: Int) -> String {
|
||||
case number < 0 {
|
||||
True -> "-" <> format_positive_number(0 - number)
|
||||
False -> format_positive_number(number)
|
||||
}
|
||||
}
|
||||
|
||||
/// Format bytes as a human-readable size string.
|
||||
///
|
||||
/// Uses binary units (1024-based): B, KB, MB, GB.
|
||||
pub fn format_bytes(bytes: Int) -> String {
|
||||
case bytes >= 1_073_741_824 {
|
||||
True ->
|
||||
format_float(int.to_float(bytes) /. 1_073_741_824.0, 1) <> " GB"
|
||||
False ->
|
||||
case bytes >= 1_048_576 {
|
||||
True ->
|
||||
format_float(int.to_float(bytes) /. 1_048_576.0, 1) <> " MB"
|
||||
False ->
|
||||
case bytes >= 1024 {
|
||||
True ->
|
||||
format_float(int.to_float(bytes) /. 1024.0, 1) <> " KB"
|
||||
False -> int.to_string(bytes) <> " B"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a rate with SI-prefix units (K, M, G).
|
||||
///
|
||||
/// Example: `format_rate(1500000, "bps")` → `"1.5 Mbps"`
|
||||
pub fn format_rate(value: Int, unit: String) -> String {
|
||||
case value >= 1_000_000_000 {
|
||||
True ->
|
||||
format_float(int.to_float(value) /. 1_000_000_000.0, 1) <> " G" <> unit
|
||||
False ->
|
||||
case value >= 1_000_000 {
|
||||
True ->
|
||||
format_float(int.to_float(value) /. 1_000_000.0, 1) <> " M" <> unit
|
||||
False ->
|
||||
case value >= 1_000 {
|
||||
True ->
|
||||
format_float(int.to_float(value) /. 1_000.0, 1) <> " K" <> unit
|
||||
False -> int.to_string(value) <> " " <> unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Format microseconds as a human-readable response time.
|
||||
///
|
||||
/// Example: `1500` → `"1.50ms"`, `2500000` → `"2.50s"`, `500` → `"500μs"`
|
||||
pub fn format_response_time(microseconds: Int) -> String {
|
||||
case microseconds >= 1_000_000 {
|
||||
True ->
|
||||
format_float(int.to_float(microseconds) /. 1_000_000.0, 2) <> "s"
|
||||
False ->
|
||||
case microseconds >= 1_000 {
|
||||
True ->
|
||||
format_float(int.to_float(microseconds) /. 1_000.0, 2) <> "ms"
|
||||
False -> int.to_string(microseconds) <> "μs"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Truncate a string to a maximum length, appending "..." if truncated.
|
||||
///
|
||||
/// For max_length <= 3, returns a plain slice with no ellipsis.
|
||||
pub fn truncate_string(s: String, max_length: Int) -> String {
|
||||
case max_length > 3 {
|
||||
True ->
|
||||
case string.length(s) <= max_length {
|
||||
True -> s
|
||||
False -> string.slice(s, 0, max_length - 3) <> "..."
|
||||
}
|
||||
False -> string.slice(s, 0, int.max(max_length, 0))
|
||||
}
|
||||
}
|
||||
|
||||
/// Format SNMP TimeTicks (centiseconds) as a human-readable uptime string.
|
||||
///
|
||||
/// Example: `9000` → `"1 minute 30 seconds"`, `42` → `"42 centiseconds"`
|
||||
pub fn format_timeticks_uptime(centiseconds: Int) -> String {
|
||||
let total_seconds = centiseconds / 100
|
||||
let remaining_cs = centiseconds % 100
|
||||
|
||||
case total_seconds, remaining_cs {
|
||||
0, 0 -> "0 centiseconds"
|
||||
0, cs -> int.to_string(cs) <> " centiseconds"
|
||||
_, _ -> build_time_parts(total_seconds, remaining_cs)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn format_positive_number(number: Int) -> String {
|
||||
number
|
||||
|> int.to_string()
|
||||
|> string.to_graphemes()
|
||||
|> list.reverse()
|
||||
|> list.sized_chunk(3)
|
||||
|> list.map(fn(chunk) { chunk |> list.reverse() |> string.join("") })
|
||||
|> list.reverse()
|
||||
|> string.join(",")
|
||||
}
|
||||
|
||||
fn build_time_parts(total_seconds: Int, centiseconds: Int) -> String {
|
||||
let days = total_seconds / 86_400
|
||||
let remaining = total_seconds % 86_400
|
||||
let hours = remaining / 3600
|
||||
let remaining = remaining % 3600
|
||||
let minutes = remaining / 60
|
||||
let seconds = remaining % 60
|
||||
|
||||
let parts = []
|
||||
let parts = append_if_positive(parts, days, "day")
|
||||
let parts = append_if_positive(parts, hours, "hour")
|
||||
let parts = append_if_positive(parts, minutes, "minute")
|
||||
let parts = append_if_positive(parts, seconds, "second")
|
||||
let parts = append_if_positive(parts, centiseconds, "centisecond")
|
||||
|
||||
case parts {
|
||||
[] -> "0 centiseconds"
|
||||
_ -> string.join(parts, " ")
|
||||
}
|
||||
}
|
||||
|
||||
fn append_if_positive(
|
||||
parts: List(String),
|
||||
value: Int,
|
||||
label: String,
|
||||
) -> List(String) {
|
||||
case value > 0 {
|
||||
True ->
|
||||
list.append(parts, [
|
||||
int.to_string(value) <> " " <> label <> plural(value),
|
||||
])
|
||||
False -> parts
|
||||
}
|
||||
}
|
||||
|
||||
fn plural(n: Int) -> String {
|
||||
case n {
|
||||
1 -> ""
|
||||
_ -> "s"
|
||||
}
|
||||
}
|
||||
221
src/snmpkit/snmp_lib/error.gleam
Normal file
221
src/snmpkit/snmp_lib/error.gleam
Normal file
|
|
@ -0,0 +1,221 @@
|
|||
/// SNMP error handling and error code utilities.
|
||||
///
|
||||
/// Provides standardized error codes, retriability classification, severity levels,
|
||||
/// and error formatting for SNMP operations (RFC 1157, RFC 3416).
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
/// SNMP error status codes (RFC 1157, RFC 3416).
|
||||
pub type ErrorStatus {
|
||||
NoError
|
||||
TooBig
|
||||
NoSuchName
|
||||
BadValue
|
||||
ReadOnly
|
||||
GenErr
|
||||
NoAccess
|
||||
WrongType
|
||||
WrongLength
|
||||
WrongEncoding
|
||||
WrongValue
|
||||
NoCreation
|
||||
InconsistentValue
|
||||
ResourceUnavailable
|
||||
CommitFailed
|
||||
UndoFailed
|
||||
AuthorizationError
|
||||
NotWritable
|
||||
InconsistentName
|
||||
UnknownError
|
||||
}
|
||||
|
||||
/// Returns the human-readable name for an error status.
|
||||
pub fn error_name(status: ErrorStatus) -> String {
|
||||
case status {
|
||||
NoError -> "no_error"
|
||||
TooBig -> "too_big"
|
||||
NoSuchName -> "no_such_name"
|
||||
BadValue -> "bad_value"
|
||||
ReadOnly -> "read_only"
|
||||
GenErr -> "gen_err"
|
||||
NoAccess -> "no_access"
|
||||
WrongType -> "wrong_type"
|
||||
WrongLength -> "wrong_length"
|
||||
WrongEncoding -> "wrong_encoding"
|
||||
WrongValue -> "wrong_value"
|
||||
NoCreation -> "no_creation"
|
||||
InconsistentValue -> "inconsistent_value"
|
||||
ResourceUnavailable -> "resource_unavailable"
|
||||
CommitFailed -> "commit_failed"
|
||||
UndoFailed -> "undo_failed"
|
||||
AuthorizationError -> "authorization_error"
|
||||
NotWritable -> "not_writable"
|
||||
InconsistentName -> "inconsistent_name"
|
||||
UnknownError -> "unknown_error"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the numeric code for an error status.
|
||||
pub fn error_code(status: ErrorStatus) -> Int {
|
||||
case status {
|
||||
NoError -> 0
|
||||
TooBig -> 1
|
||||
NoSuchName -> 2
|
||||
BadValue -> 3
|
||||
ReadOnly -> 4
|
||||
GenErr -> 5
|
||||
NoAccess -> 6
|
||||
WrongType -> 7
|
||||
WrongLength -> 8
|
||||
WrongEncoding -> 9
|
||||
WrongValue -> 10
|
||||
NoCreation -> 11
|
||||
InconsistentValue -> 12
|
||||
ResourceUnavailable -> 13
|
||||
CommitFailed -> 14
|
||||
UndoFailed -> 15
|
||||
AuthorizationError -> 16
|
||||
NotWritable -> 17
|
||||
InconsistentName -> 18
|
||||
UnknownError -> -1
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a numeric code to an ErrorStatus.
|
||||
/// Unknown codes return UnknownError.
|
||||
pub fn from_code(code: Int) -> ErrorStatus {
|
||||
case code {
|
||||
0 -> NoError
|
||||
1 -> TooBig
|
||||
2 -> NoSuchName
|
||||
3 -> BadValue
|
||||
4 -> ReadOnly
|
||||
5 -> GenErr
|
||||
6 -> NoAccess
|
||||
7 -> WrongType
|
||||
8 -> WrongLength
|
||||
9 -> WrongEncoding
|
||||
10 -> WrongValue
|
||||
11 -> NoCreation
|
||||
12 -> InconsistentValue
|
||||
13 -> ResourceUnavailable
|
||||
14 -> CommitFailed
|
||||
15 -> UndoFailed
|
||||
16 -> AuthorizationError
|
||||
17 -> NotWritable
|
||||
18 -> InconsistentName
|
||||
_ -> UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts a string name to an ErrorStatus.
|
||||
/// Unknown names return UnknownError.
|
||||
pub fn from_name(name: String) -> ErrorStatus {
|
||||
case name {
|
||||
"no_error" -> NoError
|
||||
"too_big" -> TooBig
|
||||
"no_such_name" -> NoSuchName
|
||||
"bad_value" -> BadValue
|
||||
"read_only" -> ReadOnly
|
||||
"gen_err" -> GenErr
|
||||
"no_access" -> NoAccess
|
||||
"wrong_type" -> WrongType
|
||||
"wrong_length" -> WrongLength
|
||||
"wrong_encoding" -> WrongEncoding
|
||||
"wrong_value" -> WrongValue
|
||||
"no_creation" -> NoCreation
|
||||
"inconsistent_value" -> InconsistentValue
|
||||
"resource_unavailable" -> ResourceUnavailable
|
||||
"commit_failed" -> CommitFailed
|
||||
"undo_failed" -> UndoFailed
|
||||
"authorization_error" -> AuthorizationError
|
||||
"not_writable" -> NotWritable
|
||||
"inconsistent_name" -> InconsistentName
|
||||
_ -> UnknownError
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns True if the error is typically transient and worth retrying.
|
||||
/// Retriable: TooBig, GenErr, ResourceUnavailable.
|
||||
pub fn is_retriable(status: ErrorStatus) -> Bool {
|
||||
case status {
|
||||
TooBig -> True
|
||||
GenErr -> True
|
||||
ResourceUnavailable -> True
|
||||
_ -> False
|
||||
}
|
||||
}
|
||||
|
||||
/// Categorizes error by severity level.
|
||||
/// NoError -> "info", retriable errors -> "warning", everything else -> "error".
|
||||
pub fn severity(status: ErrorStatus) -> String {
|
||||
case status {
|
||||
NoError -> "info"
|
||||
TooBig -> "warning"
|
||||
GenErr -> "warning"
|
||||
ResourceUnavailable -> "warning"
|
||||
_ -> "error"
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns True if the code is a valid SNMP error code (0-18 inclusive).
|
||||
pub fn is_valid_code(code: Int) -> Bool {
|
||||
code >= 0 && code <= 18
|
||||
}
|
||||
|
||||
/// Returns all standard SNMP error codes (0 through 18).
|
||||
pub fn all_codes() -> List(Int) {
|
||||
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18]
|
||||
}
|
||||
|
||||
/// Formats an SNMP error for human-readable display.
|
||||
///
|
||||
/// Produces a string like "SNMP Error: no_such_name (2) at index 1".
|
||||
/// If the varbind at error_index exists, appends " - OID: 1.3.6.1.2.1.1.1.0".
|
||||
///
|
||||
/// The varbinds parameter is a list of #(List(Int), Dynamic) tuples where
|
||||
/// the first element is the OID as a list of integers.
|
||||
pub fn format_error(
|
||||
status: ErrorStatus,
|
||||
error_index: Int,
|
||||
varbinds: List(#(List(Int), a)),
|
||||
) -> String {
|
||||
let name = error_name(status)
|
||||
let code = error_code(status)
|
||||
let base_msg =
|
||||
"SNMP Error: "
|
||||
<> name
|
||||
<> " ("
|
||||
<> int.to_string(code)
|
||||
<> ") at index "
|
||||
<> int.to_string(error_index)
|
||||
|
||||
case get_varbind_at(varbinds, error_index) {
|
||||
Ok(#(oid, _value)) -> {
|
||||
let oid_str =
|
||||
oid
|
||||
|> list.map(int.to_string)
|
||||
|> string.join(".")
|
||||
base_msg <> " - OID: " <> oid_str
|
||||
}
|
||||
Error(Nil) -> base_msg
|
||||
}
|
||||
}
|
||||
|
||||
/// Retrieves the varbind at a 1-based error_index from the list.
|
||||
fn get_varbind_at(
|
||||
varbinds: List(#(List(Int), a)),
|
||||
error_index: Int,
|
||||
) -> Result(#(List(Int), a), Nil) {
|
||||
case error_index > 0 {
|
||||
True -> {
|
||||
let zero_index = error_index - 1
|
||||
case list.drop(varbinds, zero_index) {
|
||||
[varbind, ..] -> Ok(varbind)
|
||||
[] -> Error(Nil)
|
||||
}
|
||||
}
|
||||
False -> Error(Nil)
|
||||
}
|
||||
}
|
||||
394
src/snmpkit/snmp_lib/oid.gleam
Normal file
394
src/snmpkit/snmp_lib/oid.gleam
Normal file
|
|
@ -0,0 +1,394 @@
|
|||
/// Comprehensive OID (Object Identifier) manipulation utilities for SNMP operations.
|
||||
///
|
||||
/// Provides string/list conversions, tree operations, table utilities, and validation
|
||||
/// functions needed by both SNMP managers and simulators.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/order.{type Order}
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
|
||||
/// Error types for OID operations.
|
||||
pub type OidError {
|
||||
EmptyOid
|
||||
InvalidOidString
|
||||
InvalidComponent
|
||||
InvalidInput
|
||||
RootOid
|
||||
EndOfMib
|
||||
InvalidTableInstance
|
||||
UnsupportedSyntax
|
||||
InvalidStringLength
|
||||
InvalidStringIndex
|
||||
SyntaxValueMismatch
|
||||
NotEnterpriseOid
|
||||
}
|
||||
|
||||
// Standard SNMP OID prefixes
|
||||
const iso_org_dod_internet = [1, 3, 6, 1]
|
||||
|
||||
const mgmt = [1, 3, 6, 1, 2]
|
||||
|
||||
const mib_2_prefix = [1, 3, 6, 1, 2, 1]
|
||||
|
||||
const enterprises_prefix = [1, 3, 6, 1, 4, 1]
|
||||
|
||||
const experimental_prefix = [1, 3, 6, 1, 3]
|
||||
|
||||
const private_prefix = [1, 3, 6, 1, 4]
|
||||
|
||||
// ---- String/List Conversions ----
|
||||
|
||||
/// Converts an OID string to a list of integers.
|
||||
/// Supports both "1.3.6.1" and ".1.3.6.1" formats.
|
||||
pub fn string_to_list(oid_string: String) -> Result(List(Int), OidError) {
|
||||
use normalized <- result.try(normalize_oid_string(oid_string))
|
||||
let parts = string.split(normalized, ".")
|
||||
use oid_list <- result.try(parse_oid_components(parts))
|
||||
use _ <- result.try(validate_oid_list(oid_list))
|
||||
Ok(oid_list)
|
||||
}
|
||||
|
||||
/// Converts an OID list to a dot-separated string.
|
||||
pub fn list_to_string(oid_list: List(Int)) -> Result(String, OidError) {
|
||||
use _ <- result.try(validate_oid_list(oid_list))
|
||||
let oid_string =
|
||||
oid_list
|
||||
|> list.map(safe_int_to_string)
|
||||
|> string.join(".")
|
||||
Ok(oid_string)
|
||||
}
|
||||
|
||||
// ---- Tree Operations ----
|
||||
|
||||
/// Checks if one OID is a child of another.
|
||||
pub fn child_of(
|
||||
child_oid: List(Int),
|
||||
parent_oid: List(Int),
|
||||
) -> Bool {
|
||||
let child_length = list.length(child_oid)
|
||||
let parent_length = list.length(parent_oid)
|
||||
|
||||
child_length > parent_length
|
||||
&& list.take(child_oid, parent_length) == parent_oid
|
||||
}
|
||||
|
||||
/// Checks if one OID is a parent of another.
|
||||
pub fn parent_of(
|
||||
parent_oid: List(Int),
|
||||
child_oid: List(Int),
|
||||
) -> Bool {
|
||||
child_of(child_oid, parent_oid)
|
||||
}
|
||||
|
||||
/// Gets the parent OID by removing the last component.
|
||||
pub fn get_parent(oid: List(Int)) -> Result(List(Int), OidError) {
|
||||
case list.length(oid) > 1 {
|
||||
True -> {
|
||||
let parent = list.take(oid, list.length(oid) - 1)
|
||||
Ok(parent)
|
||||
}
|
||||
False -> Error(RootOid)
|
||||
}
|
||||
}
|
||||
|
||||
/// Gets the immediate children prefix for an OID in a given set.
|
||||
pub fn get_children(
|
||||
parent_oid: List(Int),
|
||||
oid_set: List(List(Int)),
|
||||
) -> List(List(Int)) {
|
||||
let parent_length = list.length(parent_oid)
|
||||
|
||||
oid_set
|
||||
|> list.filter(fn(oid) { child_of(oid, parent_oid) })
|
||||
|> list.map(fn(oid) { list.take(oid, parent_length + 1) })
|
||||
|> list.unique()
|
||||
}
|
||||
|
||||
/// Gets the next OID in lexicographic order from a given set.
|
||||
pub fn get_next_oid(
|
||||
current_oid: List(Int),
|
||||
oid_set: List(List(Int)),
|
||||
) -> Result(List(Int), OidError) {
|
||||
case list.find(oid_set, fn(oid) { compare_oids(oid, current_oid) == order.Gt }) {
|
||||
Ok(next_oid) -> Ok(next_oid)
|
||||
Error(_) -> Error(EndOfMib)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Comparison Operations ----
|
||||
|
||||
/// Compares two OIDs lexicographically.
|
||||
pub fn compare_oids(oid1: List(Int), oid2: List(Int)) -> Order {
|
||||
compare_components(oid1, oid2)
|
||||
}
|
||||
|
||||
/// Sorts a list of OIDs in lexicographic order.
|
||||
pub fn sort_oids(oid_list: List(List(Int))) -> List(List(Int)) {
|
||||
list.sort(oid_list, fn(a, b) { compare_oids(a, b) })
|
||||
}
|
||||
|
||||
// ---- Table Operations ----
|
||||
|
||||
/// Extracts table index from an instance OID given the table column OID.
|
||||
pub fn extract_table_index(
|
||||
table_oid: List(Int),
|
||||
instance_oid: List(Int),
|
||||
) -> Result(List(Int), OidError) {
|
||||
let table_length = list.length(table_oid)
|
||||
let instance_length = list.length(instance_oid)
|
||||
|
||||
case
|
||||
instance_length > table_length
|
||||
&& list.take(instance_oid, table_length) == table_oid
|
||||
{
|
||||
True -> {
|
||||
let index = list.drop(instance_oid, table_length)
|
||||
Ok(index)
|
||||
}
|
||||
False -> Error(InvalidTableInstance)
|
||||
}
|
||||
}
|
||||
|
||||
/// Builds a table instance OID from table OID and index.
|
||||
pub fn build_table_instance(
|
||||
table_oid: List(Int),
|
||||
index: List(Int),
|
||||
) -> Result(List(Int), OidError) {
|
||||
use _ <- result.try(validate_oid_list(table_oid))
|
||||
use _ <- result.try(validate_oid_list(index))
|
||||
Ok(list.append(table_oid, index))
|
||||
}
|
||||
|
||||
/// Parse an integer table index (single element list).
|
||||
pub fn parse_integer_index(index: List(Int)) -> Result(Int, OidError) {
|
||||
case index {
|
||||
[value] -> Ok(value)
|
||||
_ -> Error(UnsupportedSyntax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a fixed-length string table index.
|
||||
pub fn parse_fixed_string_index(
|
||||
index: List(Int),
|
||||
length: Int,
|
||||
) -> Result(String, OidError) {
|
||||
case list.length(index) == length {
|
||||
True -> build_string_from_bytes(index)
|
||||
False -> Error(UnsupportedSyntax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a variable-length (length-prefixed) string table index.
|
||||
pub fn parse_variable_string_index(
|
||||
index: List(Int),
|
||||
) -> Result(String, OidError) {
|
||||
case index {
|
||||
[length, ..rest] ->
|
||||
case list.length(rest) == length {
|
||||
True -> build_string_from_bytes(rest)
|
||||
False -> Error(UnsupportedSyntax)
|
||||
}
|
||||
_ -> Error(UnsupportedSyntax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build an integer table index.
|
||||
pub fn build_integer_index(value: Int) -> Result(List(Int), OidError) {
|
||||
case value >= 0 {
|
||||
True -> Ok([value])
|
||||
False -> Error(UnsupportedSyntax)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a fixed-length string table index.
|
||||
pub fn build_fixed_string_index(
|
||||
value: String,
|
||||
length: Int,
|
||||
) -> Result(List(Int), OidError) {
|
||||
let chars = string_to_charlist(value)
|
||||
case list.length(chars) == length {
|
||||
True -> Ok(chars)
|
||||
False -> Error(InvalidStringLength)
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a variable-length string table index.
|
||||
pub fn build_variable_string_index(
|
||||
value: String,
|
||||
) -> Result(List(Int), OidError) {
|
||||
let chars = string_to_charlist(value)
|
||||
let length = list.length(chars)
|
||||
Ok([length, ..chars])
|
||||
}
|
||||
|
||||
// ---- Validation ----
|
||||
|
||||
/// Validates an OID list for correctness.
|
||||
pub fn validate(oid: List(Int)) -> Result(Nil, OidError) {
|
||||
validate_oid_list(oid)
|
||||
}
|
||||
|
||||
/// Normalizes a string OID to a list.
|
||||
pub fn normalize_string(oid: String) -> Result(List(Int), OidError) {
|
||||
string_to_list(oid)
|
||||
}
|
||||
|
||||
/// Normalizes a list OID (validates it).
|
||||
pub fn normalize_list(oid: List(Int)) -> Result(List(Int), OidError) {
|
||||
use _ <- result.try(validate_oid_list(oid))
|
||||
Ok(oid)
|
||||
}
|
||||
|
||||
// ---- Standard Prefix Accessors ----
|
||||
|
||||
pub fn internet() -> List(Int) {
|
||||
iso_org_dod_internet
|
||||
}
|
||||
|
||||
pub fn mgmt_prefix() -> List(Int) {
|
||||
mgmt
|
||||
}
|
||||
|
||||
pub fn mib_2() -> List(Int) {
|
||||
mib_2_prefix
|
||||
}
|
||||
|
||||
pub fn enterprises() -> List(Int) {
|
||||
enterprises_prefix
|
||||
}
|
||||
|
||||
pub fn experimental() -> List(Int) {
|
||||
experimental_prefix
|
||||
}
|
||||
|
||||
pub fn private() -> List(Int) {
|
||||
private_prefix
|
||||
}
|
||||
|
||||
/// Returns a standard prefix by name atom (for Elixir interop).
|
||||
pub fn standard_prefix(name: String) -> Result(List(Int), Nil) {
|
||||
case name {
|
||||
"internet" -> Ok(iso_org_dod_internet)
|
||||
"mgmt" -> Ok(mgmt)
|
||||
"mib_2" -> Ok(mib_2_prefix)
|
||||
"enterprises" -> Ok(enterprises_prefix)
|
||||
"experimental" -> Ok(experimental_prefix)
|
||||
"private" -> Ok(private_prefix)
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Tree Predicates ----
|
||||
|
||||
pub fn is_mib_2(oid: List(Int)) -> Bool {
|
||||
child_of(oid, mib_2_prefix) || oid == mib_2_prefix
|
||||
}
|
||||
|
||||
pub fn is_enterprise(oid: List(Int)) -> Bool {
|
||||
child_of(oid, enterprises_prefix)
|
||||
}
|
||||
|
||||
pub fn is_experimental(oid: List(Int)) -> Bool {
|
||||
child_of(oid, experimental_prefix)
|
||||
}
|
||||
|
||||
pub fn is_private(oid: List(Int)) -> Bool {
|
||||
child_of(oid, private_prefix)
|
||||
}
|
||||
|
||||
/// Gets the enterprise number from an enterprise OID.
|
||||
pub fn get_enterprise_number(oid: List(Int)) -> Result(Int, OidError) {
|
||||
let enterprises_len = list.length(enterprises_prefix)
|
||||
case is_enterprise(oid) && list.length(oid) > enterprises_len {
|
||||
True -> {
|
||||
// The element at index `enterprises_len` is the enterprise number
|
||||
case list.drop(oid, enterprises_len) {
|
||||
[num, ..] -> Ok(num)
|
||||
_ -> Error(NotEnterpriseOid)
|
||||
}
|
||||
}
|
||||
False -> Error(NotEnterpriseOid)
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Private Helpers ----
|
||||
|
||||
fn normalize_oid_string(oid_string: String) -> Result(String, OidError) {
|
||||
let trimmed = string.trim(oid_string)
|
||||
|
||||
case trimmed {
|
||||
"" -> Error(EmptyOid)
|
||||
_ ->
|
||||
case string.starts_with(trimmed, ".") {
|
||||
True -> {
|
||||
let normalized = string.drop_start(trimmed, 1)
|
||||
case normalized {
|
||||
"" -> Error(EmptyOid)
|
||||
_ -> Ok(normalized)
|
||||
}
|
||||
}
|
||||
False -> Ok(trimmed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_oid_components(
|
||||
parts: List(String),
|
||||
) -> Result(List(Int), OidError) {
|
||||
list.try_map(parts, fn(part) {
|
||||
case int.parse(part) {
|
||||
Ok(num) if num >= 0 -> Ok(num)
|
||||
_ -> Error(InvalidOidString)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_oid_list(oid_list: List(Int)) -> Result(Nil, OidError) {
|
||||
case oid_list {
|
||||
[] -> Error(EmptyOid)
|
||||
_ ->
|
||||
case list.all(oid_list, fn(c) { is_non_neg_integer(c) }) {
|
||||
True -> Ok(Nil)
|
||||
False -> Error(InvalidComponent)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Runtime check that a value is a non-negative integer.
|
||||
/// Needed because Elixir callers may pass non-integer values in lists.
|
||||
@external(erlang, "snmpkit_oid_ffi", "is_non_neg_integer")
|
||||
fn is_non_neg_integer(value: Int) -> Bool
|
||||
|
||||
fn compare_components(oid1: List(Int), oid2: List(Int)) -> Order {
|
||||
case oid1, oid2 {
|
||||
[], [] -> order.Eq
|
||||
[], _ -> order.Lt
|
||||
_, [] -> order.Gt
|
||||
[h1, ..t1], [h2, ..t2] ->
|
||||
case int.compare(h1, h2) {
|
||||
order.Eq -> compare_components(t1, t2)
|
||||
other -> other
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_string_from_bytes(bytes: List(Int)) -> Result(String, OidError) {
|
||||
bytes
|
||||
|> list.try_map(fn(b) {
|
||||
string.utf_codepoint(b)
|
||||
|> result.replace_error(InvalidStringIndex)
|
||||
})
|
||||
|> result.map(string.from_utf_codepoints)
|
||||
}
|
||||
|
||||
fn string_to_charlist(s: String) -> List(Int) {
|
||||
s
|
||||
|> string.to_utf_codepoints()
|
||||
|> list.map(string.utf_codepoint_to_int)
|
||||
}
|
||||
|
||||
/// Safe int.to_string that won't crash on non-integer BEAM values.
|
||||
/// After validation passes, this is guaranteed to receive integers.
|
||||
@external(erlang, "erlang", "integer_to_binary")
|
||||
fn safe_int_to_string(value: Int) -> String
|
||||
203
src/snmpkit/snmp_lib/pdu/constants.gleam
Normal file
203
src/snmpkit/snmp_lib/pdu/constants.gleam
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
/// Constants and utility functions for SNMP PDU operations.
|
||||
///
|
||||
/// Provides ASN.1 tag values for PDU types and data types, SNMPv3 constants,
|
||||
/// and conversion functions for version normalization, OID parsing, and
|
||||
/// message flag encoding/decoding.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
// ---- PDU Type Tags ----
|
||||
|
||||
/// GET request PDU tag (0xA0).
|
||||
pub fn get_request_tag() -> Int {
|
||||
160
|
||||
}
|
||||
|
||||
/// GETNEXT request PDU tag (0xA1).
|
||||
pub fn getnext_request_tag() -> Int {
|
||||
161
|
||||
}
|
||||
|
||||
/// GET response PDU tag (0xA2).
|
||||
pub fn get_response_tag() -> Int {
|
||||
162
|
||||
}
|
||||
|
||||
/// SET request PDU tag (0xA3).
|
||||
pub fn set_request_tag() -> Int {
|
||||
163
|
||||
}
|
||||
|
||||
/// GETBULK request PDU tag (0xA5).
|
||||
pub fn getbulk_request_tag() -> Int {
|
||||
165
|
||||
}
|
||||
|
||||
// ---- Data Type Tags ----
|
||||
|
||||
/// ASN.1 INTEGER tag (0x02).
|
||||
pub fn integer_tag() -> Int {
|
||||
2
|
||||
}
|
||||
|
||||
/// ASN.1 OCTET STRING tag (0x04).
|
||||
pub fn octet_string_tag() -> Int {
|
||||
4
|
||||
}
|
||||
|
||||
/// ASN.1 NULL tag (0x05).
|
||||
pub fn null_tag() -> Int {
|
||||
5
|
||||
}
|
||||
|
||||
/// ASN.1 OBJECT IDENTIFIER tag (0x06).
|
||||
pub fn object_identifier_tag() -> Int {
|
||||
6
|
||||
}
|
||||
|
||||
/// SNMP Counter32 tag (0x41).
|
||||
pub fn counter32_tag() -> Int {
|
||||
65
|
||||
}
|
||||
|
||||
/// SNMP Gauge32 tag (0x42).
|
||||
pub fn gauge32_tag() -> Int {
|
||||
66
|
||||
}
|
||||
|
||||
/// SNMP TimeTicks tag (0x43).
|
||||
pub fn timeticks_tag() -> Int {
|
||||
67
|
||||
}
|
||||
|
||||
/// SNMP Counter64 tag (0x46).
|
||||
pub fn counter64_tag() -> Int {
|
||||
70
|
||||
}
|
||||
|
||||
/// SNMP IpAddress tag (0x40).
|
||||
pub fn ip_address_tag() -> Int {
|
||||
64
|
||||
}
|
||||
|
||||
/// SNMP Opaque type tag (0x44).
|
||||
pub fn opaque_type_tag() -> Int {
|
||||
68
|
||||
}
|
||||
|
||||
/// SNMP noSuchObject exception tag (0x80).
|
||||
pub fn no_such_object_tag() -> Int {
|
||||
128
|
||||
}
|
||||
|
||||
/// SNMP noSuchInstance exception tag (0x81).
|
||||
pub fn no_such_instance_tag() -> Int {
|
||||
129
|
||||
}
|
||||
|
||||
/// SNMP endOfMibView exception tag (0x82).
|
||||
pub fn end_of_mib_view_tag() -> Int {
|
||||
130
|
||||
}
|
||||
|
||||
// ---- SNMPv3 Constants ----
|
||||
|
||||
/// USM security model identifier.
|
||||
pub fn usm_security_model() -> Int {
|
||||
3
|
||||
}
|
||||
|
||||
/// Default maximum SNMP message size (65507 bytes).
|
||||
pub fn default_max_message_size() -> Int {
|
||||
65_507
|
||||
}
|
||||
|
||||
// ---- Conversion Functions ----
|
||||
|
||||
/// Normalizes an SNMP version from atom or integer to its integer wire value.
|
||||
///
|
||||
/// Accepts Dynamic input since Elixir callers pass atoms (:v1, :v2c, :v2, :v3)
|
||||
/// or integer values. Returns the integer version number used in SNMP messages.
|
||||
///
|
||||
/// - :v1 -> 0
|
||||
/// - :v2c -> 1
|
||||
/// - :v2 -> 1
|
||||
/// - :v3 -> 3
|
||||
/// - integers pass through unchanged
|
||||
/// - anything else -> 0
|
||||
@external(erlang, "snmpkit_pdu_constants_ffi", "normalize_version")
|
||||
pub fn normalize_version(version: dynamic) -> Int
|
||||
|
||||
/// Parses a dotted-decimal OID string into a list of integers.
|
||||
///
|
||||
/// For example, "1.3.6.1.2.1" becomes [1, 3, 6, 1, 2, 1].
|
||||
/// Returns [1, 3, 6, 1] as a safe default for invalid strings
|
||||
/// (containing non-numeric parts or negative numbers).
|
||||
pub fn normalize_oid_string(oid: String) -> List(Int) {
|
||||
let default_oid = [1, 3, 6, 1]
|
||||
|
||||
let parts = string.split(oid, ".")
|
||||
|
||||
case parts {
|
||||
[] -> default_oid
|
||||
_ ->
|
||||
case parse_all_parts(parts) {
|
||||
Ok(parsed) -> parsed
|
||||
Error(Nil) -> default_oid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Encodes SNMPv3 message flags into a single byte value.
|
||||
///
|
||||
/// - auth: bit 0 (0x01)
|
||||
/// - priv: bit 1 (0x02)
|
||||
/// - reportable: bit 2 (0x04)
|
||||
///
|
||||
/// The Elixir wrapper is responsible for creating the binary <<flags>>.
|
||||
pub fn encode_msg_flags(auth: Bool, priv: Bool, reportable: Bool) -> Int {
|
||||
let flags = 0
|
||||
let flags = case auth {
|
||||
True -> bor(flags, 1)
|
||||
False -> flags
|
||||
}
|
||||
let flags = case priv {
|
||||
True -> bor(flags, 2)
|
||||
False -> flags
|
||||
}
|
||||
case reportable {
|
||||
True -> bor(flags, 4)
|
||||
False -> flags
|
||||
}
|
||||
}
|
||||
|
||||
/// Decodes a single byte into SNMPv3 message flags.
|
||||
///
|
||||
/// Returns a tuple of (auth, priv, reportable) booleans.
|
||||
pub fn decode_msg_flags(flags: Int) -> #(Bool, Bool, Bool) {
|
||||
let auth = band(flags, 1) != 0
|
||||
let priv = band(flags, 2) != 0
|
||||
let reportable = band(flags, 4) != 0
|
||||
#(auth, priv, reportable)
|
||||
}
|
||||
|
||||
// ---- Private Helpers ----
|
||||
|
||||
/// Parse all parts of a dotted OID string into integers.
|
||||
/// Returns Error if any part is not a valid non-negative integer.
|
||||
fn parse_all_parts(parts: List(String)) -> Result(List(Int), Nil) {
|
||||
list.try_map(parts, fn(part) {
|
||||
case int.parse(part) {
|
||||
Ok(num) if num >= 0 -> Ok(num)
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// Bitwise operations via Erlang BIFs
|
||||
@external(erlang, "erlang", "band")
|
||||
fn band(a: Int, b: Int) -> Int
|
||||
|
||||
@external(erlang, "erlang", "bor")
|
||||
fn bor(a: Int, b: Int) -> Int
|
||||
29
src/snmpkit_error_ffi.erl
Normal file
29
src/snmpkit_error_ffi.erl
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
-module(snmpkit_error_ffi).
|
||||
-export([atom_to_error_status/1, error_status_to_atom/1]).
|
||||
|
||||
%% Convert Elixir atom to Gleam ErrorStatus.
|
||||
%% Gleam zero-field variants compile to bare atoms.
|
||||
atom_to_error_status(no_error) -> no_error;
|
||||
atom_to_error_status(too_big) -> too_big;
|
||||
atom_to_error_status(no_such_name) -> no_such_name;
|
||||
atom_to_error_status(bad_value) -> bad_value;
|
||||
atom_to_error_status(read_only) -> read_only;
|
||||
atom_to_error_status(gen_err) -> gen_err;
|
||||
atom_to_error_status(no_access) -> no_access;
|
||||
atom_to_error_status(wrong_type) -> wrong_type;
|
||||
atom_to_error_status(wrong_length) -> wrong_length;
|
||||
atom_to_error_status(wrong_encoding) -> wrong_encoding;
|
||||
atom_to_error_status(wrong_value) -> wrong_value;
|
||||
atom_to_error_status(no_creation) -> no_creation;
|
||||
atom_to_error_status(inconsistent_value) -> inconsistent_value;
|
||||
atom_to_error_status(resource_unavailable) -> resource_unavailable;
|
||||
atom_to_error_status(commit_failed) -> commit_failed;
|
||||
atom_to_error_status(undo_failed) -> undo_failed;
|
||||
atom_to_error_status(authorization_error) -> authorization_error;
|
||||
atom_to_error_status(not_writable) -> not_writable;
|
||||
atom_to_error_status(inconsistent_name) -> inconsistent_name;
|
||||
atom_to_error_status(_) -> unknown_error.
|
||||
|
||||
%% Convert Gleam ErrorStatus back to Elixir atom.
|
||||
%% Identity since both are bare atoms.
|
||||
error_status_to_atom(Status) -> Status.
|
||||
6
src/snmpkit_formatting_ffi.erl
Normal file
6
src/snmpkit_formatting_ffi.erl
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-module(snmpkit_formatting_ffi).
|
||||
-export([format_float/2]).
|
||||
|
||||
%% Format a float to a string with the specified number of decimal places.
|
||||
format_float(Value, Decimals) when is_float(Value), is_integer(Decimals) ->
|
||||
erlang:float_to_binary(Value, [{decimals, Decimals}]).
|
||||
5
src/snmpkit_oid_ffi.erl
Normal file
5
src/snmpkit_oid_ffi.erl
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
-module(snmpkit_oid_ffi).
|
||||
-export([is_non_neg_integer/1]).
|
||||
|
||||
is_non_neg_integer(Value) when is_integer(Value), Value >= 0 -> true;
|
||||
is_non_neg_integer(_) -> false.
|
||||
11
src/snmpkit_pdu_constants_ffi.erl
Normal file
11
src/snmpkit_pdu_constants_ffi.erl
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
-module(snmpkit_pdu_constants_ffi).
|
||||
-export([normalize_version/1]).
|
||||
|
||||
%% Normalize SNMP version atoms/integers to wire-format integer values.
|
||||
%% Handles the atom/integer polymorphism that Gleam cannot express.
|
||||
normalize_version(v1) -> 0;
|
||||
normalize_version(v2c) -> 1;
|
||||
normalize_version(v2) -> 1;
|
||||
normalize_version(v3) -> 3;
|
||||
normalize_version(V) when is_integer(V) -> V;
|
||||
normalize_version(_) -> 0.
|
||||
218
src/towerops/accounts/user_agent_parser.gleam
Normal file
218
src/towerops/accounts/user_agent_parser.gleam
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
/// Simple regex-based User-Agent parser for extracting browser, OS, and device info.
|
||||
import gleam/dynamic.{type Dynamic}
|
||||
import gleam/dynamic/decode
|
||||
import gleam/option.{type Option, None, Some}
|
||||
import gleam/regexp.{type Regexp, Match}
|
||||
import gleam/string
|
||||
|
||||
/// Parsed user agent result.
|
||||
pub type ParseResult {
|
||||
ParseResult(
|
||||
browser_name: String,
|
||||
browser_version: Option(String),
|
||||
os_name: String,
|
||||
os_version: Option(String),
|
||||
device_type: String,
|
||||
device_name: String,
|
||||
)
|
||||
}
|
||||
|
||||
/// Parse a user agent (Dynamic to handle nil from Elixir).
|
||||
/// Returns an Elixir-compatible atom-keyed map via FFI.
|
||||
pub fn parse(value: Dynamic) -> Dynamic {
|
||||
let result = case decode.run(value, decode.string) {
|
||||
Error(_) -> unknown()
|
||||
Ok(ua) ->
|
||||
case ua {
|
||||
"" -> unknown()
|
||||
_ -> parse_ua(ua)
|
||||
}
|
||||
}
|
||||
to_elixir_map(result)
|
||||
}
|
||||
|
||||
fn parse_ua(ua: String) -> ParseResult {
|
||||
let browser = detect_browser(ua)
|
||||
let os = detect_os(ua)
|
||||
let device_type = detect_device_type(ua, os.os_name)
|
||||
|
||||
ParseResult(
|
||||
browser_name: browser.browser_name,
|
||||
browser_version: browser.browser_version,
|
||||
os_name: os.os_name,
|
||||
os_version: os.os_version,
|
||||
device_type: device_type,
|
||||
device_name: browser.browser_name <> " on " <> os.os_name,
|
||||
)
|
||||
}
|
||||
|
||||
fn unknown() -> ParseResult {
|
||||
ParseResult(
|
||||
browser_name: "Unknown",
|
||||
browser_version: None,
|
||||
os_name: "Unknown",
|
||||
os_version: None,
|
||||
device_type: "desktop",
|
||||
device_name: "Unknown on Unknown",
|
||||
)
|
||||
}
|
||||
|
||||
// --- Browser detection ---
|
||||
|
||||
type BrowserInfo {
|
||||
BrowserInfo(browser_name: String, browser_version: Option(String))
|
||||
}
|
||||
|
||||
fn detect_browser(ua: String) -> BrowserInfo {
|
||||
let assert Ok(opera_re) = regexp.from_string("OPR\\/(\\d+\\.\\d+)")
|
||||
let assert Ok(opera_legacy_re) = regexp.from_string("Opera\\/(\\d+\\.\\d+)")
|
||||
let assert Ok(edge_re) = regexp.from_string("Edg(?:e|A|iOS)?\\/(\\d+\\.\\d+)")
|
||||
let assert Ok(chrome_re) = regexp.from_string("Chrome\\/(\\d+\\.\\d+)")
|
||||
let assert Ok(firefox_re) = regexp.from_string("Firefox\\/(\\d+\\.\\d+)")
|
||||
let assert Ok(safari_check_re) = regexp.from_string("Safari\\/")
|
||||
let assert Ok(safari_ver_re) = regexp.from_string("Version\\/(\\d+\\.\\d+)")
|
||||
|
||||
case regexp.check(opera_re, ua) || regexp.check(opera_legacy_re, ua) {
|
||||
True -> {
|
||||
let assert Ok(combined_re) =
|
||||
regexp.from_string("(?:OPR|Opera)\\/(\\d+\\.\\d+)")
|
||||
BrowserInfo("Opera", extract_version(ua, combined_re))
|
||||
}
|
||||
False ->
|
||||
case regexp.check(edge_re, ua) {
|
||||
True -> BrowserInfo("Edge", extract_version(ua, edge_re))
|
||||
False ->
|
||||
case regexp.check(chrome_re, ua) {
|
||||
True -> BrowserInfo("Chrome", extract_version(ua, chrome_re))
|
||||
False ->
|
||||
case regexp.check(firefox_re, ua) {
|
||||
True ->
|
||||
BrowserInfo("Firefox", extract_version(ua, firefox_re))
|
||||
False ->
|
||||
case
|
||||
regexp.check(safari_check_re, ua)
|
||||
&& regexp.check(safari_ver_re, ua)
|
||||
{
|
||||
True ->
|
||||
BrowserInfo("Safari", extract_version(ua, safari_ver_re))
|
||||
False -> BrowserInfo("Unknown", None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- OS detection ---
|
||||
|
||||
type OsInfo {
|
||||
OsInfo(os_name: String, os_version: Option(String))
|
||||
}
|
||||
|
||||
fn detect_os(ua: String) -> OsInfo {
|
||||
let assert Ok(ios_re) = regexp.from_string("iPhone|iPad|iPod")
|
||||
let assert Ok(mac_re) = regexp.from_string("Macintosh|Mac OS X")
|
||||
let assert Ok(android_re) = regexp.from_string("Android")
|
||||
let assert Ok(windows_re) = regexp.from_string("Windows")
|
||||
let assert Ok(linux_re) = regexp.from_string("Linux")
|
||||
|
||||
case regexp.check(ios_re, ua) {
|
||||
True -> {
|
||||
let assert Ok(ios_ver_re) = regexp.from_string("OS (\\d+[_\\d]*)")
|
||||
let version = extract_version(ua, ios_ver_re)
|
||||
OsInfo("iOS", format_underscored_version(version))
|
||||
}
|
||||
False ->
|
||||
case regexp.check(mac_re, ua) {
|
||||
True -> {
|
||||
let assert Ok(mac_ver_re) =
|
||||
regexp.from_string("Mac OS X (\\d+[_\\d]*)")
|
||||
let version = extract_version(ua, mac_ver_re)
|
||||
OsInfo("macOS", format_underscored_version(version))
|
||||
}
|
||||
False ->
|
||||
case regexp.check(android_re, ua) {
|
||||
True -> {
|
||||
let assert Ok(android_ver_re) =
|
||||
regexp.from_string("Android (\\d+\\.\\d+)")
|
||||
OsInfo("Android", extract_version(ua, android_ver_re))
|
||||
}
|
||||
False ->
|
||||
case regexp.check(windows_re, ua) {
|
||||
True -> OsInfo("Windows", detect_windows_version(ua))
|
||||
False ->
|
||||
case regexp.check(linux_re, ua) {
|
||||
True -> OsInfo("Linux", None)
|
||||
False -> OsInfo("Unknown", None)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn detect_windows_version(ua: String) -> Option(String) {
|
||||
let assert Ok(win10_re) = regexp.from_string("Windows NT 10\\.0")
|
||||
let assert Ok(win81_re) = regexp.from_string("Windows NT 6\\.3")
|
||||
let assert Ok(win8_re) = regexp.from_string("Windows NT 6\\.2")
|
||||
let assert Ok(win7_re) = regexp.from_string("Windows NT 6\\.1")
|
||||
let assert Ok(win_nt_re) = regexp.from_string("Windows NT (\\d+\\.\\d+)")
|
||||
|
||||
case regexp.check(win10_re, ua) {
|
||||
True -> Some("10")
|
||||
False ->
|
||||
case regexp.check(win81_re, ua) {
|
||||
True -> Some("8.1")
|
||||
False ->
|
||||
case regexp.check(win8_re, ua) {
|
||||
True -> Some("8")
|
||||
False ->
|
||||
case regexp.check(win7_re, ua) {
|
||||
True -> Some("7")
|
||||
False ->
|
||||
case regexp.check(win_nt_re, ua) {
|
||||
True -> extract_version(ua, win_nt_re)
|
||||
False -> None
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Device type detection ---
|
||||
|
||||
fn detect_device_type(ua: String, os_name: String) -> String {
|
||||
let assert Ok(ipad_re) = regexp.from_string("iPad")
|
||||
let assert Ok(android_tablet_re) = regexp.from_string("Android.*Tablet")
|
||||
let assert Ok(mobile_re) =
|
||||
regexp.from_string("iPhone|iPod|Android.*Mobile|Mobile")
|
||||
|
||||
case regexp.check(ipad_re, ua) || regexp.check(android_tablet_re, ua) {
|
||||
True -> "tablet"
|
||||
False ->
|
||||
case regexp.check(mobile_re, ua) || os_name == "iOS" {
|
||||
True -> "mobile"
|
||||
False -> "desktop"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
fn extract_version(ua: String, re: Regexp) -> Option(String) {
|
||||
case regexp.scan(re, ua) {
|
||||
[Match(submatches: [Some(version), ..], ..)] -> Some(version)
|
||||
_ -> None
|
||||
}
|
||||
}
|
||||
|
||||
fn format_underscored_version(version: Option(String)) -> Option(String) {
|
||||
case version {
|
||||
None -> None
|
||||
Some(v) -> Some(string.replace(v, "_", "."))
|
||||
}
|
||||
}
|
||||
|
||||
@external(erlang, "towerops_user_agent_parser_ffi", "to_map")
|
||||
fn to_elixir_map(result: ParseResult) -> Dynamic
|
||||
139
src/towerops/devices/mikrotik_backups/differ.gleam
Normal file
139
src/towerops/devices/mikrotik_backups/differ.gleam
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
/// Pure-function portions of the MikroTik backup Differ.
|
||||
///
|
||||
/// Handles sensitive-data masking and diff statistics calculation.
|
||||
/// Impure operations (generate_unified_diff) remain in the Elixir module.
|
||||
///
|
||||
/// Compiles to `:towerops@devices@mikrotik_backups@differ`.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Compute the first 8 lowercase hex characters of the SHA256 hash of a value.
|
||||
@external(erlang, "towerops_differ_ffi", "sha256_short")
|
||||
pub fn sha256_short(value: String) -> String
|
||||
|
||||
/// Replace regex matches where capture group 1 is a prefix to keep and
|
||||
/// capture group 2 is the sensitive value to mask. The `caseless` flag
|
||||
/// controls case-insensitive matching.
|
||||
@external(erlang, "towerops_differ_ffi", "mask_pattern")
|
||||
fn mask_pattern(pattern: String, config: String, caseless: Bool) -> String
|
||||
|
||||
/// Same as `mask_pattern` but values present in `skip_values` are left
|
||||
/// unmasked.
|
||||
@external(erlang, "towerops_differ_ffi", "mask_pattern_skip")
|
||||
fn mask_pattern_skip(
|
||||
pattern: String,
|
||||
config: String,
|
||||
caseless: Bool,
|
||||
skip_values: List(String),
|
||||
) -> String
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Masks sensitive data in a MikroTik configuration export.
|
||||
///
|
||||
/// Chains through several masking operations in order:
|
||||
/// 1. Passwords (`password=VALUE`, case-insensitive)
|
||||
/// 2. SNMP communities (set identifier and name parameter)
|
||||
/// 3. IPsec secrets (`secret=VALUE`, case-insensitive)
|
||||
/// 4. Wireless keys (`wpa-pre-shared-key=VALUE`, `wpa2-pre-shared-key=VALUE`,
|
||||
/// case-insensitive)
|
||||
///
|
||||
/// Each sensitive value is replaced with `***MASKED:HASH***` where HASH is the
|
||||
/// first 8 hex characters (lowercase) of the SHA256 hash of the original value.
|
||||
pub fn mask_sensitive_data(config_text: String) -> String {
|
||||
config_text
|
||||
|> mask_passwords
|
||||
|> mask_snmp_communities
|
||||
|> mask_ipsec_secrets
|
||||
|> mask_wireless_keys
|
||||
}
|
||||
|
||||
/// Parse a unified diff string and return `#(additions, deletions, changes)`.
|
||||
///
|
||||
/// - `additions` = lines starting with "+" but NOT "+++"
|
||||
/// - `deletions` = lines starting with "-" but NOT "---"
|
||||
/// - `changes` = `min(additions, deletions)`
|
||||
pub fn calculate_diff_stats(diff: String) -> #(Int, Int, Int) {
|
||||
let lines = string.split(diff, "\n")
|
||||
|
||||
let additions =
|
||||
lines
|
||||
|> list.filter(fn(line) {
|
||||
string.starts_with(line, "+") && !string.starts_with(line, "+++")
|
||||
})
|
||||
|> list.length
|
||||
|
||||
let deletions =
|
||||
lines
|
||||
|> list.filter(fn(line) {
|
||||
string.starts_with(line, "-") && !string.starts_with(line, "---")
|
||||
})
|
||||
|> list.length
|
||||
|
||||
let changes = int.min(additions, deletions)
|
||||
|
||||
#(additions, deletions, changes)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal masking functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Mask `password=VALUE` patterns (case-insensitive).
|
||||
fn mask_passwords(config: String) -> String {
|
||||
mask_pattern("(password=)(\\S+)", config, True)
|
||||
}
|
||||
|
||||
/// Mask SNMP community strings via two sub-patterns.
|
||||
fn mask_snmp_communities(config: String) -> String {
|
||||
config
|
||||
|> mask_snmp_set_identifier
|
||||
|> mask_snmp_name_parameter
|
||||
}
|
||||
|
||||
/// Mask the identifier in `/snmp community set IDENTIFIER ...`.
|
||||
/// Skips "public" and "private" (common defaults).
|
||||
fn mask_snmp_set_identifier(config: String) -> String {
|
||||
mask_pattern_skip(
|
||||
"(\\/snmp\\s+community\\s+set\\s+)(\\S+)",
|
||||
config,
|
||||
False,
|
||||
["public", "private"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mask the `name=VALUE` parameter in `/snmp community ... name=VALUE`.
|
||||
/// Skips "public" and "private" (common defaults).
|
||||
fn mask_snmp_name_parameter(config: String) -> String {
|
||||
mask_pattern_skip(
|
||||
"(\\/snmp\\s+community.*?name=)(\\S+)",
|
||||
config,
|
||||
False,
|
||||
["public", "private"],
|
||||
)
|
||||
}
|
||||
|
||||
/// Mask `secret=VALUE` patterns (case-insensitive).
|
||||
fn mask_ipsec_secrets(config: String) -> String {
|
||||
mask_pattern("(secret=)(\\S+)", config, True)
|
||||
}
|
||||
|
||||
/// Mask wireless WPA and WPA2 pre-shared key patterns (case-insensitive).
|
||||
fn mask_wireless_keys(config: String) -> String {
|
||||
config
|
||||
|> mask_wpa_key("wpa-pre-shared-key")
|
||||
|> mask_wpa_key("wpa2-pre-shared-key")
|
||||
}
|
||||
|
||||
/// Mask a single wireless key pattern: `KEY_NAME=VALUE` (case-insensitive).
|
||||
fn mask_wpa_key(config: String, key_name: String) -> String {
|
||||
let pattern = "(" <> key_name <> "=)(\\S+)"
|
||||
mask_pattern(pattern, config, True)
|
||||
}
|
||||
130
src/towerops/devices/version_comparator.gleam
Normal file
130
src/towerops/devices/version_comparator.gleam
Normal file
|
|
@ -0,0 +1,130 @@
|
|||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/order.{type Order}
|
||||
import gleam/string
|
||||
|
||||
/// Compares semantic version strings.
|
||||
///
|
||||
/// Supports versions with 0-3 parts (e.g., "7", "7.14", "7.14.1").
|
||||
/// Versions with 4+ parts are considered invalid and fall back to #(0, 0, 0).
|
||||
///
|
||||
/// ## Parsing Rules
|
||||
///
|
||||
/// - Strips "v" or "V" prefix
|
||||
/// - Trims whitespace
|
||||
/// - Splits on "-" or " " and ignores suffixes (e.g., "7.14.1-beta" -> "7.14.1")
|
||||
/// - Pads missing parts with 0 (e.g., "7.14" -> #(7, 14, 0))
|
||||
/// - 0-3 parts: VALID
|
||||
/// - 4+ parts: INVALID -> fallback to #(0, 0, 0)
|
||||
/// - Non-numeric parts: INVALID
|
||||
/// - Negative numbers: INVALID
|
||||
/// - Leading zeros: VALID (e.g., "007.014.001" -> #(7, 14, 1))
|
||||
|
||||
/// Compares two version strings.
|
||||
///
|
||||
/// Returns `order.Lt` if the first version is older,
|
||||
/// `order.Gt` if newer, or `order.Eq` if equal.
|
||||
pub fn compare(version1: String, version2: String) -> Order {
|
||||
let v1 = parse_version(version1)
|
||||
let v2 = parse_version(version2)
|
||||
compare_tuples(v1, v2)
|
||||
}
|
||||
|
||||
/// Returns True if the second version is newer than the first.
|
||||
pub fn newer(current_version: String, new_version: String) -> Bool {
|
||||
compare(current_version, new_version) == order.Lt
|
||||
}
|
||||
|
||||
fn compare_tuples(
|
||||
v1: #(Int, Int, Int),
|
||||
v2: #(Int, Int, Int),
|
||||
) -> Order {
|
||||
case int.compare(v1.0, v2.0) {
|
||||
order.Eq ->
|
||||
case int.compare(v1.1, v2.1) {
|
||||
order.Eq -> int.compare(v1.2, v2.2)
|
||||
other -> other
|
||||
}
|
||||
other -> other
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_version(version: String) -> #(Int, Int, Int) {
|
||||
let invalid = #(0, 0, 0)
|
||||
|
||||
let trimmed = string.trim(version)
|
||||
let stripped = strip_prefix(trimmed)
|
||||
let base = strip_suffix(stripped)
|
||||
|
||||
case base {
|
||||
"" -> invalid
|
||||
_ -> {
|
||||
let parts = string.split(base, ".")
|
||||
case list.length(parts) > 3 {
|
||||
True -> invalid
|
||||
False -> parse_parts(parts)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn strip_prefix(version: String) -> String {
|
||||
case version {
|
||||
"v" <> rest -> rest
|
||||
"V" <> rest -> rest
|
||||
_ -> version
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip suffixes after "-" or " ".
|
||||
/// Gleam's string.split only takes a single delimiter, so we split
|
||||
/// on "-" first, take the first part, then split on " " and take the first.
|
||||
fn strip_suffix(version: String) -> String {
|
||||
let after_dash = case string.split_once(version, "-") {
|
||||
Ok(#(before, _)) -> before
|
||||
Error(_) -> version
|
||||
}
|
||||
case string.split_once(after_dash, " ") {
|
||||
Ok(#(before, _)) -> before
|
||||
Error(_) -> after_dash
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_parts(parts: List(String)) -> #(Int, Int, Int) {
|
||||
let invalid = #(0, 0, 0)
|
||||
|
||||
case parse_all_parts(parts) {
|
||||
Error(_) -> invalid
|
||||
Ok(numbers) -> pad_to_three(numbers)
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_all_parts(parts: List(String)) -> Result(List(Int), Nil) {
|
||||
case parts {
|
||||
[] -> Ok([])
|
||||
[first, ..rest] ->
|
||||
case int.parse(first) {
|
||||
Error(_) -> Error(Nil)
|
||||
Ok(n) ->
|
||||
case n < 0 {
|
||||
True -> Error(Nil)
|
||||
False ->
|
||||
case parse_all_parts(rest) {
|
||||
Error(_) -> Error(Nil)
|
||||
Ok(parsed) -> Ok([n, ..parsed])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn pad_to_three(numbers: List(Int)) -> #(Int, Int, Int) {
|
||||
case numbers {
|
||||
[a, b, c] -> #(a, b, c)
|
||||
[a, b] -> #(a, b, 0)
|
||||
[a] -> #(a, 0, 0)
|
||||
// Should never happen given we validated length <= 3 and > 0,
|
||||
// but handle gracefully
|
||||
_ -> #(0, 0, 0)
|
||||
}
|
||||
}
|
||||
96
src/towerops/snmp/mib_parser.gleam
Normal file
96
src/towerops/snmp/mib_parser.gleam
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
/// Simple MIB file parser for extracting OID definitions.
|
||||
///
|
||||
/// Extracts OID assignments from MIB file content for validation purposes.
|
||||
/// Not a full ASN.1 parser - just enough to validate hardcoded OIDs
|
||||
/// against official MIB definitions.
|
||||
import gleam/dict.{type Dict}
|
||||
import gleam/list
|
||||
import gleam/option.{Some}
|
||||
import gleam/regexp
|
||||
|
||||
/// Parse MIB content string and extract OID definitions.
|
||||
/// Returns a map of object names to OID strings.
|
||||
pub fn parse_mib_content(content: String) -> Dict(String, String) {
|
||||
let assignments = extract_assignments(content)
|
||||
resolve_oids(assignments)
|
||||
}
|
||||
|
||||
fn extract_assignments(content: String) -> Dict(String, #(String, String)) {
|
||||
// Remove comments (lines starting with --)
|
||||
let assert Ok(comment_re) =
|
||||
regexp.compile(
|
||||
"--.*$",
|
||||
regexp.Options(case_insensitive: False, multi_line: True),
|
||||
)
|
||||
let cleaned = regexp.replace(in: content, each: comment_re, with: "")
|
||||
|
||||
// Match assignment patterns: name OBJECT-TYPE|OBJECT IDENTIFIER ::= { parent index }
|
||||
let assert Ok(assignment_re) =
|
||||
regexp.from_string(
|
||||
"(\\w+)\\s+(?:OBJECT-TYPE|OBJECT\\s+IDENTIFIER)[^:]*::=\\s*\\{\\s*(\\w+)\\s+(\\d+)\\s*\\}",
|
||||
)
|
||||
|
||||
regexp.scan(assignment_re, cleaned)
|
||||
|> list.filter_map(fn(m) {
|
||||
case m.submatches {
|
||||
[Some(name), Some(parent), Some(index)] -> Ok(#(name, #(parent, index)))
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
})
|
||||
|> dict.from_list()
|
||||
}
|
||||
|
||||
fn resolve_oids(
|
||||
assignments: Dict(String, #(String, String)),
|
||||
) -> Dict(String, String) {
|
||||
// Well-known root OIDs
|
||||
let roots =
|
||||
dict.from_list([
|
||||
#("iso", "1"),
|
||||
#("org", "1.3"),
|
||||
#("dod", "1.3.6"),
|
||||
#("internet", "1.3.6.1"),
|
||||
#("directory", "1.3.6.1.1"),
|
||||
#("mgmt", "1.3.6.1.2"),
|
||||
#("mib-2", "1.3.6.1.2.1"),
|
||||
#("experimental", "1.3.6.1.3"),
|
||||
#("private", "1.3.6.1.4"),
|
||||
#("enterprises", "1.3.6.1.4.1"),
|
||||
#("security", "1.3.6.1.5"),
|
||||
#("snmpV2", "1.3.6.1.6"),
|
||||
#("snmpModules", "1.3.6.1.6.3"),
|
||||
])
|
||||
|
||||
// Resolve all assignments by following parent chains
|
||||
dict.to_list(assignments)
|
||||
|> list.fold(roots, fn(acc, entry) {
|
||||
let #(name, #(parent, index)) = entry
|
||||
case resolve_oid(parent, index, acc, assignments) {
|
||||
Ok(oid) -> dict.insert(acc, name, oid)
|
||||
Error(_) -> acc
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn resolve_oid(
|
||||
parent: String,
|
||||
index: String,
|
||||
resolved: Dict(String, String),
|
||||
assignments: Dict(String, #(String, String)),
|
||||
) -> Result(String, Nil) {
|
||||
case dict.get(resolved, parent) {
|
||||
// Parent already resolved
|
||||
Ok(parent_oid) -> Ok(parent_oid <> "." <> index)
|
||||
Error(_) ->
|
||||
// Parent needs to be resolved from assignments
|
||||
case dict.get(assignments, parent) {
|
||||
Ok(#(grandparent, parent_index)) ->
|
||||
case resolve_oid(grandparent, parent_index, resolved, assignments) {
|
||||
Ok(parent_oid) -> Ok(parent_oid <> "." <> index)
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
// Can't resolve
|
||||
Error(_) -> Error(Nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
311
src/towerops/snmp/sanitizer.gleam
Normal file
311
src/towerops/snmp/sanitizer.gleam
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/// Sanitizes SNMP data to ensure it's safe to store in the database.
|
||||
///
|
||||
/// SNMP devices may return non-UTF8 binary data, non-printable characters,
|
||||
/// malformed strings, or raw binary values (MAC addresses, IP addresses).
|
||||
/// This module provides sanitization functions to convert these to safe,
|
||||
/// printable strings suitable for PostgreSQL text/varchar columns.
|
||||
///
|
||||
/// Compiles to `:towerops@snmp@sanitizer`.
|
||||
import gleam/dynamic.{type Dynamic}
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "is_nil")
|
||||
fn is_nil(value: Dynamic) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "is_binary_value")
|
||||
fn is_binary_val(value: Dynamic) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "is_list_value")
|
||||
fn is_list_val(value: Dynamic) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "is_valid_utf8")
|
||||
fn is_valid_utf8(binary: Dynamic) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "is_printable")
|
||||
fn is_printable(binary: Dynamic) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "binary_to_byte_list")
|
||||
fn binary_to_byte_list(binary: Dynamic) -> List(Int)
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "byte_size_of")
|
||||
fn byte_size_of(binary: Dynamic) -> Int
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "get_byte")
|
||||
fn get_byte(binary: Dynamic, offset: Int) -> Int
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "trim_string")
|
||||
fn trim_string(binary: Dynamic) -> String
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "to_string_value")
|
||||
fn to_string_value(value: Dynamic) -> String
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "list_to_binary_safe")
|
||||
fn list_to_binary_safe(value: Dynamic) -> Result(Dynamic, Nil)
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "format_ipv4_bytes")
|
||||
fn format_ipv4_bytes(a: Int, b: Int, c: Int, d: Int) -> String
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "match_ipv4_regex")
|
||||
fn match_ipv4_regex(value: String) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "match_mac_regex")
|
||||
fn match_mac_regex(value: String) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "string_contains_colon")
|
||||
fn string_contains_colon(value: String) -> Bool
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "string_replace_dash")
|
||||
fn string_replace_dash(value: String) -> String
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "string_downcase")
|
||||
fn string_downcase(value: String) -> String
|
||||
|
||||
@external(erlang, "towerops_sanitizer_ffi", "decode_ipv4_tuple")
|
||||
fn decode_ipv4_tuple(value: Dynamic) -> Result(#(Int, Int, Int, Int), Nil)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Sanitizes a string field from SNMP data.
|
||||
///
|
||||
/// - Returns nil for nil input
|
||||
/// - Returns printable strings as-is (trimmed)
|
||||
/// - Converts non-printable binaries to colon-separated lowercase hex
|
||||
/// - Converts Erlang charlists to binary first, then sanitizes
|
||||
/// - Converts other types to strings
|
||||
pub fn sanitize_string(value: Dynamic) -> Dynamic {
|
||||
case is_nil(value) {
|
||||
True -> dynamic.nil()
|
||||
False ->
|
||||
case is_binary_val(value) {
|
||||
True -> sanitize_binary_string(value)
|
||||
False ->
|
||||
case is_list_val(value) {
|
||||
True -> sanitize_charlist_string(value)
|
||||
False -> dynamic.string(to_string_value(value))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes an IPv4 address from various SNMP formats.
|
||||
///
|
||||
/// Handles:
|
||||
/// - 4-byte raw binary `<<a, b, c, d>>` -> `"a.b.c.d"`
|
||||
/// - Already-formatted printable IP string -> return as-is
|
||||
/// - Printable string not matching IP format -> nil
|
||||
/// - Raw bytes >= 4 bytes (non-printable) -> take first 4 bytes as IP
|
||||
/// - 4-tuple `{a, b, c, d}` -> `"a.b.c.d"`
|
||||
/// - Anything else -> nil
|
||||
pub fn sanitize_ipv4(value: Dynamic) -> Dynamic {
|
||||
case is_nil(value) {
|
||||
True -> dynamic.nil()
|
||||
False ->
|
||||
case decode_ipv4_tuple(value) {
|
||||
Ok(#(a, b, c, d)) -> dynamic.string(format_ipv4_bytes(a, b, c, d))
|
||||
Error(_) ->
|
||||
case is_binary_val(value) {
|
||||
True -> sanitize_ipv4_binary(value)
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes an IPv6 address from various SNMP formats.
|
||||
///
|
||||
/// Handles:
|
||||
/// - 16-byte raw binary -> colon-separated hex groups (2 bytes each)
|
||||
/// - Printable string containing ":" -> trim and return
|
||||
/// - Anything else -> nil
|
||||
pub fn sanitize_ipv6(value: Dynamic) -> Dynamic {
|
||||
case is_nil(value) {
|
||||
True -> dynamic.nil()
|
||||
False ->
|
||||
case is_binary_val(value) {
|
||||
True -> sanitize_ipv6_binary(value)
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Sanitizes a MAC address from various SNMP formats.
|
||||
///
|
||||
/// Handles:
|
||||
/// - 6-byte raw binary -> colon-separated lowercase hex with zero padding
|
||||
/// - Printable string matching MAC pattern -> normalize to lowercase with colons
|
||||
/// - Anything else -> nil
|
||||
pub fn sanitize_mac(value: Dynamic) -> Dynamic {
|
||||
case is_nil(value) {
|
||||
True -> dynamic.nil()
|
||||
False ->
|
||||
case is_binary_val(value) {
|
||||
True -> sanitize_mac_binary(value)
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers: sanitize_string
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles the binary case for sanitize_string.
|
||||
fn sanitize_binary_string(value: Dynamic) -> Dynamic {
|
||||
case is_valid_utf8(value) && is_printable(value) {
|
||||
True -> dynamic.string(trim_string(value))
|
||||
False -> {
|
||||
let bytes = binary_to_byte_list(value)
|
||||
dynamic.string(bytes_to_colon_hex(bytes))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Handles the charlist case for sanitize_string.
|
||||
/// Converts list to binary then sanitizes, falling back to inspect on error.
|
||||
fn sanitize_charlist_string(value: Dynamic) -> Dynamic {
|
||||
case list_to_binary_safe(value) {
|
||||
Ok(binary) -> sanitize_binary_string(binary)
|
||||
Error(_) -> dynamic.string(to_string_value(value))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers: sanitize_ipv4
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles binary values for IPv4 sanitization.
|
||||
fn sanitize_ipv4_binary(value: Dynamic) -> Dynamic {
|
||||
let size = byte_size_of(value)
|
||||
case size {
|
||||
4 -> {
|
||||
// Exactly 4 bytes: always treat as raw IP address bytes
|
||||
let a = get_byte(value, 0)
|
||||
let b = get_byte(value, 1)
|
||||
let c = get_byte(value, 2)
|
||||
let d = get_byte(value, 3)
|
||||
dynamic.string(format_ipv4_bytes(a, b, c, d))
|
||||
}
|
||||
_ ->
|
||||
case is_printable(value) {
|
||||
True -> {
|
||||
let str = trim_string(value)
|
||||
case match_ipv4_regex(str) {
|
||||
True -> dynamic.string(str)
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
False ->
|
||||
case size >= 4 {
|
||||
True -> {
|
||||
// Non-printable binary >= 4 bytes: take first 4 bytes
|
||||
let a = get_byte(value, 0)
|
||||
let b = get_byte(value, 1)
|
||||
let c = get_byte(value, 2)
|
||||
let d = get_byte(value, 3)
|
||||
dynamic.string(format_ipv4_bytes(a, b, c, d))
|
||||
}
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers: sanitize_ipv6
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles binary values for IPv6 sanitization.
|
||||
fn sanitize_ipv6_binary(value: Dynamic) -> Dynamic {
|
||||
let size = byte_size_of(value)
|
||||
case size {
|
||||
16 -> {
|
||||
// Raw 16-byte IPv6 address
|
||||
let bytes = binary_to_byte_list(value)
|
||||
let hex_groups =
|
||||
bytes
|
||||
|> list.sized_chunk(2)
|
||||
|> list.map(fn(pair) {
|
||||
case pair {
|
||||
[high, low] -> {
|
||||
let group_value = high * 256 + low
|
||||
let assert Ok(hex) = int.to_base_string(group_value, 16)
|
||||
string.lowercase(hex)
|
||||
}
|
||||
// Should not happen with 16 bytes chunked by 2
|
||||
_ -> "0"
|
||||
}
|
||||
})
|
||||
|> string.join(":")
|
||||
dynamic.string(hex_groups)
|
||||
}
|
||||
_ ->
|
||||
case is_printable(value) {
|
||||
True -> {
|
||||
let str = trim_string(value)
|
||||
case string_contains_colon(str) {
|
||||
True -> dynamic.string(str)
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers: sanitize_mac
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Handles binary values for MAC address sanitization.
|
||||
fn sanitize_mac_binary(value: Dynamic) -> Dynamic {
|
||||
let size = byte_size_of(value)
|
||||
case size {
|
||||
6 -> {
|
||||
// Raw 6-byte MAC address
|
||||
let bytes = binary_to_byte_list(value)
|
||||
dynamic.string(bytes_to_colon_hex(bytes))
|
||||
}
|
||||
_ ->
|
||||
case is_printable(value) {
|
||||
True -> {
|
||||
let cleaned = string_replace_dash(trim_string(value))
|
||||
case match_mac_regex(cleaned) {
|
||||
True -> dynamic.string(string_downcase(cleaned))
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
False -> dynamic.nil()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Converts a list of bytes to colon-separated lowercase hex.
|
||||
/// e.g. [255, 0, 10] -> "ff:00:0a"
|
||||
fn bytes_to_colon_hex(bytes: List(Int)) -> String {
|
||||
bytes
|
||||
|> list.map(byte_to_padded_hex)
|
||||
|> string.join(":")
|
||||
}
|
||||
|
||||
/// Converts a single byte to a zero-padded 2-character lowercase hex string.
|
||||
/// e.g. 0 -> "00", 10 -> "0a", 255 -> "ff"
|
||||
fn byte_to_padded_hex(byte: Int) -> String {
|
||||
let assert Ok(hex) = int.to_base_string(byte, 16)
|
||||
let lower = string.lowercase(hex)
|
||||
case string.length(lower) {
|
||||
1 -> "0" <> lower
|
||||
_ -> lower
|
||||
}
|
||||
}
|
||||
164
src/towerops/topology/identifier.gleam
Normal file
164
src/towerops/topology/identifier.gleam
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/// Pure-function portions of the Topology Identifier module.
|
||||
///
|
||||
/// Handles MAC address normalization, hostname normalization, and
|
||||
/// candidate name generation. IP normalization delegates to an
|
||||
/// Erlang FFI for `:inet` operations.
|
||||
///
|
||||
/// Compiles to `:towerops@topology@identifier`.
|
||||
import gleam/int
|
||||
import gleam/list
|
||||
import gleam/result
|
||||
import gleam/string
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// FFI declarations
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Parse an IP string via :inet and return the normalized, lowercased form.
|
||||
/// If the string is not a valid IP, it is simply lowercased.
|
||||
@external(erlang, "towerops_topology_identifier_ffi", "inet_parse_and_normalize")
|
||||
fn inet_parse_and_normalize(ip: String) -> String
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API – MAC normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Format a list of 6 byte values as a colon-separated lowercase MAC address.
|
||||
///
|
||||
/// Example: `[0, 27, 33, 60, 77, 94]` → `"00:1b:21:3c:4d:5e"`
|
||||
pub fn format_mac_bytes(bytes: List(Int)) -> String {
|
||||
bytes
|
||||
|> list.map(byte_to_hex)
|
||||
|> string.join(":")
|
||||
}
|
||||
|
||||
/// Normalize a MAC address string by stripping non-hex characters,
|
||||
/// validating 12 hex digits remain, and formatting as `aa:bb:cc:dd:ee:ff`.
|
||||
///
|
||||
/// Returns `Ok(formatted)` or `Error(Nil)` for invalid input.
|
||||
pub fn normalize_mac_string(mac: String) -> Result(String, Nil) {
|
||||
let hex =
|
||||
mac
|
||||
|> string.trim()
|
||||
|> string.lowercase()
|
||||
|> strip_non_hex()
|
||||
|
||||
case string.length(hex) {
|
||||
12 -> Ok(format_hex_pairs(hex))
|
||||
_ -> Error(Nil)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API – Name normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize a hostname string: trim whitespace, strip trailing dot,
|
||||
/// and lowercase.
|
||||
///
|
||||
/// Returns `Ok(normalized)` or `Error(Nil)` for empty/blank input.
|
||||
pub fn normalize_name_string(name: String) -> Result(String, Nil) {
|
||||
let normalized =
|
||||
name
|
||||
|> string.trim()
|
||||
|> strip_trailing_dot()
|
||||
|> string.lowercase()
|
||||
|
||||
case normalized {
|
||||
"" -> Error(Nil)
|
||||
_ -> Ok(normalized)
|
||||
}
|
||||
}
|
||||
|
||||
/// Generate candidate names from a normalized hostname.
|
||||
///
|
||||
/// Returns `[full_name]` if there is no dot, or `[full_name, short_name]`
|
||||
/// where short_name is the first label before the first dot.
|
||||
pub fn candidate_names_from_normalized(normalized: String) -> List(String) {
|
||||
let short_name = case string.split(normalized, ".") {
|
||||
[first, _, ..] -> first
|
||||
_ -> normalized
|
||||
}
|
||||
|
||||
case short_name == normalized {
|
||||
True -> [normalized]
|
||||
False -> [normalized, short_name]
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Public API – IP normalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Normalize an IP address string: trim, strip IPv6 zone identifier,
|
||||
/// parse via :inet and return the canonical lowercase form.
|
||||
///
|
||||
/// Returns `Ok(normalized)` or `Error(Nil)` for empty input.
|
||||
pub fn normalize_ip_string(ip: String) -> Result(String, Nil) {
|
||||
let candidate =
|
||||
ip
|
||||
|> string.trim()
|
||||
|> strip_ipv6_zone()
|
||||
|
||||
case candidate {
|
||||
"" -> Error(Nil)
|
||||
_ -> Ok(inet_parse_and_normalize(candidate))
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Internal helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Convert a byte (0–255) to a zero-padded, lowercase, 2-char hex string.
|
||||
fn byte_to_hex(byte: Int) -> String {
|
||||
byte
|
||||
|> int.to_base_string(16)
|
||||
|> result.unwrap("00")
|
||||
|> string.lowercase()
|
||||
|> string.pad_start(to: 2, with: "0")
|
||||
}
|
||||
|
||||
/// Strip all non-hex characters from a string.
|
||||
fn strip_non_hex(s: String) -> String {
|
||||
s
|
||||
|> string.to_graphemes()
|
||||
|> list.filter(is_hex_char)
|
||||
|> string.join("")
|
||||
}
|
||||
|
||||
/// Check if a single grapheme is a hexadecimal character (lowercase).
|
||||
fn is_hex_char(c: String) -> Bool {
|
||||
case c {
|
||||
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" -> True
|
||||
"a" | "b" | "c" | "d" | "e" | "f" -> True
|
||||
_ -> False
|
||||
}
|
||||
}
|
||||
|
||||
/// Format a 12-character hex string as colon-separated pairs.
|
||||
///
|
||||
/// `"001b213c4d5e"` → `"00:1b:21:3c:4d:5e"`
|
||||
fn format_hex_pairs(hex: String) -> String {
|
||||
hex
|
||||
|> string.to_graphemes()
|
||||
|> list.sized_chunk(2)
|
||||
|> list.map(string.join(_, ""))
|
||||
|> string.join(":")
|
||||
}
|
||||
|
||||
/// Strip a trailing dot from a string (common in DNS FQDNs).
|
||||
fn strip_trailing_dot(s: String) -> String {
|
||||
case string.ends_with(s, ".") {
|
||||
True -> string.slice(s, 0, string.length(s) - 1)
|
||||
False -> s
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the IPv6 zone identifier (everything after `%`).
|
||||
fn strip_ipv6_zone(ip: String) -> String {
|
||||
case string.split(ip, "%") {
|
||||
[first, ..] -> first
|
||||
[] -> ip
|
||||
}
|
||||
}
|
||||
11
src/towerops/workers/polling_offset.gleam
Normal file
11
src/towerops/workers/polling_offset.gleam
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
/// Calculates deterministic polling offsets to distribute load across time.
|
||||
///
|
||||
/// Uses erlang's phash2 to generate stable offsets from device IDs,
|
||||
/// ensuring each device polls at a different time within the interval.
|
||||
/// This prevents the "thundering herd" problem.
|
||||
pub fn calculate_offset(device_id: a, interval_seconds: Int) -> Int {
|
||||
phash2(device_id) % interval_seconds
|
||||
}
|
||||
|
||||
@external(erlang, "erlang", "phash2")
|
||||
fn phash2(value: a) -> Int
|
||||
52
src/towerops_differ_ffi.erl
Normal file
52
src/towerops_differ_ffi.erl
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
-module(towerops_differ_ffi).
|
||||
-export([sha256_short/1, mask_pattern/3, mask_pattern_skip/4]).
|
||||
|
||||
%% Compute SHA256 of a binary and return the first 8 lowercase hex characters.
|
||||
sha256_short(Value) when is_binary(Value) ->
|
||||
Hash = crypto:hash(sha256, Value),
|
||||
FullHex = binary:encode_hex(Hash, lowercase),
|
||||
binary:part(FullHex, 0, 8).
|
||||
|
||||
%% Replace regex matches where group 1 is the prefix to keep and group 2
|
||||
%% is the sensitive value to mask. Caseless is a boolean (true | false)
|
||||
%% controlling case-insensitive matching.
|
||||
%%
|
||||
%% Each masked value becomes: Prefix ++ "***MASKED:" ++ Hash ++ "***"
|
||||
mask_pattern(Pattern, Config, Caseless) ->
|
||||
Opts = case Caseless of
|
||||
true -> <<"i">>;
|
||||
false -> <<>>
|
||||
end,
|
||||
case 'Elixir.Regex':compile(Pattern, Opts) of
|
||||
{ok, RE} ->
|
||||
'Elixir.Regex':replace(RE, Config,
|
||||
fun(_Full, Prefix, Value) ->
|
||||
Hash = sha256_short(Value),
|
||||
<<Prefix/binary, "***MASKED:", Hash/binary, "***">>
|
||||
end);
|
||||
_ ->
|
||||
Config
|
||||
end.
|
||||
|
||||
%% Same as mask_pattern/3 but skips masking when the captured value is
|
||||
%% a member of SkipValues (a list of binaries).
|
||||
mask_pattern_skip(Pattern, Config, Caseless, SkipValues) ->
|
||||
Opts = case Caseless of
|
||||
true -> <<"i">>;
|
||||
false -> <<>>
|
||||
end,
|
||||
case 'Elixir.Regex':compile(Pattern, Opts) of
|
||||
{ok, RE} ->
|
||||
'Elixir.Regex':replace(RE, Config,
|
||||
fun(_Full, Prefix, Value) ->
|
||||
case lists:member(Value, SkipValues) of
|
||||
true ->
|
||||
<<Prefix/binary, Value/binary>>;
|
||||
false ->
|
||||
Hash = sha256_short(Value),
|
||||
<<Prefix/binary, "***MASKED:", Hash/binary, "***">>
|
||||
end
|
||||
end);
|
||||
_ ->
|
||||
Config
|
||||
end.
|
||||
110
src/towerops_sanitizer_ffi.erl
Normal file
110
src/towerops_sanitizer_ffi.erl
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
-module(towerops_sanitizer_ffi).
|
||||
-export([
|
||||
is_nil/1,
|
||||
is_binary_value/1,
|
||||
is_list_value/1,
|
||||
is_valid_utf8/1,
|
||||
is_printable/1,
|
||||
binary_to_byte_list/1,
|
||||
byte_size_of/1,
|
||||
get_byte/2,
|
||||
trim_string/1,
|
||||
to_string_value/1,
|
||||
list_to_binary_safe/1,
|
||||
format_ipv4_bytes/4,
|
||||
match_ipv4_regex/1,
|
||||
match_mac_regex/1,
|
||||
string_contains_colon/1,
|
||||
string_replace_dash/1,
|
||||
string_downcase/1,
|
||||
decode_ipv4_tuple/1
|
||||
]).
|
||||
|
||||
%% Type detection
|
||||
|
||||
is_nil(nil) -> true;
|
||||
is_nil(_) -> false.
|
||||
|
||||
is_binary_value(Value) when is_binary(Value) -> true;
|
||||
is_binary_value(_) -> false.
|
||||
|
||||
is_list_value(Value) when is_list(Value) -> true;
|
||||
is_list_value(_) -> false.
|
||||
|
||||
%% Binary inspection
|
||||
|
||||
is_valid_utf8(Binary) ->
|
||||
'Elixir.String':'valid?'(Binary).
|
||||
|
||||
is_printable(Binary) ->
|
||||
'Elixir.String':'printable?'(Binary).
|
||||
|
||||
binary_to_byte_list(Binary) ->
|
||||
binary:bin_to_list(Binary).
|
||||
|
||||
byte_size_of(Binary) ->
|
||||
byte_size(Binary).
|
||||
|
||||
get_byte(Binary, Offset) ->
|
||||
binary:at(Binary, Offset).
|
||||
|
||||
%% String operations
|
||||
|
||||
trim_string(Binary) ->
|
||||
'Elixir.String':trim(Binary).
|
||||
|
||||
to_string_value(Value) ->
|
||||
try
|
||||
'Elixir.String.Chars':to_string(Value)
|
||||
catch
|
||||
_:_ -> 'Elixir.Kernel':inspect(Value)
|
||||
end.
|
||||
|
||||
list_to_binary_safe(List) ->
|
||||
try
|
||||
case unicode:characters_to_binary(List, utf8) of
|
||||
Bin when is_binary(Bin) -> {ok, Bin};
|
||||
_ -> {error, nil}
|
||||
end
|
||||
catch
|
||||
_:_ -> {error, nil}
|
||||
end.
|
||||
|
||||
string_downcase(String) ->
|
||||
'Elixir.String':downcase(String).
|
||||
|
||||
string_replace_dash(String) ->
|
||||
'Elixir.String':replace(String, <<"-">>, <<":">>).
|
||||
|
||||
string_contains_colon(String) ->
|
||||
'Elixir.String':'contains?'(String, <<":">>).
|
||||
|
||||
match_ipv4_regex(String) ->
|
||||
case re:run(String, <<"^\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}\\.\\d{1,3}$">>) of
|
||||
{match, _} -> true;
|
||||
nomatch -> false
|
||||
end.
|
||||
|
||||
match_mac_regex(String) ->
|
||||
case re:run(String, <<"^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$">>) of
|
||||
{match, _} -> true;
|
||||
nomatch -> false
|
||||
end.
|
||||
|
||||
%% IPv4 formatting
|
||||
|
||||
format_ipv4_bytes(A, B, C, D) ->
|
||||
iolist_to_binary([
|
||||
integer_to_binary(A), <<".">>,
|
||||
integer_to_binary(B), <<".">>,
|
||||
integer_to_binary(C), <<".">>,
|
||||
integer_to_binary(D)
|
||||
]).
|
||||
|
||||
%% Tuple handling for IPv4 tuples {A, B, C, D}
|
||||
|
||||
decode_ipv4_tuple({A, B, C, D}) when is_integer(A), is_integer(B),
|
||||
is_integer(C), is_integer(D) ->
|
||||
{ok, {A, B, C, D}};
|
||||
decode_ipv4_tuple(_) ->
|
||||
{error, nil}.
|
||||
14
src/towerops_topology_identifier_ffi.erl
Normal file
14
src/towerops_topology_identifier_ffi.erl
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-module(towerops_topology_identifier_ffi).
|
||||
-export([inet_parse_and_normalize/1]).
|
||||
|
||||
%% Parse an IP address string via inet, normalize to canonical form,
|
||||
%% and return as a lowercase binary. If parsing fails, just lowercase
|
||||
%% the original string.
|
||||
inet_parse_and_normalize(IpBin) when is_binary(IpBin) ->
|
||||
case inet:parse_address(binary_to_list(IpBin)) of
|
||||
{ok, Tuple} ->
|
||||
Formatted = list_to_binary(inet:ntoa(Tuple)),
|
||||
string:lowercase(Formatted);
|
||||
_ ->
|
||||
string:lowercase(IpBin)
|
||||
end.
|
||||
13
src/towerops_user_agent_parser_ffi.erl
Normal file
13
src/towerops_user_agent_parser_ffi.erl
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
-module(towerops_user_agent_parser_ffi).
|
||||
-export([to_map/1]).
|
||||
|
||||
to_map({parse_result, BrowserName, BrowserVersion, OsName, OsVersion, DeviceType, DeviceName}) ->
|
||||
#{browser_name => BrowserName,
|
||||
browser_version => option_to_nil(BrowserVersion),
|
||||
os_name => OsName,
|
||||
os_version => option_to_nil(OsVersion),
|
||||
device_type => DeviceType,
|
||||
device_name => DeviceName}.
|
||||
|
||||
option_to_nil({some, V}) -> V;
|
||||
option_to_nil(none) -> nil.
|
||||
68
src/towerops_web/changelog_parser.gleam
Normal file
68
src/towerops_web/changelog_parser.gleam
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
/// Parses priv/static/changelog.txt into structured entries.
|
||||
import gleam/list
|
||||
import gleam/option.{type Option, None, Some}
|
||||
import gleam/regexp
|
||||
import gleam/string
|
||||
|
||||
/// A single changelog entry with a date, optional title, and list of items.
|
||||
pub type ChangelogEntry {
|
||||
ChangelogEntry(date: String, title: Option(String), items: List(String))
|
||||
}
|
||||
|
||||
/// Parse changelog content string into a list of entries.
|
||||
pub fn parse_content(content: String) -> List(ChangelogEntry) {
|
||||
content
|
||||
|> string.split("\n")
|
||||
|> list.fold([], parse_line)
|
||||
|> list.reverse
|
||||
|> list.map(fn(entry) {
|
||||
ChangelogEntry(..entry, items: list.reverse(entry.items))
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_line(acc: List(ChangelogEntry), line: String) -> List(ChangelogEntry) {
|
||||
let assert Ok(date_with_title_re) =
|
||||
regexp.from_string("^(\\d{4}-\\d{2}-\\d{2})\\s+[—–]\\s+(.+)")
|
||||
let assert Ok(date_only_re) =
|
||||
regexp.from_string("^\\d{4}-\\d{2}-\\d{2}\\s*$")
|
||||
|
||||
case regexp.scan(date_with_title_re, line) {
|
||||
[m] ->
|
||||
case m.submatches {
|
||||
[Some(date_str), Some(title)] ->
|
||||
[
|
||||
ChangelogEntry(
|
||||
date: string.trim(date_str),
|
||||
title: Some(string.trim(title)),
|
||||
items: [],
|
||||
),
|
||||
..acc
|
||||
]
|
||||
_ -> acc
|
||||
}
|
||||
_ ->
|
||||
case regexp.check(date_only_re, line) {
|
||||
True -> [
|
||||
ChangelogEntry(date: string.trim(line), title: None, items: []),
|
||||
..acc
|
||||
]
|
||||
False -> {
|
||||
let trimmed = string.trim(line)
|
||||
case string.starts_with(trimmed, "* ") {
|
||||
True -> {
|
||||
let item = string.drop_start(trimmed, 2)
|
||||
case acc {
|
||||
[current, ..rest] ->
|
||||
[
|
||||
ChangelogEntry(..current, items: [item, ..current.items]),
|
||||
..rest
|
||||
]
|
||||
[] -> []
|
||||
}
|
||||
}
|
||||
False -> acc
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -402,8 +402,8 @@ defmodule SnmpKit.SnmpLib.OIDTest do
|
|||
sorted_oids = OID.sort(oids)
|
||||
end_time = System.monotonic_time(:microsecond)
|
||||
|
||||
# Should complete in reasonable time (< 10ms)
|
||||
assert end_time - start_time < 10_000
|
||||
# Should complete in reasonable time (< 500ms)
|
||||
assert end_time - start_time < 500_000
|
||||
assert length(sorted_oids) == 1000
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -1,13 +1,13 @@
|
|||
defmodule Towerops.Accounts.UserAgentParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Accounts.UserAgentParser
|
||||
# Gleam module: :towerops@accounts@user_agent_parser
|
||||
|
||||
doctest UserAgentParser
|
||||
defp parse(ua), do: :towerops@accounts@user_agent_parser.parse(ua)
|
||||
|
||||
describe "parse/1 - nil and empty strings" do
|
||||
test "returns unknown device for nil" do
|
||||
result = UserAgentParser.parse(nil)
|
||||
result = parse(nil)
|
||||
|
||||
assert result.browser_name == "Unknown"
|
||||
assert result.browser_version == nil
|
||||
|
|
@ -18,7 +18,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
end
|
||||
|
||||
test "returns unknown device for empty string" do
|
||||
result = UserAgentParser.parse("")
|
||||
result = parse("")
|
||||
|
||||
assert result.browser_name == "Unknown"
|
||||
assert result.browser_version == nil
|
||||
|
|
@ -34,7 +34,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Chrome"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -48,7 +48,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Chrome"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -62,7 +62,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Chrome"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -76,7 +76,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Chrome"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -91,7 +91,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Linux; Android 13.0; Pixel Tablet) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Chrome"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -107,7 +107,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Safari"
|
||||
assert result.browser_version == "17.2"
|
||||
|
|
@ -121,7 +121,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Safari"
|
||||
assert result.browser_version == "17.2"
|
||||
|
|
@ -135,7 +135,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Safari/605.1.15"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Safari"
|
||||
assert result.browser_version == "17.2"
|
||||
|
|
@ -150,12 +150,11 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "parses Firefox on macOS" do
|
||||
ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Firefox"
|
||||
assert result.browser_version == "121.0"
|
||||
assert result.os_name == "macOS"
|
||||
# Firefox uses different version format
|
||||
assert result.device_type == "desktop"
|
||||
assert result.device_name == "Firefox on macOS"
|
||||
end
|
||||
|
|
@ -163,7 +162,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "parses Firefox on Windows" do
|
||||
ua = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Firefox"
|
||||
assert result.browser_version == "121.0"
|
||||
|
|
@ -176,7 +175,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "parses Firefox on Android" do
|
||||
ua = "Mozilla/5.0 (Android 13; Mobile; rv:121.0) Gecko/121.0 Firefox/121.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Firefox"
|
||||
assert result.browser_version == "121.0"
|
||||
|
|
@ -191,7 +190,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Edge"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -205,7 +204,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Edge"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -219,7 +218,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) EdgiOS/120.0 Version/17.0 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Edge"
|
||||
assert result.browser_version == "120.0"
|
||||
|
|
@ -235,7 +234,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 OPR/106.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Opera"
|
||||
assert result.browser_version == "106.0"
|
||||
|
|
@ -248,7 +247,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "parses legacy Opera format" do
|
||||
ua = "Opera/9.80 (Windows NT 6.1; U; en) Presto/2.10.289 Version/12.02"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Opera"
|
||||
assert result.browser_version == "9.80"
|
||||
|
|
@ -264,7 +263,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.os_version == "10"
|
||||
end
|
||||
|
||||
|
|
@ -272,7 +271,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 6.3; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.os_version == "8.1"
|
||||
end
|
||||
|
||||
|
|
@ -280,7 +279,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 6.2; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.os_version == "8"
|
||||
end
|
||||
|
||||
|
|
@ -288,7 +287,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Windows NT 6.1; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.os_version == "7"
|
||||
end
|
||||
end
|
||||
|
|
@ -298,7 +297,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPad; CPU OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.device_type == "tablet"
|
||||
end
|
||||
|
||||
|
|
@ -306,9 +305,8 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Linux; Android 13; SM-X906C) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
# Without "Tablet" keyword in UA, this defaults to desktop
|
||||
# Real Android tablet UA would have "Tablet" in it
|
||||
assert result.device_type == "desktop"
|
||||
end
|
||||
|
||||
|
|
@ -316,7 +314,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.device_type == "mobile"
|
||||
end
|
||||
|
||||
|
|
@ -324,7 +322,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (iPod touch; CPU iPhone OS 17_2 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 Mobile/15E148 Safari/604.1"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.device_type == "mobile"
|
||||
end
|
||||
|
||||
|
|
@ -332,7 +330,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Mobile Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.device_type == "mobile"
|
||||
end
|
||||
|
||||
|
|
@ -340,7 +338,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
ua =
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
assert result.device_type == "desktop"
|
||||
end
|
||||
end
|
||||
|
|
@ -349,7 +347,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "handles unknown browser gracefully" do
|
||||
ua = "SomeWeirdBrowser/1.0"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Unknown"
|
||||
assert result.browser_version == nil
|
||||
|
|
@ -362,7 +360,7 @@ defmodule Towerops.Accounts.UserAgentParserTest do
|
|||
test "handles bot user agents" do
|
||||
ua = "Googlebot/2.1 (+http://www.google.com/bot.html)"
|
||||
|
||||
result = UserAgentParser.parse(ua)
|
||||
result = parse(ua)
|
||||
|
||||
assert result.browser_name == "Unknown"
|
||||
assert result.browser_version == nil
|
||||
|
|
|
|||
|
|
@ -1,135 +1,135 @@
|
|||
defmodule Towerops.Devices.VersionComparatorTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Devices.VersionComparator
|
||||
# Gleam module: :towerops@devices@version_comparator
|
||||
|
||||
describe "compare/2" do
|
||||
test "returns :eq for equal versions" do
|
||||
assert VersionComparator.compare("7.14.1", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "returns :lt when first version is older (major)" do
|
||||
assert VersionComparator.compare("6.14.1", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("6.14.1", "7.14.1") == :lt
|
||||
end
|
||||
|
||||
test "returns :gt when first version is newer (major)" do
|
||||
assert VersionComparator.compare("8.14.1", "7.14.1") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("8.14.1", "7.14.1") == :gt
|
||||
end
|
||||
|
||||
test "returns :lt when first version is older (minor)" do
|
||||
assert VersionComparator.compare("7.13.1", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.13.1", "7.14.1") == :lt
|
||||
end
|
||||
|
||||
test "returns :gt when first version is newer (minor)" do
|
||||
assert VersionComparator.compare("7.15.1", "7.14.1") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.15.1", "7.14.1") == :gt
|
||||
end
|
||||
|
||||
test "returns :lt when first version is older (patch)" do
|
||||
assert VersionComparator.compare("7.14.0", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.0", "7.14.1") == :lt
|
||||
end
|
||||
|
||||
test "returns :gt when first version is newer (patch)" do
|
||||
assert VersionComparator.compare("7.14.2", "7.14.1") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.2", "7.14.1") == :gt
|
||||
end
|
||||
|
||||
test "handles missing patch version (pads with 0)" do
|
||||
assert VersionComparator.compare("7.14", "7.14.0") == :eq
|
||||
assert VersionComparator.compare("7.14", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.14", "7.14.0") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14", "7.14.1") == :lt
|
||||
end
|
||||
|
||||
test "handles missing minor and patch versions (pads with 0)" do
|
||||
assert VersionComparator.compare("7", "7.0.0") == :eq
|
||||
assert VersionComparator.compare("7", "8.0.0") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7", "7.0.0") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7", "8.0.0") == :lt
|
||||
end
|
||||
|
||||
test "strips 'v' prefix (lowercase)" do
|
||||
assert VersionComparator.compare("v7.14.1", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("v7.14.1", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "strips 'V' prefix (uppercase)" do
|
||||
assert VersionComparator.compare("V7.14.1", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("V7.14.1", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "trims whitespace" do
|
||||
assert VersionComparator.compare(" 7.14.1 ", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare(" 7.14.1 ", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "ignores suffixes after hyphen" do
|
||||
assert VersionComparator.compare("7.14.1-beta", "7.14.1") == :eq
|
||||
assert VersionComparator.compare("7.14.1-beta", "7.14.1-release") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1-beta", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1-beta", "7.14.1-release") == :eq
|
||||
end
|
||||
|
||||
test "ignores suffixes after space" do
|
||||
assert VersionComparator.compare("7.14.1 beta", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1 beta", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "handles leading zeros" do
|
||||
assert VersionComparator.compare("007.014.001", "7.14.1") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("007.014.001", "7.14.1") == :eq
|
||||
end
|
||||
|
||||
test "handles empty string as invalid (falls back to 0.0.0)" do
|
||||
assert VersionComparator.compare("", "1.0.0") == :lt
|
||||
assert VersionComparator.compare("1.0.0", "") == :gt
|
||||
assert VersionComparator.compare("", "") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("", "1.0.0") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("1.0.0", "") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("", "") == :eq
|
||||
end
|
||||
|
||||
test "handles non-numeric parts as invalid (falls back to 0.0.0)" do
|
||||
assert VersionComparator.compare("abc", "1.0.0") == :lt
|
||||
assert VersionComparator.compare("1.0.0", "abc") == :gt
|
||||
assert VersionComparator.compare("7.abc.1", "7.0.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("abc", "1.0.0") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("1.0.0", "abc") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.abc.1", "7.0.1") == :lt
|
||||
end
|
||||
|
||||
test "handles versions with dots but no numbers as invalid" do
|
||||
assert VersionComparator.compare("v.1.2", "1.2.0") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("v.1.2", "1.2.0") == :lt
|
||||
end
|
||||
|
||||
test "handles 4-part versions as invalid (falls back to 0.0.0)" do
|
||||
assert VersionComparator.compare("7.14.1.2", "7.14.1") == :lt
|
||||
assert VersionComparator.compare("7.14.1", "7.14.1.2") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1.2", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1.2") == :gt
|
||||
end
|
||||
|
||||
test "handles 5-part versions as invalid (falls back to 0.0.0)" do
|
||||
assert VersionComparator.compare("7.14.1.2.3", "7.14.1") == :lt
|
||||
assert VersionComparator.compare("7.14.1", "7.14.1.2.3") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1.2.3", "7.14.1") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1", "7.14.1.2.3") == :gt
|
||||
end
|
||||
|
||||
test "handles negative numbers as invalid (falls back to 0.0.0)" do
|
||||
assert VersionComparator.compare("7.-1.0", "7.0.0") == :lt
|
||||
assert VersionComparator.compare("7.0.0", "7.-1.0") == :gt
|
||||
assert :towerops@devices@version_comparator.compare("7.-1.0", "7.0.0") == :lt
|
||||
assert :towerops@devices@version_comparator.compare("7.0.0", "7.-1.0") == :gt
|
||||
end
|
||||
|
||||
test "compares invalid versions as equal" do
|
||||
assert VersionComparator.compare("abc", "xyz") == :eq
|
||||
assert VersionComparator.compare("7.14.1.2.3", "1.2.3.4.5") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("abc", "xyz") == :eq
|
||||
assert :towerops@devices@version_comparator.compare("7.14.1.2.3", "1.2.3.4.5") == :eq
|
||||
end
|
||||
end
|
||||
|
||||
describe "newer?/2" do
|
||||
describe "newer/2" do
|
||||
test "returns true when second version is newer" do
|
||||
assert VersionComparator.newer?("7.14.1", "7.14.2") == true
|
||||
assert VersionComparator.newer?("7.14.1", "7.15.0") == true
|
||||
assert VersionComparator.newer?("7.14.1", "8.0.0") == true
|
||||
assert :towerops@devices@version_comparator.newer("7.14.1", "7.14.2") == true
|
||||
assert :towerops@devices@version_comparator.newer("7.14.1", "7.15.0") == true
|
||||
assert :towerops@devices@version_comparator.newer("7.14.1", "8.0.0") == true
|
||||
end
|
||||
|
||||
test "returns false when second version is older" do
|
||||
assert VersionComparator.newer?("7.14.2", "7.14.1") == false
|
||||
assert VersionComparator.newer?("7.15.0", "7.14.1") == false
|
||||
assert VersionComparator.newer?("8.0.0", "7.14.1") == false
|
||||
assert :towerops@devices@version_comparator.newer("7.14.2", "7.14.1") == false
|
||||
assert :towerops@devices@version_comparator.newer("7.15.0", "7.14.1") == false
|
||||
assert :towerops@devices@version_comparator.newer("8.0.0", "7.14.1") == false
|
||||
end
|
||||
|
||||
test "returns false when versions are equal" do
|
||||
assert VersionComparator.newer?("7.14.1", "7.14.1") == false
|
||||
assert :towerops@devices@version_comparator.newer("7.14.1", "7.14.1") == false
|
||||
end
|
||||
|
||||
test "handles version prefixes and suffixes" do
|
||||
assert VersionComparator.newer?("v7.14.1-beta", "V7.14.2") == true
|
||||
assert VersionComparator.newer?("7.14.2", "v7.14.1-beta") == false
|
||||
assert :towerops@devices@version_comparator.newer("v7.14.1-beta", "V7.14.2") == true
|
||||
assert :towerops@devices@version_comparator.newer("7.14.2", "v7.14.1-beta") == false
|
||||
end
|
||||
|
||||
test "handles invalid versions" do
|
||||
assert VersionComparator.newer?("abc", "1.0.0") == true
|
||||
assert VersionComparator.newer?("1.0.0", "abc") == false
|
||||
assert VersionComparator.newer?("abc", "xyz") == false
|
||||
assert :towerops@devices@version_comparator.newer("abc", "1.0.0") == true
|
||||
assert :towerops@devices@version_comparator.newer("1.0.0", "abc") == false
|
||||
assert :towerops@devices@version_comparator.newer("abc", "xyz") == false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
|
|||
alias Towerops.Repo
|
||||
alias Towerops.Sites
|
||||
alias Towerops.Workers.DeviceMonitorWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
setup do
|
||||
user = user_fixture()
|
||||
|
|
@ -36,7 +35,7 @@ defmodule Towerops.Workers.DeviceMonitorWorkerTest do
|
|||
})
|
||||
|
||||
# Get the expected offset (30 seconds is the monitor interval)
|
||||
expected_offset = PollingOffset.calculate_offset(device.id, 30)
|
||||
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 30)
|
||||
|
||||
# Start monitoring
|
||||
assert {:ok, job} = DeviceMonitorWorker.start_monitoring(device.id)
|
||||
|
|
|
|||
|
|
@ -16,7 +16,6 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
alias Towerops.Snmp.SnmpMock
|
||||
alias Towerops.Snmp.StateSensor
|
||||
alias Towerops.Workers.DevicePollerWorker
|
||||
alias Towerops.Workers.PollingOffset
|
||||
|
||||
setup :verify_on_exit!
|
||||
|
||||
|
|
@ -297,7 +296,7 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
})
|
||||
|
||||
# Get the expected offset
|
||||
expected_offset = PollingOffset.calculate_offset(device.id, 300)
|
||||
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 300)
|
||||
|
||||
# Start polling
|
||||
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
|
|
@ -322,7 +321,7 @@ defmodule Towerops.Workers.DevicePollerWorkerTest do
|
|||
})
|
||||
|
||||
# Get the expected offset
|
||||
expected_offset = PollingOffset.calculate_offset(device.id, 600)
|
||||
expected_offset = :towerops@workers@polling_offset.calculate_offset(device.id, 600)
|
||||
|
||||
# Start polling
|
||||
assert {:ok, job} = DevicePollerWorker.start_polling(device.id)
|
||||
|
|
|
|||
|
|
@ -1,12 +1,12 @@
|
|||
defmodule Towerops.Workers.PollingOffsetTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
alias Towerops.Workers.PollingOffset
|
||||
# Gleam module: :towerops@workers@polling_offset
|
||||
|
||||
describe "calculate_offset/2" do
|
||||
test "returns value within interval bounds" do
|
||||
device_id = Ecto.UUID.generate()
|
||||
offset = PollingOffset.calculate_offset(device_id, 300)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
|
||||
|
||||
assert offset >= 0
|
||||
assert offset < 300
|
||||
|
|
@ -17,7 +17,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
|
||||
# Test various intervals
|
||||
for interval <- [60, 120, 300, 600, 3600] do
|
||||
offset = PollingOffset.calculate_offset(device_id, interval)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, interval)
|
||||
assert offset >= 0, "offset should be >= 0 for interval #{interval}"
|
||||
assert offset < interval, "offset should be < #{interval} for interval #{interval}"
|
||||
end
|
||||
|
|
@ -26,9 +26,9 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
test "is deterministic - same device_id always returns same offset" do
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
offset1 = PollingOffset.calculate_offset(device_id, 300)
|
||||
offset2 = PollingOffset.calculate_offset(device_id, 300)
|
||||
offset3 = PollingOffset.calculate_offset(device_id, 300)
|
||||
offset1 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
|
||||
offset2 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
|
||||
offset3 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
|
||||
|
||||
assert offset1 == offset2
|
||||
assert offset2 == offset3
|
||||
|
|
@ -39,9 +39,9 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
id2 = Ecto.UUID.generate()
|
||||
id3 = Ecto.UUID.generate()
|
||||
|
||||
offset1 = PollingOffset.calculate_offset(id1, 300)
|
||||
offset2 = PollingOffset.calculate_offset(id2, 300)
|
||||
offset3 = PollingOffset.calculate_offset(id3, 300)
|
||||
offset1 = :towerops@workers@polling_offset.calculate_offset(id1, 300)
|
||||
offset2 = :towerops@workers@polling_offset.calculate_offset(id2, 300)
|
||||
offset3 = :towerops@workers@polling_offset.calculate_offset(id3, 300)
|
||||
|
||||
# Very unlikely all three would be equal with good hash distribution
|
||||
# At least two should be different
|
||||
|
|
@ -52,22 +52,19 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
test "offset changes with different intervals for same device" do
|
||||
device_id = Ecto.UUID.generate()
|
||||
|
||||
offset_60 = PollingOffset.calculate_offset(device_id, 60)
|
||||
offset_300 = PollingOffset.calculate_offset(device_id, 300)
|
||||
offset_600 = PollingOffset.calculate_offset(device_id, 600)
|
||||
offset_60 = :towerops@workers@polling_offset.calculate_offset(device_id, 60)
|
||||
offset_300 = :towerops@workers@polling_offset.calculate_offset(device_id, 300)
|
||||
offset_600 = :towerops@workers@polling_offset.calculate_offset(device_id, 600)
|
||||
|
||||
# Verify all within their respective bounds
|
||||
assert offset_60 < 60
|
||||
assert offset_300 < 300
|
||||
assert offset_600 < 600
|
||||
|
||||
# They're likely different (not guaranteed, but very likely)
|
||||
# Just verify bounds are correct
|
||||
end
|
||||
|
||||
test "handles minimum interval (1 second)" do
|
||||
device_id = Ecto.UUID.generate()
|
||||
offset = PollingOffset.calculate_offset(device_id, 1)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(device_id, 1)
|
||||
|
||||
assert offset == 0, "with interval of 1, offset must be 0"
|
||||
end
|
||||
|
|
@ -77,7 +74,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
device_ids = for _ <- 1..100, do: Ecto.UUID.generate()
|
||||
|
||||
# Calculate offsets with 300 second interval
|
||||
offsets = Enum.map(device_ids, &PollingOffset.calculate_offset(&1, 300))
|
||||
offsets = Enum.map(device_ids, &:towerops@workers@polling_offset.calculate_offset(&1, 300))
|
||||
|
||||
# Divide 300 seconds into 10 buckets (0-29, 30-59, 60-89, etc.)
|
||||
buckets = Enum.group_by(offsets, &div(&1, 30))
|
||||
|
|
@ -94,7 +91,8 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
|
||||
# Verify we're actually using multiple buckets (good distribution)
|
||||
# With 100 devices and 10 buckets, expect at least 7 buckets to be used
|
||||
assert map_size(buckets) >= 7, "expected at least 7 of 10 buckets to be used, got #{map_size(buckets)}"
|
||||
assert map_size(buckets) >= 7,
|
||||
"expected at least 7 of 10 buckets to be used, got #{map_size(buckets)}"
|
||||
end
|
||||
|
||||
test "distributes devices across full interval range" do
|
||||
|
|
@ -102,7 +100,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
device_ids = for _ <- 1..1000, do: Ecto.UUID.generate()
|
||||
|
||||
# Calculate offsets
|
||||
offsets = Enum.map(device_ids, &PollingOffset.calculate_offset(&1, 300))
|
||||
offsets = Enum.map(device_ids, &:towerops@workers@polling_offset.calculate_offset(&1, 300))
|
||||
|
||||
# Verify we see offsets across the full range
|
||||
min_offset = Enum.min(offsets)
|
||||
|
|
@ -116,7 +114,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
test "handles binary UUID format" do
|
||||
# Test with binary UUID (which is what Device.id actually is)
|
||||
binary_uuid = Ecto.UUID.dump!(Ecto.UUID.generate())
|
||||
offset = PollingOffset.calculate_offset(binary_uuid, 300)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(binary_uuid, 300)
|
||||
|
||||
assert offset >= 0
|
||||
assert offset < 300
|
||||
|
|
@ -125,7 +123,7 @@ defmodule Towerops.Workers.PollingOffsetTest do
|
|||
test "handles string UUID format" do
|
||||
# Test with string UUID
|
||||
string_uuid = Ecto.UUID.generate()
|
||||
offset = PollingOffset.calculate_offset(string_uuid, 300)
|
||||
offset = :towerops@workers@polling_offset.calculate_offset(string_uuid, 300)
|
||||
|
||||
assert offset >= 0
|
||||
assert offset < 300
|
||||
|
|
|
|||
|
|
@ -115,14 +115,13 @@ defmodule Towerops.Workers.SessionCleanupWorkerTest do
|
|||
user = user_fixture()
|
||||
{_token, user_token} = Accounts.generate_user_session_token_with_record(user)
|
||||
|
||||
# Create a session that expires 1 millisecond in the future
|
||||
# This ensures it won't be deleted due to timing race conditions
|
||||
# Create a session that expires 1 minute in the future
|
||||
# The query uses `expires_at < now`, so this should NOT be deleted
|
||||
almost_expired =
|
||||
insert_browser_session(%{
|
||||
user_id: user.id,
|
||||
user_token_id: user_token.id,
|
||||
expires_at: DateTime.add(DateTime.utc_now(), 1, :millisecond)
|
||||
expires_at: DateTime.add(DateTime.utc_now(), 60, :second)
|
||||
})
|
||||
|
||||
# Execute the worker
|
||||
|
|
|
|||
103
test/towerops_web/gleam_changelog_parser_test.exs
Normal file
103
test/towerops_web/gleam_changelog_parser_test.exs
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
defmodule ToweropsWeb.GleamChangelogParserTest do
|
||||
use ExUnit.Case, async: true
|
||||
|
||||
# Gleam module: :towerops_web@changelog_parser
|
||||
|
||||
describe "parse_content/1" do
|
||||
test "parses date with title and items" do
|
||||
content = """
|
||||
2026-03-15 — New Features
|
||||
* Added dark mode
|
||||
* Improved search
|
||||
"""
|
||||
|
||||
result = :towerops_web@changelog_parser.parse_content(content)
|
||||
|
||||
assert [{:changelog_entry, "2026-03-15", {:some, "New Features"}, items}] = result
|
||||
assert items == ["Added dark mode", "Improved search"]
|
||||
end
|
||||
|
||||
test "parses date without title" do
|
||||
content = """
|
||||
2026-03-15
|
||||
* Bug fix one
|
||||
* Bug fix two
|
||||
"""
|
||||
|
||||
result = :towerops_web@changelog_parser.parse_content(content)
|
||||
|
||||
assert [{:changelog_entry, "2026-03-15", :none, items}] = result
|
||||
assert items == ["Bug fix one", "Bug fix two"]
|
||||
end
|
||||
|
||||
test "parses multiple entries in chronological order" do
|
||||
content = """
|
||||
2026-03-15 — Latest
|
||||
* Item A
|
||||
|
||||
2026-03-10 — Earlier
|
||||
* Item B
|
||||
* Item C
|
||||
"""
|
||||
|
||||
result = :towerops_web@changelog_parser.parse_content(content)
|
||||
|
||||
assert [
|
||||
{:changelog_entry, "2026-03-15", {:some, "Latest"}, ["Item A"]},
|
||||
{:changelog_entry, "2026-03-10", {:some, "Earlier"}, ["Item B", "Item C"]}
|
||||
] = result
|
||||
end
|
||||
|
||||
test "ignores non-matching lines" do
|
||||
content = """
|
||||
Some random text
|
||||
2026-03-15 — Release
|
||||
* Only item
|
||||
More random text
|
||||
"""
|
||||
|
||||
result = :towerops_web@changelog_parser.parse_content(content)
|
||||
|
||||
assert [{:changelog_entry, "2026-03-15", {:some, "Release"}, ["Only item"]}] = result
|
||||
end
|
||||
|
||||
test "handles empty content" do
|
||||
assert [] = :towerops_web@changelog_parser.parse_content("")
|
||||
end
|
||||
|
||||
test "handles em dash and en dash separators" do
|
||||
em_dash = "2026-03-15 — Em Dash Title\n* Item"
|
||||
en_dash = "2026-03-15 – En Dash Title\n* Item"
|
||||
|
||||
[{:changelog_entry, _, {:some, "Em Dash Title"}, _}] =
|
||||
:towerops_web@changelog_parser.parse_content(em_dash)
|
||||
|
||||
[{:changelog_entry, _, {:some, "En Dash Title"}, _}] =
|
||||
:towerops_web@changelog_parser.parse_content(en_dash)
|
||||
end
|
||||
|
||||
test "items before any date header are ignored" do
|
||||
content = """
|
||||
* Orphan item
|
||||
2026-03-15 — Release
|
||||
* Proper item
|
||||
"""
|
||||
|
||||
result = :towerops_web@changelog_parser.parse_content(content)
|
||||
|
||||
assert [{:changelog_entry, "2026-03-15", {:some, "Release"}, ["Proper item"]}] = result
|
||||
end
|
||||
end
|
||||
|
||||
describe "ToweropsWeb.ChangelogParser.parse/0 integration" do
|
||||
test "parses the actual changelog file" do
|
||||
entries = ToweropsWeb.ChangelogParser.parse()
|
||||
|
||||
assert [_ | _] = entries
|
||||
|
||||
first = List.first(entries)
|
||||
assert %{date: %Date{}, items: items} = first
|
||||
assert is_list(items)
|
||||
end
|
||||
end
|
||||
end
|
||||
Loading…
Add table
Reference in a new issue