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}