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 mj_core::program_path::find_program_on_path("tailscale", Some(path))
198}
199
200#[cfg(target_os = "macos")]
201fn find_bundled_binary() -> Option<PathBuf> {
202 let bundled = PathBuf::from("/Applications/Tailscale.app/Contents/MacOS/Tailscale");
203 bundled.is_file().then_some(bundled)
204}
205
206#[cfg(not(target_os = "macos"))]
207fn find_bundled_binary() -> Option<PathBuf> {
208 None
209}
210
211#[derive(Debug, Deserialize)]
212struct Status {
213 #[serde(rename = "BackendState")]
214 backend_state: String,
215 #[serde(rename = "CertDomains")]
216 cert_domains: Option<Vec<String>>,
217}
218
219fn cert_domain(status: &Status) -> Result<String> {
220 if status.backend_state != "Running" {
221 bail!(
222 "tailscale is not running (state: {}); run `tailscale up` first",
223 status.backend_state
224 );
225 }
226 status
227 .cert_domains
228 .as_deref()
229 .unwrap_or_default()
230 .first()
231 .map(|domain| domain.trim_end_matches('.').to_owned())
232 .filter(|domain| !domain.is_empty())
233 .ok_or_else(|| {
234 anyhow!(
235 "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`"
236 )
237 })
238}
239
240#[cfg(test)]
241mod tests {
242 use std::collections::VecDeque;
243 use std::sync::Mutex;
244
245 use super::*;
246 use crate::targets::CommandOutput;
247
248 struct FakeExecutor {
249 outputs: Mutex<VecDeque<CommandOutput>>,
250 commands: Mutex<Vec<CommandSpec>>,
251 issue_files: bool,
252 }
253
254 impl FakeExecutor {
255 fn new(outputs: impl IntoIterator<Item = CommandOutput>, issue_files: bool) -> Self {
256 Self {
257 outputs: Mutex::new(outputs.into_iter().collect()),
258 commands: Mutex::new(Vec::new()),
259 issue_files,
260 }
261 }
262 }
263
264 impl CommandExecutor for FakeExecutor {
265 fn execute(&self, command: &CommandSpec) -> Result<CommandOutput> {
266 self.commands.lock().unwrap().push(command.clone());
267 if self.issue_files && command.args.first().map(String::as_str) == Some("cert") {
268 fs::write(&command.args[2], b"-----BEGIN CERTIFICATE-----\ncert\n")?;
269 fs::write(&command.args[4], b"-----BEGIN PRIVATE KEY-----\nkey\n")?;
270 }
271 self.outputs
272 .lock()
273 .unwrap()
274 .pop_front()
275 .context("fake command output missing")
276 }
277 }
278
279 fn output(status: i32, stdout: &str, stderr: &str) -> CommandOutput {
280 CommandOutput {
281 status,
282 stdout: stdout.as_bytes().to_vec(),
283 stderr: stderr.as_bytes().to_vec(),
284 }
285 }
286
287 #[test]
288 fn discovers_domain_and_mints_expected_certificate() {
289 let directory = tempfile::tempdir().unwrap();
290 let executor = FakeExecutor::new(
291 [
292 output(
293 0,
294 r#"{"BackendState":"Running","CertDomains":["minas.tail.ts.net."]}"#,
295 "",
296 ),
297 output(0, "", ""),
298 ],
299 true,
300 );
301
302 let tls = prepare_tailscale_tls_with_binary(
303 directory.path(),
304 PathBuf::from("/usr/bin/tailscale"),
305 &executor,
306 )
307 .unwrap();
308
309 assert_eq!(tls.cert_domain(), "minas.tail.ts.net");
310 assert!(tls.cert_path().is_file());
311 assert!(tls.key_path().is_file());
312 let commands = executor.commands.lock().unwrap();
313 assert_eq!(commands[0].args, ["status", "--json"]);
314 assert_eq!(commands[1].args[0], "cert");
315 assert_eq!(commands[1].args[5], "minas.tail.ts.net");
316 }
317
318 #[test]
319 fn missing_certificate_domains_has_actionable_guidance() {
320 let executor = FakeExecutor::new([output(0, r#"{"BackendState":"Running"}"#, "")], false);
321 let error = discover_with_binary(PathBuf::from("tailscale"), &executor).unwrap_err();
322
323 assert!(error.to_string().contains("MagicDNS"));
324 assert!(error.to_string().contains("mj daemon restart"));
325 }
326
327 #[test]
328 fn stopped_or_malformed_status_is_rejected() {
329 let stopped = FakeExecutor::new(
330 [output(
331 0,
332 r#"{"BackendState":"Stopped","CertDomains":["host.ts.net"]}"#,
333 "",
334 )],
335 false,
336 );
337 assert!(
338 discover_with_binary(PathBuf::from("tailscale"), &stopped)
339 .unwrap_err()
340 .to_string()
341 .contains("not running")
342 );
343
344 let malformed = FakeExecutor::new([output(0, "not-json", "")], false);
345 assert!(
346 discover_with_binary(PathBuf::from("tailscale"), &malformed)
347 .unwrap_err()
348 .to_string()
349 .contains("parse")
350 );
351 }
352
353 #[test]
354 fn command_failure_does_not_replace_existing_pair() {
355 let directory = tempfile::tempdir().unwrap();
356 let cert_path = directory.path().join(CERT_FILE);
357 let key_path = directory.path().join(KEY_FILE);
358 fs::write(&cert_path, "old-cert").unwrap();
359 fs::write(&key_path, "old-key").unwrap();
360 let executor = FakeExecutor::new([output(1, "", "issuance failed")], false);
361 let tailscale = Tailscale {
362 binary: PathBuf::from("tailscale"),
363 cert_domain: "host.ts.net".into(),
364 };
365
366 assert!(mint_certificate(&tailscale, &cert_path, &key_path, &executor).is_err());
367 assert_eq!(fs::read_to_string(cert_path).unwrap(), "old-cert");
368 assert_eq!(fs::read_to_string(key_path).unwrap(), "old-key");
369 }
370
371 #[cfg(unix)]
372 #[test]
373 fn persisted_private_key_is_owner_only() {
374 use std::os::unix::fs::PermissionsExt;
375
376 let directory = tempfile::tempdir().unwrap();
377 let executor = FakeExecutor::new(
378 [
379 output(
380 0,
381 r#"{"BackendState":"Running","CertDomains":["host.ts.net"]}"#,
382 "",
383 ),
384 output(0, "", ""),
385 ],
386 true,
387 );
388 let tls = prepare_tailscale_tls_with_binary(
389 directory.path(),
390 PathBuf::from("tailscale"),
391 &executor,
392 )
393 .unwrap();
394
395 let mode = fs::metadata(tls.key_path()).unwrap().permissions().mode() & 0o777;
396 assert_eq!(mode, 0o600);
397 }
398}