1use std::ffi::OsStr;
8use std::fs;
9use std::path::{Path, PathBuf};
10
11use anyhow::{Context, Result, anyhow, bail};
12use serde::Deserialize;
13
14use crate::targets::{CommandExecutor, CommandSpec};
15use mj_core::config::atomic_write;
16
17const CERT_FILE: &str = "tailscale-cert.pem";
18const KEY_FILE: &str = "tailscale-key.pem";
19
20#[derive(Debug, Clone)]
21struct Tailscale {
22 binary: PathBuf,
23 cert_domain: String,
24}
25
26#[derive(Debug, Clone)]
28pub struct TailscaleTls {
29 tailscale: Tailscale,
30 cert_path: PathBuf,
31 key_path: PathBuf,
32}
33
34impl TailscaleTls {
35 pub fn cert_domain(&self) -> &str {
36 &self.tailscale.cert_domain
37 }
38
39 pub fn cert_path(&self) -> &Path {
40 &self.cert_path
41 }
42
43 pub fn key_path(&self) -> &Path {
44 &self.key_path
45 }
46
47 pub fn renew(&self, executor: &impl CommandExecutor) -> Result<()> {
50 mint_certificate(&self.tailscale, &self.cert_path, &self.key_path, executor)
51 }
52}
53
54pub fn prepare_tailscale_tls(root: &Path, executor: &impl CommandExecutor) -> Result<TailscaleTls> {
58 let binary = find_binary()
59 .ok_or_else(|| anyhow!("tailscale CLI not found in PATH or the macOS app bundle"))?;
60 prepare_tailscale_tls_with_binary(root, binary, executor)
61}
62
63fn prepare_tailscale_tls_with_binary(
64 root: &Path,
65 binary: PathBuf,
66 executor: &impl CommandExecutor,
67) -> Result<TailscaleTls> {
68 let tailscale = discover_with_binary(binary, executor)?;
69 fs::create_dir_all(root)
70 .with_context(|| format!("create web viewer TLS directory {}", root.display()))?;
71 let tls = TailscaleTls {
72 tailscale,
73 cert_path: root.join(CERT_FILE),
74 key_path: root.join(KEY_FILE),
75 };
76 tls.renew(executor)?;
77 Ok(tls)
78}
79
80fn discover_with_binary(binary: PathBuf, executor: &impl CommandExecutor) -> Result<Tailscale> {
81 let command = CommandSpec::new(
82 binary.to_string_lossy(),
83 ["status".to_owned(), "--json".to_owned()],
84 )
85 .purpose("inspect local Tailscale status");
86 let output = executor.execute(&command)?;
87 if output.status != 0 {
88 bail!(
89 "`tailscale status` failed: {}",
90 String::from_utf8_lossy(&output.stderr).trim()
91 );
92 }
93 let status: Status =
94 serde_json::from_slice(&output.stdout).context("parse `tailscale status --json` output")?;
95 let cert_domain = cert_domain(&status)?;
96 Ok(Tailscale {
97 binary,
98 cert_domain,
99 })
100}
101
102fn mint_certificate(
103 tailscale: &Tailscale,
104 cert_path: &Path,
105 key_path: &Path,
106 executor: &impl CommandExecutor,
107) -> Result<()> {
108 let parent = cert_path
109 .parent()
110 .filter(|parent| !parent.as_os_str().is_empty())
111 .unwrap_or_else(|| Path::new("."));
112 fs::create_dir_all(parent)
113 .with_context(|| format!("create web viewer TLS directory {}", parent.display()))?;
114 let suffix = temporary_suffix()?;
115 let staged_cert = parent.join(format!(".{CERT_FILE}.{suffix}.tmp"));
116 let staged_key = parent.join(format!(".{KEY_FILE}.{suffix}.tmp"));
117 let result = (|| -> Result<()> {
118 let command = CommandSpec::new(
119 tailscale.binary.to_string_lossy(),
120 [
121 "cert".to_owned(),
122 "--cert-file".to_owned(),
123 staged_cert.to_string_lossy().into_owned(),
124 "--key-file".to_owned(),
125 staged_key.to_string_lossy().into_owned(),
126 tailscale.cert_domain.clone(),
127 ],
128 )
129 .purpose("obtain the web viewer Tailscale certificate");
130 let output = executor.execute(&command)?;
131 if output.status != 0 {
132 bail!(
133 "`tailscale cert {}` failed: {}",
134 tailscale.cert_domain,
135 String::from_utf8_lossy(&output.stderr).trim()
136 );
137 }
138 let cert = fs::read(&staged_cert)
139 .with_context(|| format!("read issued certificate {}", staged_cert.display()))?;
140 let key = fs::read(&staged_key)
141 .with_context(|| format!("read issued private key {}", staged_key.display()))?;
142 validate_pem(&cert, &key)?;
143 atomic_write(cert_path, &cert)?;
144 atomic_write(key_path, &key)?;
145 Ok(())
146 })();
147 remove_staged_file(&staged_cert);
148 remove_staged_file(&staged_key);
149 result
150}
151
152fn temporary_suffix() -> Result<String> {
153 let mut random = [0_u8; 8];
154 getrandom::fill(&mut random)
155 .map_err(|error| anyhow!("generate temporary certificate filename: {error}"))?;
156 Ok(format!(
157 "{}.{:016x}",
158 std::process::id(),
159 u64::from_le_bytes(random)
160 ))
161}
162
163fn validate_pem(cert: &[u8], key: &[u8]) -> Result<()> {
164 if !cert
165 .windows(b"-----BEGIN CERTIFICATE-----".len())
166 .any(|window| window == b"-----BEGIN CERTIFICATE-----")
167 {
168 bail!("tailscale returned a certificate file without a PEM certificate");
169 }
170 if !key
171 .windows(b"PRIVATE KEY-----".len())
172 .any(|window| window == b"PRIVATE KEY-----")
173 {
174 bail!("tailscale returned a key file without a PEM private key");
175 }
176 Ok(())
177}
178
179fn remove_staged_file(path: &Path) {
180 if let Err(error) = fs::remove_file(path)
181 && error.kind() != std::io::ErrorKind::NotFound
182 {
183 tracing::warn!(path = %path.display(), %error, "could not remove staged Tailscale certificate file");
184 }
185}
186
187fn find_binary() -> Option<PathBuf> {
188 if let Some(path) = std::env::var_os("PATH")
189 && let Some(binary) = find_binary_in_path(&path)
190 {
191 return Some(binary);
192 }
193 find_bundled_binary()
194}
195
196fn find_binary_in_path(path: &OsStr) -> Option<PathBuf> {
197 std::env::split_paths(path).find_map(|directory| {
198 tailscale_binary_names()
199 .iter()
200 .map(|name| directory.join(name))
201 .find(|candidate| candidate.is_file())
202 })
203}
204
205#[cfg(windows)]
206fn tailscale_binary_names() -> &'static [&'static str] {
207 &["tailscale.exe", "tailscale"]
208}
209
210#[cfg(not(windows))]
211fn tailscale_binary_names() -> &'static [&'static str] {
212 &["tailscale"]
213}
214
215#[cfg(target_os = "macos")]
216fn find_bundled_binary() -> Option<PathBuf> {
217 let bundled = PathBuf::from("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
218 bundled.is_file().then_some(bundled)
219}
220
221#[cfg(not(target_os = "macos"))]
222fn find_bundled_binary() -> Option<PathBuf> {
223 None
224}
225
226#[derive(Debug, Deserialize)]
227struct Status {
228 #[serde(rename = "BackendState")]
229 backend_state: String,
230 #[serde(rename = "CertDomains")]
231 cert_domains: Option<Vec<String>>,
232}
233
234fn cert_domain(status: &Status) -> Result<String> {
235 if status.backend_state != "Running" {
236 bail!(
237 "tailscale is not running (state: {}); run `tailscale up` first",
238 status.backend_state
239 );
240 }
241 status
242 .cert_domains
243 .as_deref()
244 .unwrap_or_default()
245 .first()
246 .map(|domain| domain.trim_end_matches('.').to_owned())
247 .filter(|domain| !domain.is_empty())
248 .ok_or_else(|| {
249 anyhow!(
250 "this tailnet has no HTTPS certificate domains; enable MagicDNS and HTTPS Certificates under the DNS tab at https://login.tailscale.com/admin/dns, then run `mj daemon restart`"
251 )
252 })
253}
254
255#[cfg(test)]
256mod tests {
257 use std::collections::VecDeque;
258 use std::sync::Mutex;
259
260 use super::*;
261 use crate::targets::CommandOutput;
262
263 struct FakeExecutor {
264 outputs: Mutex<VecDeque<CommandOutput>>,
265 commands: Mutex<Vec<CommandSpec>>,
266 issue_files: bool,
267 }
268
269 impl FakeExecutor {
270 fn new(outputs: impl IntoIterator<Item = CommandOutput>, issue_files: bool) -> Self {
271 Self {
272 outputs: Mutex::new(outputs.into_iter().collect()),
273 commands: Mutex::new(Vec::new()),
274 issue_files,
275 }
276 }
277 }
278
279 impl CommandExecutor for FakeExecutor {
280 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
281 self.commands.lock().unwrap().push(command.clone());
282 if self.issue_files && command.args.first().map(String::as_str) == Some("cert") {
283 fs::write(&command.args[2], b"-----BEGIN CERTIFICATE-----\ncert\n")?;
284 fs::write(&command.args[4], b"-----BEGIN PRIVATE KEY-----\nkey\n")?;
285 }
286 self.outputs
287 .lock()
288 .unwrap()
289 .pop_front()
290 .context("fake command output missing")
291 }
292 }
293
294 fn output(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
295 CommandOutput {
296 status,
297 stdout: stdout.as_bytes().to_vec(),
298 stderr: stderr.as_bytes().to_vec(),
299 }
300 }
301
302 #[test]
303 fn discovers_domain_and_mints_expected_certificate() {
304 let directory = tempfile::tempdir().unwrap();
305 let executor = FakeExecutor::new(
306 [
307 output(
308 0,
309 r#"{"BackendState":"Running","CertDomains":["minas.tail.ts.net."]}"#,
310 "",
311 ),
312 output(0, "", ""),
313 ],
314 true,
315 );
316
317 let tls = prepare_tailscale_tls_with_binary(
318 directory.path(),
319 PathBuf::from("/usr/bin/tailscale"),
320 &executor,
321 )
322 .unwrap();
323
324 assert_eq!(tls.cert_domain(), "minas.tail.ts.net");
325 assert!(tls.cert_path().is_file());
326 assert!(tls.key_path().is_file());
327 let commands = executor.commands.lock().unwrap();
328 assert_eq!(commands[0].args, ["status", "--json"]);
329 assert_eq!(commands[1].args[0], "cert");
330 assert_eq!(commands[1].args[5], "minas.tail.ts.net");
331 }
332
333 #[test]
334 fn missing_certificate_domains_has_actionable_guidance() {
335 let executor = FakeExecutor::new([output(0, r#"{"BackendState":"Running"}"#, "")], false);
336 let error = discover_with_binary(PathBuf::from("tailscale"), &executor).unwrap_err();
337
338 assert!(error.to_string().contains("MagicDNS"));
339 assert!(error.to_string().contains("mj daemon restart"));
340 }
341
342 #[test]
343 fn stopped_or_malformed_status_is_rejected() {
344 let stopped = FakeExecutor::new(
345 [output(
346 0,
347 r#"{"BackendState":"Stopped","CertDomains":["host.ts.net"]}"#,
348 "",
349 )],
350 false,
351 );
352 assert!(
353 discover_with_binary(PathBuf::from("tailscale"), &stopped)
354 .unwrap_err()
355 .to_string()
356 .contains("not running")
357 );
358
359 let malformed = FakeExecutor::new([output(0, "not-json", "")], false);
360 assert!(
361 discover_with_binary(PathBuf::from("tailscale"), &malformed)
362 .unwrap_err()
363 .to_string()
364 .contains("parse")
365 );
366 }
367
368 #[test]
369 fn command_failure_does_not_replace_existing_pair() {
370 let directory = tempfile::tempdir().unwrap();
371 let cert_path = directory.path().join(CERT_FILE);
372 let key_path = directory.path().join(KEY_FILE);
373 fs::write(&cert_path, "old-cert").unwrap();
374 fs::write(&key_path, "old-key").unwrap();
375 let executor = FakeExecutor::new([output(1, "", "issuance failed")], false);
376 let tailscale = Tailscale {
377 binary: PathBuf::from("tailscale"),
378 cert_domain: "host.ts.net".into(),
379 };
380
381 assert!(mint_certificate(&tailscale, &cert_path, &key_path, &executor).is_err());
382 assert_eq!(fs::read_to_string(cert_path).unwrap(), "old-cert");
383 assert_eq!(fs::read_to_string(key_path).unwrap(), "old-key");
384 }
385
386 #[cfg(unix)]
387 #[test]
388 fn persisted_private_key_is_owner_only() {
389 use std::os::unix::fs::PermissionsExt;
390
391 let directory = tempfile::tempdir().unwrap();
392 let executor = FakeExecutor::new(
393 [
394 output(
395 0,
396 r#"{"BackendState":"Running","CertDomains":["host.ts.net"]}"#,
397 "",
398 ),
399 output(0, "", ""),
400 ],
401 true,
402 );
403 let tls = prepare_tailscale_tls_with_binary(
404 directory.path(),
405 PathBuf::from("tailscale"),
406 &executor,
407 )
408 .unwrap();
409
410 let mode = fs::metadata(tls.key_path()).unwrap().permissions().mode() & 0o777;
411 assert_eq!(mode, 0o600);
412 }
413}