@@ -620,6 +620,14 @@ pub struct Monitor {
620620 /// record every raw probe result. Not applicable to push monitors.
621621 #[ serde( default ) ]
622622 pub probe_retries : Option < u32 > ,
623+ /// Probe both address families and require both to pass - the classic
624+ /// silent failure is a service whose IPv6 has been dead for weeks behind a
625+ /// healthy IPv4. One broken family marks the monitor down, naming it in
626+ /// the reason ("IPv6 failing: connection timed out (IPv4 ok)"). For http,
627+ /// tcp and icmp monitors with a hostname target; not combinable with
628+ /// `proxy`. The probing host itself needs working IPv4 *and* IPv6.
629+ #[ serde( default ) ]
630+ pub dual_stack : Option < bool > ,
623631 /// Restrict this monitor's alerts to these channel names (e.g. `["ops"]`).
624632 /// Unset = every configured channel.
625633 #[ serde( default ) ]
@@ -714,6 +722,7 @@ impl std::fmt::Debug for Monitor {
714722 . field ( "json_expected" , & self . json_expected )
715723 . field ( "max_body_kb" , & self . max_body_kb )
716724 . field ( "probe_retries" , & self . probe_retries )
725+ . field ( "dual_stack" , & self . dual_stack )
717726 . field ( "notify" , & self . notify )
718727 . field ( "proxy" , & self . proxy . as_deref ( ) . map ( redact_url_credentials) )
719728 . field ( "push_token" , & self . push_token )
@@ -762,6 +771,12 @@ impl Monitor {
762771 Duration :: from_secs ( self . interval_secs )
763772 }
764773
774+ /// Whether this monitor probes both address families (default off).
775+ #[ must_use]
776+ pub fn dual_stack ( & self ) -> bool {
777+ self . dual_stack . unwrap_or ( false )
778+ }
779+
765780 /// Whether this monitor should have its TLS certificate expiry checked.
766781 #[ must_use]
767782 pub fn checks_cert ( & self ) -> bool {
@@ -1344,10 +1359,49 @@ fn validate_monitor_io(monitor: &Monitor) -> anyhow::Result<()> {
13441359 monitor. id
13451360 ) ;
13461361 }
1362+ if monitor. dual_stack ( ) {
1363+ validate_dual_stack ( monitor) ?;
1364+ }
13471365 validate_schedule_and_slo ( monitor) ?;
13481366 Ok ( ( ) )
13491367}
13501368
1369+ /// `dual_stack` needs a probe that can be steered per address family: an
1370+ /// active kind, no proxy in the way, and a hostname target (an IP literal has
1371+ /// a single family by construction). Runs after the per-kind target checks,
1372+ /// so the target is already known to be well-shaped.
1373+ fn validate_dual_stack ( monitor : & Monitor ) -> anyhow:: Result < ( ) > {
1374+ anyhow:: ensure!(
1375+ matches!( monitor. kind, Kind :: Http | Kind :: Tcp | Kind :: Icmp ) ,
1376+ "monitor {}: dual_stack requires an http, tcp or icmp monitor" ,
1377+ monitor. id
1378+ ) ;
1379+ anyhow:: ensure!(
1380+ monitor. proxy. is_none( ) ,
1381+ "monitor {}: dual_stack cannot go through a proxy (only the proxy hop's family would be tested)" ,
1382+ monitor. id
1383+ ) ;
1384+ let host = match monitor. kind {
1385+ Kind :: Http => reqwest:: Url :: parse ( & monitor. target )
1386+ . ok ( )
1387+ . and_then ( |url| url. host_str ( ) . map ( str:: to_owned) ) ,
1388+ Kind :: Tcp => monitor
1389+ . target
1390+ . rsplit_once ( ':' )
1391+ . map ( |( host, _port) | host. to_owned ( ) ) ,
1392+ _ => Some ( monitor. target . clone ( ) ) ,
1393+ } ;
1394+ // URL and tcp hosts may carry an IPv6 literal in brackets.
1395+ let host = host. unwrap_or_default ( ) ;
1396+ let bare = host. trim_start_matches ( '[' ) . trim_end_matches ( ']' ) ;
1397+ anyhow:: ensure!(
1398+ bare. parse:: <std:: net:: IpAddr >( ) . is_err( ) ,
1399+ "monitor {}: dual_stack requires a hostname target (an IP literal has a single address family)" ,
1400+ monitor. id
1401+ ) ;
1402+ Ok ( ( ) )
1403+ }
1404+
13511405/// Validate the push `schedule`/`grace_secs` pair and the availability SLO
13521406/// fields. The cron expression is parsed here so a typo fails at load, not at
13531407/// the first missed heartbeat.
@@ -1708,6 +1762,59 @@ mod tests {
17081762 assert ! ( error. contains( "require an http monitor" ) , "got: {error}" ) ;
17091763 }
17101764
1765+ #[ test]
1766+ fn dual_stack_validation ( ) {
1767+ let base = |body : & str | {
1768+ format ! (
1769+ r#"
1770+ [page]
1771+ [server]
1772+ [[monitors]]
1773+ id = "x"
1774+ name = "X"
1775+ interval_secs = 60
1776+ dual_stack = true
1777+ {body}
1778+ "#
1779+ )
1780+ } ;
1781+
1782+ // Hostname targets pass for the three active kinds.
1783+ for body in [
1784+ "target = \" https://example.com\" " ,
1785+ "kind = \" tcp\" \n target = \" db.example.com:5432\" " ,
1786+ "kind = \" icmp\" \n target = \" gw.example.com\" " ,
1787+ ] {
1788+ validate ( & parse ( & base ( body) ) ) . expect ( "hostname target valid" ) ;
1789+ }
1790+
1791+ // An IP literal has a single family - rejected, brackets included.
1792+ for body in [
1793+ "target = \" https://192.0.2.1/health\" " ,
1794+ "target = \" https://[2001:db8::1]/health\" " ,
1795+ "kind = \" tcp\" \n target = \" 192.0.2.1:5432\" " ,
1796+ "kind = \" icmp\" \n target = \" 2001:db8::1\" " ,
1797+ ] {
1798+ let error = validate ( & parse ( & base ( body) ) ) . unwrap_err ( ) . to_string ( ) ;
1799+ assert ! ( error. contains( "requires a hostname" ) , "got: {error}" ) ;
1800+ }
1801+
1802+ // Wrong kind, and proxy combination.
1803+ let error = validate ( & parse ( & base ( "kind = \" dns\" \n target = \" example.com\" " ) ) )
1804+ . unwrap_err ( )
1805+ . to_string ( ) ;
1806+ assert ! (
1807+ error. contains( "requires an http, tcp or icmp monitor" ) ,
1808+ "got: {error}"
1809+ ) ;
1810+ let error = validate ( & parse ( & base (
1811+ "target = \" https://example.com\" \n proxy = \" http://127.0.0.1:8080\" " ,
1812+ ) ) )
1813+ . unwrap_err ( )
1814+ . to_string ( ) ;
1815+ assert ! ( error. contains( "cannot go through a proxy" ) , "got: {error}" ) ;
1816+ }
1817+
17111818 #[ test]
17121819 fn maintenance_window_mutes_selected_monitor ( ) {
17131820 let config = parse (
0 commit comments