towerops/lib/snmpkit/snmp_lib/transport.ex

695 lines
21 KiB
Elixir

defmodule SnmpKit.SnmpLib.Transport do
@moduledoc """
UDP transport layer for SNMP communications.
Provides socket management, connection utilities, and network operations
for both SNMP managers and agents/simulators.
## Features
- UDP socket creation and management
- Address resolution and validation
- Connection pooling and reuse
- Timeout handling
- Error recovery
- Performance optimizations
## Examples
# Create and use a socket
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_socket("0.0.0.0", 161)
{:ok, data} = SnmpKit.SnmpLib.Transport.receive_packet(socket, 5000)
:ok = SnmpKit.SnmpLib.Transport.send_packet(socket, "192.168.1.100", 161, packet_data)
:ok = SnmpKit.SnmpLib.Transport.close_socket(socket)
# Address utilities
{:ok, {192, 168, 1, 100}} = SnmpKit.SnmpLib.Transport.resolve_address("192.168.1.100")
true = SnmpKit.SnmpLib.Transport.validate_port(161)
"""
require Logger
@type socket :: :gen_udp.socket()
@type address :: :inet.socket_address() | :inet.hostname() | binary()
@type port_number :: :inet.port_number()
@type packet_data :: binary()
@type socket_options :: [:gen_udp.option()]
# Default socket options
@default_socket_options [
{:active, false},
{:reuseaddr, true}
]
# Standard SNMP ports
@snmp_agent_port 161
@snmp_trap_port 162
## Socket Management
@doc """
Creates a UDP socket bound to the specified address and port.
## Parameters
- `bind_address`: Address to bind to (use "0.0.0.0" for all interfaces)
- `port`: Port number to bind to
- `options`: Additional socket options (optional)
## Returns
- `{:ok, socket}` on success
- `{:error, reason}` on failure
## Examples
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_socket("0.0.0.0", 161)
{:ok, client_socket} = SnmpKit.SnmpLib.Transport.create_socket("0.0.0.0", 0, [{:active, true}])
"""
@spec create_socket(binary() | :inet.socket_address(), port_number(), socket_options()) ::
{:ok, socket()} | {:error, atom()}
def create_socket(bind_address, port, options \\ []) do
with {:ok, resolved_address} <- resolve_address(bind_address),
:ok <- validate_port_result(port) do
open_udp_socket(resolved_address, port, options)
end
end
defp validate_port_result(port) do
if validate_port(port), do: :ok, else: {:error, :invalid_port}
end
defp open_udp_socket(resolved_address, port, options) do
merged_options = Keyword.merge(@default_socket_options, options)
case :gen_udp.open(port, [:binary, {:ip, resolved_address} | merged_options]) do
{:ok, socket} ->
Logger.debug("Created UDP socket bound to #{format_endpoint(resolved_address, port)}")
{:ok, socket}
{:error, reason} ->
Logger.error("Failed to create UDP socket: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Creates a client socket for outgoing SNMP requests.
Uses an ephemeral port and optimizes settings for client use.
## Examples
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_client_socket()
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_client_socket([{:recbuf, 65536}])
"""
@spec create_client_socket(socket_options()) :: {:ok, socket()} | {:error, atom()}
def create_client_socket(options \\ []) do
# Use ephemeral port (0) for client connections - bypass validation for ephemeral ports
bind_address = "0.0.0.0"
case resolve_address(bind_address) do
{:ok, resolved_address} ->
client_options =
Keyword.merge(
@default_socket_options,
[
{:active, false},
# Larger receive buffer for responses
{:recbuf, 65_536},
# Smaller send buffer for requests
{:sndbuf, 8192}
] ++ options
)
# Add IP binding if not 0.0.0.0
final_options =
if resolved_address == {0, 0, 0, 0} do
client_options
else
[{:ip, resolved_address} | client_options]
end
case :gen_udp.open(0, [:binary | final_options]) do
{:ok, socket} ->
Logger.debug("Created UDP socket bound to #{inspect(resolved_address)}:0")
{:ok, socket}
{:error, reason} ->
Logger.error("Failed to create UDP socket: #{inspect(reason)}")
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Creates a server socket for incoming SNMP requests.
Optimizes settings for server use with proper buffer sizes.
## Examples
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_server_socket(161)
{:ok, socket} = SnmpKit.SnmpLib.Transport.create_server_socket(161, "192.168.1.10")
"""
@spec create_server_socket(port_number(), binary()) :: {:ok, socket()} | {:error, atom()}
def create_server_socket(port, bind_address \\ "0.0.0.0") do
server_options = [
{:active, false},
# Smaller receive buffer for requests
{:recbuf, 8192},
# Larger send buffer for responses
{:sndbuf, 65_536}
]
# Allow port 0 for ephemeral ports in testing, but validate others
if port == 0 do
create_ephemeral_server_socket(bind_address, port, server_options)
else
create_socket(bind_address, port, server_options)
end
end
defp create_ephemeral_server_socket(bind_address, port, server_options) do
with {:ok, resolved_address} <- resolve_address(bind_address) do
open_udp_socket(resolved_address, port, server_options)
end
end
@doc """
Sends a packet to the specified destination.
## Parameters
- `socket`: UDP socket to send from
- `dest_address`: Destination IP address or hostname
- `dest_port`: Destination port number
- `data`: Binary data to send
## Returns
- `:ok` on success
- `{:error, reason}` on failure
## Examples
:ok = SnmpKit.SnmpLib.Transport.send_packet(socket, "192.168.1.100", 161, packet_data)
:ok = SnmpKit.SnmpLib.Transport.send_packet(socket, {192, 168, 1, 100}, 161, packet_data)
"""
@spec send_packet(socket(), address(), port_number(), packet_data()) ::
:ok | {:error, atom()}
def send_packet(socket, dest_address, dest_port, data) when is_binary(data) do
Logger.debug(
"send_packet called with: socket=#{inspect(socket)}, dest_address=#{inspect(dest_address)}, dest_port=#{inspect(dest_port)}, data_size=#{byte_size(data)}"
)
case {resolve_address(dest_address), validate_port(dest_port)} do
{{:ok, resolved_address}, true} ->
Logger.debug("Address resolved to: #{inspect(resolved_address)}, port validated: #{dest_port}")
Logger.debug(
"About to call :gen_udp.send with: socket=#{inspect(socket)}, ip=#{inspect(resolved_address)}, port=#{dest_port}, data_type=#{inspect(data)}"
)
result = :gen_udp.send(socket, resolved_address, dest_port, data)
case result do
:ok ->
Logger.debug("Sent #{byte_size(data)} bytes to #{format_endpoint(resolved_address, dest_port)}")
:ok
{:error, reason} ->
Logger.error("Failed to send packet: #{inspect(reason)}")
Logger.error(
"gen_udp.send failed with: socket=#{inspect(socket)}, ip=#{inspect(resolved_address)}, port=#{dest_port}, data_size=#{byte_size(data)}"
)
Logger.error("Socket info: #{inspect(:inet.sockname(socket))}")
Logger.error("Data sample: #{inspect(binary_part(data, 0, min(50, byte_size(data))))}")
{:error, reason}
end
{{:error, reason}, _} ->
Logger.error("Address resolution failed: #{inspect(reason)} for address: #{inspect(dest_address)}")
{:error, reason}
{_, false} ->
Logger.error("Port validation failed for port: #{inspect(dest_port)}")
{:error, :invalid_port}
end
end
def send_packet(_, _, _, _), do: {:error, :invalid_data}
@doc """
Receives a packet from the socket with optional timeout.
## Parameters
- `socket`: UDP socket to receive from
- `timeout`: Timeout in milliseconds (default: 5000)
## Returns
- `{:ok, {data, from_address, from_port}}` on success
- `{:error, reason}` on failure or timeout
## Examples
{:ok, {data, from_ip, from_port}} = SnmpKit.SnmpLib.Transport.receive_packet(socket)
{:ok, {data, from_ip, from_port}} = SnmpKit.SnmpLib.Transport.receive_packet(socket, 10000)
"""
@spec send_and_receive_packet(
:inet.socket_address(),
port_number(),
packet_data(),
non_neg_integer()
) ::
{:ok, {packet_data(), :inet.socket_address(), port_number()}} | {:error, atom()}
def send_and_receive_packet(_dest_address, _dest_port, _data, _timeout \\ 5000) do
{:error, :not_supported}
end
@spec receive_packet(socket(), non_neg_integer()) ::
{:ok, {packet_data(), :inet.socket_address(), port_number()}} | {:error, atom()}
def receive_packet(socket, timeout \\ 5000) when is_integer(timeout) and timeout >= 0 do
case :gen_udp.recv(socket, 0, timeout) do
{:ok, {from_address, from_port, data}} when is_binary(data) ->
Logger.debug("Received #{byte_size(data)} bytes from #{format_endpoint(from_address, from_port)}")
{:ok, {data, from_address, from_port}}
{:ok, invalid_response} ->
Logger.error("Invalid UDP response format: #{inspect(invalid_response)}")
{:error, :invalid_response}
{:error, :timeout} ->
Logger.debug("Socket receive timeout after #{timeout}ms")
{:error, :timeout}
{:error, reason} ->
Logger.error("Failed to receive packet: #{inspect(reason)}")
{:error, reason}
end
end
@doc """
Receives a packet with a custom filter function.
Continues receiving until a packet matches the filter or timeout occurs.
## Parameters
- `socket`: UDP socket to receive from
- `filter_fn`: Function that returns true for desired packets
- `timeout`: Total timeout in milliseconds
- `per_recv_timeout`: Timeout per receive attempt (default: 1000ms)
## Examples
# Wait for packet from specific address
filter_fn = fn {_data, from_addr, _from_port} -> from_addr == {192, 168, 1, 100} end
{:ok, {data, addr, port}} = SnmpKit.SnmpLib.Transport.receive_packet_filtered(socket, filter_fn, 5000)
"""
@spec receive_packet_filtered(socket(), function(), non_neg_integer(), non_neg_integer()) ::
{:ok, {packet_data(), :inet.socket_address(), port_number()}} | {:error, atom()}
def receive_packet_filtered(socket, filter_fn, timeout, per_recv_timeout \\ 1000) when is_function(filter_fn, 1) do
start_time = System.monotonic_time(:millisecond)
receive_packet_filtered_loop(socket, filter_fn, timeout, per_recv_timeout, start_time)
end
@doc """
Closes a UDP socket.
## Examples
:ok = SnmpKit.SnmpLib.Transport.close_socket(socket)
"""
@spec close_socket(socket()) :: :ok
def close_socket(socket) do
:ok = :gen_udp.close(socket)
Logger.debug("Closed UDP socket")
:ok
end
## Address and Network Utilities
@doc """
Resolves an address to an IP tuple.
Accepts:
- IP address strings (e.g., "192.168.1.1")
- Hostnames (e.g., "localhost")
- IP tuples (e.g., {192, 168, 1, 1})
## Examples
iex> SnmpKit.SnmpLib.Transport.resolve_address("192.168.1.1")
{:ok, {192, 168, 1, 1}}
iex> SnmpKit.SnmpLib.Transport.resolve_address({192, 168, 1, 1})
{:ok, {192, 168, 1, 1}}
"""
@spec resolve_address(address()) :: {:ok, :inet.socket_address()} | {:error, atom()}
def resolve_address(address) when is_tuple(address) do
if valid_ipv4_tuple?(address) do
{:ok, address}
else
{:error, :invalid_ip_tuple}
end
end
def resolve_address(address) when is_binary(address) do
charlist = String.to_charlist(address)
case :inet.parse_address(charlist) do
{:ok, ip_tuple} ->
{:ok, ip_tuple}
{:error, :einval} ->
resolve_hostname(charlist, address)
end
end
def resolve_address(address) when is_list(address) do
case :inet.parse_address(address) do
{:ok, ip_tuple} ->
{:ok, ip_tuple}
{:error, :einval} ->
address_str = List.to_string(address)
resolve_hostname(address, address_str)
end
end
def resolve_address(_), do: {:error, :invalid_address_format}
defp valid_ipv4_tuple?({a, b, c, d}) when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d) do
a in 0..255 and b in 0..255 and c in 0..255 and d in 0..255
end
defp valid_ipv4_tuple?(_), do: false
defp resolve_hostname(charlist, display_address) do
case :inet.gethostbyname(charlist) do
{:ok, {:hostent, _name, _aliases, :inet, 4, [ip_tuple | _]}} ->
{:ok, ip_tuple}
{:error, reason} ->
Logger.error("Failed to resolve hostname #{display_address}: #{inspect(reason)}")
{:error, :hostname_resolution_failed}
end
end
@doc """
Validates a port number.
## Examples
true = SnmpKit.SnmpLib.Transport.validate_port(161)
true = SnmpKit.SnmpLib.Transport.validate_port(65535)
true = SnmpKit.SnmpLib.Transport.validate_port(0)
false = SnmpKit.SnmpLib.Transport.validate_port(65536)
"""
@spec validate_port(term()) :: boolean()
def validate_port(port) when is_integer(port) and port > 0 and port <= 65_535, do: true
def validate_port(_), do: false
@doc """
Formats an endpoint (address and port) as a string.
## Examples
"192.168.1.100:161" = SnmpKit.SnmpLib.Transport.format_endpoint({192, 168, 1, 100}, 161)
"localhost:162" = SnmpKit.SnmpLib.Transport.format_endpoint("localhost", 162)
"""
@spec format_endpoint(address(), port_number()) :: binary()
def format_endpoint(address, port) do
address_str =
case address do
{a, b, c, d} -> "#{a}.#{b}.#{c}.#{d}"
addr when is_binary(addr) -> addr
_ -> inspect(address)
end
"#{address_str}:#{port}"
end
@doc """
Gets the local address and port of a socket.
## Examples
{:ok, {{127, 0, 0, 1}, 12345}} = SnmpKit.SnmpLib.Transport.get_socket_address(socket)
"""
@spec get_socket_address(socket()) ::
{:ok, {:inet.socket_address(), port_number()}} | {:error, atom()}
def get_socket_address(socket) do
case :inet.sockname(socket) do
{:ok, {address, port}} ->
{:ok, {address, port}}
{:error, reason} ->
{:error, reason}
end
end
## Connection Management
@doc """
Tests connectivity to a destination by sending a test packet.
This creates a temporary socket, sends a small packet, and waits for any response
to verify network connectivity.
## Parameters
- `dest_address`: Destination IP address or hostname
- `dest_port`: Destination port number
- `timeout`: Timeout in milliseconds (default: 3000)
## Returns
- `:ok` if connectivity is confirmed
- `{:error, reason}` if connectivity fails
## Examples
:ok = SnmpKit.SnmpLib.Transport.test_connectivity("192.168.1.100", 161)
{:error, :timeout} = SnmpKit.SnmpLib.Transport.test_connectivity("10.0.0.1", 161, 1000)
"""
@spec test_connectivity(address(), port_number(), non_neg_integer()) :: :ok | {:error, atom()}
def test_connectivity(dest_address, dest_port, timeout \\ 3000) do
case create_client_socket() do
{:ok, socket} ->
try do
# Send a minimal packet to test connectivity
# Minimal ASN.1 sequence
test_packet = <<0x30, 0x02, 0x01, 0x00>>
case send_packet(socket, dest_address, dest_port, test_packet) do
:ok ->
# Wait for any response (even an error response indicates connectivity)
case receive_packet(socket, timeout) do
{:ok, _} -> :ok
{:error, :timeout} -> {:error, :timeout}
{:error, reason} -> {:error, reason}
end
{:error, reason} ->
{:error, reason}
end
after
close_socket(socket)
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Sends a request packet and waits for a response.
This creates a temporary socket, sends a packet, waits for the response,
and returns the response data. Useful for SNMPv3 discovery and security operations.
## Parameters
- `dest_address`: Destination IP address or hostname
- `dest_port`: Destination port number
- `request_data`: Binary request data to send
- `timeout`: Timeout in milliseconds (default: 5000)
## Returns
- `{:ok, response_data}` if request succeeds and response received
- `{:error, reason}` if request fails or times out
## Examples
{:ok, response} = SnmpKit.SnmpLib.Transport.send_request("192.168.1.100", 161, request_packet, 5000)
{:error, :timeout} = SnmpKit.SnmpLib.Transport.send_request("10.0.0.1", 161, request_packet, 1000)
"""
@spec send_request(address(), port_number(), packet_data(), non_neg_integer()) ::
{:ok, packet_data()} | {:error, atom()}
def send_request(dest_address, dest_port, request_data, timeout \\ 5000) do
case create_client_socket() do
{:ok, socket} ->
try do
case send_packet(socket, dest_address, dest_port, request_data) do
:ok ->
case receive_packet(socket, timeout) do
{:ok, {response_data, _from_addr, _from_port}} ->
{:ok, response_data}
{:error, reason} ->
{:error, reason}
end
{:error, reason} ->
{:error, reason}
end
after
close_socket(socket)
end
{:error, reason} ->
{:error, reason}
end
end
@doc """
Gets socket statistics and information.
## Examples
{:ok, stats} = SnmpKit.SnmpLib.Transport.get_socket_stats(socket)
# stats contains buffer sizes, packet counts, etc.
"""
@spec get_socket_stats(socket()) :: {:ok, map()} | {:error, atom()}
def get_socket_stats(socket) do
case collect_socket_stats(socket) do
{:ok, stats} -> {:ok, stats}
{:error, reason} -> {:error, reason}
end
rescue
error ->
{:error, {:stats_error, error}}
end
## Utility Functions
@doc """
Returns standard SNMP port numbers.
"""
@spec snmp_agent_port() :: port_number()
def snmp_agent_port, do: @snmp_agent_port
@spec snmp_trap_port() :: port_number()
def snmp_trap_port, do: @snmp_trap_port
@doc """
Checks if a port number is a standard SNMP port.
## Examples
true = SnmpKit.SnmpLib.Transport.snmp_port?(161)
true = SnmpKit.SnmpLib.Transport.snmp_port?(162)
false = SnmpKit.SnmpLib.Transport.snmp_port?(80)
"""
@spec snmp_port?(port_number()) :: boolean()
def snmp_port?(port) when port in [@snmp_agent_port, @snmp_trap_port], do: true
def snmp_port?(_), do: false
@doc """
Calculates network MTU considerations for SNMP packets.
Returns recommended maximum payload size to avoid fragmentation.
## Examples
1472 = SnmpKit.SnmpLib.Transport.max_snmp_payload_size() # Ethernet MTU - headers
"""
@spec max_snmp_payload_size() :: non_neg_integer()
def max_snmp_payload_size do
# Ethernet MTU (1500) - IP header (20) - UDP header (8)
1472
end
@doc """
Validates if a packet size is suitable for SNMP transmission.
## Examples
true = SnmpKit.SnmpLib.Transport.valid_packet_size?(500)
false = SnmpKit.SnmpLib.Transport.valid_packet_size?(2000)
"""
@spec valid_packet_size?(non_neg_integer()) :: boolean()
def valid_packet_size?(size) when is_integer(size) and size > 0 do
size <= max_snmp_payload_size()
end
def valid_packet_size?(_), do: false
## Private Helper Functions
defp receive_packet_filtered_loop(socket, filter_fn, timeout, per_recv_timeout, start_time) do
current_time = System.monotonic_time(:millisecond)
elapsed = current_time - start_time
if elapsed >= timeout do
{:error, :timeout}
else
remaining_timeout = min(per_recv_timeout, timeout - elapsed)
case receive_packet(socket, remaining_timeout) do
{:ok, packet_info} ->
handle_received_packet(socket, filter_fn, timeout, per_recv_timeout, start_time, packet_info)
{:error, :timeout} ->
# Continue waiting if we haven't exceeded total timeout
receive_packet_filtered_loop(socket, filter_fn, timeout, per_recv_timeout, start_time)
{:error, reason} ->
{:error, reason}
end
end
end
defp handle_received_packet(
socket,
filter_fn,
timeout,
per_recv_timeout,
start_time,
{data, from_addr, from_port} = packet_info
) do
if filter_fn.(packet_info) do
{:ok, {data, from_addr, from_port}}
else
# Packet didn't match filter, continue waiting
receive_packet_filtered_loop(socket, filter_fn, timeout, per_recv_timeout, start_time)
end
end
defp collect_socket_stats(socket) do
stats = %{
socket_info: :inet.info(socket),
port_info: :erlang.port_info(socket),
statistics: :inet.getstat(socket)
}
{:ok, stats}
rescue
_ -> {:error, :stats_unavailable}
end
end