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