Skip to main content

tailscale_cli/
secret.rs

1//! Handing the CLI something too big or too private for an argument list.
2//!
3//! An argument list is world-readable on every platform we support: `ps` shows
4//! it, `/proc` shows it, and a crash reporter will happily upload it. Tailscale
5//! anticipates this and accepts `file:<path>` wherever it accepts a key, so the
6//! secret goes into a file only this process can read, for only as long as the
7//! call takes. That is [`SecretFile`].
8//!
9//! [`PrivateFile`] is the other direction: a document the CLI insists on
10//! exchanging through a file rather than a stream, in either direction. It
11//! protects the *directory* rather than the file, because a file the CLI
12//! creates is created with the CLI's idea of a mode, not ours.
13
14use std::io::Write;
15use std::path::Path;
16
17use crate::exec::ExecError;
18
19/// A private file holding one secret, removed when this value is dropped.
20///
21/// Dropping is enough for the ordinary paths, including a timeout: the guard
22/// lives in the same scope as the call, so unwinding or an early return takes
23/// the file with it. A hard kill of the server leaves it behind, which is why
24/// it is created under the system temporary directory with a private mode
25/// rather than somewhere durable.
26#[derive(Debug)]
27pub struct SecretFile {
28    file: tempfile::NamedTempFile,
29}
30
31impl SecretFile {
32    /// Write `secret` to a new private file.
33    pub fn new(secret: &str) -> Result<Self, ExecError> {
34        let mut builder = tempfile::Builder::new();
35        builder.prefix("tailscale-mcp-").suffix(".key");
36        #[cfg(unix)]
37        {
38            use std::os::unix::fs::PermissionsExt as _;
39            let _ = std::fs::Permissions::from_mode(0o600);
40            builder.permissions(std::fs::Permissions::from_mode(0o600));
41        }
42        let mut file = builder.tempfile().map_err(ExecError::SecretFile)?;
43        file.write_all(secret.as_bytes())
44            .and_then(|()| file.flush())
45            .map_err(ExecError::SecretFile)?;
46        Ok(Self { file })
47    }
48
49    pub fn path(&self) -> &Path {
50        self.file.path()
51    }
52
53    /// The argument value the CLI expects: a path behind the `file:` scheme.
54    pub fn arg(&self) -> String {
55        format!("file:{}", self.file.path().display())
56    }
57}
58
59/// A scratch file inside a directory only this user can enter, removed when
60/// this value is dropped along with the directory holding it.
61///
62/// Used for `serve set-config`, which reads its configuration from a file.
63#[derive(Debug)]
64pub struct PrivateFile {
65    // Held, not read: dropping the directory is what removes whatever is
66    // inside it, the client's file included.
67    #[expect(dead_code, reason = "kept alive so that dropping it cleans up")]
68    dir: tempfile::TempDir,
69    path: std::path::PathBuf,
70}
71
72impl PrivateFile {
73    /// A file called `name` holding `contents`, inside a new private directory.
74    pub fn written(name: &str, contents: &[u8]) -> Result<Self, ExecError> {
75        let mut builder = tempfile::Builder::new();
76        builder.prefix("tailscale-mcp-");
77        #[cfg(unix)]
78        {
79            use std::os::unix::fs::PermissionsExt as _;
80            builder.permissions(std::fs::Permissions::from_mode(0o700));
81        }
82        let dir = builder.tempdir().map_err(ExecError::SecretFile)?;
83        let path = dir.path().join(name);
84        std::fs::write(&path, contents).map_err(ExecError::SecretFile)?;
85        Ok(Self { dir, path })
86    }
87
88    pub fn path(&self) -> &Path {
89        &self.path
90    }
91
92    /// The path as the CLI wants it: a plain path, since these commands take a
93    /// filename rather than a URL.
94    pub fn arg(&self) -> String {
95        self.path.display().to_string()
96    }
97
98    /// The directory the file lives in, for the tests that check it is private.
99    #[cfg(test)]
100    fn directory(&self) -> &Path {
101        self.path.parent().unwrap_or(&self.path)
102    }
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[cfg(unix)]
110    #[test]
111    fn nobody_else_can_enter_the_directory_the_file_lives_in() {
112        use std::os::unix::fs::PermissionsExt as _;
113        let file = PrivateFile::written("serve-config.json", b"{}").expect("a private directory");
114        let mode = std::fs::metadata(file.directory())
115            .expect("metadata")
116            .permissions()
117            .mode();
118        assert_eq!(mode & 0o077, 0, "group and other must have no access");
119    }
120
121    #[test]
122    fn the_file_and_its_directory_go_away_with_the_call() {
123        let (path, dir) = {
124            let file = PrivateFile::written("serve-config.json", b"{}").expect("a private file");
125            (file.path().to_path_buf(), file.directory().to_path_buf())
126        };
127        assert!(!path.exists(), "{} outlived its guard", path.display());
128        assert!(!dir.exists(), "{} outlived its guard", dir.display());
129    }
130
131    #[test]
132    fn the_secret_reaches_the_cli_by_reference_not_by_value() {
133        let secret = "tskey-auth-example-notreal";
134        let file = SecretFile::new(secret).expect("a temporary file");
135        let arg = file.arg();
136        assert!(arg.starts_with("file:"));
137        assert!(
138            !arg.contains(secret),
139            "the argument must not carry the secret: {arg}"
140        );
141        assert_eq!(
142            std::fs::read_to_string(file.path()).expect("readable"),
143            secret
144        );
145    }
146
147    #[cfg(unix)]
148    #[test]
149    fn the_file_is_readable_only_by_this_user() {
150        use std::os::unix::fs::PermissionsExt as _;
151        let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
152        let mode = std::fs::metadata(file.path())
153            .expect("metadata")
154            .permissions()
155            .mode();
156        assert_eq!(mode & 0o077, 0, "group and other must have no access");
157    }
158
159    #[test]
160    fn the_file_goes_away_with_the_call() {
161        let path = {
162            let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
163            file.path().to_path_buf()
164        };
165        assert!(!path.exists(), "{} outlived its guard", path.display());
166    }
167}