- REST CRUD at /api/v1/checks for HTTP, TCP, DNS, ping checks - GraphQL queries (checks, check) and mutations (create/update/delete) - ping executor using system ping with macOS/Linux parsing - check config validation for ping type (requires host) - API documentation updated with checks resource section Reviewed-on: graham/towerops-web#52
125 lines
3.7 KiB
Elixir
125 lines
3.7 KiB
Elixir
defmodule Towerops.Monitoring.Executors.PingExecutor do
|
|
@moduledoc """
|
|
Executes ICMP ping checks using the system `ping` command.
|
|
|
|
Config format:
|
|
%{
|
|
"host" => "10.0.0.1",
|
|
"count" => 3 # optional, default: 3
|
|
}
|
|
"""
|
|
|
|
require Logger
|
|
|
|
@default_count 3
|
|
@default_timeout 5000
|
|
|
|
@doc """
|
|
Executes a ping check.
|
|
|
|
Returns:
|
|
- {:ok, response_time_ms, output} on success
|
|
- {:error, reason} on failure
|
|
"""
|
|
def execute(config, timeout_ms \\ @default_timeout) do
|
|
host = Map.fetch!(config, "host")
|
|
count = config |> Map.get("count", @default_count) |> max(1) |> min(10)
|
|
|
|
with :ok <- validate_host(host) do
|
|
args = build_args(host, count, timeout_ms)
|
|
|
|
# Use timeout_ms plus buffer for the system call
|
|
system_timeout = timeout_ms + count * 1000 + 2000
|
|
|
|
case System.cmd("ping", args, stderr_to_stdout: true, timeout: system_timeout) do
|
|
{output, 0} ->
|
|
parse_output(output)
|
|
|
|
{output, _exit_code} ->
|
|
# Non-zero exit — try to parse anyway (partial loss still has stats)
|
|
case parse_output(output) do
|
|
{:ok, _, _} = success -> success
|
|
{:error, _} = error -> error
|
|
end
|
|
end
|
|
end
|
|
rescue
|
|
e ->
|
|
Logger.error("Ping check exception: #{inspect(e)}")
|
|
{:error, "Exception: #{Exception.message(e)}"}
|
|
catch
|
|
:exit, {:timeout, _} ->
|
|
{:error, "Ping command timed out"}
|
|
end
|
|
|
|
@doc """
|
|
Parses ping command output to extract RTT and packet loss.
|
|
"""
|
|
def parse_output(output) when is_binary(output) and byte_size(output) > 0 do
|
|
with {:ok, loss_pct, packets_info} <- parse_packet_loss(output),
|
|
{:ok, avg_ms} <- parse_rtt(output) do
|
|
if loss_pct >= 100.0 do
|
|
{:error, "100% packet loss"}
|
|
else
|
|
summary = "#{packets_info}, #{format_loss(loss_pct)} loss, avg #{avg_ms}ms"
|
|
{:ok, avg_ms, summary}
|
|
end
|
|
end
|
|
end
|
|
|
|
def parse_output(_), do: {:error, "No ping output"}
|
|
|
|
defp validate_host(host) do
|
|
# Only allow valid hostnames and IP addresses — no shell metacharacters
|
|
if Regex.match?(~r/^[a-zA-Z0-9\.\-\:]+$/, host) do
|
|
:ok
|
|
else
|
|
{:error, "Invalid host: contains disallowed characters"}
|
|
end
|
|
end
|
|
|
|
defp build_args(host, count, timeout_ms) do
|
|
count_str = Integer.to_string(count)
|
|
|
|
case :os.type() do
|
|
{:unix, :darwin} ->
|
|
# macOS: -W is in milliseconds
|
|
["-c", count_str, "-W", Integer.to_string(timeout_ms), host]
|
|
|
|
{:unix, _} ->
|
|
# Linux: -w is in seconds (total deadline)
|
|
deadline = max(div(timeout_ms, 1000), count + 1)
|
|
["-c", count_str, "-w", Integer.to_string(deadline), host]
|
|
end
|
|
end
|
|
|
|
defp parse_packet_loss(output) do
|
|
# Match patterns like "3 packets transmitted, 3 packets received, 0.0% packet loss"
|
|
# or "3 packets transmitted, 3 received, 0% packet loss"
|
|
case Regex.run(~r/(\d+) packets? transmitted, (\d+) (?:packets? )?received, ([\d.]+)% packet loss/, output) do
|
|
[_, transmitted, _received, loss_str] ->
|
|
{loss_pct, _} = Float.parse(loss_str)
|
|
{:ok, loss_pct, "#{transmitted} packets"}
|
|
|
|
nil ->
|
|
{:error, "Could not parse packet loss from ping output"}
|
|
end
|
|
end
|
|
|
|
defp parse_rtt(output) do
|
|
# macOS: "round-trip min/avg/max/stddev = 0.042/0.059/0.068/0.012 ms"
|
|
# Linux: "rtt min/avg/max/mdev = 1.120/1.266/1.450/0.140 ms"
|
|
case Regex.run(~r/(?:round-trip|rtt) min\/avg\/max\/(?:std|m)dev = [\d.]+\/([\d.]+)\//, output) do
|
|
[_, avg_str] ->
|
|
{avg_ms, _} = Float.parse(avg_str)
|
|
{:ok, avg_ms}
|
|
|
|
nil ->
|
|
# No RTT stats (100% loss case)
|
|
{:error, "100% packet loss"}
|
|
end
|
|
end
|
|
|
|
defp format_loss(loss_pct) when loss_pct == 0.0, do: "0%"
|
|
defp format_loss(loss_pct), do: "#{Float.round(loss_pct, 1)}%"
|
|
end
|