1use anyhow::{Context, Result};
14use serde::{Deserialize, Serialize};
15
16pub struct HeadscaleClient {
27 base_url: String,
28 api_key: String,
29 http: reqwest::Client,
30}
31
32#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct PreauthKey {
35 pub key: String,
37 pub acl_tags: Vec<String>,
39}
40
41#[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#[derive(Debug, Clone)]
52pub struct HeadscaleHealth {
53 pub reachable: bool,
54 pub status_code: u16,
55}
56
57impl HeadscaleClient {
58 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 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 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 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 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 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 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
243fn 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
274pub 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 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
336pub const DEFAULT_ACL_POLICY: &str = r#"{
343 "acls": [
344 { "action": "accept", "src": ["*"], "dst": ["*:*"] }
345 ]
346}
347"#;
348
349pub const HEADSCALE_VERSION: &str = "0.23.0";
355
356pub 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
382pub 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 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
492pub 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#[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 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 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 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 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 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 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}