@@ -493,6 +493,11 @@ pub enum Kind {
493493 #[ default]
494494 Http ,
495495 Tcp ,
496+ /// ICMP echo (ping). Target is a host or IP (no port). Uses an unprivileged
497+ /// datagram socket, so it works in rootless Docker without `CAP_NET_RAW`
498+ /// (the kernel's `net.ipv4.ping_group_range` must cover the process, which is
499+ /// the Docker default). IPv4 and IPv6 are both supported.
500+ Icmp ,
496501 /// Passive heartbeat: the monitored job pings `/api/push/{id}`; missing a
497502 /// heartbeat within the interval marks it down. No active probing.
498503 Push ,
@@ -505,7 +510,8 @@ pub struct Monitor {
505510 pub name : String ,
506511 #[ serde( default ) ]
507512 pub kind : Kind ,
508- /// Probe target (URL for HTTP, `host:port` for TCP). Unused for push monitors.
513+ /// Probe target (URL for HTTP, `host:port` for TCP, host or IP for ICMP).
514+ /// Unused for push monitors.
509515 #[ serde( default ) ]
510516 pub target : String ,
511517 pub interval_secs : u64 ,
@@ -1000,6 +1006,23 @@ fn validate_monitor_io(monitor: &Monitor) -> anyhow::Result<()> {
10001006 "monitor {}: tcp target must be host:port" ,
10011007 monitor. id
10021008 ) ,
1009+ Kind :: Icmp => {
1010+ // A stray `:port` is a common mistake; an IPv6 literal parses as an IP
1011+ // and is exempt, so its colons are fine.
1012+ let has_port = monitor. target . parse :: < std:: net:: IpAddr > ( ) . is_err ( )
1013+ && monitor
1014+ . target
1015+ . rsplit_once ( ':' )
1016+ . is_some_and ( |( host, port) | !host. is_empty ( ) && port. parse :: < u16 > ( ) . is_ok ( ) ) ;
1017+ anyhow:: ensure!(
1018+ !monitor. target. contains( "://" )
1019+ && !monitor. target. contains( '/' )
1020+ && !monitor. target. chars( ) . any( char :: is_whitespace)
1021+ && !has_port,
1022+ "monitor {}: icmp target must be a bare host or IP (no scheme, path, or port)" ,
1023+ monitor. id
1024+ ) ;
1025+ }
10031026 Kind :: Push => { }
10041027 }
10051028 // A negative latency threshold would mark every check degraded/breached.
@@ -1866,4 +1889,44 @@ mod tests {
18661889 let error = validate ( & config) . unwrap_err ( ) . to_string ( ) ;
18671890 assert ! ( error. contains( "cycle" ) , "got: {error}" ) ;
18681891 }
1892+
1893+ #[ test]
1894+ fn parses_icmp_monitor ( ) {
1895+ let config = parse (
1896+ r#"
1897+ [page]
1898+ [server]
1899+ [[monitors]]
1900+ id = "host"
1901+ name = "Host"
1902+ kind = "icmp"
1903+ target = "192.0.2.1"
1904+ interval_secs = 30
1905+ "# ,
1906+ ) ;
1907+ assert_eq ! ( config. monitors[ 0 ] . kind, Kind :: Icmp ) ;
1908+ // ICMP is not HTTPS, so no certificate check.
1909+ assert ! ( !config. monitors[ 0 ] . checks_cert( ) ) ;
1910+ validate ( & config) . expect ( "valid icmp monitor" ) ;
1911+ }
1912+
1913+ #[ test]
1914+ fn rejects_icmp_target_with_scheme_or_port ( ) {
1915+ for bad in [ "https://example.com" , "1.2.3.4:443" , "a/b" ] {
1916+ let config = parse ( & format ! (
1917+ r#"
1918+ [page]
1919+ [server]
1920+ [[monitors]]
1921+ id = "host"
1922+ name = "Host"
1923+ kind = "icmp"
1924+ target = "{bad}"
1925+ interval_secs = 30
1926+ "#
1927+ ) ) ;
1928+ let error = validate ( & config) . unwrap_err ( ) . to_string ( ) ;
1929+ assert ! ( error. contains( "bare host or IP" ) , "{bad} -> {error}" ) ;
1930+ }
1931+ }
18691932}
0 commit comments