ryu_mesh/lib.rs
1//! Mesh status + Funnel helpers (P5 of the unified-tool-gateway epic, #478).
2//!
3//! Extracted from `apps/core/src/mesh` into its own primitive crate (in-process
4//! default preserved — every entry point is a plain function call, never IPC).
5//!
6//! Core owns **what runs** — the optional Tailscale/Headscale daemon (a `Sidecar`
7//! managed by the `SidecarManager`, `apps/core/src/sidecar/tailscale.rs`). This
8//! crate is the **read/shape side**: it shapes `tailscale status --json` into the
9//! canonical `GET /api/mesh/status` contract (Appendix A Contract 6 of
10//! `docs/unified-tool-gateway-spec.md`), resolves the fail-closed shared-mesh-token
11//! bearer for `GET /api/mesh/peers`, and exposes the `ensure_funnel`/`funnel_url`
12//! primitives P6 consumes for public webhook ingress.
13//!
14//! The one kernel coupling — the `tailscale`/`tailscaled` process shell-outs —
15//! inverts through the narrow [`MeshHost`] trait (host shim implemented Core-side
16//! in `apps/core/src/mesh_host.rs`, installed once at boot via [`set_global_host`],
17//! mirroring the `CryptoHost`/`RecipesHost` precedent). So this crate has ZERO
18//! dependency on apps/core.
19//!
20//! The mesh is **opt-in**. The enabled signal is `RYU_MESH_ENABLED` (env) OR the
21//! `mesh-enabled` pref (seeded by Core at boot into [`set_pref_enabled`] — the
22//! desktop's Gateway → Integrations toggle writes the pref through
23//! `POST /api/mesh/config`). The env wins when set (operator override → pref,
24//! matching the `mesh-login-server`/ingress-URL precedence). When off,
25//! [`query_status`] returns the all-default object (HTTP 200, never 500) WITHOUT
26//! touching the host, so a build with no host installed still behaves correctly
27//! for the default (mesh-disabled) install.
28
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::{Arc, OnceLock};
31
32use serde::Serialize;
33
34// ── Host seam (the "what runs" half — tailscale daemon shell-outs) ────────────
35
36/// The kernel-side couplings this crate needs but cannot own: the three
37/// `tailscale`/`tailscaled` process shell-outs (the "what runs" half of the mesh,
38/// a `Sidecar` in Core). Core implements this in `apps/core/src/mesh_host.rs` and
39/// installs it once at boot via [`set_global_host`].
40///
41/// Every method is only ever called when the mesh is **enabled**
42/// (`RYU_MESH_ENABLED`); the disabled paths short-circuit before the host is
43/// consulted, so a process that never installs a host still runs the default
44/// (mesh-off) install correctly.
45#[async_trait::async_trait]
46pub trait MeshHost: Send + Sync {
47 /// Run `tailscale status --json` and return the parsed JSON. Errors when the
48 /// daemon is absent or returns non-JSON (the caller maps that to an
49 /// enabled-but-unreachable status).
50 async fn status_json(&self) -> anyhow::Result<serde_json::Value>;
51
52 /// Ensure a Tailscale Funnel is serving `port`, returning the public URL.
53 async fn ensure_funnel(&self, port: u16) -> anyhow::Result<String>;
54
55 /// The active public Funnel URL for `port`, or `None` when unreachable.
56 async fn funnel_url(&self, port: u16) -> Option<String>;
57}
58
59fn host_slot() -> &'static OnceLock<Arc<dyn MeshHost>> {
60 static HOST: OnceLock<Arc<dyn MeshHost>> = OnceLock::new();
61 &HOST
62}
63
64/// Install the process-global [`MeshHost`]. Idempotent (a second call is a no-op).
65/// Called once from Core's `main` at boot.
66pub fn set_global_host(host: Arc<dyn MeshHost>) {
67 let _ = host_slot().set(host);
68}
69
70/// The installed host, or `None` when none was installed. Only consulted on the
71/// mesh-**enabled** paths, so `None` here means "mesh enabled but no daemon host
72/// wired" — treated as unreachable, never a panic.
73fn host() -> Option<Arc<dyn MeshHost>> {
74 host_slot().get().cloned()
75}
76
77// ── Node-admittance security model (anchored here) ────────────────────────────
78
79/// Whether an auth token is a well-known insecure placeholder. This is the
80/// canonical home for the node-admittance placeholder check: [`resolve_mesh_bearer`]
81/// refuses to hand out such a token as a peer bearer (a peer provisioned with a
82/// placeholder refuses to start under mesh, so offering it would be a lie), and
83/// Core's `enforce_remote_auth` startup gate consults the same predicate so both
84/// agree on the same signal. Pure + const — no dependency on apps/core.
85pub fn is_insecure_auth_token_placeholder(token: &str) -> bool {
86 const PLACEHOLDERS: &[&str] = &[
87 "CHANGE_ME",
88 "CHANGEME",
89 "REPLACE_ME",
90 "REPLACEME",
91 "YOUR_TOKEN_HERE",
92 "TOKEN",
93 "SECRET",
94 "PASSWORD",
95 ];
96
97 let trimmed = token.trim();
98 PLACEHOLDERS
99 .iter()
100 .any(|placeholder| trimmed.eq_ignore_ascii_case(placeholder))
101}
102
103// ── Mesh plane handle + enabled gate ──────────────────────────────────────────
104
105/// Handle held by Core's `ServerState` for the mesh plane. Cheap to clone. Today
106/// it is a stateless façade over the env-driven [`query_status`]/[`is_enabled`]
107/// free functions (the daemon itself is a Sidecar managed by the
108/// `SidecarManager`), but giving the server a typed handle keeps the call site
109/// stable for when P6 wires Funnel-backed ingress through here.
110#[derive(Clone, Default)]
111pub struct MeshHandle;
112
113impl MeshHandle {
114 pub fn new() -> Self {
115 Self
116 }
117
118 /// Live mesh status for `GET /api/mesh/status` (Contract 6).
119 pub async fn status(&self) -> MeshStatus {
120 query_status().await
121 }
122
123 /// Whether the mesh is enabled on this node.
124 pub fn enabled(&self) -> bool {
125 is_enabled()
126 }
127}
128
129/// The pref-driven half of the mesh-enabled signal. Core seeds this once at boot
130/// from its `mesh-enabled` preference (and `POST /api/mesh/config` updates it at
131/// runtime), so [`is_enabled`] reads `env || pref` without an async store. Kept
132/// in lockstep with the gateway's `tools::mesh_enabled()` so the loopback-trust
133/// neutralization (B-9) and Core fail-closed gate agree on the same signal — the
134/// gateway child is spawned with `RYU_MESH_ENABLED=1` whenever this reads true.
135static MESH_PREF_ENABLED: AtomicBool = AtomicBool::new(false);
136
137/// Seed the pref half of the mesh-enabled signal (env wins when set). Mirrors
138/// the entitlement / claude-config / untrusted pref seeders in Core's `main`.
139/// Also called by the runtime `POST /api/mesh/config` handler so an enable/disable
140/// takes effect without a restart.
141pub fn set_pref_enabled(enabled: bool) {
142 MESH_PREF_ENABLED.store(enabled, Ordering::Relaxed);
143}
144
145/// Whether a string parses as a truthy mesh-enabled value — the SAME truthiness
146/// [`is_enabled`] applies to `RYU_MESH_ENABLED`, exposed so Core can parse its
147/// `mesh-enabled` pref with the identical semantics instead of a second copy.
148pub fn parse_enabled(value: Option<&str>) -> bool {
149 match value.map(|v| v.trim().to_ascii_lowercase()).as_deref() {
150 None | Some("") | Some("0") | Some("false") | Some("no") => false,
151 Some(_) => true,
152 }
153}
154
155/// Whether the mesh is enabled for this node. Opt-in via `RYU_MESH_ENABLED`
156/// (truthy = anything but empty/`0`/`false`/`no`) OR the `mesh-enabled` pref
157/// (seeded into [`MESH_PREF_ENABLED`]). The env wins when SET — including an
158/// explicit `RYU_MESH_ENABLED=0`, which overrides the pref — so an operator can
159/// always force the mesh off; only when the env is unset does the pref decide.
160/// Kept in lockstep with the gateway's `tools::mesh_enabled()` so the
161/// loopback-trust neutralization (B-9) and Core fail-closed gate agree on the
162/// same signal.
163pub fn is_enabled() -> bool {
164 match std::env::var("RYU_MESH_ENABLED").ok() {
165 Some(v) => parse_enabled(Some(&v)),
166 None => MESH_PREF_ENABLED.load(Ordering::Relaxed),
167 }
168}
169
170/// A peer node on the tailnet, as surfaced in Contract 6. Carries both the P7
171/// fields (`name`, `host_or_dns`) and the P5 fields (`magic_dns_name`,
172/// `tailscale_ips`, `os`).
173#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
174pub struct MeshPeer {
175 pub name: String,
176 pub host_or_dns: String,
177 pub magic_dns_name: String,
178 pub tailscale_ips: Vec<String>,
179 pub online: bool,
180 pub os: String,
181}
182
183/// The canonical `GET /api/mesh/status` superset (Contract 6). snake_case keys;
184/// `reachable` and `up` are both present and equal. `enabled:false` ⇒ all-default.
185#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
186pub struct MeshStatus {
187 pub enabled: bool,
188 pub reachable: bool,
189 /// `up == reachable` — both present in the wire shape per Contract 6.
190 pub up: bool,
191 /// `"tailscale"` | `"headscale"` | `null`.
192 pub backend: Option<String>,
193 /// Raw `BackendState` string from `tailscale status --json` (e.g.
194 /// `"Running"`, `"NeedsLogin"`, `"Stopped"`).
195 pub backend_state: String,
196 /// Control-plane server URL (Headscale → its login server; Tailscale SaaS →
197 /// the coordination server). `null` when unknown.
198 pub control_server: Option<String>,
199 pub magic_dns_name: Option<String>,
200 pub tailscale_ips: Vec<String>,
201 pub peers: Vec<MeshPeer>,
202 /// Independent of mesh — P7 reads the ingress mode from
203 /// `/api/webhook-ingress/status`, not here. Always `null` in this object.
204 pub webhook_ingress_mode: Option<String>,
205}
206
207impl Default for MeshStatus {
208 fn default() -> Self {
209 Self {
210 enabled: false,
211 reachable: false,
212 up: false,
213 backend: None,
214 backend_state: "Stopped".to_owned(),
215 control_server: None,
216 magic_dns_name: None,
217 tailscale_ips: Vec::new(),
218 peers: Vec::new(),
219 webhook_ingress_mode: None,
220 }
221 }
222}
223
224/// The default control server for Tailscale's SaaS coordination plane. A
225/// `control_server` that is empty or this host classifies the backend as
226/// `tailscale`; anything else (a self-hosted `--login-server`) is `headscale`.
227const TAILSCALE_SAAS_CONTROL: &str = "controlplane.tailscale.com";
228
229/// Classify the mesh backend from the control server URL. A Headscale install is
230/// reached via `--login-server <url>`; Tailscale's SaaS uses its own coordination
231/// server. When no control URL is reported (the caller passes `None` — the URL is
232/// absent or was filtered out as empty), the backend stays `null`: a valid
233/// Contract 6 value, since we cannot distinguish Tailscale from Headscale without
234/// it.
235fn classify_backend(control_url: Option<&str>) -> Option<String> {
236 match control_url {
237 None => None,
238 Some(url) if url.contains(TAILSCALE_SAAS_CONTROL) => Some("tailscale".to_owned()),
239 Some(_) => Some("headscale".to_owned()),
240 }
241}
242
243/// Parse the JSON emitted by `tailscale status --json` into a [`MeshStatus`].
244///
245/// `enabled` is supplied by the caller (it reflects `RYU_MESH_ENABLED`, not the
246/// daemon). The shape is defensive: missing fields degrade to the defaults so a
247/// `NeedsLogin` daemon never panics this path.
248pub fn parse_status_json(enabled: bool, raw: &serde_json::Value) -> MeshStatus {
249 let backend_state = raw
250 .get("BackendState")
251 .and_then(|v| v.as_str())
252 .unwrap_or("Stopped")
253 .to_owned();
254 let reachable = backend_state == "Running";
255
256 // Control plane. The precedence here was originally written the other way
257 // round — it read `ControlURL` and dismissed `CurrentTailnet` as "absent on
258 // Headscale". Verified against a real Headscale v0.29 tailnet, it is the
259 // exact opposite: `ControlURL` is absent (null under both `Self` and the top
260 // level) while `CurrentTailnet.Name` carries the control host. The result was
261 // that `control_server` — and therefore `backend`, which is classified from
262 // it — came back null on every Headscale node, so the desktop could never
263 // tell a self-hosted tailnet from Tailscale SaaS. Try both, ControlURL first
264 // (it is the more specific value when a client does report it).
265 let control_server = raw
266 .get("Self")
267 .and_then(|s| s.get("ControlURL"))
268 .and_then(|v| v.as_str())
269 .or_else(|| raw.get("ControlURL").and_then(|v| v.as_str()))
270 .or_else(|| {
271 raw.get("CurrentTailnet")
272 .and_then(|t| t.get("Name"))
273 .and_then(|v| v.as_str())
274 })
275 .filter(|s| !s.is_empty())
276 .map(str::to_owned);
277
278 let backend = if backend_state == "Stopped" || backend_state == "NoState" {
279 None
280 } else {
281 classify_backend(control_server.as_deref())
282 };
283
284 let self_node = raw.get("Self");
285 let magic_dns_name = self_node
286 .and_then(|s| s.get("DNSName"))
287 .and_then(|v| v.as_str())
288 .map(|s| s.trim_end_matches('.').to_owned())
289 .filter(|s| !s.is_empty());
290 let tailscale_ips = self_node
291 .and_then(|s| s.get("TailscaleIPs"))
292 .and_then(|v| v.as_array())
293 .map(|arr| {
294 arr.iter()
295 .filter_map(|v| v.as_str().map(str::to_owned))
296 .collect()
297 })
298 .unwrap_or_default();
299
300 let peers = raw
301 .get("Peer")
302 .and_then(|v| v.as_object())
303 .map(|map| map.values().map(parse_peer).collect::<Vec<_>>())
304 .unwrap_or_default();
305
306 MeshStatus {
307 enabled,
308 reachable,
309 up: reachable,
310 backend,
311 backend_state,
312 control_server,
313 magic_dns_name,
314 tailscale_ips,
315 peers,
316 webhook_ingress_mode: None,
317 }
318}
319
320/// Map one entry of the `Peer` map into a [`MeshPeer`]. The MagicDNS name has its
321/// trailing `.` stripped; `host_or_dns` prefers the MagicDNS name and falls back
322/// to the first Tailscale IP so P7 always has something to dial.
323fn parse_peer(peer: &serde_json::Value) -> MeshPeer {
324 let dns = peer
325 .get("DNSName")
326 .and_then(|v| v.as_str())
327 .map(|s| s.trim_end_matches('.').to_owned())
328 .unwrap_or_default();
329 let host = peer
330 .get("HostName")
331 .and_then(|v| v.as_str())
332 .unwrap_or_default()
333 .to_owned();
334 let tailscale_ips: Vec<String> = peer
335 .get("TailscaleIPs")
336 .and_then(|v| v.as_array())
337 .map(|arr| {
338 arr.iter()
339 .filter_map(|v| v.as_str().map(str::to_owned))
340 .collect()
341 })
342 .unwrap_or_default();
343 let online = peer
344 .get("Online")
345 .and_then(|v| v.as_bool())
346 .unwrap_or(false);
347 let os = peer
348 .get("OS")
349 .and_then(|v| v.as_str())
350 .unwrap_or_default()
351 .to_owned();
352
353 // host_or_dns: prefer MagicDNS, then the first Tailscale IP, then HostName.
354 let host_or_dns = if !dns.is_empty() {
355 dns.clone()
356 } else if let Some(ip) = tailscale_ips.first() {
357 ip.clone()
358 } else {
359 host.clone()
360 };
361 // name: prefer HostName, fall back to the leftmost MagicDNS label.
362 let name = if !host.is_empty() {
363 host
364 } else {
365 dns.split('.').next().unwrap_or_default().to_owned()
366 };
367
368 MeshPeer {
369 name,
370 host_or_dns,
371 magic_dns_name: dns,
372 tailscale_ips,
373 online,
374 os,
375 }
376}
377
378/// Query the live mesh status. When the mesh is disabled this returns the
379/// all-default object without shelling out (HTTP 200, never 500) and WITHOUT
380/// consulting the host. When enabled but the daemon is absent/erroring (or no
381/// host is installed), it returns an enabled-but-unreachable object so the
382/// desktop can render an amber "configured but down" state.
383pub async fn query_status() -> MeshStatus {
384 let enabled = is_enabled();
385 if !enabled {
386 return MeshStatus::default();
387 }
388 let Some(h) = host() else {
389 // Mesh enabled but no daemon host wired — treat as unreachable, never
390 // panic. (Core installs the host at boot; this is the defensive path.)
391 return MeshStatus {
392 enabled: true,
393 ..Default::default()
394 };
395 };
396 match h.status_json().await {
397 Ok(raw) => parse_status_json(true, &raw),
398 Err(e) => {
399 tracing::debug!("mesh: status query failed: {e}");
400 MeshStatus {
401 enabled: true,
402 ..Default::default()
403 }
404 }
405 }
406}
407
408/// Ensure a Tailscale Funnel is serving `port` to the public internet, returning
409/// the public HTTPS URL. Consumed by P6's `TailscaleFunnelSource`.
410///
411/// Requires the mesh to be enabled and the daemon running with HTTPS certs
412/// provisioned; otherwise returns a clear error so the ingress seam can fall back
413/// or surface the reason.
414pub async fn ensure_funnel(port: u16) -> anyhow::Result<String> {
415 if !is_enabled() {
416 anyhow::bail!("mesh disabled: set RYU_MESH_ENABLED to use Tailscale Funnel");
417 }
418 let h = host().ok_or_else(|| anyhow::anyhow!("mesh host not installed"))?;
419 h.ensure_funnel(port).await
420}
421
422/// The public Funnel URL for `port` if one is active, else `None`. Cheap read
423/// (no mutation) used by P6's status surface.
424pub async fn funnel_url(port: u16) -> Option<String> {
425 if !is_enabled() {
426 return None;
427 }
428 host()?.funnel_url(port).await
429}
430
431// ── Peer token bridge (#478, P7 desktop NodeSelector handoff) ─────────────────
432//
433// Adding a mesh peer as a node is fail-closed: every exposed peer runs
434// `enforce_remote_auth`, so its protected routes 401 without a valid bearer. The
435// desktop's `addNode(name, url)` is tokenless, which is exactly why a freshly
436// added peer's requests bounce. This seam provides the bearer WITHOUT weakening
437// the peer's check: the peer still requires a valid token; we hand the caller one.
438//
439// The bearer we can offer is **this node's own `RYU_TOKEN`**. `require_auth` on the
440// peer is a string compare (`provided == expected`), and `enforce_remote_auth` on
441// the peer accepts any non-placeholder token at startup — so this node's token
442// authenticates on a peer **iff that peer was provisioned with the same
443// `RYU_TOKEN`** (the shared-fleet convention: a tailnet operator gives every node
444// the same node-admittance secret). The code cannot verify the peer's token, so
445// `bearer_source: "shared-mesh-token"` means "candidate bearer, valid on peers
446// sharing this RYU_TOKEN"; a peer running a distinct token still 401s and the
447// operator must supply that peer's token by hand. Returning this token is not a
448// disclosure: `/api/mesh/peers` sits behind `require_auth`, so only a caller who
449// already holds this node's `RYU_TOKEN` can read it back.
450
451/// How the offered bearer was derived, surfaced so the desktop (and a human) know
452/// whether the token is a real candidate or absent.
453pub const BEARER_SOURCE_SHARED: &str = "shared-mesh-token";
454pub const BEARER_SOURCE_NONE: &str = "none";
455
456/// Provisioning guidance returned when no usable bearer exists on this node. Names
457/// the EXACT secret a peer must share for the fail-closed check to pass.
458pub const BEARER_NONE_NOTE: &str =
459 "No usable RYU_TOKEN on this node. Provision every mesh node with the SAME strong \
460 RYU_TOKEN (the shared node-admittance secret) so a peer's require_auth accepts it; \
461 otherwise supply the target peer's own RYU_TOKEN when adding it.";
462
463/// The default Core listen port peers are assumed to serve on (`127.0.0.1:7980`
464/// default bind, reached over the tailnet on the same port). Overridable per
465/// deployment via `RYU_MESH_PEER_PORT` when the fleet binds a non-default port.
466const DEFAULT_CORE_PORT: u16 = 7980;
467
468/// Resolve the port peers are dialed on: `RYU_MESH_PEER_PORT` when set to a valid
469/// `u16`, else the default 7980.
470fn peer_core_port() -> u16 {
471 std::env::var("RYU_MESH_PEER_PORT")
472 .ok()
473 .and_then(|v| v.trim().parse::<u16>().ok())
474 .unwrap_or(DEFAULT_CORE_PORT)
475}
476
477/// Build the URL the desktop should register for a peer. Prefers the MagicDNS
478/// name (stable, resolvable inside the tailnet), falling back to `host_or_dns`
479/// (which itself falls back to a Tailscale IP). `http://` is correct: the tailnet
480/// wire is WireGuard-encrypted and Core does not serve TLS itself.
481fn peer_url(peer: &MeshPeer, port: u16) -> String {
482 let host = if peer.magic_dns_name.is_empty() {
483 peer.host_or_dns.as_str()
484 } else {
485 peer.magic_dns_name.as_str()
486 };
487 format!("http://{host}:{port}")
488}
489
490/// Resolve the candidate bearer to hand the desktop from this node's node token
491/// (`RYU_TOKEN`, passed in). Returns `None` — meaning "no usable bearer" — when the
492/// token is absent, empty/whitespace, or a known insecure placeholder (a peer with
493/// a placeholder token refuses to start under mesh, so offering it would be a lie).
494///
495/// Pure + unit-testable: the returned string, when a peer runs the same token, is
496/// exactly what that peer's `enforce_remote_auth` accepts at startup and its
497/// `require_auth` compares equal against.
498pub fn resolve_mesh_bearer(node_token: Option<&str>) -> Option<String> {
499 let token = node_token?.trim();
500 if token.is_empty() || is_insecure_auth_token_placeholder(token) {
501 return None;
502 }
503 Some(token.to_owned())
504}
505
506/// One peer entry in the `GET /api/mesh/peers` response.
507#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
508pub struct MeshPeerEntry {
509 pub name: String,
510 /// The URL to register with `addNode` — `http://<magic_dns>:<port>`.
511 pub url: String,
512 pub magic_dns_name: String,
513 pub host_or_dns: String,
514 pub port: u16,
515 pub online: bool,
516 pub os: String,
517 /// Whether a candidate bearer is obtainable for this peer (true when this node
518 /// has a usable `RYU_TOKEN` under the shared-fleet convention).
519 pub bearer_available: bool,
520 /// The candidate bearer to attach when adding this peer, or `null`. Same shared
521 /// token for every peer; valid only on peers provisioned with this `RYU_TOKEN`.
522 pub bearer: Option<String>,
523}
524
525/// The `GET /api/mesh/peers` response (Contract 6 companion, P7). `enabled:false`
526/// ⇒ empty `peers`, `bearer_source:"none"`.
527#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
528pub struct MeshPeersResponse {
529 pub enabled: bool,
530 pub reachable: bool,
531 pub peers: Vec<MeshPeerEntry>,
532 /// `"shared-mesh-token"` when a candidate bearer is offered, else `"none"`.
533 pub bearer_source: String,
534 /// Present only when no bearer is available: names the exact secret to
535 /// provision. `null` when a bearer is offered.
536 pub note: Option<String>,
537}
538
539/// Build the peers response from a live [`MeshStatus`] and this node's token.
540///
541/// Pure so the token-resolution + URL shaping is unit-testable without shelling out
542/// to `tailscale`. Every reported peer is returned with its `online` flag (the
543/// desktop filters/labels), each carrying the same shared bearer when one exists.
544pub fn build_peers_response(status: &MeshStatus, node_token: Option<&str>) -> MeshPeersResponse {
545 let bearer = resolve_mesh_bearer(node_token);
546 let bearer_available = bearer.is_some();
547 let port = peer_core_port();
548
549 let peers = status
550 .peers
551 .iter()
552 .map(|p| MeshPeerEntry {
553 name: p.name.clone(),
554 url: peer_url(p, port),
555 magic_dns_name: p.magic_dns_name.clone(),
556 host_or_dns: p.host_or_dns.clone(),
557 port,
558 online: p.online,
559 os: p.os.clone(),
560 bearer_available,
561 bearer: bearer.clone(),
562 })
563 .collect();
564
565 MeshPeersResponse {
566 enabled: status.enabled,
567 reachable: status.reachable,
568 peers,
569 bearer_source: if bearer_available {
570 BEARER_SOURCE_SHARED.to_owned()
571 } else {
572 BEARER_SOURCE_NONE.to_owned()
573 },
574 note: if bearer_available {
575 None
576 } else {
577 Some(BEARER_NONE_NOTE.to_owned())
578 },
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585
586 // Serializes get/restore of RYU_MESH_ENABLED against parallel runs (this
587 // crate's own module-local lock; env vars are process-global).
588 static MESH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
589 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
590 MESH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
591 }
592
593 struct EnvGuard {
594 prev: Option<String>,
595 }
596 impl EnvGuard {
597 fn set(key: &str, val: &str) -> Self {
598 let prev = std::env::var(key).ok();
599 std::env::set_var(key, val);
600 Self { prev }
601 }
602 }
603 impl Drop for EnvGuard {
604 fn drop(&mut self) {
605 match &self.prev {
606 Some(v) => std::env::set_var("RYU_MESH_ENABLED", v),
607 None => std::env::remove_var("RYU_MESH_ENABLED"),
608 }
609 }
610 }
611
612 fn running_status_json() -> serde_json::Value {
613 serde_json::json!({
614 "BackendState": "Running",
615 "Self": {
616 "DNSName": "ryu-host.tailnet-x.ts.net.",
617 "TailscaleIPs": ["100.64.0.1", "fd7a:115c::1"],
618 "ControlURL": "https://controlplane.tailscale.com"
619 },
620 "Peer": {
621 "nodekey:abc": {
622 "HostName": "ryu-pi",
623 "DNSName": "ryu-pi.tailnet-x.ts.net.",
624 "TailscaleIPs": ["100.64.0.8"],
625 "Online": true,
626 "OS": "macOS"
627 }
628 }
629 })
630 }
631
632 #[test]
633 fn parse_status_json_running() {
634 let status = parse_status_json(true, &running_status_json());
635 assert!(status.enabled);
636 assert!(status.reachable);
637 assert!(status.up);
638 assert_eq!(status.reachable, status.up);
639 assert_eq!(status.backend.as_deref(), Some("tailscale"));
640 assert_eq!(status.backend_state, "Running");
641 assert_eq!(
642 status.magic_dns_name.as_deref(),
643 Some("ryu-host.tailnet-x.ts.net")
644 );
645 assert_eq!(status.tailscale_ips.len(), 2);
646 assert_eq!(status.peers.len(), 1);
647 let peer = &status.peers[0];
648 assert_eq!(peer.name, "ryu-pi");
649 assert_eq!(peer.host_or_dns, "ryu-pi.tailnet-x.ts.net");
650 assert_eq!(peer.magic_dns_name, "ryu-pi.tailnet-x.ts.net");
651 assert_eq!(peer.tailscale_ips, vec!["100.64.0.8".to_owned()]);
652 assert!(peer.online);
653 assert_eq!(peer.os, "macOS");
654 }
655
656 #[test]
657 fn parse_status_json_needs_login() {
658 let raw = serde_json::json!({ "BackendState": "NeedsLogin", "Self": {} });
659 let status = parse_status_json(true, &raw);
660 assert!(status.enabled);
661 assert!(!status.reachable);
662 assert!(!status.up);
663 assert_eq!(status.backend_state, "NeedsLogin");
664 // With no control URL the backend cannot be classified yet → None
665 // (defensive: we never guess a backend we can't see).
666 assert!(status.backend.is_none());
667 assert!(status.peers.is_empty());
668 assert!(status.tailscale_ips.is_empty());
669 }
670
671 #[test]
672 fn parse_status_json_headscale_backend() {
673 let mut raw = running_status_json();
674 raw["Self"]["ControlURL"] = serde_json::json!("https://headscale.example.org");
675 let status = parse_status_json(true, &raw);
676 assert_eq!(status.backend.as_deref(), Some("headscale"));
677 assert_eq!(
678 status.control_server.as_deref(),
679 Some("https://headscale.example.org")
680 );
681 }
682
683 #[test]
684 fn disabled_shape_is_all_default() {
685 let status = MeshStatus::default();
686 assert!(!status.enabled);
687 assert!(!status.reachable);
688 assert!(!status.up);
689 assert!(status.backend.is_none());
690 assert_eq!(status.backend_state, "Stopped");
691 assert!(status.control_server.is_none());
692 assert!(status.magic_dns_name.is_none());
693 assert!(status.tailscale_ips.is_empty());
694 assert!(status.peers.is_empty());
695 assert!(status.webhook_ingress_mode.is_none());
696 }
697
698 #[test]
699 fn disabled_shape_serializes_to_contract6() {
700 let json = serde_json::to_value(MeshStatus::default()).unwrap();
701 assert_eq!(json["enabled"], serde_json::json!(false));
702 assert_eq!(json["reachable"], serde_json::json!(false));
703 assert_eq!(json["up"], serde_json::json!(false));
704 assert_eq!(json["backend"], serde_json::Value::Null);
705 assert_eq!(json["backend_state"], serde_json::json!("Stopped"));
706 assert_eq!(json["control_server"], serde_json::Value::Null);
707 assert_eq!(json["magic_dns_name"], serde_json::Value::Null);
708 assert_eq!(json["tailscale_ips"], serde_json::json!([]));
709 assert_eq!(json["peers"], serde_json::json!([]));
710 assert_eq!(json["webhook_ingress_mode"], serde_json::Value::Null);
711 }
712
713 #[test]
714 fn is_enabled_default_off() {
715 // In the test process RYU_MESH_ENABLED is unset → off (the pref global
716 // is off by default and no prior test in this process turned it on).
717 if std::env::var("RYU_MESH_ENABLED").is_err() {
718 assert!(!is_enabled());
719 }
720 }
721
722 /// A drop guard restoring the pref global, so a pref-flipping test never
723 /// leaks its value into the parallel tests of this same process.
724 struct PrefGuard {
725 prev: bool,
726 }
727 impl PrefGuard {
728 fn set(v: bool) -> Self {
729 let prev = MESH_PREF_ENABLED.load(Ordering::Relaxed);
730 set_pref_enabled(v);
731 Self { prev }
732 }
733 }
734 impl Drop for PrefGuard {
735 fn drop(&mut self) {
736 set_pref_enabled(self.prev);
737 }
738 }
739
740 #[test]
741 fn pref_enable_drives_is_enabled_when_env_unset() {
742 let _lock = lock_env();
743 if std::env::var("RYU_MESH_ENABLED").is_err() {
744 let _p = PrefGuard::set(true);
745 assert!(is_enabled());
746 set_pref_enabled(false);
747 assert!(!is_enabled());
748 }
749 }
750
751 #[test]
752 fn env_wins_over_pref() {
753 let _lock = lock_env();
754 let _p = PrefGuard::set(true);
755 // Env set to an explicit off wins over a pref that says on.
756 let _e = EnvGuard::set("RYU_MESH_ENABLED", "0");
757 assert!(!is_enabled());
758 // Env set to on wins over a pref that says off.
759 let _e = EnvGuard::set("RYU_MESH_ENABLED", "1");
760 assert!(is_enabled());
761 }
762
763 #[test]
764 fn parse_enabled_matches_env_truthiness() {
765 assert!(!parse_enabled(None));
766 assert!(!parse_enabled(Some("")));
767 assert!(!parse_enabled(Some("0")));
768 assert!(!parse_enabled(Some("false")));
769 assert!(!parse_enabled(Some("FALSE")));
770 assert!(!parse_enabled(Some("no")));
771 assert!(parse_enabled(Some("1")));
772 assert!(parse_enabled(Some("true")));
773 assert!(parse_enabled(Some("yes")));
774 assert!(parse_enabled(Some(" 1 ")));
775 }
776
777 #[test]
778 fn peer_host_or_dns_falls_back_to_ip() {
779 let peer = serde_json::json!({
780 "HostName": "",
781 "DNSName": "",
782 "TailscaleIPs": ["100.64.0.9"],
783 "Online": false,
784 "OS": "linux"
785 });
786 let parsed = parse_peer(&peer);
787 assert_eq!(parsed.host_or_dns, "100.64.0.9");
788 assert!(!parsed.online);
789 }
790
791 #[test]
792 fn resolve_mesh_bearer_returns_real_token() {
793 // A real (non-placeholder) token is handed back verbatim — this is the
794 // exact bearer a peer provisioned with the same RYU_TOKEN accepts.
795 assert_eq!(
796 resolve_mesh_bearer(Some("ryu_shared_secret")).as_deref(),
797 Some("ryu_shared_secret")
798 );
799 }
800
801 #[test]
802 fn resolve_mesh_bearer_is_fail_closed_without_a_real_token() {
803 // Fail-closed (crate side): the bearer resolver NEVER fabricates a token.
804 // Absent, empty/whitespace, and every known placeholder resolve to None,
805 // so `/api/mesh/peers` reports `bearer_source:"none"` rather than handing
806 // out a bearer that would not authenticate (offering one would be a lie).
807 assert!(resolve_mesh_bearer(None).is_none());
808 assert!(resolve_mesh_bearer(Some("")).is_none());
809 assert!(resolve_mesh_bearer(Some(" ")).is_none());
810 assert!(resolve_mesh_bearer(Some("CHANGE_ME")).is_none());
811 assert!(resolve_mesh_bearer(Some("change_me")).is_none());
812 assert!(resolve_mesh_bearer(Some("REPLACE_ME")).is_none());
813 assert!(resolve_mesh_bearer(Some("SECRET")).is_none());
814 }
815
816 #[test]
817 fn placeholder_predicate_matches_known_weak_tokens() {
818 // The canonical node-admittance placeholder check (Core's
819 // `enforce_remote_auth` startup gate consults this same predicate).
820 assert!(is_insecure_auth_token_placeholder("CHANGE_ME"));
821 assert!(is_insecure_auth_token_placeholder(" changeme "));
822 assert!(is_insecure_auth_token_placeholder("PASSWORD"));
823 assert!(!is_insecure_auth_token_placeholder("ryu_strong_random"));
824 assert!(!is_insecure_auth_token_placeholder(""));
825 }
826
827 #[test]
828 fn peers_response_carries_shared_bearer_and_urls() {
829 let status = parse_status_json(true, &running_status_json());
830 let resp = build_peers_response(&status, Some("ryu_shared_secret"));
831 assert!(resp.enabled);
832 assert_eq!(resp.bearer_source, BEARER_SOURCE_SHARED);
833 assert!(resp.note.is_none());
834 assert_eq!(resp.peers.len(), 1);
835 let peer = &resp.peers[0];
836 assert_eq!(peer.name, "ryu-pi");
837 assert_eq!(peer.url, "http://ryu-pi.tailnet-x.ts.net:7980");
838 assert_eq!(peer.port, 7980);
839 assert!(peer.bearer_available);
840 assert_eq!(peer.bearer.as_deref(), Some("ryu_shared_secret"));
841 }
842
843 #[test]
844 fn peers_response_without_token_is_honest_and_documents_secret() {
845 let status = parse_status_json(true, &running_status_json());
846 let resp = build_peers_response(&status, None);
847 assert_eq!(resp.bearer_source, BEARER_SOURCE_NONE);
848 assert_eq!(resp.note.as_deref(), Some(BEARER_NONE_NOTE));
849 let peer = &resp.peers[0];
850 assert!(!peer.bearer_available);
851 assert!(peer.bearer.is_none());
852 // The peer is still returned (URL usable) so the desktop can add it and the
853 // operator can attach the peer's own token manually.
854 assert_eq!(peer.url, "http://ryu-pi.tailnet-x.ts.net:7980");
855 }
856
857 #[test]
858 fn disabled_mesh_yields_empty_peers() {
859 let resp = build_peers_response(&MeshStatus::default(), Some("ryu_shared_secret"));
860 assert!(!resp.enabled);
861 assert!(resp.peers.is_empty());
862 // A token exists, so the source still reflects a candidate bearer even with
863 // no peers to attach it to yet.
864 assert_eq!(resp.bearer_source, BEARER_SOURCE_SHARED);
865 }
866
867 #[tokio::test]
868 async fn disabled_query_status_never_touches_host() {
869 // With mesh disabled (default in the test process), query_status returns
870 // the all-default object WITHOUT a host installed — the mesh-off install
871 // path must never depend on the daemon host being wired.
872 let _lock = lock_env();
873 let _p = PrefGuard::set(false);
874 if std::env::var("RYU_MESH_ENABLED").is_err() {
875 let status = query_status().await;
876 assert_eq!(status, MeshStatus::default());
877 // ensure_funnel bails and funnel_url is None, both without a host.
878 assert!(ensure_funnel(443).await.is_err());
879 assert!(funnel_url(443).await.is_none());
880 }
881 }
882}