towerops/lib/snmpkit/snmp_lib/host_parser.ex
Graham McIntire d1403c8069 Fix failing tests and clean up code
- Fix doctests for Accounts, Agents.Stats, Snmp to match actual behavior
- Fix dynamic_extra_test vendor post-processing tests to seed sensor data
- Fix activity_controller_test to seed devices for feed data
- Fix session_manager_test to create browser session for test
- Fix topology_test link creation for connection test
- Fix device_monitor/driver_worker tests for unique job constraints
- Fix accounts_test expired_tokens assertion (magic link token is expired)
- Fix happy_path_test and show_events_test to seed monitor data
- Fix admin user_live_test user.name -> user.email (no name field)
- Fix schema_test to seed activity data
- Fix mobile_qr_live_test to match actual template text
- Fix SnmpKit.MIB doctests and tests for enriched return values
- Fix onboarding_live, mobile_controller, mib_test weak assertions
- Remove dead code and fix credo warnings
2026-06-16 14:54:34 -05:00

444 lines
12 KiB
Elixir

defmodule SnmpKit.SnmpLib.HostParser do
@moduledoc """
Comprehensive host and port parsing for all possible input formats.
This module handles parsing of host and port information from any conceivable
input format and returns exactly what gen_udp needs: an IP tuple and integer port.
## Supported Input Formats
### IPv4
- Tuples: `{192, 168, 1, 1}`, `{{192, 168, 1, 1}, 161}`
- Strings: `"192.168.1.1"`, `"192.168.1.1:161"`
- Charlists: `'192.168.1.1'`, `'192.168.1.1:161'`
- Hostnames: `"router.local"`, `"router.local:161"`
### IPv6
- Tuples: `{0x2001, 0xdb8, 0, 0, 0, 0, 0, 1}`, `{{0x2001, 0xdb8, 0, 0, 0, 0, 0, 1}, 161}`
- Strings: `"2001:db8::1"`, `"[2001:db8::1]:161"`
- Charlists: `'2001:db8::1'`, `'[2001:db8::1]:161'`
- Compressed: `"::1"`, `"[::1]:161"`
### Mixed Formats
- Maps: `%{host: "192.168.1.1", port: 161}`
- Keyword lists: `[host: "192.168.1.1", port: 161]`
## Returns
`{:ok, {ip_tuple, port}}` where:
- `ip_tuple` is a 4-tuple for IPv4 or 8-tuple for IPv6
- `port` is an integer between 1-65535
`{:error, reason}` for invalid input
"""
@type ip4_tuple :: {0..255, 0..255, 0..255, 0..255}
@type ip6_tuple ::
{0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535, 0..65_535}
@type ip_tuple :: ip4_tuple() | ip6_tuple()
@type port_number :: 1..65_535
@type parse_result :: {:ok, {ip_tuple(), port_number()}} | {:error, atom()}
@default_port 161
@doc """
Parse host and port from any input format.
## Examples
# IPv4 tuples
iex> SnmpKit.SnmpLib.HostParser.parse({192, 168, 1, 1})
{:ok, {{192, 168, 1, 1}, 161}}
iex> SnmpKit.SnmpLib.HostParser.parse({{192, 168, 1, 1}, 8161})
{:ok, {{192, 168, 1, 1}, 8161}}
# IPv4 strings
iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1")
{:ok, {{192, 168, 1, 1}, 161}}
iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1:8161")
{:ok, {{192, 168, 1, 1}, 8161}}
# IPv4 charlists
iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1")
{:ok, {{192, 168, 1, 1}, 161}}
iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1:8161")
{:ok, {{192, 168, 1, 1}, 8161}}
# IPv6 strings
iex> SnmpKit.SnmpLib.HostParser.parse("::1")
{:ok, {{0, 0, 0, 0, 0, 0, 0, 1}, 161}}
iex> SnmpKit.SnmpLib.HostParser.parse("[::1]:8161")
{:ok, {{0, 0, 0, 0, 0, 0, 0, 1}, 8161}}
# Error cases
iex> SnmpKit.SnmpLib.HostParser.parse("invalid")
{:error, :hostname_resolution_failed}
iex> SnmpKit.SnmpLib.HostParser.parse("192.168.1.1:99999")
{:error, :invalid_port}
"""
@spec parse(any()) :: parse_result()
def parse(input, default_port \\ @default_port)
# IPv4/IPv6 tuple without port
def parse({a, b, c, d} = ip_tuple, default_port)
when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do
case validate_ipv4_tuple(ip_tuple) do
:ok -> {:ok, {ip_tuple, default_port}}
error -> error
end
end
# IPv6 tuple without port
# credo:disable-for-next-line Credo.Check.Refactor.CyclomaticComplexity
def parse({a, b, c, d, e, f, g, h} = ip_tuple, default_port)
when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(e) and is_integer(f) and
is_integer(g) and is_integer(h) do
case validate_ipv6_tuple(ip_tuple) do
:ok -> {:ok, {ip_tuple, default_port}}
error -> error
end
end
# Tuple with port: {ip_tuple, port}
def parse({{a, b, c, d} = ip_tuple, port}, _default_port)
when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(port) do
with :ok <- validate_ipv4_tuple(ip_tuple),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
end
end
# IPv6 tuple with port: {{ip6_tuple}, port}
def parse({{a, b, c, d, e, f, g, h} = ip_tuple, port}, _default_port)
when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) and is_integer(e) and is_integer(f) and
is_integer(g) and is_integer(h) and is_integer(port) do
with :ok <- validate_ipv6_tuple(ip_tuple),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
end
end
# String input
def parse(input, default_port) when is_binary(input) do
parse_string(input, default_port)
end
# Keyword list input: [host: ..., port: ...]
def parse([_ | _] = input, default_port) do
if Keyword.keyword?(input) do
parse_keyword(input, default_port)
else
parse_charlist(input, default_port)
end
end
# Map input: %{host: ..., port: ...}
def parse(%{host: host} = map, default_port) do
port = Map.get(map, :port, default_port)
with {:ok, {ip_tuple, _}} <- parse(host, default_port),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
end
end
# Charlist input
def parse(input, default_port) when is_list(input) do
# Validate charlist contains only valid ASCII/UTF-8 characters
if valid_charlist?(input) do
try do
string_input = List.to_string(input)
parse_string(string_input, default_port)
rescue
# Not a valid charlist
_ -> {:error, :invalid_charlist}
end
else
{:error, :invalid_charlist}
end
end
# Atom input (for hostnames like :localhost)
def parse(input, default_port) when is_atom(input) do
parse_string(Atom.to_string(input), default_port)
end
# Invalid input
def parse(_input, _default_port) do
{:error, :unsupported_format}
end
defp parse_keyword(input, default_port) do
host = Keyword.get(input, :host)
port = Keyword.get(input, :port, default_port)
if host do
with {:ok, {ip_tuple, _}} <- parse(host, default_port),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
end
else
{:error, :missing_host}
end
end
# Private helper functions
defp parse_string(input, default_port) do
input = String.trim(input)
cond do
# IPv6 with port: [::1]:8161
String.starts_with?(input, "[") and String.contains?(input, "]:") ->
parse_ipv6_with_port(input)
# IPv4 with port: 192.168.1.1:8161
String.contains?(input, ":") and not String.contains?(input, "::") ->
parse_ipv4_with_port(input, default_port)
# IPv6 without port or IPv4 without port
true ->
parse_ip_without_port(input, default_port)
end
end
defp parse_charlist(input, default_port) do
string_input = List.to_string(input)
parse_string(string_input, default_port)
rescue
_ -> {:error, :invalid_charlist}
end
defp parse_ipv6_with_port(input) do
# Format: [2001:db8::1]:8161
case Regex.run(~r/^\[([^\]]+)\]:(\d+)$/, input) do
[_full, ipv6_str, port_str] ->
with {:ok, ip_tuple} <- parse_ipv6_address(ipv6_str),
{port, ""} <- Integer.parse(port_str),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
else
_ -> {:error, :invalid_ipv6_with_port}
end
nil ->
{:error, :invalid_ipv6_with_port}
end
end
defp parse_ipv4_with_port(input, default_port) do
# Handle multiple colons (might be IPv6)
parts = String.split(input, ":")
cond do
length(parts) == 2 ->
# Likely IPv4:port
[host_part, port_part] = parts
parse_host_with_port(host_part, port_part, input, default_port)
length(parts) > 2 ->
# Definitely IPv6
parse_ip_without_port(input, default_port)
true ->
{:error, :invalid_format}
end
end
defp parse_host_with_port(host_part, port_part, input, default_port) do
case Integer.parse(port_part) do
{port, ""} ->
with {:ok, ip_tuple} <- parse_ipv4_address(host_part),
:ok <- validate_port(port) do
{:ok, {ip_tuple, port}}
end
_ ->
# Port part is not a number, might be IPv6
parse_ip_without_port(input, default_port)
end
end
defp parse_ip_without_port(input, default_port) do
parser =
cond do
String.contains?(input, ".") -> &parse_ipv4_address/1
String.contains?(input, ":") -> &parse_ipv6_address/1
true -> &resolve_hostname/1
end
case parser.(input) do
{:ok, ip_tuple} -> {:ok, {ip_tuple, default_port}}
error -> error
end
end
defp parse_ipv4_address(input) do
charlist_input = String.to_charlist(input)
case :inet.parse_address(charlist_input) do
{:ok, ip_tuple} ->
case validate_ipv4_tuple(ip_tuple) do
:ok -> {:ok, ip_tuple}
error -> error
end
{:error, _} ->
{:error, :invalid_ipv4}
end
end
defp parse_ipv6_address(input) do
charlist_input = String.to_charlist(input)
case :inet.parse_address(charlist_input) do
{:ok, ip_tuple} ->
case validate_ipv6_tuple(ip_tuple) do
:ok -> {:ok, ip_tuple}
error -> error
end
{:error, _} ->
{:error, :invalid_ipv6}
end
end
defp resolve_hostname(hostname) do
charlist_hostname = String.to_charlist(hostname)
case :inet.gethostbyname(charlist_hostname) do
{:ok, {:hostent, _name, _aliases, :inet, 4, [ip_tuple | _]}} ->
{:ok, ip_tuple}
{:ok, {:hostent, _name, _aliases, :inet6, 16, [ip_tuple | _]}} ->
{:ok, ip_tuple}
{:error, _reason} ->
{:error, :hostname_resolution_failed}
end
end
defp validate_ipv4_tuple({a, b, c, d}) do
if valid_ipv4_octet?(a) and valid_ipv4_octet?(b) and
valid_ipv4_octet?(c) and valid_ipv4_octet?(d) do
:ok
else
{:error, :invalid_ipv4_tuple}
end
end
defp validate_ipv4_tuple(_), do: {:error, :invalid_ipv4_tuple}
defp valid_ipv4_octet?(n), do: is_integer(n) and n >= 0 and n <= 255
defp validate_ipv6_tuple({a, b, c, d, e, f, g, h}) do
octets = [a, b, c, d, e, f, g, h]
if Enum.all?(octets, &valid_ipv6_segment?/1) do
:ok
else
{:error, :invalid_ipv6_tuple}
end
end
defp validate_ipv6_tuple(_), do: {:error, :invalid_ipv6_tuple}
defp valid_ipv6_segment?(n), do: is_integer(n) and n >= 0 and n <= 65_535
defp validate_port(port) when is_integer(port) and port >= 1 and port <= 65_535, do: :ok
defp validate_port(_), do: {:error, :invalid_port}
# Validate charlist contains only valid characters (0-255 for bytes)
defp valid_charlist?(charlist) do
# Additional check: ensure it would create valid UTF-8 when converted to string
Enum.all?(charlist, fn char ->
is_integer(char) and char >= 0 and char <= 255
end) and
try do
_string = List.to_string(charlist)
true
rescue
_ -> false
end
end
@doc """
Quick validation function to check if input can be parsed.
## Examples
iex> SnmpKit.SnmpLib.HostParser.valid?("192.168.1.1")
true
iex> SnmpKit.SnmpLib.HostParser.valid?("invalid")
false
"""
@spec valid?(any()) :: boolean()
def valid?(input) do
case parse(input) do
{:ok, _} -> true
{:error, _} -> false
end
end
@doc """
Parse and return only the IP tuple, using default port.
## Examples
iex> SnmpKit.SnmpLib.HostParser.parse_ip("192.168.1.1")
{:ok, {192, 168, 1, 1}}
"""
@spec parse_ip(any()) :: {:ok, ip_tuple()} | {:error, atom()}
def parse_ip(input) do
case parse(input) do
{:ok, {ip_tuple, _port}} -> {:ok, ip_tuple}
error -> error
end
end
@doc """
Parse and return only the port, using 161 as default.
## Examples
iex> SnmpKit.SnmpLib.HostParser.parse_port("192.168.1.1:8161")
{:ok, 8161}
iex> SnmpKit.SnmpLib.HostParser.parse_port("192.168.1.1")
{:ok, 161}
"""
@spec parse_port(any()) :: {:ok, port_number()} | {:error, atom()}
def parse_port(input) do
case parse(input) do
{:ok, {_ip_tuple, port}} -> {:ok, port}
error -> error
end
end
@doc """
Format an IP tuple and port back to string representation.
## Examples
iex> SnmpKit.SnmpLib.HostParser.format({{192, 168, 1, 1}, 161})
"192.168.1.1:161"
iex> SnmpKit.SnmpLib.HostParser.format({{0, 0, 0, 0, 0, 0, 0, 1}, 161})
"[::1]:161"
"""
@spec format({ip_tuple(), port_number()}) :: String.t()
def format({{a, b, c, d}, port}) do
"#{a}.#{b}.#{c}.#{d}:#{port}"
end
def format({{a, b, c, d, e, f, g, h}, port}) do
ip_str = {a, b, c, d, e, f, g, h} |> :inet.ntoa() |> List.to_string()
"[#{ip_str}]:#{port}"
end
end