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