towerops/lib/snmpkit/snmp_mgr/target.ex
2026-01-23 12:52:17 -06:00

154 lines
4.2 KiB
Elixir

defmodule SnmpKit.SnmpMgr.Target do
@moduledoc """
Target parsing and validation for SNMP requests.
Handles parsing of various target formats and resolves hostnames to IP addresses.
"""
@default_port 161
@doc """
Parses a target string into a structured format.
## Examples
iex> SnmpKit.SnmpMgr.Target.parse("192.168.1.1:161")
{:ok, %{host: {192, 168, 1, 1}, port: 161}}
iex> SnmpKit.SnmpMgr.Target.parse("device.local")
{:ok, %{host: "device.local", port: 161}}
iex> SnmpKit.SnmpMgr.Target.parse("192.168.1.1")
{:ok, %{host: {192, 168, 1, 1}, port: 161}}
"""
def parse(target) when is_binary(target) do
case String.split(target, ":") do
[host] ->
parse_host_and_port(host, @default_port)
[host, port_str] ->
case Integer.parse(port_str) do
{port, ""} when port > 0 and port <= 65_535 ->
parse_host_and_port(host, port)
_ ->
{:error, {:invalid_port, port_str}}
end
_ ->
{:error, {:invalid_target_format, target}}
end
end
def parse(target) when is_tuple(target) and tuple_size(target) == 4 do
# Already an IP tuple
{:ok, %{host: target, port: @default_port}}
end
def parse(%{host: _host, port: _port} = target) do
# Already parsed
{:ok, target}
end
def parse(_target) do
{:error, :invalid_target_format}
end
@doc """
Resolves a target to a parsed struct, with fallback for unparseable targets.
Unlike `parse/1` which returns `{:ok, target}` or `{:error, reason}`,
this function always returns a target struct, using the raw input as
the host with default port 161 if parsing fails.
This is the canonical function for target resolution across all modules.
## Examples
iex> SnmpKit.SnmpMgr.Target.resolve("192.168.1.1:161")
%{host: {192, 168, 1, 1}, port: 161}
iex> SnmpKit.SnmpMgr.Target.resolve("device.local")
%{host: "device.local", port: 161}
iex> SnmpKit.SnmpMgr.Target.resolve(%{host: {127, 0, 0, 1}, port: 162})
%{host: {127, 0, 0, 1}, port: 162}
"""
def resolve(target) when is_binary(target) do
case parse(target) do
{:ok, parsed} -> parsed
{:error, _} -> %{host: target, port: @default_port}
end
end
def resolve(%{host: _, port: _} = target), do: target
def resolve(target) when is_tuple(target) and tuple_size(target) == 4 do
%{host: target, port: @default_port}
end
def resolve(target), do: %{host: target, port: @default_port}
@doc """
Resolves a hostname to an IP address if needed.
If the host is already an IP tuple, returns it unchanged.
"""
def resolve_hostname(%{host: host, port: _port} = target) when is_tuple(host) do
{:ok, target}
end
def resolve_hostname(%{host: hostname, port: port}) when is_binary(hostname) do
case parse_ip_address(hostname) do
{:ok, ip_tuple} ->
{:ok, %{host: ip_tuple, port: port}}
:error ->
case :inet.gethostbyname(String.to_charlist(hostname)) do
{:ok, {:hostent, _name, _aliases, :inet, 4, [ip_tuple | _]}} ->
{:ok, %{host: ip_tuple, port: port}}
{:error, reason} ->
{:error, {:hostname_resolution_failed, hostname, reason}}
end
end
end
@doc """
Validates that a target is reachable (basic connectivity check).
"""
def validate_connectivity(%{host: host, port: port}, timeout \\ 5000) do
case :gen_tcp.connect(host, port, [], timeout) do
{:ok, socket} ->
:gen_tcp.close(socket)
:ok
{:error, :econnrefused} ->
# This is actually good - the port responded (even if it refused)
:ok
{:error, reason} ->
{:error, {:connectivity_check_failed, reason}}
end
end
# Private functions
defp parse_host_and_port(host, port) do
case parse_ip_address(host) do
{:ok, ip_tuple} ->
{:ok, %{host: ip_tuple, port: port}}
:error ->
# Assume it's a hostname
{:ok, %{host: host, port: port}}
end
end
defp parse_ip_address(ip_string) do
case :inet.parse_address(String.to_charlist(ip_string)) do
{:ok, ip_tuple} -> {:ok, ip_tuple}
{:error, _} -> :error
end
end
end