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:
parent
b0d1c8d9b1
commit
f530c1b9a6
2 changed files with 176 additions and 82 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue