Skip to main content

tailscale_mcp/
context.rs

1//! What a tool handler is given.
2//!
3//! Deliberately a plain struct of backends and limits rather than the server
4//! itself: a handler that could reach the server could reach the router, and
5//! then the tests would have to construct one to call anything.
6
7use std::path::{Component, Path, PathBuf};
8use std::sync::{Arc, Mutex};
9use std::time::{Duration, Instant};
10
11use tailscale_cli::LocalBackend;
12
13use crate::error::Redactor;
14use crate::meta::Tier;
15use crate::version::Version;
16
17/// Where a tool may write when the caller names a path on this machine.
18///
19/// In this release the tier is what confines host filesystem access: those
20/// tools sit at the write tier and no higher, so a read-only session reaches
21/// none of them. The allow-list is the mechanism meant to confine them further,
22/// and it is here rather than in a comment so that switching it on is a matter
23/// of populating one value: every tool that takes a path already asks.
24#[derive(Debug, Clone, Default, PartialEq, Eq)]
25pub enum PathPolicy {
26    /// Any path the caller names. What this release ships.
27    #[default]
28    Unrestricted,
29    /// Only paths under one of these roots.
30    Within(Vec<PathBuf>),
31}
32
33impl PathPolicy {
34    /// Whether this policy would let a tool write to `path`.
35    #[must_use]
36    pub fn permits(&self, path: &Path) -> bool {
37        match self {
38            Self::Unrestricted => true,
39            // A `..` walks out of whatever root it is checked against, so a
40            // path carrying one is refused rather than resolved. Resolving
41            // would have to touch the filesystem, and the path a caller names
42            // here is usually one that does not exist yet.
43            Self::Within(roots) => {
44                !path.components().any(|c| c == Component::ParentDir)
45                    && roots.iter().any(|root| path.starts_with(root))
46            }
47        }
48    }
49}
50
51/// The identity of the node this server runs on, read from status at startup.
52///
53/// Used to recognise a control-plane operation aimed at ourselves, which is the
54/// difference between deleting a device and severing the connection the caller
55/// is talking over.
56#[derive(Debug, Clone, Default, PartialEq, Eq)]
57pub struct SelfIdentity {
58    /// The node id, which is the identifier the control plane prefers:
59    /// `status --json` reports it as `Self.ID`, and it looks like
60    /// `n1234567CNTRL`.
61    pub node_id: Option<String>,
62    /// The numeric device id, which the control plane accepts for the same
63    /// device.
64    ///
65    /// Not in status — the local node has never been told it — so it is
66    /// resolved from the control plane when there is a credential, and stays
67    /// `None` when there is not. A caller naming this node by its numeric id
68    /// in a session with no credential is therefore not recognised, which is
69    /// the same blind spot as a session with no local surface and is handled
70    /// the same way: the call is treated as ordinary.
71    pub numeric_id: Option<String>,
72    /// Tailscale addresses assigned to this node.
73    pub addresses: Vec<String>,
74    /// The node's MagicDNS name.
75    pub dns_name: Option<String>,
76}
77
78impl SelfIdentity {
79    /// Whether `target` names this node. Matching is generous on purpose: a
80    /// caller may refer to the node by any of the identifiers the API accepts,
81    /// and a missed match is the expensive direction.
82    pub fn matches(&self, target: &str) -> bool {
83        let target = target.trim().trim_end_matches('.');
84        if target.is_empty() {
85            return false;
86        }
87        let same = |candidate: &Option<String>| {
88            candidate
89                .as_deref()
90                .is_some_and(|c| c.trim_end_matches('.').eq_ignore_ascii_case(target))
91        };
92        same(&self.node_id)
93            || same(&self.numeric_id)
94            || same(&self.dns_name)
95            || self.addresses.iter().any(|a| a == target)
96            // A MagicDNS name may be given unqualified.
97            || self
98                .dns_name
99                .as_deref()
100                .and_then(|n| n.split('.').next())
101                .is_some_and(|short| short.eq_ignore_ascii_case(target))
102    }
103}
104
105/// How long a reading of who we are is trusted before status is asked again.
106///
107/// An address or a name can change under a running server — a node is renamed,
108/// re-tagged, or moves onto a different address — and an identity that went
109/// stale would stop recognising an operation aimed at this node, which is the
110/// expensive direction to be wrong in. A minute is short enough that the window
111/// is small and long enough that a burst of device calls does not become a
112/// burst of `tailscale status` (Q87).
113pub const IDENTITY_FRESH_FOR: Duration = Duration::from_secs(60);
114
115/// The local node's identity, kept current.
116///
117/// Cheap to clone and shared between clones, so that one refresh serves every
118/// handler rather than each holding its own idea of who we are.
119#[derive(Clone, Default)]
120pub struct Identity {
121    held: Arc<Mutex<Held>>,
122    /// Whether status can be asked again at all. False when the local surface
123    /// is not offered, in which case there was nothing to read to begin with
124    /// and re-reading nothing on a timer is only noise.
125    live: bool,
126}
127
128#[derive(Debug, Default)]
129struct Held {
130    known: SelfIdentity,
131    /// When `known` was read. `None` before the first reading.
132    read_at: Option<Instant>,
133}
134
135impl Identity {
136    /// An identity that was read from status and may be read again.
137    pub fn probed(known: SelfIdentity) -> Self {
138        Self {
139            held: Arc::new(Mutex::new(Held {
140                known,
141                read_at: Some(Instant::now()),
142            })),
143            live: true,
144        }
145    }
146
147    /// An identity fixed at what it was given: what tests and a session with
148    /// no local surface get.
149    pub fn fixed(known: SelfIdentity) -> Self {
150        Self {
151            held: Arc::new(Mutex::new(Held {
152                known,
153                read_at: None,
154            })),
155            live: false,
156        }
157    }
158
159    /// The last reading, without asking for a new one.
160    ///
161    /// For the places that run once at startup and would gain nothing from a
162    /// refresh, such as the instructions.
163    pub fn last_known(&self) -> SelfIdentity {
164        self.held
165            .lock()
166            .map(|held| held.known.clone())
167            .unwrap_or_default()
168    }
169
170    /// Whether the last reading is old enough to be worth replacing.
171    fn stale(&self) -> bool {
172        self.live
173            && self.held.lock().is_ok_and(|held| {
174                held.read_at
175                    .is_none_or(|at| at.elapsed() >= IDENTITY_FRESH_FOR)
176            })
177    }
178
179    /// Store a fresh reading, keeping a numeric id the reading cannot carry.
180    fn store(&self, mut known: SelfIdentity) {
181        if let Ok(mut held) = self.held.lock() {
182            if known.numeric_id.is_none() && held.known.node_id == known.node_id {
183                known.numeric_id = held.known.numeric_id.take_if(|_| true);
184            }
185            held.known = known;
186            held.read_at = Some(Instant::now());
187        }
188    }
189
190    /// Store a numeric id resolved from the control plane.
191    fn store_numeric(&self, numeric_id: String) {
192        if let Ok(mut held) = self.held.lock() {
193            held.known.numeric_id = Some(numeric_id);
194        }
195    }
196}
197
198impl std::fmt::Debug for Identity {
199    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
200        f.debug_struct("Identity")
201            .field("known", &self.last_known())
202            .field("live", &self.live)
203            .finish()
204    }
205}
206
207impl From<SelfIdentity> for Identity {
208    fn from(known: SelfIdentity) -> Self {
209        Self::fixed(known)
210    }
211}
212
213/// Every string in a JSON array, or nothing at all if it is not one.
214fn strings(value: &serde_json::Value) -> Vec<String> {
215    value
216        .as_array()
217        .map(|items| {
218            items
219                .iter()
220                .filter_map(|item| Some(item.as_str()?.to_owned()))
221                .collect()
222        })
223        .unwrap_or_default()
224}
225
226/// One device, in the fields anything outside the device tools needs of it.
227///
228/// The control plane's device object is large and mostly irrelevant here: what
229/// resolution and completion both want is the set of names a person might use
230/// for a machine, and the identifier the API will actually take in exchange.
231#[derive(Clone, Debug)]
232pub struct Device {
233    /// What the control plane accepts, and what resolution answers with.
234    pub node_id: String,
235    /// The MagicDNS name, fully qualified.
236    pub name: String,
237    /// The machine's own name for itself, which need not be unique.
238    pub hostname: String,
239    /// Every Tailscale address it answers on.
240    pub addresses: Vec<String>,
241    /// The tags it wears, `tag:` prefix included. Read from the device rather
242    /// than from the policy file because a tag nothing wears and a tag in use
243    /// are different questions, and completion wants both answered.
244    pub tags: Vec<String>,
245}
246
247impl Device {
248    /// The label before the first dot of the MagicDNS name.
249    ///
250    /// `laptop.example-tailnet.ts.net` is what a listing prints and `laptop` is
251    /// what a person types, so both have to name the same device.
252    #[must_use]
253    pub fn short_name(&self) -> &str {
254        self.name.split('.').next().unwrap_or(&self.name)
255    }
256
257    /// Whether an already-lowercased value is one of this device's names.
258    ///
259    /// Exact against each field rather than a prefix: this decides which device
260    /// a caller meant, and a value that merely begins like a name is not an
261    /// answer to that. Completion matches loosely; addressing does not.
262    #[must_use]
263    pub fn answers_to(&self, lowercased: &str) -> bool {
264        self.name.to_ascii_lowercase() == lowercased
265            || self.hostname.to_ascii_lowercase() == lowercased
266            || self.short_name().to_ascii_lowercase() == lowercased
267            || self
268                .addresses
269                .iter()
270                .any(|address| address.to_ascii_lowercase() == lowercased)
271    }
272}
273
274/// The tailnet's device list, held briefly.
275///
276/// Two callers read it and both read it in bursts: resolving an identifier
277/// happens once per device-addressing call, and completing one happens once per
278/// keystroke. Ten seconds is longer than either burst and shorter than anyone's
279/// patience for a device that has since been renamed.
280///
281/// The lock is never held across an await — the listing is fetched outside it
282/// and stored after — so two callers racing simply both fetch, which costs a
283/// request and no correctness.
284/// What the cache holds: when it was read, and what it read.
285type Listing = Arc<Mutex<Option<(Instant, Arc<[Device]>)>>>;
286
287#[derive(Clone, Debug, Default)]
288pub struct DeviceCache {
289    held: Listing,
290}
291
292impl DeviceCache {
293    const TTL: Duration = Duration::from_secs(10);
294
295    fn fresh(&self) -> Option<Arc<[Device]>> {
296        let held = self.held.lock().ok()?;
297        let (at, devices) = held.as_ref()?;
298        (at.elapsed() < Self::TTL).then(|| Arc::clone(devices))
299    }
300
301    fn put(&self, devices: &Arc<[Device]>) {
302        if let Ok(mut held) = self.held.lock() {
303            *held = Some((Instant::now(), Arc::clone(devices)));
304        }
305    }
306}
307
308/// Everything a handler may reach.
309#[derive(Clone)]
310pub struct ToolContext {
311    /// The local node. Present even when the local surface is disabled, in
312    /// which case it is a backend that reports the binary as missing.
313    pub local: Arc<dyn LocalBackend>,
314    /// The control plane, when there is a credential to reach it with.
315    ///
316    /// Deliberately not `pub`: a handler asks [`ToolContext::tailnet`] for it
317    /// and gets either the client or the sentence explaining its absence, so
318    /// that the ninety-three tailnet tools do not each find their own words
319    /// for the same missing credential.
320    pub(crate) tailnet: Option<tailscale_rest::Client>,
321    /// Removes secrets from anything on its way out.
322    pub redactor: Redactor,
323    /// The size above which a result is refused rather than truncated.
324    pub max_result_bytes: usize,
325    /// Who we are on the tailnet, when we could find out.
326    pub identity: Identity,
327    /// The version the local CLI reports, when it could be read.
328    pub cli_version: Option<Version>,
329    /// Where the tools that take a path are allowed to write.
330    pub paths: PathPolicy,
331    /// The tailnet's device list, cached for a few seconds.
332    ///
333    /// The only mutable state a session holds. It exists because two features
334    /// ask the same question repeatedly — which device did you mean, and which
335    /// could you have meant — and neither should cost a request each time.
336    pub devices: DeviceCache,
337    /// The most dangerous tier this session permits.
338    ///
339    /// The gate is what normally applies this, before a handler is reached, so
340    /// no typed tool has to look at it. The passthrough does: its row carries a
341    /// floor rather than its real tier, so it is the one tool that has to make
342    /// the same decision the gate makes, against the command it was given.
343    pub max_tier: Tier,
344}
345
346impl ToolContext {
347    /// The tailnet's devices, from the cache when it is warm enough.
348    ///
349    /// The error is the one the caller would have got anyway: without a
350    /// credential this is the missing-credential sentence, and a control-plane
351    /// failure is reported as itself rather than as an absent device.
352    pub async fn tailnet_devices(&self) -> crate::error::ToolResult<Arc<[Device]>> {
353        if let Some(warm) = self.devices.fresh() {
354            return Ok(warm);
355        }
356        let client = self.tailnet()?;
357        let answer = client
358            .get(client.tailnet_path(None, "/devices"))
359            .send_as::<serde_json::Value>()
360            .await?;
361        let devices: Arc<[Device]> = answer["devices"]
362            .as_array()
363            .map(|listed| {
364                listed
365                    .iter()
366                    .filter_map(|device| {
367                        Some(Device {
368                            node_id: device["nodeId"].as_str()?.to_owned(),
369                            name: device["name"].as_str().unwrap_or_default().to_owned(),
370                            hostname: device["hostname"].as_str().unwrap_or_default().to_owned(),
371                            addresses: strings(&device["addresses"]),
372                            tags: strings(&device["tags"]),
373                        })
374                    })
375                    .collect()
376            })
377            .unwrap_or_else(|| Vec::new().into());
378        self.devices.put(&devices);
379        Ok(devices)
380    }
381
382    /// Whether `target` names the node this server runs on.
383    ///
384    /// Two sources, because the control plane accepts two identifiers for the
385    /// same device and the local node only knows one of them. Status gives the
386    /// node id, the addresses and the name, and is re-read as it ages. The
387    /// numeric id has to be asked of the control plane — and is, only when the
388    /// answer could turn on it: a target that is not all digits is not a
389    /// numeric id, so the overwhelming majority of calls cost nothing extra,
390    /// and the one that does costs one request per process (Q87).
391    pub async fn names_us(&self, target: &str) -> bool {
392        if self.identity.stale() {
393            self.identity
394                .store(crate::cli::probe_identity(self.local.as_ref()).await);
395        }
396        let known = self.identity.last_known();
397        if known.matches(target) {
398            return true;
399        }
400
401        let numeric = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
402        if known.numeric_id.is_some() || !numeric(target.trim()) {
403            return false;
404        }
405        let (Some(node_id), Some(client)) = (&known.node_id, self.tailnet.as_ref()) else {
406            return false;
407        };
408        // A device's numeric id does not change while its node id stays the
409        // same, so this is asked once and then remembered.
410        let Ok(path) = crate::tools::tailnet_devices::device_path(node_id, "") else {
411            return false;
412        };
413        let Ok(device) = client.get(path).send_as::<serde_json::Value>().await else {
414            return false;
415        };
416        let Some(numeric_id) = device["id"].as_str() else {
417            return false;
418        };
419        self.identity.store_numeric(numeric_id.to_owned());
420        self.identity.last_known().matches(target)
421    }
422
423    /// The control plane, or the reason there is none.
424    pub fn tailnet(&self) -> crate::error::ToolResult<&tailscale_rest::Client> {
425        self.tailnet.as_ref().ok_or_else(|| {
426            crate::error::ToolError::backend_unavailable(
427                "the tailnet surface",
428                "no control-plane credential was found; set TAILSCALE_API_KEY, or \
429                 TAILSCALE_OAUTH_CLIENT_ID and TAILSCALE_OAUTH_CLIENT_SECRET",
430            )
431        })
432    }
433}
434
435impl std::fmt::Debug for ToolContext {
436    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
437        // No credential-bearing field is printed, and none should be added.
438        f.debug_struct("ToolContext")
439            .field("tailnet", &self.tailnet.is_some())
440            .field("max_result_bytes", &self.max_result_bytes)
441            .field("identity", &self.identity)
442            .field("cli_version", &self.cli_version)
443            .field("max_tier", &self.max_tier)
444            .finish_non_exhaustive()
445    }
446}
447
448#[cfg(test)]
449mod tests {
450    use super::*;
451
452    fn identity() -> SelfIdentity {
453        SelfIdentity {
454            node_id: Some("n1234567CNTRL".to_owned()),
455            numeric_id: Some("92960230385".to_owned()),
456            addresses: vec!["100.64.0.1".to_owned(), "fd7a::1".to_owned()],
457            dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
458        }
459    }
460
461    #[test]
462    fn a_node_is_recognised_by_any_name_the_api_accepts() {
463        let id = identity();
464        for name in [
465            "n1234567CNTRL",
466            // Both identifier forms the control plane accepts for a device.
467            "92960230385",
468            "100.64.0.1",
469            "fd7a::1",
470            "workstation.example-tailnet.ts.net",
471            "workstation.example-tailnet.ts.net.",
472            "workstation",
473            "WORKSTATION",
474        ] {
475            assert!(id.matches(name), "{name} should name this node");
476        }
477    }
478
479    #[test]
480    fn another_node_is_not() {
481        let id = identity();
482        for name in [
483            "n7654321CNTRL",
484            "92960230386",
485            // A public key is not an identifier the control plane accepts, so
486            // matching one would be a claim this server cannot cash.
487            "nodekey:1111111111111111111111111111111111111111111111111111111111111111",
488            "100.64.0.2",
489            "laptop.example-tailnet.ts.net",
490            "laptop",
491            "",
492            "   ",
493        ] {
494            assert!(!id.matches(name), "{name} should not name this node");
495        }
496    }
497
498    #[test]
499    fn a_context_with_no_credential_names_the_variables_that_would_give_it_one() {
500        // Reachable only when a session has the tailnet surface but no client:
501        // startup switches the surface off when there is no credential, so the
502        // tools are not offered and no call arrives (`tailnet_surface.rs`
503        // asserts that absence). What is left is a credential that stops being
504        // usable mid-session, and this is the sentence such a call gets. It is
505        // asserted here because there is nowhere else it can be seen.
506        let ctx = crate::testing::context(std::sync::Arc::new(crate::testing::StubBackend::ok("")));
507        let error = ctx.tailnet().expect_err("no credential was configured");
508        let reported = serde_json::to_value(&error).expect("reportable");
509        assert_eq!(reported["code"], serde_json::json!("backend_unavailable"));
510        let message = reported["message"].as_str().expect("a message");
511        for variable in [
512            "TAILSCALE_API_KEY",
513            "TAILSCALE_OAUTH_CLIENT_ID",
514            "TAILSCALE_OAUTH_CLIENT_SECRET",
515        ] {
516            assert!(message.contains(variable), "{message}");
517        }
518    }
519}