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: CurrentTailnet is absent on Headscale; ControlURL (under
257 // Self / the top-level) carries the login server when configured.
258 let control_server = raw
259 .get("Self")
260 .and_then(|s| s.get("ControlURL"))
261 .and_then(|v| v.as_str())
262 .or_else(|| raw.get("ControlURL").and_then(|v| v.as_str()))
263 .filter(|s| !s.is_empty())
264 .map(str::to_owned);
265
266 let backend = if backend_state == "Stopped" || backend_state == "NoState" {
267 None
268 } else {
269 classify_backend(control_server.as_deref())
270 };
271
272 let self_node = raw.get("Self");
273 let magic_dns_name = self_node
274 .and_then(|s| s.get("DNSName"))
275 .and_then(|v| v.as_str())
276 .map(|s| s.trim_end_matches('.').to_owned())
277 .filter(|s| !s.is_empty());
278 let tailscale_ips = self_node
279 .and_then(|s| s.get("TailscaleIPs"))
280 .and_then(|v| v.as_array())
281 .map(|arr| {
282 arr.iter()
283 .filter_map(|v| v.as_str().map(str::to_owned))
284 .collect()
285 })
286 .unwrap_or_default();
287
288 let peers = raw
289 .get("Peer")
290 .and_then(|v| v.as_object())
291 .map(|map| map.values().map(parse_peer).collect::<Vec<_>>())
292 .unwrap_or_default();
293
294 MeshStatus {
295 enabled,
296 reachable,
297 up: reachable,
298 backend,
299 backend_state,
300 control_server,
301 magic_dns_name,
302 tailscale_ips,
303 peers,
304 webhook_ingress_mode: None,
305 }
306}
307
308/// Map one entry of the `Peer` map into a [`MeshPeer`]. The MagicDNS name has its
309/// trailing `.` stripped; `host_or_dns` prefers the MagicDNS name and falls back
310/// to the first Tailscale IP so P7 always has something to dial.
311fn parse_peer(peer: &serde_json::Value) -> MeshPeer {
312 let dns = peer
313 .get("DNSName")
314 .and_then(|v| v.as_str())
315 .map(|s| s.trim_end_matches('.').to_owned())
316 .unwrap_or_default();
317 let host = peer
318 .get("HostName")
319 .and_then(|v| v.as_str())
320 .unwrap_or_default()
321 .to_owned();
322 let tailscale_ips: Vec<String> = peer
323 .get("TailscaleIPs")
324 .and_then(|v| v.as_array())
325 .map(|arr| {
326 arr.iter()
327 .filter_map(|v| v.as_str().map(str::to_owned))
328 .collect()
329 })
330 .unwrap_or_default();
331 let online = peer
332 .get("Online")
333 .and_then(|v| v.as_bool())
334 .unwrap_or(false);
335 let os = peer
336 .get("OS")
337 .and_then(|v| v.as_str())
338 .unwrap_or_default()
339 .to_owned();
340
341 // host_or_dns: prefer MagicDNS, then the first Tailscale IP, then HostName.
342 let host_or_dns = if !dns.is_empty() {
343 dns.clone()
344 } else if let Some(ip) = tailscale_ips.first() {
345 ip.clone()
346 } else {
347 host.clone()
348 };
349 // name: prefer HostName, fall back to the leftmost MagicDNS label.
350 let name = if !host.is_empty() {
351 host
352 } else {
353 dns.split('.').next().unwrap_or_default().to_owned()
354 };
355
356 MeshPeer {
357 name,
358 host_or_dns,
359 magic_dns_name: dns,
360 tailscale_ips,
361 online,
362 os,
363 }
364}
365
366/// Query the live mesh status. When the mesh is disabled this returns the
367/// all-default object without shelling out (HTTP 200, never 500) and WITHOUT
368/// consulting the host. When enabled but the daemon is absent/erroring (or no
369/// host is installed), it returns an enabled-but-unreachable object so the
370/// desktop can render an amber "configured but down" state.
371pub async fn query_status() -> MeshStatus {
372 let enabled = is_enabled();
373 if !enabled {
374 return MeshStatus::default();
375 }
376 let Some(h) = host() else {
377 // Mesh enabled but no daemon host wired — treat as unreachable, never
378 // panic. (Core installs the host at boot; this is the defensive path.)
379 return MeshStatus {
380 enabled: true,
381 ..Default::default()
382 };
383 };
384 match h.status_json().await {
385 Ok(raw) => parse_status_json(true, &raw),
386 Err(e) => {
387 tracing::debug!("mesh: status query failed: {e}");
388 MeshStatus {
389 enabled: true,
390 ..Default::default()
391 }
392 }
393 }
394}
395
396/// Ensure a Tailscale Funnel is serving `port` to the public internet, returning
397/// the public HTTPS URL. Consumed by P6's `TailscaleFunnelSource`.
398///
399/// Requires the mesh to be enabled and the daemon running with HTTPS certs
400/// provisioned; otherwise returns a clear error so the ingress seam can fall back
401/// or surface the reason.
402pub async fn ensure_funnel(port: u16) -> anyhow::Result<String> {
403 if !is_enabled() {
404 anyhow::bail!("mesh disabled: set RYU_MESH_ENABLED to use Tailscale Funnel");
405 }
406 let h = host().ok_or_else(|| anyhow::anyhow!("mesh host not installed"))?;
407 h.ensure_funnel(port).await
408}
409
410/// The public Funnel URL for `port` if one is active, else `None`. Cheap read
411/// (no mutation) used by P6's status surface.
412pub async fn funnel_url(port: u16) -> Option<String> {
413 if !is_enabled() {
414 return None;
415 }
416 host()?.funnel_url(port).await
417}
418
419// ── Peer token bridge (#478, P7 desktop NodeSelector handoff) ─────────────────
420//
421// Adding a mesh peer as a node is fail-closed: every exposed peer runs
422// `enforce_remote_auth`, so its protected routes 401 without a valid bearer. The
423// desktop's `addNode(name, url)` is tokenless, which is exactly why a freshly
424// added peer's requests bounce. This seam provides the bearer WITHOUT weakening
425// the peer's check: the peer still requires a valid token; we hand the caller one.
426//
427// The bearer we can offer is **this node's own `RYU_TOKEN`**. `require_auth` on the
428// peer is a string compare (`provided == expected`), and `enforce_remote_auth` on
429// the peer accepts any non-placeholder token at startup — so this node's token
430// authenticates on a peer **iff that peer was provisioned with the same
431// `RYU_TOKEN`** (the shared-fleet convention: a tailnet operator gives every node
432// the same node-admittance secret). The code cannot verify the peer's token, so
433// `bearer_source: "shared-mesh-token"` means "candidate bearer, valid on peers
434// sharing this RYU_TOKEN"; a peer running a distinct token still 401s and the
435// operator must supply that peer's token by hand. Returning this token is not a
436// disclosure: `/api/mesh/peers` sits behind `require_auth`, so only a caller who
437// already holds this node's `RYU_TOKEN` can read it back.
438
439/// How the offered bearer was derived, surfaced so the desktop (and a human) know
440/// whether the token is a real candidate or absent.
441pub const BEARER_SOURCE_SHARED: &str = "shared-mesh-token";
442pub const BEARER_SOURCE_NONE: &str = "none";
443
444/// Provisioning guidance returned when no usable bearer exists on this node. Names
445/// the EXACT secret a peer must share for the fail-closed check to pass.
446pub const BEARER_NONE_NOTE: &str =
447 "No usable RYU_TOKEN on this node. Provision every mesh node with the SAME strong \
448 RYU_TOKEN (the shared node-admittance secret) so a peer's require_auth accepts it; \
449 otherwise supply the target peer's own RYU_TOKEN when adding it.";
450
451/// The default Core listen port peers are assumed to serve on (`127.0.0.1:7980`
452/// default bind, reached over the tailnet on the same port). Overridable per
453/// deployment via `RYU_MESH_PEER_PORT` when the fleet binds a non-default port.
454const DEFAULT_CORE_PORT: u16 = 7980;
455
456/// Resolve the port peers are dialed on: `RYU_MESH_PEER_PORT` when set to a valid
457/// `u16`, else the default 7980.
458fn peer_core_port() -> u16 {
459 std::env::var("RYU_MESH_PEER_PORT")
460 .ok()
461 .and_then(|v| v.trim().parse::<u16>().ok())
462 .unwrap_or(DEFAULT_CORE_PORT)
463}
464
465/// Build the URL the desktop should register for a peer. Prefers the MagicDNS
466/// name (stable, resolvable inside the tailnet), falling back to `host_or_dns`
467/// (which itself falls back to a Tailscale IP). `http://` is correct: the tailnet
468/// wire is WireGuard-encrypted and Core does not serve TLS itself.
469fn peer_url(peer: &MeshPeer, port: u16) -> String {
470 let host = if peer.magic_dns_name.is_empty() {
471 peer.host_or_dns.as_str()
472 } else {
473 peer.magic_dns_name.as_str()
474 };
475 format!("http://{host}:{port}")
476}
477
478/// Resolve the candidate bearer to hand the desktop from this node's node token
479/// (`RYU_TOKEN`, passed in). Returns `None` — meaning "no usable bearer" — when the
480/// token is absent, empty/whitespace, or a known insecure placeholder (a peer with
481/// a placeholder token refuses to start under mesh, so offering it would be a lie).
482///
483/// Pure + unit-testable: the returned string, when a peer runs the same token, is
484/// exactly what that peer's `enforce_remote_auth` accepts at startup and its
485/// `require_auth` compares equal against.
486pub fn resolve_mesh_bearer(node_token: Option<&str>) -> Option<String> {
487 let token = node_token?.trim();
488 if token.is_empty() || is_insecure_auth_token_placeholder(token) {
489 return None;
490 }
491 Some(token.to_owned())
492}
493
494/// One peer entry in the `GET /api/mesh/peers` response.
495#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
496pub struct MeshPeerEntry {
497 pub name: String,
498 /// The URL to register with `addNode` — `http://<magic_dns>:<port>`.
499 pub url: String,
500 pub magic_dns_name: String,
501 pub host_or_dns: String,
502 pub port: u16,
503 pub online: bool,
504 pub os: String,
505 /// Whether a candidate bearer is obtainable for this peer (true when this node
506 /// has a usable `RYU_TOKEN` under the shared-fleet convention).
507 pub bearer_available: bool,
508 /// The candidate bearer to attach when adding this peer, or `null`. Same shared
509 /// token for every peer; valid only on peers provisioned with this `RYU_TOKEN`.
510 pub bearer: Option<String>,
511}
512
513/// The `GET /api/mesh/peers` response (Contract 6 companion, P7). `enabled:false`
514/// ⇒ empty `peers`, `bearer_source:"none"`.
515#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
516pub struct MeshPeersResponse {
517 pub enabled: bool,
518 pub reachable: bool,
519 pub peers: Vec<MeshPeerEntry>,
520 /// `"shared-mesh-token"` when a candidate bearer is offered, else `"none"`.
521 pub bearer_source: String,
522 /// Present only when no bearer is available: names the exact secret to
523 /// provision. `null` when a bearer is offered.
524 pub note: Option<String>,
525}
526
527/// Build the peers response from a live [`MeshStatus`] and this node's token.
528///
529/// Pure so the token-resolution + URL shaping is unit-testable without shelling out
530/// to `tailscale`. Every reported peer is returned with its `online` flag (the
531/// desktop filters/labels), each carrying the same shared bearer when one exists.
532pub fn build_peers_response(status: &MeshStatus, node_token: Option<&str>) -> MeshPeersResponse {
533 let bearer = resolve_mesh_bearer(node_token);
534 let bearer_available = bearer.is_some();
535 let port = peer_core_port();
536
537 let peers = status
538 .peers
539 .iter()
540 .map(|p| MeshPeerEntry {
541 name: p.name.clone(),
542 url: peer_url(p, port),
543 magic_dns_name: p.magic_dns_name.clone(),
544 host_or_dns: p.host_or_dns.clone(),
545 port,
546 online: p.online,
547 os: p.os.clone(),
548 bearer_available,
549 bearer: bearer.clone(),
550 })
551 .collect();
552
553 MeshPeersResponse {
554 enabled: status.enabled,
555 reachable: status.reachable,
556 peers,
557 bearer_source: if bearer_available {
558 BEARER_SOURCE_SHARED.to_owned()
559 } else {
560 BEARER_SOURCE_NONE.to_owned()
561 },
562 note: if bearer_available {
563 None
564 } else {
565 Some(BEARER_NONE_NOTE.to_owned())
566 },
567 }
568}
569
570#[cfg(test)]
571mod tests {
572 use super::*;
573
574 // Serializes get/restore of RYU_MESH_ENABLED against parallel runs (this
575 // crate's own module-local lock; env vars are process-global).
576 static MESH_ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
577 fn lock_env() -> std::sync::MutexGuard<'static, ()> {
578 MESH_ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
579 }
580
581 struct EnvGuard {
582 prev: Option<String>,
583 }
584 impl EnvGuard {
585 fn set(key: &str, val: &str) -> Self {
586 let prev = std::env::var(key).ok();
587 std::env::set_var(key, val);
588 Self { prev }
589 }
590 }
591 impl Drop for EnvGuard {
592 fn drop(&mut self) {
593 match &self.prev {
594 Some(v) => std::env::set_var("RYU_MESH_ENABLED", v),
595 None => std::env::remove_var("RYU_MESH_ENABLED"),
596 }
597 }
598 }
599
600 fn running_status_json() -> serde_json::Value {
601 serde_json::json!({
602 "BackendState": "Running",
603 "Self": {
604 "DNSName": "ryu-host.tailnet-x.ts.net.",
605 "TailscaleIPs": ["100.64.0.1", "fd7a:115c::1"],
606 "ControlURL": "https://controlplane.tailscale.com"
607 },
608 "Peer": {
609 "nodekey:abc": {
610 "HostName": "ryu-pi",
611 "DNSName": "ryu-pi.tailnet-x.ts.net.",
612 "TailscaleIPs": ["100.64.0.8"],
613 "Online": true,
614 "OS": "macOS"
615 }
616 }
617 })
618 }
619
620 #[test]
621 fn parse_status_json_running() {
622 let status = parse_status_json(true, &running_status_json());
623 assert!(status.enabled);
624 assert!(status.reachable);
625 assert!(status.up);
626 assert_eq!(status.reachable, status.up);
627 assert_eq!(status.backend.as_deref(), Some("tailscale"));
628 assert_eq!(status.backend_state, "Running");
629 assert_eq!(
630 status.magic_dns_name.as_deref(),
631 Some("ryu-host.tailnet-x.ts.net")
632 );
633 assert_eq!(status.tailscale_ips.len(), 2);
634 assert_eq!(status.peers.len(), 1);
635 let peer = &status.peers[0];
636 assert_eq!(peer.name, "ryu-pi");
637 assert_eq!(peer.host_or_dns, "ryu-pi.tailnet-x.ts.net");
638 assert_eq!(peer.magic_dns_name, "ryu-pi.tailnet-x.ts.net");
639 assert_eq!(peer.tailscale_ips, vec!["100.64.0.8".to_owned()]);
640 assert!(peer.online);
641 assert_eq!(peer.os, "macOS");
642 }
643
644 #[test]
645 fn parse_status_json_needs_login() {
646 let raw = serde_json::json!({ "BackendState": "NeedsLogin", "Self": {} });
647 let status = parse_status_json(true, &raw);
648 assert!(status.enabled);
649 assert!(!status.reachable);
650 assert!(!status.up);
651 assert_eq!(status.backend_state, "NeedsLogin");
652 // With no control URL the backend cannot be classified yet → None
653 // (defensive: we never guess a backend we can't see).
654 assert!(status.backend.is_none());
655 assert!(status.peers.is_empty());
656 assert!(status.tailscale_ips.is_empty());
657 }
658
659 #[test]
660 fn parse_status_json_headscale_backend() {
661 let mut raw = running_status_json();
662 raw["Self"]["ControlURL"] = serde_json::json!("https://headscale.example.org");
663 let status = parse_status_json(true, &raw);
664 assert_eq!(status.backend.as_deref(), Some("headscale"));
665 assert_eq!(
666 status.control_server.as_deref(),
667 Some("https://headscale.example.org")
668 );
669 }
670
671 #[test]
672 fn disabled_shape_is_all_default() {
673 let status = MeshStatus::default();
674 assert!(!status.enabled);
675 assert!(!status.reachable);
676 assert!(!status.up);
677 assert!(status.backend.is_none());
678 assert_eq!(status.backend_state, "Stopped");
679 assert!(status.control_server.is_none());
680 assert!(status.magic_dns_name.is_none());
681 assert!(status.tailscale_ips.is_empty());
682 assert!(status.peers.is_empty());
683 assert!(status.webhook_ingress_mode.is_none());
684 }
685
686 #[test]
687 fn disabled_shape_serializes_to_contract6() {
688 let json = serde_json::to_value(MeshStatus::default()).unwrap();
689 assert_eq!(json["enabled"], serde_json::json!(false));
690 assert_eq!(json["reachable"], serde_json::json!(false));
691 assert_eq!(json["up"], serde_json::json!(false));
692 assert_eq!(json["backend"], serde_json::Value::Null);
693 assert_eq!(json["backend_state"], serde_json::json!("Stopped"));
694 assert_eq!(json["control_server"], serde_json::Value::Null);
695 assert_eq!(json["magic_dns_name"], serde_json::Value::Null);
696 assert_eq!(json["tailscale_ips"], serde_json::json!([]));
697 assert_eq!(json["peers"], serde_json::json!([]));
698 assert_eq!(json["webhook_ingress_mode"], serde_json::Value::Null);
699 }
700
701 #[test]
702 fn is_enabled_default_off() {
703 // In the test process RYU_MESH_ENABLED is unset → off (the pref global
704 // is off by default and no prior test in this process turned it on).
705 if std::env::var("RYU_MESH_ENABLED").is_err() {
706 assert!(!is_enabled());
707 }
708 }
709
710 /// A drop guard restoring the pref global, so a pref-flipping test never
711 /// leaks its value into the parallel tests of this same process.
712 struct PrefGuard {
713 prev: bool,
714 }
715 impl PrefGuard {
716 fn set(v: bool) -> Self {
717 let prev = MESH_PREF_ENABLED.load(Ordering::Relaxed);
718 set_pref_enabled(v);
719 Self { prev }
720 }
721 }
722 impl Drop for PrefGuard {
723 fn drop(&mut self) {
724 set_pref_enabled(self.prev);
725 }
726 }
727
728 #[test]
729 fn pref_enable_drives_is_enabled_when_env_unset() {
730 let _lock = lock_env();
731 if std::env::var("RYU_MESH_ENABLED").is_err() {
732 let _p = PrefGuard::set(true);
733 assert!(is_enabled());
734 set_pref_enabled(false);
735 assert!(!is_enabled());
736 }
737 }
738
739 #[test]
740 fn env_wins_over_pref() {
741 let _lock = lock_env();
742 let _p = PrefGuard::set(true);
743 // Env set to an explicit off wins over a pref that says on.
744 let _e = EnvGuard::set("RYU_MESH_ENABLED", "0");
745 assert!(!is_enabled());
746 // Env set to on wins over a pref that says off.
747 let _e = EnvGuard::set("RYU_MESH_ENABLED", "1");
748 assert!(is_enabled());
749 }
750
751 #[test]
752 fn parse_enabled_matches_env_truthiness() {
753 assert!(!parse_enabled(None));
754 assert!(!parse_enabled(Some("")));
755 assert!(!parse_enabled(Some("0")));
756 assert!(!parse_enabled(Some("false")));
757 assert!(!parse_enabled(Some("FALSE")));
758 assert!(!parse_enabled(Some("no")));
759 assert!(parse_enabled(Some("1")));
760 assert!(parse_enabled(Some("true")));
761 assert!(parse_enabled(Some("yes")));
762 assert!(parse_enabled(Some(" 1 ")));
763 }
764
765 #[test]
766 fn peer_host_or_dns_falls_back_to_ip() {
767 let peer = serde_json::json!({
768 "HostName": "",
769 "DNSName": "",
770 "TailscaleIPs": ["100.64.0.9"],
771 "Online": false,
772 "OS": "linux"
773 });
774 let parsed = parse_peer(&peer);
775 assert_eq!(parsed.host_or_dns, "100.64.0.9");
776 assert!(!parsed.online);
777 }
778
779 #[test]
780 fn resolve_mesh_bearer_returns_real_token() {
781 // A real (non-placeholder) token is handed back verbatim — this is the
782 // exact bearer a peer provisioned with the same RYU_TOKEN accepts.
783 assert_eq!(
784 resolve_mesh_bearer(Some("ryu_shared_secret")).as_deref(),
785 Some("ryu_shared_secret")
786 );
787 }
788
789 #[test]
790 fn resolve_mesh_bearer_is_fail_closed_without_a_real_token() {
791 // Fail-closed (crate side): the bearer resolver NEVER fabricates a token.
792 // Absent, empty/whitespace, and every known placeholder resolve to None,
793 // so `/api/mesh/peers` reports `bearer_source:"none"` rather than handing
794 // out a bearer that would not authenticate (offering one would be a lie).
795 assert!(resolve_mesh_bearer(None).is_none());
796 assert!(resolve_mesh_bearer(Some("")).is_none());
797 assert!(resolve_mesh_bearer(Some(" ")).is_none());
798 assert!(resolve_mesh_bearer(Some("CHANGE_ME")).is_none());
799 assert!(resolve_mesh_bearer(Some("change_me")).is_none());
800 assert!(resolve_mesh_bearer(Some("REPLACE_ME")).is_none());
801 assert!(resolve_mesh_bearer(Some("SECRET")).is_none());
802 }
803
804 #[test]
805 fn placeholder_predicate_matches_known_weak_tokens() {
806 // The canonical node-admittance placeholder check (Core's
807 // `enforce_remote_auth` startup gate consults this same predicate).
808 assert!(is_insecure_auth_token_placeholder("CHANGE_ME"));
809 assert!(is_insecure_auth_token_placeholder(" changeme "));
810 assert!(is_insecure_auth_token_placeholder("PASSWORD"));
811 assert!(!is_insecure_auth_token_placeholder("ryu_strong_random"));
812 assert!(!is_insecure_auth_token_placeholder(""));
813 }
814
815 #[test]
816 fn peers_response_carries_shared_bearer_and_urls() {
817 let status = parse_status_json(true, &running_status_json());
818 let resp = build_peers_response(&status, Some("ryu_shared_secret"));
819 assert!(resp.enabled);
820 assert_eq!(resp.bearer_source, BEARER_SOURCE_SHARED);
821 assert!(resp.note.is_none());
822 assert_eq!(resp.peers.len(), 1);
823 let peer = &resp.peers[0];
824 assert_eq!(peer.name, "ryu-pi");
825 assert_eq!(peer.url, "http://ryu-pi.tailnet-x.ts.net:7980");
826 assert_eq!(peer.port, 7980);
827 assert!(peer.bearer_available);
828 assert_eq!(peer.bearer.as_deref(), Some("ryu_shared_secret"));
829 }
830
831 #[test]
832 fn peers_response_without_token_is_honest_and_documents_secret() {
833 let status = parse_status_json(true, &running_status_json());
834 let resp = build_peers_response(&status, None);
835 assert_eq!(resp.bearer_source, BEARER_SOURCE_NONE);
836 assert_eq!(resp.note.as_deref(), Some(BEARER_NONE_NOTE));
837 let peer = &resp.peers[0];
838 assert!(!peer.bearer_available);
839 assert!(peer.bearer.is_none());
840 // The peer is still returned (URL usable) so the desktop can add it and the
841 // operator can attach the peer's own token manually.
842 assert_eq!(peer.url, "http://ryu-pi.tailnet-x.ts.net:7980");
843 }
844
845 #[test]
846 fn disabled_mesh_yields_empty_peers() {
847 let resp = build_peers_response(&MeshStatus::default(), Some("ryu_shared_secret"));
848 assert!(!resp.enabled);
849 assert!(resp.peers.is_empty());
850 // A token exists, so the source still reflects a candidate bearer even with
851 // no peers to attach it to yet.
852 assert_eq!(resp.bearer_source, BEARER_SOURCE_SHARED);
853 }
854
855 #[tokio::test]
856 async fn disabled_query_status_never_touches_host() {
857 // With mesh disabled (default in the test process), query_status returns
858 // the all-default object WITHOUT a host installed — the mesh-off install
859 // path must never depend on the daemon host being wired.
860 let _lock = lock_env();
861 let _p = PrefGuard::set(false);
862 if std::env::var("RYU_MESH_ENABLED").is_err() {
863 let status = query_status().await;
864 assert_eq!(status, MeshStatus::default());
865 // ensure_funnel bails and funnel_url is None, both without a host.
866 assert!(ensure_funnel(443).await.is_err());
867 assert!(funnel_url(443).await.is_none());
868 }
869 }
870}