towerops/lib/towerops/monitoring/ping.ex
Graham McIntire ba5464332b
fix: use system ping command instead of raw ICMP sockets
- Rewrote Ping module to use system ping binary instead of raw ICMP
  sockets which required CAP_NET_RAW privileges
- Added iputils-ping package to Docker images for production
- Added proper IP address validation before executing ping command
- Updated tests to account for 1 second minimum timeout of system ping
- System ping binary is setuid root and works without elevated privileges
2026-01-21 11:15:59 -06:00

114 lines
3.5 KiB
Elixir

defmodule Towerops.Monitoring.Ping do
@moduledoc """
Handles ping operations for device monitoring using the system ping command.
This implementation uses the system `ping` binary which is typically setuid root,
allowing ICMP operations without requiring CAP_NET_RAW or elevated privileges
for the application itself.
"""
@behaviour Towerops.Monitoring.PingBehaviour
require Logger
@doc """
Pings an IP address using the system ping command with default timeout (5 seconds).
Returns {:ok, response_time_ms} on success, {:error, reason} on failure.
Response time is returned as a float with millisecond precision.
"""
@impl true
def ping(ip_address) when is_binary(ip_address) do
ping(ip_address, 5000)
end
@doc """
Pings an IP address using the system ping command with custom timeout.
Returns {:ok, response_time_ms} on success, {:error, reason} on failure.
Response time is returned as a float with millisecond precision.
Note: The system ping command has a minimum timeout of 1 second.
Timeouts less than 1000ms will be rounded up to 1 second.
"""
@impl true
def ping(ip_address, timeout_ms) when is_binary(ip_address) and is_integer(timeout_ms) do
case validate_ip_address(ip_address) do
:ok ->
do_ping(ip_address, timeout_ms)
{:error, _} = error ->
error
end
end
defp do_ping(ip_address, timeout_ms) do
timeout_seconds = max(1, div(timeout_ms, 1000))
# Use -W for timeout on macOS/BSD, -w for Linux
# -c 1 sends a single ping
args =
case :os.type() do
{:unix, :darwin} ->
# macOS: -W is timeout in ms, but we use seconds for consistency
["-c", "1", "-W", to_string(timeout_seconds * 1000), ip_address]
{:unix, _} ->
# Linux: -W is timeout in seconds
["-c", "1", "-W", to_string(timeout_seconds), ip_address]
_ ->
# Fallback
["-c", "1", "-W", to_string(timeout_seconds), ip_address]
end
case System.cmd("ping", args, stderr_to_stdout: true) do
{output, 0} ->
parse_ping_output(output)
{output, _exit_code} ->
Logger.debug("Ping failed for #{ip_address}: #{String.trim(output)}")
{:error, :timeout}
end
rescue
e in ErlangError ->
Logger.error("Ping command failed: #{inspect(e)}")
{:error, :command_not_found}
end
# Validate IP address format using Erlang's inet module
defp validate_ip_address(""), do: {:error, :invalid_ip}
defp validate_ip_address(ip_address) do
case :inet.parse_address(String.to_charlist(ip_address)) do
{:ok, _} -> :ok
{:error, _} -> {:error, :invalid_ip}
end
end
# Parse the ping output to extract round-trip time
# macOS format: "round-trip min/avg/max/stddev = 1.234/1.234/1.234/0.000 ms"
# Linux format: "rtt min/avg/max/mdev = 1.234/1.234/1.234/0.000 ms"
defp parse_ping_output(output) do
cond do
# macOS format
match = Regex.run(~r/round-trip.*=\s*[\d.]+\/([\d.]+)\//, output) ->
[_, avg_ms] = match
{:ok, String.to_float(avg_ms)}
# Linux format
match = Regex.run(~r/rtt.*=\s*[\d.]+\/([\d.]+)\//, output) ->
[_, avg_ms] = match
{:ok, String.to_float(avg_ms)}
# Alternative: extract from "time=X.XX ms" in the reply line
match = Regex.run(~r/time[=<]([\d.]+)\s*ms/, output) ->
[_, time_ms] = match
{:ok, String.to_float(time_ms)}
true ->
Logger.warning("Could not parse ping output: #{String.trim(output)}")
{:error, :parse_error}
end
end
end