Current section
Files
Jump to
Current section
Files
lib/raw_ping.ex
defmodule RawPing do
@moduledoc """
Pure Erlang/OTP ICMP ping library using the modern `:socket` API.
No NIFs, no external dependencies. Requires Elixir 1.17+ (OTP 25+).
## Usage
# Single ping
{:ok, rtt_ms} = RawPing.ping("8.8.8.8")
{:ok, rtt_ms} = RawPing.ping({8, 8, 8, 8})
# With options
{:ok, rtt_ms} = RawPing.ping("8.8.8.8", timeout: 2000)
# Multiple pings with stats
{:ok, stats} = RawPing.ping_stats("8.8.8.8", count: 5)
# => {:ok, %{min: 10.5, max: 15.2, avg: 12.3, success_rate: 1.0, ...}}
# Batch ping multiple hosts
results = RawPing.ping_batch(["8.8.8.8", "1.1.1.1", "192.168.1.1"])
# => %{"8.8.8.8" => {:ok, 12.5}, "1.1.1.1" => {:ok, 8.2}, ...}
## Privileges
Raw ICMP sockets typically require elevated privileges. Options:
- Run as root/sudo
- Set CAP_NET_RAW capability on the BEAM: `setcap cap_net_raw+ep /path/to/beam.smp`
- Use a setuid wrapper
## How It Works
Uses Erlang's `:socket` module to open a raw ICMP socket, builds ICMP echo
request packets manually, sends them, and parses the echo replies to calculate
round-trip time.
"""
alias RawPing.{Socket, Packet}
@default_timeout 5_000
@default_count 1
@default_payload_size 56
@type ip_address :: String.t() | :inet.ip_address() | [integer()]
@type ping_result :: {:ok, float()} | {:error, term()}
@type ping_stats :: %{
min: float() | nil,
max: float() | nil,
avg: float() | nil,
success_rate: float(),
success_count: non_neg_integer(),
failure_count: non_neg_integer(),
rtts: [float()]
}
@doc """
Ping a host and return the round-trip time in milliseconds.
## Options
* `:timeout` - Timeout in milliseconds (default: #{@default_timeout})
* `:payload_size` - Size of ICMP payload in bytes (default: #{@default_payload_size})
## Examples
{:ok, rtt} = RawPing.ping("8.8.8.8")
{:ok, rtt} = RawPing.ping({8, 8, 8, 8}, timeout: 1000)
{:error, :timeout} = RawPing.ping("10.255.255.1", timeout: 100)
"""
@spec ping(ip_address(), keyword()) :: ping_result()
def ping(host, opts \\ []) do
timeout = Keyword.get(opts, :timeout, @default_timeout)
payload_size = Keyword.get(opts, :payload_size, @default_payload_size)
with {:ok, ip} <- parse_ip(host),
{:ok, socket} <- Socket.open(),
result <- do_ping(socket, ip, timeout, payload_size) do
Socket.close(socket)
result
else
{:error, _} = error -> error
end
end
@doc """
Ping a host multiple times and return statistics.
## Options
* `:count` - Number of pings to send (default: #{@default_count})
* `:timeout` - Timeout per ping in milliseconds (default: #{@default_timeout})
* `:payload_size` - Size of ICMP payload in bytes (default: #{@default_payload_size})
## Examples
{:ok, stats} = RawPing.ping_stats("8.8.8.8", count: 5)
# %{min: 10.2, max: 15.8, avg: 12.5, success_rate: 1.0, ...}
"""
@spec ping_stats(ip_address(), keyword()) :: {:ok, ping_stats()} | {:error, term()}
def ping_stats(host, opts \\ []) do
count = Keyword.get(opts, :count, @default_count)
timeout = Keyword.get(opts, :timeout, @default_timeout)
payload_size = Keyword.get(opts, :payload_size, @default_payload_size)
with {:ok, ip} <- parse_ip(host),
{:ok, socket} <- Socket.open() do
results =
Enum.map(1..count, fn seq ->
do_ping(socket, ip, timeout, payload_size, seq)
end)
Socket.close(socket)
{:ok, calculate_stats(results)}
end
end
@doc """
Ping multiple hosts concurrently.
Returns a map of host => result.
## Options
* `:timeout` - Timeout per ping in milliseconds (default: #{@default_timeout})
* `:max_concurrency` - Maximum concurrent pings (default: 50)
## Examples
results = RawPing.ping_batch(["8.8.8.8", "1.1.1.1"])
# %{"8.8.8.8" => {:ok, 12.5}, "1.1.1.1" => {:ok, 8.2}}
"""
@spec ping_batch([ip_address()], keyword()) :: %{String.t() => ping_result()}
def ping_batch(hosts, opts \\ []) do
max_concurrency = Keyword.get(opts, :max_concurrency, 50)
hosts
|> Task.async_stream(
fn host -> {to_string_ip(host), ping(host, opts)} end,
max_concurrency: max_concurrency,
timeout: Keyword.get(opts, :timeout, @default_timeout) + 1000
)
|> Enum.reduce(%{}, fn
{:ok, {host, result}}, acc -> Map.put(acc, host, result)
{:exit, _reason}, acc -> acc
end)
end
# Private functions
defp do_ping(socket, ip, timeout, payload_size, seq \\ 1) do
id = :rand.uniform(65535)
packet = Packet.build_echo_request(id, seq, payload_size)
send_time = System.monotonic_time(:microsecond)
deadline = send_time + timeout * 1000
case Socket.send(socket, packet, ip) do
:ok ->
recv_loop(socket, id, seq, send_time, deadline)
{:error, reason} ->
{:error, reason}
end
end
# Keep receiving until we get our reply or timeout
defp recv_loop(socket, expected_id, expected_seq, send_time, deadline) do
now = System.monotonic_time(:microsecond)
remaining_ms = div(deadline - now, 1000)
if remaining_ms <= 0 do
{:error, :timeout}
else
case Socket.recv(socket, remaining_ms) do
{:ok, reply} ->
case Packet.parse_echo_reply(reply) do
{:ok, ^expected_id, ^expected_seq, _ttl} ->
recv_time = System.monotonic_time(:microsecond)
rtt_ms = (recv_time - send_time) / 1000.0
{:ok, rtt_ms}
{:ok, _other_id, _other_seq, _ttl} ->
# Got someone else's reply, keep waiting
recv_loop(socket, expected_id, expected_seq, send_time, deadline)
{:error, _reason} ->
# Malformed packet, keep waiting
recv_loop(socket, expected_id, expected_seq, send_time, deadline)
end
{:error, :timeout} ->
{:error, :timeout}
{:error, reason} ->
{:error, reason}
end
end
end
defp calculate_stats(results) do
# Single-pass extraction and stats calculation
{rtts, min, max, sum, success_count, total_count} =
Enum.reduce(results, {[], nil, nil, 0.0, 0, 0}, fn
{:ok, rtt}, {rtts, min, max, sum, success, total} ->
new_min = if min == nil, do: rtt, else: min(min, rtt)
new_max = if max == nil, do: rtt, else: max(max, rtt)
{[rtt | rtts], new_min, new_max, sum + rtt, success + 1, total + 1}
{:error, _}, {rtts, min, max, sum, success, total} ->
{rtts, min, max, sum, success, total + 1}
end)
%{
min: min,
max: max,
avg: if(success_count > 0, do: sum / success_count, else: nil),
success_rate: if(total_count > 0, do: success_count / total_count, else: 0.0),
success_count: success_count,
failure_count: total_count - success_count,
rtts: Enum.reverse(rtts)
}
end
defp parse_ip(ip) when is_tuple(ip), do: {:ok, ip}
defp parse_ip([a, b, c, d] = _ip)
when is_integer(a) and is_integer(b) and is_integer(c) and is_integer(d),
do: {:ok, {a, b, c, d}}
defp parse_ip(ip) when is_binary(ip) do
case :inet.parse_address(String.to_charlist(ip)) do
{:ok, ip_tuple} -> {:ok, ip_tuple}
{:error, _} -> {:error, :invalid_ip}
end
end
defp to_string_ip(ip) when is_binary(ip), do: ip
defp to_string_ip(ip) when is_tuple(ip), do: :inet.ntoa(ip) |> to_string()
defp to_string_ip([a, b, c, d]), do: "#{a}.#{b}.#{c}.#{d}"
end