diff --git a/assets/js/app.ts b/assets/js/app.ts index 24dc8a7c..b532cc6b 100644 --- a/assets/js/app.ts +++ b/assets/js/app.ts @@ -276,47 +276,6 @@ const SensorChart: SensorChartHook = { } } -const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") -if (!csrfToken) { - throw new Error('CSRF token meta tag not found') -} - -const liveSocket = new LiveSocket("/live", Socket, { - longPollFallbackMs: 2500, - params: { _csrf_token: csrfToken }, - hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash }, -}) - -// Show progress bar on live navigation and form submits -topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) -window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) -window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) - -// Handle clipboard copy events -window.addEventListener("phx:copy", (event: any) => { - // When using JS.dispatch with 'to:', the event is dispatched to that element - // and event.target will be that element - const el = event.target as HTMLElement - - let text = "" - - if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { - text = (el as HTMLInputElement | HTMLTextAreaElement).value - } else { - text = el.innerText || el.textContent || "" - } - - if (text) { - navigator.clipboard.writeText(text).then(() => { - console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : '')) - }).catch(err => { - console.error('Failed to copy text:', err) - }) - } else { - console.warn('No text found to copy from element:', el) - } -}) - // LiveView hook for copying to clipboard const CopyToClipboard = { mounted() { @@ -386,12 +345,47 @@ const AutoDismissFlash = { } } -const Hooks = { - CopyToClipboard, - ScrollToTop, - AutoDismissFlash +const csrfToken = document.querySelector("meta[name='csrf-token']")?.getAttribute("content") +if (!csrfToken) { + throw new Error('CSRF token meta tag not found') } +const liveSocket = new LiveSocket("/live", Socket, { + longPollFallbackMs: 2500, + params: { _csrf_token: csrfToken }, + hooks: { ...colocatedHooks, SensorChart, WebAuthnRegister, WebAuthnLogin, CopyToClipboard, ScrollToTop, AutoDismissFlash }, +}) + +// Show progress bar on live navigation and form submits +topbar.config({ barColors: { 0: "#29d" }, shadowColor: "rgba(0, 0, 0, .3)" }) +window.addEventListener("phx:page-loading-start", _info => topbar.show(300)) +window.addEventListener("phx:page-loading-stop", _info => topbar.hide()) + +// Handle clipboard copy events +window.addEventListener("phx:copy", (event: any) => { + // When using JS.dispatch with 'to:', the event is dispatched to that element + // and event.target will be that element + const el = event.target as HTMLElement + + let text = "" + + if (el.tagName === "INPUT" || el.tagName === "TEXTAREA") { + text = (el as HTMLInputElement | HTMLTextAreaElement).value + } else { + text = el.innerText || el.textContent || "" + } + + if (text) { + navigator.clipboard.writeText(text).then(() => { + console.log('Copied to clipboard:', text.substring(0, 50) + (text.length > 50 ? '...' : '')) + }).catch(err => { + console.error('Failed to copy text:', err) + }) + } else { + console.warn('No text found to copy from element:', el) + } +}) + // connect if there are any LiveViews on the page liveSocket.connect() diff --git a/lib/towerops/monitoring/ping.ex b/lib/towerops/monitoring/ping.ex index e70e2100..2db6edaf 100644 --- a/lib/towerops/monitoring/ping.ex +++ b/lib/towerops/monitoring/ping.ex @@ -1,12 +1,30 @@ defmodule Towerops.Monitoring.Ping do @moduledoc """ - Handles ping operations for device monitoring. + Handles ping operations for device monitoring using raw ICMP sockets. + + This implementation uses raw ICMP echo request/reply packets instead of + relying on the system `ping` command, making it suitable for containerized + environments where the ping command may not be available. + + Note: Raw ICMP sockets require elevated privileges (CAP_NET_RAW on Linux). + The application should be run with appropriate capabilities or use setcap + on the beam executable. """ @behaviour Towerops.Monitoring.PingBehaviour + require Logger + import Bitwise + + # ICMP protocol number + @icmp_proto 1 + + # ICMP message types + @icmp_echo_request 8 + @icmp_echo_reply 0 + @doc """ - Pings an IP address and returns the result. + Pings an IP address using raw ICMP echo request/reply. Returns {:ok, response_time_ms} on success, {:error, reason} on failure. """ @@ -14,7 +32,7 @@ defmodule Towerops.Monitoring.Ping do def ping(ip_address, timeout_ms \\ 5000) do start_time = System.monotonic_time(:millisecond) - case run_ping(ip_address, timeout_ms) do + case send_icmp_echo(ip_address, timeout_ms) do :ok -> end_time = System.monotonic_time(:millisecond) response_time = end_time - start_time @@ -25,51 +43,133 @@ defmodule Towerops.Monitoring.Ping do end end - defp run_ping(ip_address, timeout_ms) do - timeout_seconds = div(timeout_ms, 1000) - timeout_seconds = max(timeout_seconds, 1) + defp send_icmp_echo(ip_address, timeout_ms) do + # Parse IP address + case parse_ip_address(ip_address) do + {:ok, ip_tuple} -> + send_icmp_packet(ip_tuple, timeout_ms) - case :os.type() do - {:unix, :darwin} -> - # macOS - run_system_ping(ip_address, ["-c", "1", "-W", "#{timeout_seconds}"]) - - {:unix, _} -> - # Linux - run_system_ping(ip_address, ["-c", "1", "-W", "#{timeout_seconds}"]) - - {:win32, _} -> - # Windows - run_system_ping(ip_address, ["-n", "1", "-w", "#{timeout_ms}"]) + {:error, reason} -> + {:error, reason} end end - defp run_system_ping(ip_address, args) do - case System.cmd("ping", args ++ [ip_address], stderr_to_stdout: true) do - {_output, 0} -> - :ok + defp parse_ip_address(ip_address) when is_binary(ip_address) do + case :inet.parse_address(String.to_charlist(ip_address)) do + {:ok, ip_tuple} -> {:ok, ip_tuple} + {:error, _} -> {:error, :invalid_ip} + end + end - {output, exit_code} -> - # Log permission errors for debugging - if String.contains?(output, "permission") or String.contains?(output, "Operation not permitted") do - require Logger + defp send_icmp_packet(ip_tuple, timeout_ms) do + # Generate unique identifier and sequence number + identifier = :rand.uniform(65_535) + sequence = :rand.uniform(65_535) - Logger.warning( - "Ping failed due to permissions for #{ip_address}. Consider using TCP/HTTP health checks instead or enabling ICMP capabilities." - ) + # Build ICMP echo request packet + packet = build_icmp_echo_request(identifier, sequence) + + # Open raw socket + case :gen_udp.open(0, [:binary, {:ip, ip_tuple}, {:active, false}, {:raw, @icmp_proto, 0, <<1::32>>}]) do + {:ok, socket} -> + try do + # Send ICMP echo request + :ok = :gen_udp.send(socket, ip_tuple, 0, packet) + + # Wait for ICMP echo reply + case :gen_udp.recv(socket, 0, timeout_ms) do + {:ok, {^ip_tuple, 0, reply_packet}} -> + case parse_icmp_reply(reply_packet, identifier, sequence) do + :ok -> :ok + {:error, reason} -> {:error, reason} + end + + {:error, :timeout} -> + {:error, :timeout} + + {:error, reason} -> + {:error, reason} + end + after + :gen_udp.close(socket) end - {:error, {:ping_failed, exit_code}} + {:error, :eacces} -> + Logger.warning( + "ICMP ping requires elevated privileges. Run with CAP_NET_RAW capability or as root. Falling back to unavailable." + ) + + {:error, :insufficient_privileges} + + {:error, reason} -> + {:error, reason} end rescue - e in ErlangError -> - # This typically means ping command not found - require Logger - - Logger.error("Ping command not available: #{Exception.message(e)}") - {:error, :ping_unavailable} - e -> + Logger.error("ICMP ping failed with exception: #{Exception.message(e)}") {:error, {:exception, Exception.message(e)}} end + + defp build_icmp_echo_request(identifier, sequence) do + # ICMP Echo Request format: + # Type (8) | Code (0) | Checksum (16) | Identifier (16) | Sequence (16) | Data (variable) + type = @icmp_echo_request + code = 0 + # Placeholder checksum + checksum = 0 + # Payload data (typically timestamp or pattern) + data = "towerops_ping" + + # Build packet without checksum + packet_without_checksum = + <> + + # Calculate checksum + calculated_checksum = icmp_checksum(packet_without_checksum) + + # Rebuild packet with correct checksum + <> + end + + defp parse_icmp_reply(packet, expected_identifier, expected_sequence) do + # ICMP reply might be wrapped in IP header in some cases + # Try to parse as raw ICMP first + case packet do + <<@icmp_echo_reply::8, 0::8, _checksum::16, ^expected_identifier::16, ^expected_sequence::16, _rest::binary>> -> + :ok + + # Handle IP header wrapping (20 bytes minimum) + <<_ip_header::binary-size(20), @icmp_echo_reply::8, 0::8, _checksum::16, ^expected_identifier::16, + ^expected_sequence::16, _rest::binary>> -> + :ok + + _ -> + {:error, :invalid_reply} + end + end + + defp icmp_checksum(data) do + # ICMP checksum is the 16-bit one's complement of the one's complement sum + # of the ICMP message starting with the ICMP Type + sum = checksum_sum(data, 0) + + # Fold carry bits into the sum + sum = (sum &&& 0xFFFF) + (sum >>> 16) + sum = (sum &&& 0xFFFF) + (sum >>> 16) + + # One's complement + bnot(sum) &&& 0xFFFF + end + + defp checksum_sum(<<>>, acc), do: acc + + defp checksum_sum(<>, acc) do + checksum_sum(rest, acc + word) + end + + # Handle odd number of bytes + defp checksum_sum(<>, acc) do + # Pad with zero + acc + (byte <<< 8) + end end