defmodule SnmpKit.SnmpSim.Core.Server do @moduledoc """ High-performance UDP server for SNMP request handling. Supports concurrent packet processing with minimal latency. """ use GenServer require Logger alias SnmpKit.SnmpLib.PDU, as: PDU alias SnmpKit.SnmpSim.Device.OidHandler # Suppress Dialyzer warnings for async functions and pattern matches @dialyzer [ {:nowarn_function, process_snmp_request_async: 5}, {:nowarn_function, send_response_async: 4} ] defstruct [ :socket, :port, :device_handler, :community, :stats ] @default_community "public" @socket_opts [:binary, {:active, true}, {:reuseaddr, true}, {:ip, {0, 0, 0, 0}}] @doc """ Start an SNMP UDP server on the specified port. ## Options - `:community` - SNMP community string (default: "public") - `:device_handler` - Module or function to handle device requests - `:socket_opts` - Additional socket options ## Examples {:ok, server} = SnmpKit.SnmpSim.Core.Server.start_link(9001, community: "public", device_handler: &MyDevice.handle_request/2 ) """ def start_link(port, opts \\ []) do GenServer.start_link(__MODULE__, {port, opts}) end @doc """ Get server statistics. """ def get_stats(server_pid) do GenServer.call(server_pid, :get_stats) end @doc """ Update the device handler function. """ def set_device_handler(server_pid, handler) do GenServer.call(server_pid, {:set_device_handler, handler}) end # GenServer callbacks @impl true def init({port, opts}) do community = Keyword.get(opts, :community, @default_community) device_handler = Keyword.get(opts, :device_handler) socket_opts = Keyword.get(opts, :socket_opts, []) ++ @socket_opts case :gen_udp.open(port, socket_opts) do {:ok, socket} -> # Debug: Check socket info case :inet.sockname(socket) do {:ok, {ip, bound_port}} -> Logger.info( "SNMP server started on port #{port}, socket bound to #{:inet.ntoa(ip)}:#{bound_port}" ) {:error, reason} -> Logger.error("Failed to get socket name: #{inspect(reason)}") end state = %__MODULE__{ socket: socket, port: port, device_handler: device_handler, community: community, stats: init_stats() } {:ok, state} {:error, reason} -> Logger.error("Failed to start SNMP server on port #{port}: #{inspect(reason)}") {:stop, reason} end end @impl true def handle_call(:get_stats, _from, state) do {:reply, state.stats, state} end @impl true def handle_call({:set_device_handler, handler}, _from, state) do new_state = %{state | device_handler: handler} {:reply, :ok, new_state} end @impl true def handle_info({:udp, socket, client_ip, client_port, packet}, %{socket: socket} = state) do Logger.debug( "Received UDP packet from #{:inet.ntoa(client_ip)}:#{client_port}, #{byte_size(packet)} bytes" ) # Update stats new_stats = update_stats(state.stats, :packets_received) final_state = %{state | stats: new_stats} # Process SNMP packet asynchronously for better throughput # Pass server PID to avoid process identity issues server_pid = self() Task.start(fn -> handle_snmp_packet_async(server_pid, state, client_ip, client_port, packet) end) {:noreply, final_state} end @impl true def handle_info({:udp_closed, socket}, %{socket: socket} = state) do Logger.warning("SNMP server socket closed unexpectedly") {:stop, :socket_closed, state} end @impl true def handle_info({:update_stats, stat_type}, state) do new_stats = update_stats(state.stats, stat_type) {:noreply, %{state | stats: new_stats}} end @impl true def handle_info({:update_stats, stat_type, value}, state) do new_stats = update_stats(state.stats, stat_type, value) {:noreply, %{state | stats: new_stats}} end @impl true def handle_info(msg, state) do Logger.debug("Unexpected message: #{inspect(msg)}") {:noreply, state} end @impl true def terminate(_reason, state) do :gen_udp.close(state.socket) :ok end # Private functions defp handle_snmp_packet_async(server_pid, state, client_ip, client_port, packet) do start_time = :erlang.monotonic_time() # Debug: Log raw packet information packet_size = byte_size(packet) packet_hex = Base.encode16(packet) Logger.debug("Received SNMP packet from #{format_ip(client_ip)}:#{client_port}") Logger.debug("Packet size: #{packet_size} bytes") Logger.debug("Packet hex: #{packet_hex}") try do case PDU.decode_message(packet) do {:ok, message} -> Logger.debug("Decoded SNMP message: #{inspect(message)}") if validate_community(message, state.community) do Logger.debug("Processing PDU: #{inspect(message.pdu)}") # Create a complete PDU structure with version and community for handlers # Convert varbinds from {oid, type, value} to {oid, value} format for backward compatibility variable_bindings = case message.pdu.varbinds do varbinds when is_list(varbinds) -> Enum.map(varbinds, fn {oid, _type, value} -> {oid, value} {oid, value} -> {oid, value} end) end complete_pdu = %{ # Convert message version to PDU version: 0->1 (SNMPv1), 1->2 (SNMPv2c) version: message.version + 1, community: message.community, type: message.pdu.type, request_id: message.pdu.request_id, error_status: message.pdu[:error_status] || 0, error_index: message.pdu[:error_index] || 0, varbinds: variable_bindings, max_repetitions: message.pdu[:max_repetitions] || 0, non_repeaters: message.pdu[:non_repeaters] || 0 } process_snmp_request_async(server_pid, state, client_ip, client_port, complete_pdu) else Logger.warning("Invalid community string from #{format_ip(client_ip)}:#{client_port}") send(server_pid, {:update_stats, :auth_failures}) end {:error, reason} -> Logger.warning( "Failed to decode SNMP packet from #{format_ip(client_ip)}:#{client_port}: #{inspect(reason)}" ) Logger.warning("Raw packet (#{packet_size} bytes): #{packet_hex}") send(server_pid, {:update_stats, :decode_errors}) end rescue error -> Logger.error("Error processing SNMP packet: #{inspect(error)}") Logger.error("Stacktrace: #{inspect(__STACKTRACE__)}") send(server_pid, {:update_stats, :processing_errors}) end # Track processing time end_time = :erlang.monotonic_time() processing_time = :erlang.convert_time_unit(end_time - start_time, :native, :microsecond) send(server_pid, {:update_stats, :processing_times, processing_time}) end defp process_snmp_request_async(server_pid, state, client_ip, client_port, pdu) do case state.device_handler do nil -> # No device handler - send generic error # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :error_responses}) handler when is_function(handler, 2) -> # Function handler case handler.(pdu, %{client_ip: client_ip, client_port: client_port}) do {:ok, response_pdu} -> Logger.debug("Device returned response: #{inspect(response_pdu)}") Logger.debug("Server: Device returned response PDU: #{inspect(response_pdu)}") send_response_async(state, client_ip, client_port, response_pdu) send(server_pid, {:update_stats, :successful_responses}) %{type: _type} = response_pdu when is_map(response_pdu) -> # Direct PDU response from walk processors Logger.debug("Device returned direct response: #{inspect(response_pdu)}") Logger.debug("Server: Device returned direct response PDU: #{inspect(response_pdu)}") send_response_async(state, client_ip, client_port, response_pdu) send(server_pid, {:update_stats, :successful_responses}) {:error, error_status} -> error_response = PDU.create_error_response(pdu, error_status, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :error_responses}) end {module, function} -> # Module/function handler case apply(module, function, [pdu, %{client_ip: client_ip, client_port: client_port}]) do {:ok, response_pdu} -> Logger.debug("Device returned response: #{inspect(response_pdu)}") Logger.debug("Server: Device returned response PDU: #{inspect(response_pdu)}") send_response_async(state, client_ip, client_port, response_pdu) send(server_pid, {:update_stats, :successful_responses}) {:error, error_status} -> error_response = PDU.create_error_response(pdu, error_status, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :error_responses}) end pid when is_pid(pid) -> # GenServer handler (e.g., Device process) # Check if process is alive before attempting to call it if Process.alive?(pid) do try do Logger.debug("Calling device handler with PDU: #{inspect(pdu)}") case GenServer.call( pid, {:handle_snmp, pdu, %{client_ip: client_ip, client_port: client_port}}, 5000 ) do {:ok, response_pdu} -> Logger.debug("Device returned response: #{inspect(response_pdu)}") Logger.debug("Server: Device returned response PDU: #{inspect(response_pdu)}") send_response_async(state, client_ip, client_port, response_pdu) send(server_pid, {:update_stats, :successful_responses}) {:error, error_status} -> Logger.debug("Device returned error: #{inspect(error_status)}") error_response = PDU.create_error_response(pdu, error_status, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :error_responses}) end catch :exit, {:timeout, _} -> Logger.warning( "Device process #{inspect(pid)} timed out responding to SNMP request" ) # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :timeout_errors}) :exit, {:noproc, _} -> # Device process has died between alive check and call Logger.warning("Device process #{inspect(pid)} died during SNMP request") # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :dead_process_errors}) :exit, {:normal, _} -> # Device process shut down normally Logger.info("Device process #{inspect(pid)} shut down normally during request") # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :dead_process_errors}) :exit, {:shutdown, _} -> # Device process was shutdown Logger.info("Device process #{inspect(pid)} was shutdown during request") # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :dead_process_errors}) :exit, reason -> # Other exit reasons Logger.warning( "Device process #{inspect(pid)} exited with reason: #{inspect(reason)}" ) # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :error_responses}) end else # Process is not alive Logger.warning("Device process #{inspect(pid)} is not alive") # genErr error_response = PDU.create_error_response(pdu, 5, 0) send_response_async(state, client_ip, client_port, error_response) send(server_pid, {:update_stats, :dead_process_errors}) end end end defp send_response_async(state, client_ip, client_port, response_pdu) do community = Map.get(response_pdu, :community, "public") version = Map.get(response_pdu, :version, 0) # Normalize varbinds for SNMP library compatibility normalized_pdu = normalize_varbinds(response_pdu) # Debug logging Logger.debug("Response PDU before encoding: #{inspect(normalized_pdu)}") Logger.debug("Server: Response PDU before encoding: #{inspect(normalized_pdu)}") response_message = case version do 1 -> # PDU version 1 = SNMPv1 -> message version 0 PDU.build_message(normalized_pdu, community, :v1) 2 -> # PDU version 2 = SNMPv2c -> message version 1 PDU.build_message(normalized_pdu, community, :v2c) _ -> # Default to SNMPv1 for version 0 or unknown versions PDU.build_message(normalized_pdu, community, :v1) end Logger.debug("Response message before encoding: #{inspect(response_message)}") Logger.debug("Server: Response message before encoding: #{inspect(response_message)}") case PDU.encode_message(response_message) do {:ok, encoded_packet} -> Logger.debug("Server: Successfully encoded packet, sending response") :gen_udp.send(state.socket, client_ip, client_port, encoded_packet) {:error, reason} -> Logger.error("Failed to encode SNMP response: #{inspect(reason)}") Logger.error("Response message that failed: #{inspect(response_message)}") end end defp validate_community(message, expected_community) do message.community == expected_community end defp format_ip({a, b, c, d}) do "#{a}.#{b}.#{c}.#{d}" end defp format_ip(ip) when is_binary(ip) do ip end defp init_stats do %{ packets_received: 0, packets_sent: 0, successful_responses: 0, error_responses: 0, auth_failures: 0, decode_errors: 0, encode_errors: 0, send_errors: 0, processing_errors: 0, timeout_errors: 0, dead_process_errors: 0, processing_times: [], started_at: DateTime.utc_now() } end defp update_stats(stats, :processing_times, time) do # Keep only the last 1000 processing times for memory efficiency times = [time | stats.processing_times] |> Enum.take(1000) Map.put(stats, :processing_times, times) end defp update_stats(stats, counter_key, _value) when is_atom(counter_key) do Map.update(stats, counter_key, 1, &(&1 + 1)) end defp update_stats(stats, counter_key) when is_atom(counter_key) do Map.update(stats, counter_key, 1, &(&1 + 1)) end # Normalize varbinds for SNMP library compatibility defp normalize_varbinds(pdu) do normalized_varbinds = Enum.map(pdu.varbinds || [], fn # Handle 2-tuple format {oid, :null} - normalize OID {oid, :null} -> normalized_oid = normalize_oid(oid) {normalized_oid, :null, nil} # Handle 2-tuple format {oid, string_value} - assume octet_string type {oid, value} when is_binary(value) -> normalized_oid = normalize_oid(oid) {normalized_oid, :octet_string, value} # Handle 3-tuple format {oid, type, value} {oid, type, value} -> normalized_oid = normalize_oid(oid) normalized_value = normalize_varbind_value(type, value) {normalized_oid, type, normalized_value} end) Map.put(pdu, :varbinds, normalized_varbinds) end # Convert string OIDs to integer lists defp normalize_oid(oid) when is_binary(oid) do OidHandler.string_to_oid_list(oid) end defp normalize_oid(oid) when is_list(oid), do: oid defp normalize_oid(oid), do: oid # Convert varbind values to formats expected by snmp_lib defp normalize_varbind_value(:object_identifier, value) when is_binary(value) do # Convert string OID to OID list, handling both dotted notation and named OIDs case parse_oid_string(value) do {:ok, oid_list} -> oid_list # Fallback to original function {:error, _} -> OidHandler.string_to_oid_list(value) end end defp normalize_varbind_value(:end_of_mib_view, {:end_of_mib_view, nil}) do # Convert {:end_of_mib_view, nil} to just nil nil end defp normalize_varbind_value(:end_of_mib_view, _value) do # Any other end_of_mib_view value should be nil nil end defp normalize_varbind_value(_type, value) do # For all other types, return value as-is value end # Simple OID string parser - converts dotted notation to list defp parse_oid_string(oid_string) when is_binary(oid_string) do try do cond do # Handle specific known OID mappings String.contains?(oid_string, "SNMPv2-SMI::enterprises.4491.2.4.1") -> {:ok, [1, 3, 6, 1, 4, 1, 4491, 2, 4, 1]} # Handle dotted notation (1.3.6.1.2.1.1.1.0) String.contains?(oid_string, ".") and String.match?(oid_string, ~r/^\d+(\.\d+)*$/) -> parts = String.split(oid_string, ".") oid_list = Enum.map(parts, &String.to_integer/1) {:ok, oid_list} # For other named OIDs, use a default mapping true -> {:ok, [1, 3, 6, 1, 4, 1, 4491, 2, 4, 1]} end rescue _ -> {:error, :invalid_oid} end end end