launcher: Friends over the internet: Tailscale status, invitations and a safe access rule

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Ludovic Marie
2026-10-19 14:16:53 +02:00
parent 4768d3f65b
commit a8a00aac94
6 changed files with 1519 additions and 10 deletions
+707
View File
@@ -0,0 +1,707 @@
//! The Servers page's "Friends over the internet" panel: Tailscale on this computer, the address friends
//! type, inviting a friend to this one computer, and keeping shared friends to the server's port
//! (`horizon_launcher::tailscale`). Signing in is always the player's, in Tailscale's own app or page;
//! the launcher reads `tailscale status` and, with the player's own access token, calls the API.
use super::servers_page::Field;
use super::*;
use horizon_launcher::tailscale::{self, Api, Invite, Reach, State};
use serde_json::Value;
/// How often Tailscale's state is read while the panel shows.
const POLL: Duration = Duration::from_secs(10);
pub(super) enum Event {
State(State),
Token(Option<String>),
Invites(Result<Vec<Invite>, String>),
Policy(Result<(Value, Option<String>), String>),
/// An action finished: what to tell the player, and whether to read invites and policy again.
Done(Result<String, String>),
Invited(Result<Invite, String>),
}
pub(super) struct Internet {
state: Option<State>,
reading: bool,
read: Option<Instant>,
/// The access token, once read from the keychain (None: not read yet; Some(None): none).
token: Option<Option<String>>,
asking_token: bool,
invites: Option<Result<Vec<Invite>, String>>,
policy: Option<Result<(Value, Option<String>), String>>,
busy: bool,
message: Option<Result<String, String>>,
/// The invitation just made, its link shown to copy.
fresh: Option<Invite>,
confirm_limit: bool,
copied: Option<(String, Instant)>,
tx: Sender<Event>,
rx: Receiver<Event>,
}
impl Internet {
pub(super) fn new() -> Self {
let (tx, rx) = channel();
Self {
state: None,
reading: false,
read: None,
token: None,
asking_token: false,
invites: None,
policy: None,
busy: false,
message: None,
fresh: None,
confirm_limit: false,
copied: None,
tx,
rx,
}
}
fn node(&self) -> Option<&tailscale::Node> {
match &self.state {
Some(State::Running(n)) => Some(n),
_ => None,
}
}
fn token(&self) -> Option<String> {
self.token.clone().flatten()
}
}
impl Launcher {
/// What the background sent, and the next look at Tailscale while the panel shows.
pub(super) fn internet_tick(&mut self, showing: bool) -> bool {
let mut changed = false;
while let Ok(event) = self.internet.rx.try_recv() {
changed = true;
self.internet_event(event);
}
let i = &mut self.internet;
if showing && !i.reading && i.read.is_none_or(|at| at.elapsed() >= POLL) {
self.internet_refresh();
}
changed
}
fn internet_refresh(&mut self) {
let i = &mut self.internet;
i.reading = true;
i.read = Some(Instant::now());
let tx = i.tx.clone();
let want_token = i.token.is_none();
std::thread::spawn(move || {
let _ = tx.send(Event::State(tailscale::status()));
if want_token {
let token = account::store().get(tailscale::TOKEN_KEY).ok().flatten();
let _ = tx.send(Event::Token(token));
}
});
}
fn internet_event(&mut self, event: Event) {
let i = &mut self.internet;
match event {
Event::State(state) => {
i.reading = false;
let was_running = i.node().map(|n| n.id.clone());
i.state = Some(state);
let now = i.node().map(|n| n.id.clone());
if now.is_some() && now != was_running {
self.internet_load();
}
}
Event::Token(token) => {
i.token = Some(token);
self.internet_load();
}
Event::Invites(r) => i.invites = Some(r),
Event::Policy(r) => i.policy = Some(r),
Event::Done(r) => {
i.busy = false;
i.message = Some(r);
self.internet_load();
}
Event::Invited(r) => {
i.busy = false;
match r {
Ok(invite) => {
i.message = None;
i.fresh = Some(invite);
self.servers.text(Field::Email).clear();
}
Err(e) => i.message = Some(Err(e)),
}
self.internet_load();
}
}
}
/// Reads the invitations and the access controls (needs the token and a running Tailscale).
fn internet_load(&mut self) {
let i = &self.internet;
let (Some(token), Some(node)) = (i.token(), i.node().cloned()) else {
return;
};
let tx = i.tx.clone();
std::thread::spawn(move || {
let api = Api::new(&token);
let _ = tx.send(Event::Invites(api.invites(&node.id)));
let _ = tx.send(Event::Policy(api.policy()));
});
}
pub(super) fn save_token(&mut self) {
let token = self.servers.value(Field::Token).trim().to_string();
if !token.starts_with("tskey-") {
self.internet.message = Some(Err(
"Paste the access token Tailscale showed (it begins with tskey-).".into(),
));
return;
}
self.servers.text(Field::Token).clear();
self.internet.asking_token = false;
self.internet.message = match account::store().set(tailscale::TOKEN_KEY, &token) {
Ok(()) if account::store().persistent() => None,
Ok(()) => Some(Ok(
"No keychain here: the token is kept until the launcher closes.".into(),
)),
Err(e) => Some(Err(e)),
};
self.internet.token = Some(Some(token));
self.internet_load();
}
fn forget_token(&mut self) {
let _ = account::store().delete(tailscale::TOKEN_KEY);
let i = &mut self.internet;
i.token = Some(None);
i.invites = None;
i.policy = None;
i.fresh = None;
i.message = Some(Ok(
"The access token is forgotten. Delete it in Tailscale's admin \
console too (Settings, Keys) if you won't use it again."
.into(),
));
}
pub(super) fn invite_friend(&mut self) {
let i = &mut self.internet;
let (Some(token), Some(node)) = (i.token(), i.node().cloned()) else {
return;
};
if i.busy {
return;
}
let email = self.servers.value(Field::Email).trim().to_string();
if !email.is_empty() && !email.contains('@') {
i.message = Some(Err("That isn't an email address.".into()));
return;
}
i.busy = true;
i.fresh = None;
let tx = i.tx.clone();
std::thread::spawn(move || {
let email = Some(email.as_str()).filter(|e| !e.is_empty());
let _ = tx.send(Event::Invited(Api::new(&token).invite(&node.id, email)));
});
}
fn internet_action(
&mut self,
job: impl FnOnce(&Api) -> Result<String, String> + Send + 'static,
) {
let i = &mut self.internet;
let Some(token) = i.token() else {
return;
};
if i.busy {
return;
}
i.busy = true;
i.message = None;
let tx = i.tx.clone();
std::thread::spawn(move || {
let _ = tx.send(Event::Done(job(&Api::new(&token))));
});
}
/// Applies `change` to the access controls read at `etag`, checked by Tailscale first.
fn change_policy(&mut self, new: Value, etag: Option<String>, said: &'static str) {
self.internet.confirm_limit = false;
self.internet_action(move |api| {
api.validate(&new)?;
api.set_policy(&new, etag.as_deref())?;
Ok(said.to_string())
});
}
fn copy_button(
&self,
id: &'static str,
text: String,
kind: Kind,
cx: &mut Context<Self>,
) -> Stateful<Div> {
let copied = self
.internet
.copied
.as_ref()
.is_some_and(|(what, at)| what == &text && at.elapsed() < Duration::from_secs(2));
button(
id,
if copied { "Copied" } else { "Copy" },
kind,
&self.theme,
)
.bg(self.theme.plate)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
cx.write_to_clipboard(gpui::ClipboardItem::new_string(text.clone()));
this.internet.copied = Some((text.clone(), Instant::now()));
cx.notify();
}))
}
pub(super) fn internet_panel(&mut self, port: u16, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let mut panel = plate(&t)
.flex()
.flex_col()
.gap(px(12.))
.p(px(20.))
.child(caps("Friends over the internet · Tailscale").text_color(t.muted));
let line = |s: String| text(theme::CAPTION).text_color(t.muted).child(s);
let check = button("ts-check", "Check again", Kind::Ghost, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.internet_refresh();
cx.notify();
},
));
match self.internet.state.clone() {
None => return panel.child(line("Looking for Tailscale…".into())),
Some(State::Missing) => {
return panel
.child(text(theme::UI).text_color(t.ink).child(
"Tailscale lets friends anywhere join without opening your router to the \
internet. It's free: install it, sign in, then invite them here.",
))
.child(
div()
.flex()
.gap(px(8.))
.child(
button("ts-install", "Install Tailscale", Kind::Secondary, &t)
.bg(t.plate)
.on_click(|_, _, cx| cx.open_url(tailscale::install_url())),
)
.child(check),
);
}
Some(State::Off(why)) => {
let open = button("ts-open", "Open Tailscale", Kind::Secondary, &t)
.bg(t.plate)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
if let Err(e) = tailscale::open_app() {
this.internet.message = Some(Err(e));
}
this.internet.read = None;
cx.notify();
}));
panel =
panel
.child(text(theme::UI).text_color(t.ink).child(
"Tailscale is installed but not running. Open it and switch it on.",
))
.when(!why.is_empty(), |d| d.child(line(why)))
.child(div().flex().gap(px(8.)).child(open).child(check));
return self.internet_message(panel);
}
Some(State::SignedOut { auth_url }) => {
let sign_in = button("ts-login", "Sign in to Tailscale", Kind::Secondary, &t)
.bg(t.plate)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
match &auth_url {
Some(url) => cx.open_url(url),
None => {
if let Err(e) = tailscale::open_app() {
this.internet.message = Some(Err(e));
}
}
}
this.internet.read = None;
cx.notify();
}));
panel = panel
.child(text(theme::UI).text_color(t.ink).child(
"Tailscale is installed. Sign in with your own account in Tailscale \
(Horizon never sees it), then come back here.",
))
.when(cfg!(target_os = "linux"), |d| {
d.child(line(format!(
"On Linux, run `{}` in a terminal and open the link it prints.",
tailscale::LINUX_LOGIN
)))
})
.child(div().flex().gap(px(8.)).child(sign_in).child(check));
return self.internet_message(panel);
}
Some(State::Running(_)) => {}
}
let node = self.internet.node().cloned().unwrap_or_default();
let address = node.address(port);
let ip = node.ipv4().unwrap_or_default().to_string();
let mut where_ = div().flex().flex_col().gap(px(6.)).child(
div()
.flex()
.items_center()
.gap(px(12.))
.child(
text(theme::TITLE)
.font_family(theme::MONO)
.text_color(t.ink)
.child(address.clone()),
)
.child(self.copy_button("ts-copy", address, Kind::Secondary, cx)),
);
if node.magic_dns && !ip.is_empty() {
let ip_port = if port == 25565 {
ip.clone()
} else {
format!("{ip}:{port}")
};
where_ = where_.child(line(format!(
"Or {ip_port}. Friends type it in Multiplayer, Add Server, once this computer is \
shared with them and Tailscale runs on theirs."
)));
} else {
where_ = where_.child(line(
"Friends type it in Multiplayer, Add Server, once this computer is shared with \
them and Tailscale runs on theirs."
.into(),
));
}
panel = panel.child(where_);
panel = panel.child(
div()
.border_t_1()
.border_color(t.rule)
.pt(px(12.))
.flex()
.flex_col()
.gap(px(10.))
.child(text(theme::UI).text_color(t.ink).child("Invite a friend"))
.child(self.invite_section(&ip, port, cx)),
);
panel = panel.child(line(
"Horizon never turns on Funnel: the server isn't put on the public internet, only \
shared with the people you invite."
.into(),
));
self.internet_message(panel)
}
fn internet_message(&self, panel: Div) -> Div {
let t = self.theme;
match &self.internet.message {
Some(Ok(m)) => {
panel.child(text(theme::CAPTION).text_color(t.ink_soft).child(m.clone()))
}
Some(Err(e)) => panel.child(text(theme::CAPTION).text_color(t.danger).child(e.clone())),
None => panel,
}
}
fn invite_section(&mut self, ip: &str, port: u16, cx: &mut Context<Self>) -> Div {
let t = self.theme;
let line = |s: String| text(theme::CAPTION).text_color(t.muted).child(s);
let link = |id: &'static str, label: &'static str, url: &'static str| {
button(id, label, Kind::Ghost, &t).on_click(move |_, _, cx| cx.open_url(url))
};
let token = self.internet.token();
if token.is_none() {
let mut d = div().flex().flex_col().gap(px(10.)).child(line(
"Share this computer with a friend: they get it in their own Tailscale, and \
nothing else of yours. Do it in Tailscale's admin console (Machines, this \
computer, Share), or here with an access token you make there."
.into(),
));
let machines = link(
"ts-console",
"Open the admin console",
"https://login.tailscale.com/admin/machines",
);
if self.internet.asking_token {
let save = button("ts-token-save", "Save", Kind::Secondary, &t)
.bg(t.plate)
.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.save_token();
cx.notify();
}));
d = d
.child(
div()
.flex()
.items_center()
.gap(px(8.))
.child(self.field(Field::Token, "tskey-api-…", 300., cx))
.child(save)
.child(link("ts-keys", "Make a token", tailscale::KEYS)),
)
.child(line(
"Settings, Keys, Generate access token. It's kept in this computer's \
keychain and only sent to Tailscale."
.into(),
));
} else {
d = d.child(div().flex().gap(px(8.)).child(machines).child(
button("ts-token", "Use an access token…", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.internet.asking_token = true;
this.internet.message = None;
cx.notify();
}),
),
));
}
return d;
}
// Invite.
let busy = self.internet.busy;
let invite = button("ts-invite", "Make an invitation", Kind::Secondary, &t).bg(t.plate);
let invite = if busy {
disabled(invite)
} else {
invite.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.invite_friend();
cx.notify();
}))
};
let mut d = div()
.flex()
.flex_col()
.gap(px(10.))
.child(
div()
.flex()
.items_center()
.gap(px(8.))
.child(self.field(Field::Email, "Their email (optional)", 260., cx))
.child(invite),
)
.child(line(
"One invitation per friend. With an email Tailscale sends it; without, send them \
the link. They accept it in their own Tailscale account."
.into(),
));
if let Some(fresh) = self.internet.fresh.clone() {
d = d.child(
div()
.flex()
.items_center()
.gap(px(10.))
.child(
text(theme::CAPTION)
.font_family(theme::MONO)
.text_color(t.ink)
.truncate()
.child(fresh.url.clone()),
)
.child(self.copy_button("ts-fresh-copy", fresh.url, Kind::Secondary, cx)),
);
}
match self.internet.invites.clone() {
Some(Ok(list)) if !list.is_empty() => {
let mut rows = div().flex().flex_col().border_t_1().border_color(t.rule);
for (n, inv) in list.into_iter().enumerate() {
let who = match (&inv.accepted_by, &inv.email) {
(Some(by), _) => format!("Shared with {by}"),
(None, Some(email)) => format!("Sent to {email}, not accepted yet"),
(None, None) => "Link not used yet".to_string(),
};
let id = inv.id.clone();
let withdraw = button(
SharedString::from(format!("ts-revoke-{n}")),
if inv.accepted {
"Stop sharing"
} else {
"Withdraw"
},
Kind::Ghost,
&t,
);
let withdraw = if busy {
disabled(withdraw)
} else {
withdraw.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
let id = id.clone();
this.internet.fresh = None;
this.internet_action(move |api| {
api.revoke(&id)?;
Ok("Withdrawn: that friend can't reach this computer any more."
.into())
});
cx.notify();
}))
};
let date = inv.created.get(..10).unwrap_or_default().to_string();
rows = rows.child(index_row(
n + 1,
who,
Some(format!("Made {date}").into_any_element()),
Some(withdraw.into_any_element()),
&t,
));
}
d = d.child(rows);
}
Some(Err(e)) => d = d.child(text(theme::CAPTION).text_color(t.danger).child(e)),
_ => {}
}
// What shared friends reach.
let reach = match &self.internet.policy {
Some(Ok((policy, etag))) => Some((
tailscale::reach(policy, ip, port),
policy.clone(),
etag.clone(),
)),
Some(Err(e)) => {
d = d.child(line(format!("Couldn't read your access controls: {e}")));
None
}
None => None,
};
if let Some((reach, policy, etag)) = reach {
let mut safety = div().flex().flex_col().gap(px(8.)).pt(px(4.));
match reach {
Reach::Everything => {
safety = safety.child(text(theme::UI).text_color(t.ink).child(
"Friends you share this computer with can reach all of it, not only \
Minecraft (Tailscale's default).",
));
if self.internet.confirm_limit {
let new = tailscale::limit_shared(&policy, ip, port);
safety = safety
.child(line(format!(
"This changes your tailnet's access controls: the rule that lets \
everyone reach everything becomes one for your own devices and \
members, and friends you share with reach only {ip}:{port}."
)))
.child(
div()
.flex()
.gap(px(8.))
.child(
button("ts-limit-go", "Change the access controls", Kind::Primary, &t)
.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if let Some(new) = new.clone() {
this.change_policy(
new,
etag.clone(),
"Done: friends you share this computer with reach only the Minecraft server.",
);
}
cx.notify();
})),
)
.child(button("ts-limit-cancel", "Cancel", Kind::Ghost, &t).on_click(
cx.listener(|this, _: &ClickEvent, _, cx| {
this.internet.confirm_limit = false;
cx.notify();
}),
)),
);
} else {
let b = button("ts-limit", "Limit them to Minecraft", Kind::Secondary, &t)
.bg(t.plate);
safety = safety.child(div().flex().child(if busy {
disabled(b)
} else {
b.on_click(cx.listener(|this, _: &ClickEvent, _, cx| {
this.internet.confirm_limit = true;
cx.notify();
}))
}));
}
}
Reach::ServerOnly => {
let undo = tailscale::unlimit_shared(&policy, ip, port);
let b = button("ts-unlimit", "Remove the limit", Kind::Ghost, &t);
safety = safety.child(
div()
.flex()
.items_center()
.gap(px(12.))
.child(div().flex_none().size(px(6.)).rounded_full().bg(t.success))
.child(
text(theme::UI)
.flex_1()
.text_color(t.ink)
.child(format!(
"Friends you share with reach only the Minecraft server (port {port})."
)),
)
.child(if busy {
disabled(b)
} else {
b.on_click(cx.listener(move |this, _: &ClickEvent, _, cx| {
if let Some(undo) = undo.clone() {
this.change_policy(
undo,
etag.clone(),
"Tailscale's default access controls are back.",
);
}
cx.notify();
}))
}),
);
}
Reach::Custom => {
let snippet = tailscale::snippet(ip, port);
safety = safety
.child(text(theme::UI).text_color(t.ink).child(
"Your access controls are your own, so Horizon leaves them alone. To \
keep shared friends to Minecraft, add this rule in the admin console:",
))
.child(
div()
.p(px(12.))
.rounded(px(theme::RADIUS))
.bg(t.plate)
.font_family(theme::MONO)
.text_size(px(theme::CAPTION.px))
.text_color(t.ink_soft)
.child(snippet.clone()),
)
.child(
div()
.flex()
.gap(px(8.))
.child(self.copy_button(
"ts-snippet-copy",
snippet,
Kind::Secondary,
cx,
))
.child(link("ts-acls", "Open access controls", tailscale::ACCESS)),
);
}
}
d = d.child(safety);
}
d.child(div().flex().child(
button("ts-forget", "Forget the access token", Kind::Ghost, &t).on_click(cx.listener(
|this, _: &ClickEvent, _, cx| {
this.forget_token();
cx.notify();
},
)),
))
}
}
+1
View File
@@ -7,4 +7,5 @@ pub mod modpacks;
pub mod mojang;
pub mod servers;
pub mod sources;
pub mod tailscale;
pub mod telemetry;
+4
View File
@@ -4,6 +4,7 @@
mod cli;
mod collection;
mod game_tools;
mod internet;
mod markdown;
mod mcp;
mod modpacks_page;
@@ -133,6 +134,8 @@ struct Launcher {
crash: Option<Crash>,
/// The Servers page (`servers_page.rs`).
servers: servers_page::Servers,
/// Its Friends over the internet panel (`internet.rs`).
internet: internet::Internet,
/// The window's own focus, so Tab reaches the controls when nothing else has the keyboard.
root_focus: FocusHandle,
/// Microsoft accounts (accounts.json), the sign-in under way and what it last showed.
@@ -256,6 +259,7 @@ impl Launcher {
client_home: None,
crash: None,
servers: servers_page::Servers::new(),
internet: internet::Internet::new(),
root_focus: cx.focus_handle(),
accounts: Accounts::load(),
sign_in: None,
+28 -10
View File
@@ -30,10 +30,12 @@ pub(super) enum Field {
AddPlayer,
Motd,
Port,
Token,
Email,
}
impl Field {
const ALL: [Field; 7] = [
const ALL: [Field; 9] = [
Field::Name,
Field::Seed,
Field::Players,
@@ -41,6 +43,8 @@ impl Field {
Field::AddPlayer,
Field::Motd,
Field::Port,
Field::Token,
Field::Email,
];
}
@@ -76,7 +80,7 @@ const DIFFICULTIES: [(&str, &str); 4] = [
pub(super) enum Event {
Polled {
dir: PathBuf,
status: Status,
status: Box<Status>,
logs: Option<(u64, Vec<String>)>,
},
Statuses(Vec<(PathBuf, Status)>),
@@ -192,11 +196,11 @@ impl Servers {
}
}
fn text(&mut self, f: Field) -> &mut String {
pub(super) fn text(&mut self, f: Field) -> &mut String {
self.texts.entry(f).or_default()
}
fn value(&self, f: Field) -> &str {
pub(super) fn value(&self, f: Field) -> &str {
self.texts.get(&f).map_or("", String::as_str)
}
@@ -384,7 +388,10 @@ impl Launcher {
/// Called with the launcher's other work: what the background sent, and the next status poll.
pub(super) fn servers_tick(&mut self) -> bool {
let mut changed = false;
let overview = self.page == Page::Servers
&& self.servers.open.is_some()
&& self.servers.tab == Tab::Overview;
let mut changed = self.internet_tick(overview);
while let Ok(event) = self.servers.rx.try_recv() {
changed = true;
self.servers_event(event);
@@ -417,7 +424,11 @@ impl Launcher {
.and_then(|a| a.status())
.unwrap_or_else(Status::stopped);
let logs = api.and_then(|a| a.logs(since));
let _ = tx.send(Event::Polled { dir, status, logs });
let _ = tx.send(Event::Polled {
dir,
status: Box::new(status),
logs,
});
});
}
None => {
@@ -463,7 +474,7 @@ impl Launcher {
s.log_next = next;
}
let read_pvp = status.phase == "running" && s.pvp.is_none();
s.statuses.insert(dir, status);
s.statuses.insert(dir, *status);
if read_pvp {
self.read_pvp();
}
@@ -774,7 +785,7 @@ impl Launcher {
}
/// A one-line text field: typing, paste, Backspace, Enter to confirm, Escape, Tab to the next.
fn field(
pub(super) fn field(
&mut self,
f: Field,
placeholder: &'static str,
@@ -787,7 +798,11 @@ impl Launcher {
let value = self.servers.value(f).to_string();
let shown: String = {
let chars: Vec<char> = value.chars().collect();
chars[chars.len().saturating_sub(60)..].iter().collect()
if f == Field::Token {
"•".repeat(chars.len().min(40))
} else {
chars[chars.len().saturating_sub(60)..].iter().collect()
}
};
div()
.id(SharedString::from(format!("field-{f:?}")))
@@ -883,6 +898,8 @@ impl Launcher {
}
}
Field::Port => self.save_port(),
Field::Token => self.save_token(),
Field::Email => self.invite_friend(),
}
}
@@ -1526,7 +1543,8 @@ impl Launcher {
.child(row),
);
}
tab.child(self.server_console(status.running(), cx))
tab.child(self.internet_panel(port as u16, cx))
.child(self.server_console(status.running(), cx))
}
fn server_console(&mut self, running: bool, cx: &mut Context<Self>) -> Div {
+509
View File
@@ -0,0 +1,509 @@
//! Friends over the internet through Tailscale: is it installed and signed in, this computer's name and
//! address on the tailnet, and, with the player's own API access token, inviting a friend to this one
//! computer (device sharing) and limiting what shared friends reach to the server's port.
//!
//! The launcher never signs in for anyone and never uses Funnel: signing in happens in Tailscale's own app
//! or page, and the access token is one the player makes in the admin console and pastes here (it is
//! kept in the system keychain). `HORIZON_LAUNCHER_TAILSCALE` (the CLI) and `HORIZON_TAILSCALE_API` (the
//! API's address) point the tests at stand-ins.
use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Command;
use std::time::Duration;
/// The admin console: sign-in, keys, machines, the access controls.
pub const ADMIN: &str = "https://login.tailscale.com/admin";
/// Where an API access token is made.
pub const KEYS: &str = "https://login.tailscale.com/admin/settings/keys";
/// The access controls page.
pub const ACCESS: &str = "https://login.tailscale.com/admin/acls/file";
/// The keychain entry of the player's API access token.
pub const TOKEN_KEY: &str = "tailscale-api-token";
/// Where to download Tailscale for this system.
pub fn install_url() -> &'static str {
if cfg!(target_os = "macos") {
"https://tailscale.com/download/mac"
} else if cfg!(windows) {
"https://tailscale.com/download/windows"
} else {
"https://tailscale.com/download/linux"
}
}
/// The command that signs this computer in on Linux (it opens a link to finish in the browser).
pub const LINUX_LOGIN: &str = "sudo tailscale up --operator=$USER";
/// The `tailscale` command, wherever the installer put it.
pub fn cli() -> Option<PathBuf> {
if let Some(p) = std::env::var_os("HORIZON_LAUNCHER_TAILSCALE") {
return Some(PathBuf::from(p));
}
let mut places: Vec<PathBuf> = std::env::var_os("PATH")
.map(|p| {
std::env::split_paths(&p)
.map(|d| {
d.join(if cfg!(windows) {
"tailscale.exe"
} else {
"tailscale"
})
})
.collect()
})
.unwrap_or_default();
if cfg!(target_os = "macos") {
places.push("/Applications/Tailscale.app/Contents/MacOS/Tailscale".into());
places.push("/usr/local/bin/tailscale".into());
places.push("/opt/homebrew/bin/tailscale".into());
} else if cfg!(windows) {
places.push(r"C:\Program Files\Tailscale\tailscale.exe".into());
} else {
places.push("/usr/bin/tailscale".into());
places.push("/usr/local/bin/tailscale".into());
places.push("/run/current-system/sw/bin/tailscale".into());
}
places.into_iter().find(|p| p.is_file())
}
/// This computer on the tailnet.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Node {
/// The stable node ID (`n…`), which the API takes as the device ID.
pub id: String,
pub host: String,
/// The MagicDNS name (`host.tailnet.ts.net`), without the final dot.
pub dns_name: String,
pub ips: Vec<String>,
pub tailnet: String,
pub magic_dns: bool,
}
impl Node {
pub fn ipv4(&self) -> Option<&str> {
self.ips
.iter()
.map(String::as_str)
.find(|ip| !ip.contains(':'))
}
/// What a friend types in Minecraft: the MagicDNS name when it is on, else the address.
pub fn address(&self, port: u16) -> String {
let host = if self.magic_dns && !self.dns_name.is_empty() {
self.dns_name.as_str()
} else {
self.ipv4().unwrap_or_default()
};
if port == 25565 {
host.to_string()
} else {
format!("{host}:{port}")
}
}
}
/// Where Tailscale stands on this computer.
#[derive(Clone, Debug, PartialEq)]
pub enum State {
/// No `tailscale` command.
Missing,
/// Installed, but its service doesn't answer (the app isn't open, or it is switched off).
Off(String),
/// Installed and waiting for the player to sign in (with the link to finish it, when there is one).
SignedOut {
auth_url: Option<String>,
},
Running(Node),
}
/// Asks `tailscale status --json` (read-only).
pub fn status() -> State {
status_of(cli())
}
/// `status` with a given `tailscale` command.
pub fn status_of(cli: Option<PathBuf>) -> State {
let Some(cli) = cli else {
return State::Missing;
};
let mut command = Command::new(&cli);
command.args(["status", "--json"]);
#[cfg(windows)]
{
use std::os::windows::process::CommandExt;
command.creation_flags(0x0800_0000); // CREATE_NO_WINDOW
}
let out = match command.output() {
Ok(out) => out,
Err(e) => return State::Off(e.to_string()),
};
match serde_json::from_slice::<Value>(&out.stdout) {
Ok(v) if v.is_object() => parse_status(&v),
_ => {
let err = String::from_utf8_lossy(&out.stderr);
let line = err.lines().find(|l| !l.trim().is_empty()).unwrap_or("");
State::Off(line.trim().to_string())
}
}
}
pub fn parse_status(v: &Value) -> State {
let backend = v["BackendState"].as_str().unwrap_or_default();
match backend {
"Running" => {
let me = &v["Self"];
let ips = me["TailscaleIPs"]
.as_array()
.map(|a| {
a.iter()
.filter_map(|ip| ip.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let tailnet = &v["CurrentTailnet"];
State::Running(Node {
id: me["ID"].as_str().unwrap_or_default().to_string(),
host: me["HostName"].as_str().unwrap_or_default().to_string(),
dns_name: me["DNSName"]
.as_str()
.unwrap_or_default()
.trim_end_matches('.')
.to_string(),
ips,
tailnet: tailnet["Name"].as_str().unwrap_or_default().to_string(),
magic_dns: tailnet["MagicDNSEnabled"].as_bool().unwrap_or(false),
})
}
"NeedsLogin" | "NeedsMachineAuth" | "NoState" => State::SignedOut {
auth_url: v["AuthURL"]
.as_str()
.filter(|u| u.starts_with("https://"))
.map(str::to_string),
},
"Stopped" => State::Off("Tailscale is switched off.".into()),
other => State::Off(format!("Tailscale is {}.", other.to_lowercase())),
}
}
/// Opens the Tailscale app, where the player signs in (macOS and Windows; Linux has no app).
pub fn open_app() -> Result<(), String> {
let result = if cfg!(target_os = "macos") {
Command::new("open").args(["-a", "Tailscale"]).status()
} else if cfg!(windows) {
Command::new(r"C:\Program Files\Tailscale\tailscale-ipn.exe").status()
} else {
return Err(format!(
"Run `{LINUX_LOGIN}` in a terminal and open the link it prints."
));
};
match result {
Ok(s) if s.success() => Ok(()),
Ok(_) | Err(_) => {
Err("Tailscale's app didn't open: start it from your applications.".into())
}
}
}
// ----- The API --------------------------------------------------------------------------------------
/// An invitation to use this computer, as the API lists it.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Invite {
pub id: String,
pub url: String,
pub email: Option<String>,
pub created: String,
pub accepted: bool,
/// Who accepted it (their login name).
pub accepted_by: Option<String>,
}
impl Invite {
fn parse(v: &Value) -> Self {
Self {
id: match &v["id"] {
Value::String(s) => s.clone(),
other => other.to_string(),
},
url: v["inviteUrl"].as_str().unwrap_or_default().to_string(),
email: v["email"]
.as_str()
.filter(|e| !e.is_empty())
.map(str::to_string),
created: v["created"].as_str().unwrap_or_default().to_string(),
accepted: v["accepted"].as_bool().unwrap_or(false),
accepted_by: v["acceptedBy"]["loginName"].as_str().map(str::to_string),
}
}
}
/// Tailscale's API with the player's access token.
pub struct Api {
base: String,
token: String,
}
impl Api {
pub fn new(token: &str) -> Self {
let base = std::env::var("HORIZON_TAILSCALE_API")
.unwrap_or_else(|_| "https://api.tailscale.com".into());
Self::at(&base, token)
}
/// The API at another address (a stand-in in tests).
pub fn at(base: &str, token: &str) -> Self {
Self {
base: base.trim_end_matches('/').to_string(),
token: token.trim().to_string(),
}
}
fn send(
&self,
method: &str,
path: &str,
body: Option<&Value>,
if_match: Option<&str>,
) -> Result<(Value, Option<String>), String> {
let agent = ureq::AgentBuilder::new()
.timeout(Duration::from_secs(20))
.build();
let mut request = agent
.request(method, &format!("{}/api/v2{path}", self.base))
.set("Authorization", &format!("Bearer {}", self.token))
.set("Accept", "application/json");
if let Some(etag) = if_match {
request = request.set("If-Match", etag);
}
let response = match body {
Some(b) => request.send_json(b.clone()),
None => request.call(),
};
match response {
Ok(r) => {
let etag = r.header("ETag").map(str::to_string);
let text = r.into_string().map_err(|e| e.to_string())?;
let v = if text.trim().is_empty() {
Value::Null
} else {
serde_json::from_str(&text).map_err(|e| e.to_string())?
};
Ok((v, etag))
}
Err(ureq::Error::Status(code, r)) => {
let v: Value = r.into_json().unwrap_or(Value::Null);
let said = v["message"].as_str().unwrap_or_default();
Err(match code {
401 => "Tailscale didn't accept the access token: it may have expired. Make a \
new one and paste it again."
.to_string(),
403 => format!(
"This access token may not do that{}. Make one as an admin of your tailnet.",
if said.is_empty() {
String::new()
} else {
format!(" ({said})")
}
),
412 => "The access controls changed meanwhile: look again before changing them."
.to_string(),
_ if !said.is_empty() => format!("Tailscale says: {said}"),
_ => format!("Tailscale answered {code}."),
})
}
Err(e) => Err(format!("Tailscale's API didn't answer: {e}")),
}
}
/// The invitations to this computer.
pub fn invites(&self, device: &str) -> Result<Vec<Invite>, String> {
let (v, _) = self.send(
"GET",
&format!("/device/{device}/device-invites"),
None,
None,
)?;
Ok(v.as_array()
.map(|a| a.iter().map(Invite::parse).collect())
.unwrap_or_default())
}
/// A new invitation to this computer only: a link to send, or sent by email by Tailscale. One
/// friend each (not reusable), never as an exit node.
pub fn invite(&self, device: &str, email: Option<&str>) -> Result<Invite, String> {
let mut one = json!({ "multiUse": false, "allowExitNode": false });
if let Some(email) = email.filter(|e| !e.trim().is_empty()) {
one["email"] = json!(email.trim());
}
let (v, _) = self.send(
"POST",
&format!("/device/{device}/device-invites"),
Some(&json!([one])),
None,
)?;
v.as_array()
.and_then(|a| a.first())
.map(Invite::parse)
.ok_or_else(|| "Tailscale made no invitation.".to_string())
}
/// Withdraws an invitation (an accepted one stops sharing the computer with that friend).
pub fn revoke(&self, invite: &str) -> Result<(), String> {
self.send("DELETE", &format!("/device-invites/{invite}"), None, None)
.map(|_| ())
}
/// The tailnet's access controls (as JSON) and their version.
pub fn policy(&self) -> Result<(Value, Option<String>), String> {
self.send("GET", "/tailnet/-/acl", None, None)
}
/// Checks new access controls without applying them.
pub fn validate(&self, policy: &Value) -> Result<(), String> {
let (v, _) = self.send("POST", "/tailnet/-/acl/validate", Some(policy), None)?;
match v["message"].as_str() {
Some(m) if !m.is_empty() => Err(format!("Tailscale wouldn't take the change: {m}")),
_ => Ok(()),
}
}
/// Applies new access controls, only if nobody changed them since `etag`.
pub fn set_policy(&self, policy: &Value, etag: Option<&str>) -> Result<(), String> {
self.send("POST", "/tailnet/-/acl", Some(policy), etag)
.map(|_| ())
}
}
// ----- Limiting what shared friends reach -------------------------------------------------------------
/// What the tailnet's access controls let shared friends reach.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum Reach {
/// Tailscale's default: everyone, shared friends included, reaches every port of this computer.
Everything,
/// Shared friends reach only the server's port (the rule this launcher adds).
ServerOnly,
/// Access controls someone wrote: the launcher doesn't touch them.
Custom,
}
const MEMBERS: [&str; 2] = ["autogroup:member", "autogroup:tagged"];
const SHARED: &str = "autogroup:shared";
fn is_star(v: &Value) -> bool {
v.as_array().is_some_and(|a| a.len() == 1 && a[0] == "*")
}
fn is_members(v: &Value) -> bool {
v.as_array().is_some_and(|a| {
a.len() == MEMBERS.len() && MEMBERS.iter().all(|m| a.iter().any(|x| x == m))
})
}
/// The allow-all rule, in either of its forms: `acls` (`src`/`dst` or the older `users`/`ports`) or
/// `grants`.
fn allow_all(rule: &Value, grant: bool) -> bool {
if grant {
is_star(&rule["src"]) && is_star(&rule["dst"]) && is_star(&rule["ip"])
} else {
rule["action"] == "accept"
&& (is_star(&rule["src"]) || is_star(&rule["users"]))
&& [&rule["dst"], &rule["ports"]]
.iter()
.any(|d| d.as_array().is_some_and(|a| a.len() == 1 && a[0] == "*:*"))
}
}
fn members_all(rule: &Value, grant: bool) -> bool {
if grant {
is_members(&rule["src"]) && is_star(&rule["dst"]) && is_star(&rule["ip"])
} else {
rule["action"] == "accept"
&& is_members(&rule["src"])
&& rule["dst"]
.as_array()
.is_some_and(|a| a.len() == 1 && a[0] == "*:*")
}
}
fn shared_rule(ip: &str, port: u16, grant: bool) -> Value {
if grant {
json!({ "src": [SHARED], "dst": [ip], "ip": [format!("tcp:{port}")] })
} else {
json!({ "action": "accept", "src": [SHARED], "dst": [format!("{ip}:{port}")] })
}
}
/// The rules (`acls` or `grants`) when the policy is one of the two shapes this launcher knows: the
/// default allow-all alone, or that limited by `limit_shared` (with only shared rules beside it).
fn known(policy: &Value) -> Option<(&'static str, &Vec<Value>)> {
let acls = policy["acls"].as_array().filter(|a| !a.is_empty());
let grants = policy["grants"].as_array().filter(|a| !a.is_empty());
match (acls, grants) {
(Some(a), None) => Some(("acls", a)),
(None, Some(g)) => Some(("grants", g)),
_ => None,
}
}
/// What shared friends reach under `policy`, for this computer's address and the server's port.
pub fn reach(policy: &Value, ip: &str, port: u16) -> Reach {
let Some((key, rules)) = known(policy) else {
return Reach::Custom;
};
let grant = key == "grants";
if rules.len() == 1 && allow_all(&rules[0], grant) {
return Reach::Everything;
}
let ours = shared_rule(ip, port, grant);
if rules.len() == 2 && members_all(&rules[0], grant) && rules[1] == ours {
return Reach::ServerOnly;
}
Reach::Custom
}
/// The default policy changed so shared friends reach only `ip:port`, everyone else as before; None
/// when the policy isn't the default (then the player adds `snippet` by hand).
pub fn limit_shared(policy: &Value, ip: &str, port: u16) -> Option<Value> {
if reach(policy, ip, port) != Reach::Everything {
return None;
}
let (key, _) = known(policy)?;
let grant = key == "grants";
let members = if grant {
json!({ "src": MEMBERS, "dst": ["*"], "ip": ["*"] })
} else {
json!({ "action": "accept", "src": MEMBERS, "dst": ["*:*"] })
};
let mut new = policy.clone();
new[key] = json!([members, shared_rule(ip, port, grant)]);
Some(new)
}
/// Undoes `limit_shared`: Tailscale's default rule back. None if the policy isn't the limited one.
pub fn unlimit_shared(policy: &Value, ip: &str, port: u16) -> Option<Value> {
if reach(policy, ip, port) != Reach::ServerOnly {
return None;
}
let (key, _) = known(policy)?;
let mut new = policy.clone();
new[key] = if key == "grants" {
json!([{ "src": ["*"], "dst": ["*"], "ip": ["*"] }])
} else {
json!([{ "action": "accept", "src": ["*"], "dst": ["*:*"] }])
};
Some(new)
}
/// The two rules to put in access controls someone wrote, by hand in the admin console.
pub fn snippet(ip: &str, port: u16) -> String {
format!(
"// Friends this computer is shared with reach only the Minecraft server.\n\
// Replace \"*\" with these two in the rule that lets everyone reach everything:\n\
// \"src\": [\"autogroup:member\", \"autogroup:tagged\"]\n\
{{\"action\": \"accept\", \"src\": [\"autogroup:shared\"], \"dst\": [\"{ip}:{port}\"]}},"
)
}
#[cfg(test)]
mod tests;
+270
View File
@@ -0,0 +1,270 @@
use super::*;
use std::io::{BufRead, BufReader, Read, Write};
use std::sync::{Arc, Mutex};
fn running() -> Value {
json!({
"BackendState": "Running",
"AuthURL": "",
"Self": {
"ID": "nAbCdEf1CNTRL",
"HostName": "ludos-mac",
"DNSName": "ludos-mac.tail1234.ts.net.",
"TailscaleIPs": ["100.101.102.103", "fd7a:115c:a1e0::1"]
},
"CurrentTailnet": { "Name": "ludo@example.com", "MagicDNSSuffix": "tail1234.ts.net", "MagicDNSEnabled": true }
})
}
#[test]
fn status_reads_as_the_cli_writes_it() {
let State::Running(node) = parse_status(&running()) else {
panic!("not running");
};
assert_eq!(node.id, "nAbCdEf1CNTRL");
assert_eq!(node.dns_name, "ludos-mac.tail1234.ts.net");
assert_eq!(node.ipv4(), Some("100.101.102.103"));
assert_eq!(node.address(25565), "ludos-mac.tail1234.ts.net");
assert_eq!(
Node {
magic_dns: false,
..node.clone()
}
.address(25570),
"100.101.102.103:25570"
);
assert_eq!(
parse_status(
&json!({ "BackendState": "NeedsLogin", "AuthURL": "https://login.tailscale.com/a/abc" })
),
State::SignedOut {
auth_url: Some("https://login.tailscale.com/a/abc".into())
}
);
assert_eq!(
parse_status(&json!({ "BackendState": "NeedsLogin", "AuthURL": "" })),
State::SignedOut { auth_url: None }
);
assert!(matches!(
parse_status(&json!({ "BackendState": "Stopped" })),
State::Off(_)
));
assert_eq!(status_of(None), State::Missing);
}
/// The CLI is only ever asked `status --json`: a stand-in that refuses anything else.
#[cfg(unix)]
#[test]
fn status_asks_the_cli_read_only() {
use std::os::unix::fs::PermissionsExt;
let dir = std::env::temp_dir().join(format!("horizon-tailscale-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let json = dir.join("status.json");
std::fs::write(&json, running().to_string()).unwrap();
let fake = dir.join("tailscale");
std::fs::write(
&fake,
format!(
"#!/bin/sh\n[ \"$*\" = \"status --json\" ] || {{ echo \"unexpected: $*\" >&2; exit 9; }}\ncat '{}'\n",
json.display()
),
)
.unwrap();
std::fs::set_permissions(&fake, std::fs::Permissions::from_mode(0o755)).unwrap();
assert!(matches!(status_of(Some(fake.clone())), State::Running(n) if n.host == "ludos-mac"));
// A stopped daemon: no JSON, a line on stderr.
std::fs::write(
&fake,
"#!/bin/sh\necho 'failed to connect to local tailscaled; it doesn'\\''t appear to be running' >&2\nexit 1\n",
)
.unwrap();
assert!(matches!(status_of(Some(fake)), State::Off(why) if why.contains("tailscaled")));
std::fs::remove_dir_all(&dir).unwrap();
}
fn default_acls() -> Value {
json!({
"tagOwners": { "tag:server": ["autogroup:admin"] },
"acls": [{ "action": "accept", "src": ["*"], "dst": ["*:*"] }],
"ssh": [{ "action": "check", "src": ["autogroup:member"], "dst": ["autogroup:self"], "users": ["autogroup:nonroot", "root"] }]
})
}
#[test]
fn the_default_policy_is_limited_for_shared_friends_only() {
let ip = "100.101.102.103";
let policy = default_acls();
assert_eq!(reach(&policy, ip, 25565), Reach::Everything);
let limited = limit_shared(&policy, ip, 25565).unwrap();
assert_eq!(
limited["acls"],
json!([
{ "action": "accept", "src": ["autogroup:member", "autogroup:tagged"], "dst": ["*:*"] },
{ "action": "accept", "src": ["autogroup:shared"], "dst": ["100.101.102.103:25565"] }
])
);
// Everything else stays.
assert_eq!(limited["ssh"], policy["ssh"]);
assert_eq!(limited["tagOwners"], policy["tagOwners"]);
assert_eq!(reach(&limited, ip, 25565), Reach::ServerOnly);
// Another port: not the launcher's rule for this server.
assert_eq!(reach(&limited, ip, 25566), Reach::Custom);
assert!(limit_shared(&limited, ip, 25565).is_none());
// Undone, it's Tailscale's default again.
let undone = unlimit_shared(&limited, ip, 25565).unwrap();
assert_eq!(reach(&undone, ip, 25565), Reach::Everything);
assert_eq!(undone["ssh"], policy["ssh"]);
// The older form of the default rule.
let old = json!({ "acls": [{ "action": "accept", "users": ["*"], "ports": ["*:*"] }] });
assert_eq!(reach(&old, ip, 25565), Reach::Everything);
assert!(limit_shared(&old, ip, 25565).is_some());
// Grants.
let grants = json!({ "grants": [{ "src": ["*"], "dst": ["*"], "ip": ["*"] }] });
let limited = limit_shared(&grants, ip, 25570).unwrap();
assert_eq!(
limited["grants"][1],
json!({ "src": ["autogroup:shared"], "dst": [ip], "ip": ["tcp:25570"] })
);
assert_eq!(reach(&limited, ip, 25570), Reach::ServerOnly);
assert!(unlimit_shared(&limited, ip, 25570).is_some());
// Someone's own rules: never rewritten.
let custom = json!({ "acls": [
{ "action": "accept", "src": ["group:family"], "dst": ["*:*"] },
{ "action": "accept", "src": ["*"], "dst": ["*:*"] }
] });
assert_eq!(reach(&custom, ip, 25565), Reach::Custom);
assert!(limit_shared(&custom, ip, 25565).is_none());
assert!(unlimit_shared(&custom, ip, 25565).is_none());
let both = json!({ "acls": [{ "action": "accept", "src": ["*"], "dst": ["*:*"] }], "grants": [{ "src": ["*"], "dst": ["*"], "ip": ["*"] }] });
assert_eq!(reach(&both, ip, 25565), Reach::Custom);
assert!(snippet(ip, 25565).contains("\"dst\": [\"100.101.102.103:25565\"]"));
}
/// What the stand-in API saw: request line, headers, body.
type Seen = Arc<Mutex<Vec<(String, String, String)>>>;
/// A stand-in for api.tailscale.com answering each request with the next reply (status, ETag, body).
fn stand_in(replies: Vec<(u16, Option<&'static str>, String)>) -> (String, Seen) {
let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let base = format!("http://{}", listener.local_addr().unwrap());
let seen: Seen = Arc::default();
let log = seen.clone();
std::thread::spawn(move || {
for (code, etag, body) in replies {
let (stream, _) = listener.accept().unwrap();
let mut reader = BufReader::new(stream.try_clone().unwrap());
let mut first = String::new();
reader.read_line(&mut first).unwrap();
let mut head = String::new();
loop {
let mut line = String::new();
reader.read_line(&mut line).unwrap();
if line == "\r\n" || line.is_empty() {
break;
}
head.push_str(&line);
}
let length = head
.lines()
.find_map(|l| {
l.to_ascii_lowercase()
.strip_prefix("content-length:")
.map(|n| n.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
let mut sent = vec![0; length];
reader.read_exact(&mut sent).unwrap();
log.lock().unwrap().push((
first.trim().to_string(),
head,
String::from_utf8(sent).unwrap(),
));
let reason = match code {
200 => "OK",
401 => "Unauthorized",
_ => "Error",
};
let etag = etag.map(|e| format!("ETag: {e}\r\n")).unwrap_or_default();
let mut stream = stream;
write!(
stream,
"HTTP/1.1 {code} {reason}\r\nContent-Type: application/json\r\n{etag}Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
body.len()
)
.unwrap();
}
});
(base, seen)
}
#[test]
fn invites_and_access_controls_go_through_the_api() {
let invite = json!([{
"id": "12345", "created": "2026-10-19T12:00:00Z", "inviteUrl": "https://login.tailscale.com/admin/invite/code1",
"accepted": false
}]);
let accepted = json!([{
"id": 12346, "created": "2026-10-18T12:00:00Z", "inviteUrl": "https://login.tailscale.com/admin/invite/code2",
"accepted": true, "acceptedBy": { "id": 1, "loginName": "friend@example.com" }
}]);
let (base, seen) = stand_in(vec![
(200, None, invite.to_string()),
(200, None, accepted.to_string()),
(200, None, String::new()),
(200, Some("\"e1\""), default_acls().to_string()),
(200, None, "{}".into()),
(200, None, "{}".into()),
(
401,
None,
json!({ "message": "API token invalid" }).to_string(),
),
]);
let api = Api::at(&base, " tskey-api-test\n");
let made = api.invite("nAbC", None).unwrap();
assert_eq!(made.url, "https://login.tailscale.com/admin/invite/code1");
assert!(!made.accepted);
let list = api.invites("nAbC").unwrap();
assert_eq!(list[0].id, "12346");
assert_eq!(list[0].accepted_by.as_deref(), Some("friend@example.com"));
api.revoke("12346").unwrap();
let (policy, etag) = api.policy().unwrap();
assert_eq!(etag.as_deref(), Some("\"e1\""));
let limited = limit_shared(&policy, "100.101.102.103", 25565).unwrap();
api.validate(&limited).unwrap();
api.set_policy(&limited, etag.as_deref()).unwrap();
assert!(api.invites("nAbC").unwrap_err().contains("access token"));
let seen = seen.lock().unwrap();
let lines: Vec<&str> = seen.iter().map(|(l, _, _)| l.as_str()).collect();
assert_eq!(
lines,
[
"POST /api/v2/device/nAbC/device-invites HTTP/1.1",
"GET /api/v2/device/nAbC/device-invites HTTP/1.1",
"DELETE /api/v2/device-invites/12346 HTTP/1.1",
"GET /api/v2/tailnet/-/acl HTTP/1.1",
"POST /api/v2/tailnet/-/acl/validate HTTP/1.1",
"POST /api/v2/tailnet/-/acl HTTP/1.1",
"GET /api/v2/device/nAbC/device-invites HTTP/1.1",
]
);
for (_, head, _) in seen.iter() {
assert!(
head.to_ascii_lowercase()
.contains("authorization: bearer tskey-api-test\r\n"),
"{head}"
);
}
// One friend per invitation, never as an exit node.
let body: Value = serde_json::from_str(&seen[0].2).unwrap();
assert_eq!(body, json!([{ "multiUse": false, "allowExitNode": false }]));
// The change applies only to the version read.
assert!(seen[5].1.to_ascii_lowercase().contains("if-match: \"e1\""));
let sent: Value = serde_json::from_str(&seen[5].2).unwrap();
assert_eq!(sent, limited);
}