Skip to main content

cloud/
mesh.rs

1//! Headscale API client and configuration helpers for `yah mesh` operations.
2//!
3//! The mesh layer manages the Headscale coordinator that all yah-provisioned
4//! machines join via Tailscale. Phase 1a runs Headscale on the operator's camp
5//! (bootstrap coordinator); Phase 1b promotes it to a cluster machine (R040-F19);
6//! Phase 2 adds openraft-based HA (R040-F20/F21).
7//!
8//! Consumers:
9//! - `app/yah/cli/src/mesh.rs` — `yah mesh start/status/backup/restore`
10//! - `app/yah/cli/src/cloud.rs` — `yah cloud machine provision` (auto-generates
11//!   a Headscale preauth key when `mesh-url` is set in the vault)
12
13use anyhow::{Context, Result};
14use serde::{Deserialize, Serialize};
15
16// ---------------------------------------------------------------------------
17// Headscale REST API client
18// ---------------------------------------------------------------------------
19
20/// HTTP client for a running Headscale coordinator.
21///
22/// The Headscale REST API lives at `<server_url>/api/v1/…`. Requests are
23/// authenticated with an API key in the `Authorization: Bearer <key>` header.
24/// The API key must be created on the coordinator with `headscale apikeys create`
25/// or the equivalent API call; store it in the vault as `headscale-api-key`.
26pub struct HeadscaleClient {
27    base_url: String,
28    api_key: String,
29    http: reqwest::Client,
30}
31
32/// A Headscale pre-authentication key for onboarding a new node.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PreauthKey {
35    /// The key string to pass to `tailscale up --auth-key=<key>`.
36    pub key: String,
37    /// ACL tags the new node will be advertised with.
38    pub acl_tags: Vec<String>,
39}
40
41/// Summary information about a node in the Headscale tailnet.
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct NodeInfo {
44    pub id: String,
45    pub name: String,
46    pub ip_addresses: Vec<String>,
47    pub online: bool,
48}
49
50/// Headscale version + health response.
51#[derive(Debug, Clone)]
52pub struct HeadscaleHealth {
53    pub reachable: bool,
54    pub status_code: u16,
55}
56
57impl HeadscaleClient {
58    /// Construct a client from explicit credentials.
59    pub fn new(base_url: impl Into<String>, api_key: impl Into<String>) -> Result<Self> {
60        let base_url = base_url.into().trim_end_matches('/').to_string();
61        let http = reqwest::Client::builder()
62            .timeout(std::time::Duration::from_secs(30))
63            .build()
64            .context("building Headscale HTTP client")?;
65        Ok(Self {
66            base_url,
67            api_key: api_key.into(),
68            http,
69        })
70    }
71
72    /// Open a client from the vault (slot `headscale-api-key` + `mesh-url`)
73    /// with env-var fallbacks (`HEADSCALE_API_KEY` + `HEADSCALE_URL`).
74    ///
75    /// Returns `Ok(None)` when either credential is absent — callers can
76    /// decide whether that's fatal.
77    pub fn from_vault_or_env() -> Result<Option<Self>> {
78        let api_key = match fob::get_or_env("headscale-api-key", "HEADSCALE_API_KEY")? {
79            Some(k) => k,
80            None => return Ok(None),
81        };
82        let url = match fob::get_or_env("mesh-url", "HEADSCALE_URL")? {
83            Some(u) => u,
84            None => return Ok(None),
85        };
86        Ok(Some(Self::new(url, api_key)?))
87    }
88
89    /// Generate a single-use pre-auth key for the given ACL tags.
90    ///
91    /// The key expires in 1 hour and is non-reusable — suitable for one-shot
92    /// machine onboarding via cloud-init. Each `yah cloud machine provision`
93    /// call that uses Headscale mesh should request its own key.
94    pub async fn create_preauth_key(&self, user: &str, tags: &[String]) -> Result<PreauthKey> {
95        let expiration = chrono::Utc::now()
96            + chrono::TimeDelta::try_hours(1)
97                .ok_or_else(|| anyhow::anyhow!("overflow computing 1-hour expiry"))?;
98        let body = serde_json::json!({
99            "user": user,
100            "expiration": expiration.to_rfc3339(),
101            "reusable": false,
102            "ephemeral": false,
103            "aclTags": tags,
104        });
105        let resp = self
106            .http
107            .post(format!("{}/api/v1/preauthkey", self.base_url))
108            .bearer_auth(&self.api_key)
109            .json(&body)
110            .send()
111            .await
112            .context("POST /api/v1/preauthkey")?;
113        let status = resp.status();
114        if !status.is_success() {
115            let text = resp.text().await.unwrap_or_default();
116            anyhow::bail!("Headscale API {status} on POST /api/v1/preauthkey: {text}");
117        }
118        let resp_json: serde_json::Value =
119            resp.json().await.context("parsing preauthkey response")?;
120        let key = resp_json["preAuthKey"]["key"]
121            .as_str()
122            .ok_or_else(|| anyhow::anyhow!("missing preAuthKey.key in response: {resp_json}"))?;
123        Ok(PreauthKey {
124            key: key.to_string(),
125            acl_tags: tags.to_vec(),
126        })
127    }
128
129    /// List all nodes currently in the tailnet.
130    ///
131    /// Uses Headscale's `GET /api/v1/node` (the endpoint was renamed from the
132    /// pre-v0.23 `/api/v1/machine`, with the response key `machines` → `nodes`,
133    /// when Headscale retired "machine" for "node"). The per-node JSON shape is
134    /// otherwise unchanged (`id`, `name`, `ipAddresses`, `online`).
135    pub async fn list_nodes(&self) -> Result<Vec<NodeInfo>> {
136        let resp = self
137            .http
138            .get(format!("{}/api/v1/node", self.base_url))
139            .bearer_auth(&self.api_key)
140            .send()
141            .await
142            .context("GET /api/v1/node")?;
143        let status = resp.status();
144        if !status.is_success() {
145            let text = resp.text().await.unwrap_or_default();
146            anyhow::bail!("Headscale API {status} on GET /api/v1/node: {text}");
147        }
148        let resp_json: serde_json::Value = resp.json().await.context("parsing node list")?;
149        let nodes = resp_json["nodes"].as_array().cloned().unwrap_or_default();
150        Ok(nodes
151            .iter()
152            .filter_map(|m| {
153                Some(NodeInfo {
154                    id: m["id"].as_str()?.to_string(),
155                    name: m["name"].as_str()?.to_string(),
156                    ip_addresses: m["ipAddresses"]
157                        .as_array()
158                        .map(|arr| {
159                            arr.iter()
160                                .filter_map(|v| v.as_str().map(str::to_string))
161                                .collect()
162                        })
163                        .unwrap_or_default(),
164                    online: m["online"].as_bool().unwrap_or(false),
165                })
166            })
167            .collect())
168    }
169
170    /// List the Headscale users (namespaces) preauth keys can be minted against.
171    ///
172    /// `GET /api/v1/user`. Headscale scopes every preauth key to a user, and a
173    /// key minted against a user that does not exist fails at mint time.
174    pub async fn list_users(&self) -> Result<Vec<String>> {
175        let resp = self
176            .http
177            .get(format!("{}/api/v1/user", self.base_url))
178            .bearer_auth(&self.api_key)
179            .send()
180            .await
181            .context("GET /api/v1/user")?;
182        let status = resp.status();
183        if !status.is_success() {
184            let text = resp.text().await.unwrap_or_default();
185            anyhow::bail!("Headscale API {status} on GET /api/v1/user: {text}");
186        }
187        let resp_json: serde_json::Value = resp.json().await.context("parsing user list")?;
188        Ok(resp_json["users"]
189            .as_array()
190            .cloned()
191            .unwrap_or_default()
192            .iter()
193            .filter_map(|u| u["name"].as_str().map(str::to_string))
194            .collect())
195    }
196
197    /// Resolve the user to mint a preauth key against, asking the coordinator
198    /// instead of assuming a name.
199    ///
200    /// R608-B19: this used to be the literal `"default"` at the
201    /// `yah cloud machine provision` call site, and against the live
202    /// coordinator that is simply wrong — `POST /api/v1/preauthkey` answers
203    /// **500** for a user that does not exist. The two halves of the system
204    /// disagree on the name: [`crate::mesh`]'s camp-local `yah mesh start` path
205    /// creates `default` (its `--user` default), while `yah mesh bootstrap`
206    /// creates `yah` (`mint_bootstrap_preauth_key` in yubaba's lib.rs runs
207    /// `headscale users create yah`). The production coordinator was
208    /// bootstrapped, so it has only `yah` — every provision against it would
209    /// have failed at the preauth step with an opaque 500.
210    ///
211    /// Rather than swap one hardcoded guess for the other:
212    ///
213    /// - `preferred` present and known to the coordinator → use it;
214    /// - `preferred` absent and the coordinator has exactly one user → use it,
215    ///   which is every yah mesh in existence today;
216    /// - otherwise → error naming the users that DO exist, so the operator can
217    ///   pick, instead of a 500 that names nothing.
218    pub async fn resolve_user(&self, preferred: Option<&str>) -> Result<String> {
219        let users = self.list_users().await?;
220        choose_user(&self.base_url, &users, preferred)
221    }
222
223    /// Light health check — HEAD or GET the Headscale root, no auth required.
224    pub async fn health(&self) -> HeadscaleHealth {
225        match self
226            .http
227            .get(format!("{}/health", self.base_url))
228            .send()
229            .await
230        {
231            Ok(r) => HeadscaleHealth {
232                reachable: r.status().is_success(),
233                status_code: r.status().as_u16(),
234            },
235            Err(_) => HeadscaleHealth {
236                reachable: false,
237                status_code: 0,
238            },
239        }
240    }
241}
242
243/// The user-selection half of [`HeadscaleClient::resolve_user`], split out so it
244/// is testable without standing up an HTTP server — this crate carries no HTTP
245/// mock, and the interesting behaviour is entirely in the choice, not the GET.
246fn choose_user(base_url: &str, users: &[String], preferred: Option<&str>) -> Result<String> {
247    if users.is_empty() {
248        anyhow::bail!(
249            "Headscale at {base_url} has no users — preauth keys are scoped to one. \
250             Create it with `headscale users create yah`."
251        );
252    }
253    if let Some(want) = preferred {
254        if users.iter().any(|u| u == want) {
255            return Ok(want.to_string());
256        }
257        anyhow::bail!(
258            "Headscale at {base_url} has no user '{want}' — preauth keys are scoped to \
259             a user, and minting against a missing one fails with an opaque 500. \
260             Existing users: {}.",
261            users.join(", ")
262        );
263    }
264    if let [only] = users {
265        return Ok(only.clone());
266    }
267    anyhow::bail!(
268        "Headscale at {base_url} has several users ({}) — pass one explicitly rather \
269         than letting this pick.",
270        users.join(", ")
271    )
272}
273
274// ---------------------------------------------------------------------------
275// Headscale config + ACL generation
276// ---------------------------------------------------------------------------
277
278/// Generate a headscale YAML configuration for Phase 1a (camp-local coordinator).
279///
280/// All state files (private keys, SQLite DB, socket) go under `data_dir`.
281/// `server_url` is the publicly-reachable stable URL (`https://mesh.<domain>`)
282/// that provisioned machines embed as their `--login-server`. It must be
283/// stable across Phase 1b promotion and Phase 2 leader changes — only DNS
284/// gets re-pointed, not the nodes.
285pub fn generate_headscale_config(server_url: &str, data_dir: &std::path::Path) -> String {
286    let private_key = data_dir.join("private.key").display().to_string();
287    let noise_key = data_dir.join("noise_private.key").display().to_string();
288    let db_path = data_dir.join("headscale.db").display().to_string();
289    let socket_path = data_dir.join("headscale.sock").display().to_string();
290    let acl_path = data_dir.join("acls.yaml").display().to_string();
291
292    // listen_addr is localhost-only; production-facing traffic goes through
293    // cloudflared or a port-forward — the stable URL is the public face.
294    format!(
295        r#"---
296server_url: {server_url}
297listen_addr: 127.0.0.1:8080
298grpc_listen_addr: 127.0.0.1:50443
299metrics_listen_addr: 127.0.0.1:9090
300private_key_path: {private_key}
301noise:
302  private_key_path: {noise_key}
303database:
304  type: sqlite
305  sqlite:
306    path: {db_path}
307unix_socket: {socket_path}
308unix_socket_permission: "0770"
309dns:
310  magic_dns: true
311  base_domain: mesh.internal
312  nameservers:
313    global:
314      - 1.1.1.1
315      - 8.8.8.8
316log:
317  level: info
318prefixes:
319  v4: 100.64.0.0/10
320  v6: fd7a:115c:a1e0::/48
321  allocation: sequential
322policy:
323  mode: file
324  path: {acl_path}
325derp:
326  server:
327    enabled: false
328  urls:
329    - https://controlplane.tailscale.com/derpmap/default
330  auto_update_enabled: false
331  update_frequency: 24h
332"#
333    )
334}
335
336/// A permissive ACL policy that allows all nodes to communicate.
337/// Can be refined later with `yah mesh acl edit`.
338///
339/// Headscale's file-based policy loader parses HuJSON (JSON-with-comments),
340/// NOT YAML — a leading `---` fails with "invalid literal: ---". Keep this
341/// JSON.
342pub const DEFAULT_ACL_POLICY: &str = r#"{
343  "acls": [
344    { "action": "accept", "src": ["*"], "dst": ["*:*"] }
345  ]
346}
347"#;
348
349// ---------------------------------------------------------------------------
350// Binary management helpers
351// ---------------------------------------------------------------------------
352
353/// Pinned Headscale release version used by `yah mesh start`.
354pub const HEADSCALE_VERSION: &str = "0.23.0";
355
356/// Return the GitHub release download URL for headscale on this platform.
357///
358/// Only supports darwin (amd64/arm64) and linux (amd64/arm64) — the
359/// platforms where `yah mesh start` makes sense. Returns `Err` for others.
360pub fn headscale_download_url() -> Result<String> {
361    let os = match std::env::consts::OS {
362        "macos" => "darwin",
363        "linux" => "linux",
364        other => anyhow::bail!(
365            "unsupported OS '{other}' for `yah mesh start`; \
366             install headscale manually from https://headscale.net"
367        ),
368    };
369    let arch = match std::env::consts::ARCH {
370        "x86_64" => "amd64",
371        "aarch64" => "arm64",
372        other => anyhow::bail!(
373            "unsupported architecture '{other}' for `yah mesh start`; \
374             install headscale manually from https://headscale.net"
375        ),
376    };
377    Ok(format!(
378        "https://github.com/juanfont/headscale/releases/download/v{HEADSCALE_VERSION}/headscale_{HEADSCALE_VERSION}_{os}_{arch}"
379    ))
380}
381
382// ---------------------------------------------------------------------------
383// Cloudflare DNS helpers
384// ---------------------------------------------------------------------------
385
386/// Update (or create) the A record for `record_name` in the given Cloudflare
387/// zone, pointing it at `new_ip`. Credentials come from the caller — use
388/// [`cloudflare_credentials`] to load them from the vault / env.
389///
390/// The record is matched by listing all A records in the zone that match
391/// `record_name`. Fails fast if zero or multiple records are found so we
392/// don't silently skip or duplicate.
393pub async fn update_cloudflare_dns(
394    api_token: &str,
395    zone_id: &str,
396    record_name: &str,
397    new_ip: &str,
398) -> Result<()> {
399    let http = reqwest::Client::builder()
400        .timeout(std::time::Duration::from_secs(30))
401        .build()
402        .context("building Cloudflare HTTP client")?;
403
404    let list_url = format!("https://api.cloudflare.com/client/v4/zones/{zone_id}/dns_records");
405
406    let resp = http
407        .get(&list_url)
408        .bearer_auth(api_token)
409        .query(&[("name", record_name), ("type", "A")])
410        .send()
411        .await
412        .context("GET Cloudflare DNS records")?;
413
414    if !resp.status().is_success() {
415        let text = resp.text().await.unwrap_or_default();
416        anyhow::bail!("Cloudflare list DNS records failed: {text}");
417    }
418
419    let body: serde_json::Value = resp
420        .json()
421        .await
422        .context("parsing Cloudflare records list")?;
423    let records = body["result"]
424        .as_array()
425        .ok_or_else(|| anyhow::anyhow!("unexpected Cloudflare response shape: missing 'result'"))?;
426
427    let record_id = match records.len() {
428        // Create-if-missing (R330-T9 follow-up b): on a fresh zone the A record
429        // won't exist yet. POST a new one instead of bailing, so `yah mesh
430        // bootstrap` is self-sufficient — no manual one-time DNS step. Same
431        // endpoint as the list (POST /zones/{zone}/dns_records), same body shape
432        // as the PATCH below. DNS-only (proxied: false) so Let's Encrypt HTTP-01
433        // can reach the node directly.
434        0 => {
435            println!("  A record '{record_name}' absent — creating it (→ {new_ip}) ...");
436            let create_body = serde_json::json!({
437                "type": "A",
438                "name": record_name,
439                "content": new_ip,
440                "ttl": 120,
441                "proxied": false
442            });
443            let resp = http
444                .post(&list_url)
445                .bearer_auth(api_token)
446                .json(&create_body)
447                .send()
448                .await
449                .context("POST Cloudflare DNS record (create-if-missing)")?;
450            if !resp.status().is_success() {
451                let text = resp.text().await.unwrap_or_default();
452                anyhow::bail!("Cloudflare create DNS record failed: {text}");
453            }
454            println!("  DNS record created.");
455            return Ok(());
456        }
457        1 => records[0]["id"]
458            .as_str()
459            .ok_or_else(|| anyhow::anyhow!("missing 'id' in Cloudflare record"))?
460            .to_string(),
461        n => anyhow::bail!(
462            "{n} A records named '{record_name}' found — expected exactly one; \
463             resolve the ambiguity in your Cloudflare dashboard"
464        ),
465    };
466
467    let patch_url = format!("{list_url}/{record_id}");
468    let patch_body = serde_json::json!({
469        "type": "A",
470        "name": record_name,
471        "content": new_ip,
472        "ttl": 120,
473        "proxied": false
474    });
475
476    let resp = http
477        .patch(&patch_url)
478        .bearer_auth(api_token)
479        .json(&patch_body)
480        .send()
481        .await
482        .context("PATCH Cloudflare DNS record")?;
483
484    if !resp.status().is_success() {
485        let text = resp.text().await.unwrap_or_default();
486        anyhow::bail!("Cloudflare PATCH DNS record failed: {text}");
487    }
488
489    Ok(())
490}
491
492/// Load Cloudflare credentials from vault or environment.
493///
494/// Vault slots → env var fallbacks:
495/// - `cloudflare-api-token` ↔ `CLOUDFLARE_API_TOKEN`
496/// - `cloudflare-zone-id`   ↔ `CLOUDFLARE_ZONE_ID`
497///
498/// Returns `Ok(None)` when either credential is absent so callers can decide
499/// whether to proceed with manual-DNS instructions or bail.
500pub fn cloudflare_credentials() -> Result<Option<(String, String)>> {
501    let token = fob::get_or_env("cloudflare-api-token", "CLOUDFLARE_API_TOKEN")?;
502    let zone_id = fob::get_or_env("cloudflare-zone-id", "CLOUDFLARE_ZONE_ID")?;
503    match (token, zone_id) {
504        (Some(t), Some(z)) => Ok(Some((t, z))),
505        _ => Ok(None),
506    }
507}
508
509// ---------------------------------------------------------------------------
510// Tests
511// ---------------------------------------------------------------------------
512
513#[cfg(test)]
514mod tests {
515    use super::*;
516
517    #[test]
518    fn config_contains_server_url() {
519        let dir = std::path::PathBuf::from("/tmp/test-mesh");
520        let config = generate_headscale_config("https://mesh.example.com", &dir);
521        assert!(config.contains("server_url: https://mesh.example.com"));
522        assert!(config.contains("127.0.0.1:8080"));
523        assert!(config.contains("base_domain: mesh.internal"));
524        assert!(config.contains("acls.yaml"));
525        // Regression (R330-T9): base_domain must not be a substring of the
526        // server_url host, or headscale 0.23+ refuses to start ("server_url
527        // cannot contain the base_domain"). mesh.internal is decoupled from
528        // any mesh.yah.dev-style coordinator hostname.
529        let cfg2 = generate_headscale_config("https://mesh.yah.dev", &dir);
530        assert!(!cfg2.contains("base_domain: mesh.yah\n"));
531    }
532
533    #[test]
534    fn config_all_paths_in_data_dir() {
535        let dir = std::path::PathBuf::from("/home/user/.yah/mesh");
536        let config = generate_headscale_config("https://mesh.example.com", &dir);
537        assert!(config.contains("/home/user/.yah/mesh/private.key"));
538        assert!(config.contains("/home/user/.yah/mesh/headscale.db"));
539    }
540
541    #[test]
542    fn download_url_current_platform() {
543        // Just ensure it doesn't panic on the current CI platform.
544        let result = headscale_download_url();
545        assert!(result.is_ok(), "unsupported platform: {result:?}");
546        let url = result.unwrap();
547        assert!(url.contains(HEADSCALE_VERSION));
548        assert!(url.starts_with("https://github.com"));
549    }
550
551    // -- choose_user (R608-B19) ---------------------------------------------
552    //
553    // The regression these pin down: `yah cloud machine provision` minted its
554    // preauth key against the literal `"default"`, which the production
555    // coordinator does not have. It was bootstrapped, so its only user is
556    // `yah` (`headscale users create yah`, yubaba lib.rs
557    // `mint_bootstrap_preauth_key`), while the camp-local `yah mesh start` path
558    // creates `default`. Headscale answers a mint against a missing user with a
559    // bare 500 naming nothing, so this failed opaquely on every provision.
560
561    fn users(names: &[&str]) -> Vec<String> {
562        names.iter().map(|s| s.to_string()).collect()
563    }
564
565    #[test]
566    fn a_lone_user_is_chosen_without_being_named() {
567        // The live shape: one bootstrapped coordinator, one user, and no reason
568        // to make the caller know whether it is called `yah` or `default`.
569        assert_eq!(
570            choose_user("https://mesh.test", &users(&["yah"]), None).unwrap(),
571            "yah"
572        );
573        assert_eq!(
574            choose_user("https://mesh.test", &users(&["default"]), None).unwrap(),
575            "default"
576        );
577    }
578
579    #[test]
580    fn a_preferred_user_that_exists_wins() {
581        assert_eq!(
582            choose_user("https://mesh.test", &users(&["yah", "ops"]), Some("ops")).unwrap(),
583            "ops"
584        );
585    }
586
587    #[test]
588    fn a_preferred_user_that_does_not_exist_is_refused_by_name() {
589        // THE bug, as a test: asking for "default" on a coordinator that only
590        // has "yah" must fail here, locally, saying what does exist — not reach
591        // the API and come back with a 500 that names nothing.
592        let err = choose_user("https://mesh.test", &users(&["yah"]), Some("default"))
593            .expect_err("a missing user must not be requested from the API");
594        let msg = err.to_string();
595        assert!(msg.contains("default"), "names what was asked for: {msg}");
596        assert!(msg.contains("yah"), "names what actually exists: {msg}");
597    }
598
599    #[test]
600    fn several_users_and_no_preference_refuses_rather_than_guessing() {
601        // Silently taking the first would reintroduce the same class of bug:
602        // a name picked by the code that the operator never chose.
603        let err = choose_user("https://mesh.test", &users(&["yah", "default"]), None)
604            .expect_err("an ambiguous mint target must not be guessed");
605        let msg = err.to_string();
606        assert!(msg.contains("yah") && msg.contains("default"), "{msg}");
607    }
608
609    #[test]
610    fn no_users_at_all_says_how_to_create_one() {
611        let err = choose_user("https://mesh.test", &[], None).expect_err("no users is fatal");
612        assert!(err.to_string().contains("headscale users create"));
613    }
614}