Skip to main content

clawft_kernel/
mesh_serve.rs

1//! Mesh accept loop and seed-connect loop (P3-K0).
2//!
3//! Moved out of `boot.rs` unchanged in behaviour so a machine mesh
4//! service can reuse them. [`serve_listener`] owns the per-connection
5//! Noise responder handshake and the bidirectional pump;
6//! [`connect_seeds`] dials the configured seed peers as Noise initiator.
7
8use std::sync::Arc;
9
10use crate::ipc::MessageTarget;
11use crate::mesh::{MeshTransport, TransportListener};
12use crate::mesh_admit::{
13    AdmitContext, AdmitHello, Admission, DialIdentity, AdmissionGate, ChannelBinding, ChannelKind, HelloFailure,
14    PeerClass, PeerLimits, Refusal,
15};
16use crate::mesh_assess::{AssessmentEnvelope, AssessmentTransport};
17use crate::mesh_delivery::PeerCtx;
18use crate::mesh_ipc::{MeshIpcEnvelope, Scope};
19use crate::mesh_limits::{IpSlot, Limits, HANDSHAKE_TIMEOUT, MAX_CONNECTIONS, ROUTE_CHECK};
20use crate::mesh_noise::{
21    noise_static_public, EncryptedChannel, NoiseChannel, NoiseConfig, PassthroughChannel,
22};
23use crate::mesh_runtime::{MeshRuntime, RouteTally};
24use crate::mesh_leaf::{Prepare, MAX_LEAF_FRAME};
25use weftos_leaf_types::link::{parse_parent_scope, SignedPublish, FRAME_MAGIC, ACK_MAGIC};
26
27/// Build the transport for a `kernel.mesh.transport` name.
28///
29/// `seed_peer` is set when selecting for an outbound seed connection; it
30/// only changes the wording of the QUIC-unavailable fallback log.
31pub fn transport_for(name: &str, seed_peer: Option<&str>) -> Box<dyn MeshTransport> {
32    match name {
33        "ws" | "websocket" => Box::new(crate::mesh_ws::WsTransport),
34        #[cfg(feature = "quic")]
35        "quic" => Box::new(crate::mesh_quic::QuicTransport),
36        #[cfg(not(feature = "quic"))]
37        "quic" => {
38            match seed_peer {
39                Some(peer) => tracing::error!(
40                    peer = %peer,
41                    "seed peer uses quic but binary built without `quic` feature; falling back to tcp"
42                ),
43                None => tracing::error!(
44                    "mesh transport=quic requested but clawft-kernel built without `quic` feature; falling back to tcp"
45                ),
46            }
47            Box::new(crate::mesh_tcp::TcpTransport)
48        }
49        _ => {
50            let _ = seed_peer;
51            Box::new(crate::mesh_tcp::TcpTransport)
52        }
53    }
54}
55
56/// Accept connections on `listener` forever, pumping each through
57/// `runtime`. Spawn this on a task; it never returns.
58///
59/// `listen_addr` is only the fallback for the "listener started" log when
60/// the listener cannot report its bound address. `gate` decides, on each
61/// connection's first frame, whether the peer is admitted (P3-K1);
62/// [`AllowAll`](crate::mesh_admit::AllowAll) keeps the pre-admission
63/// behaviour.
64pub async fn serve_listener(
65    runtime: Arc<MeshRuntime>,
66    listener: Box<dyn TransportListener>,
67    noise: Option<Arc<NoiseConfig>>,
68    transport_name: &str,
69    listen_addr: &str,
70    gate: Arc<dyn AdmissionGate>,
71) {
72    serve_listener_with(
73        runtime,
74        listener,
75        noise,
76        transport_name,
77        listen_addr,
78        gate,
79        Limits::default(),
80    )
81    .await;
82}
83
84/// [`serve_listener`] with explicit [`Limits`] (tests use short ones).
85pub async fn serve_listener_with(
86    runtime: Arc<MeshRuntime>,
87    mut listener: Box<dyn TransportListener>,
88    noise: Option<Arc<NoiseConfig>>,
89    transport_name: &str,
90    listen_addr: &str,
91    gate: Arc<dyn AdmissionGate>,
92    limits: Limits,
93) {
94    let bind = listener
95        .local_addr()
96        .map(|a| a.to_string())
97        .unwrap_or_else(|_| listen_addr.to_owned());
98    tracing::info!(
99        transport = transport_name,
100        addr = %bind,
101        noise = noise.is_some(),
102        "mesh listener started"
103    );
104
105    // Connection tasks live in a JoinSet so aborting this future (dropping
106    // the set) also stops every connection it spawned, and a semaphore caps
107    // concurrent connections.
108    let mut conns = tokio::task::JoinSet::new();
109    if let Some(ingress) = runtime.leaf_ingress().cloned()
110        && let Ok(mesh_addr) = bind.parse::<std::net::SocketAddr>()
111        && let Some(port) = mesh_addr.port().checked_add(2) {
112            let leaf_addr = std::net::SocketAddr::new(mesh_addr.ip(), port);
113            match crate::mesh_tcp::TcpTransport.listen(&leaf_addr.to_string()).await {
114                Ok(leaf_listener) => {
115                    let leaf_rt = Arc::clone(&runtime);
116                    conns.spawn(async move { serve_signed_leaf_listener(leaf_rt, leaf_listener, limits).await });
117                    conns.spawn(async move {
118                        if let Err(e) = ingress.serve_discovery(mesh_addr, leaf_addr).await {
119                            tracing::error!(error = %e, "leaf discovery stopped");
120                        }
121                    });
122                    tracing::info!(addr = %leaf_addr, "dedicated certified leaf listener started");
123                }
124                Err(e) => tracing::error!(addr = %leaf_addr, error = %e, "certified leaf listener unavailable; no leaf discovery started"),
125            }
126    }
127    let slots = Arc::new(tokio::sync::Semaphore::new(MAX_CONNECTIONS));
128    let per_ip = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
129    loop {
130        while conns.try_join_next().is_some() {}
131        match listener.accept().await {
132            Ok((stream, peer_addr)) => {
133                let Ok(permit) = Arc::clone(&slots).try_acquire_owned() else {
134                    tracing::warn!(peer = %peer_addr, max = MAX_CONNECTIONS,
135                        "mesh connection cap reached, dropping");
136                    continue;
137                };
138                // The per-IP cap applies under every admission mode: the
139                // global cap alone lets one host starve the rest.
140                let Some(ip_slot) = IpSlot::acquire(&per_ip, peer_addr.ip(), limits.per_ip) else {
141                    tracing::warn!(peer = %peer_addr, max = limits.per_ip,
142                        "mesh per-IP connection cap reached, dropping");
143                    continue;
144                };
145                let rt = Arc::clone(&runtime);
146                let nc = noise.clone();
147                let gate = Arc::clone(&gate);
148                conns.spawn(async move {
149                    serve_connection(rt, stream, peer_addr, nc, gate, limits, false).await;
150                    drop(permit);
151                    drop(ip_slot);
152                });
153            }
154            Err(e) => {
155                tracing::warn!(error = %e, "mesh accept error");
156            }
157        }
158    }
159}
160
161/// Separate bounded TCP ingress for WLF1 only. The ordinary mesh listener
162/// keeps its configured Noise requirement; no plaintext fallback is added
163/// to the node-to-node port.
164async fn serve_signed_leaf_listener(runtime: Arc<MeshRuntime>, mut listener: Box<dyn TransportListener>, limits: Limits) {
165    let slots = Arc::new(tokio::sync::Semaphore::new(MAX_CONNECTIONS));
166    let per_ip = Arc::new(std::sync::Mutex::new(std::collections::HashMap::new()));
167    let mut conns = tokio::task::JoinSet::new();
168    loop {
169        while conns.try_join_next().is_some() {}
170        match listener.accept().await {
171            Ok((stream, peer)) => {
172                let Ok(permit) = Arc::clone(&slots).try_acquire_owned() else { continue };
173                let Some(ip_slot) = IpSlot::acquire(&per_ip, peer.ip(), limits.per_ip) else { continue };
174                let rt = Arc::clone(&runtime);
175                conns.spawn(async move {
176                    serve_connection(rt, stream, peer, None, Arc::new(crate::mesh_admit::AllowAll), limits, true).await;
177                    drop(permit);
178                    drop(ip_slot);
179                });
180            }
181            Err(e) => tracing::warn!(error = %e, "certified leaf accept error"),
182        }
183    }
184}
185
186/// What admission granted this connection.
187pub(crate) struct Active {
188    /// Verified node id; later envelopes must carry it as `source_node`.
189    pub(crate) bound: Option<String>,
190    pub(crate) limits: PeerLimits,
191    pub(crate) trust_scope: bool,
192    /// Admitted = hello verified AND the gate enforced AND said Admit.
193    /// `bound` alone is only key possession, not membership.
194    pub(crate) admitted: bool,
195    pub(crate) class: PeerClass,
196    pub(crate) remote_static: Option<Vec<u8>>,
197    /// This connection must present a signed WLF1 frame for every input.
198    pub(crate) signed_leaf: bool,
199    pub(crate) leaf_cert: Option<weftos_leaf_types::link::LeafCertificate>,
200}
201
202impl Active {
203    /// The connection identity handed to the runtime and to delivery.
204    fn peer_ctx(&self) -> PeerCtx {
205        PeerCtx {
206            peer_id: self.bound.clone().unwrap_or_default(),
207            node_verified: self.admitted,
208            class: self.class,
209            remote_static: self.remote_static.clone(),
210            src_scope: None,
211        }
212    }
213}
214
215fn unix_now() -> u64 {
216    std::time::SystemTime::now()
217        .duration_since(std::time::UNIX_EPOCH)
218        .map(|d| d.as_secs())
219        .unwrap_or(0)
220}
221
222/// Run admission on a connection's first frame. Returns the terms and the
223/// frame to process normally (`None` when the frame was the hello).
224async fn admit_first_frame(
225    channel: &dyn EncryptedChannel,
226    kind: ChannelKind,
227    gate: &dyn AdmissionGate,
228    data: Vec<u8>,
229) -> Result<(Active, Option<Vec<u8>>), Refusal> {
230    let legacy = AdmitContext { class: PeerClass::Legacy, channel: kind };
231    let binding = channel.handshake_hash().map(|h| ChannelBinding {
232        handshake_hash: h,
233        remote_static: channel.remote_static_key(),
234    });
235    let (decision, bound, rest) = match AdmitHello::parse_frame(&data) {
236        None => (
237            gate.admit_unverified(&HelloFailure::Missing, &legacy).await,
238            None,
239            Some(data),
240        ),
241        Some(Err(f)) => (gate.admit_unverified(&f, &legacy).await, None, None),
242        Some(Ok(hello)) => match hello.verify(binding.as_ref(), unix_now()) {
243            Ok(v) => {
244                let ctx = AdmitContext { class: v.class(), channel: kind };
245                (gate.admit(&v, &ctx).await, Some(v.node_id), None)
246            }
247            Err(f) => (gate.admit_unverified(&f, &legacy).await, None, None),
248        },
249    };
250    match decision {
251        Admission::Refuse(r) => Err(r),
252        Admission::Admit(g) => Ok((
253            Active {
254                admitted: g.admitted && bound.is_some(),
255                trust_scope: g.trust_scope && g.admitted && bound.is_some(),
256                limits: g.limits,
257                bound,
258                class: g.class,
259                remote_static: channel.remote_static_key().map(<[u8]>::to_vec),
260                signed_leaf: false,
261                leaf_cert: None,
262            },
263            rest,
264        )),
265    }
266}
267
268/// The node id a frame says it comes from: an envelope's `source_node`, or an
269/// assessment frame's. `None` for anything else.
270pub(crate) fn claimed_source(data: &[u8]) -> Option<String> {
271    if let Ok(env) = MeshIpcEnvelope::from_bytes(data) {
272        return Some(env.source_node);
273    }
274    AssessmentTransport::try_extract_payload(data)
275        .ok()
276        .flatten()
277        .and_then(|p| AssessmentEnvelope::from_bytes(&p).ok())
278        .map(|e| e.source_node)
279}
280
281/// Apply post-admission rules to one frame. `None` drops it.
282///
283/// - `source_node` must equal the verified node id (envelopes and
284///   assessment frames alike).
285/// - Leaf limits: only `substrate/<own-id>/...` topics and `mesh.subscribe`.
286/// - `src_scope` is stripped unless admission verified the peer.
287pub(crate) fn screen_frame(data: Vec<u8>, act: &Active) -> Option<Vec<u8>> {
288    match MeshIpcEnvelope::from_bytes(&data) {
289        Ok(mut env) => {
290            // The old ESP32 plaintext `ipc.publish` path must not remain a
291            // leaf-write bypass while rollout runs in observe mode.
292            if !act.admitted {
293                let protected = match (&env.message.target, &env.message.payload) {
294                    (MessageTarget::Topic(t), _) if t.starts_with("substrate/") || t.starts_with("mesh.leaf.") => true,
295                    (MessageTarget::Topic(t), crate::ipc::MessagePayload::Json(v)) if t == "ipc.publish" || t == "mesh.subscribe" =>
296                        v.get("topic").and_then(|t| t.as_str()).is_some_and(|t| t.starts_with("mesh.leaf.") || t.starts_with("substrate/")),
297                    _ => false,
298                };
299                if protected {
300                    tracing::warn!("dropping unsigned legacy leaf frame");
301                    return None;
302                }
303            }
304            if let Some(id) = &act.bound
305                && &env.source_node != id
306            {
307                tracing::warn!(claimed = %env.source_node, verified = %id,
308                    "dropping envelope: source_node differs from admitted node id");
309                return None;
310            }
311            if act.limits == PeerLimits::Leaf {
312                // The established, Noise-authenticated CAP_LEAF grant may
313                // publish only to its own substrate prefix. WLF1 is required
314                // for the newer certified input/announce path and has its
315                // own per-publish replay check in the pump.
316                let id = act.bound.as_deref().unwrap_or_default();
317                let allowed = matches!(&env.message.target, MessageTarget::Topic(t)
318                    if t == "mesh.subscribe" || t.starts_with(&format!("substrate/{id}/")));
319                if !allowed {
320                    tracing::warn!(node = id, "dropping leaf envelope outside substrate/<id>/");
321                    return None;
322                }
323            }
324            if env.src_scope.is_some() && !act.trust_scope {
325                env.src_scope = None;
326                return env.to_bytes().ok();
327            }
328            Some(data)
329        }
330        Err(_) => {
331            if act.limits == PeerLimits::Leaf {
332                return None;
333            }
334            if let Some(id) = &act.bound {
335                let src = AssessmentTransport::try_extract_payload(&data)
336                    .ok()
337                    .flatten()
338                    .and_then(|p| AssessmentEnvelope::from_bytes(&p).ok())
339                    .map(|e| e.source_node);
340                if src.is_some_and(|s| &s != id) {
341                    tracing::warn!(verified = %id, "dropping assessment frame: source_node mismatch");
342                    return None;
343                }
344            }
345            Some(data)
346        }
347    }
348}
349
350/// Decode only the leaf's own publish/subscription shape. The outer signed
351/// target must agree with the inner routed topic; a signature over one topic
352/// cannot authorize another topic hidden inside JSON.
353fn leaf_routed_envelope(frame: &SignedPublish) -> Option<Vec<u8>> {
354    let id = frame.cert.leaf_id();
355    let mut env = MeshIpcEnvelope::from_bytes(&frame.payload).ok()?;
356    if env.source_node != id { return None; }
357    // A certificate binds the parent scope, but the envelope's tenant
358    // claims are caller-controlled and cannot be inferred from that string.
359    // Never pass those claims to the delivery authorizer.
360    env.src_scope = None;
361    let (user_id, project_id) = parse_parent_scope(&frame.cert.parent_scope).ok()?;
362    env.dest_scope = Some(Scope { user_id: user_id.to_owned(), project_id: project_id.map(str::to_owned) });
363    let crate::ipc::MessagePayload::Json(v) = &env.message.payload else { return None };
364    let inner_topic = v.get("topic")?.as_str()?;
365    match (&env.message.target, frame.target.as_str()) {
366        (MessageTarget::Topic(t), "mesh.subscribe") if t == "mesh.subscribe"
367            && inner_topic == weftos_leaf_types::push_topic(&id) => env.to_bytes().ok(),
368        (MessageTarget::Topic(t), target) if t == "ipc.publish" && inner_topic == target
369            && (target == format!("mesh.leaf.{id}.input") || target == format!("mesh.leaf.{id}.announce")) => {
370            let message = v.get("message")?.as_str()?;
371            let payload = match serde_json::from_str::<serde_json::Value>(message) {
372                Ok(json) => crate::ipc::MessagePayload::Json(json),
373                Err(_) => crate::ipc::MessagePayload::Text(message.to_owned()),
374            };
375            env.message.target = MessageTarget::Topic(target.to_owned());
376            env.message.payload = payload;
377            env.to_bytes().ok()
378        }
379        _ => None,
380    }
381}
382
383/// One accepted connection: optional Noise responder handshake, admission
384/// on the first frame, then the bidirectional pump until either side
385/// closes.
386async fn serve_connection(
387    rt: Arc<MeshRuntime>,
388    stream: Box<dyn crate::mesh::MeshStream>,
389    peer_addr: std::net::SocketAddr,
390    nc: Option<Arc<NoiseConfig>>,
391    gate: Arc<dyn AdmissionGate>,
392    limits: Limits,
393    leaf_only: bool,
394) {
395    tracing::info!(
396        peer = %peer_addr,
397        noise = nc.is_some(),
398        "mesh peer connected"
399    );
400
401    // Optionally wrap in Noise encryption.
402    let channel: Box<dyn EncryptedChannel> = match &nc {
403        Some(cfg) => match tokio::time::timeout(
404            HANDSHAKE_TIMEOUT,
405            NoiseChannel::respond(stream, cfg),
406        )
407        .await
408        .unwrap_or_else(|_| Err(crate::mesh::MeshError::Handshake("handshake timed out".into())))
409        {
410            Ok(ch) => {
411                tracing::info!(peer = %peer_addr, "noise handshake complete");
412                Box::new(ch)
413            }
414            Err(e) => {
415                tracing::warn!(peer = %peer_addr, error = %e, "noise handshake failed, dropping");
416                return;
417            }
418        },
419        None => Box::new(PassthroughChannel::new(stream)),
420    };
421    let kind = if nc.is_some() { ChannelKind::Noise } else { ChannelKind::Passthrough };
422    let (out_tx, out_rx) = tokio::sync::mpsc::channel::<Vec<u8>>(256);
423    pump(&rt, channel, kind, &peer_addr.to_string(), &*gate, limits, out_tx, out_rx, None, RouteTally::default(), false, leaf_only).await;
424}
425
426/// The bidirectional pump shared by accepted and dialled connections.
427///
428/// The kernel pushes frames into `out_tx` (via `MeshRuntime::send_to_peer`)
429/// and this task drains `out_rx` back through the encrypted stream. This is
430/// what lets the topic forwarder in `A2ARouter` deliver pushes to inbound
431/// leaf peers that subscribed via `mesh.subscribe`. `active` is `None` until
432/// the first frame has been through admission; a dialled connection starts
433/// with `active` set (we chose the peer, so there is no admission step; it is
434/// not *admitted* in the membership sense) and its route already counted in
435/// `tally`. Nested seed dials instead install `active` only after reciprocal
436/// cryptographic admission, and retain the real gate for live revocation.
437#[allow(clippy::too_many_arguments)]
438async fn pump(
439    rt: &Arc<MeshRuntime>,
440    mut channel: Box<dyn EncryptedChannel>,
441    kind: ChannelKind,
442    peer_addr: &str,
443    gate: &dyn AdmissionGate,
444    limits: Limits,
445    out_tx: tokio::sync::mpsc::Sender<Vec<u8>>,
446    mut out_rx: tokio::sync::mpsc::Receiver<Vec<u8>>,
447    mut active: Option<Active>,
448    tally: RouteTally,
449    dialled: bool,
450    leaf_only: bool,
451) {
452    // Removes this connection's routes however the pump ends, including
453    // when the task is aborted mid-await.
454    let _routes = ConnRoutes { rt: Arc::clone(rt), tx: out_tx.clone() };
455    // Bidirectional loop. `handle_incoming_from` auto-registers the peer
456    // by `envelope.source_node` on first arrival so the kernel can route
457    // back.
458    //
459    // Cancel-safety (ADR-010 / WEFT-18): both racing futures must be
460    // cancel-safe.
461    // - `out_rx.recv()` — tokio mpsc, cancel-safe.
462    // - `channel.recv_encrypted()` → `MeshStream::recv` — contract on
463    //   `MeshStream` requires cancel-safe framing (`TcpMeshStream` keeps
464    //   partial progress).
465    // Losing either race must not drop a complete message already removed
466    // from its source, and must not desync the TCP length-prefix stream.
467    // Admission runs in the handler of a completed `recv`, never inside a
468    // raced future.
469    let strict = gate.strict();
470    // Set once this connection carries a route; when the route is removed
471    // from under it (`disconnect_peer`, e.g. `weaver mesh peer revoke`, or a
472    // replacement connection), the connection is closed instead of lingering
473    // and re-registering on its next frame.
474    let mut routed = tally.live() > 0;
475    let mut route_check = tokio::time::interval(ROUTE_CHECK);
476    route_check.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay);
477    // The silence timers run from the last frame in either direction; the
478    // route check's own ticks must not reset them.
479    let mut last_activity = tokio::time::Instant::now();
480    // Frames dropped on a dialled connection for speaking as an id other than
481    // the one it is bound to (or claiming a routed one). A seed that keeps
482    // doing it is cut off and redialled with backoff, not left spamming.
483    let mut mismatched = 0u32;
484    loop {
485        // Every mode bounds the silence before the first frame; strict gates
486        // also bound how long an admitted peer may stay silent. Lenient gates
487        // keep the pre-admission behaviour (no idle limit) so slow legacy
488        // leaves still work.
489        // A dialled connection (which starts with `active` set) bounds
490        // inbound silence so a half-open seed is detected and redialled.
491        let limit = if active.is_none() {
492            Some(limits.first_frame)
493        } else {
494            (strict || dialled).then_some(limits.idle)
495        };
496        let timer = async {
497            match limit {
498                Some(d) => tokio::time::sleep_until(last_activity + d).await,
499                None => std::future::pending::<()>().await,
500            }
501        };
502        tokio::select! {
503            _ = timer => {
504                tracing::warn!(peer = %peer_addr, "mesh connection timed out, dropping");
505                break;
506            }
507            _ = route_check.tick() => {
508                if routed && tally.live() == 0 {
509                    tracing::info!(peer = %peer_addr, "mesh route removed (disconnect or revocation), closing");
510                    break;
511                }
512                if let Some(id) = active.as_ref().and_then(|a| a.bound.as_deref())
513                    && gate.is_revoked(id)
514                {
515                    tracing::warn!(peer = %peer_addr, node = id, "mesh peer revoked, closing");
516                    break;
517                }
518                if let Some(cert) = active.as_ref().and_then(|a| a.leaf_cert.as_ref())
519                    && !rt.leaf_ingress().is_some_and(|ingress| ingress.still_enrolled(cert, unix_now())) {
520                    tracing::warn!(peer = %peer_addr, node = %cert.leaf_id(), "certified leaf enrollment expired or revoked; closing route");
521                    break;
522                }
523            }
524            inbound = channel.recv_encrypted() => match inbound {
525                Ok(data) => {
526                    last_activity = tokio::time::Instant::now();
527                    if leaf_only && !data.starts_with(FRAME_MAGIC) {
528                        tracing::warn!(peer = %peer_addr, "non-certified frame on leaf-only listener");
529                        break;
530                    }
531                    if data.starts_with(FRAME_MAGIC) {
532                        if data.len() > MAX_LEAF_FRAME { break; }
533                        let Some(ingress) = rt.leaf_ingress() else {
534                            tracing::warn!(peer = %peer_addr, "signed leaf frame but no registry installed");
535                            break;
536                        };
537                        let Ok(frame) = weftos_leaf_types::decode::<SignedPublish>(&data[FRAME_MAGIC.len()..]) else { break };
538                        let id = frame.cert.leaf_id();
539                        if active.as_ref().is_some_and(|a| !a.signed_leaf || a.bound.as_deref() != Some(&id)
540                            || a.leaf_cert.as_ref() != Some(&frame.cert)) { break; }
541                        let _guard = ingress.lock.lock().await;
542                        let prepared = match ingress.prepare(&frame, unix_now()) {
543                            Ok(p) => p,
544                            Err(e) => { tracing::warn!(peer = %peer_addr, node = %id, error = %e, "leaf frame refused"); break; }
545                        };
546                        let Some(routed_frame) = leaf_routed_envelope(&frame) else {
547                            tracing::warn!(peer = %peer_addr, node = %id, "leaf payload does not match signed target");
548                            break;
549                        };
550                        if active.is_none() {
551                            if !rt.register_authenticated_as(id.clone(), out_tx.clone(), true, PeerClass::Leaf, &tally) {
552                                tracing::warn!(peer = %peer_addr, node = %id, "leaf route refused");
553                                break;
554                            }
555                            active = Some(Active {
556                                bound: Some(id.clone()), limits: PeerLimits::Leaf, trust_scope: false,
557                                admitted: true, class: PeerClass::Leaf, remote_static: None, signed_leaf: true,
558                                leaf_cert: Some(frame.cert.clone()),
559                            });
560                            routed = true;
561                        }
562                        // A duplicate subscribe after reconnect is a safe
563                        // control-plane refresh: route subscriptions are
564                        // connection-local, unlike the durable publish floor.
565                        if prepared == Prepare::New || frame.target == "mesh.subscribe" {
566                            let ctx = active.as_ref().expect("set above").peer_ctx();
567                            if let Err(e) = rt.handle_incoming_tallied(&routed_frame, out_tx.clone(), Some(&ctx), Some(&tally)).await {
568                                tracing::warn!(peer = %peer_addr, node = %id, error = %e, "leaf delivery failed");
569                                break;
570                            }
571                            if prepared == Prepare::New
572                                && let Err(e) = ingress.commit(&frame) {
573                                tracing::error!(node = %id, error = %e, "leaf replay floor commit failed; no ACK");
574                                break;
575                            }
576                        }
577                        let ack = ingress.ack(&frame);
578                        let Ok(mut ack_bytes) = weftos_leaf_types::encode(&ack) else { break };
579                        let mut wire = Vec::with_capacity(ACK_MAGIC.len() + ack_bytes.len());
580                        wire.extend_from_slice(ACK_MAGIC);
581                        wire.append(&mut ack_bytes);
582                        if channel.send_encrypted(&wire).await.is_err() { break; }
583                        continue;
584                    }
585                    if active.as_ref().is_some_and(|a| a.signed_leaf) {
586                        tracing::warn!(peer = %peer_addr, "signed leaf sent an unsigned frame");
587                        break;
588                    }
589                    let reciprocal = active.is_none() && serde_json::from_slice::<serde_json::Value>(&data)
590                        .ok().is_some_and(|v| v.get("reciprocal").and_then(|v| v.as_bool()) == Some(true));
591                    let frame = match active {
592                        Some(_) => Some(data),
593                        None => match admit_first_frame(&*channel, kind, gate, data).await {
594                            Ok((act, rest)) => {
595                                tracing::info!(peer = %peer_addr,
596                                    node = act.bound.as_deref().unwrap_or("-"), "mesh peer admitted");
597                                // Key-authenticated: join now, keyed by the
598                                // verified id, not on the first envelope.
599                                // Admitted peers only: a key-valid peer under
600                                // off/observe is not a member, so it keeps the
601                                // first-envelope path.
602                                if act.admitted
603                                    && let Some(id) = act.bound.as_deref()
604                                    && !rt.register_authenticated_as(
605                                        id.to_owned(), out_tx.clone(), true, act.class, &tally)
606                                {
607                                    tracing::warn!(peer = %peer_addr, node = id,
608                                        "route refused for admitted peer, closing");
609                                    break;
610                                }
611                                if reciprocal {
612                                    let Some(auth) = rt.authentication() else { break };
613                                    let Some(hash) = channel.handshake_hash() else { break };
614                                    if !act.admitted { break; }
615                                    let reply = auth.identity.hello(hash, &auth.noise_static, unix_now());
616                                    if channel.send_encrypted(&reply.to_bytes()).await.is_err() { break; }
617                                }
618                                active = Some(act);
619                                rest
620                            }
621                            Err(r) => {
622                                tracing::warn!(peer = %peer_addr, code = r.code, detail = %r.detail,
623                                    "mesh peer refused, dropping");
624                                break;
625                            }
626                        },
627                    };
628                    let Some(frame) = frame else { continue };
629                    if dialled
630                        && let Some(act) = active.as_mut()
631                        && act.bound.is_none()
632                    {
633                        // The seed's id is not authenticated, so bind the
634                        // connection to the first id it claims (in an
635                        // envelope or an assessment frame) and never let it
636                        // claim an id another connection already routes. A
637                        // frame that names no id cannot be attributed to
638                        // anyone: it is dropped, not passed on unbound.
639                        let Some(claimed) = claimed_source(&frame) else {
640                            tracing::warn!(peer = %peer_addr,
641                                "dialled peer sent a frame naming no node id before binding, dropping");
642                            continue;
643                        };
644                        if rt.route_is_foreign(&claimed, &out_tx) {
645                            tracing::warn!(peer = %peer_addr, claimed = %claimed,
646                                "dialled peer claims an id routed elsewhere, dropping frame");
647                            mismatched += 1;
648                            if mismatched >= MAX_ID_MISMATCHES {
649                                tracing::warn!(peer = %peer_addr, "dialled peer keeps claiming routed ids, closing");
650                                break;
651                            }
652                            continue;
653                        }
654                        act.bound = Some(claimed);
655                    }
656                    let Some(act) = active.as_ref() else { break };
657                    // A revocation after admission: the next frame must not
658                    // re-register the route.
659                    if let Some(id) = act.bound.as_deref()
660                        && gate.is_revoked(id)
661                    {
662                        tracing::warn!(peer = %peer_addr, node = id, "mesh peer revoked, closing");
663                        break;
664                    }
665                    let screened = screen_frame(frame, act);
666                    if screened.is_none() && dialled && act.bound.is_some() {
667                        // `screen_frame` has logged this one; the connection
668                        // is cut after a few so the log cannot be flooded.
669                        mismatched += 1;
670                        if mismatched >= MAX_ID_MISMATCHES {
671                            tracing::warn!(peer = %peer_addr, node = act.bound.as_deref().unwrap_or("-"),
672                                "dialled peer keeps speaking for other ids, closing");
673                            break;
674                        }
675                    }
676                    if let Some(frame) = screened {
677                        let ctx = act.peer_ctx();
678                        if let Err(e) = rt.handle_incoming_tallied(&frame, out_tx.clone(), Some(&ctx), Some(&tally)).await {
679                            tracing::debug!(error = %e, "mesh message handling error");
680                        }
681                        routed = routed || tally.live() > 0;
682                    }
683                }
684                Err(_) => break,
685            },
686            outbound = out_rx.recv() => match outbound {
687                Some(data) => {
688                    if let Some(cert) = active.as_ref().and_then(|a| a.leaf_cert.as_ref())
689                        && !rt.leaf_ingress().is_some_and(|ingress| ingress.still_enrolled(cert, unix_now())) {
690                        tracing::warn!(peer = %peer_addr, node = %cert.leaf_id(), "certified leaf revoked before outbound push; closing route");
691                        break;
692                    }
693                    if !dialled {
694                        last_activity = tokio::time::Instant::now();
695                    }
696                    if channel.send_encrypted(&data).await.is_err() {
697                        break;
698                    }
699                }
700                None => break,
701            },
702        }
703    }
704}
705
706/// Drops every route that still points at one connection (a verified route
707/// must not outlive its connection) when the pump ends or is aborted.
708struct ConnRoutes {
709    rt: Arc<MeshRuntime>,
710    tx: tokio::sync::mpsc::Sender<Vec<u8>>,
711}
712
713impl Drop for ConnRoutes {
714    fn drop(&mut self) {
715        self.rt.disconnect_channel(&self.tx);
716    }
717}
718
719/// Frames a dialled connection may have dropped for naming the wrong id
720/// before it is closed.
721const MAX_ID_MISMATCHES: u32 = 3;
722
723/// First reconnect delay; doubles per consecutive failure up to
724/// [`SEED_BACKOFF_MAX`]. Each wait is jittered to 50-100% of its nominal
725/// value so a fleet restarted together does not redial in lockstep.
726const SEED_BACKOFF_BASE: std::time::Duration = std::time::Duration::from_secs(1);
727const SEED_BACKOFF_MAX: std::time::Duration = std::time::Duration::from_secs(60);
728/// Inbound silence after which a dialled connection is considered half-open.
729const SEED_IDLE: std::time::Duration = std::time::Duration::from_secs(300);
730/// A connection that stayed up this long resets the backoff.
731const SEED_STABLE: std::time::Duration = std::time::Duration::from_secs(30);
732
733/// Backoff for the `failures`-th consecutive failure (0-based), jittered.
734pub(crate) fn seed_backoff(
735    failures: u32,
736    base: std::time::Duration,
737    max: std::time::Duration,
738) -> std::time::Duration {
739    let nominal = base.saturating_mul(1u32 << failures.min(16)).min(max);
740    let jitter = rand::random::<f64>() * 0.5 + 0.5;
741    nominal.mul_f64(jitter)
742}
743
744/// Retry timing for a seed dial loop (tests shorten it).
745#[derive(Clone, Copy)]
746pub(crate) struct SeedTiming {
747    pub base: std::time::Duration,
748    pub max: std::time::Duration,
749    pub stable: std::time::Duration,
750    /// Inbound silence after which a dialled connection is dropped and redialled.
751    pub idle: std::time::Duration,
752}
753
754impl Default for SeedTiming {
755    fn default() -> Self {
756        Self { base: SEED_BACKOFF_BASE, max: SEED_BACKOFF_MAX, stable: SEED_STABLE, idle: SEED_IDLE }
757    }
758}
759
760/// One configured seed: the address to dial and, when the operator pinned
761/// it, the node id the seed must claim. Written `addr` or `addr#node-id`.
762///
763/// A seed sends no hello, so its id is otherwise whatever it claims first.
764/// A pin binds the connection to that id from the start: frames naming any
765/// other id are dropped, so a seed cannot speak for (or impersonate while
766/// offline) another node. The pin is the operator's word, not a proof: the
767/// seed's peer is still held as unverified.
768#[derive(Debug, Clone, PartialEq, Eq)]
769pub struct SeedSpec {
770    /// Address to dial (`host:port`, or a URL for the transport).
771    pub addr: String,
772    /// Node id the seed is expected to be, if pinned.
773    pub node_id: Option<String>,
774}
775
776/// Parse one `seed_peers` entry (`addr` or `addr#node-id`).
777pub fn parse_seed(entry: &str) -> Result<SeedSpec, String> {
778    let entry = entry.trim();
779    let (addr, id) = match entry.rsplit_once('#') {
780        Some((a, i)) => (a, Some(i)),
781        None => (entry, None),
782    };
783    if addr.is_empty() || addr.chars().any(|c| c.is_whitespace() || c.is_control() || c == '#') {
784        return Err(format!("seed address {addr:?} is not usable"));
785    }
786    let node_id = match id {
787        None => None,
788        Some(i) if i.is_empty() || i.len() > 128 => {
789            return Err(format!("seed {addr}: pinned node id must be 1 to 128 characters"));
790        }
791        Some(i) if !i.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | ':' | '-')) => {
792            return Err(format!("seed {addr}: pinned node id has characters a node id cannot"));
793        }
794        Some(i) => Some(i.to_owned()),
795    };
796    Ok(SeedSpec { addr: addr.to_owned(), node_id })
797}
798
799/// The seeds to dial: parsed, unusable entries skipped with a warning, and
800/// one entry per address (the first wins; a different pin on a repeat is
801/// reported, not honoured).
802pub fn seed_specs(entries: &[String]) -> Vec<SeedSpec> {
803    let mut seen: std::collections::HashMap<String, Option<String>> = Default::default();
804    let mut out = Vec::new();
805    for e in entries {
806        match parse_seed(e) {
807            Err(why) => tracing::warn!(seed = %e.split('#').next().unwrap_or(""), "{why}; seed skipped"),
808            Ok(spec) => match seen.get(&spec.addr) {
809                None => {
810                    seen.insert(spec.addr.clone(), spec.node_id.clone());
811                    out.push(spec);
812                }
813                Some(first) if *first != spec.node_id => tracing::warn!(peer = %spec.addr,
814                    "seed listed twice with different pinned ids; the first is used"),
815                Some(_) => {}
816            },
817        }
818    }
819    out
820}
821
822/// Dial each seed peer in its own task (Noise initiator when `noise` is
823/// set) and register it with `runtime`. Returns immediately.
824///
825/// Entries are `addr` or `addr#node-id` (see [`SeedSpec`]).
826///
827/// Connections are bidirectional (inbound frames from the seed reach the
828/// runtime) and are redialled with jittered exponential backoff when they
829/// drop. The returned handles run until aborted; abort them to stop
830/// dialling and close the connections.
831pub fn connect_seeds(
832    runtime: &Arc<MeshRuntime>,
833    seed_peers: &[String],
834    transport_name: &str,
835    noise: Option<Arc<NoiseConfig>>,
836    identity: Option<Arc<DialIdentity>>,
837) -> Vec<tokio::task::JoinHandle<()>> {
838    connect_seeds_with(runtime, seed_peers, transport_name, noise, identity, SeedTiming::default())
839}
840
841pub(crate) fn connect_seeds_with(
842    runtime: &Arc<MeshRuntime>,
843    seed_peers: &[String],
844    transport_name: &str,
845    noise: Option<Arc<NoiseConfig>>,
846    identity: Option<Arc<DialIdentity>>,
847    timing: SeedTiming,
848) -> Vec<tokio::task::JoinHandle<()>> {
849    seed_specs(seed_peers)
850        .into_iter()
851        .map(|SeedSpec { addr, node_id: expected }| {
852            // Weak: an idle dial loop does not keep a dropped runtime alive
853            // (an established connection still holds it until it ends).
854            let rt = Arc::downgrade(runtime);
855            let transport_name = transport_name.to_owned();
856            let nc = noise.clone();
857            let identity = identity.clone();
858            tokio::spawn(async move {
859                let mut failures = 0u32;
860                loop {
861                    let Some(rt) = rt.upgrade() else { return };
862                    let started = tokio::time::Instant::now();
863                    dial_seed_once(&rt, &addr, expected.as_deref(), &transport_name, nc.clone(), identity.clone(), timing.idle).await;
864                    drop(rt);
865                    if started.elapsed() >= timing.stable {
866                        failures = 0;
867                    }
868                    let wait = seed_backoff(failures, timing.base, timing.max);
869                    failures = failures.saturating_add(1);
870                    tracing::info!(peer = %addr, retry_in_ms = wait.as_millis() as u64,
871                        "seed connection down, will redial");
872                    tokio::time::sleep(wait).await;
873                }
874            })
875        })
876        .collect()
877}
878
879/// One dial of a seed: connect, handshake, hello, then pump until the
880/// connection ends. Returns when it is over (for any reason).
881async fn dial_seed_once(
882    rt: &Arc<MeshRuntime>,
883    addr: &str,
884    expected: Option<&str>,
885    transport_name: &str,
886    nc: Option<Arc<NoiseConfig>>,
887    identity: Option<Arc<DialIdentity>>,
888    idle: std::time::Duration,
889) {
890    let auth = rt.authentication();
891    let strict_seed = crate::mesh_admit_gate::nested_peer_ceiling_active()
892        || auth.is_some_and(|a| a.require_authenticated_seeds);
893    if strict_seed && (expected.is_none() || nc.is_none() || identity.is_none()
894        || !auth.is_some_and(|a| a.gate.strict())) {
895        tracing::warn!(peer = %addr, "nested seed refused: missing pin, Noise, identity or enforcing gate");
896        return;
897    }
898    let transport = transport_for(transport_name, Some(addr));
899    let stream = match transport.connect(addr).await {
900        Ok(s) => s,
901        Err(e) => {
902            tracing::warn!(peer = %addr, error = %e, "failed to connect to seed peer");
903            return;
904        }
905    };
906    // Optionally wrap in Noise encryption (as initiator).
907    let mut channel: Box<dyn EncryptedChannel> = match &nc {
908        Some(cfg) => match tokio::time::timeout(HANDSHAKE_TIMEOUT, NoiseChannel::initiate(stream, cfg))
909            .await
910            .unwrap_or_else(|_| {
911                Err(crate::mesh::MeshError::Handshake("handshake timed out".into()))
912            }) {
913            Ok(ch) => {
914                tracing::info!(peer = %addr, "noise handshake complete (initiator)");
915                Box::new(ch)
916            }
917            Err(e) => {
918                tracing::warn!(peer = %addr, error = %e, "noise handshake failed");
919                return;
920            }
921        },
922        None => Box::new(PassthroughChannel::new(stream)),
923    };
924    let kind = if nc.is_some() { ChannelKind::Noise } else { ChannelKind::Passthrough };
925
926    // Introduce ourselves before anything else is sent. Only a Noise session
927    // has a handshake hash to bind the hello to; plaintext dials send nothing
928    // (the far side's gate decides whether that is acceptable).
929    if let (Some(id), Some(cfg), Some(hash)) =
930        (&identity, &nc, channel.handshake_hash().map(<[u8]>::to_vec))
931    {
932        match noise_static_public(&cfg.local_private_key) {
933            Some(stat) => {
934                let hello = id.hello(&hash, &stat, unix_now());
935                let hello_bytes = if strict_seed {
936                    // This flag only asks for a response; the response itself is
937                    // signed over this Noise session and goes through admission.
938                    let mut wire = serde_json::to_value(&hello).expect("hello serializes");
939                    wire["reciprocal"] = serde_json::Value::Bool(true);
940                    serde_json::to_vec(&wire).expect("hello serializes")
941                } else { hello.to_bytes() };
942                if channel.send_encrypted(&hello_bytes).await.is_err() {
943                    tracing::warn!(peer = %addr, "failed to send admission hello");
944                    return;
945                }
946            }
947            None => tracing::warn!(peer = %addr, "cannot derive noise static key"),
948        }
949    }
950
951    if strict_seed {
952        let gate = &*auth.expect("checked above").gate;
953        let admission = async {
954            let frame = channel.recv_encrypted().await.map_err(|e| e.to_string())?;
955            let (active, rest) = admit_first_frame(&*channel, kind, gate, frame).await
956                .map_err(|e| e.detail)?;
957            if !active.admitted || active.bound.as_deref() != expected || rest.is_some() {
958                return Err("seed did not prove the granted identity under admission".to_owned());
959            }
960            Ok(active)
961        };
962        let active = match tokio::time::timeout(HANDSHAKE_TIMEOUT, admission).await {
963            Ok(Ok(active)) => active,
964            _ => { tracing::warn!(peer = %addr, "reciprocal seed admission failed"); return; }
965        };
966        let (out_tx, out_rx) = tokio::sync::mpsc::channel(256);
967        let tally = RouteTally::default();
968        let id = active.bound.as_ref().expect("verified above");
969        if !rt.register_authenticated_as(id.clone(), out_tx.clone(), true, active.class, &tally) { return; }
970        // This address alias is installed only AFTER key/session verification and
971        // admission, so even the first queued outbound payload cannot leak.
972        rt.add_peer_tallied(addr.to_owned(), out_tx.clone(), &tally);
973        pump(rt, channel, kind, addr, gate, Limits { idle, ..Limits::default() },
974            out_tx, out_rx, Some(active), tally, true, false).await;
975        return;
976    }
977
978    let (out_tx, out_rx) = tokio::sync::mpsc::channel(256);
979    // A pinned id that another connection already routes is not ours to take.
980    if let Some(id) = expected
981        && rt.route_is_foreign(id, &out_tx)
982    {
983        tracing::warn!(peer = %addr, pinned = %id,
984            "pinned seed id is routed through another connection, not dialling");
985        return;
986    }
987    let tally = RouteTally::default();
988    rt.add_peer_tallied(addr.to_owned(), out_tx.clone(), &tally);
989    tracing::info!(peer = %addr, noise = nc.is_some(), "connected to seed peer");
990    // No hello comes back from a seed, so `bound` is the id the operator
991    // pinned for it, or unset until its first frame names an id (see the
992    // pump); frames then go through `screen_frame` and the runtime's identity
993    // rules like any other.
994    let active = Active {
995        bound: expected.map(str::to_owned),
996        limits: PeerLimits::None,
997        trust_scope: false,
998        admitted: false,
999        class: PeerClass::Legacy,
1000        remote_static: channel.remote_static_key().map(<[u8]>::to_vec),
1001        signed_leaf: false,
1002        leaf_cert: None,
1003    };
1004    let limits = Limits { idle, ..Limits::default() };
1005    pump(rt, channel, kind, addr, &crate::mesh_admit::AllowAll, limits, out_tx, out_rx, Some(active), tally, true, false).await;
1006}
1007
1008#[cfg(test)]
1009mod tests {
1010    use super::*;
1011    use crate::error::KernelResult;
1012    use crate::ipc::{KernelMessage, MessagePayload, MessageTarget};
1013    use crate::mesh_delivery::{LocalDelivery, PeerCtx};
1014    use crate::mesh_ipc::{MeshIpcEnvelope, Scope};
1015    use std::sync::Mutex;
1016
1017    #[derive(Default)]
1018    struct Recorder {
1019        got: Mutex<Vec<(Option<Scope>, KernelMessage)>>,
1020    }
1021
1022    #[async_trait::async_trait]
1023    impl LocalDelivery for Recorder {
1024        async fn deliver(
1025            &self,
1026            _from: &PeerCtx,
1027            scope: Option<&Scope>,
1028            msg: KernelMessage,
1029        ) -> KernelResult<()> {
1030            self.got.lock().unwrap().push((scope.cloned(), msg));
1031            Ok(())
1032        }
1033    }
1034
1035    #[tokio::test]
1036    async fn serve_listener_delivers_scoped_envelope_to_local_delivery() {
1037        let rec = Arc::new(Recorder::default());
1038        let mut rt = MeshRuntime::new("node-b".into());
1039        rt.set_local_delivery(rec.clone());
1040        let rt = Arc::new(rt);
1041
1042        let transport = crate::mesh_tcp::TcpTransport;
1043        let listener = transport.listen("127.0.0.1:0").await.unwrap();
1044        let addr = listener.local_addr().unwrap();
1045        let server = tokio::spawn(serve_listener(
1046            Arc::clone(&rt),
1047            listener,
1048            None,
1049            "tcp",
1050            "127.0.0.1:0",
1051            Arc::new(crate::mesh_admit::AllowAll),
1052        ));
1053
1054        let mut client = transport.connect(&addr.to_string()).await.unwrap();
1055        let msg = KernelMessage::text(0, MessageTarget::Topic("t.scoped".into()), "hi");
1056        let mut env = MeshIpcEnvelope::new("node-a".into(), "node-b".into(), msg);
1057        env.dest_scope = Some(Scope {
1058            user_id: "a".repeat(32),
1059            project_id: None,
1060        });
1061        client.send(&env.to_bytes().unwrap()).await.unwrap();
1062
1063        for _ in 0..100 {
1064            if !rec.got.lock().unwrap().is_empty() {
1065                break;
1066            }
1067            tokio::time::sleep(std::time::Duration::from_millis(20)).await;
1068        }
1069        server.abort();
1070
1071        let got = rec.got.lock().unwrap();
1072        assert_eq!(got.len(), 1, "envelope not delivered");
1073        assert_eq!(got[0].0.as_ref().unwrap().user_id, "a".repeat(32));
1074        assert!(matches!(&got[0].1.payload, MessagePayload::Text(s) if s == "hi"));
1075        // The accept loop registered the sender by source_node.
1076        assert!(rt.peer_ids().contains(&"node-a".to_string()));
1077    }
1078}
1079
1080#[cfg(test)]
1081#[path = "mesh_serve_tests.rs"]
1082mod serve_tests;
1083
1084#[cfg(test)]
1085#[path = "mesh_nested_dial_tests.rs"]
1086mod nested_dial_tests;