dora_core/topics.rs
1use std::net::{IpAddr, Ipv4Addr, SocketAddr};
2
3pub const LOCALHOST: IpAddr = IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1));
4pub const DORA_DAEMON_LOCAL_LISTEN_PORT_DEFAULT: u16 = 53291;
5/// Env var to override the daemon's local listener port for dynamic nodes.
6pub const DORA_DAEMON_LOCAL_LISTEN_PORT_ENV: &str = "DORA_DAEMON_LOCAL_LISTEN_PORT";
7pub const DORA_COORDINATOR_PORT_WS_DEFAULT: u16 = 6013;
8
9/// Comma-separated zenoh endpoints a spawned node should connect to, injected by
10/// the daemon: the daemon's own listener plus the listeners of the nodes this one
11/// consumes from (see [`DORA_ZENOH_LISTEN_ENV`]).
12///
13/// The daemon's listener is loopback for a single-machine deployment, but may be
14/// a routable address when the daemon is part of a cluster (see
15/// [`zenoh_bind_address_for`]). Either way it is on this node's own host, so the
16/// node can always reach it.
17///
18/// Lets nodes bootstrap zenoh peer discovery without multicast (dev containers,
19/// locked-down hosts, many CI runners), and — since zenoh 1.9 removed peer
20/// relaying — establishes the node↔node links the dataflow needs *explicitly*
21/// rather than leaving them to gossip's best-effort autoconnect.
22pub const DORA_ZENOH_CONNECT_ENV: &str = "DORA_ZENOH_CONNECT";
23
24/// Loopback zenoh endpoint a spawned node should listen on, injected by the
25/// daemon so this node's consumers can dial it directly (they receive it via
26/// their [`DORA_ZENOH_CONNECT_ENV`]).
27///
28/// Peers in zenoh 1.9 do not relay for each other, so a producer and consumer
29/// that never form a direct link simply cannot exchange data — no amount of
30/// waiting fixes it. Assigning each node a known listener makes those links
31/// deterministic instead of racy.
32///
33/// **Stays single-valued.** A node that also needs a routable listener gets it
34/// via [`DORA_ZENOH_LISTEN_EXTRA_ENV`] rather than as a second entry here,
35/// because a node binary built before that variable existed pushes this value
36/// into `listen/endpoints` verbatim as *one* locator. A comma-separated value
37/// would therefore be rejected wholesale by such a node, costing it the
38/// loopback listener too and partitioning it from its same-machine consumers.
39/// Keeping the old variable's shape means an older node degrades to
40/// loopback-only — same-machine links keep working, cross-machine ones fall
41/// back to the daemon path — instead of losing every link (dora-rs/dora#2742).
42pub const DORA_ZENOH_LISTEN_ENV: &str = "DORA_ZENOH_LISTEN";
43
44/// Additional zenoh endpoints a spawned node should listen on, beyond the
45/// loopback one in [`DORA_ZENOH_LISTEN_ENV`]. Comma-separated; injected by the
46/// daemon only for a node that has a consumer under another daemon.
47///
48/// Split out from [`DORA_ZENOH_LISTEN_ENV`] for forward compatibility — see the
49/// note there. A node that does not know this variable simply ignores it.
50pub const DORA_ZENOH_LISTEN_EXTRA_ENV: &str = "DORA_ZENOH_LISTEN_EXTRA";
51
52/// Opt out of zenoh multicast scouting for this process, regardless of whether
53/// explicit connect endpoints replaced it.
54///
55/// Set to `off`, `0`, `false`, or `no` to disable. Any other value (including
56/// unset) leaves the default behaviour, where multicast is dropped only once
57/// [`DORA_ZENOH_CONNECT_ENV`] gives the session something to dial instead.
58///
59/// Exists for networks where the scouting socket itself is the problem: a busy
60/// DDS/ROS2 multicast graph can keep zenoh from binding its scouting group,
61/// which fails `zenoh::open` outright. Disabling scouting sidesteps that bind
62/// entirely — but it removes a discovery mechanism, so a session that has no
63/// connect endpoints *and* no multicast can reach nobody. Set it only where
64/// every link is established explicitly (the daemon injects
65/// [`DORA_ZENOH_CONNECT_ENV`] into the nodes it spawns, so those are covered).
66///
67/// The daemon sets this on the nodes it spawns when started with
68/// `--zenoh-no-multicast`, so a single flag covers the whole process tree.
69pub const DORA_ZENOH_MULTICAST_ENV: &str = "DORA_ZENOH_MULTICAST";
70
71/// Pid of the process whose death must end this node, injected **only** by a
72/// daemon that runs in-process with whoever started it: `Daemon::run_dataflow`
73/// and its callers — `dora run`, `dora daemon --run-dataflow`, and embedders
74/// that drive one dataflow to completion.
75///
76/// There, the one process is coordinator, daemon and node-parent at once, so
77/// its death is the end of the dataflow by definition. Every teardown path
78/// dora has is cooperative and so cannot survive `SIGKILL`, which is neither
79/// catchable nor blockable: no CLI- or daemon-side code runs after it. Nodes
80/// are deliberately spawned as process-group leaders (so a terminal `Ctrl-C`
81/// cannot kill them out from under the daemon), which also means an orphan
82/// keeps running with `ppid 1` in a group of its own — unreachable by both
83/// inherited signal delivery and a group-kill of the parent. Handing the node
84/// the pid lets it notice on its own (dora-rs/dora#2856).
85///
86/// Deliberately NOT set on the `dora up` + `dora start` path: there the parent
87/// is a long-lived daemon whose lifetime is decoupled from its nodes on
88/// purpose — a node survives a coordinator drop, a reconnect, and a watchdog
89/// disconnect while keeping its pid (dora-rs/dora#2029). Tying node lifetime
90/// to that parent would break exactly the property `daemon-reconnect-e2e`
91/// asserts.
92pub const DORA_RUN_PARENT_PID_ENV: &str = "DORA_RUN_PARENT_PID";
93
94/// Zenoh's own config-file override, honored by
95/// [`open_zenoh_session_with_listen`].
96///
97/// Takes precedence over every `DORA_ZENOH_*` variable: when it is set the
98/// session is built entirely from the named file, so the connect/listen plan
99/// and the multicast decision are never read. That makes it a full bypass of
100/// the daemon's node wiring, which is why the daemon refuses it from a
101/// descriptor's `env:` (#2944) while still honoring it from its own
102/// environment — the documented way to point a whole deployment at a custom
103/// zenoh config.
104#[cfg(feature = "zenoh")]
105pub const ZENOH_CONFIG_PATH_ENV: &str = zenoh::Config::DEFAULT_CONFIG_PATH_ENV;
106
107/// Whether a session may discover peers by multicast scouting.
108///
109/// Spelled as an enum rather than a bool because the concept flips polarity at
110/// every hop it crosses — `--zenoh-no-multicast`, `DORA_ZENOH_MULTICAST=off`,
111/// `scouting/multicast/enabled=false` — and an inverted bool would not fail a
112/// test, it would silently partition the dataflow.
113#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
114pub enum MulticastScouting {
115 /// Scout unless explicit connect endpoints replace it. The default
116 /// everywhere; [`DORA_ZENOH_MULTICAST_ENV`] can still turn it off.
117 #[default]
118 Allowed,
119 /// The caller establishes every link explicitly and wants no scouting.
120 Disabled,
121}
122
123/// Whether [`DORA_ZENOH_MULTICAST_ENV`] asks for multicast scouting to be off.
124#[cfg(feature = "zenoh")]
125fn multicast_disabled_by_env() -> bool {
126 multicast_disabled_by_value(std::env::var(DORA_ZENOH_MULTICAST_ENV).ok().as_deref())
127}
128
129/// The effective decision for a process that also has its own request.
130///
131/// [`open_zenoh_session_with_listen`] ORs the caller's request with
132/// [`DORA_ZENOH_MULTICAST_ENV`], so a process that has to *forward* its
133/// decision — the daemon, to the nodes it spawns — must OR them the same way.
134/// Forwarding only its own flag drops the environment half, leaving nodes
135/// scouting by multicast in exactly the environments where the variable was
136/// set to stop them.
137#[cfg(feature = "zenoh")]
138pub fn multicast_disabled(requested_off: bool) -> bool {
139 requested_off || multicast_disabled_by_env()
140}
141
142/// Parse a [`DORA_ZENOH_MULTICAST_ENV`] value (`None` when the var is unset).
143///
144/// Split from [`multicast_disabled_by_env`] so it is testable without mutating
145/// the process environment, which is `unsafe` in edition 2024 and racy against
146/// other tests in the same binary.
147#[cfg(feature = "zenoh")]
148fn multicast_disabled_by_value(value: Option<&str>) -> bool {
149 matches!(
150 value.map(|v| v.trim().to_ascii_lowercase()).as_deref(),
151 Some("off" | "0" | "false" | "no")
152 )
153}
154
155/// Split a comma-separated endpoint list env var, ignoring empty entries.
156#[cfg(feature = "zenoh")]
157fn split_endpoints(value: &str) -> impl Iterator<Item = String> + '_ {
158 value
159 .split(',')
160 .map(str::trim)
161 .filter(|s| !s.is_empty())
162 .map(String::from)
163}
164
165/// Assemble a node's listen endpoints from the two env vars the daemon sets.
166///
167/// Split across two variables rather than one comma-separated value for
168/// forward compatibility — see [`DORA_ZENOH_LISTEN_EXTRA_ENV`]. Both are run
169/// through [`split_endpoints`] so a hand-set list in either keeps working, and
170/// so the loopback entry stays first: `listen/endpoints` order is what decides
171/// which locator a same-machine consumer picks, and loopback is the one whose
172/// transport can carry shared memory.
173#[cfg(feature = "zenoh")]
174fn listen_endpoints_from_env(listen: Option<&str>, extra: Option<&str>) -> Vec<String> {
175 listen
176 .into_iter()
177 .chain(extra)
178 .flat_map(split_endpoints)
179 .collect()
180}
181
182/// Path to a JSON5 file whose contents are layered on top of the zenoh config
183/// dora computes.
184///
185/// The additive counterpart to [`ZENOH_CONFIG_PATH_ENV`], which replaces the
186/// whole config — including the per-node connect/listen plan the daemon builds,
187/// so a router named there ends up relaying even same-machine traffic. An
188/// overlay keeps that plan and adds to it, which is what "point this deployment
189/// at my routers" actually means. Setting both is refused rather than merged.
190///
191/// Inherited by spawned nodes (like [`ZENOH_CONFIG_PATH_ENV`]) so one variable
192/// covers a whole process tree, and refused from a descriptor's `env:` for the
193/// same reason: it is deployment wiring, not per-node configuration.
194#[cfg(feature = "zenoh")]
195pub const DORA_ZENOH_CONFIG_OVERLAY_ENV: &str = "DORA_ZENOH_CONFIG_OVERLAY";
196
197/// Operator-supplied zenoh settings to layer onto dora's computed config.
198///
199/// Split into the two endpoint lists, which **merge** with what dora computed,
200/// and everything else, which **replaces** it. Merging is what makes the
201/// overlay additive where it matters: naming a router under
202/// `connect.endpoints` adds a path to it without deleting the direct node links
203/// dora planned, which is exactly the difference from a wholesale
204/// [`ZENOH_CONFIG_PATH_ENV`].
205#[cfg(feature = "zenoh")]
206#[derive(Debug, Default, Clone)]
207pub struct ZenohOverlay {
208 /// Extra peers to dial, appended to dora's `connect/endpoints`.
209 pub connect_endpoints: Vec<String>,
210 /// Extra addresses to bind, appended to dora's `listen/endpoints`.
211 pub listen_endpoints: Vec<String>,
212 /// Every other setting, applied after dora's own inserts.
213 rest: serde_json::Map<String, serde_json::Value>,
214}
215
216#[cfg(feature = "zenoh")]
217impl ZenohOverlay {
218 /// Read the overlay named by [`DORA_ZENOH_CONFIG_OVERLAY_ENV`], if any.
219 ///
220 /// A named file that cannot be read or parsed is a hard error: the operator
221 /// asked for these settings, and a session silently opened without them is
222 /// the kind of "runs but talks to nobody" outcome that takes hours to
223 /// diagnose.
224 pub fn from_env() -> eyre::Result<Option<Self>> {
225 use eyre::Context;
226
227 let Some(path) = std::env::var_os(DORA_ZENOH_CONFIG_OVERLAY_ENV) else {
228 return Ok(None);
229 };
230 let path = std::path::PathBuf::from(path);
231 let contents = std::fs::read_to_string(&path).wrap_err_with(|| {
232 format!(
233 "failed to read the zenoh config overlay at `{}` named by {}",
234 path.display(),
235 DORA_ZENOH_CONFIG_OVERLAY_ENV
236 )
237 })?;
238 Self::parse(&contents)
239 .wrap_err_with(|| format!("invalid zenoh config overlay at `{}`", path.display()))
240 .map(Some)
241 }
242
243 /// Parse an overlay from JSON5 — zenoh's own config dialect, so an operator
244 /// can paste a fragment of a zenoh config file, comments and all.
245 pub fn parse(json5_str: &str) -> eyre::Result<Self> {
246 use eyre::{Context, eyre};
247
248 let value: serde_json::Value = json5::from_str(json5_str)
249 .map_err(|err| eyre!("{err}"))
250 .wrap_err(
251 "overlay must be a JSON5 object of zenoh config keys, e.g. \
252 `{ connect: { endpoints: [\"tcp/10.0.0.1:7447\"] } }`",
253 )?;
254 let serde_json::Value::Object(mut object) = value else {
255 eyre::bail!("overlay must be a JSON5 object, not a bare value");
256 };
257
258 let mut overlay = Self::default();
259 for (key, endpoints) in [
260 ("connect", &mut overlay.connect_endpoints),
261 ("listen", &mut overlay.listen_endpoints),
262 ] {
263 let Some(serde_json::Value::Object(section)) = object.get_mut(key) else {
264 continue;
265 };
266 // Taken out of `rest` so the merged list survives: re-inserting the
267 // section wholesale afterwards would overwrite it.
268 let Some(value) = section.remove("endpoints") else {
269 continue;
270 };
271 let serde_json::Value::Array(list) = value else {
272 eyre::bail!("`{key}.endpoints` must be an array of endpoint strings");
273 };
274 for entry in list {
275 match entry {
276 serde_json::Value::String(endpoint) => endpoints.push(endpoint),
277 other => eyre::bail!(
278 "`{key}.endpoints` must contain endpoint strings, found `{other}`"
279 ),
280 }
281 }
282 // An emptied section would otherwise be re-inserted as `{}`.
283 if section.is_empty() {
284 object.remove(key);
285 }
286 }
287 overlay.rest = object;
288 Ok(overlay)
289 }
290
291 /// Apply everything except the endpoint lists, which the caller has already
292 /// merged into its own.
293 ///
294 /// Each setting is inserted at its deepest path, so an overlay touching one
295 /// subkey leaves its siblings alone. A path zenoh rejects is retried one
296 /// level up, because some sections only accept a whole-object write —
297 /// `insert_json5("gateway/south", ..)` is refused where
298 /// `insert_json5("gateway", ..)` is accepted (dora-rs/dora#2721).
299 fn apply(&self, config: &mut zenoh::Config) -> eyre::Result<()> {
300 for (key, value) in &self.rest {
301 insert_overlay_value(config, key, value)?;
302 }
303 Ok(())
304 }
305}
306
307/// Insert one overlay value, descending into objects and falling back to a
308/// whole-object write when a subpath is rejected. See [`ZenohOverlay::apply`].
309#[cfg(feature = "zenoh")]
310fn insert_overlay_value(
311 config: &mut zenoh::Config,
312 path: &str,
313 value: &serde_json::Value,
314) -> eyre::Result<()> {
315 if let serde_json::Value::Object(fields) = value
316 && !fields.is_empty()
317 {
318 let mut written = true;
319 for (field, child) in fields {
320 if insert_overlay_value(config, &format!("{path}/{field}"), child).is_err() {
321 written = false;
322 break;
323 }
324 }
325 if written {
326 return Ok(());
327 }
328 // Falling through re-writes the fields that did land, with the same
329 // values — `value` still contains them — so a partial descent is not
330 // left half-applied.
331 }
332 config
333 .insert_json5(path, &value.to_string())
334 .map_err(|err| eyre::eyre!("failed to apply zenoh config overlay key `{path}`: {err}"))
335}
336
337#[cfg(feature = "zenoh")]
338pub async fn open_zenoh_session(coordinator_addr: Option<IpAddr>) -> eyre::Result<zenoh::Session> {
339 // Nodes and the coordinator have no in-process way to know, so
340 // [`DORA_ZENOH_MULTICAST_ENV`] (honored inside) is their only channel.
341 let (session, _) = open_zenoh_session_with_listen(ZenohSessionParams {
342 coordinator_addr,
343 ..Default::default()
344 })
345 .await?;
346 Ok(session)
347}
348
349/// How a dora process wants its zenoh session wired.
350///
351/// A struct rather than a parameter list because every field is optional and
352/// most callers set one of them: the positional form made
353/// `open_zenoh_session_with_listen(None, None, None, Allowed)` a common sight,
354/// where a misplaced `None` is a silent partition rather than a type error.
355/// [`Default`] gives "a plain peer with whatever the environment says", which
356/// is what nodes and the coordinator want.
357#[cfg(feature = "zenoh")]
358#[derive(Debug, Default, Clone, Copy)]
359pub struct ZenohSessionParams<'a> {
360 /// Coordinator to reach through a zenoh router/peer pair. Unused by every
361 /// in-tree caller today; see the `coordinator_addr` branch below.
362 pub coordinator_addr: Option<IpAddr>,
363 /// Endpoint this session listens on and advertises to its peers, e.g.
364 /// `tcp/127.0.0.1:43217` for a single-machine daemon or `tcp/10.0.2.100:5456`
365 /// for one in a cluster. Verified against `info().locators()` after open;
366 /// see the return value.
367 pub listen_endpoint: Option<&'a str>,
368 /// Shared rendezvous endpoint for daemon-to-daemon discovery when multicast
369 /// isn't available: added to *both* listen and connect endpoints, so the
370 /// first daemon to bind it becomes the gossip hub and the rest fall through
371 /// to connect-only.
372 pub inter_daemon_peer: Option<&'a str>,
373 /// Peers this session dials, in addition to whatever
374 /// [`DORA_ZENOH_CONNECT_ENV`] carries. This is how a deployment wires its
375 /// daemons into an explicit mesh instead of relying on gossip through a
376 /// single rendezvous: every daemon dials every other one, which is the
377 /// clique zenoh 1.9 requires of a peer region.
378 ///
379 /// Operator-supplied, and therefore *authoritative*: naming them is a
380 /// statement that this deployment does not rely on scouting, so they
381 /// replace multicast (see the `#1856` guard below).
382 pub connect_endpoints: &'a [String],
383 /// Peers this session dials that dora *discovered* rather than the operator
384 /// naming them — today, the endpoints the coordinator handed back at
385 /// registration.
386 ///
387 /// Dialed exactly like [`Self::connect_endpoints`], but deliberately
388 /// excluded from the decision to turn multicast scouting off. A discovered
389 /// list can be incomplete or stale in ways an operator-supplied one cannot:
390 /// a peer that has not yet reported its endpoint is missing from it, and a
391 /// peer that died moments ago is still in it until the coordinator notices.
392 /// Letting such a list disable scouting would make a partial answer *worse*
393 /// than no answer — it would strip the fallback that was working — so
394 /// discovery here is strictly additive: it adds links, and multicast stays
395 /// available to cover whatever it missed.
396 pub discovered_connect_endpoints: &'a [String],
397 /// Whether this session may scout by multicast. A request, not a command —
398 /// see the `#1856` guard below.
399 pub multicast: MulticastScouting,
400}
401
402/// Builds the zenoh `connect/endpoints` JSON5 for a coordinator peer.
403///
404/// The peer address is formatted through a [`SocketAddr`] so that IPv6
405/// addresses are bracketed (`tcp/[::1]:5456`), matching zenoh's TCP locator
406/// grammar. Interpolating a bare [`IpAddr`] instead would emit `tcp/::1:5456`
407/// for IPv6 — a malformed locator where the port colon is indistinguishable
408/// from the address colons, which `insert_json5` rejects (#3041). This is the
409/// same bracketing [`reserve_zenoh_endpoint`] already relies on.
410#[cfg(feature = "zenoh")]
411fn coordinator_connect_endpoints(addr: IpAddr) -> String {
412 let peer = SocketAddr::new(addr, 5456);
413 format!(r#"{{ router: ["tcp/[::]:7447"], peer: ["tcp/{peer}"] }}"#)
414}
415
416/// Like [`open_zenoh_session`], but takes the full [`ZenohSessionParams`]: a
417/// listen endpoint to bind and advertise (e.g. `tcp/127.0.0.1:43217`, or a
418/// routable address such as `tcp/10.0.2.100:5456` for a daemon in a cluster),
419/// extra peers to dial, and the multicast request. The daemon uses this so
420/// spawned nodes can connect via `DORA_ZENOH_CONNECT` without multicast
421/// scouting, and so that other daemons can dial it.
422///
423/// `connect_endpoints` are dialed in addition to whatever
424/// [`DORA_ZENOH_CONNECT_ENV`] carries, deduplicated against it. They are
425/// dial-only, which is what distinguishes an explicit mesh (each daemon
426/// listens on its own endpoint and dials its peers') from the rendezvous
427/// below (every daemon both listens on and dials the *same* endpoint).
428///
429/// `inter_daemon_peer` is an optional shared endpoint used as the
430/// rendezvous for daemon-to-daemon discovery when multicast isn't
431/// available. When set, it is added to both `listen/endpoints` and
432/// `connect/endpoints`: the first daemon to bind it serves as the
433/// gossip hub, and the rest fall through to connect-only via
434/// `listen/exit_on_failure: false`. Multicast scouting is disabled in
435/// that mode since we have explicit endpoints. This complements the
436/// per-spawned-node `DORA_ZENOH_CONNECT` fallback from #1778, which
437/// only covers daemon↔node, leaving daemon↔daemon dependent on
438/// multicast — broken in dev containers and many CI environments.
439///
440/// Returns `(session, effective_listen_endpoint)`. The second element is
441/// `Some(ep)` only when `listen_endpoint` was requested, zenoh accepted the
442/// `listen/endpoints` insert, and `session.info().locators()` confirms that
443/// it actually bound. It is `None` if `listen_endpoint` was `None`, the insert
444/// failed, the open path used the `ZENOH_CONFIG_PATH`-from-file branch, or the
445/// configured listener did not bind. Callers must inject the returned endpoint
446/// into peers (e.g. via `DORA_ZENOH_CONNECT`) instead of the value they passed
447/// in, so peers never receive a stale endpoint the listener did not actually
448/// bind (#1856, #1858). The `inter_daemon_peer` is intentionally NOT
449/// part of the returned endpoint — it is cluster-wide configuration
450/// shared by the caller (e.g. `dora cluster up`), not per-daemon
451/// state to advertise back to nodes.
452///
453/// `multicast` lets a caller that establishes every link explicitly opt out of
454/// scouting — see [`DORA_ZENOH_MULTICAST_ENV`], honored in addition to this
455/// argument. It is a request, not a command: it is ignored unless this session
456/// ends up reachable some other way (see the `#1856` guard below).
457#[cfg(feature = "zenoh")]
458pub async fn open_zenoh_session_with_listen(
459 params: ZenohSessionParams<'_>,
460) -> eyre::Result<(zenoh::Session, Option<String>)> {
461 use eyre::{Context, eyre};
462 use tracing::warn;
463
464 let ZenohSessionParams {
465 coordinator_addr,
466 listen_endpoint,
467 inter_daemon_peer,
468 connect_endpoints,
469 discovered_connect_endpoints,
470 multicast,
471 } = params;
472
473 // Source-of-truth for the listener: stays `None` unless we actually
474 // accepted `listen/endpoints` into the config below. Callers use this
475 // (not their requested endpoint) to advertise the listener to peers.
476 let mut effective_listen_endpoint: Option<String> = None;
477
478 let overlay = ZenohOverlay::from_env()?;
479
480 let zenoh_session = match std::env::var(zenoh::Config::DEFAULT_CONFIG_PATH_ENV) {
481 Ok(path) => {
482 if overlay.is_some() {
483 // Merging the two would be guesswork: the file replaces the
484 // config dora computed, so there is no dora-side endpoint list
485 // left for the overlay's to append to, and "append to whatever
486 // the file happened to set" is not a rule anyone could predict.
487 eyre::bail!(
488 "both {} and {} are set. {} replaces the whole zenoh \
489 configuration, while {} layers onto the one dora computes — \
490 keep one. Prefer the overlay: it adds your endpoints \
491 without discarding the direct node-to-node links the daemon \
492 plans for this dataflow.",
493 zenoh::Config::DEFAULT_CONFIG_PATH_ENV,
494 DORA_ZENOH_CONFIG_OVERLAY_ENV,
495 zenoh::Config::DEFAULT_CONFIG_PATH_ENV,
496 DORA_ZENOH_CONFIG_OVERLAY_ENV,
497 );
498 }
499 let zenoh_config = zenoh::Config::from_file(&path)
500 .map_err(|e| eyre!(e))
501 .wrap_err_with(|| format!("failed to read zenoh config from {path}"))?;
502 zenoh::open(zenoh_config)
503 .await
504 .map_err(|e| eyre!(e))
505 .context("failed to open zenoh session")?
506 }
507 Err(std::env::VarError::NotPresent) => {
508 let mut zenoh_config = zenoh::Config::default();
509 // NOTE: we used to set `routing/peer: { mode: "linkstate" }` here so
510 // that peers would relay for each other (e.g. two daemons on separate
511 // networks reaching each other through a public one). In zenoh 1.8 that
512 // worked: its `linkstate_peer` hat derived
513 // `peer_full_linkstate = routing.peer.mode == "linkstate"`. Zenoh 1.9
514 // dropped that hat; its `peer` hat hardcodes `full_linkstate: false`
515 // (release notes, under Bug fixes: "Disable `full_linkstate` in
516 // `peer::Hat::Network`"), so peers no longer relay. The setting became a
517 // silent no-op — `insert_json5` still returns `Ok`, so our own error
518 // branch never fired, and only zenoh's deprecation log hinted at it.
519 // Deleted rather than ported: there is no peer-side equivalent in 1.9.
520 //
521 // Consequences, and why this is not a regression here:
522 // * Same-machine nodes are all loopback-addressable, so the links
523 // the dataflow needs are established explicitly via
524 // `connect/endpoints` below (see `DORA_ZENOH_CONNECT`) instead of
525 // being left to gossip's best-effort autoconnect.
526 // * Multi-machine/NAT setups, which is what linkstate was meant to
527 // serve, supply their own config via `ZENOH_CONFIG_PATH` (handled
528 // in the branch above) and can put a real router in the path.
529 //
530 // NOTE: we used to set `transport/unicast/lowlatency: true` here (and
531 // `qos/enabled: false` with it, since the low-latency transport is
532 // negotiated without QoS) to skip zenoh's batching/priority queues.
533 // Both are gone, because low-latency cannot fragment: a message has to
534 // fit one batch, and `batch_size` is capped at 64 KiB
535 // (`pub type BatchSize = u16`, so 65535 is the max, not just the
536 // default). Shared memory hid that — an SHM payload travels as a
537 // ~16-byte descriptor and never fragments — but SHM is per-host, so it
538 // cannot negotiate between machines. A >64 KiB message to another host
539 // therefore had *no* working path: the sender writes it with a 4-byte
540 // length prefix and no size check, `put()` returns `Ok`, and the peer
541 // rejects the frame ("Batch len is invalid") — silent loss, with the
542 // publisher believing it succeeded.
543 //
544 // Dropping both is not a latency regression — measured against the old
545 // config (release, `examples/benchmark`), p50 is neutral-to-better
546 // (64 B 65->55 µs, 512 B 66->58 µs, 16 KB 73->63 µs; only 8 B is ~7 µs
547 // worse) and throughput is up (4 KB +51%, 16 KB +41%), since bypassing
548 // batching cost a syscall per message.
549 //
550 // The two settings must be removed *together*: dropping `lowlatency`
551 // while leaving `qos/enabled: false` did cost ~25 µs p50 on small
552 // messages. Restoring QoS recovers it, because the publishers'
553 // `Priority::RealTime` finally takes effect — it was silently inert
554 // while QoS was off. Publishers also still set `express(true)`, which is
555 // what actually carries small-message latency here.
556 //
557 // We rely on zenoh's SHM transport (`transport/shared_memory/enabled`)
558 // being enabled, which is its default — do NOT set it to `false`: the
559 // API keeps working, but SHM buffers silently get serialized as plain
560 // bytes onto the wire (i.e. copied) instead of sent as a ~16-byte
561 // descriptor.
562
563 // Build the connect-endpoint list from three sources:
564 // 1. DORA_ZENOH_CONNECT env var — daemon-bootstrapped local
565 // discovery for spawned nodes (#1778).
566 // 2. `connect_endpoints` — peers this process was told to dial,
567 // i.e. the explicit daemon↔daemon mesh (`--zenoh-connect`).
568 // Unlike (3) this is dial-only: a mesh member listens on its
569 // own advertised endpoint, not on its peers'.
570 // 3. `inter_daemon_peer` — shared rendezvous for daemon-to-
571 // daemon discovery (extends #1778 to the daemon↔daemon
572 // hop). One daemon binds it as a listener, others connect
573 // and gossip-discover their peers via it.
574 // 4. `discovered_connect_endpoints` — peers the coordinator
575 // reported. Dialed like the rest, but see below: because a
576 // discovered list can be incomplete or stale, it alone does
577 // not disable scouting.
578 // Setting any of the *operator-supplied* ones disables multicast
579 // scouting, so we don't end up with mixed discovery modes.
580 let mut connect_eps: Vec<String> = Vec::new();
581 if let Ok(eps) = std::env::var(DORA_ZENOH_CONNECT_ENV) {
582 connect_eps.extend(split_endpoints(&eps));
583 }
584 connect_eps.extend(connect_endpoints.iter().cloned());
585 // Everything appended from here on is dialed but does not, on its
586 // own, justify dropping multicast — see `discovered_connect_endpoints`.
587 let authoritative_connect_eps = connect_eps.len();
588 // Discovered endpoints are checked here as well as by whoever handed
589 // them over. The coordinator validates what daemons report to it, so
590 // this is not the only guard — but it is the one that sits where the
591 // value is *used*, which keeps the property true for any future
592 // source of discovered endpoints and for a coordinator that is
593 // itself wrong. Operator-supplied endpoints are deliberately not
594 // filtered: someone who typed an endpoint on the command line is
595 // owed a zenoh error about it, not silence.
596 connect_eps.extend(discovered_connect_endpoints.iter().filter_map(|ep| {
597 match validate_zenoh_endpoint(ep) {
598 Ok(()) => Some(ep.clone()),
599 Err(err) => {
600 warn!("ignoring discovered zenoh endpoint: {err}");
601 None
602 }
603 }
604 }));
605 if let Some(peer) = inter_daemon_peer {
606 connect_eps.push(peer.to_string());
607 }
608 if let Some(overlay) = &overlay {
609 connect_eps.extend(overlay.connect_endpoints.iter().cloned());
610 }
611 // A duplicate dial is not fatal, but it is a wasted connection
612 // attempt per duplicate and, when the peer is unreachable, a
613 // second retry loop against it — which is exactly what keeps the
614 // net runtime busy (#2776). Callers can legitimately overlap:
615 // `--zenoh-connect` may name the same endpoint the environment
616 // already carries. `retain` over a seen-set rather than `dedup`,
617 // which only collapses *adjacent* equals and would leave the
618 // env/param/rendezvous interleaving untouched.
619 let mut seen_connect = std::collections::HashSet::new();
620 // Counted before dedup: `retain` only ever removes later duplicates
621 // of an earlier entry, and the authoritative entries come first, so
622 // "were there any" is unaffected by it.
623 let has_authoritative_connect = authoritative_connect_eps > 0
624 || inter_daemon_peer.is_some()
625 || overlay
626 .as_ref()
627 .is_some_and(|o| !o.connect_endpoints.is_empty());
628 connect_eps.retain(|ep| seen_connect.insert(ep.clone()));
629 let mut connect_inserted = false;
630 if !connect_eps.is_empty() {
631 // Serialized, not interpolated. These strings are no longer all
632 // operator-supplied: the coordinator hands over endpoints that
633 // other daemons reported, so a `"` in one would otherwise end
634 // the JSON5 string and either break the whole array — costing
635 // this session every connect endpoint, legitimate ones included
636 // — or append endpoints of someone else's choosing to the dial
637 // list. `endpoint_array_json` escapes; `validate_zenoh_endpoint`
638 // rejects such a value on ingest. Both, deliberately.
639 let json = endpoint_array_json(&connect_eps);
640 match zenoh_config.insert_json5("connect/endpoints", &json) {
641 Ok(()) => connect_inserted = true,
642 Err(err) => {
643 warn!(
644 "failed to set zenoh connect/endpoints to {json} ({err}); leaving multicast scouting enabled as fallback"
645 );
646 }
647 }
648 }
649 // Track whether listen/endpoints was accepted into THIS config.
650 // We don't promote it to `effective_listen_endpoint` until the
651 // configured open succeeds — the fallback default-config path
652 // below has no listener and must not advertise one (#1856).
653 // We only track the caller's own `listen_endpoint` (the per-daemon
654 // listener that gets advertised to spawned nodes), NOT
655 // `inter_daemon_peer` which is cluster-wide config — daemons that
656 // bind it act as the rendezvous, but advertising it back to nodes
657 // would be wrong (nodes would try to reach it through what may be a
658 // remote address, defeating the loopback shortcut) — and not the
659 // env-supplied node listeners either, which the daemon planned and
660 // already knows.
661 let mut listen_inserted_into_configured: Option<String> = None;
662 // Any accepted listener, including the cluster-wide rendezvous that
663 // is deliberately absent from `listen_inserted_into_configured`.
664 // Reachability, not advertisability, is what the #1856 guard needs.
665 let mut listen_configured = false;
666
667 // Build the listen-endpoint list (loopback for spawned nodes +
668 // optional inter-daemon rendezvous). With multiple entries,
669 // zenoh binds whichever ones it can; `listen/exit_on_failure:
670 // false` (set below when any listener is configured) lets the
671 // daemon proceed even if some don't bind — e.g. the second
672 // daemon to start on the same host with the same rendezvous
673 // port falls through to connect-only.
674 // A spawned node gets its listeners from the daemon via
675 // `DORA_ZENOH_LISTEN` (the daemon itself passes `listen_endpoint`
676 // directly). Without a known listener a node cannot be dialled, and
677 // since zenoh 1.9 peers do not relay, a consumer that cannot dial its
678 // producer never receives its data at all.
679 //
680 // A node with a consumer on another machine listens both on
681 // loopback (for its same-machine consumers, whose transport can
682 // then carry shared memory) and on a routable address (for the
683 // remote one). The two arrive in *separate* variables so an older
684 // node binary, which treats `DORA_ZENOH_LISTEN` as a single
685 // locator, still gets a valid one — see the doc on
686 // `DORA_ZENOH_LISTEN_EXTRA_ENV`. `split_endpoints` is applied to
687 // both so a hand-set list in either keeps working.
688 let env_listen_endpoints = listen_endpoints_from_env(
689 std::env::var(DORA_ZENOH_LISTEN_ENV).ok().as_deref(),
690 std::env::var(DORA_ZENOH_LISTEN_EXTRA_ENV).ok().as_deref(),
691 );
692
693 let mut listen_eps: Vec<String> = Vec::new();
694 if let Some(ep) = listen_endpoint {
695 listen_eps.push(ep.to_string());
696 }
697 listen_eps.extend(env_listen_endpoints.iter().cloned());
698 if let Some(peer) = inter_daemon_peer {
699 listen_eps.push(peer.to_string());
700 }
701 if let Some(overlay) = &overlay {
702 listen_eps.extend(overlay.listen_endpoints.iter().cloned());
703 }
704 if !listen_eps.is_empty() {
705 let json = endpoint_array_json(&listen_eps);
706 let listen_inserted = match zenoh_config.insert_json5("listen/endpoints", &json) {
707 Ok(()) => {
708 listen_inserted_into_configured = listen_endpoint.map(String::from);
709 listen_configured = true;
710 true
711 }
712 Err(err) => {
713 warn!("failed to set zenoh listen/endpoints to {json}: {err}");
714 false
715 }
716 };
717 // Tolerate a race between OS port reservation and zenoh's
718 // own bind, AND the multi-daemon-same-rendezvous case where
719 // only one daemon wins the bind. The connect side still
720 // works, and child nodes get a clear error rather than the
721 // daemon exiting.
722 if listen_inserted
723 && let Err(err) = zenoh_config.insert_json5("listen/exit_on_failure", "false")
724 {
725 warn!("failed to set zenoh listen/exit_on_failure: {err}");
726 }
727 }
728
729 // Drop multicast scouting only once this session is reachable some
730 // other way — otherwise it has no endpoints to dial and no way to
731 // be found, which is the silent partition #1856 exists to prevent.
732 //
733 // Two things make it reachable. `connect_inserted`: explicit
734 // endpoints replaced scouting (the pre-existing rule). Or an
735 // accepted listener plus a caller that asked to stop scouting —
736 // `dora run` without dynamic nodes, or `--zenoh-no-multicast` on a
737 // network where the scouting bind itself fails; a listener means
738 // peers that hold the endpoint can dial in.
739 //
740 // Deliberately *not* honoring the request when neither holds: the
741 // reservation-failure paths in `build_daemon` log "falling back to
742 // multicast scouting only" and mean it. Treating the request as
743 // absolute would disarm that recovery and strand the daemon.
744 let requested_off =
745 multicast_disabled(matches!(multicast, MulticastScouting::Disabled));
746 // Computed once and reused by the listener-did-not-bind diagnostic
747 // below, which used to test `connect_inserted` alone — a proxy that
748 // disagreed with this in both directions, telling a session with
749 // scouting off that it was "falling back to multicast scouting"
750 // (and vice versa). That is the same class of misdirection #2762
751 // fixed once already.
752 let multicast_scouting_off = (connect_inserted && has_authoritative_connect)
753 || (requested_off && listen_configured);
754 if multicast_scouting_off
755 && let Err(err) = zenoh_config.insert_json5("scouting/multicast/enabled", "false")
756 {
757 warn!("failed to disable zenoh scouting/multicast: {err}");
758 }
759
760 if let Some(addr) = coordinator_addr
761 && let Err(err) = zenoh_config
762 .insert_json5("connect/endpoints", &coordinator_connect_endpoints(addr))
763 {
764 warn!("failed to set zenoh connect/endpoints for coordinator {addr}: {err}");
765 }
766 // Last, so an operator's setting wins over dora's default for the
767 // same key. The endpoint lists are already merged above rather than
768 // overwritten here — that is what makes the overlay additive.
769 if let Some(overlay) = &overlay {
770 overlay.apply(&mut zenoh_config)?;
771 }
772 match zenoh::open(zenoh_config).await {
773 Ok(zenoh_session) => {
774 // Verify the listener actually bound. `zenoh::open` returning
775 // Ok is necessary but not sufficient — with
776 // `listen/exit_on_failure: false` (set above), zenoh tolerates
777 // a silently-failed listen bind. The most plausible cause is
778 // the race between `reserve_loopback_zenoh_endpoint` dropping
779 // its reservation socket and zenoh's own bind, during which
780 // some other process could grab the port. Trusting `Ok` here
781 // would advertise an endpoint nothing is listening on, and
782 // spawned nodes would fail their `DORA_ZENOH_CONNECT` connect
783 // attempts (#1858).
784 //
785 // `info().locators()` is the zenoh-canonical "what actually
786 // bound" query (unstable API gated behind the workspace
787 // `unstable` feature, already enabled in the root Cargo.toml
788 // zenoh dependency).
789 // Verify every endpoint we asked for, but only ever
790 // *return* the caller's own: the env-supplied ones belong
791 // to a node whose daemon planned them and already knows
792 // them, so there they are a diagnostic, not a value to
793 // propagate.
794 let mut verify: Vec<&str> = listen_inserted_into_configured
795 .iter()
796 .map(String::as_str)
797 .collect();
798 if listen_configured {
799 verify.extend(env_listen_endpoints.iter().map(String::as_str));
800 }
801 if !verify.is_empty() {
802 let bound_locators: Vec<String> = zenoh_session
803 .info()
804 .locators()
805 .await
806 .into_iter()
807 .map(|l| l.as_str().to_string())
808 .collect();
809 // Strip zenoh's endpoint-string separators before
810 // comparing. Per `zenoh-protocol::core::endpoint`,
811 // `?` separates metadata and `#` separates config
812 // (e.g. `tcp/127.0.0.1:43217?prio=high#iface=lo0`).
813 // `Locator::from(EndPoint)` already truncates `#`,
814 // but a `?`-metadata suffix would survive into
815 // `info().locators()`'s output. Strip both, then
816 // exact-match — substring `contains` would
817 // false-positive on port-prefix collisions (e.g.
818 // requested `:5000` matching bound `:50000`), which
819 // is exactly the mismatch this check exists to
820 // catch. NOTE: the comparison is against the
821 // requested string verbatim, so a caller must request
822 // the same canonical `tcp/<addr>:<port>` form that
823 // zenoh reports back. `reserve_zenoh_endpoint` emits
824 // that form, but only for a *concrete* address: a
825 // wildcard request (`tcp/0.0.0.0:<port>`) can never
826 // match, because zenoh binds every interface and
827 // reports the concrete one. Callers must therefore
828 // reject wildcards up front (the daemon does) rather
829 // than reach this check, which would read the mismatch
830 // as "the listener did not bind" and silently fall back
831 // to multicast scouting.
832 for requested in verify {
833 let bound = bound_locators
834 .iter()
835 .any(|l| l.split(['?', '#']).next() == Some(requested));
836 if bound {
837 if listen_inserted_into_configured.as_deref() == Some(requested) {
838 effective_listen_endpoint = Some(requested.to_string());
839 }
840 } else if multicast_scouting_off {
841 // Scouting is off for this session, so there is
842 // NO discovery fallback:
843 // peers already told to dial `{requested}` (e.g. via
844 // the per-node `DORA_ZENOH_CONNECT` plan from #2716)
845 // cannot reach this now-listener-less session, and it
846 // cannot be scouted either — that edge is silently
847 // partitioned. Do not claim "multicast scouting only"
848 // here; that fallback does not exist in this mode and
849 // the old message pointed debuggers the wrong way
850 // (#2762).
851 warn!(
852 "zenoh session opened but listener for `{requested}` \
853 did not bind (actually bound: {bound_locators:?}); \
854 multicast scouting is disabled for this session \
855 (explicit connect endpoints are set), so peers told \
856 to dial `{requested}` have no fallback path to reach \
857 it (#2762)"
858 );
859 } else {
860 warn!(
861 "zenoh session opened but listener for `{requested}` \
862 did not bind (actually bound: {bound_locators:?}); \
863 falling back to multicast scouting for discovery"
864 );
865 }
866 }
867 }
868 zenoh_session
869 }
870 Err(err) => {
871 warn!(
872 "failed to open tuned zenoh session ({err}), retrying with default config"
873 );
874 // Default fallback has no listener; `effective_listen_endpoint`
875 // stays `None` so peers don't try to reach a bind that isn't
876 // there (#1856).
877 let zenoh_config = zenoh::Config::default();
878 zenoh::open(zenoh_config)
879 .await
880 .map_err(|e| eyre!(e))
881 .context("failed to open zenoh session")?
882 }
883 }
884 }
885 Err(std::env::VarError::NotUnicode(_)) => eyre::bail!(
886 "{} env variable is not valid unicode",
887 zenoh::Config::DEFAULT_CONFIG_PATH_ENV
888 ),
889 };
890 Ok((zenoh_session, effective_listen_endpoint))
891}
892
893/// Render endpoints as a JSON array for `insert_json5`, escaping each one.
894///
895/// `serde_json` output is valid JSON5, and escaping is what keeps a hostile or
896/// merely malformed endpoint from reshaping the array around it.
897#[cfg(feature = "zenoh")]
898fn endpoint_array_json(endpoints: &[String]) -> String {
899 serde_json::Value::Array(
900 endpoints
901 .iter()
902 .map(|e| serde_json::Value::String(e.clone()))
903 .collect(),
904 )
905 .to_string()
906}
907
908/// Whether `endpoint` is safe to accept from the network as a zenoh locator.
909///
910/// Applied to the endpoint a daemon reports to the coordinator, which is the
911/// first value to reach dora's zenoh config from off-machine — everything
912/// before it came from the operator's own command line. The coordinator hands
913/// it to every daemon that registers later, so an unchecked value would travel
914/// straight into their `connect/endpoints`.
915///
916/// Called on every hop that value takes: both ways a daemon can report one
917/// (`accept_reported_zenoh_endpoint` in the coordinator covers the registration
918/// *and* the later correction, which exists to replace it), and again where a
919/// receiving daemon merges the result into its own dial list. Validating only
920/// at the first hop would leave the property depending on which path ran last.
921///
922/// Deliberately a charset check rather than a locator parse: zenoh's locator
923/// grammar covers protocols dora does not model (`quic`, `unixsock-stream`,
924/// metadata after `?`, config after `#`), and rejecting a valid endpoint would
925/// break a working deployment. What matters is that nothing here can terminate
926/// a JSON string or escape it — so quotes, backslashes and control characters
927/// are refused, along with anything long enough to be worth truncating.
928pub fn validate_zenoh_endpoint(endpoint: &str) -> Result<(), String> {
929 const MAX_LEN: usize = 256;
930 if endpoint.is_empty() {
931 return Err("zenoh endpoint must not be empty".to_string());
932 }
933 if endpoint.len() > MAX_LEN {
934 return Err(format!(
935 "zenoh endpoint is {} bytes, over the {MAX_LEN}-byte limit",
936 endpoint.len()
937 ));
938 }
939 if let Some(bad) = endpoint
940 .chars()
941 .find(|c| !matches!(c, 'a'..='z' | 'A'..='Z' | '0'..='9' | '.' | ':' | '/' | '-' | '_' | '[' | ']' | '%' | '?' | '#' | '=' | '&' | '+' | '*' | ','))
942 {
943 return Err(format!(
944 "zenoh endpoint contains the disallowed character {bad:?}"
945 ));
946 }
947 Ok(())
948}
949
950/// Default TCP port for a daemon's inter-daemon zenoh listener.
951///
952/// Only used when a deployment *names* the port — `--zenoh-listen <IP>` alone
953/// still reserves an ephemeral one. An explicit mesh needs a port its peers can
954/// predict, since they must dial the endpoint before the daemon has told anyone
955/// what it bound. 5456 is the port dora already uses for a zenoh peer in
956/// [`coordinator_connect_endpoints`] and in every deployment doc example.
957pub const DORA_ZENOH_LISTEN_PORT_DEFAULT: u16 = 5456;
958
959/// Format `addr:port` as a zenoh TCP endpoint string.
960///
961/// The address goes through [`SocketAddr`], whose `Display` brackets IPv6 —
962/// which is also zenoh's locator grammar (`tcp/[::1]:7447`). Interpolating a
963/// bare [`IpAddr`] instead would emit `tcp/::1:7447`, where the port colon is
964/// indistinguishable from the address colons and `insert_json5` rejects the
965/// result (#3041).
966///
967/// Unlike [`reserve_zenoh_endpoint`] this binds nothing, so there is no
968/// reserve→bind window for another process to slip into: a named port is
969/// either free when zenoh binds it or it is not, and the
970/// `info().locators()` check in [`open_zenoh_session_with_listen`] tells the
971/// caller which.
972pub fn zenoh_endpoint(addr: IpAddr, port: u16) -> String {
973 format!("tcp/{}", SocketAddr::new(addr, port))
974}
975
976/// Reserve an unused TCP port on `bind` for use as a zenoh listen endpoint.
977/// Returns a string suitable for the zenoh `listen/endpoints` config
978/// (e.g. `tcp/127.0.0.1:43217`, or `tcp/[::1]:43217` for IPv6).
979///
980/// The bind address matters beyond which interface accepts connections:
981/// zenoh advertises the address it bound as its locator, and remote peers
982/// dial exactly that. A daemon that binds loopback is therefore not merely
983/// unreachable from another machine — it actively tells remote daemons to
984/// dial `127.0.0.1`, i.e. their own loopback, where they find nothing (or an
985/// unrelated local process). Since zenoh 1.9 peers do not relay for each
986/// other, such a pair has no fallback path and is silently dead. Multi-machine
987/// deployments must therefore reserve on an address the other machines can
988/// actually reach; see [`reserve_loopback_zenoh_endpoint`] for the
989/// single-machine case.
990///
991/// There is a small race window between dropping the reservation socket
992/// and zenoh's own bind. `open_zenoh_session_with_listen` defends against
993/// it on two layers:
994///
995/// 1. `listen/exit_on_failure: false` keeps the daemon alive if zenoh's
996/// bind silently fails inside the race window.
997/// 2. After `zenoh::open`, the helper queries `session.info().locators()`
998/// and only advertises the returned endpoint when our requested
999/// `addr:port` is actually in the bound-locator list. If the port was
1000/// grabbed by another process, the returned `effective_listen_endpoint`
1001/// is `None` and callers fall back to multicast scouting instead of
1002/// advertising a phantom endpoint (#1858).
1003///
1004/// In practice the OS keeps allocating fresh ephemeral ports each call,
1005/// so collisions remain vanishingly rare.
1006///
1007/// Note that the locator check in (2) compares the requested `addr:port`
1008/// literally, so reserving on the unspecified address (`0.0.0.0`) will not
1009/// match the concrete interface address zenoh reports back. Pass the address
1010/// you want advertised, not a wildcard.
1011pub fn reserve_zenoh_endpoint(bind: IpAddr) -> std::io::Result<String> {
1012 let listener = std::net::TcpListener::bind((bind, 0))?;
1013 let port = listener.local_addr()?.port();
1014 drop(listener);
1015 Ok(zenoh_endpoint(bind, port))
1016}
1017
1018/// Loopback case of [`reserve_zenoh_endpoint`] — the right choice when every
1019/// zenoh peer that needs this listener is on the same host, which is what a
1020/// single-machine deployment looks like.
1021pub fn reserve_loopback_zenoh_endpoint() -> std::io::Result<String> {
1022 reserve_zenoh_endpoint(LOCALHOST)
1023}
1024
1025/// Pick the address a daemon's zenoh listener should bind so that the other
1026/// daemons in the deployment can dial it.
1027///
1028/// Returns [`LOCALHOST`] when the coordinator is itself local: everything that
1029/// needs this listener is then on this host, and binding loopback keeps the
1030/// daemon's zenoh unreachable from the network — which is the status quo for
1031/// single-machine users, and worth preserving.
1032///
1033/// Otherwise the daemon is part of a multi-machine deployment, and we return
1034/// the local address the kernel would use to reach `coordinator_addr`. That is
1035/// the correct choice without anyone configuring anything: on a LAN it is the
1036/// LAN address, and on a mesh VPN (Tailscale/WireGuard) — where the
1037/// coordinator is reached over the tunnel — it is the tunnel address, which is
1038/// exactly the one remote daemons can dial. A multi-homed host that picks the
1039/// wrong interface can be overridden explicitly by the caller.
1040///
1041/// The lookup `connect()`s a UDP socket, which sends no packets: it only asks
1042/// the routing table which source address applies. If there is no route (or
1043/// the lookup otherwise fails) we fall back to [`LOCALHOST`] rather than
1044/// guessing, since a wrong address is advertised to peers and fails silently,
1045/// whereas loopback at least keeps same-host behavior working.
1046pub fn zenoh_bind_address_for(coordinator_addr: SocketAddr) -> IpAddr {
1047 if coordinator_addr.ip().is_loopback() {
1048 return LOCALHOST;
1049 }
1050 local_address_toward(coordinator_addr).unwrap_or(LOCALHOST)
1051}
1052
1053/// Where a daemon's zenoh listener binds: an address, and optionally the port.
1054///
1055/// Both forms are accepted from `--zenoh-listen`:
1056///
1057/// * `10.0.2.100` — the port is left to the OS. Peers learn the resulting
1058/// endpoint by gossip, so this suits a deployment with a rendezvous
1059/// (`--zenoh-peer`) or working multicast.
1060/// * `10.0.2.100:5456`, `[fd7a:1::2]:5456` — a named port, which peers can dial
1061/// without having discovered it first. This is what an explicit mesh needs:
1062/// every daemon's endpoint has to be known before any of them has announced
1063/// anything.
1064///
1065/// IPv6 must be bracketed when naming a port, because `fd7a:1::2:5456` is
1066/// itself a valid IPv6 address and is read as one.
1067#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1068pub struct ZenohListen {
1069 /// Address to bind, which is also the address advertised to peers.
1070 pub addr: IpAddr,
1071 /// Port to bind, or `None` to let the OS pick one.
1072 pub port: Option<u16>,
1073}
1074
1075impl ZenohListen {
1076 /// The endpoint to request, reserving an ephemeral port if none was named.
1077 ///
1078 /// A named port skips the reservation entirely: it has no reserve→bind
1079 /// window for another process to slip into, and zenoh's own bind is the
1080 /// only claim on it.
1081 pub fn endpoint(&self) -> std::io::Result<String> {
1082 match self.port {
1083 Some(port) => Ok(zenoh_endpoint(self.addr, port)),
1084 None => reserve_zenoh_endpoint(self.addr),
1085 }
1086 }
1087}
1088
1089impl std::fmt::Display for ZenohListen {
1090 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1091 match self.port {
1092 Some(port) => write!(f, "{}", SocketAddr::new(self.addr, port)),
1093 None => write!(f, "{}", self.addr),
1094 }
1095 }
1096}
1097
1098impl std::str::FromStr for ZenohListen {
1099 type Err = String;
1100
1101 fn from_str(s: &str) -> Result<Self, Self::Err> {
1102 // `SocketAddr` first: `10.0.2.100:5456` is not an `IpAddr`, while a
1103 // bare `::1` is not a `SocketAddr`, so neither form is stolen by the
1104 // other. The one genuine ambiguity — unbracketed IPv6 with a port —
1105 // resolves to "address", which is why the docs above insist on
1106 // brackets.
1107 let listen = if let Ok(socket) = s.parse::<SocketAddr>() {
1108 Self {
1109 addr: socket.ip(),
1110 port: Some(socket.port()),
1111 }
1112 } else if let Ok(addr) = s.parse::<IpAddr>() {
1113 Self { addr, port: None }
1114 } else {
1115 return Err(format!(
1116 "`{s}` is neither an IP address (`10.0.2.100`) nor an address \
1117 with a port (`10.0.2.100:5456`, `[fd7a:1::2]:5456`)"
1118 ));
1119 };
1120 if listen.port == Some(0) {
1121 // Port 0 binds an ephemeral port, which is fine, but the endpoint
1122 // we advertise would still say `:0` — no peer could dial it, and
1123 // the `info().locators()` check would read the mismatch as "the
1124 // listener did not bind". Omitting the port asks for the same thing
1125 // and reserves a concrete one to advertise.
1126 return Err(format!(
1127 "`{s}` names port 0; omit the port to let the OS pick one \
1128 (dora then advertises the concrete port it reserved)"
1129 ));
1130 }
1131 validate_zenoh_listen(listen.addr).map_err(|err| format!("{err}"))?;
1132 Ok(listen)
1133 }
1134}
1135
1136/// Reject a zenoh listen address that cannot be advertised to peers.
1137///
1138/// Only the wildcard is rejected here, and only because it is *structurally*
1139/// unadvertisable: zenoh would bind every interface but report a concrete
1140/// locator, so the listener verification in [`open_zenoh_session_with_listen`]
1141/// could never match it, and the daemon would silently stop advertising an
1142/// endpoint to its nodes. Whether a concrete address actually exists on this
1143/// host is not knowable here — that surfaces as a bind error from
1144/// [`reserve_zenoh_endpoint`], which callers must treat as fatal when the
1145/// operator named the address explicitly.
1146pub fn validate_zenoh_listen(bind: IpAddr) -> eyre::Result<()> {
1147 if bind.is_unspecified() {
1148 eyre::bail!(
1149 "zenoh listen address must be concrete, not the wildcard `{bind}`. \
1150 Zenoh advertises the address it binds and remote daemons dial exactly \
1151 that, so a wildcard has nothing to advertise. Pass the address other \
1152 daemons should use to reach this host (e.g. its LAN or VPN address)."
1153 );
1154 }
1155 Ok(())
1156}
1157
1158/// Whether a source address the routing table handed back can be advertised to
1159/// peers as a locator.
1160///
1161/// The unspecified address is not a real source, and a loopback source for a
1162/// *remote* target means the routing table told us nothing usable (there is no
1163/// route, or the target resolved back to this host). Advertising either would
1164/// point peers at nothing, so both mean "we learned nothing" and the caller
1165/// should fall back rather than guess.
1166fn usable_source(local: IpAddr) -> Option<IpAddr> {
1167 if local.is_unspecified() || local.is_loopback() {
1168 return None;
1169 }
1170 Some(local)
1171}
1172
1173fn local_address_toward(target: SocketAddr) -> Option<IpAddr> {
1174 // Bind the wildcard of the same family as the target, then `connect` to
1175 // consult the routing table. UDP `connect` is local-only: no traffic.
1176 let bind: SocketAddr = if target.is_ipv4() {
1177 (Ipv4Addr::UNSPECIFIED, 0).into()
1178 } else {
1179 (std::net::Ipv6Addr::UNSPECIFIED, 0).into()
1180 };
1181 let socket = std::net::UdpSocket::bind(bind).ok()?;
1182 socket.connect(target).ok()?;
1183 usable_source(socket.local_addr().ok()?.ip())
1184}
1185
1186/// Zenoh key for node output data.
1187///
1188/// Payload format: raw Arrow bytes with postcard `Metadata` in the Zenoh
1189/// attachment. This topic is published by nodes and consumed directly by
1190/// downstream nodes (plus debug-inspection subscribers). Daemon control frames
1191/// must not be published here; use [`zenoh_daemon_control_topic`] instead.
1192#[cfg(feature = "zenoh")]
1193pub fn zenoh_output_publish_topic(
1194 dataflow_id: uuid::Uuid,
1195 node_id: &dora_message::id::NodeId,
1196 output_id: &dora_message::id::DataId,
1197) -> String {
1198 let network_id = "default";
1199 format!("dora/{network_id}/{dataflow_id}/output/{node_id}/{output_id}")
1200}
1201
1202/// Hex-encode a `DataId` so it occupies exactly one zenoh key chunk.
1203///
1204/// A `DataId` may legally contain `/` (unlike a `NodeId`), so embedding one
1205/// verbatim as a key segment would spill into extra chunks. Hex is unambiguous
1206/// (`[0-9a-f]`, never `/`) and collision-free. Same helper previously used for
1207/// readiness liveliness keys (#2666).
1208#[cfg(feature = "zenoh")]
1209fn hex_key_segment(id: &dora_message::id::DataId) -> String {
1210 use std::fmt::Write;
1211 let s: &str = id.as_ref();
1212 let mut out = String::with_capacity(s.len() * 2);
1213 for b in s.bytes() {
1214 let _ = write!(out, "{b:02x}");
1215 }
1216 out
1217}
1218
1219/// Zenoh key carrying the Arrow IPC **schema** for an output's data topic.
1220///
1221/// Layout: `dora/{network}/{dataflow}/schema/{node}/{hex(output_id)}`.
1222///
1223/// This lives under a dedicated `schema/` plane — **not** under
1224/// [`zenoh_output_publish_topic`] — so it cannot collide with a nested DataId
1225/// such as `cmd/_schema` (whose data topic would otherwise share a key with
1226/// the schema side-channel for `cmd`), and so wildcard subscribers on the
1227/// data-topic namespace never see schema traffic.
1228///
1229/// The output id is [hex-encoded](hex_key_segment) into a single chunk because
1230/// DataIds may contain `/`. The key has no `@…` verbatim chunks: zenoh-ext
1231/// liveliness tokens are `${remaining:**}/@adv/${entity}/${zid}/${eid}/${meta}`
1232/// and Zenoh verbatim chunks are hermetic — `**` cannot cross them — so a key
1233/// that introduced `@schema` before `/@adv/…` failed `ke_liveliness::parse`
1234/// and flooded WARN logs (#2923). The producer still publishes here through a
1235/// zenoh-ext `AdvancedPublisher` (cache + `publisher_detection`); subscribers
1236/// recover via `AdvancedSubscriber` history.
1237#[cfg(feature = "zenoh")]
1238pub fn zenoh_output_schema_topic(
1239 dataflow_id: uuid::Uuid,
1240 node_id: &dora_message::id::NodeId,
1241 output_id: &dora_message::id::DataId,
1242) -> String {
1243 let network_id = "default";
1244 let output = hex_key_segment(output_id);
1245 format!("dora/{network_id}/{dataflow_id}/schema/{node_id}/{output}")
1246}
1247
1248/// Zenoh key on which consumers acknowledge a producer's startup route-probe
1249/// markers, as a `/@ack` sub-key of [`zenoh_output_publish_topic`].
1250///
1251/// The producer declares one **exact-key** subscriber here per output and the
1252/// consumers of that output publish their acks to the same exact key, with the
1253/// acking consumer's identity in the attachment (never in the key). Exact-key
1254/// matching means `.../cmd/@ack` and `.../cmd/vel/@ack` can never
1255/// cross-deliver even though `cmd` is a chunk-prefix of `cmd/vel` — the
1256/// collision that forced hex-encoded wildcard keys in the earlier
1257/// liveliness-counting design (#2666) cannot arise without wildcards. The
1258/// `@`-prefixed final chunk additionally keeps the key from matching any
1259/// wildcard subscription on the data-topic namespace.
1260#[cfg(feature = "zenoh")]
1261pub fn zenoh_output_ack_topic(
1262 dataflow_id: uuid::Uuid,
1263 node_id: &dora_message::id::NodeId,
1264 output_id: &dora_message::id::DataId,
1265) -> String {
1266 format!(
1267 "{}/@ack",
1268 zenoh_output_publish_topic(dataflow_id, node_id, output_id)
1269 )
1270}
1271
1272/// Zenoh key for control frames associated with a node output.
1273///
1274/// Payload format: postcard `Timestamped<InterDaemonEvent>` with no Zenoh
1275/// attachment. Published by daemons for inter-daemon control (for example
1276/// `OutputClosed`) and by the coordinator for explicit topic injection. Keeping
1277/// this separate from [`zenoh_output_publish_topic`] avoids mixing control frames
1278/// with raw node output payloads on the same key.
1279#[cfg(feature = "zenoh")]
1280pub fn zenoh_daemon_control_topic(
1281 dataflow_id: uuid::Uuid,
1282 node_id: &dora_message::id::NodeId,
1283 output_id: &dora_message::id::DataId,
1284) -> String {
1285 let network_id = "default";
1286 format!("dora/{network_id}/{dataflow_id}/control/{node_id}/{output_id}")
1287}
1288
1289/// Zenoh topic carrying [`InterDaemonEvent::ExtensionMessage`][msg] for one
1290/// extension within one dataflow. Every daemon in the dataflow subscribes.
1291///
1292/// Per-namespace rather than one shared topic, so two extensions in the same
1293/// dataflow never see each other's traffic.
1294///
1295/// [msg]: dora_message::daemon_to_daemon::InterDaemonEvent::ExtensionMessage
1296pub fn dataflow_extension_topic(dataflow_id: &uuid::Uuid, namespace: &str) -> String {
1297 let network_id = "default";
1298 format!("dora/{network_id}/{dataflow_id}/ext/{namespace}")
1299}
1300
1301#[cfg(test)]
1302mod endpoint_validation_tests {
1303 use super::validate_zenoh_endpoint;
1304
1305 #[test]
1306 fn ordinary_locators_are_accepted() {
1307 for ok in [
1308 "tcp/127.0.0.1:7447",
1309 "tcp/10.0.2.100:5456",
1310 "tcp/[fd7a:1::2]:5456",
1311 "udp/192.168.1.1:7447",
1312 "tcp/host.example:7447",
1313 "tcp/10.0.0.1:7447?prio=high",
1314 "tcp/10.0.0.1:7447#iface=eth0",
1315 ] {
1316 assert!(validate_zenoh_endpoint(ok).is_ok(), "rejected `{ok}`");
1317 }
1318 }
1319
1320 /// The reason this validator exists: the endpoint reaches dora's zenoh
1321 /// config from off-machine, and a quote would end the JSON string it is
1322 /// written into — either breaking the whole array (costing that daemon
1323 /// every connect endpoint) or appending endpoints of someone else's
1324 /// choosing to it.
1325 #[test]
1326 fn quotes_and_escapes_are_rejected() {
1327 for bad in [
1328 r#"tcp/1.2.3.4:1"#.to_string() + "\"",
1329 r#"tcp/1.2.3.4:1","tcp/evil:7447"#.to_string(),
1330 r"tcp/1.2.3.4:1\u0022".to_string(),
1331 "tcp/1.2.3.4:1\n".to_string(),
1332 "tcp/1.2.3.4:1 ".to_string(),
1333 ] {
1334 assert!(validate_zenoh_endpoint(&bad).is_err(), "accepted `{bad}`");
1335 }
1336 }
1337
1338 #[test]
1339 fn empty_and_oversized_endpoints_are_rejected() {
1340 assert!(validate_zenoh_endpoint("").is_err());
1341 assert!(validate_zenoh_endpoint(&"a".repeat(257)).is_err());
1342 assert!(validate_zenoh_endpoint(&"a".repeat(256)).is_ok());
1343 }
1344}
1345
1346#[cfg(test)]
1347mod tests {
1348 use super::*;
1349
1350 /// The forward-compat contract: the loopback endpoint stays alone in
1351 /// `DORA_ZENOH_LISTEN` so a node built before `DORA_ZENOH_LISTEN_EXTRA`
1352 /// existed — which pushes that value in as a *single* locator — still gets
1353 /// a usable one. A list there would be rejected wholesale by such a node,
1354 /// costing it loopback too and partitioning it from same-machine consumers
1355 /// (dora-rs/dora#2742).
1356 #[cfg(feature = "zenoh")]
1357 #[test]
1358 fn listen_endpoints_keep_loopback_first_and_merge_the_extra_var() {
1359 assert_eq!(
1360 listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), Some("tcp/10.0.0.2:41001")),
1361 vec![
1362 "tcp/127.0.0.1:41000".to_string(),
1363 "tcp/10.0.0.2:41001".to_string()
1364 ],
1365 "loopback must stay first — order decides which locator a \
1366 same-machine consumer picks, and only loopback carries shared memory"
1367 );
1368 }
1369
1370 #[cfg(feature = "zenoh")]
1371 #[test]
1372 fn listen_endpoints_tolerate_an_absent_or_empty_extra_var() {
1373 assert_eq!(
1374 listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), None),
1375 vec!["tcp/127.0.0.1:41000".to_string()]
1376 );
1377 assert_eq!(
1378 listen_endpoints_from_env(Some("tcp/127.0.0.1:41000"), Some("")),
1379 vec!["tcp/127.0.0.1:41000".to_string()]
1380 );
1381 assert!(listen_endpoints_from_env(None, None).is_empty());
1382 }
1383
1384 /// A hand-set list in either variable keeps working, so an operator who
1385 /// already scripted a comma-separated `DORA_ZENOH_LISTEN` is not broken by
1386 /// the split.
1387 #[cfg(feature = "zenoh")]
1388 #[test]
1389 fn listen_endpoints_still_accept_a_list_in_either_var() {
1390 assert_eq!(
1391 listen_endpoints_from_env(Some("tcp/127.0.0.1:41000, tcp/10.0.0.2:41001"), None),
1392 vec![
1393 "tcp/127.0.0.1:41000".to_string(),
1394 "tcp/10.0.0.2:41001".to_string()
1395 ]
1396 );
1397 }
1398
1399 #[cfg(feature = "zenoh")]
1400 #[test]
1401 fn multicast_disable_spellings_are_recognized() {
1402 for value in ["off", "0", "false", "no", "OFF", "False", " off "] {
1403 assert!(
1404 multicast_disabled_by_value(Some(value)),
1405 "{value:?} should disable multicast scouting"
1406 );
1407 }
1408 }
1409
1410 #[cfg(feature = "zenoh")]
1411 #[test]
1412 fn unset_or_unrecognized_multicast_value_keeps_default() {
1413 // Anything that is not an explicit disable spelling must leave the
1414 // default behaviour: silently dropping discovery because of a typo
1415 // would be a partition with no error to point at (#1856).
1416 assert!(!multicast_disabled_by_value(None));
1417 for value in ["on", "1", "true", "yes", "", "maybe"] {
1418 assert!(
1419 !multicast_disabled_by_value(Some(value)),
1420 "{value:?} must not disable multicast scouting"
1421 );
1422 }
1423 }
1424
1425 /// A caller's own request must survive the fold, whatever the environment
1426 /// says. The environment half is covered by the value tests above; this
1427 /// pins that [`multicast_disabled`] never *weakens* an explicit request —
1428 /// the daemon forwards its result to every node it spawns.
1429 #[cfg(feature = "zenoh")]
1430 #[test]
1431 fn an_explicit_multicast_disable_is_never_lost() {
1432 assert!(multicast_disabled(true));
1433 }
1434
1435 #[test]
1436 fn reserve_loopback_endpoint_returns_loopback_tcp() {
1437 let endpoint = reserve_loopback_zenoh_endpoint().expect("reservation succeeds");
1438 assert!(
1439 endpoint.starts_with("tcp/127.0.0.1:"),
1440 "expected loopback tcp endpoint, got {endpoint}"
1441 );
1442 let port: u16 = endpoint
1443 .rsplit(':')
1444 .next()
1445 .and_then(|p| p.parse().ok())
1446 .expect("endpoint has a numeric port");
1447 assert!(port > 0, "kernel must hand out a non-zero ephemeral port");
1448 }
1449
1450 // A local coordinator means every zenoh peer is on this host, so we must
1451 // keep binding loopback — a single-machine daemon should not start
1452 // listening on the network just because this code path exists.
1453 #[test]
1454 fn local_coordinator_keeps_the_listener_on_loopback() {
1455 for addr in ["127.0.0.1:6013", "[::1]:6013"] {
1456 let addr: SocketAddr = addr.parse().unwrap();
1457 assert_eq!(
1458 zenoh_bind_address_for(addr),
1459 LOCALHOST,
1460 "a loopback coordinator at {addr} must not move the listener off loopback"
1461 );
1462 }
1463 }
1464
1465 // IPv6 endpoints must be bracketed or zenoh cannot parse the port back off
1466 // (`tcp/::1:7447` is ambiguous).
1467 #[test]
1468 fn ipv6_endpoints_are_bracketed() {
1469 match reserve_zenoh_endpoint(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)) {
1470 Ok(endpoint) => assert!(
1471 endpoint.starts_with("tcp/[::1]:"),
1472 "expected a bracketed IPv6 endpoint, got {endpoint}"
1473 ),
1474 // Many CI runners, Docker containers, and locked-down hosts have no
1475 // IPv6 address on `lo`, so binding `::1` fails with EAFNOSUPPORT
1476 // (`Unsupported`) or `AddrNotAvailable`. That is a property of the
1477 // host, not of the endpoint formatting under test, so skip rather
1478 // than fail — the production path never binds `::1` on such hosts.
1479 Err(e)
1480 if matches!(
1481 e.kind(),
1482 std::io::ErrorKind::AddrNotAvailable | std::io::ErrorKind::Unsupported
1483 ) || e.raw_os_error() == Some(97) => {}
1484 Err(e) => panic!("unexpected error reserving ::1 endpoint: {e}"),
1485 }
1486 }
1487
1488 // The coordinator peer endpoint must bracket IPv6 too, or `insert_json5`
1489 // rejects the malformed locator and the peer connect-endpoint is silently
1490 // dropped (#3041). Pure string formatting — no session is opened.
1491 #[cfg(feature = "zenoh")]
1492 #[test]
1493 fn coordinator_connect_endpoints_bracket_ipv6() {
1494 let v6 = coordinator_connect_endpoints(IpAddr::V6(std::net::Ipv6Addr::LOCALHOST));
1495 assert!(
1496 v6.contains(r#"peer: ["tcp/[::1]:5456"]"#),
1497 "IPv6 coordinator peer must be bracketed, got {v6}"
1498 );
1499 assert!(
1500 !v6.contains("tcp/::1:5456"),
1501 "unbracketed IPv6 peer locator is malformed, got {v6}"
1502 );
1503
1504 let v4 = coordinator_connect_endpoints(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));
1505 assert!(
1506 v4.contains(r#"peer: ["tcp/127.0.0.1:5456"]"#),
1507 "IPv4 coordinator peer must be unbracketed, got {v4}"
1508 );
1509 }
1510
1511 // The filter behind the routing lookup, tested directly rather than through
1512 // the runner's routing table (which would make the assertion depend on the
1513 // machine and, for a remote target, be satisfiable by either branch).
1514 #[test]
1515 fn only_concrete_routable_sources_are_advertisable() {
1516 // Real interface addresses are what we want to advertise.
1517 for ok in ["10.0.2.100", "192.168.1.7", "100.64.0.3"] {
1518 let addr: IpAddr = ok.parse().unwrap();
1519 assert_eq!(
1520 usable_source(addr),
1521 Some(addr),
1522 "{ok} is a concrete routable address and must be advertisable"
1523 );
1524 }
1525 // A wildcard is not a source at all, and a loopback source for a remote
1526 // target means the routing table told us nothing — advertising either
1527 // points peers at nothing.
1528 for rejected in ["0.0.0.0", "127.0.0.1", "::", "::1"] {
1529 let addr: IpAddr = rejected.parse().unwrap();
1530 assert_eq!(
1531 usable_source(addr),
1532 None,
1533 "{rejected} must never be advertised to peers as a locator"
1534 );
1535 }
1536 }
1537
1538 // The wildcard cannot be advertised: zenoh binds every interface but reports
1539 // a concrete locator, so the listener check can never match it and the
1540 // daemon would silently stop advertising an endpoint to its nodes —
1541 // reintroducing the gossip race #2716 removed. Reject it up front instead.
1542 #[test]
1543 fn wildcard_zenoh_listen_is_rejected() {
1544 for wildcard in ["0.0.0.0", "::"] {
1545 let addr: IpAddr = wildcard.parse().unwrap();
1546 let err = validate_zenoh_listen(addr)
1547 .expect_err("wildcard listen address must be rejected, not accepted");
1548 assert!(
1549 err.to_string().contains("concrete"),
1550 "error should tell the operator to pass a concrete address, got: {err}"
1551 );
1552 }
1553 }
1554
1555 // An IPv6 endpoint must bracket its address, or the port colon is
1556 // indistinguishable from the address colons and `insert_json5` rejects the
1557 // locator outright (#3041). A named-port endpoint has to match the shape
1558 // `reserve_zenoh_endpoint` produces, because both end up in the same
1559 // `listen/endpoints` list and are compared verbatim against
1560 // `info().locators()` after open.
1561 #[test]
1562 fn zenoh_endpoint_brackets_ipv6_and_matches_the_reserved_shape() {
1563 assert_eq!(
1564 zenoh_endpoint("10.0.2.100".parse().unwrap(), 5456),
1565 "tcp/10.0.2.100:5456"
1566 );
1567 assert_eq!(
1568 zenoh_endpoint("::1".parse().unwrap(), 5456),
1569 "tcp/[::1]:5456"
1570 );
1571
1572 let reserved = reserve_zenoh_endpoint(LOCALHOST).expect("loopback reservation");
1573 let port = reserved
1574 .rsplit_once(':')
1575 .expect("reserved endpoint carries a port")
1576 .1
1577 .parse()
1578 .expect("reserved port is numeric");
1579 assert_eq!(zenoh_endpoint(LOCALHOST, port), reserved);
1580 }
1581
1582 // `--zenoh-listen` takes both forms, and which one was given decides
1583 // whether peers can dial this daemon before it has announced anything.
1584 #[test]
1585 fn zenoh_listen_parses_address_with_and_without_port() {
1586 let addr_only: ZenohListen = "10.0.2.100".parse().unwrap();
1587 assert_eq!(addr_only.addr, "10.0.2.100".parse::<IpAddr>().unwrap());
1588 assert_eq!(addr_only.port, None);
1589
1590 let with_port: ZenohListen = "10.0.2.100:5456".parse().unwrap();
1591 assert_eq!(with_port.port, Some(5456));
1592 assert_eq!(with_port.endpoint().unwrap(), "tcp/10.0.2.100:5456");
1593
1594 // IPv6 needs brackets to carry a port; unbracketed, the trailing group
1595 // is part of the address. Both parse — they just mean different things,
1596 // which is why the flag docs insist on brackets.
1597 let v6_port: ZenohListen = "[fd7a:1::2]:5456".parse().unwrap();
1598 assert_eq!(v6_port.port, Some(5456));
1599 assert_eq!(v6_port.endpoint().unwrap(), "tcp/[fd7a:1::2]:5456");
1600 let v6_bare: ZenohListen = "fd7a:1::2:5456".parse().unwrap();
1601 assert_eq!(v6_bare.port, None);
1602 }
1603
1604 // A wildcard has nothing to advertise, and port 0 would advertise `:0`.
1605 // Both are rejected at parse time so the operator hears about it at the
1606 // command line rather than as a silent partition later.
1607 #[test]
1608 fn zenoh_listen_rejects_unadvertisable_forms() {
1609 for bad in ["0.0.0.0", "0.0.0.0:5456", "::"] {
1610 let err = bad
1611 .parse::<ZenohListen>()
1612 .expect_err("wildcard must be rejected");
1613 assert!(
1614 err.contains("concrete"),
1615 "unexpected error for {bad}: {err}"
1616 );
1617 }
1618 let err = "10.0.2.100:0"
1619 .parse::<ZenohListen>()
1620 .expect_err("port 0 must be rejected");
1621 assert!(err.contains("port 0"), "unexpected error: {err}");
1622
1623 let err = "not-an-address"
1624 .parse::<ZenohListen>()
1625 .expect_err("garbage must be rejected");
1626 assert!(err.contains("neither"), "unexpected error: {err}");
1627 }
1628
1629 // The endpoint lists merge with dora's rather than replacing them — the
1630 // whole difference between an overlay and `ZENOH_CONFIG`. Naming a router
1631 // must add a path to it, not delete the direct node links dora planned.
1632 #[cfg(feature = "zenoh")]
1633 #[test]
1634 fn an_overlay_splits_off_the_endpoint_lists() {
1635 let overlay = ZenohOverlay::parse(
1636 r#"{
1637 // a router of our own, plus a second listen address
1638 connect: { endpoints: ["tcp/10.0.0.1:7447"], timeout_ms: 5000 },
1639 listen: { endpoints: ["tcp/10.0.0.2:7448"] },
1640 scouting: { multicast: { enabled: true } },
1641 }"#,
1642 )
1643 .expect("valid overlay");
1644
1645 assert_eq!(overlay.connect_endpoints, ["tcp/10.0.0.1:7447"]);
1646 assert_eq!(overlay.listen_endpoints, ["tcp/10.0.0.2:7448"]);
1647 // `endpoints` is taken out of the sections so re-applying them cannot
1648 // overwrite the merged list; a section left empty is dropped entirely.
1649 assert!(overlay.rest.contains_key("connect"));
1650 assert!(!overlay.rest.contains_key("listen"));
1651 assert!(overlay.rest.contains_key("scouting"));
1652 }
1653
1654 // An overlay is applied to a real config: the values must land where zenoh
1655 // expects them, and a subkey must not wipe its siblings.
1656 #[cfg(feature = "zenoh")]
1657 #[test]
1658 fn an_overlay_applies_onto_a_zenoh_config() {
1659 let mut config = zenoh::Config::default();
1660 config
1661 .insert_json5("connect/endpoints", r#"["tcp/127.0.0.1:1"]"#)
1662 .expect("dora's own endpoints");
1663
1664 ZenohOverlay::parse(r#"{ connect: { timeout_ms: 5000 }, mode: "peer" }"#)
1665 .expect("valid overlay")
1666 .apply(&mut config)
1667 .expect("overlay applies");
1668
1669 let rendered = config.to_string();
1670 assert!(
1671 rendered.contains("5000"),
1672 "overlay value missing: {rendered}"
1673 );
1674 assert!(
1675 rendered.contains("tcp/127.0.0.1:1"),
1676 "writing a sibling subkey must not wipe dora's endpoints: {rendered}"
1677 );
1678 }
1679
1680 // A malformed overlay must fail loudly: the operator asked for these
1681 // settings, and a session quietly opened without them is the "runs but
1682 // talks to nobody" outcome that takes hours to diagnose.
1683 #[cfg(feature = "zenoh")]
1684 #[test]
1685 fn a_malformed_overlay_is_rejected() {
1686 for (bad, expected) in [
1687 ("not an object", "object"),
1688 (
1689 r#"{ connect: { endpoints: "tcp/10.0.0.1:7447" } }"#,
1690 "array",
1691 ),
1692 (r#"{ listen: { endpoints: [7447] } }"#, "endpoint strings"),
1693 ] {
1694 let err = ZenohOverlay::parse(bad)
1695 .expect_err("must be rejected")
1696 .to_string();
1697 assert!(
1698 err.contains(expected),
1699 "unexpected error for `{bad}`: {err}"
1700 );
1701 }
1702 }
1703
1704 // Concrete addresses pass validation whether or not they exist on this host:
1705 // existence is not knowable here and surfaces as a bind error instead.
1706 #[test]
1707 fn concrete_zenoh_listen_addresses_are_accepted() {
1708 for ok in ["127.0.0.1", "10.0.2.100", "::1"] {
1709 let addr: IpAddr = ok.parse().unwrap();
1710 assert!(
1711 validate_zenoh_listen(addr).is_ok(),
1712 "{ok} is concrete and must pass validation"
1713 );
1714 }
1715 }
1716
1717 // Node raw output and daemon control frames MUST live on distinct Zenoh
1718 // keys: they share neither format nor consumer, and merging them caused the
1719 // #1992 crossover (daemon postcard-decoding node output). Guard the split.
1720 #[cfg(feature = "zenoh")]
1721 #[test]
1722 fn output_and_control_topics_are_distinct() {
1723 use dora_message::id::{DataId, NodeId};
1724
1725 let dataflow_id = uuid::Uuid::nil();
1726 let node = NodeId::from("node".to_string());
1727 let output = DataId::from("out".to_string());
1728
1729 let output_topic = zenoh_output_publish_topic(dataflow_id, &node, &output);
1730 let control_topic = zenoh_daemon_control_topic(dataflow_id, &node, &output);
1731
1732 assert!(
1733 output_topic.contains("/output/"),
1734 "node output key must contain `/output/`, got {output_topic}"
1735 );
1736 assert!(
1737 control_topic.contains("/control/"),
1738 "daemon control key must contain `/control/`, got {control_topic}"
1739 );
1740 assert_ne!(
1741 output_topic, control_topic,
1742 "node output and daemon control must not share a Zenoh key (dora #1992/#2008)"
1743 );
1744 }
1745
1746 // Data, schema, ack, and control keys for the same (node, output) must all
1747 // be distinct: each carries a different payload format for a different
1748 // consumer, and any overlap would cross-deliver frames to a decoder that
1749 // cannot parse them.
1750 #[cfg(feature = "zenoh")]
1751 #[test]
1752 fn per_output_topics_are_distinct() {
1753 use dora_message::id::{DataId, NodeId};
1754
1755 let dataflow_id = uuid::Uuid::nil();
1756 let node = NodeId::from("node".to_string());
1757 let output = DataId::from("out".to_string());
1758
1759 let topics = [
1760 zenoh_output_publish_topic(dataflow_id, &node, &output),
1761 zenoh_output_schema_topic(dataflow_id, &node, &output),
1762 zenoh_output_ack_topic(dataflow_id, &node, &output),
1763 zenoh_daemon_control_topic(dataflow_id, &node, &output),
1764 ];
1765 for (i, a) in topics.iter().enumerate() {
1766 for b in &topics[i + 1..] {
1767 assert_ne!(a, b, "per-output zenoh keys must not overlap");
1768 }
1769 }
1770 }
1771
1772 // zenoh-ext publisher-detection liveliness uses
1773 // `${remaining:**}/@adv/...`. Verbatim (`@…`) chunks are hermetic, so a
1774 // schema key that itself introduced `/@schema` before `/@adv/` made tokens
1775 // unparseable and flooded WARN logs (#2923). Keep the schema key free of
1776 // `@` chunks (AdvancedPublisher + publisher_detection still used).
1777 #[cfg(feature = "zenoh")]
1778 #[test]
1779 fn schema_topic_has_no_verbatim_chunks() {
1780 use dora_message::id::{DataId, NodeId};
1781
1782 let dataflow_id = uuid::Uuid::nil();
1783 let node = NodeId::from("node".to_string());
1784 let output = DataId::from("out".to_string());
1785 let topic = zenoh_output_schema_topic(dataflow_id, &node, &output);
1786
1787 assert!(
1788 topic.contains("/schema/"),
1789 "schema side-channel must live under the dedicated `/schema/` plane, got {topic}"
1790 );
1791 assert!(
1792 !topic.contains("/output/"),
1793 "schema side-channel must not nest under the data-topic `/output/` path, got {topic}"
1794 );
1795 for chunk in topic.split('/') {
1796 assert!(
1797 !chunk.starts_with('@'),
1798 "schema topic chunk `{chunk}` must not be verbatim (`@…`); \
1799 otherwise zenoh_ext liveliness tokens fail to parse (#2923)"
1800 );
1801 }
1802 }
1803
1804 // `/_schema` nested under the output path collided with a valid DataId
1805 // `cmd/_schema`. The schema plane must stay outside `output/{node}/{data_id}`.
1806 #[cfg(feature = "zenoh")]
1807 #[test]
1808 fn schema_topic_does_not_collide_with_nested_data_id() {
1809 use dora_message::id::{DataId, NodeId};
1810
1811 let dataflow_id = uuid::Uuid::nil();
1812 let node = NodeId::from("node".to_string());
1813 let parent = DataId::from("cmd");
1814 let nested = DataId::from("cmd/_schema");
1815 assert_ne!(
1816 zenoh_output_schema_topic(dataflow_id, &node, &parent),
1817 zenoh_output_publish_topic(dataflow_id, &node, &nested),
1818 );
1819 }
1820
1821 // The ack design relies on exact-key matching instead of wildcards, so an
1822 // output id that is a chunk-prefix of another (`cmd` vs `cmd/vel` — the
1823 // collision that forced hex-encoded keys in the #2666 liveliness design)
1824 // must yield distinct ack keys with no subsumption possible.
1825 #[cfg(feature = "zenoh")]
1826 #[test]
1827 fn ack_topics_of_prefix_outputs_are_distinct() {
1828 use dora_message::id::{DataId, NodeId};
1829
1830 let dataflow_id = uuid::Uuid::nil();
1831 let node = NodeId::from("node".to_string());
1832 let cmd = DataId::from("cmd".to_string());
1833 let cmd_vel = DataId::from("cmd/vel".to_string());
1834
1835 let cmd_ack = zenoh_output_ack_topic(dataflow_id, &node, &cmd);
1836 let cmd_vel_ack = zenoh_output_ack_topic(dataflow_id, &node, &cmd_vel);
1837
1838 assert_ne!(cmd_ack, cmd_vel_ack);
1839 // `cmd`'s ack key ends in `cmd/@ack`; the nested output's key contains
1840 // `cmd/vel/@ack`. Neither is a prefix of the other, so exact-key
1841 // subscribers can never receive the other output's acks.
1842 assert!(cmd_ack.ends_with("/cmd/@ack"));
1843 assert!(cmd_vel_ack.ends_with("/cmd/vel/@ack"));
1844 assert!(!cmd_vel_ack.starts_with(&cmd_ack));
1845 assert!(!cmd_ack.starts_with(&cmd_vel_ack));
1846 }
1847}