Skip to content

Commit 40b4ebf

Browse files
committed
feat: multi-vantage confirmation via the peers
The headline of 0.6.0: when a monitor confirms down locally, the peers probe the same target from their side before the alert goes out, and the alert carries the verdict - "confirmed down from 3/3 vantage points" (real outage) vs "seen UP by hora-b - network issue near this node?". Two Raspberry Pi at two homes become a distributed Pingdom. Built to be boring under failure: - Strictly fail-open: peers being slow, broken, unreachable or misconfigured never block, delay past a hard 10s concurrent deadline, or suppress the alert. The worst outcome is an alert without the annotation - exactly what Hora sent before. The incident record is written before the peers are consulted. - Never a proxy: POST /api/peer/probe only probes targets present in the responder's OWN config (matched on kind + target, probed with its own timeout/assertions/proxy and no retries), so a leaked token cannot turn a peer into an SSRF relay. The requesting peer's listen_token is strictly required (the id alone never authorizes), unknown peers answer exactly like wrong tokens, and response bodies are read bounded (16 KiB) like the witness reports. - A disputed down still alerts: a peer seeing the target up softens the message, never silences it - geo-partial outages are real. - Plumbing: Event::Down gains a vantage field rendered by all ten backends; DownAlert carries it through the coalescer; peer requests ride the shared plain client, never a monitor's proxy client. Enabled with [health] confirm_with_peers = true (per-monitor override), validated at load (needs [health] + a peer with ping_url; push monitors can't opt in). Tested by a two-real-nodes round trip over live HTTP sockets covering agreement, dispute, wrong-token and dead-peer modes - plus a manual two-daemon smoke test end to end.
1 parent 2e44809 commit 40b4ebf

34 files changed

Lines changed: 1034 additions & 61 deletions

CHANGELOG.md

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,34 @@ All notable changes to this project are documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8-
## [Unreleased]
8+
## [0.6.0] - 2026-06-12
99

1010
### Added
1111

12+
- **Multi-vantage confirmation** (`confirm_with_peers`, the headline of
13+
0.6.0): when a monitor confirms down locally, the peers probe the same
14+
target from their side before the alert goes out, and the alert carries
15+
the verdict - *"confirmed down from 3/3 vantage points"* (a real outage)
16+
vs *"seen UP by hora-b - network issue near this node?"*. Two Raspberry Pi
17+
at two homes become a distributed Pingdom. Built to be boring under
18+
failure:
19+
- **Strictly fail-open**: peers being slow, broken, unreachable or
20+
misconfigured never block, delay past a hard 10s deadline (probes run
21+
concurrently), or suppress the alert - the worst outcome is an alert
22+
without the annotation, exactly what Hora sent before. The incident
23+
record is written *before* the peers are consulted.
24+
- **Never a proxy**: the new `POST /api/peer/probe` only probes targets
25+
present in the responder's *own* configuration (matched on kind +
26+
target, probed with its own settings), so a leaked token cannot turn a
27+
peer into an SSRF relay. It strictly requires the requesting peer's
28+
`listen_token` - the id alone never authorizes - and unknown peers are
29+
indistinguishable from wrong tokens.
30+
- **A disputed down still alerts**: a peer seeing the target up softens
31+
the message, never silences it - geo-partial outages are real outages.
32+
- Enabled globally with `[health] confirm_with_peers = true`, overridden
33+
per monitor; peer probe requests never ride a monitor's proxy; verified
34+
end-to-end by a two-real-nodes test over live HTTP sockets.
35+
1236
- **Per-group status pages** (`/status/{group}`): one display group's
1337
monitors, nothing else - lightweight multi-tenancy for an operator hosting
1438
several clients on one Hora. A per-group token (`server.group_tokens`)

Cargo.lock

Lines changed: 4 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ members = [
88
]
99

1010
[workspace.package]
11-
version = "0.5.1"
11+
version = "0.6.0"
1212
edition = "2024"
1313
license = "MIT"
1414
publish = false

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,12 @@ Full guides for everything below live in the
4444
**Alerting that never cries wolf**
4545

4646
- **Down only after N consecutive failures**; degraded alerts opt-in.
47+
- **Multi-vantage confirmation** (`confirm_with_peers`) - before alerting, your
48+
peers probe the same target from *their* side: _"confirmed down from 3/3
49+
vantage points"_ (real outage) vs _"seen UP by hora-b"_ (network issue near
50+
this node). Two Raspberry Pi at two homes become a distributed Pingdom -
51+
and a peer only ever probes targets in its **own** config, never arbitrary
52+
ones.
4753
- **Root-cause grouping** - a database taking ten services down sends **one**
4854
notification, annotated _"caused by X"_ / _"impacts: A, B, C"_ via `depends_on`.
4955
- **SLOs & error budgets** - `slo_uptime = 99.9` shows the budget left and arms
@@ -221,6 +227,7 @@ rate-limit settings are read once at startup and still require a restart.
221227
| `GET /api/monitors/{id}/latency?hours=24` | Latency samples `[{ "t", "latency_ms" }]` (404 if unknown). |
222228
| `POST /api/push/{id}` | Record a heartbeat for a push monitor. Send the token as an `X-Push-Token` header (preferred - it stays out of proxy access logs) or as `?token=…`. Optional `status=up\|down\|degraded`, `msg`, `ping`. 401 on a wrong token, 404 if not a push monitor. |
223229
| `POST /api/silence?monitors=api,web&duration=10m` | Mute alerts ad hoc (deploy hook): `monitors` is a comma-separated id list or `all`, `duration` like `10m`/`1h30m` (max 7d), optional `reason`. **Requires `server.auth_token`** (as `Authorization: Bearer` or `?token=`); without one configured the endpoint is closed. |
230+
| `POST /api/peer/probe` | Multi-vantage confirmation between Hora nodes: probe a target *present in this node's own config* and answer with the verdict. Requires the requesting peer's `listen_token` (`X-Push-Token`). Never probes arbitrary targets. |
224231
| `GET /api/monitors/{id}/heatmap.svg` | 28-day hours-by-days latency heatmap (SVG), colour relative to the monitor's median. |
225232
| `GET /api/badge/{id}/status` | Embeddable SVG status badge for a monitor. |
226233
| `GET /api/badge/{id}/uptime` | Embeddable SVG 24h-uptime badge for a monitor. |

UPGRADES.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,25 @@ Version-specific notes when moving between Hora releases. The general
44
procedure (pull the new image, recreate the container, history lives on the
55
`hora-data` volume) is in the [README](README.md#upgrade).
66

7+
## 0.5.1 → 0.6.0
8+
9+
No behavioural changes: everything is opt-in. Notes:
10+
11+
- **Multi-vantage confirmation is off by default.** To enable it, set
12+
`[health] confirm_with_peers = true` on the node(s) that should ask, make
13+
sure each `[[peers]]` entry has a `ping_url` (its origin is where probe
14+
requests go) and that the *responding* node sets a `listen_token` for the
15+
requester - `POST /api/peer/probe` strictly requires it. Both nodes must
16+
know the monitor (same kind + target); a peer never probes a target outside
17+
its own config.
18+
- **One new endpoint** (`POST /api/peer/probe`) exists even with the feature
19+
off; without a matching authenticated peer it answers 401 to everyone.
20+
- New config keys (`confirm_with_peers`, plus 0.6.0's `[digest]`,
21+
`server.group_tokens`) are rejected by older binaries
22+
(`deny_unknown_fields`): **deploy the binary before the config**, on every
23+
node of the mesh. One schema migration (the digest's `meta` table) applies
24+
automatically.
25+
726
## 0.5.0 → 0.5.1
827

928
Two things to know:

config.example.toml

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,16 @@ interval_secs = 90000 # max gap before "down" (~25h for a daily job)
309309
# quorum = true # before alerting a peer down, ask the OTHER peers'
310310
# # /healthz: if any still sees it up, it's a partition
311311
# # (PeerLinkDegraded), not an outage. No-op with < 3 nodes.
312+
# confirm_with_peers = true # multi-vantage confirmation: when a MONITOR confirms
313+
# # down, ask the peers to probe the same target before
314+
# # alerting. The alert says "confirmed down from 3/3
315+
# # vantage points" (real outage) or "seen UP by hora-b"
316+
# # (network issue near this node). A peer only probes
317+
# # targets in its OWN config (never an arbitrary one),
318+
# # and its [[peers]] entry for this node must set a
319+
# # listen_token. Strictly fail-open: unreachable peers
320+
# # never block or suppress the alert. Override per
321+
# # monitor with confirm_with_peers = true/false.
312322
# heartbeat_url = "${HC_PING_URL}" # optional extra dead-man target (e.g. healthchecks.io)
313323

314324
# Each [[peers]] entry has two independent halves; set either or both:

crates/hora-core/src/coalesce.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,8 @@ pub struct DownAlert {
5050
pub cause_name: Option<String>,
5151
pub impacted: Vec<String>,
5252
pub notify: Option<Vec<String>>,
53+
/// Multi-vantage verdict, when peers were asked before this alert.
54+
pub vantage: Option<String>,
5355
}
5456

5557
/// How long a sent (or folded) down alert keeps absorbing late symptom
@@ -260,6 +262,7 @@ async fn send_down(notifier: &Notifiers, alert: &DownAlert) {
260262
error: alert.error.as_deref(),
261263
cause: alert.cause_name.as_deref(),
262264
impacted: &impacted,
265+
vantage: alert.vantage.as_deref(),
263266
},
264267
alert.notify.as_deref(),
265268
)
@@ -279,6 +282,7 @@ mod tests {
279282
cause_name: upstreams.first().map(|u| u.to_uppercase()),
280283
impacted: Vec::new(),
281284
notify: None,
285+
vantage: None,
282286
}
283287
}
284288

crates/hora-core/src/config.rs

Lines changed: 163 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -492,6 +492,14 @@ pub struct Health {
492492
/// `interval_secs` when locally healthy, alongside the peers' `ping_url`.
493493
#[serde(default)]
494494
pub heartbeat_url: Option<Secret>,
495+
/// Multi-vantage confirmation: when a monitor confirms down locally, ask
496+
/// the peers to probe the same target from their side before alerting,
497+
/// and annotate the alert with the verdict ("confirmed from 3/3 vantage
498+
/// points" vs "seen UP by hora-b"). Per-monitor `confirm_with_peers`
499+
/// overrides this default either way. Strictly fail-open: peers being
500+
/// slow, broken or unreachable never blocks or suppresses the alert.
501+
#[serde(default)]
502+
pub confirm_with_peers: bool,
495503
}
496504

497505
impl Health {
@@ -568,16 +576,30 @@ impl Peer {
568576
if let Some(url) = &self.witness_url {
569577
return Some(url.clone());
570578
}
579+
self.api_origin().map(|origin| format!("{origin}/healthz"))
580+
}
581+
582+
/// The peer's `/api/peer/probe` URL for multi-vantage confirmation, derived
583+
/// from the origin of `ping_url`. `None` when there is no `ping_url` (an
584+
/// IN-only or external peer cannot be asked to probe).
585+
#[must_use]
586+
pub fn probe_url(&self) -> Option<String> {
587+
self.api_origin()
588+
.map(|origin| format!("{origin}/api/peer/probe"))
589+
}
590+
591+
/// The `scheme://host[:port]` origin of the peer's API, from its `ping_url`.
592+
fn api_origin(&self) -> Option<String> {
571593
let ping = self.ping_url.as_ref()?;
572594
let url = reqwest::Url::parse(ping.as_ref()).ok()?;
573595
let origin = url.origin();
574-
origin
575-
.is_tuple()
576-
.then(|| format!("{}/healthz", origin.ascii_serialization()))
596+
origin.is_tuple().then(|| origin.ascii_serialization())
577597
}
578598
}
579599

580-
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize)]
600+
#[derive(
601+
Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, serde::Serialize, utoipa::ToSchema,
602+
)]
581603
#[serde(rename_all = "lowercase")]
582604
pub enum Kind {
583605
#[default]
@@ -736,6 +758,10 @@ pub struct Monitor {
736758
/// public-suffix list, and the operator already knows the answer.
737759
#[serde(default)]
738760
pub domain_expiry: Option<String>,
761+
/// Multi-vantage confirmation override for this monitor; unset = the
762+
/// `[health].confirm_with_peers` default. See that field for semantics.
763+
#[serde(default)]
764+
pub confirm_with_peers: Option<bool>,
739765
}
740766

741767
// Manual `Debug` (rather than derived) so credentials never leak: `target` and
@@ -780,6 +806,7 @@ impl std::fmt::Debug for Monitor {
780806
.field("dns_resolver", &self.dns_resolver)
781807
.field("cert_pin", &self.cert_pin)
782808
.field("domain_expiry", &self.domain_expiry)
809+
.field("confirm_with_peers", &self.confirm_with_peers)
783810
.finish()
784811
}
785812
}
@@ -1182,8 +1209,52 @@ fn validate(config: &Config) -> anyhow::Result<()> {
11821209

11831210
validate_peers(config, &seen, &channel_names)?;
11841211

1185-
// Digest: the cron must parse at load (not at the first missed send), and
1186-
// routes must name real channels, like a monitor's `notify`.
1212+
validate_confirm(config)?;
1213+
validate_digest(config, &channel_names)?;
1214+
Ok(())
1215+
}
1216+
1217+
/// Multi-vantage confirmation needs peers that can actually be asked: a
1218+
/// `[health]` section and at least one peer with a `ping_url` (its API origin).
1219+
/// A flag that can never confirm anything is a config mistake, not a plan.
1220+
fn validate_confirm(config: &Config) -> anyhow::Result<()> {
1221+
let confirmable_peers = config.peers.iter().any(|peer| peer.probe_url().is_some());
1222+
let confirm_requested = config
1223+
.health
1224+
.as_ref()
1225+
.is_some_and(|health| health.confirm_with_peers)
1226+
|| config
1227+
.monitors
1228+
.iter()
1229+
.any(|monitor| monitor.confirm_with_peers == Some(true));
1230+
if confirm_requested {
1231+
anyhow::ensure!(
1232+
config.health.is_some(),
1233+
"confirm_with_peers requires a [health] section"
1234+
);
1235+
anyhow::ensure!(
1236+
confirmable_peers,
1237+
"confirm_with_peers requires at least one peer with a ping_url \
1238+
(its API origin is where probe requests go)"
1239+
);
1240+
}
1241+
for monitor in &config.monitors {
1242+
anyhow::ensure!(
1243+
!(monitor.confirm_with_peers == Some(true) && monitor.kind == Kind::Push),
1244+
"monitor {}: confirm_with_peers needs an active probe (push monitors \
1245+
have no target a peer could probe)",
1246+
monitor.id
1247+
);
1248+
}
1249+
Ok(())
1250+
}
1251+
1252+
/// Digest: the cron must parse at load (not at the first missed send), and
1253+
/// routes must name real channels, like a monitor's `notify`.
1254+
fn validate_digest(
1255+
config: &Config,
1256+
channel_names: &std::collections::HashSet<&str>,
1257+
) -> anyhow::Result<()> {
11871258
if let Some(digest) = &config.digest {
11881259
digest.schedule.parse::<croner::Cron>().map_err(|err| {
11891260
anyhow::anyhow!("digest: invalid schedule {:?}: {err}", digest.schedule)
@@ -2219,6 +2290,92 @@ mod tests {
22192290
assert!(error.contains("duplicate channel name"), "got: {error}");
22202291
}
22212292

2293+
#[test]
2294+
fn confirm_with_peers_requires_health_and_a_probeable_peer() {
2295+
// Global flag without [health]: rejected.
2296+
let err = super::parse(
2297+
r#"
2298+
[page]
2299+
[server]
2300+
[[monitors]]
2301+
id = "web"
2302+
name = "Web"
2303+
target = "https://example.com"
2304+
interval_secs = 60
2305+
confirm_with_peers = true
2306+
"#,
2307+
)
2308+
.unwrap_err()
2309+
.to_string();
2310+
assert!(err.contains("[health]"), "{err}");
2311+
2312+
// [health] flag but no peer with a ping_url: rejected.
2313+
let err = super::parse(
2314+
r#"
2315+
[page]
2316+
[server]
2317+
[health]
2318+
id = "hora-a"
2319+
confirm_with_peers = true
2320+
[[peers]]
2321+
id = "hora-b"
2322+
name = "B"
2323+
expect_every_secs = 60
2324+
"#,
2325+
)
2326+
.unwrap_err()
2327+
.to_string();
2328+
assert!(err.contains("ping_url"), "{err}");
2329+
2330+
// A push monitor cannot opt in (nothing to probe).
2331+
let err = super::parse(
2332+
r#"
2333+
[page]
2334+
[server]
2335+
[health]
2336+
id = "hora-a"
2337+
[[peers]]
2338+
id = "hora-b"
2339+
name = "B"
2340+
ping_url = "https://b.example/api/push/hora-a"
2341+
[[monitors]]
2342+
id = "beat"
2343+
name = "Beat"
2344+
kind = "push"
2345+
interval_secs = 60
2346+
confirm_with_peers = true
2347+
"#,
2348+
)
2349+
.unwrap_err()
2350+
.to_string();
2351+
assert!(err.contains("push"), "{err}");
2352+
2353+
// The healthy shape parses, and probe_url derives from ping_url's origin.
2354+
let config = super::parse(
2355+
r#"
2356+
[page]
2357+
[server]
2358+
[health]
2359+
id = "hora-a"
2360+
confirm_with_peers = true
2361+
[[peers]]
2362+
id = "hora-b"
2363+
name = "B"
2364+
ping_url = "https://b.example:8443/api/push/hora-a"
2365+
[[monitors]]
2366+
id = "web"
2367+
name = "Web"
2368+
target = "https://example.com"
2369+
interval_secs = 60
2370+
"#,
2371+
)
2372+
.expect("valid");
2373+
assert_eq!(
2374+
config.peers[0].probe_url().as_deref(),
2375+
Some("https://b.example:8443/api/peer/probe")
2376+
);
2377+
}
2378+
22222379
#[test]
22232380
fn digest_validates_schedule_and_routes() {
22242381
let base = r#"

0 commit comments

Comments
 (0)