use std::time::Duration;
use rmcp::schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use tailscale_cli::{Invocation, Output};
use crate::cli;
use crate::context::ToolContext;
use crate::error::{ErrorCode, ToolError, ToolResult};
use crate::meta::ToolMeta;
use crate::tools::common::{
bounded_wait, flag, note, object, printed, push_bool, push_text, real_path, report,
};
crate::tools! {
tailscale_file_cp => FileCpParams, file_cp,
toolset: LocalFiles, tier: Write;
tailscale_file_targets => NoParams, file_targets,
toolset: LocalFiles, tier: Read, idempotent: true;
tailscale_file_get => FileGetParams, file_get,
toolset: LocalFiles, tier: Write;
tailscale_cert => CertParams, cert,
toolset: LocalFiles, tier: Write;
tailscale_metrics_write => MetricsWriteParams, metrics_write,
toolset: LocalFiles, tier: Write, idempotent: true, since: "1.78";
tailscale_configure_kubeconfig => KubeconfigParams, configure_kubeconfig,
toolset: LocalFiles, tier: Write, idempotent: true;
tailscale_syspolicy_reload => NoParams, syspolicy_reload,
toolset: LocalFiles, tier: Write, idempotent: true, since: "1.72";
tailscale_drive_list => NoParams, drive_list,
toolset: LocalFiles, tier: Read, idempotent: true;
tailscale_drive_share => DriveShareParams, drive_share,
toolset: LocalFiles, tier: Write, idempotent: true;
tailscale_drive_rename => DriveRenameParams, drive_rename,
toolset: LocalFiles, tier: Write;
tailscale_drive_unshare => DriveUnshareParams, drive_unshare,
toolset: LocalFiles, tier: Destructive;
}
const DEFAULT_TRANSFER_TIMEOUT: u64 = 300;
const DEFAULT_CERT_TIMEOUT: u64 = 120;
const MAX_LONG_TIMEOUT: u64 = 600;
fn budget(requested: Option<u64>, default: u64) -> (u64, Duration) {
bounded_wait(requested, default, MAX_LONG_TIMEOUT)
}
const NO_PROGRESS: &str = "--update-interval=0";
const NO_TAILDRIVE: &str = "Taildrive CLI commands are not supported";
#[derive(Debug, Deserialize, JsonSchema)]
pub struct NoParams {}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct FileCpParams {
pub files: Vec<String>,
pub target: String,
#[serde(default)]
pub name: Option<String>,
#[serde(default)]
pub verbose: Option<bool>,
#[serde(default)]
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Default, Clone, Copy, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
pub enum Conflict {
#[default]
Skip,
Overwrite,
Rename,
}
impl Conflict {
const fn as_str(self) -> &'static str {
match self {
Self::Skip => "skip",
Self::Overwrite => "overwrite",
Self::Rename => "rename",
}
}
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct FileGetParams {
pub directory: String,
#[serde(default)]
pub conflict: Conflict,
#[serde(default)]
pub verbose: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct CertParams {
pub domain: String,
pub cert_file: String,
pub key_file: String,
#[serde(default)]
pub min_validity_seconds: Option<u64>,
#[serde(default)]
pub timeout_seconds: Option<u64>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct MetricsWriteParams {
pub path: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct KubeconfigParams {
pub hostname: String,
#[serde(default)]
pub http: Option<bool>,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct DriveShareParams {
pub name: String,
pub path: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct DriveRenameParams {
pub name: String,
pub new_name: String,
}
#[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct DriveUnshareParams {
pub name: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TransferReport {
pub target: String,
pub files: Vec<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub delivered_as: Option<String>,
pub timeout_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct Target {
pub address: String,
pub hostname: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub status: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct TargetsReport {
pub targets: Vec<Target>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct InboxReport {
pub directory: String,
pub conflict: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct CertificateReport {
pub domain: String,
pub cert_file: String,
pub key_file: String,
pub timeout_seconds: u64,
#[serde(skip_serializing_if = "Option::is_none")]
pub printed: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct OutcomeReport {
pub subject: String,
pub outcome: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct Share {
pub name: String,
pub path: String,
pub as_user: String,
}
#[derive(Debug, Serialize, JsonSchema)]
pub struct SharesReport {
pub shares: Vec<Share>,
}
fn transfer_target(target: &str) -> ToolResult<String> {
let trimmed = target.trim();
if trimmed.is_empty() || trimmed == ":" {
return Err(ToolError::invalid_args("`target` has to name a node"));
}
Ok(if trimmed.ends_with(':') {
trimmed.to_owned()
} else {
format!("{trimmed}:")
})
}
async fn drive(ctx: &ToolContext, meta: &ToolMeta, invocation: Invocation) -> ToolResult<Output> {
let display = invocation.display();
let output = cli::run_tolerant(ctx, meta, invocation).await?;
if output.success() {
return Ok(output);
}
let said = ctx.redactor.apply(&output.stderr);
if said.contains(NO_TAILDRIVE) {
return Err(ToolError::new(
ErrorCode::UnsupportedPlatform,
format!(
"Taildrive is not configurable through the client on this node: {}",
said.trim()
),
)
.with_hint("Configure Taildrive in the Tailscale application on this node instead."));
}
Err(cli::command_failure(ctx, meta, &display, &output))
}
async fn file_cp(ctx: &ToolContext, params: FileCpParams) -> ToolResult<Value> {
let meta = &metas::tailscale_file_cp;
if params.files.is_empty() {
return Err(ToolError::invalid_args("`files` needs at least one path"));
}
let files = params
.files
.iter()
.map(|file| real_path(ctx, "files", file))
.collect::<ToolResult<Vec<String>>>()?;
let target = transfer_target(¶ms.target)?;
let (seconds, timeout) = budget(params.timeout_seconds, DEFAULT_TRANSFER_TIMEOUT);
let mut args = vec!["file".to_owned(), "cp".to_owned(), NO_PROGRESS.to_owned()];
push_text(&mut args, "name", params.name.as_deref());
push_bool(&mut args, "verbose", params.verbose);
args.extend(files.iter().cloned());
args.push(target.clone());
let output = cli::run(
ctx,
meta,
Invocation::mutate_shared(args).with_timeout(timeout),
)
.await?;
report(TransferReport {
target,
files,
delivered_as: params.name,
timeout_seconds: seconds,
printed: printed(ctx, &output),
})
}
async fn file_targets(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let meta = &metas::tailscale_file_targets;
let text = cli::run_text(
ctx,
meta,
Invocation::read(["file", "cp", &flag("targets", true)]),
)
.await?;
report(TargetsReport {
targets: parse_targets(&text),
})
}
fn parse_targets(text: &str) -> Vec<Target> {
text.lines()
.map(str::trim_end)
.filter(|line| !line.trim().is_empty())
.filter_map(|line| {
let mut columns = line.split('\t').map(str::trim);
let address = columns.next()?;
let hostname = columns.next().unwrap_or_default();
let status = columns.next().filter(|note| !note.is_empty());
Some(Target {
address: address.to_owned(),
hostname: hostname.to_owned(),
status: status.map(str::to_owned),
})
})
.collect()
}
async fn file_get(ctx: &ToolContext, params: FileGetParams) -> ToolResult<Value> {
let meta = &metas::tailscale_file_get;
let directory = real_path(ctx, "directory", ¶ms.directory)?;
let args = vec![
"file".to_owned(),
"get".to_owned(),
format!("--conflict={}", params.conflict.as_str()),
flag("wait", false),
flag("loop", false),
flag("verbose", params.verbose.unwrap_or(false)),
directory.clone(),
];
let output = cli::run(ctx, meta, Invocation::mutate_shared(args)).await?;
report(InboxReport {
directory,
conflict: params.conflict.as_str().to_owned(),
printed: printed(ctx, &output),
})
}
async fn cert(ctx: &ToolContext, params: CertParams) -> ToolResult<Value> {
let meta = &metas::tailscale_cert;
let cert_file = real_path(ctx, "cert_file", ¶ms.cert_file)?;
let key_file = real_path(ctx, "key_file", ¶ms.key_file)?;
if params.domain.trim().is_empty() {
return Err(ToolError::invalid_args("`domain` cannot be empty"));
}
let (seconds, timeout) = budget(params.timeout_seconds, DEFAULT_CERT_TIMEOUT);
let mut args = vec![
"cert".to_owned(),
format!("--cert-file={cert_file}"),
format!("--key-file={key_file}"),
];
if let Some(validity) = params.min_validity_seconds {
args.push(format!("--min-validity={validity}s"));
}
args.push(params.domain.clone());
let output = cli::run(
ctx,
meta,
Invocation::mutate_shared(args).with_timeout(timeout),
)
.await?;
report(CertificateReport {
domain: params.domain,
cert_file,
key_file,
timeout_seconds: seconds,
printed: printed(ctx, &output),
})
}
async fn metrics_write(ctx: &ToolContext, params: MetricsWriteParams) -> ToolResult<Value> {
let meta = &metas::tailscale_metrics_write;
let path = real_path(ctx, "path", ¶ms.path)?;
let output = cli::run(
ctx,
meta,
Invocation::mutate_shared(["metrics", "write", &path]),
)
.await?;
report(OutcomeReport {
subject: path,
outcome: "client metrics written in Prometheus text format".to_owned(),
note: note(ctx, &output.stderr),
})
}
async fn configure_kubeconfig(ctx: &ToolContext, params: KubeconfigParams) -> ToolResult<Value> {
let meta = &metas::tailscale_configure_kubeconfig;
if params.hostname.trim().is_empty() {
return Err(ToolError::invalid_args("`hostname` cannot be empty"));
}
let mut args = vec!["configure".to_owned(), "kubeconfig".to_owned()];
push_bool(&mut args, "http", params.http);
args.push(params.hostname.clone());
let output = cli::run(ctx, meta, Invocation::mutate(args)).await?;
report(OutcomeReport {
subject: params.hostname,
outcome: "added to the local kubectl configuration".to_owned(),
note: printed(ctx, &output),
})
}
async fn syspolicy_reload(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let meta = &metas::tailscale_syspolicy_reload;
object(
ctx,
meta,
Invocation::mutate(["syspolicy", "reload", &flag("json", true)]),
)
.await
}
async fn drive_list(ctx: &ToolContext, _params: NoParams) -> ToolResult<Value> {
let meta = &metas::tailscale_drive_list;
let output = drive(ctx, meta, Invocation::read(["drive", "list"])).await?;
report(SharesReport {
shares: parse_shares(&output.stdout_str()),
})
}
fn column_starts(header: &str) -> Vec<usize> {
let mut starts = Vec::new();
let mut previous = ' ';
for (index, character) in header.chars().enumerate() {
if previous == ' ' && character != ' ' {
starts.push(index);
}
previous = character;
}
starts
}
fn cells(row: &str, starts: &[usize]) -> Vec<String> {
let characters: Vec<char> = row.chars().collect();
starts
.iter()
.enumerate()
.map(|(nth, &from)| {
let to = starts.get(nth + 1).copied().unwrap_or(characters.len());
let cell: String = characters
.get(from..to.min(characters.len()))
.unwrap_or_default()
.iter()
.collect();
cell.trim().to_owned()
})
.collect()
}
fn parse_shares(text: &str) -> Vec<Share> {
let mut rows = text.lines().filter(|line| !line.trim().is_empty());
let Some(header) = rows.next() else {
return Vec::new();
};
let starts = column_starts(header);
rows
.filter(|row| !row.chars().all(|c| c == '-' || c == ' '))
.map(|row| {
let cells = cells(row, &starts);
Share {
name: cells.first().cloned().unwrap_or_default(),
path: cells.get(1).cloned().unwrap_or_default(),
as_user: cells.get(2).cloned().unwrap_or_default(),
}
})
.filter(|share| !share.name.is_empty())
.collect()
}
async fn drive_share(ctx: &ToolContext, params: DriveShareParams) -> ToolResult<Value> {
let meta = &metas::tailscale_drive_share;
let path = real_path(ctx, "path", ¶ms.path)?;
if params.name.trim().is_empty() {
return Err(ToolError::invalid_args("`name` cannot be empty"));
}
let output = drive(
ctx,
meta,
Invocation::mutate(["drive", "share", ¶ms.name, &path]),
)
.await?;
report(OutcomeReport {
subject: params.name,
outcome: format!("`{path}` shared with the tailnet over Taildrive"),
note: printed(ctx, &output),
})
}
async fn drive_rename(ctx: &ToolContext, params: DriveRenameParams) -> ToolResult<Value> {
let meta = &metas::tailscale_drive_rename;
if params.name.trim().is_empty() || params.new_name.trim().is_empty() {
return Err(ToolError::invalid_args(
"`name` and `new_name` both have to be given",
));
}
let output = drive(
ctx,
meta,
Invocation::mutate(["drive", "rename", ¶ms.name, ¶ms.new_name]),
)
.await?;
report(OutcomeReport {
subject: params.name,
outcome: format!("renamed to `{}`", params.new_name),
note: printed(ctx, &output),
})
}
async fn drive_unshare(ctx: &ToolContext, params: DriveUnshareParams) -> ToolResult<Value> {
let meta = &metas::tailscale_drive_unshare;
if params.name.trim().is_empty() {
return Err(ToolError::invalid_args("`name` cannot be empty"));
}
let output = drive(
ctx,
meta,
Invocation::mutate(["drive", "unshare", ¶ms.name]),
)
.await?;
report(OutcomeReport {
subject: params.name,
outcome: "no longer shared over Taildrive; the files are untouched".to_owned(),
note: printed(ctx, &output),
})
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use std::sync::Arc;
use serde_json::json;
use super::*;
use crate::context::PathPolicy;
use crate::meta::Tier;
use crate::testing::{Reply, StubBackend, context};
const TARGETS: &str = "100.64.0.2\tworkstation\n\
100.64.0.3\tlaptop\toffline; last seen 66h38m0s ago\n";
const SHARES: &str = "name path as\n\
---- ---- --\n\
docs /srv/shared docs alice\n\
photos /srv/photos alice\n";
async fn against<F, P, Fut>(reply: Reply, handler: F, params: P) -> (Value, Vec<Vec<String>>)
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: 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 refused<F, P, Fut>(handler: F, params: P) -> ToolError
where
F: FnOnce(ToolContext, P) -> Fut,
Fut: Future<Output = ToolResult<Value>>,
{
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
let error = handler(ctx, params).await.expect_err("the handler refuses");
assert!(
backend.argv().is_empty(),
"nothing should have run: {:?}",
backend.argv()
);
error
}
fn only(argv: &[Vec<String>]) -> &[String] {
assert_eq!(argv.len(), 1, "one command should have run: {argv:?}");
&argv[0]
}
#[tokio::test]
async fn sending_a_file_puts_the_target_last_and_gives_it_a_colon() {
let (answer, argv) = against(
Reply::ok("workstation.crt: 1.2 kB\n"),
|ctx, p| async move { file_cp(&ctx, p).await },
FileCpParams {
files: vec!["/tmp/notes.txt".to_owned()],
target: "laptop".to_owned(),
..FileCpParams::default()
},
)
.await;
assert_eq!(
only(&argv),
[
"file",
"cp",
"--update-interval=0",
"/tmp/notes.txt",
"laptop:"
]
);
assert_eq!(answer["target"], json!("laptop:"));
assert_eq!(answer["timeout_seconds"], json!(DEFAULT_TRANSFER_TIMEOUT));
}
#[tokio::test]
async fn a_target_that_already_ends_in_a_colon_is_left_alone() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { file_cp(&ctx, p).await },
FileCpParams {
files: vec!["/tmp/notes.txt".to_owned()],
target: "laptop:".to_owned(),
..FileCpParams::default()
},
)
.await;
assert_eq!(only(&argv).last().expect("a target"), "laptop:");
}
#[tokio::test]
async fn every_flag_precedes_every_path() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { file_cp(&ctx, p).await },
FileCpParams {
files: vec!["/tmp/a".to_owned(), "/tmp/b".to_owned()],
target: "laptop".to_owned(),
name: Some("bundle".to_owned()),
verbose: Some(true),
..FileCpParams::default()
},
)
.await;
let args = only(&argv);
let first_path = args
.iter()
.position(|arg| arg == "/tmp/a")
.expect("the first file");
assert!(
args[2..first_path].iter().all(|arg| arg.starts_with("--")),
"Go stops reading flags at the first positional: {args:?}"
);
assert_eq!(&args[first_path..], ["/tmp/a", "/tmp/b", "laptop:"]);
}
#[tokio::test]
async fn sending_nothing_is_refused() {
let error = refused(
|ctx, p| async move { file_cp(&ctx, p).await },
FileCpParams {
target: "laptop".to_owned(),
..FileCpParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn a_transfer_may_be_lengthened_but_only_so_far() {
let (answer, _) = against(
Reply::ok(""),
|ctx, p| async move { file_cp(&ctx, p).await },
FileCpParams {
files: vec!["/tmp/big.iso".to_owned()],
target: "laptop".to_owned(),
timeout_seconds: Some(9_999),
..FileCpParams::default()
},
)
.await;
assert_eq!(answer["timeout_seconds"], json!(MAX_LONG_TIMEOUT));
}
#[test]
fn targets_are_read_column_by_column() {
let targets = parse_targets(TARGETS);
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].hostname, "workstation");
assert_eq!(targets[0].status, None);
assert_eq!(targets[1].address, "100.64.0.3");
assert_eq!(
targets[1].status.as_deref(),
Some("offline; last seen 66h38m0s ago")
);
}
#[tokio::test]
async fn listing_targets_asks_for_them_and_sends_no_file() {
let (answer, argv) = against(
Reply::ok(TARGETS),
|ctx, p| async move { file_targets(&ctx, p).await },
NoParams {},
)
.await;
assert_eq!(only(&argv), ["file", "cp", "--targets=true"]);
assert_eq!(answer["targets"].as_array().expect("a list").len(), 2);
}
#[tokio::test]
async fn receiving_files_never_waits_and_never_loops() {
let (answer, argv) = against(
Reply::ok("notes.txt\n"),
|ctx, p| async move { file_get(&ctx, p).await },
FileGetParams {
directory: "/tmp/inbox".to_owned(),
..FileGetParams::default()
},
)
.await;
assert_eq!(
only(&argv),
[
"file",
"get",
"--conflict=skip",
"--wait=false",
"--loop=false",
"--verbose=false",
"/tmp/inbox"
]
);
assert_eq!(answer["directory"], json!("/tmp/inbox"));
assert_eq!(answer["conflict"], json!("skip"));
}
#[tokio::test]
async fn the_conflict_rule_reaches_the_client_and_the_answer() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { file_get(&ctx, p).await },
FileGetParams {
directory: "/tmp/inbox".to_owned(),
conflict: Conflict::Rename,
verbose: Some(true),
},
)
.await;
let args = only(&argv);
assert!(args.contains(&"--conflict=rename".to_owned()), "{args:?}");
assert!(args.contains(&"--verbose=true".to_owned()), "{args:?}");
assert_eq!(answer["conflict"], json!("rename"));
}
#[tokio::test]
async fn an_empty_inbox_is_an_answer_rather_than_a_failure() {
let (answer, _) = against(
Reply::ok(""),
|ctx, p| async move { file_get(&ctx, p).await },
FileGetParams {
directory: "/tmp/inbox".to_owned(),
..FileGetParams::default()
},
)
.await;
assert_eq!(answer["printed"], json!(null));
}
#[tokio::test]
async fn issuing_a_certificate_names_both_files() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { cert(&ctx, p).await },
CertParams {
domain: "workstation.example-tailnet.ts.net".to_owned(),
cert_file: "/etc/ssl/node.crt".to_owned(),
key_file: "/etc/ssl/node.key".to_owned(),
..CertParams::default()
},
)
.await;
assert_eq!(
only(&argv),
[
"cert",
"--cert-file=/etc/ssl/node.crt",
"--key-file=/etc/ssl/node.key",
"workstation.example-tailnet.ts.net"
]
);
assert_eq!(answer["timeout_seconds"], json!(DEFAULT_CERT_TIMEOUT));
}
#[tokio::test]
async fn a_transfer_and_a_certificate_bound_the_process_too() {
let backend = Arc::new(StubBackend::always(Reply::ok("")));
let ctx = context(Arc::clone(&backend));
file_cp(
&ctx,
FileCpParams {
files: vec!["/tmp/notes.txt".to_owned()],
target: "laptop".to_owned(),
..FileCpParams::default()
},
)
.await
.expect("the transfer succeeds");
cert(
&ctx,
CertParams {
domain: "workstation.example-tailnet.ts.net".to_owned(),
cert_file: "/etc/ssl/node.crt".to_owned(),
key_file: "/etc/ssl/node.key".to_owned(),
..CertParams::default()
},
)
.await
.expect("the certificate is issued");
let bounds: Vec<Duration> = backend.calls().iter().map(|call| call.timeout).collect();
assert_eq!(
bounds,
[
Duration::from_secs(DEFAULT_TRANSFER_TIMEOUT + 5),
Duration::from_secs(DEFAULT_CERT_TIMEOUT + 5)
]
);
}
#[tokio::test]
async fn no_certificate_path_may_be_standard_output() {
for (cert_file, key_file) in [("-", "/etc/ssl/node.key"), ("/etc/ssl/node.crt", "-")] {
let error = refused(
|ctx, p| async move { cert(&ctx, p).await },
CertParams {
domain: "workstation.example-tailnet.ts.net".to_owned(),
cert_file: cert_file.to_owned(),
key_file: key_file.to_owned(),
..CertParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs, "{cert_file} {key_file}");
}
}
#[tokio::test]
async fn a_certificate_needs_both_paths_to_be_real() {
let error = refused(
|ctx, p| async move { cert(&ctx, p).await },
CertParams {
domain: "workstation.example-tailnet.ts.net".to_owned(),
cert_file: "/etc/ssl/node.crt".to_owned(),
key_file: " ".to_owned(),
..CertParams::default()
},
)
.await;
assert_eq!(error.code, ErrorCode::InvalidArgs);
}
#[tokio::test]
async fn a_minimum_validity_is_rendered_as_a_duration() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { cert(&ctx, p).await },
CertParams {
domain: "workstation.example-tailnet.ts.net".to_owned(),
cert_file: "/etc/ssl/node.crt".to_owned(),
key_file: "/etc/ssl/node.key".to_owned(),
min_validity_seconds: Some(604_800),
..CertParams::default()
},
)
.await;
let args = only(&argv);
assert!(
args.contains(&"--min-validity=604800s".to_owned()),
"{args:?}"
);
}
#[tokio::test]
async fn writing_metrics_names_the_file_it_wrote() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { metrics_write(&ctx, p).await },
MetricsWriteParams {
path: "/var/lib/node-exporter/tailscaled.prom".to_owned(),
},
)
.await;
assert_eq!(
only(&argv),
["metrics", "write", "/var/lib/node-exporter/tailscaled.prom"]
);
assert_eq!(
answer["subject"],
json!("/var/lib/node-exporter/tailscaled.prom")
);
}
#[tokio::test]
async fn kubeconfig_puts_its_flag_before_the_hostname() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { configure_kubeconfig(&ctx, p).await },
KubeconfigParams {
hostname: "cluster".to_owned(),
http: Some(true),
},
)
.await;
assert_eq!(
only(&argv),
["configure", "kubeconfig", "--http=true", "cluster"]
);
}
#[tokio::test]
async fn reloading_policy_asks_for_json_and_forwards_the_document() {
let (answer, argv) = against(
Reply::ok(r#"{"Summary":{"Scope":"Device"},"Settings":{}}"#),
|ctx, p| async move { syspolicy_reload(&ctx, p).await },
NoParams {},
)
.await;
assert_eq!(only(&argv), ["syspolicy", "reload", "--json=true"]);
assert_eq!(answer["Summary"]["Scope"], json!("Device"));
}
#[test]
fn shares_survive_a_path_with_a_space_in_it() {
let shares = parse_shares(SHARES);
assert_eq!(shares.len(), 2);
assert_eq!(shares[0].name, "docs");
assert_eq!(shares[0].path, "/srv/shared docs");
assert_eq!(shares[0].as_user, "alice");
assert_eq!(shares[1].path, "/srv/photos");
}
#[test]
fn a_populated_allow_list_confines_a_path_to_its_roots() {
let mut ctx = context(Arc::new(StubBackend::ok("")));
ctx.paths = PathPolicy::Within(vec![PathBuf::from("/srv/exports")]);
assert!(real_path(&ctx, "path", "/srv/exports/report.txt").is_ok());
let refused = real_path(&ctx, "path", "/etc/shadow").unwrap_err();
assert_eq!(refused.code, ErrorCode::NotPermitted);
}
#[test]
fn a_parent_directory_step_cannot_leave_an_allowed_root() {
let mut ctx = context(Arc::new(StubBackend::ok("")));
ctx.paths = PathPolicy::Within(vec![PathBuf::from("/srv/exports")]);
let refused = real_path(&ctx, "path", "/srv/exports/../../etc/shadow").unwrap_err();
assert_eq!(refused.code, ErrorCode::NotPermitted);
}
#[test]
fn a_share_with_no_as_user_is_still_a_share() {
let shares = parse_shares(
"name path as\n\
---- ---- --\n\
docs /srv/shared docs \n\
photos /srv/photos\n",
);
assert_eq!(shares.len(), 2);
assert_eq!(shares[0].path, "/srv/shared docs");
assert_eq!(shares[0].as_user, "");
assert_eq!(shares[1].name, "photos");
assert_eq!(shares[1].as_user, "");
}
#[test]
fn a_path_may_hold_consecutive_spaces() {
let shares = parse_shares(
"name path as\n\
---- ---- --\n\
docs /srv/two spaces alice\n",
);
assert_eq!(shares.len(), 1);
assert_eq!(shares[0].path, "/srv/two spaces");
assert_eq!(shares[0].as_user, "alice");
}
#[test]
fn a_share_called_name_is_not_mistaken_for_the_header() {
let shares = parse_shares(
"name path as\n\
---- ---- --\n\
name /srv/awkward alice\n",
);
assert_eq!(shares.len(), 1);
assert_eq!(shares[0].name, "name");
assert_eq!(shares[0].path, "/srv/awkward");
}
#[tokio::test]
async fn listing_shares_reads_the_table() {
let (answer, argv) = against(
Reply::ok(SHARES),
|ctx, p| async move { drive_list(&ctx, p).await },
NoParams {},
)
.await;
assert_eq!(only(&argv), ["drive", "list"]);
assert_eq!(answer["shares"].as_array().expect("a list").len(), 2);
}
#[tokio::test]
async fn sharing_a_directory_passes_the_name_then_the_path() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { drive_share(&ctx, p).await },
DriveShareParams {
name: "docs".to_owned(),
path: "/srv/docs".to_owned(),
},
)
.await;
assert_eq!(only(&argv), ["drive", "share", "docs", "/srv/docs"]);
assert_eq!(answer["subject"], json!("docs"));
}
#[tokio::test]
async fn renaming_passes_the_old_name_then_the_new() {
let (_, argv) = against(
Reply::ok(""),
|ctx, p| async move { drive_rename(&ctx, p).await },
DriveRenameParams {
name: "docs".to_owned(),
new_name: "handbook".to_owned(),
},
)
.await;
assert_eq!(only(&argv), ["drive", "rename", "docs", "handbook"]);
}
#[tokio::test]
async fn unsharing_names_only_the_share() {
let (answer, argv) = against(
Reply::ok(""),
|ctx, p| async move { drive_unshare(&ctx, p).await },
DriveUnshareParams {
name: "docs".to_owned(),
},
)
.await;
assert_eq!(only(&argv), ["drive", "unshare", "docs"]);
assert!(
answer["outcome"]
.as_str()
.expect("an outcome")
.contains("untouched"),
"{answer:?}"
);
}
#[test]
fn nothing_here_is_reachable_from_a_read_only_session_except_the_two_readers() {
let readers: Vec<&str> = entries()
.iter()
.filter(|entry| entry.meta.tier == Tier::Read)
.map(|entry| entry.meta.name)
.collect();
assert_eq!(readers, ["tailscale_file_targets", "tailscale_drive_list"]);
}
#[test]
fn withdrawing_a_share_is_the_one_destructive_tool() {
let destructive: Vec<&str> = entries()
.iter()
.filter(|entry| entry.meta.tier == Tier::Destructive)
.map(|entry| entry.meta.name)
.collect();
assert_eq!(destructive, ["tailscale_drive_unshare"]);
}
#[test]
fn every_description_says_which_files_it_touches() {
for entry in entries()
.iter()
.filter(|entry| entry.meta.name != "tailscale_syspolicy_reload")
{
let summary = entry.meta.summary.to_lowercase();
assert!(
summary.contains("local filesystem")
|| summary.contains("file")
|| summary.contains("directories"),
"`{}` does not say what it touches: {}",
entry.meta.name,
entry.meta.summary
);
}
}
}