fix AutoDismissFlash hook order and implement raw ICMP ping

AutoDismissFlash hook fix:
- Move hook definitions before LiveSocket initialization
- Ensures hooks are defined when referenced in LiveSocket constructor
- Remove duplicate Hooks object

Raw ICMP ping implementation:
- Replace system ping command with pure Elixir implementation
- Use raw ICMP sockets for echo request/reply
- Build ICMP packets manually with proper checksum calculation
- Parse IP addresses using :inet.parse_address/1
- Handle both raw ICMP and IP-wrapped responses
- Requires CAP_NET_RAW capability in production containers
- Suitable for containerized environments without ping command
This commit is contained in:
Graham McIntire 2026-01-18 10:56:04 -06:00
parent b0d1c8d9b1
commit f530c1b9a6
No known key found for this signature in database
2 changed files with 176 additions and 82 deletions

View file

@ -276,47 +276,6 @@ const SensorChart: SensorChartHook = {
}
}
const csrfToken = document.querySelector<HTMLMetaElement>("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<HTMLMetaElement>("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()

View file

@ -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 =
<<type::8, code::8, checksum::16, identifier::16, sequence::16, data::binary>>
# Calculate checksum
calculated_checksum = icmp_checksum(packet_without_checksum)
# Rebuild packet with correct checksum
<<type::8, code::8, calculated_checksum::16, identifier::16, sequence::16, data::binary>>
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(<<word::16, rest::binary>>, acc) do
checksum_sum(rest, acc + word)
end
# Handle odd number of bytes
defp checksum_sum(<<byte::8>>, acc) do
# Pad with zero
acc + (byte <<< 8)
end
end