Skip to main content

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}