diff --git a/crates/maestro-daemon/src/registry_client.rs b/crates/maestro-daemon/src/registry_client.rs index e8d73f5..bd2442e 100644 --- a/crates/maestro-daemon/src/registry_client.rs +++ b/crates/maestro-daemon/src/registry_client.rs @@ -165,8 +165,13 @@ impl RegistryClient { } } _ = hb.tick() => { + // perimeter_id / private_addr are wired from daemon config + // in NAP-243; NAP-242 lands the protocol field only. sink.send(Message::Text(serde_json::to_string( - &DaemonOutbound::Heartbeat, + &DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }, )?)).await?; } } @@ -208,6 +213,11 @@ impl RegistryClient { )?)) .await?; } + DaemonInbound::ResolveAttachResult { .. } | DaemonInbound::RelaySetup { .. } => { + // Wired by NAP-243 (broker mode). Lands here only so registries + // emitting these messages don't crash older daemons on parse. + tracing::debug!("broker message received but unhandled (NAP-243 pending)"); + } } Ok(()) } diff --git a/crates/maestro-protocol/src/lib.rs b/crates/maestro-protocol/src/lib.rs index d866a01..66cd438 100644 --- a/crates/maestro-protocol/src/lib.rs +++ b/crates/maestro-protocol/src/lib.rs @@ -113,7 +113,15 @@ pub enum DaemonOutbound { SessionUpdated { session: Session, }, - Heartbeat, + /// Liveness ticker. The optional `perimeter_id` / `private_addr` advertise + /// the daemon's reachability for the T2 transport (NAP-242). Old daemons + /// that don't set them still produce the bare `{"type":"Heartbeat"}` shape. + Heartbeat { + #[serde(default, skip_serializing_if = "Option::is_none")] + perimeter_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + private_addr: Option, + }, CreateSessionResult { reply_to: ReplyTo, result: RpcResult, @@ -426,7 +434,14 @@ mod tests { DaemonOutbound::SessionUpdated { session: session.clone(), }, - DaemonOutbound::Heartbeat, + DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }, + DaemonOutbound::Heartbeat { + perimeter_id: Some("wg-naps62".into()), + private_addr: Some("10.0.0.7:7070".into()), + }, DaemonOutbound::CreateSessionResult { reply_to, result: RpcResult::Ok { @@ -569,6 +584,50 @@ mod tests { assert_eq!(json["type"], "DaemonRemoved"); } + #[test] + fn heartbeat_legacy_payload_parses_with_none_fields() { + // Daemons predating NAP-242 send the bare `{"type":"Heartbeat"}` shape. + // It must still deserialize, with both new fields defaulting to None. + let legacy = serde_json::json!({ "type": "Heartbeat" }); + let parsed: DaemonOutbound = serde_json::from_value(legacy).unwrap(); + match parsed { + DaemonOutbound::Heartbeat { + perimeter_id, + private_addr, + } => { + assert!(perimeter_id.is_none()); + assert!(private_addr.is_none()); + } + other => panic!("expected Heartbeat, got {other:?}"), + } + } + + #[test] + fn heartbeat_empty_serializes_to_legacy_shape() { + // A new daemon that doesn't set perimeter/addr must produce wire bytes + // identical to an old daemon — registries doing equality checks or + // logging payloads see no surprise. + let hb = DaemonOutbound::Heartbeat { + perimeter_id: None, + private_addr: None, + }; + let v = serde_json::to_value(&hb).unwrap(); + assert_eq!(v, serde_json::json!({ "type": "Heartbeat" })); + } + + #[test] + fn heartbeat_with_fields_round_trips() { + let hb = DaemonOutbound::Heartbeat { + perimeter_id: Some("wg-naps62".into()), + private_addr: Some("10.0.0.7:7070".into()), + }; + let v = serde_json::to_value(&hb).unwrap(); + assert_eq!(v["type"], "Heartbeat"); + assert_eq!(v["perimeter_id"], "wg-naps62"); + assert_eq!(v["private_addr"], "10.0.0.7:7070"); + assert_eq!(hb, round_trip(&hb)); + } + #[test] fn daemon_outbound_resolve_attach_round_trip_and_tag() { let msg = DaemonOutbound::ResolveAttach { diff --git a/crates/maestro-registry/src/handler.rs b/crates/maestro-registry/src/handler.rs index 0baa1f1..d34b1fa 100644 --- a/crates/maestro-registry/src/handler.rs +++ b/crates/maestro-registry/src/handler.rs @@ -139,8 +139,10 @@ async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool DaemonOutbound::SessionUpdated { session } => { registry.update_session(daemon_id, session).await; } - DaemonOutbound::Heartbeat => { + DaemonOutbound::Heartbeat { .. } => { // Per-read timeout above is the heartbeat watchdog; no extra work. + // perimeter_id / private_addr advertised here are persisted by + // NAP-243 for the T2 transport. } DaemonOutbound::CreateSessionResult { reply_to, result } => { registry.complete_create(reply_to, result).await; @@ -148,6 +150,12 @@ async fn dispatch_text(registry: &Registry, daemon_id: &str, text: &str) -> bool DaemonOutbound::KillSessionResult { reply_to, result } => { registry.complete_kill(reply_to, result).await; } + DaemonOutbound::ResolveAttach { reply_to, .. } + | DaemonOutbound::RelayAck { reply_to, .. } => { + // Handled by NAP-243 (broker mode); type already lands here so old + // daemons sending it don't kill the connection on parse failure. + tracing::debug!(%daemon_id, ?reply_to, "broker message received but unhandled (NAP-243 pending)"); + } } true }