1use std::fmt::Write as _;
10
11use crate::context::ToolContext;
12use crate::gating::Gate;
13use crate::meta::{Surface, Tier, Toolset};
14
15#[derive(Clone, Copy, Debug, Default)]
25pub struct Offered {
26 confirmable: bool,
27}
28
29impl Offered {
30 pub fn new(registry: &crate::registry::Registry, gate: &Gate) -> Self {
32 Self {
33 confirmable: registry
34 .visible(gate)
35 .into_iter()
36 .any(|entry| entry.meta.takes_confirmation()),
37 }
38 }
39}
40
41pub fn render(gate: &Gate, ctx: &ToolContext, offered: Offered) -> String {
43 let mut out = String::with_capacity(1024);
44 out.push_str(
45 "This server drives Tailscale through two separate surfaces.\n\n\
46 Tools named `tailscale_*` act on the node this server runs on, through its \
47 command-line interface. They can only ever affect that one machine.\n\n\
48 Tools named `tailnet_*` act on the whole tailnet through the control-plane API. \
49 They affect every device and user in it, including this node.\n\n",
50 );
51
52 let local = gate.offers(Surface::Local);
57 let tailnet = gate.offers(Surface::Tailnet);
58 if !tailnet {
59 out.push_str(
60 "The tailnet surface is not available in this session, so no `tailnet_*` tool is \
61 offered. Questions about other devices, users, keys or policy cannot be answered \
62 here.\n\n",
63 );
64 }
65 if !local {
66 out.push_str(
67 "The local surface is not available in this session, so no `tailscale_*` tool is \
68 offered. Nothing can be read from or changed on this machine directly.\n\n",
69 );
70 }
71
72 let _ = write!(out, "Permitted tier: {}. ", describe_tier(gate.max_tier()));
73 match gate.max_tier() {
74 Tier::Read => out.push_str(
75 "Only tools that change nothing are offered. Tools that would change or remove \
76 something are hidden, not merely refused, so a tool you cannot see does not exist \
77 for this session. Do not describe a change as done; say what would be needed.\n\n",
78 ),
79 Tier::Write => out.push_str(
80 "Tools that change configuration are offered. Tools that remove something, or \
81 expose it to the public internet, are hidden.\n\n",
82 ),
83 Tier::Destructive => out.push_str(
84 "Every tier is offered, including tools that delete, revoke, or publish to the \
85 public internet. Prefer reading before writing, and say what a call will do before \
86 making it.\n\n",
87 ),
88 }
89
90 if gate
95 .offered_toolsets()
96 .any(|toolset| toolset == Toolset::LocalPassthrough)
97 {
98 out.push_str(
103 "`tailscale_run` is the exception to the paragraph above. It runs a `tailscale` \
104 subcommand no other tool covers, so what it does is decided by its arguments \
105 rather than by the tool, and it is annotated as destructive for that reason \
106 alone. It is still held to the permitted tier, command by command, and it \
107 refuses outright the commands this server never runs. Prefer a typed tool \
108 wherever one exists.\n\n",
109 );
110 }
111
112 if offered.confirmable {
117 out.push_str(
118 "Some tools take a `confirm` argument. They are the ones that cannot be undone, or \
119 that can cut this server off from the tailnet it is managing. They refuse to run \
120 without it. Set it only when the person you are working for has asked for that \
121 specific action; it is not a formality to be filled in.\n\n",
122 );
123 }
124
125 if local || tailnet {
134 out.push_str(
135 "Identifiers: a device can be named by its MagicDNS name, the short name before \
136 the first dot of it, its hostname, or one of its Tailscale IP addresses.",
137 );
138 if tailnet {
139 out.push_str(
140 " The `tailnet_*` tools also take the node ID (`n1234567CNTRL`) and the \
141 numeric id a listing reports, and answer with those; a name is resolved \
142 against the tailnet's device list, and a name matching more than one device \
143 is refused rather than guessed at.",
144 );
145 }
146 out.push_str("\n\n");
147 }
148
149 if let Some(version) = ctx.cli_version {
150 let _ = writeln!(
151 out,
152 "The local `tailscale` binary reports version {version}."
153 );
154 }
155 if let Some(name) = ctx.identity.last_known().dns_name.as_deref() {
156 let _ = writeln!(out, "This node is {}.", name.trim_end_matches('.'));
157 }
158
159 let _ = write!(
160 out,
161 "\nToolsets offered: {}.",
162 gate.offered_toolsets()
163 .map(|toolset| toolset.as_str())
164 .collect::<Vec<_>>()
165 .join(", ")
166 );
167 out
168}
169
170const fn describe_tier(tier: Tier) -> &'static str {
171 match tier {
172 Tier::Read => "read",
173 Tier::Write => "read and write",
174 Tier::Destructive => "read, write and destructive",
175 }
176}
177
178#[cfg(test)]
179mod tests {
180 use std::collections::BTreeSet;
181 use std::sync::Arc;
182
183 use tailscale_cli::Unavailable;
184
185 use super::*;
186 use crate::context::{Identity, PathPolicy, SelfIdentity};
187 use crate::error::Redactor;
188 use crate::gating::Preset;
189 use crate::meta::Toolset;
190 use crate::version::Version;
191
192 fn context() -> ToolContext {
193 ToolContext {
194 local: Arc::new(Unavailable::default()),
195 tailnet: None,
196 redactor: Redactor::default(),
197 max_result_bytes: 1 << 20,
198 identity: SelfIdentity {
199 dns_name: Some("workstation.example-tailnet.ts.net.".to_owned()),
200 ..SelfIdentity::default()
201 }
202 .into(),
203 cli_version: Some(Version::new(1, 102, 2)),
204 paths: PathPolicy::default(),
205 devices: Default::default(),
206 max_tier: Tier::Destructive,
207 }
208 }
209
210 fn gate(toolsets: BTreeSet<Toolset>, tier: Tier) -> Gate {
211 Gate::unchecked(toolsets, tier, BTreeSet::new())
212 }
213
214 #[test]
215 fn a_surface_that_was_asked_for_and_is_not_there_is_stated_too() {
216 let both = BTreeSet::from([Toolset::LocalStatus, Toolset::TailnetDevices]);
221 let no_control_plane =
222 Gate::unchecked(both.clone(), Tier::Read, BTreeSet::from([Surface::Tailnet]));
223 let text = render(&no_control_plane, &context(), Offered::default());
224 assert!(text.contains("tailnet surface is not available"), "{text}");
225 assert!(!text.contains("local surface is not available"), "{text}");
226
227 let no_cli = Gate::unchecked(both, Tier::Read, BTreeSet::from([Surface::Local]));
228 let text = render(&no_cli, &context(), Offered::default());
229 assert!(text.contains("local surface is not available"), "{text}");
230 }
231
232 #[test]
233 fn the_tier_is_stated_and_hiding_is_explained() {
234 let text = render(
235 &gate(Preset::Core.toolsets(), Tier::Read),
236 &context(),
237 Offered::default(),
238 );
239 assert!(text.contains("Permitted tier: read."), "{text}");
240 assert!(text.contains("hidden, not merely refused"), "{text}");
241
242 let text = render(
243 &gate(Preset::Core.toolsets(), Tier::Destructive),
244 &context(),
245 Offered::default(),
246 );
247 assert!(text.contains("read, write and destructive"), "{text}");
248 }
249
250 #[test]
251 fn what_was_learned_at_startup_is_passed_on() {
252 let text = render(
253 &gate(Preset::Core.toolsets(), Tier::Read),
254 &context(),
255 Offered::default(),
256 );
257 assert!(text.contains("1.102.2"), "{text}");
258 assert!(
259 text.contains("This node is workstation.example-tailnet.ts.net.\n"),
260 "{text}"
261 );
262 assert!(
263 !text.contains("ts.net.."),
264 "the trailing dot of a fully-qualified name should not be doubled: {text}"
265 );
266 }
267
268 #[test]
269 fn nothing_is_claimed_that_was_not_learned() {
270 let bare = ToolContext {
271 identity: Identity::default(),
272 cli_version: None,
273 paths: PathPolicy::default(),
274 ..context()
275 };
276 let text = render(
277 &gate(Preset::Core.toolsets(), Tier::Read),
278 &bare,
279 Offered::default(),
280 );
281 assert!(!text.contains("reports version"), "{text}");
282 assert!(!text.contains("This node is"), "{text}");
283 }
284}