Skip to main content

tailscale_mcp/tools/
local_debug.rs

1//! The debug toolset: what tailscaled will say about itself, and what it will
2//! be told to do again.
3//!
4//! `tailscale debug` is hidden, and its own help calls it "not a stable
5//! interface". That is the whole reason this toolset exists separately and is
6//! in no preset: a caller reaches it by naming `local-debug`, which is a
7//! statement that it accepts output that may change shape between releases.
8//! [`crate::gating`] keeps it out of `full` for that reason, so "every toolset"
9//! never silently means "this one too".
10//!
11//! The thirty tools are twenty-two readers and eight knobs. The readers ask the
12//! daemon what it currently believes — its netmap, its DERP map, its goroutines,
13//! its bus — and change nothing. The knobs make it do something over again: a
14//! fresh STUN round, a new socket, a different home relay. A knob changes
15//! transient runtime state and never a preference, so the two are separated by
16//! tier as well as by name, and a session that added this toolset at the read
17//! tier gets the readers only.
18//!
19//! Because a knob is not a configuration write, its invocation takes the shared
20//! lane: the exclusive lane exists to keep two `set` calls apart, and forcing a
21//! re-STUN is not that.
22//!
23//! ## What is not here
24//!
25//! [`EXCLUDED`] names the fourteen `debug` subcommands that never become tools,
26//! with the reason for each, and is the list the passthrough refuses. They fall
27//! into four groups, which are [`CONTEXT.md`]'s own grounds for excluding a
28//! command: it prints a secret, it runs in the foreground indefinitely, it
29//! reaches outside what this server is willing to drive, or its whole purpose
30//! is to break the node.
31//!
32//! `debug prefs` is in that list, and it is the one exclusion the research
33//! table did not predict. Its help says it prints preferences; what it actually
34//! prints includes `PrivateNodeKey`, `OldPrivateNodeKey` and the tailnet-lock
35//! private key, none of which the shape-based redaction knows. Every other
36//! field it holds is already reported by a tool that does not carry the keys,
37//! so excluding it costs a caller nothing (DECISIONS Q45). The same dump is
38//! reachable through `debug watch-ipn --initial`, which is the one flag of that
39//! command not offered here; its six `--initial-*` siblings each ask for one
40//! narrow field and carry no key, so they are parameters like any other.
41//!
42//! The parent command's own flags are excluded on the same grounds:
43//! `--cpu-profile` and `--mem-profile` write a binary profile to a path or to
44//! standard output, and `--file=<name>` and `--file=delete:<name>` act on the
45//! Taildrop inbox that `tailscale_file_get` already owns. Its remaining form,
46//! `--file=get`, lists that inbox without downloading anything and is offered
47//! here as `tailscale_debug_file_list`, because no other tool answers "what is
48//! waiting for me" (DECISIONS Q46).
49//!
50//! `debug reload-config` is in neither list, which is itself the statement. It
51//! is the ninth command that would otherwise be a knob, and it is the one that
52//! is not one: a knob changes transient runtime state without changing any
53//! preference, and reloading makes tailscaled re-read a configuration file that
54//! this server did not write, cannot see and cannot describe. Its effect is
55//! whatever that file now says, so no honest summary of it could be written and
56//! no annotation could state its tier. It stays a legitimate operation for
57//! whoever does own that file, so the passthrough may still run it
58//! (DECISIONS Q44).
59//!
60//! ## Bounded forms
61//!
62//! Two of these commands would otherwise run until interrupted.
63//! `tailscale_debug_watch_ipn` takes a required count, which is how the client
64//! itself ends the stream, and a wall-clock bound in case the node is quiet
65//! enough that the count is never reached. `tailscale_debug_portmap` bounds
66//! itself through the `--duration` the client already has. A bound that expires
67//! is reported as a timeout carrying what the command had printed, which is the
68//! same answer a foreground `tailscale_funnel` gives.
69//!
70//! A third would, and is the one flag withheld from an otherwise whole command:
71//! `debug metrics --watch` prints deltas until interrupted and has no count to
72//! bound it, so `tailscale_debug_metrics` offers the one-shot dump only.
73//!
74//! [`CONTEXT.md`]: https://github.com/tailscale-mcp/tailscale-mcp/blob/main/CONTEXT.md
75
76use rmcp::schemars::JsonSchema;
77use serde::{Deserialize, Serialize};
78use serde_json::{Value, json};
79use tailscale_cli::Invocation;
80
81use crate::cli;
82use crate::context::ToolContext;
83use crate::error::{ToolError, ToolResult};
84use crate::meta::ToolMeta;
85use crate::tools::common::{
86    Excluded, bounded_wait, document, note, object, printed, push_bool, push_text, real_path,
87    report,
88};
89
90crate::tools! {
91    // -----------------------------------------------------------------------
92    // readers: what the daemon currently believes
93    // -----------------------------------------------------------------------
94
95    /// The DERP map the local node is using: every relay region the control
96    /// plane has told it about, with the servers in each. Large.
97    tailscale_debug_derp_map => NoParams, derp_map,
98        toolset: LocalDebug, tier: Read, idempotent: true;
99
100    /// The current netmap: every peer the local node knows, with the keys,
101    /// addresses and endpoints it holds for each. The largest document this
102    /// server produces, and the one most likely to exceed the result cap.
103    tailscale_debug_netmap => NoParams, netmap,
104        toolset: LocalDebug, tier: Read, idempotent: true;
105
106    /// What the local node reports about itself to the control plane: its OS,
107    /// version, hardware and the features it has enabled.
108    tailscale_debug_hostinfo => NoParams, hostinfo,
109        toolset: LocalDebug, tier: Read, idempotent: true;
110
111    /// The control knobs the control plane has set on this node: the per-tailnet
112    /// switches that change how the daemon behaves without a preference.
113    tailscale_debug_control_knobs => NoParams, control_knobs,
114        toolset: LocalDebug, tier: Read, idempotent: true;
115
116    /// A stack dump of every goroutine in tailscaled, for diagnosing a daemon
117    /// that is wedged. Tens of kilobytes of Go stack traces.
118    tailscale_debug_daemon_goroutines => NoParams, daemon_goroutines,
119        toolset: LocalDebug, tier: Read, idempotent: true;
120
121    /// The daemon's internal event bus as a graph: which components publish
122    /// which events and which subscribe to them.
123    tailscale_debug_daemon_bus_graph => DaemonBusGraphParams, daemon_bus_graph,
124        toolset: LocalDebug, tier: Read, idempotent: true;
125
126    /// How much is queued on each of the daemon's internal event bus queues,
127    /// for finding a subscriber that has fallen behind.
128    tailscale_debug_daemon_bus_queues => NoParams, daemon_bus_queues,
129        toolset: LocalDebug, tier: Read, idempotent: true;
130
131    /// The daemon's own internal metrics, in Prometheus text format. Distinct
132    /// from `tailscale_metrics_print`, which reports the node's client metrics.
133    tailscale_debug_metrics => NoParams, metrics,
134        toolset: LocalDebug, tier: Read, idempotent: true;
135
136    /// The directory tailscaled keeps its state in on the local filesystem.
137    tailscale_debug_statedir => NoParams, statedir,
138        toolset: LocalDebug, tier: Read, idempotent: true;
139
140    /// The Go build information of the `tailscale` binary: its module versions,
141    /// build settings and the toolchain that produced it.
142    tailscale_debug_go_buildinfo => NoParams, go_buildinfo,
143        toolset: LocalDebug, tier: Read, idempotent: true;
144
145    /// The peers this node could relay through, for reaching a peer that no
146    /// direct path and no DERP region can carry.
147    tailscale_debug_peer_relay_servers => NoParams, peer_relay_servers,
148        toolset: LocalDebug, tier: Read, idempotent: true;
149
150    /// The relay sessions currently running through this node on behalf of
151    /// other peers, and whether it is configured to serve them at all.
152    tailscale_debug_peer_relay_sessions => NoParams, peer_relay_sessions,
153        toolset: LocalDebug, tier: Read, idempotent: true;
154
155    /// List the files waiting in this node's Taildrop inbox without downloading
156    /// any of them. Use `tailscale_file_get` to fetch what this reports.
157    tailscale_debug_file_list => NoParams, file_list,
158        toolset: LocalDebug, tier: Read, idempotent: true;
159
160    /// The mode and size of files on the local filesystem, as tailscaled's own
161    /// process sees them, for telling a missing file from an unreadable one.
162    tailscale_debug_stat => StatParams, stat,
163        toolset: LocalDebug, tier: Read, idempotent: true;
164
165    /// Convert between a site's IPv4 prefix and the IPv6 `via` route that
166    /// carries it. Give `site_id` and `prefix` to go one way, or `route` to go
167    /// back. Arithmetic on the values given: nothing is contacted.
168    tailscale_debug_via => ViaParams, via,
169        toolset: LocalDebug, tier: Read, idempotent: true;
170
171    /// Watch the local node's state notifications until `count` of them have
172    /// arrived or the bound expires, whichever comes first. Reports the events
173    /// as they were published. A quiet node reaches the bound instead, which is
174    /// reported as a timeout carrying whatever had arrived.
175    tailscale_debug_watch_ipn => WatchIpnParams, watch_ipn,
176        toolset: LocalDebug, tier: Read;
177
178    /// The history of endpoint changes the local node has recorded for one
179    /// peer, for diagnosing a connection that keeps being rebuilt.
180    tailscale_debug_peer_endpoint_changes => PeerParams, peer_endpoint_changes,
181        toolset: LocalDebug, tier: Read, idempotent: true;
182
183    /// Resolve a hostname through the daemon's own resolver rather than the
184    /// operating system's, to see what tailscaled would have got.
185    tailscale_debug_resolve => ResolveParams, resolve,
186        toolset: LocalDebug, tier: Read, idempotent: true;
187
188    /// Try every path type in turn to a host and port — direct, through DERP,
189    /// through the operating system — and report which of them connected.
190    tailscale_debug_dial_types => DialTypesParams, dial_types,
191        toolset: LocalDebug, tier: Read, idempotent: true;
192
193    /// Test the local node's DERP configuration by exercising the relays it
194    /// would use. A network probe: it makes connections but changes nothing.
195    tailscale_debug_derp => NoParams, derp,
196        toolset: LocalDebug, tier: Read, idempotent: true;
197
198    /// Test that the control plane is reachable over the Noise protocol the
199    /// daemon uses, reporting each dial attempt and its result.
200    tailscale_debug_ts2021 => Ts2021Params, ts2021,
201        toolset: LocalDebug, tier: Read, idempotent: true;
202
203    /// Probe the local network's gateway for port-mapping support — PMP, PCP
204    /// and UPnP — and report what it offered. Runs for `seconds`.
205    tailscale_debug_portmap => PortmapParams, portmap,
206        toolset: LocalDebug, tier: Read, idempotent: true;
207
208    // -----------------------------------------------------------------------
209    // knobs: make the daemon do something over again
210    // -----------------------------------------------------------------------
211
212    /// Turn verbose logging on for one of the daemon's components for a while.
213    /// A zero or negative duration turns it off again. The logs go to the
214    /// daemon's own log, which this server does not read.
215    tailscale_debug_component_logs => ComponentLogsParams, component_logs,
216        toolset: LocalDebug, tier: Write;
217
218    /// Force a fresh STUN round, so the node re-learns the addresses peers
219    /// should reach it on. Useful after a network change the daemon missed.
220    tailscale_debug_restun => NoParams, restun,
221        toolset: LocalDebug, tier: Write, idempotent: true;
222
223    /// Force the node to rebind its UDP sockets, so it starts using whatever
224    /// the operating system's routing table now says.
225    tailscale_debug_rebind => NoParams, rebind,
226        toolset: LocalDebug, tier: Write, idempotent: true;
227
228    /// Rotate this node's disco key, so that peers must re-establish their
229    /// direct paths to it. Existing sessions are rebuilt, not dropped.
230    tailscale_debug_rotate_disco_key => NoParams, rotate_disco_key,
231        toolset: LocalDebug, tier: Write;
232
233    /// Put DERP connections back to always-on after something set them to
234    /// on-demand. The repair for a node that has become slow to be reached.
235    tailscale_debug_derp_unset_on_demand => NoParams, derp_unset_on_demand,
236        toolset: LocalDebug, tier: Write, idempotent: true;
237
238    /// Move the node to a different home DERP region, chosen by the daemon,
239    /// until the next restart. For testing whether a region is the problem.
240    tailscale_debug_pick_new_derp => NoParams, pick_new_derp,
241        toolset: LocalDebug, tier: Write;
242
243    /// Pin the node's home DERP to one region until the next restart, or pass
244    /// region 0 to un-pin it and let the daemon choose again.
245    tailscale_debug_force_prefer_derp => ForcePreferDerpParams, force_prefer_derp,
246        toolset: LocalDebug, tier: Write, idempotent: true;
247
248    /// Push a full netmap update through the daemon without changing it, to
249    /// exercise everything that reacts to one.
250    tailscale_debug_force_netmap_update => NoParams, force_netmap_update,
251        toolset: LocalDebug, tier: Write, idempotent: true;
252}
253
254/// Every `debug` subcommand that never becomes a tool.
255///
256/// Fourteen of `debug`'s forty-four subcommands. Twenty-nine of the rest are
257/// declared above and `reload-config` is deliberately in neither list, which
258/// accounts for all forty-four; the thirtieth tool, `tailscale_debug_file_list`,
259/// runs a flag on the parent command rather than a subcommand. The
260/// passthrough refuses these, so a toolset that is off is not a way in.
261pub const EXCLUDED: &[Excluded] = &[
262    Excluded {
263        path: "debug prefs",
264        reason: "it prints the node's private keys along with its preferences; \
265                 `tailscale_debug_netmap` and the preference tools report the rest",
266    },
267    Excluded {
268        path: "debug local-creds",
269        reason: "it prints the credential for reaching tailscaled's private HTTP \
270                 interface, which this server never uses",
271    },
272    Excluded {
273        path: "debug env",
274        reason: "it prints the whole process environment, which is where secrets \
275                 for other services live",
276    },
277    Excluded {
278        path: "debug localapi",
279        reason: "it calls tailscaled's private HTTP interface directly, which this \
280                 server does not do at all",
281    },
282    Excluded {
283        path: "debug daemon-logs",
284        reason: "it streams the daemon log until interrupted and has no bounded form",
285    },
286    Excluded {
287        path: "debug daemon-bus-events",
288        reason: "it streams bus events until interrupted and has no bounded form",
289    },
290    Excluded {
291        path: "debug capture",
292        reason: "it streams a packet capture of tunnel traffic until interrupted, \
293                 and launches a separate application when given no path",
294    },
295    Excluded {
296        path: "debug test-risk",
297        reason: "it exists to test the client's interactive risk prompt, which a \
298                 tool call cannot answer",
299    },
300    Excluded {
301        path: "debug set-expire",
302        reason: "it expires the local node's key, cutting this server off from the \
303                 tailnet; re-authenticate deliberately instead",
304    },
305    Excluded {
306        path: "debug break-tcp-conns",
307        reason: "its purpose is to break the daemon's connections",
308    },
309    Excluded {
310        path: "debug break-derp-conns",
311        reason: "its purpose is to break the daemon's relay connections",
312    },
313    Excluded {
314        path: "debug derp-set-on-demand",
315        reason: "it makes the node reachable only after a delay; \
316                 `tailscale_debug_derp_unset_on_demand` undoes it if something else did it",
317    },
318    Excluded {
319        path: "debug clear-netmap-cache",
320        reason: "it deletes the cached netmaps the node falls back on when the \
321                 control plane is unreachable",
322    },
323    Excluded {
324        path: "debug dev-store-set",
325        reason: "it writes directly into tailscaled's state store, behind every \
326                 check the rest of the client makes",
327    },
328];
329
330/// A tool that takes nothing.
331#[derive(Debug, Deserialize, JsonSchema)]
332pub struct NoParams {}
333
334// ---------------------------------------------------------------------------
335// reports
336// ---------------------------------------------------------------------------
337
338/// What a command that prints prose rather than a document had to say.
339///
340/// Most of `debug` predates any thought of being parsed and prints for a person
341/// reading a terminal. Handing that text back whole is the honest answer: this
342/// server does not know which line mattered, and inventing a structure over
343/// output the client calls unstable would break on the release that reworded
344/// it.
345#[derive(Debug, Serialize, JsonSchema)]
346pub struct TextReport {
347    #[serde(skip_serializing_if = "Option::is_none")]
348    pub printed: Option<String>,
349}
350
351/// A single path this server was asked about.
352#[derive(Debug, Serialize, JsonSchema)]
353pub struct StatEntry {
354    pub path: String,
355    pub mode: String,
356    pub size: String,
357}
358
359#[derive(Debug, Serialize, JsonSchema)]
360pub struct StatReport {
361    pub entries: Vec<StatEntry>,
362    #[serde(skip_serializing_if = "Option::is_none")]
363    pub printed: Option<String>,
364}
365
366#[derive(Debug, Serialize, JsonSchema)]
367pub struct AddressReport {
368    pub addresses: Vec<String>,
369    #[serde(skip_serializing_if = "Option::is_none")]
370    pub printed: Option<String>,
371}
372
373#[derive(Debug, Serialize, JsonSchema)]
374pub struct PathReport {
375    pub path: String,
376}
377
378#[derive(Debug, Serialize, JsonSchema)]
379/// What `debug via` made of its arguments.
380///
381/// The command answers with one line and nothing else, so the line is the
382/// report: there is no second thing it printed to hand back beside it.
383pub struct ViaReport {
384    pub converted: String,
385}
386
387#[derive(Debug, Serialize, JsonSchema)]
388pub struct BusGraphReport {
389    pub format: String,
390    #[serde(skip_serializing_if = "Option::is_none")]
391    pub graph: Option<Value>,
392    #[serde(skip_serializing_if = "Option::is_none")]
393    pub printed: Option<String>,
394}
395
396#[derive(Debug, Serialize, JsonSchema)]
397pub struct WatchReport {
398    /// The notifications that arrived, in the order they were published.
399    pub events: Vec<Value>,
400    /// How many were asked for. Fewer may have arrived only if the command
401    /// ended early, which it does not do on its own.
402    pub asked_for: u32,
403    /// The bound the call was given, in seconds.
404    pub seconds: u64,
405    #[serde(skip_serializing_if = "Option::is_none")]
406    pub note: Option<String>,
407}
408
409/// What a knob did, which is never more than "the daemon was told".
410///
411/// None of these commands reports a new state, and asking for one afterwards
412/// would be a second call with a different answer, so the report says what was
413/// asked for and hands back anything the client printed.
414#[derive(Debug, Serialize, JsonSchema)]
415pub struct KnobReport {
416    pub outcome: String,
417    #[serde(skip_serializing_if = "Option::is_none")]
418    pub printed: Option<String>,
419}
420
421// ---------------------------------------------------------------------------
422// parameters
423// ---------------------------------------------------------------------------
424
425#[derive(Debug, Deserialize, JsonSchema)]
426pub struct DaemonBusGraphParams {
427    /// `json` for a document, or `dot` for a Graphviz drawing returned as text.
428    #[serde(default)]
429    pub format: Option<BusGraphFormat>,
430}
431
432#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
433#[serde(rename_all = "lowercase")]
434pub enum BusGraphFormat {
435    Json,
436    Dot,
437}
438
439impl BusGraphFormat {
440    const fn as_str(self) -> &'static str {
441        match self {
442            Self::Json => "json",
443            Self::Dot => "dot",
444        }
445    }
446}
447
448#[derive(Debug, Deserialize, JsonSchema)]
449pub struct StatParams {
450    /// Paths on the local filesystem to report on. At least one.
451    pub paths: Vec<String>,
452}
453
454#[derive(Debug, Deserialize, JsonSchema)]
455pub struct ViaParams {
456    /// The site's numeric identifier. Give this with `prefix` to build a route.
457    #[serde(default)]
458    pub site_id: Option<u32>,
459    /// The site's IPv4 prefix, such as `10.1.0.0/16`. Needs `site_id`.
460    #[serde(default)]
461    pub prefix: Option<String>,
462    /// An IPv6 `via` route to take apart, reported as the site and prefix it
463    /// stands for. Give this on its own.
464    #[serde(default)]
465    pub route: Option<String>,
466}
467
468/// The client's own default for how long to wait, and the longest this server
469/// will hold a call open for one.
470const DEFAULT_WATCH_SECONDS: u64 = 30;
471const MAX_WATCH_SECONDS: u64 = 300;
472const MAX_EVENTS: u32 = 100;
473
474#[derive(Debug, Deserialize, JsonSchema)]
475pub struct WatchIpnParams {
476    /// How many notifications to wait for before returning. Required, because
477    /// without one the command never ends. Capped at 100.
478    pub count: u32,
479    /// How long to wait for them, in seconds. Defaults to 30, capped at 300.
480    /// Reaching this bound is reported as a timeout.
481    #[serde(default)]
482    pub timeout_seconds: Option<u64>,
483    /// Include the daemon's engine statistics in each notification.
484    #[serde(default)]
485    pub engine_updates: Option<bool>,
486    /// Include the health actions the daemon wants taken.
487    #[serde(default)]
488    pub health_actions: Option<bool>,
489    /// Include peer changes. On by default in the client.
490    #[serde(default)]
491    pub peer_changes: Option<bool>,
492    /// Include incremental peer patches. On by default in the client.
493    #[serde(default)]
494    pub peer_patches: Option<bool>,
495    /// Include each peer's WireGuard state.
496    #[serde(default)]
497    pub peer_wireguard_state: Option<bool>,
498    /// Put the client version the daemon knows about in the first message.
499    #[serde(default)]
500    pub initial_client_version: Option<bool>,
501    /// Put the current Taildrive shares in the first message.
502    #[serde(default)]
503    pub initial_drive_shares: Option<bool>,
504    /// Put the current health state in the first message.
505    #[serde(default)]
506    pub initial_health: Option<bool>,
507    /// Put the Taildrop files currently going out in the first message.
508    #[serde(default)]
509    pub initial_outgoing_files: Option<bool>,
510    /// Put the current status in the first message.
511    #[serde(default)]
512    pub initial_status: Option<bool>,
513    /// Put the exit node the daemon would suggest in the first message.
514    #[serde(default)]
515    pub initial_suggested_exit_node: Option<bool>,
516}
517
518#[derive(Debug, Deserialize, JsonSchema)]
519pub struct PeerParams {
520    /// The peer, by hostname or Tailscale IP address.
521    pub peer: String,
522}
523
524#[derive(Debug, Deserialize, JsonSchema)]
525pub struct ResolveParams {
526    /// The hostname to look up.
527    pub host: String,
528    /// Which addresses to ask for: `ip`, `ip4` or `ip6`. Defaults to `ip`.
529    #[serde(default)]
530    pub net: Option<String>,
531}
532
533#[derive(Debug, Deserialize, JsonSchema)]
534pub struct DialTypesParams {
535    /// The host or IP address to dial.
536    pub host: String,
537    /// The port to dial.
538    pub port: u16,
539    /// The network to dial: `tcp`, `udp` and so on. Defaults to `tcp`.
540    #[serde(default)]
541    pub network: Option<String>,
542}
543
544#[derive(Debug, Deserialize, JsonSchema)]
545pub struct Ts2021Params {
546    /// The control-plane host to test against. Defaults to the real one.
547    #[serde(default)]
548    pub host: Option<String>,
549    /// The protocol version to speak. Defaults to the client's own.
550    #[serde(default)]
551    pub version: Option<u32>,
552    /// An ACE server address to offer as a candidate path.
553    #[serde(default)]
554    pub ace: Option<String>,
555    /// A file on the local filesystem holding a dial plan as JSON.
556    #[serde(default)]
557    pub dial_plan: Option<String>,
558    /// Report every step of the exchange rather than the outcome.
559    #[serde(default)]
560    pub verbose: Option<bool>,
561}
562
563/// The client's own default probe length, and the longest this server will hold
564/// a call open for one.
565const DEFAULT_PORTMAP_SECONDS: u64 = 5;
566const MAX_PORTMAP_SECONDS: u64 = 120;
567
568#[derive(Debug, Deserialize, JsonSchema)]
569pub struct PortmapParams {
570    /// How long to probe for, in seconds. Defaults to 5, capped at 120.
571    #[serde(default)]
572    pub duration_seconds: Option<u64>,
573    /// Probe only one protocol: `pmp`, `pcp` or `upnp`. All three by default.
574    #[serde(default)]
575    pub r#type: Option<String>,
576    /// Probe this gateway rather than the one the daemon found. Must be given
577    /// together with `self_addr`.
578    #[serde(default)]
579    pub gateway_addr: Option<String>,
580    /// The address to claim as this node's own. Must be given together with
581    /// `gateway_addr`.
582    #[serde(default)]
583    pub self_addr: Option<String>,
584    /// Report every HTTP request and response the probe made.
585    #[serde(default)]
586    pub log_http: Option<bool>,
587}
588
589/// The client's own default, which is what an unspecified call should do.
590const DEFAULT_COMPONENT_LOG_SECONDS: i64 = 3600;
591
592const fn default_component_log_seconds() -> i64 {
593    DEFAULT_COMPONENT_LOG_SECONDS
594}
595
596#[derive(Debug, Deserialize, JsonSchema)]
597pub struct ComponentLogsParams {
598    /// Which component to log: `magicsock`, `sockstats` or `syspolicy`.
599    pub component: String,
600    /// For how long, in seconds. Zero or negative turns the logging off again.
601    /// Defaults to an hour, which is the client's own default.
602    #[serde(default = "default_component_log_seconds")]
603    pub for_seconds: i64,
604}
605
606#[derive(Debug, Deserialize, JsonSchema)]
607pub struct ForcePreferDerpParams {
608    /// The DERP region to pin the node to, or 0 to un-pin it.
609    pub region_id: i32,
610}
611
612// ---------------------------------------------------------------------------
613// running
614// ---------------------------------------------------------------------------
615
616/// Run a command that prints for a person, and hand back what it printed.
617async fn text(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Value> {
618    let output = cli::run(ctx, meta, invocation).await?;
619    report(TextReport {
620        printed: printed(ctx, &output),
621    })
622}
623
624/// Run a knob and report that the daemon was told.
625async fn knob(
626    ctx: &ToolContext,
627    meta: &ToolMeta,
628    invocation: Invocation,
629    outcome: impl Into<String>,
630) -> ToolResult<Value> {
631    let output = cli::run(ctx, meta, invocation).await?;
632    report(KnobReport {
633        outcome: outcome.into(),
634        printed: printed(ctx, &output),
635    })
636}
637
638// ---------------------------------------------------------------------------
639// readers
640// ---------------------------------------------------------------------------
641
642async fn derp_map(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
643    object(
644        ctx,
645        &metas::tailscale_debug_derp_map,
646        Invocation::read(["debug", "derp-map"]),
647    )
648    .await
649}
650
651async fn netmap(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
652    object(
653        ctx,
654        &metas::tailscale_debug_netmap,
655        Invocation::read(["debug", "netmap"]),
656    )
657    .await
658}
659
660async fn hostinfo(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
661    // The TPM line this prints goes to standard error, which `object` does not
662    // parse, so the document survives it.
663    object(
664        ctx,
665        &metas::tailscale_debug_hostinfo,
666        Invocation::read(["debug", "hostinfo"]),
667    )
668    .await
669}
670
671async fn control_knobs(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
672    object(
673        ctx,
674        &metas::tailscale_debug_control_knobs,
675        Invocation::read(["debug", "control-knobs"]),
676    )
677    .await
678}
679
680async fn daemon_goroutines(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
681    text(
682        ctx,
683        &metas::tailscale_debug_daemon_goroutines,
684        Invocation::read(["debug", "daemon-goroutines"]),
685    )
686    .await
687}
688
689async fn daemon_bus_graph(ctx: &ToolContext, params: DaemonBusGraphParams) -> ToolResult<Value> {
690    let meta = &metas::tailscale_debug_daemon_bus_graph;
691    let format = params.format.unwrap_or(BusGraphFormat::Json);
692    let invocation = Invocation::read([
693        "debug".to_owned(),
694        "daemon-bus-graph".to_owned(),
695        format!("--format={}", format.as_str()),
696    ]);
697    match format {
698        BusGraphFormat::Json => {
699            let graph = document(ctx, meta, invocation).await?;
700            report(BusGraphReport {
701                format: format.as_str().to_owned(),
702                graph: Some(graph),
703                printed: None,
704            })
705        }
706        BusGraphFormat::Dot => {
707            let output = cli::run(ctx, meta, invocation).await?;
708            report(BusGraphReport {
709                format: format.as_str().to_owned(),
710                graph: None,
711                printed: Some(output.stdout_str().trim_end().to_owned()),
712            })
713        }
714    }
715}
716
717async fn daemon_bus_queues(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
718    object(
719        ctx,
720        &metas::tailscale_debug_daemon_bus_queues,
721        Invocation::read(["debug", "daemon-bus-queues"]),
722    )
723    .await
724}
725
726async fn metrics(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
727    // Prometheus text, not JSON, and deliberately not parsed here: the daemon's
728    // internal metric names are the least stable thing in a toolset that is
729    // already unstable, and `tailscale_metrics_print` is the parsed one.
730    text(
731        ctx,
732        &metas::tailscale_debug_metrics,
733        Invocation::read(["debug", "metrics"]),
734    )
735    .await
736}
737
738async fn statedir(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
739    let path = cli::run_text(
740        ctx,
741        &metas::tailscale_debug_statedir,
742        Invocation::read(["debug", "statedir"]),
743    )
744    .await?;
745    report(PathReport {
746        path: path.trim().to_owned(),
747    })
748}
749
750async fn go_buildinfo(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
751    object(
752        ctx,
753        &metas::tailscale_debug_go_buildinfo,
754        Invocation::read(["debug", "go-buildinfo"]),
755    )
756    .await
757}
758
759async fn peer_relay_servers(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
760    object(
761        ctx,
762        &metas::tailscale_debug_peer_relay_servers,
763        Invocation::read(["debug", "peer-relay-servers"]),
764    )
765    .await
766}
767
768async fn peer_relay_sessions(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
769    text(
770        ctx,
771        &metas::tailscale_debug_peer_relay_sessions,
772        Invocation::read(["debug", "peer-relay-sessions"]),
773    )
774    .await
775}
776
777async fn file_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
778    // An empty inbox prints `null`, which is a document but not a list. The
779    // caller asked what is waiting, and "nothing" is a list of nothing.
780    let value = document(
781        ctx,
782        &metas::tailscale_debug_file_list,
783        Invocation::read(["debug", "--file=get"]),
784    )
785    .await?;
786    let files = if value.is_null() {
787        Value::Array(Vec::new())
788    } else {
789        value
790    };
791    Ok(json!({ "files": files }))
792}
793
794async fn stat(ctx: &ToolContext, params: StatParams) -> ToolResult<Value> {
795    let meta = &metas::tailscale_debug_stat;
796    if params.paths.is_empty() {
797        return Err(ToolError::invalid_args(
798            "`paths` needs at least one path to report on",
799        ));
800    }
801    let mut args = vec!["debug".to_owned(), "stat".to_owned()];
802    for path in &params.paths {
803        args.push(real_path(ctx, "paths", path)?);
804    }
805    let output = cli::run(ctx, meta, Invocation::read(args)).await?;
806    report(StatReport {
807        entries: output.stdout_str().lines().filter_map(parse_stat).collect(),
808        printed: printed(ctx, &output),
809    })
810}
811
812/// One `path: mode, size` line.
813///
814/// Split from the right, because a path may hold a colon and a comma but the
815/// mode and size that follow it may not.
816fn parse_stat(line: &str) -> Option<StatEntry> {
817    let (path, rest) = line.trim().rsplit_once(": ")?;
818    let (mode, size) = rest.rsplit_once(", ")?;
819    Some(StatEntry {
820        path: path.to_owned(),
821        mode: mode.to_owned(),
822        size: size.to_owned(),
823    })
824}
825
826async fn via(ctx: &ToolContext, params: ViaParams) -> ToolResult<Value> {
827    let meta = &metas::tailscale_debug_via;
828    // One positional, two meanings, and the client tells them apart by how many
829    // words follow. Refusing the mixtures here means the caller learns which
830    // conversion it asked for rather than reading an argument-count error.
831    let args = match (
832        params.site_id,
833        params.prefix.as_deref(),
834        params.route.as_deref(),
835    ) {
836        (Some(site_id), Some(prefix), None) => {
837            vec![
838                "debug".to_owned(),
839                "via".to_owned(),
840                site_id.to_string(),
841                prefix.to_owned(),
842            ]
843        }
844        (None, None, Some(route)) => {
845            vec!["debug".to_owned(), "via".to_owned(), route.to_owned()]
846        }
847        (None, None, None) => {
848            return Err(ToolError::invalid_args(
849                "give `site_id` and `prefix` to build a route, or `route` to take one apart",
850            ));
851        }
852        _ => {
853            return Err(ToolError::invalid_args(
854                "`site_id` and `prefix` go together and neither goes with `route`",
855            ));
856        }
857    };
858    let text = cli::run_text(ctx, meta, Invocation::read(args)).await?;
859    report(ViaReport {
860        converted: ctx.redactor.apply(text.trim()).into_owned(),
861    })
862}
863
864async fn watch_ipn(ctx: &ToolContext, params: WatchIpnParams) -> ToolResult<Value> {
865    let meta = &metas::tailscale_debug_watch_ipn;
866    if params.count == 0 {
867        return Err(ToolError::invalid_args(
868            "`count` must be at least 1: the client reads zero as \"never stop\"",
869        ));
870    }
871    let count = params.count.min(MAX_EVENTS);
872    let (seconds, bound) = bounded_wait(
873        params.timeout_seconds,
874        DEFAULT_WATCH_SECONDS,
875        MAX_WATCH_SECONDS,
876    );
877
878    let mut args = vec![
879        "debug".to_owned(),
880        "watch-ipn".to_owned(),
881        format!("--count={count}"),
882    ];
883    push_bool(&mut args, "engine-updates", params.engine_updates);
884    push_bool(&mut args, "health-actions", params.health_actions);
885    push_bool(&mut args, "peer-changes", params.peer_changes);
886    push_bool(&mut args, "peer-patches", params.peer_patches);
887    push_bool(
888        &mut args,
889        "peer-wireguard-state",
890        params.peer_wireguard_state,
891    );
892    push_bool(
893        &mut args,
894        "initial-client-version",
895        params.initial_client_version,
896    );
897    push_bool(
898        &mut args,
899        "initial-drive-shares",
900        params.initial_drive_shares,
901    );
902    push_bool(&mut args, "initial-health", params.initial_health);
903    push_bool(
904        &mut args,
905        "initial-outgoing-files",
906        params.initial_outgoing_files,
907    );
908    push_bool(&mut args, "initial-status", params.initial_status);
909    push_bool(
910        &mut args,
911        "initial-suggested-exit-node",
912        params.initial_suggested_exit_node,
913    );
914
915    let output = cli::run(ctx, meta, Invocation::read(args).with_timeout(bound)).await?;
916    report(WatchReport {
917        events: notifications(&output.stdout_str()),
918        asked_for: count,
919        seconds,
920        note: note(ctx, &output.stderr),
921    })
922}
923
924/// The notifications in a `watch-ipn` stream.
925///
926/// The command prints one pretty JSON object per notification, one after
927/// another with nothing between them, so this is not a document and
928/// `serde_json::from_str` refuses it. Reading the stream value by value is what
929/// the format actually is.
930fn notifications(stdout: &str) -> Vec<Value> {
931    serde_json::Deserializer::from_str(stdout)
932        .into_iter::<Value>()
933        .map_while(Result::ok)
934        .collect()
935}
936
937async fn peer_endpoint_changes(ctx: &ToolContext, params: PeerParams) -> ToolResult<Value> {
938    object(
939        ctx,
940        &metas::tailscale_debug_peer_endpoint_changes,
941        Invocation::read(["debug", "peer-endpoint-changes", &params.peer]),
942    )
943    .await
944}
945
946async fn resolve(ctx: &ToolContext, params: ResolveParams) -> ToolResult<Value> {
947    let mut args = vec![
948        "debug".to_owned(),
949        "resolve".to_owned(),
950        params.host.clone(),
951    ];
952    push_text(&mut args, "net", params.net.as_deref());
953    let output = cli::run(ctx, &metas::tailscale_debug_resolve, Invocation::read(args)).await?;
954    report(AddressReport {
955        addresses: output
956            .stdout_str()
957            .lines()
958            .map(str::trim)
959            .filter(|l| !l.is_empty())
960            .map(str::to_owned)
961            .collect(),
962        printed: printed(ctx, &output),
963    })
964}
965
966async fn dial_types(ctx: &ToolContext, params: DialTypesParams) -> ToolResult<Value> {
967    let mut args = vec![
968        "debug".to_owned(),
969        "dial-types".to_owned(),
970        params.host.clone(),
971        params.port.to_string(),
972    ];
973    push_text(&mut args, "network", params.network.as_deref());
974    text(
975        ctx,
976        &metas::tailscale_debug_dial_types,
977        Invocation::read(args),
978    )
979    .await
980}
981
982async fn derp(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
983    text(
984        ctx,
985        &metas::tailscale_debug_derp,
986        Invocation::read(["debug", "derp"]),
987    )
988    .await
989}
990
991async fn ts2021(ctx: &ToolContext, params: Ts2021Params) -> ToolResult<Value> {
992    let mut args = vec!["debug".to_owned(), "ts2021".to_owned()];
993    push_text(&mut args, "host", params.host.as_deref());
994    push_text(&mut args, "ace", params.ace.as_deref());
995    if let Some(path) = params.dial_plan.as_deref() {
996        args.push(format!(
997            "--dial-plan={}",
998            real_path(ctx, "dial_plan", path)?
999        ));
1000    }
1001    if let Some(version) = params.version {
1002        args.push(format!("--version={version}"));
1003    }
1004    push_bool(&mut args, "verbose", params.verbose);
1005    text(ctx, &metas::tailscale_debug_ts2021, Invocation::read(args)).await
1006}
1007
1008async fn portmap(ctx: &ToolContext, params: PortmapParams) -> ToolResult<Value> {
1009    let meta = &metas::tailscale_debug_portmap;
1010    // The client's own note: neither override means anything without the other,
1011    // and passing one alone probes a network that does not exist.
1012    if params.gateway_addr.is_some() != params.self_addr.is_some() {
1013        return Err(ToolError::invalid_args(
1014            "`gateway_addr` and `self_addr` override the probe's view of the network \
1015             together, so give both or neither",
1016        ));
1017    }
1018    let (seconds, bound) = bounded_wait(
1019        params.duration_seconds,
1020        DEFAULT_PORTMAP_SECONDS,
1021        MAX_PORTMAP_SECONDS,
1022    );
1023    let mut args = vec![
1024        "debug".to_owned(),
1025        "portmap".to_owned(),
1026        format!("--duration={seconds}s"),
1027    ];
1028    push_text(&mut args, "type", params.r#type.as_deref());
1029    push_text(&mut args, "gateway-addr", params.gateway_addr.as_deref());
1030    push_text(&mut args, "self-addr", params.self_addr.as_deref());
1031    push_bool(&mut args, "log-http", params.log_http);
1032    text(ctx, meta, Invocation::read(args).with_timeout(bound)).await
1033}
1034
1035// ---------------------------------------------------------------------------
1036// knobs
1037// ---------------------------------------------------------------------------
1038
1039async fn component_logs(ctx: &ToolContext, params: ComponentLogsParams) -> ToolResult<Value> {
1040    let seconds = params.for_seconds;
1041    let outcome = if seconds > 0 {
1042        format!(
1043            "verbose logging for `{}` is on for the next {seconds}s",
1044            params.component
1045        )
1046    } else {
1047        format!("verbose logging for `{}` is off", params.component)
1048    };
1049    knob(
1050        ctx,
1051        &metas::tailscale_debug_component_logs,
1052        Invocation::mutate_shared([
1053            "debug".to_owned(),
1054            "component-logs".to_owned(),
1055            format!("--for={seconds}s"),
1056            params.component.clone(),
1057        ]),
1058        outcome,
1059    )
1060    .await
1061}
1062
1063async fn restun(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1064    knob(
1065        ctx,
1066        &metas::tailscale_debug_restun,
1067        Invocation::mutate_shared(["debug", "restun"]),
1068        "the node was told to re-learn its own addresses",
1069    )
1070    .await
1071}
1072
1073async fn rebind(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1074    knob(
1075        ctx,
1076        &metas::tailscale_debug_rebind,
1077        Invocation::mutate_shared(["debug", "rebind"]),
1078        "the node was told to rebind its sockets",
1079    )
1080    .await
1081}
1082
1083async fn rotate_disco_key(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1084    knob(
1085        ctx,
1086        &metas::tailscale_debug_rotate_disco_key,
1087        Invocation::mutate_shared(["debug", "rotate-disco-key"]),
1088        "the node's disco key was rotated; peers will rebuild their direct paths",
1089    )
1090    .await
1091}
1092
1093async fn derp_unset_on_demand(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1094    knob(
1095        ctx,
1096        &metas::tailscale_debug_derp_unset_on_demand,
1097        Invocation::mutate_shared(["debug", "derp-unset-on-demand"]),
1098        "relay connections are back to always-on",
1099    )
1100    .await
1101}
1102
1103async fn pick_new_derp(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1104    knob(
1105        ctx,
1106        &metas::tailscale_debug_pick_new_derp,
1107        Invocation::mutate_shared(["debug", "pick-new-derp"]),
1108        "the node was moved to another home relay region until it restarts",
1109    )
1110    .await
1111}
1112
1113async fn force_prefer_derp(ctx: &ToolContext, params: ForcePreferDerpParams) -> ToolResult<Value> {
1114    let outcome = if params.region_id == 0 {
1115        "the node is free to choose its own home relay region again".to_owned()
1116    } else {
1117        format!(
1118            "the node prefers relay region {} until it restarts",
1119            params.region_id
1120        )
1121    };
1122    knob(
1123        ctx,
1124        &metas::tailscale_debug_force_prefer_derp,
1125        Invocation::mutate_shared([
1126            "debug".to_owned(),
1127            "force-prefer-derp".to_owned(),
1128            params.region_id.to_string(),
1129        ]),
1130        outcome,
1131    )
1132    .await
1133}
1134
1135async fn force_netmap_update(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
1136    knob(
1137        ctx,
1138        &metas::tailscale_debug_force_netmap_update,
1139        Invocation::mutate_shared(["debug", "force-netmap-update"]),
1140        "a full netmap update was pushed through the daemon",
1141    )
1142    .await
1143}
1144
1145#[allow(clippy::unwrap_used, clippy::expect_used)]
1146#[cfg(test)]
1147mod tests {
1148    use std::collections::BTreeSet;
1149    use std::sync::Arc;
1150    use std::time::Duration;
1151
1152    use super::*;
1153
1154    use crate::error::ErrorCode;
1155    use crate::testing::{Reply, StubBackend, context};
1156
1157    /// Run a handler against a scripted client and report both what it answered
1158    /// and what it ran.
1159    async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
1160    where
1161        F: FnOnce(ToolContext, P) -> Fut,
1162        Fut: std::future::Future<Output = ToolResult<Value>>,
1163    {
1164        let backend = Arc::new(StubBackend::always(reply));
1165        let ctx = context(Arc::clone(&backend));
1166        let value = handler(ctx, params).await.expect("the handler succeeds");
1167        (value, backend.argv())
1168    }
1169
1170    /// Run a handler that is expected to refuse, and report the refusal.
1171    async fn refusal<F, P, Fut>(reply: Reply, handler: F, params: P) -> ToolError
1172    where
1173        F: FnOnce(ToolContext, P) -> Fut,
1174        Fut: std::future::Future<Output = ToolResult<Value>>,
1175    {
1176        let backend = Arc::new(StubBackend::always(reply));
1177        let ctx = context(backend);
1178        handler(ctx, params).await.expect_err("the handler refuses")
1179    }
1180
1181    fn watching(count: u32, timeout_seconds: Option<u64>) -> WatchIpnParams {
1182        WatchIpnParams {
1183            count,
1184            timeout_seconds,
1185            engine_updates: None,
1186            health_actions: None,
1187            peer_changes: None,
1188            peer_patches: None,
1189            peer_wireguard_state: None,
1190            initial_client_version: None,
1191            initial_drive_shares: None,
1192            initial_health: None,
1193            initial_outgoing_files: None,
1194            initial_status: None,
1195            initial_suggested_exit_node: None,
1196        }
1197    }
1198
1199    #[test]
1200    fn the_excluded_list_is_complete_and_says_why() {
1201        assert_eq!(
1202            EXCLUDED.len(),
1203            14,
1204            "fourteen of the forty-four are excluded"
1205        );
1206        let mut paths = BTreeSet::new();
1207        for excluded in EXCLUDED {
1208            assert!(
1209                excluded.path.starts_with("debug "),
1210                "`{}` is not a debug subcommand",
1211                excluded.path
1212            );
1213            assert!(
1214                excluded.reason.len() > 20,
1215                "`{}` is excluded without saying why",
1216                excluded.path
1217            );
1218            assert!(
1219                paths.insert(excluded.path),
1220                "`{}` is excluded twice",
1221                excluded.path
1222            );
1223        }
1224    }
1225
1226    #[test]
1227    fn a_stat_line_splits_from_the_right() {
1228        let entry = parse_stat("/etc/hosts: -rw-r--r--, 213").expect("a well-formed line");
1229        assert_eq!(entry.path, "/etc/hosts");
1230        assert_eq!(entry.mode, "-rw-r--r--");
1231        assert_eq!(entry.size, "213");
1232
1233        // A path may hold both separators; the mode and size that follow may not.
1234        let odd = parse_stat("/tmp/a: b, c/file: -rw-------, 0").expect("a path with separators");
1235        assert_eq!(odd.path, "/tmp/a: b, c/file");
1236        assert_eq!(odd.size, "0");
1237
1238        assert!(parse_stat("something else entirely").is_none());
1239    }
1240
1241    #[test]
1242    fn concatenated_notifications_are_read_one_at_a_time() {
1243        // What `watch-ipn` actually prints: whole objects, pretty, with nothing
1244        // between them. A document parser refuses the second one.
1245        let stream = "{\n\t\"Version\": \"1.102.2\"\n}\n{\n\t\"Version\": \"1.102.2\"\n}\n";
1246        assert!(serde_json::from_str::<Value>(stream).is_err());
1247
1248        let events = notifications(stream);
1249        assert_eq!(events.len(), 2);
1250        assert_eq!(events[0]["Version"], "1.102.2");
1251    }
1252
1253    #[test]
1254    fn a_half_written_notification_is_dropped_rather_than_losing_the_rest() {
1255        let stream = "{\"a\": 1}\n{\"b\": 2}\n{\"c\":";
1256        let events = notifications(stream);
1257        assert_eq!(
1258            events.len(),
1259            2,
1260            "the whole objects survive the truncated one"
1261        );
1262    }
1263
1264    // -- the event watcher always returns ------------------------------------
1265
1266    #[tokio::test]
1267    async fn the_watcher_honours_its_count_and_its_cap() {
1268        let (value, argv) = against(
1269            Reply::ok("{\"Version\":\"1.102.2\"}\n"),
1270            |ctx, p| async move { watch_ipn(&ctx, p).await },
1271            watching(3, None),
1272        )
1273        .await;
1274        assert_eq!(argv, [["debug", "watch-ipn", "--count=3"]]);
1275        assert_eq!(value["asked_for"], 3);
1276        assert_eq!(value["seconds"], DEFAULT_WATCH_SECONDS);
1277        assert_eq!(value["events"].as_array().map(Vec::len), Some(1));
1278
1279        let (value, argv) = against(
1280            Reply::ok(""),
1281            |ctx, p| async move { watch_ipn(&ctx, p).await },
1282            watching(10_000, Some(10_000)),
1283        )
1284        .await;
1285        assert_eq!(
1286            argv,
1287            [["debug", "watch-ipn", &format!("--count={MAX_EVENTS}")]]
1288        );
1289        assert_eq!(value["asked_for"], MAX_EVENTS);
1290        assert_eq!(value["seconds"], MAX_WATCH_SECONDS);
1291    }
1292
1293    #[tokio::test]
1294    async fn the_watcher_and_the_portmap_probe_bound_the_process_too() {
1295        let backend = Arc::new(StubBackend::always(Reply::ok("")));
1296        let ctx = context(Arc::clone(&backend));
1297        watch_ipn(&ctx, watching(3, None))
1298            .await
1299            .expect("the watcher succeeds");
1300        portmap(
1301            &ctx,
1302            PortmapParams {
1303                duration_seconds: None,
1304                r#type: None,
1305                gateway_addr: None,
1306                self_addr: None,
1307                log_http: None,
1308            },
1309        )
1310        .await
1311        .expect("the probe succeeds");
1312        let bounds: Vec<Duration> = backend.calls().iter().map(|call| call.timeout).collect();
1313        assert_eq!(
1314            bounds,
1315            [
1316                Duration::from_secs(DEFAULT_WATCH_SECONDS + 5),
1317                Duration::from_secs(DEFAULT_PORTMAP_SECONDS + 5)
1318            ]
1319        );
1320    }
1321
1322    // -- what the rest of them run -------------------------------------------
1323
1324    #[tokio::test]
1325    async fn the_bus_graph_reports_a_document_or_a_drawing_but_never_both() {
1326        let (value, argv) = against(
1327            Reply::ok(r#"{"nodes":[]}"#),
1328            |ctx, p| async move { daemon_bus_graph(&ctx, p).await },
1329            DaemonBusGraphParams { format: None },
1330        )
1331        .await;
1332        assert_eq!(argv, [["debug", "daemon-bus-graph", "--format=json"]]);
1333        assert_eq!(value["format"], "json");
1334        assert!(value["graph"].is_object());
1335        assert!(value.get("printed").is_none());
1336
1337        let (value, argv) = against(
1338            Reply::ok("digraph {}\n"),
1339            |ctx, p| async move { daemon_bus_graph(&ctx, p).await },
1340            DaemonBusGraphParams {
1341                format: Some(BusGraphFormat::Dot),
1342            },
1343        )
1344        .await;
1345        assert_eq!(argv, [["debug", "daemon-bus-graph", "--format=dot"]]);
1346        assert_eq!(value["format"], "dot");
1347        assert_eq!(value["printed"], "digraph {}");
1348        assert!(value.get("graph").is_none());
1349    }
1350
1351    #[tokio::test]
1352    async fn an_empty_inbox_is_a_list_of_nothing_rather_than_a_null() {
1353        let (value, argv) = against(
1354            Reply::ok("null\n"),
1355            |ctx, p| async move { file_list(&ctx, p).await },
1356            NoParams {},
1357        )
1358        .await;
1359        assert_eq!(argv, [["debug", "--file=get"]]);
1360        assert_eq!(value["files"], json!([]));
1361    }
1362
1363    #[tokio::test]
1364    async fn the_two_conversions_via_offers_are_told_apart_before_anything_runs() {
1365        let (value, argv) = against(
1366            Reply::ok("fd7a:115c:a1e0:b1a:0:7:a01:0/112\n"),
1367            |ctx, p| async move { via(&ctx, p).await },
1368            ViaParams {
1369                site_id: Some(7),
1370                prefix: Some("10.1.0.0/16".to_owned()),
1371                route: None,
1372            },
1373        )
1374        .await;
1375        assert_eq!(argv, [["debug", "via", "7", "10.1.0.0/16"]]);
1376        assert_eq!(value["converted"], "fd7a:115c:a1e0:b1a:0:7:a01:0/112");
1377
1378        let (_, argv) = against(
1379            Reply::ok("site 7 (0x7), 10.1.0.0/16\n"),
1380            |ctx, p| async move { via(&ctx, p).await },
1381            ViaParams {
1382                site_id: None,
1383                prefix: None,
1384                route: Some("fd7a:115c:a1e0:b1a:0:7:a01:0/112".to_owned()),
1385            },
1386        )
1387        .await;
1388        assert_eq!(argv, [["debug", "via", "fd7a:115c:a1e0:b1a:0:7:a01:0/112"]]);
1389
1390        for mixture in [
1391            ViaParams {
1392                site_id: Some(7),
1393                prefix: None,
1394                route: None,
1395            },
1396            ViaParams {
1397                site_id: Some(7),
1398                prefix: Some("10.1.0.0/16".to_owned()),
1399                route: Some("fd7a::/112".to_owned()),
1400            },
1401            ViaParams {
1402                site_id: None,
1403                prefix: None,
1404                route: None,
1405            },
1406        ] {
1407            let error = refusal(
1408                Reply::ok(""),
1409                |ctx, p| async move { via(&ctx, p).await },
1410                mixture,
1411            )
1412            .await;
1413            assert_eq!(error.code, ErrorCode::InvalidArgs);
1414        }
1415    }
1416
1417    #[tokio::test]
1418    async fn the_portmap_overrides_are_refused_one_at_a_time() {
1419        // The client says each override "must also pass" the other, so a lone
1420        // one describes a network that does not exist.
1421        for lonely in [
1422            PortmapParams {
1423                duration_seconds: None,
1424                r#type: None,
1425                gateway_addr: Some("192.0.2.1".to_owned()),
1426                self_addr: None,
1427                log_http: None,
1428            },
1429            PortmapParams {
1430                duration_seconds: None,
1431                r#type: None,
1432                gateway_addr: None,
1433                self_addr: Some("192.0.2.2".to_owned()),
1434                log_http: None,
1435            },
1436        ] {
1437            let error = refusal(
1438                Reply::ok(""),
1439                |ctx, p| async move { portmap(&ctx, p).await },
1440                lonely,
1441            )
1442            .await;
1443            assert_eq!(error.code, ErrorCode::InvalidArgs);
1444        }
1445
1446        let (value, argv) = against(
1447            Reply::ok("portmapper: no port mapping services were found\n"),
1448            |ctx, p| async move { portmap(&ctx, p).await },
1449            PortmapParams {
1450                duration_seconds: Some(10_000),
1451                r#type: Some("upnp".to_owned()),
1452                gateway_addr: Some("192.0.2.1".to_owned()),
1453                self_addr: Some("192.0.2.2".to_owned()),
1454                log_http: Some(false),
1455            },
1456        )
1457        .await;
1458        assert_eq!(
1459            argv,
1460            [[
1461                "debug",
1462                "portmap",
1463                &format!("--duration={MAX_PORTMAP_SECONDS}s"),
1464                "--type=upnp",
1465                "--gateway-addr=192.0.2.1",
1466                "--self-addr=192.0.2.2",
1467                "--log-http=false"
1468            ]]
1469        );
1470        assert!(
1471            value["printed"]
1472                .as_str()
1473                .is_some_and(|p| p.contains("portmapper"))
1474        );
1475    }
1476
1477    #[tokio::test]
1478    async fn turning_component_logging_off_says_so_rather_than_saying_how_long_for() {
1479        let (value, argv) = against(
1480            Reply::ok(""),
1481            |ctx, p| async move { component_logs(&ctx, p).await },
1482            ComponentLogsParams {
1483                component: "magicsock".to_owned(),
1484                for_seconds: default_component_log_seconds(),
1485            },
1486        )
1487        .await;
1488        assert_eq!(
1489            argv,
1490            [[
1491                "debug",
1492                "component-logs",
1493                &format!("--for={DEFAULT_COMPONENT_LOG_SECONDS}s"),
1494                "magicsock"
1495            ]]
1496        );
1497        assert!(
1498            value["outcome"]
1499                .as_str()
1500                .is_some_and(|o| o.contains("is on"))
1501        );
1502
1503        let (value, argv) = against(
1504            Reply::ok(""),
1505            |ctx, p| async move { component_logs(&ctx, p).await },
1506            ComponentLogsParams {
1507                component: "magicsock".to_owned(),
1508                for_seconds: 0,
1509            },
1510        )
1511        .await;
1512        assert_eq!(argv, [["debug", "component-logs", "--for=0s", "magicsock"]]);
1513        assert_eq!(value["outcome"], "verbose logging for `magicsock` is off");
1514    }
1515
1516    #[tokio::test]
1517    async fn pinning_a_relay_region_and_letting_it_go_read_differently() {
1518        let (value, argv) = against(
1519            Reply::ok(""),
1520            |ctx, p| async move { force_prefer_derp(&ctx, p).await },
1521            ForcePreferDerpParams { region_id: 0 },
1522        )
1523        .await;
1524        assert_eq!(argv, [["debug", "force-prefer-derp", "0"]]);
1525        assert!(
1526            value["outcome"]
1527                .as_str()
1528                .is_some_and(|o| o.contains("free to choose"))
1529        );
1530
1531        let (value, _) = against(
1532            Reply::ok(""),
1533            |ctx, p| async move { force_prefer_derp(&ctx, p).await },
1534            ForcePreferDerpParams { region_id: 2 },
1535        )
1536        .await;
1537        assert!(
1538            value["outcome"]
1539                .as_str()
1540                .is_some_and(|o| o.contains("region 2"))
1541        );
1542    }
1543
1544    #[tokio::test]
1545    async fn stat_refuses_an_empty_list_and_a_stream() {
1546        let error = refusal(
1547            Reply::ok(""),
1548            |ctx, p| async move { stat(&ctx, p).await },
1549            StatParams { paths: Vec::new() },
1550        )
1551        .await;
1552        assert_eq!(error.code, ErrorCode::InvalidArgs);
1553
1554        // `-` is the client's spelling of standard output, which a tool call
1555        // does not have.
1556        let error = refusal(
1557            Reply::ok(""),
1558            |ctx, p| async move { stat(&ctx, p).await },
1559            StatParams {
1560                paths: vec!["-".to_owned()],
1561            },
1562        )
1563        .await;
1564        assert_eq!(error.code, ErrorCode::InvalidArgs);
1565    }
1566}