- 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
114 lines
3.5 KiB
Elixir
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
|