use std::fmt::Write as _;
use crate::context::ToolContext;
use crate::gating::Gate;
use crate::meta::{Surface, Tier, Toolset};
#[derive(Clone, Copy, Debug, Default)]
pub struct Offered {
confirmable: bool,
}
impl Offered {
pub fn new(registry: &crate::registry::Registry, gate: &Gate) -> Self {
Self {
confirmable: registry
.visible(gate)
.into_iter()
.any(|entry| entry.meta.takes_confirmation()),
}
}
}
pub fn render(gate: &Gate, ctx: &ToolContext, offered: Offered) -> String {
let mut out = String::with_capacity(1024);
out.push_str(
"This server drives Tailscale through two separate surfaces.\n\n\
Tools named `tailscale_*` act on the node this server runs on, through its \
command-line interface. They can only ever affect that one machine.\n\n\
Tools named `tailnet_*` act on the whole tailnet through the control-plane API. \
They affect every device and user in it, including this node.\n\n",
);
let local = gate.offers(Surface::Local);
let tailnet = gate.offers(Surface::Tailnet);
match (local, tailnet) {
(true, true) => {}
(true, false) => out.push_str(
"The tailnet surface is not available in this session, so no `tailnet_*` tool is \
offered. Questions about other devices, users, keys or policy cannot be answered \
here.\n\n",
),
(false, true) => out.push_str(
"The local surface is not available in this session, so no `tailscale_*` tool is \
offered. Nothing can be read from or changed on this machine directly.\n\n",
),
(false, false) => out.push_str("No tools are available in this session.\n\n"),
}
let _ = write!(out, "Permitted tier: {}. ", describe_tier(gate.max_tier()));
match gate.max_tier() {
Tier::Read => out.push_str(
"Only tools that change nothing are offered. Tools that would change or remove \
something are hidden, not merely refused, so a tool you cannot see does not exist \
for this session. Do not describe a change as done; say what would be needed.\n\n",
),
Tier::Write => out.push_str(
"Tools that change configuration are offered. Tools that remove something, or \
expose it to the public internet, are hidden.\n\n",
),
Tier::Destructive => out.push_str(
"Every tier is offered, including tools that delete, revoke, or publish to the \
public internet. Prefer reading before writing, and say what a call will do before \
making it.\n\n",
),
}
if gate
.offered_toolsets()
.any(|toolset| toolset == Toolset::LocalPassthrough)
{
out.push_str(
"`tailscale_run` is the exception to the paragraph above. It runs a `tailscale` \
subcommand no other tool covers, so what it does is decided by its arguments \
rather than by the tool, and it is annotated as destructive for that reason \
alone. It is still held to the permitted tier, command by command, and it \
refuses outright the commands this server never runs. Prefer a typed tool \
wherever one exists.\n\n",
);
}
if offered.confirmable {
out.push_str(
"Some tools take a `confirm` argument. They are the ones that cannot be undone, or \
that can cut this server off from the tailnet it is managing. They refuse to run \
without it. Set it only when the person you are working for has asked for that \
specific action; it is not a formality to be filled in.\n\n",
);
}
if local || tailnet {
out.push_str(
"Identifiers: a device can be named by its MagicDNS name, the short name before \
the first dot of it, its hostname, or one of its Tailscale IP addresses.",
);
if tailnet {
out.push_str(
" The `tailnet_*` tools also take the node ID (`n1234567CNTRL`) and the \
numeric id a listing reports, and answer with those; a name is resolved \
against the tailnet's device list, and a name matching more than one device \
is refused rather than guessed at.",
);
}
out.push_str("\n\n");
}
if let Some(version) = ctx.cli_version {
let _ = writeln!(
out,
"The local `tailscale` binary reports version {version}."
);
}
if let Some(name) = ctx.identity.last_known().dns_name.as_deref() {
let _ = writeln!(out, "This node is {}.", name.trim_end_matches('.'));
}
let _ = write!(
out,
"\nToolsets offered: {}.",
gate.offered_toolsets()
.map(|toolset| toolset.as_str())
.collect::<Vec<_>>()
.join(", ")
);
out
}
const fn describe_tier(tier: Tier) -> &'static str {
match tier {
Tier::Read => "read",
Tier::Write => "read and write",
Tier::Destructive => "read, write and destructive",
}
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use std::sync::Arc;
use tailscale_cli::Unavailable;
use super::*;
use crate::context::{Identity, PathPolicy, SelfIdentity};
use crate::error::Redactor;
use crate::gating::Preset;
use crate::meta::Toolset;
use crate::version::Version;
fn context() -> ToolContext {
ToolContext {
local: Arc::new(Unavailable::default()),
tailnet: None,
redactor: Redactor::default(),
max_result_bytes: 1 << 20,
identity: SelfIdentity {
dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
..SelfIdentity::default()
}
.into(),
cli_version: Some(Version::new(1, 102, 2)),
paths: PathPolicy::default(),
devices: Default::default(),
max_tier: Tier::Destructive,
}
}
fn gate(toolsets: BTreeSet<Toolset>, tier: Tier) -> Gate {
Gate::unchecked(toolsets, tier, BTreeSet::new())
}
#[test]
fn a_surface_that_was_asked_for_and_is_not_there_is_stated_too() {
let both = BTreeSet::from([Toolset::LocalStatus, Toolset::TailnetDevices]);
let no_control_plane =
Gate::unchecked(both.clone(), Tier::Read, BTreeSet::from([Surface::Tailnet]));
let text = render(&no_control_plane, &context(), Offered::default());
assert!(text.contains("tailnet surface is not available"), "{text}");
assert!(!text.contains("local surface is not available"), "{text}");
let no_cli = Gate::unchecked(both, Tier::Read, BTreeSet::from([Surface::Local]));
let text = render(&no_cli, &context(), Offered::default());
assert!(text.contains("local surface is not available"), "{text}");
}
#[test]
fn the_tier_is_stated_and_hiding_is_explained() {
let text = render(
&gate(Preset::Core.toolsets(), Tier::Read),
&context(),
Offered::default(),
);
assert!(text.contains("Permitted tier: read."), "{text}");
assert!(text.contains("hidden, not merely refused"), "{text}");
let text = render(
&gate(Preset::Core.toolsets(), Tier::Destructive),
&context(),
Offered::default(),
);
assert!(text.contains("read, write and destructive"), "{text}");
}
#[test]
fn what_was_learned_at_startup_is_passed_on() {
let text = render(
&gate(Preset::Core.toolsets(), Tier::Read),
&context(),
Offered::default(),
);
assert!(text.contains("1.102.2"), "{text}");
assert!(
text.contains("This node is workstation.example-tailnet.ts.net.\n"),
"{text}"
);
assert!(
!text.contains("ts.net.."),
"the trailing dot of a fully-qualified name should not be doubled: {text}"
);
}
#[test]
fn nothing_is_claimed_that_was_not_learned() {
let bare = ToolContext {
identity: Identity::default(),
cli_version: None,
paths: PathPolicy::default(),
..context()
};
let text = render(
&gate(Preset::Core.toolsets(), Tier::Read),
&bare,
Offered::default(),
);
assert!(!text.contains("reports version"), "{text}");
assert!(!text.contains("This node is"), "{text}");
}
}