Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,19 @@ layout) per [`STABILITY_POLICY.md`](STABILITY_POLICY.md).
default — the block is the fallback). The outputs are dropped by the on-disk
index (no size increase, no reindex) and re-derived at serve time. SDK:
`TweakEntry::taproot_outputs` and `SubscribeOptions::tweak_outputs`.
- Alerting: node-health events on the streaming API. A new `status` category
(bit 16 — explicit-request only, so a `categories=0` subscriber is unaffected)
carries `StatusEvent` bodies describing the node itself (`ibd_complete`,
`tip_stall`, `disk_low`, `mempool_congested`, `peer_floor`, `deep_reorg`),
level-triggered with paired `raised`/`cleared` states and an additive
`details` string map. Served on gRPC and WS/SSE; **not** on the ZMQ
`nodeevent` topic, which has no per-subscriber category mask (use `alertfile`
webhooks or `-alertnotify` for health over ZMQ). Requires **`rpc:read` as well
as `stream:subscribe`** where `-authfile` is in use — the bodies carry host
telemetry (disk, peers, mempool occupancy, tip height) that the same
capability gates on the RPC surface. Not replayable (no cursor —
detectors re-raise standing conditions after a restart). Wire schema only in
this change; the detectors that emit them land next.

## Releases

Expand Down
70 changes: 70 additions & 0 deletions docs/api/streaming.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ message NodeEvent {
BlockTweaks block_tweaks = 28; // BIP 352 per-block tweak data (§7.7)
SilentPaymentMatched silent_payment_matched = 29; // BIP 352 scan-key watch match (§7.7)
MempoolTweak mempool_tweak = 30; // BIP 352 mempool-time tweak, Tier 1.5 (§7.7)
StatusEvent status = 31; // node-health condition (§7.8)
}
}
```
Expand Down Expand Up @@ -913,6 +914,75 @@ deliver `Event::SilentPaymentMatched`; `examples/sp_wallet.rs` re-derives each
match's spending key offline from `tweak` + `k`. The scan-key helpers need the
crate's default `bitcoin` feature (to derive the removal identity `b_scan·G`).

### 7.8 Node-health status events

Every other body on this surface describes the *chain* or the *mempool*. A
`StatusEvent` describes the **node itself** — a stalled tip, a filling disk, a
peer-starved daemon — so an operator can drive alerting off the same stream they
already consume, instead of polling `getblockchaininfo` on a timer.

The `status` category is **bit 16**. Like `tweaks`, it is **not** part of the
`categories = 0` ("all") default: a subscriber written against an older node
never begins receiving a body it has no parser for. Unlike `tweaks` it has no
index prerequisite.

**Authorization: `status` requires `rpc:read` in addition to
`stream:subscribe`.** Every other category describes public chain data, which is
what `stream:subscribe` exists to hand out. `StatusEvent` describes the host —
free bytes on the node's volume, connected peers split inbound/outbound, the
mempool byte cap with its occupancy and `mempoolminfee`, tip height, IBD state,
reorg depth and fork heights. That is the content of `getblockchaininfo`,
`getpeerinfo`, `getmempoolinfo` and `getwarnings`, and `satd-auth` separates the
two capabilities precisely so an operator can issue a streaming token to a
wallet backend or tenant *without* granting node read RPC. A token holding only
`stream:subscribe` is refused with `PERMISSION_DENIED` (gRPC) or `403` (WS/SSE)
when it requests bit 16, and a mid-stream `SetCategories` / `SetWatchSet` from
such a token has the bit stripped. This applies only where `-authfile` is
configured: with no token store the node behaves exactly as before, and the
operator principal (cookie / `rpcuser` / `rpcauth`) holds every capability.

It is served on the carriers that have a per-subscriber category mask to opt in
with: gRPC `Subscribe`, gRPC `Watch`, WS, and SSE. It is **not** emitted on the
ZMQ `nodeevent` topic. ZMQ has no per-subscriber mask — only an all-or-nothing
topic toggle — so honoring "explicit-only" there is impossible: emitting status
would hand a new body type to every already-deployed `nodeevent` consumer on
upgrade, and their only escape would be `eventszmqnodeevent=0`, which silences
every other envelope too. Operators consuming health over ZMQ should use the
`alertfile` webhook dispatcher or `-alertnotify` instead.

```proto
message StatusEvent {
StatusKind kind = 1; // ibd_complete | tip_stall | disk_low |
// mempool_congested | peer_floor | deep_reorg
StatusState state = 2; // raised | cleared | edge
StatusSeverity severity = 3; // info | warning | critical
string message = 4; // human-readable; log it, do not parse it
map<string, string> details = 5; // kind-specific fields (see below)
}
```

**Level-triggered.** A standing condition emits exactly one `raised` on entry and
exactly one `cleared` on recovery, with hysteresis between the two thresholds so
a value hovering at the line does not flap. Observations with no recovered state
(IBD finishing, a deep reorg landing) are `edge` and never produce a `cleared`.

**Not replayable.** A status event carries no `cursor` and is excluded from the
replay ring, so a `from_cursor` resume never yields one. Durability comes from
re-evaluation instead: the detectors re-examine every condition at startup and
re-raise the ones still standing, which is what makes health alerting
at-least-once across a restart. A condition that both raised and fully cleared
while a consumer was away is stale by definition and is not reconstructed.

**Additive by construction.** `details` is a string map and `StatusKind` is an
open enum: new kinds and new detail keys ship without a schema bump (§4). A
consumer must tolerate an unrecognized `kind` — `message` and `severity` remain
meaningful — and must not require any particular `details` key to be present.

The same detections also drive the node's warnings registry, so `getwarnings`,
the Core-compatible `-alertnotify` hook, and this stream can never disagree about
node state. Operator-facing configuration (detector thresholds, the `alertfile`
webhook dispatcher) is documented in the Operator Manual, not here.

## 8. Reorg events

Reorgs are **first-class**, emitted in-process as consensus ground truth — a
Expand Down
51 changes: 51 additions & 0 deletions docs/release-notes/0.5.0-pre.md
Original file line number Diff line number Diff line change
Expand Up @@ -204,6 +204,57 @@ and matching against it arrive in the following changes.
helpers use the crate's default `bitcoin` feature to derive a target's removal
identity.

## Alerting & node health

Every body on the streaming API so far described the *chain* or the *mempool*.
None of them described the **node**, so an operator who wanted to know that their
daemon had stalled, filled its disk, or lost its peers had to poll
`getblockchaininfo` on a timer and diff the results themselves.

- **Node-health events are now first-class on the firehose.** A new `status`
category carries `StatusEvent` bodies for six conditions the daemon detects
about itself: `ibd_complete`, `tip_stall`, `disk_low`, `mempool_congested`,
`peer_floor`, and `deep_reorg`. Each event carries a machine-readable `kind`,
a `severity`, a human-readable `message`, and a `details` map of structured
fields as strings — mostly decimal numbers (thresholds, counts, byte totals),
but not exclusively: a `cleared` event can carry a `reason` token such as
`detector_disabled`. Do not parse the map uniformly as integers.
- **Level-triggered, not edge-spammed.** A standing condition emits exactly one
`raised` when it is entered and exactly one `cleared` when it recovers, with
hysteresis between the two thresholds so a value hovering at the line does not
flap a pager. Conditions with no recovered state — IBD finishing, a deep reorg
landing — are `edge` and never produce a `cleared`.
- **Opt-in by construction.** The category is **bit 16** and, like the
silent-payment `tweaks` category, is deliberately excluded from the
`categories = 0` ("all") default: a subscriber written against an older node
never begins receiving a body it has no parser for after an upgrade. Unlike
`tweaks` there is no index prerequisite. Served on the three mask-bearing
transports — gRPC `Subscribe`/`Watch`, WS, and SSE. It is deliberately **not**
emitted on the ZMQ `nodeevent` topic: ZMQ has no per-subscriber category mask,
so putting it there would push a new body shape at every existing `nodeevent`
consumer. For health over ZMQ, use `alertfile` webhooks or `-alertnotify`.
- **`status` needs `rpc:read` on top of `stream:subscribe`.** Every other
category describes the chain, which is public; this one describes your host —
free disk, peer topology, mempool occupancy and `mempoolminfee`, tip height,
IBD state. That is the content of `getblockchaininfo`, `getpeerinfo`,
`getmempoolinfo` and `getwarnings`, and the whole point of a separate
`stream:subscribe` capability is that you can give a wallet backend or a
tenant a streaming token *without* node read RPC. So a token holding only
`stream:subscribe` is refused (`PERMISSION_DENIED` / `403`) when it asks for
bit 16, and a mid-stream category update from one has the bit stripped rather
than honored. This narrows nothing for a node that has not enabled
`-authfile`: with no token store the behavior is unchanged, and the operator
principal (cookie, `rpcuser`, `rpcauth`) holds every capability already.
- **Durability is by re-evaluation, not replay.** A status event carries no
resume cursor and is excluded from the replay ring, so a `from_cursor` resume
never yields one. Instead the detectors re-examine every condition at startup
and re-raise the ones still standing — which is what makes health alerting
at-least-once across a restart. A condition that both raised and fully cleared
while a consumer was away is stale by definition and is not reconstructed.
- Additive on the wire: the schema version does not bump, `StatusKind` is an open
enum, and `details` is a string map, so future conditions and fields ship
without breaking a deployed consumer.

## Storage / Core compatibility

### Core v28+ XOR-obfuscated block files (`blocksxor`)
Expand Down
120 changes: 120 additions & 0 deletions events/src/catauth.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
//! Per-category authorization for the streaming carriers.
//!
//! Most categories carry data that is *about the chain* — blocks, transactions,
//! the mempool — which is public by nature and which `stream:subscribe` exists
//! to hand out. The `status` category is different: it carries data about the
//! **host**, and every other surface keeps that behind `rpc:read`.
//!
//! A `StatusEvent` body reports free bytes on the node's volume, connected peer
//! counts split inbound/outbound, the mempool byte cap with its current
//! occupancy and `mempoolminfee`, tip height, IBD state, and reorg depth with
//! fork heights. Those are the answers to `getblockchaininfo`, `getpeerinfo`,
//! `getmempoolinfo` and `getwarnings`.
//!
//! `satd-auth` separates `rpc:read` from `stream:subscribe` precisely so an
//! operator can issue a streaming token to a wallet backend, an indexer, or a
//! tenant *without* also handing over the node's read RPC. Serving `status` on
//! `stream:subscribe` alone would route around that split rather than extend
//! it: the token would deliver, over the stream, the host telemetry it was
//! issued to withhold. So the bit needs both capabilities.
//!
//! This is not a new capability in the `auth.toml` vocabulary. Adding one would
//! be a Tier 1 surface change, and it is not needed — the data already has an
//! owner in the existing vocabulary, and `status` is new in this release, so no
//! deployed token loses access to something it has today.

/// Whether `principal` may receive the `status` category.
///
/// `None` means the token store is not configured at all, which is the
/// Core-compatible default (loopback trust) and grants everything — this gate
/// only ever narrows a `-authfile` deployment. The operator principal
/// (cookie / userpass / rpcauth) carries `CapabilitySet::ALL` and so passes.
pub(crate) fn may_receive_status(principal: Option<&satd_auth::Principal>) -> bool {
principal.is_none_or(|p| p.has(satd_auth::Capability::RpcRead))
}

/// Strip `status` from `mask` when `principal` may not receive it.
///
/// Used on the mid-stream control paths (`SetCategories`, `SetWatchSet`), which
/// have no per-message error channel of their own. The handshake paths reject
/// outright instead — see `may_receive_status`'s callers in `grpc`/`ws` — so a
/// client that asks for `status` up front is told plainly rather than left
/// wondering why a category it requested never arrives.
#[cfg_attr(not(feature = "grpc"), allow(dead_code))]
pub(crate) fn strip_unauthorized(principal: Option<&satd_auth::Principal>, mask: u32) -> u32 {
if may_receive_status(principal) {
return mask;
}
if mask & node::events::CATEGORY_STATUS != 0 {
tracing::debug!(
target: "events::auth",
"dropping the status category from a mid-stream category update: \
the token lacks rpc:read",
);
}
mask & !node::events::CATEGORY_STATUS
}

#[cfg(test)]
mod tests {
use super::*;
use satd_auth::{Capability, CapabilitySet, Principal};

/// `has()` reads only `caps`, so the principal kind is immaterial here and
/// `loopback` is the cheapest constructor that takes an explicit set.
fn principal_with(caps: CapabilitySet) -> Principal {
Principal::loopback(caps)
}

/// The Core-compatible default: no `-authfile`, no token store, nothing to
/// narrow. This gate must never change behavior for a node that has not
/// opted into bearer auth.
#[test]
fn auth_disabled_receives_status() {
assert!(may_receive_status(None));
assert_eq!(
strip_unauthorized(None, node::events::CATEGORY_STATUS),
node::events::CATEGORY_STATUS,
);
}

/// The finding: a token issued `stream:subscribe` *in order to* withhold
/// `getblockchaininfo` / `getpeerinfo` / `getwarnings` must not get the same
/// telemetry back over the stream.
#[test]
fn stream_subscribe_alone_does_not_carry_status() {
let p = principal_with(CapabilitySet::EMPTY.with(Capability::StreamSubscribe));
assert!(!may_receive_status(Some(&p)));

let asked = node::events::CATEGORY_CHAIN | node::events::CATEGORY_STATUS;
let got = strip_unauthorized(Some(&p), asked);
assert_eq!(got & node::events::CATEGORY_STATUS, 0, "status is stripped");
assert_eq!(
got & node::events::CATEGORY_CHAIN,
node::events::CATEGORY_CHAIN,
"and nothing else is",
);
}

#[test]
fn rpc_read_carries_status() {
let p = principal_with(
CapabilitySet::EMPTY
.with(Capability::StreamSubscribe)
.with(Capability::RpcRead),
);
assert!(may_receive_status(Some(&p)));
assert_eq!(
strip_unauthorized(Some(&p), node::events::CATEGORY_STATUS),
node::events::CATEGORY_STATUS,
);
}

/// The operator principal holds everything, so an operator-authenticated
/// stream is unaffected.
#[test]
fn the_operator_principal_carries_status() {
let p = principal_with(CapabilitySet::ALL);
assert!(may_receive_status(Some(&p)));
}
}
Loading
Loading