use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_cli::Invocation;
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::meta::Tier;
use crate::tools::common::{Excluded, bounded_wait, printed, report};
use crate::tools::local_debug;
crate::tools! {
tailscale_run => RunParams, run,
toolset: LocalPassthrough, tier: Read, varying: true;
}
const MAX_DEPTH: usize = 3;
const DEFAULT_RUN_TIMEOUT: u64 = 30;
const MAX_RUN_TIMEOUT: u64 = 300;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Covered {
pub path: &'static str,
pub tier: Tier,
pub confirm: bool,
}
pub const COVERED: &[Covered] = &[
Covered {
path: "up",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "down",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "set",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "get",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "login",
tier: Tier::Write,
confirm: true,
},
Covered {
path: "logout",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "switch",
tier: Tier::Write,
confirm: true,
},
Covered {
path: "switch remove",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "ip",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "netcheck",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "ping",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "whois",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "whoami",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "version",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "licenses",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "bugreport",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "appc-routes",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "routecheck",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "wait",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "dns status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "dns query",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "exit-node list",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "exit-node suggest",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "metrics print",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "service list",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "syspolicy list",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "configure sysext status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "serve",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "serve status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "serve reset",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "serve drain",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "serve clear",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "serve advertise",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "serve get-config",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "serve set-config",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "funnel",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "funnel status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "file cp",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "file get",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "cert",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "metrics write",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "configure kubeconfig",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "syspolicy reload",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "drive list",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "drive share",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "drive rename",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "drive unshare",
tier: Tier::Destructive,
confirm: false,
},
Covered {
path: "lock status",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "lock log",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "lock init",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "lock add",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "lock remove",
tier: Tier::Destructive,
confirm: false,
},
Covered {
path: "lock sign",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "lock disable",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "lock disablement-kdf",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "lock local-disable",
tier: Tier::Destructive,
confirm: false,
},
Covered {
path: "lock revoke-keys",
tier: Tier::Destructive,
confirm: true,
},
Covered {
path: "debug derp-map",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug netmap",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug hostinfo",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug control-knobs",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug daemon-goroutines",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug daemon-bus-graph",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug daemon-bus-queues",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug metrics",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug statedir",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug go-buildinfo",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug peer-relay-servers",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug peer-relay-sessions",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug stat",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug via",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug watch-ipn",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug peer-endpoint-changes",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug resolve",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug dial-types",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug derp",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug ts2021",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug portmap",
tier: Tier::Read,
confirm: false,
},
Covered {
path: "debug component-logs",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug restun",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug rebind",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug rotate-disco-key",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug derp-unset-on-demand",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug pick-new-derp",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug force-prefer-derp",
tier: Tier::Write,
confirm: false,
},
Covered {
path: "debug force-netmap-update",
tier: Tier::Write,
confirm: false,
},
];
const EXCLUDED: &[Excluded] = &[
Excluded {
path: "ssh",
reason: "it opens an interactive session on a peer, which needs a terminal \
this server does not have",
},
Excluded {
path: "nc",
reason: "it wires a socket to standard input and output, which a tool call \
has nothing to connect",
},
Excluded {
path: "web",
reason: "it runs a web server in the foreground until interrupted",
},
Excluded {
path: "systray",
reason: "it runs a desktop application in the foreground until quit",
},
Excluded {
path: "update",
reason: "it replaces the `tailscale` binary this server is talking to; \
update the host the way the host is normally updated",
},
Excluded {
path: "configure sysext activate",
reason: "it installs a system extension and reboots into it, which is a \
change to the host rather than to the tailnet",
},
Excluded {
path: "configure sysext deactivate",
reason: "it removes a system extension the node may be running on, which is \
a change to the host rather than to the tailnet",
},
Excluded {
path: "configure mac-vpn install",
reason: "it installs a system VPN profile, which is a change to the host \
rather than to the tailnet",
},
Excluded {
path: "configure mac-vpn uninstall",
reason: "it removes a system VPN profile, which is a change to the host \
rather than to the tailnet",
},
];
pub fn excluded() -> impl Iterator<Item = &'static Excluded> {
EXCLUDED.iter().chain(local_debug::EXCLUDED)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Known {
Covered(&'static Covered),
Excluded(&'static Excluded),
Unknown,
}
pub fn classify(args: &[String]) -> ToolResult<(String, Known)> {
let leading = words(args.iter().take_while(|arg| !is_flag(arg)));
let every = words(args.iter().filter(|arg| !is_flag(arg)));
if every.first().is_none_or(String::is_empty) {
return Err(ToolError::invalid_args(
"the first argument has to be a subcommand rather than a flag or an \
empty string, so that what runs can be read from the arguments",
));
}
let (path, known) = if leading.is_empty() {
read(&every)
} else {
std::cmp::max_by_key(read(&leading), read(&every), |(_, known)| strictness(known))
};
if known == Known::Unknown {
for reading in [&leading, &every] {
if excluded().any(|e| is_proper_prefix(reading, e.path)) {
return Err(ToolError::invalid_args(format!(
"`tailscale {}` names only part of a command, and this server \
decides what to allow from the words that are not flags. Write \
the whole subcommand, with its flags after it.",
reading.join(" ")
)));
}
}
}
Ok((path, known))
}
fn is_flag(arg: &str) -> bool {
arg.starts_with('-')
}
fn words<'a>(args: impl Iterator<Item = &'a String>) -> Vec<String> {
args.map(|arg| arg.trim().to_ascii_lowercase()).collect()
}
fn read(words: &[String]) -> (String, Known) {
for depth in (1..=words.len().min(MAX_DEPTH)).rev() {
let path = words[..depth].join(" ");
if let Some(found) = excluded().find(|e| e.path == path) {
return (path, Known::Excluded(found));
}
if let Some(found) = COVERED.iter().find(|c| c.path == path) {
return (path, Known::Covered(found));
}
}
(words.join(" "), Known::Unknown)
}
fn strictness(known: &Known) -> (bool, Tier, bool) {
match known {
Known::Excluded(_) => (true, Tier::Destructive, true),
Known::Covered(covered) => (false, covered.tier, covered.confirm),
Known::Unknown => (false, Tier::Destructive, false),
}
}
fn is_proper_prefix(words: &[String], path: &str) -> bool {
let mut theirs = path.split(' ');
!words.is_empty()
&& words
.iter()
.all(|word| theirs.next() == Some(word.as_str()))
&& theirs.next().is_some()
}
#[derive(Debug, Deserialize, JsonSchema)]
pub struct RunParams {
pub args: Vec<String>,
#[serde(default)]
pub confirm: bool,
#[serde(default)]
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct RunReport {
pub command: String,
pub tier: &'static str,
pub covered: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
async fn run(ctx: &ToolContext, params: RunParams) -> ToolResult<Value> {
let meta = &metas::tailscale_run;
let (path, known) = classify(¶ms.args)?;
let (tier, confirm) = match known {
Known::Excluded(excluded) => {
return Err(ToolError::new(
ErrorCode::NotPermitted,
format!(
"`tailscale {}` is never run by this server: {}",
excluded.path, excluded.reason
),
));
}
Known::Covered(covered) => (covered.tier, covered.confirm),
Known::Unknown => (Tier::Destructive, false),
};
if tier > ctx.max_tier
&& let Some(flag) = tier.flag()
{
return Err(ToolError::not_permitted(&format!("tailscale {path}"), flag));
}
if confirm && !params.confirm {
return Err(ToolError::confirmation_required(
&format!("tailscale {path}"),
"runs at the same terms as the tool that covers it",
));
}
let (_, bound) = bounded_wait(params.timeout_seconds, DEFAULT_RUN_TIMEOUT, MAX_RUN_TIMEOUT);
let invocation = if tier == Tier::Read {
Invocation::read(params.args.clone())
} else {
Invocation::mutate(params.args.clone())
}
.with_timeout(bound);
let command = cli::displayed(ctx, &invocation);
tracing::info!(command = %command, tier = tier.as_str(), "passthrough");
let output = cli::run(ctx, meta, invocation).await?;
report(RunReport {
command,
tier: tier.as_str(),
covered: matches!(known, Known::Covered(_)),
printed: printed(ctx, &output),
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::{Reply, StubBackend, context};
use std::sync::Arc;
fn words(args: &[&str]) -> Vec<String> {
args.iter().map(|a| (*a).to_owned()).collect()
}
fn class(args: &[&str]) -> Known {
classify(&words(args))
.expect("the arguments name a command")
.1
}
#[test]
fn every_path_fits_the_depth_the_matcher_searches() {
for path in COVERED
.iter()
.map(|c| c.path)
.chain(excluded().map(|e| e.path))
{
assert!(
path.split(' ').count() <= MAX_DEPTH,
"`{path}` is deeper than the matcher looks"
);
}
}
#[test]
fn no_path_is_named_twice() {
let mut seen: Vec<&str> = COVERED
.iter()
.map(|c| c.path)
.chain(excluded().map(|e| e.path))
.collect();
let before = seen.len();
seen.sort_unstable();
seen.dedup();
assert_eq!(before, seen.len(), "a command is judged in two places");
}
#[test]
fn every_path_is_written_the_way_the_matcher_compares_it() {
for path in COVERED
.iter()
.map(|c| c.path)
.chain(excluded().map(|e| e.path))
{
assert_eq!(path, path.to_ascii_lowercase(), "`{path}` cannot match");
assert_eq!(path.trim(), path, "`{path}` cannot match");
}
}
#[test]
fn a_partial_path_into_an_excluded_command_is_refused_rather_than_guessed() {
let error = classify(&words(&["debug", "--file=get"]))
.expect_err("`debug` on its own cannot be judged");
assert_eq!(error.code, ErrorCode::InvalidArgs);
assert!(
error.message.contains("only part of a command"),
"{error:?}"
);
}
#[test]
fn a_flag_value_that_only_looks_like_a_subcommand_keeps_the_stricter_terms() {
let (path, known) = classify(&words(&["funnel", "--set-path", "status", "8080"]))
.expect("`funnel` is readable");
assert_eq!(path, "funnel");
let Known::Covered(covered) = known else {
panic!("`funnel` has a row");
};
assert_eq!((covered.tier, covered.confirm), (Tier::Destructive, true));
}
#[test]
fn a_blank_first_argument_is_refused_rather_than_run_as_a_nameless_command() {
for args in [vec![""], vec!["", "down"], vec![" "]] {
let args: Vec<String> = args.into_iter().map(str::to_owned).collect();
let error = classify(&args).expect_err("a blank subcommand cannot be read");
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
let args: Vec<String> = ["ping", ""].into_iter().map(str::to_owned).collect();
let (path, _) = classify(&args).expect("`ping` is still readable");
assert_eq!(path, "ping");
}
#[test]
fn a_leading_flag_does_not_hide_the_subcommand_behind_it() {
let (path, known) =
classify(&words(&["--socket=/tmp/x", "status"])).expect("`status` is still named");
assert_eq!(path, "status");
assert!(matches!(known, Known::Covered(_)));
}
#[test]
fn reload_config_stays_runnable() {
assert!(matches!(class(&["debug", "reload-config"]), Known::Unknown));
}
async fn against(reply: Reply, params: RunParams) -> (ToolResult<Value>, Vec<Vec<String>>) {
let backend = Arc::new(StubBackend::always(reply));
let ctx = context(backend.clone());
let answer = run(&ctx, params).await;
(answer, backend.argv())
}
fn params(args: &[&str]) -> RunParams {
RunParams {
args: words(args),
confirm: false,
timeout_seconds: None,
}
}
#[tokio::test]
async fn the_report_says_how_the_command_was_judged() {
let (answer, _) = against(Reply::ok("100.64.0.1\n"), params(&["ip"])).await;
let value = answer.expect("a read runs");
assert_eq!(value["tier"], "read");
assert_eq!(value["covered"], true);
assert_eq!(value["command"], "tailscale ip");
assert_eq!(value["printed"], "100.64.0.1");
}
#[tokio::test]
async fn a_secret_on_the_argument_list_is_kept_out_of_the_report() {
let key = "tskey-auth-kFakeExample-0123456789";
let (answer, argv) = against(
Reply::ok("done\n"),
RunParams {
args: words(&["up", &format!("--auth-key={key}")]),
confirm: true,
timeout_seconds: None,
},
)
.await;
let value = answer.expect("`up` runs once confirmed");
let command = value["command"].as_str().expect("a command line");
assert!(
!command.contains(key),
"the key reached the report: {command}"
);
assert_eq!(argv, vec![words(&["up", &format!("--auth-key={key}")])]);
}
}