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 the two `serve` commands that exchange configuration through a
63/// file. The file itself may not exist: `serve get-config` refuses a path that
64/// is already taken, so the reserved form hands over a name and lets the client
65/// create it.
66#[derive(Debug)]
67pub struct PrivateFile {
68    // Held, not read: dropping the directory is what removes whatever is
69    // inside it, the client's file included.
70    #[expect(dead_code, reason = "kept alive so that dropping it cleans up")]
71    dir: tempfile::TempDir,
72    path: std::path::PathBuf,
73}
74
75impl PrivateFile {
76    /// A name inside a new private directory, with nothing at it yet.
77    pub fn reserved(name: &str) -> Result<Self, ExecError> {
78        let mut builder = tempfile::Builder::new();
79        builder.prefix("tailscale-mcp-");
80        #[cfg(unix)]
81        {
82            use std::os::unix::fs::PermissionsExt as _;
83            builder.permissions(std::fs::Permissions::from_mode(0o700));
84        }
85        let dir = builder.tempdir().map_err(ExecError::SecretFile)?;
86        let path = dir.path().join(name);
87        Ok(Self { dir, path })
88    }
89
90    /// The same, with `contents` already written to it.
91    pub fn written(name: &str, contents: &[u8]) -> Result<Self, ExecError> {
92        let file = Self::reserved(name)?;
93        std::fs::write(&file.path, contents).map_err(ExecError::SecretFile)?;
94        Ok(file)
95    }
96
97    pub fn path(&self) -> &Path {
98        &self.path
99    }
100
101    /// The path as the CLI wants it: a plain path, since these commands take a
102    /// filename rather than a URL.
103    pub fn arg(&self) -> String {
104        self.path.display().to_string()
105    }
106
107    /// What is at the path now. An error if the client wrote nothing.
108    pub fn read(&self) -> Result<Vec<u8>, ExecError> {
109        std::fs::read(&self.path).map_err(ExecError::SecretFile)
110    }
111
112    /// The directory the file lives in, for the tests that check it is private.
113    #[cfg(test)]
114    fn directory(&self) -> &Path {
115        self.path.parent().unwrap_or(&self.path)
116    }
117}
118
119#[cfg(test)]
120mod tests {
121    use super::*;
122
123    #[test]
124    fn a_reserved_name_is_free_for_the_client_to_create() {
125        let file = PrivateFile::reserved("serve-config.json").expect("a private directory");
126        assert!(
127            !file.path().exists(),
128            "the client refuses a path that is already taken"
129        );
130        std::fs::write(file.path(), b"{}").expect("the client can create it");
131        assert_eq!(file.read().expect("readable"), b"{}");
132    }
133
134    #[cfg(unix)]
135    #[test]
136    fn nobody_else_can_enter_the_directory_the_file_lives_in() {
137        use std::os::unix::fs::PermissionsExt as _;
138        let file = PrivateFile::written("serve-config.json", b"{}").expect("a private directory");
139        let mode = std::fs::metadata(file.directory())
140            .expect("metadata")
141            .permissions()
142            .mode();
143        assert_eq!(mode & 0o077, 0, "group and other must have no access");
144    }
145
146    #[test]
147    fn the_file_and_its_directory_go_away_with_the_call() {
148        let (path, dir) = {
149            let file = PrivateFile::written("serve-config.json", b"{}").expect("a private file");
150            (file.path().to_path_buf(), file.directory().to_path_buf())
151        };
152        assert!(!path.exists(), "{} outlived its guard", path.display());
153        assert!(!dir.exists(), "{} outlived its guard", dir.display());
154    }
155
156    #[test]
157    fn the_secret_reaches_the_cli_by_reference_not_by_value() {
158        let secret = "tskey-auth-example-notreal";
159        let file = SecretFile::new(secret).expect("a temporary file");
160        let arg = file.arg();
161        assert!(arg.starts_with("file:"));
162        assert!(
163            !arg.contains(secret),
164            "the argument must not carry the secret: {arg}"
165        );
166        assert_eq!(
167            std::fs::read_to_string(file.path()).expect("readable"),
168            secret
169        );
170    }
171
172    #[cfg(unix)]
173    #[test]
174    fn the_file_is_readable_only_by_this_user() {
175        use std::os::unix::fs::PermissionsExt as _;
176        let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
177        let mode = std::fs::metadata(file.path())
178            .expect("metadata")
179            .permissions()
180            .mode();
181        assert_eq!(mode & 0o077, 0, "group and other must have no access");
182    }
183
184    #[test]
185    fn the_file_goes_away_with_the_call() {
186        let path = {
187            let file = SecretFile::new("tskey-auth-example-notreal").expect("a temporary file");
188            file.path().to_path_buf()
189        };
190        assert!(!path.exists(), "{} outlived its guard", path.display());
191    }
192}