use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
use tailscale_cli::Invocation;
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ToolError, ToolResult};
use crate::meta::ToolMeta;
use crate::tools::common::{
Excluded, bounded_wait, document, note, object, printed, push_bool, push_text, real_path,
report,
};
crate::tools! {
tailscale_debug_derp_map => NoParams, derp_map,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_netmap => NoParams, netmap,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_hostinfo => NoParams, hostinfo,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_control_knobs => NoParams, control_knobs,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_daemon_goroutines => NoParams, daemon_goroutines,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_daemon_bus_graph => DaemonBusGraphParams, daemon_bus_graph,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_daemon_bus_queues => NoParams, daemon_bus_queues,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_metrics => NoParams, metrics,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_statedir => NoParams, statedir,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_go_buildinfo => NoParams, go_buildinfo,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_peer_relay_servers => NoParams, peer_relay_servers,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_peer_relay_sessions => NoParams, peer_relay_sessions,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_file_list => NoParams, file_list,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_stat => StatParams, stat,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_via => ViaParams, via,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_watch_ipn => WatchIpnParams, watch_ipn,
toolset: LocalDebug, tier: Read;
tailscale_debug_peer_endpoint_changes => PeerParams, peer_endpoint_changes,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_resolve => ResolveParams, resolve,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_dial_types => DialTypesParams, dial_types,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_derp => NoParams, derp,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_ts2021 => Ts2021Params, ts2021,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_portmap => PortmapParams, portmap,
toolset: LocalDebug, tier: Read, idempotent: true;
tailscale_debug_component_logs => ComponentLogsParams, component_logs,
toolset: LocalDebug, tier: Write;
tailscale_debug_restun => NoParams, restun,
toolset: LocalDebug, tier: Write, idempotent: true;
tailscale_debug_rebind => NoParams, rebind,
toolset: LocalDebug, tier: Write, idempotent: true;
tailscale_debug_rotate_disco_key => NoParams, rotate_disco_key,
toolset: LocalDebug, tier: Write;
tailscale_debug_derp_unset_on_demand => NoParams, derp_unset_on_demand,
toolset: LocalDebug, tier: Write, idempotent: true;
tailscale_debug_pick_new_derp => NoParams, pick_new_derp,
toolset: LocalDebug, tier: Write;
tailscale_debug_force_prefer_derp => ForcePreferDerpParams, force_prefer_derp,
toolset: LocalDebug, tier: Write, idempotent: true;
tailscale_debug_force_netmap_update => NoParams, force_netmap_update,
toolset: LocalDebug, tier: Write, idempotent: true;
}
pub const EXCLUDED: &[Excluded] = &[
Excluded {
path: "debug prefs",
reason: "it prints the node's private keys along with its preferences; \
`tailscale_debug_netmap` and the preference tools report the rest",
},
Excluded {
path: "debug local-creds",
reason: "it prints the credential for reaching tailscaled's private HTTP \
interface, which this server never uses",
},
Excluded {
path: "debug env",
reason: "it prints the whole process environment, which is where secrets \
for other services live",
},
Excluded {
path: "debug localapi",
reason: "it calls tailscaled's private HTTP interface directly, which this \
server does not do at all",
},
Excluded {
path: "debug daemon-logs",
reason: "it streams the daemon log until interrupted and has no bounded form",
},
Excluded {
path: "debug daemon-bus-events",
reason: "it streams bus events until interrupted and has no bounded form",
},
Excluded {
path: "debug capture",
reason: "it streams a packet capture of tunnel traffic until interrupted, \
and launches a separate application when given no path",
},
Excluded {
path: "debug test-risk",
reason: "it exists to test the client's interactive risk prompt, which a \
tool call cannot answer",
},
Excluded {
path: "debug set-expire",
reason: "it expires the local node's key, cutting this server off from the \
tailnet; re-authenticate deliberately instead",
},
Excluded {
path: "debug break-tcp-conns",
reason: "its purpose is to break the daemon's connections",
},
Excluded {
path: "debug break-derp-conns",
reason: "its purpose is to break the daemon's relay connections",
},
Excluded {
path: "debug derp-set-on-demand",
reason: "it makes the node reachable only after a delay; \
`tailscale_debug_derp_unset_on_demand` undoes it if something else did it",
},
Excluded {
path: "debug clear-netmap-cache",
reason: "it deletes the cached netmaps the node falls back on when the \
control plane is unreachable",
},
Excluded {
path: "debug dev-store-set",
reason: "it writes directly into tailscaled's state store, behind every \
check the rest of the client makes",
},
];
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TextReport {
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatEntry {
pub path: String,
pub mode: String,
pub size: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct StatReport {
pub entries: Vec<StatEntry>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct AddressReport {
pub addresses: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct PathReport {
pub path: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct ViaReport {
pub converted: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct BusGraphReport {
pub format: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub graph: Option<Value>,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct WatchReport {
pub events: Vec<Value>,
pub asked_for: u32,
pub seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct KnobReport {
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DaemonBusGraphParams {
#[serde(default)]
pub format: Option<BusGraphFormat>,
}
#[derive(Debug, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "lowercase")]
pub enum BusGraphFormat {
Json,
Dot,
}
impl BusGraphFormat {
const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Dot => "dot",
}
}
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct StatParams {
pub paths: Vec<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ViaParams {
#[serde(default)]
pub site_id: Option<u32>,
#[serde(default)]
pub prefix: Option<String>,
#[serde(default)]
pub route: Option<String>,
}
const DEFAULT_WATCH_SECONDS: u64 = 30;
const MAX_WATCH_SECONDS: u64 = 300;
const MAX_EVENTS: u32 = 100;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct WatchIpnParams {
pub count: u32,
#[serde(default)]
pub timeout_seconds: Option<u64>,
#[serde(default)]
pub engine_updates: Option<bool>,
#[serde(default)]
pub health_actions: Option<bool>,
#[serde(default)]
pub peer_changes: Option<bool>,
#[serde(default)]
pub peer_patches: Option<bool>,
#[serde(default)]
pub peer_wireguard_state: Option<bool>,
#[serde(default)]
pub initial_client_version: Option<bool>,
#[serde(default)]
pub initial_drive_shares: Option<bool>,
#[serde(default)]
pub initial_health: Option<bool>,
#[serde(default)]
pub initial_outgoing_files: Option<bool>,
#[serde(default)]
pub initial_status: Option<bool>,
#[serde(default)]
pub initial_suggested_exit_node: Option<bool>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PeerParams {
pub peer: String,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ResolveParams {
pub host: String,
#[serde(default)]
pub net: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct DialTypesParams {
pub host: String,
pub port: u16,
#[serde(default)]
pub network: Option<String>,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct Ts2021Params {
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub version: Option<u32>,
#[serde(default)]
pub ace: Option<String>,
#[serde(default)]
pub dial_plan: Option<String>,
#[serde(default)]
pub verbose: Option<bool>,
}
const DEFAULT_PORTMAP_SECONDS: u64 = 5;
const MAX_PORTMAP_SECONDS: u64 = 120;
#[derive(Debug, Deserialize, JsonSchema)]
pub struct PortmapParams {
#[serde(default)]
pub duration_seconds: Option<u64>,
#[serde(default)]
pub r#type: Option<String>,
#[serde(default)]
pub gateway_addr: Option<String>,
#[serde(default)]
pub self_addr: Option<String>,
#[serde(default)]
pub log_http: Option<bool>,
}
const DEFAULT_COMPONENT_LOG_SECONDS: i64 = 3600;
const fn default_component_log_seconds() -> i64 {
DEFAULT_COMPONENT_LOG_SECONDS
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ComponentLogsParams {
pub component: String,
#[serde(default = "default_component_log_seconds")]
pub for_seconds: i64,
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct ForcePreferDerpParams {
pub region_id: i32,
}
async fn text(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Value> {
let output = cli::run(ctx, meta, invocation).await?;
report(TextReport {
printed: printed(ctx, &output),
})
}
async fn knob(
ctx: &ToolContext,
meta: &ToolMeta,
invocation: Invocation,
outcome: impl Into<String>,
) -> ToolResult<Value> {
let output = cli::run(ctx, meta, invocation).await?;
report(KnobReport {
outcome: outcome.into(),
printed: printed(ctx, &output),
})
}
async fn derp_map(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_derp_map,
Invocation::read(["debug", "derp-map"]),
)
.await
}
async fn netmap(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_netmap,
Invocation::read(["debug", "netmap"]),
)
.await
}
async fn hostinfo(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_hostinfo,
Invocation::read(["debug", "hostinfo"]),
)
.await
}
async fn control_knobs(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_control_knobs,
Invocation::read(["debug", "control-knobs"]),
)
.await
}
async fn daemon_goroutines(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
text(
ctx,
&metas::tailscale_debug_daemon_goroutines,
Invocation::read(["debug", "daemon-goroutines"]),
)
.await
}
async fn daemon_bus_graph(ctx: &ToolContext, params: DaemonBusGraphParams) -> ToolResult<Value> {
let meta = &metas::tailscale_debug_daemon_bus_graph;
let format = params.format.unwrap_or(BusGraphFormat::Json);
let invocation = Invocation::read([
"debug".to_owned(),
"daemon-bus-graph".to_owned(),
format!("--format={}", format.as_str()),
]);
match format {
BusGraphFormat::Json => {
let graph = document(ctx, meta, invocation).await?;
report(BusGraphReport {
format: format.as_str().to_owned(),
graph: Some(graph),
printed: None,
})
}
BusGraphFormat::Dot => {
let output = cli::run(ctx, meta, invocation).await?;
report(BusGraphReport {
format: format.as_str().to_owned(),
graph: None,
printed: Some(output.stdout_str().trim_end().to_owned()),
})
}
}
}
async fn daemon_bus_queues(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_daemon_bus_queues,
Invocation::read(["debug", "daemon-bus-queues"]),
)
.await
}
async fn metrics(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
text(
ctx,
&metas::tailscale_debug_metrics,
Invocation::read(["debug", "metrics"]),
)
.await
}
async fn statedir(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let path = cli::run_text(
ctx,
&metas::tailscale_debug_statedir,
Invocation::read(["debug", "statedir"]),
)
.await?;
report(PathReport {
path: path.trim().to_owned(),
})
}
async fn go_buildinfo(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_go_buildinfo,
Invocation::read(["debug", "go-buildinfo"]),
)
.await
}
async fn peer_relay_servers(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_peer_relay_servers,
Invocation::read(["debug", "peer-relay-servers"]),
)
.await
}
async fn peer_relay_sessions(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
text(
ctx,
&metas::tailscale_debug_peer_relay_sessions,
Invocation::read(["debug", "peer-relay-sessions"]),
)
.await
}
async fn file_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let value = document(
ctx,
&metas::tailscale_debug_file_list,
Invocation::read(["debug", "--file=get"]),
)
.await?;
let files = if value.is_null() {
Value::Array(Vec::new())
} else {
value
};
Ok(json!({ "files": files }))
}
async fn stat(ctx: &ToolContext, params: StatParams) -> ToolResult<Value> {
let meta = &metas::tailscale_debug_stat;
if params.paths.is_empty() {
return Err(ToolError::invalid_args(
"`paths` needs at least one path to report on",
));
}
let mut args = vec!["debug".to_owned(), "stat".to_owned()];
for path in ¶ms.paths {
args.push(real_path(ctx, "paths", path)?);
}
let output = cli::run(ctx, meta, Invocation::read(args)).await?;
report(StatReport {
entries: output.stdout_str().lines().filter_map(parse_stat).collect(),
printed: printed(ctx, &output),
})
}
fn parse_stat(line: &str) -> Option<StatEntry> {
let (path, rest) = line.trim().rsplit_once(": ")?;
let (mode, size) = rest.rsplit_once(", ")?;
Some(StatEntry {
path: path.to_owned(),
mode: mode.to_owned(),
size: size.to_owned(),
})
}
async fn via(ctx: &ToolContext, params: ViaParams) -> ToolResult<Value> {
let meta = &metas::tailscale_debug_via;
let args = match (
params.site_id,
params.prefix.as_deref(),
params.route.as_deref(),
) {
(Some(site_id), Some(prefix), None) => {
vec![
"debug".to_owned(),
"via".to_owned(),
site_id.to_string(),
prefix.to_owned(),
]
}
(None, None, Some(route)) => {
vec!["debug".to_owned(), "via".to_owned(), route.to_owned()]
}
(None, None, None) => {
return Err(ToolError::invalid_args(
"give `site_id` and `prefix` to build a route, or `route` to take one apart",
));
}
_ => {
return Err(ToolError::invalid_args(
"`site_id` and `prefix` go together and neither goes with `route`",
));
}
};
let text = cli::run_text(ctx, meta, Invocation::read(args)).await?;
report(ViaReport {
converted: ctx.redactor.apply(text.trim()).into_owned(),
})
}
async fn watch_ipn(ctx: &ToolContext, params: WatchIpnParams) -> ToolResult<Value> {
let meta = &metas::tailscale_debug_watch_ipn;
if params.count == 0 {
return Err(ToolError::invalid_args(
"`count` must be at least 1: the client reads zero as \"never stop\"",
));
}
let count = params.count.min(MAX_EVENTS);
let (seconds, bound) = bounded_wait(
params.timeout_seconds,
DEFAULT_WATCH_SECONDS,
MAX_WATCH_SECONDS,
);
let mut args = vec![
"debug".to_owned(),
"watch-ipn".to_owned(),
format!("--count={count}"),
];
push_bool(&mut args, "engine-updates", params.engine_updates);
push_bool(&mut args, "health-actions", params.health_actions);
push_bool(&mut args, "peer-changes", params.peer_changes);
push_bool(&mut args, "peer-patches", params.peer_patches);
push_bool(
&mut args,
"peer-wireguard-state",
params.peer_wireguard_state,
);
push_bool(
&mut args,
"initial-client-version",
params.initial_client_version,
);
push_bool(
&mut args,
"initial-drive-shares",
params.initial_drive_shares,
);
push_bool(&mut args, "initial-health", params.initial_health);
push_bool(
&mut args,
"initial-outgoing-files",
params.initial_outgoing_files,
);
push_bool(&mut args, "initial-status", params.initial_status);
push_bool(
&mut args,
"initial-suggested-exit-node",
params.initial_suggested_exit_node,
);
let output = cli::run(ctx, meta, Invocation::read(args).with_timeout(bound)).await?;
report(WatchReport {
events: notifications(&output.stdout_str()),
asked_for: count,
seconds,
note: note(ctx, &output.stderr),
})
}
fn notifications(stdout: &str) -> Vec<Value> {
serde_json::Deserializer::from_str(stdout)
.into_iter::<Value>()
.map_while(Result::ok)
.collect()
}
async fn peer_endpoint_changes(ctx: &ToolContext, params: PeerParams) -> ToolResult<Value> {
object(
ctx,
&metas::tailscale_debug_peer_endpoint_changes,
Invocation::read(["debug", "peer-endpoint-changes", ¶ms.peer]),
)
.await
}
async fn resolve(ctx: &ToolContext, params: ResolveParams) -> ToolResult<Value> {
let mut args = vec![
"debug".to_owned(),
"resolve".to_owned(),
params.host.clone(),
];
push_text(&mut args, "net", params.net.as_deref());
let output = cli::run(ctx, &metas::tailscale_debug_resolve, Invocation::read(args)).await?;
report(AddressReport {
addresses: output
.stdout_str()
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect(),
printed: printed(ctx, &output),
})
}
async fn dial_types(ctx: &ToolContext, params: DialTypesParams) -> ToolResult<Value> {
let mut args = vec![
"debug".to_owned(),
"dial-types".to_owned(),
params.host.clone(),
params.port.to_string(),
];
push_text(&mut args, "network", params.network.as_deref());
text(
ctx,
&metas::tailscale_debug_dial_types,
Invocation::read(args),
)
.await
}
async fn derp(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
text(
ctx,
&metas::tailscale_debug_derp,
Invocation::read(["debug", "derp"]),
)
.await
}
async fn ts2021(ctx: &ToolContext, params: Ts2021Params) -> ToolResult<Value> {
let mut args = vec!["debug".to_owned(), "ts2021".to_owned()];
push_text(&mut args, "host", params.host.as_deref());
push_text(&mut args, "ace", params.ace.as_deref());
if let Some(path) = params.dial_plan.as_deref() {
args.push(format!(
"--dial-plan={}",
real_path(ctx, "dial_plan", path)?
));
}
if let Some(version) = params.version {
args.push(format!("--version={version}"));
}
push_bool(&mut args, "verbose", params.verbose);
text(ctx, &metas::tailscale_debug_ts2021, Invocation::read(args)).await
}
async fn portmap(ctx: &ToolContext, params: PortmapParams) -> ToolResult<Value> {
let meta = &metas::tailscale_debug_portmap;
if params.gateway_addr.is_some() != params.self_addr.is_some() {
return Err(ToolError::invalid_args(
"`gateway_addr` and `self_addr` override the probe's view of the network \
together, so give both or neither",
));
}
let (seconds, bound) = bounded_wait(
params.duration_seconds,
DEFAULT_PORTMAP_SECONDS,
MAX_PORTMAP_SECONDS,
);
let mut args = vec![
"debug".to_owned(),
"portmap".to_owned(),
format!("--duration={seconds}s"),
];
push_text(&mut args, "type", params.r#type.as_deref());
push_text(&mut args, "gateway-addr", params.gateway_addr.as_deref());
push_text(&mut args, "self-addr", params.self_addr.as_deref());
push_bool(&mut args, "log-http", params.log_http);
text(ctx, meta, Invocation::read(args).with_timeout(bound)).await
}
async fn component_logs(ctx: &ToolContext, params: ComponentLogsParams) -> ToolResult<Value> {
let seconds = params.for_seconds;
let outcome = if seconds > 0 {
format!(
"verbose logging for `{}` is on for the next {seconds}s",
params.component
)
} else {
format!("verbose logging for `{}` is off", params.component)
};
knob(
ctx,
&metas::tailscale_debug_component_logs,
Invocation::mutate_shared([
"debug".to_owned(),
"component-logs".to_owned(),
format!("--for={seconds}s"),
params.component.clone(),
]),
outcome,
)
.await
}
async fn restun(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_restun,
Invocation::mutate_shared(["debug", "restun"]),
"the node was told to re-learn its own addresses",
)
.await
}
async fn rebind(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_rebind,
Invocation::mutate_shared(["debug", "rebind"]),
"the node was told to rebind its sockets",
)
.await
}
async fn rotate_disco_key(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_rotate_disco_key,
Invocation::mutate_shared(["debug", "rotate-disco-key"]),
"the node's disco key was rotated; peers will rebuild their direct paths",
)
.await
}
async fn derp_unset_on_demand(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_derp_unset_on_demand,
Invocation::mutate_shared(["debug", "derp-unset-on-demand"]),
"relay connections are back to always-on",
)
.await
}
async fn pick_new_derp(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_pick_new_derp,
Invocation::mutate_shared(["debug", "pick-new-derp"]),
"the node was moved to another home relay region until it restarts",
)
.await
}
async fn force_prefer_derp(ctx: &ToolContext, params: ForcePreferDerpParams) -> ToolResult<Value> {
let outcome = if params.region_id == 0 {
"the node is free to choose its own home relay region again".to_owned()
} else {
format!(
"the node prefers relay region {} until it restarts",
params.region_id
)
};
knob(
ctx,
&metas::tailscale_debug_force_prefer_derp,
Invocation::mutate_shared([
"debug".to_owned(),
"force-prefer-derp".to_owned(),
params.region_id.to_string(),
]),
outcome,
)
.await
}
async fn force_netmap_update(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
knob(
ctx,
&metas::tailscale_debug_force_netmap_update,
Invocation::mutate_shared(["debug", "force-netmap-update"]),
"a full netmap update was pushed through the daemon",
)
.await
}
#[allow(clippy::unwrap_used, clippy::expect_used)]
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::sync::Arc;
use std::time::Duration;
use super::*;
use crate::error::ErrorCode;
use crate::testing::{Reply, StubBackend, context};
async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: std::future::Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(reply));
let ctx = context(Arc::clone(&backend));
let value = handler(ctx, params).await.expect("the handler succeeds");
(value, backend.argv())
}
async fn refusal<F, P, Fut>(reply: Reply, handler: F, params: P) -> ToolError
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: std::future::Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(reply));
let ctx = context(backend);
handler(ctx, params).await.expect_err("the handler refuses")
}
fn watching(count: u32, timeout_seconds: Option<u64>) -> WatchIpnParams {
WatchIpnParams {
count,
timeout_seconds,
engine_updates: None,
health_actions: None,
peer_changes: None,
peer_patches: None,
peer_wireguard_state: None,
initial_client_version: None,
initial_drive_shares: None,
initial_health: None,
initial_outgoing_files: None,
initial_status: None,
initial_suggested_exit_node: None,
}
}
#[test]
fn the_excluded_list_is_complete_and_says_why() {
assert_eq!(
EXCLUDED.len(),
14,
"fourteen of the forty-four are excluded"
);
let mut paths = BTreeSet::new();
for excluded in EXCLUDED {
assert!(
excluded.path.starts_with("debug "),
"`{}` is not a debug subcommand",
excluded.path
);
assert!(
excluded.reason.len() > 20,
"`{}` is excluded without saying why",
excluded.path
);
assert!(
paths.insert(excluded.path),
"`{}` is excluded twice",
excluded.path
);
}
}
#[test]
fn a_stat_line_splits_from_the_right() {
let entry = parse_stat("/etc/hosts: -rw-r--r--, 213").expect("a well-formed line");
assert_eq!(entry.path, "/etc/hosts");
assert_eq!(entry.mode, "-rw-r--r--");
assert_eq!(entry.size, "213");
let odd = parse_stat("/tmp/a: b, c/file: -rw-------, 0").expect("a path with separators");
assert_eq!(odd.path, "/tmp/a: b, c/file");
assert_eq!(odd.size, "0");
assert!(parse_stat("something else entirely").is_none());
}
#[test]
fn concatenated_notifications_are_read_one_at_a_time() {
let stream = "{\n\t\"Version\": \"1.102.2\"\n}\n{\n\t\"Version\": \"1.102.2\"\n}\n";
assert!(serde_json::from_str::<Value>(stream).is_err());
let events = notifications(stream);
assert_eq!(events.len(), 2);
assert_eq!(events[0]["Version"], "1.102.2");
}
#[test]
fn a_half_written_notification_is_dropped_rather_than_losing_the_rest() {
let stream = "{\"a\": 1}\n{\"b\": 2}\n{\"c\":";
let events = notifications(stream);
assert_eq!(
events.len(),
2,
"the whole objects survive the truncated one"
);
}
#[tokio::test]
async fn the_watcher_honours_its_count_and_its_cap() {
let (value, argv) = against(
Reply::ok("{\"Version\":\"1.102.2\"}\n"),
|ctx, p| async move { watch_ipn(&ctx, p).await },
watching(3, None),
)
.await;
assert_eq!(argv, [["debug", "watch-ipn", "--count=3"]]);
assert_eq!(value["asked_for"], 3);
assert_eq!(value["seconds"], DEFAULT_WATCH_SECONDS);
assert_eq!(value["events"].as_array().map(Vec::len), Some(1));
let (value, argv) = against(
Reply::ok(""),
|ctx, p| async move { watch_ipn(&ctx, p).await },
watching(10_000, Some(10_000)),
)
.await;
assert_eq!(
argv,
[["debug", "watch-ipn", &format!("--count={MAX_EVENTS}")]]
);
assert_eq!(value["asked_for"], MAX_EVENTS);
assert_eq!(value["seconds"], MAX_WATCH_SECONDS);
}
#[tokio::test]
async fn the_watcher_and_the_portmap_probe_bound_the_process_too() {
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
watch_ipn(&ctx, watching(3, None))
.await
.expect("the watcher succeeds");
portmap(
&ctx,
PortmapParams {
duration_seconds: None,
r#type: None,
gateway_addr: None,
self_addr: None,
log_http: None,
},
)
.await
.expect("the probe succeeds");
let bounds: Vec<Duration> = backend.calls().iter().map(|call| call.timeout).collect();
assert_eq!(
bounds,
[
Duration::from_secs(DEFAULT_WATCH_SECONDS + 5),
Duration::from_secs(DEFAULT_PORTMAP_SECONDS + 5)
]
);
}
#[tokio::test]
async fn the_bus_graph_reports_a_document_or_a_drawing_but_never_both() {
let (value, argv) = against(
Reply::ok(r#"{"nodes":[]}"#),
|ctx, p| async move { daemon_bus_graph(&ctx, p).await },
DaemonBusGraphParams { format: None },
)
.await;
assert_eq!(argv, [["debug", "daemon-bus-graph", "--format=json"]]);
assert_eq!(value["format"], "json");
assert!(value["graph"].is_object());
assert!(value.get("printed").is_none());
let (value, argv) = against(
Reply::ok("digraph {}\n"),
|ctx, p| async move { daemon_bus_graph(&ctx, p).await },
DaemonBusGraphParams {
format: Some(BusGraphFormat::Dot),
},
)
.await;
assert_eq!(argv, [["debug", "daemon-bus-graph", "--format=dot"]]);
assert_eq!(value["format"], "dot");
assert_eq!(value["printed"], "digraph {}");
assert!(value.get("graph").is_none());
}
#[tokio::test]
async fn an_empty_inbox_is_a_list_of_nothing_rather_than_a_null() {
let (value, argv) = against(
Reply::ok("null\n"),
|ctx, p| async move { file_list(&ctx, p).await },
NoParams {},
)
.await;
assert_eq!(argv, [["debug", "--file=get"]]);
assert_eq!(value["files"], json!([]));
}
#[tokio::test]
async fn the_two_conversions_via_offers_are_told_apart_before_anything_runs() {
let (value, argv) = against(
Reply::ok("fd7a:115c:a1e0:b1a:0:7:a01:0/112\n"),
|ctx, p| async move { via(&ctx, p).await },
ViaParams {
site_id: Some(7),
prefix: Some("10.1.0.0/16".to_owned()),
route: None,
},
)
.await;
assert_eq!(argv, [["debug", "via", "7", "10.1.0.0/16"]]);
assert_eq!(value["converted"], "fd7a:115c:a1e0:b1a:0:7:a01:0/112");
let (_, argv) = against(
Reply::ok("site 7 (0x7), 10.1.0.0/16\n"),
|ctx, p| async move { via(&ctx, p).await },
ViaParams {
site_id: None,
prefix: None,
route: Some("fd7a:115c:a1e0:b1a:0:7:a01:0/112".to_owned()),
},
)
.await;
assert_eq!(argv, [["debug", "via", "fd7a:115c:a1e0:b1a:0:7:a01:0/112"]]);
for mixture in [
ViaParams {
site_id: Some(7),
prefix: None,
route: None,
},
ViaParams {
site_id: Some(7),
prefix: Some("10.1.0.0/16".to_owned()),
route: Some("fd7a::/112".to_owned()),
},
ViaParams {
site_id: None,
prefix: None,
route: None,
},
] {
let error = refusal(
Reply::ok(""),
|ctx, p| async move { via(&ctx, p).await },
mixture,
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
}
#[tokio::test]
async fn the_portmap_overrides_are_refused_one_at_a_time() {
for lonely in [
PortmapParams {
duration_seconds: None,
r#type: None,
gateway_addr: Some("192.0.2.1".to_owned()),
self_addr: None,
log_http: None,
},
PortmapParams {
duration_seconds: None,
r#type: None,
gateway_addr: None,
self_addr: Some("192.0.2.2".to_owned()),
log_http: None,
},
] {
let error = refusal(
Reply::ok(""),
|ctx, p| async move { portmap(&ctx, p).await },
lonely,
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
let (value, argv) = against(
Reply::ok("portmapper: no port mapping services were found\n"),
|ctx, p| async move { portmap(&ctx, p).await },
PortmapParams {
duration_seconds: Some(10_000),
r#type: Some("upnp".to_owned()),
gateway_addr: Some("192.0.2.1".to_owned()),
self_addr: Some("192.0.2.2".to_owned()),
log_http: Some(false),
},
)
.await;
assert_eq!(
argv,
[[
"debug",
"portmap",
&format!("--duration={MAX_PORTMAP_SECONDS}s"),
"--type=upnp",
"--gateway-addr=192.0.2.1",
"--self-addr=192.0.2.2",
"--log-http=false"
]]
);
assert!(
value["printed"]
.as_str()
.is_some_and(|p| p.contains("portmapper"))
);
}
#[tokio::test]
async fn turning_component_logging_off_says_so_rather_than_saying_how_long_for() {
let (value, argv) = against(
Reply::ok(""),
|ctx, p| async move { component_logs(&ctx, p).await },
ComponentLogsParams {
component: "magicsock".to_owned(),
for_seconds: default_component_log_seconds(),
},
)
.await;
assert_eq!(
argv,
[[
"debug",
"component-logs",
&format!("--for={DEFAULT_COMPONENT_LOG_SECONDS}s"),
"magicsock"
]]
);
assert!(
value["outcome"]
.as_str()
.is_some_and(|o| o.contains("is on"))
);
let (value, argv) = against(
Reply::ok(""),
|ctx, p| async move { component_logs(&ctx, p).await },
ComponentLogsParams {
component: "magicsock".to_owned(),
for_seconds: 0,
},
)
.await;
assert_eq!(argv, [["debug", "component-logs", "--for=0s", "magicsock"]]);
assert_eq!(value["outcome"], "verbose logging for `magicsock` is off");
}
#[tokio::test]
async fn pinning_a_relay_region_and_letting_it_go_read_differently() {
let (value, argv) = against(
Reply::ok(""),
|ctx, p| async move { force_prefer_derp(&ctx, p).await },
ForcePreferDerpParams { region_id: 0 },
)
.await;
assert_eq!(argv, [["debug", "force-prefer-derp", "0"]]);
assert!(
value["outcome"]
.as_str()
.is_some_and(|o| o.contains("free to choose"))
);
let (value, _) = against(
Reply::ok(""),
|ctx, p| async move { force_prefer_derp(&ctx, p).await },
ForcePreferDerpParams { region_id: 2 },
)
.await;
assert!(
value["outcome"]
.as_str()
.is_some_and(|o| o.contains("region 2"))
);
}
#[tokio::test]
async fn stat_refuses_an_empty_list_and_a_stream() {
let error = refusal(
Reply::ok(""),
|ctx, p| async move { stat(&ctx, p).await },
StatParams { paths: Vec::new() },
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
let error = refusal(
Reply::ok(""),
|ctx, p| async move { stat(&ctx, p).await },
StatParams {
paths: vec!["-".to_owned()],
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
}