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