diff --git a/launcher/src/internet.rs b/launcher/src/internet.rs new file mode 100644 index 000000000..483b5a9ee --- /dev/null +++ b/launcher/src/internet.rs @@ -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), + Invites(Result, String>), + Policy(Result<(Value, Option), String>), + /// An action finished: what to tell the player, and whether to read invites and policy again. + Done(Result), + Invited(Result), +} + +pub(super) struct Internet { + state: Option, + reading: bool, + read: Option, + /// The access token, once read from the keychain (None: not read yet; Some(None): none). + token: Option>, + asking_token: bool, + invites: Option, String>>, + policy: Option), String>>, + busy: bool, + message: Option>, + /// The invitation just made, its link shown to copy. + fresh: Option, + confirm_limit: bool, + copied: Option<(String, Instant)>, + tx: Sender, + rx: Receiver, +} + +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 { + 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 + 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, 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, + ) -> Stateful
{ + 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) -> 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) -> 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(); + }, + )), + )) + } +} diff --git a/launcher/src/lib.rs b/launcher/src/lib.rs index f9dccee61..a90d8c22f 100644 --- a/launcher/src/lib.rs +++ b/launcher/src/lib.rs @@ -7,4 +7,5 @@ pub mod modpacks; pub mod mojang; pub mod servers; pub mod sources; +pub mod tailscale; pub mod telemetry; diff --git a/launcher/src/main.rs b/launcher/src/main.rs index ad99bee8d..8d2ff508b 100644 --- a/launcher/src/main.rs +++ b/launcher/src/main.rs @@ -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, /// 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, diff --git a/launcher/src/servers_page.rs b/launcher/src/servers_page.rs index a83df527d..980d907cf 100644 --- a/launcher/src/servers_page.rs +++ b/launcher/src/servers_page.rs @@ -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, logs: Option<(u64, Vec)>, }, 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 = 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) -> Div { diff --git a/launcher/src/tailscale.rs b/launcher/src/tailscale.rs new file mode 100644 index 000000000..41e15683d --- /dev/null +++ b/launcher/src/tailscale.rs @@ -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 { + if let Some(p) = std::env::var_os("HORIZON_LAUNCHER_TAILSCALE") { + return Some(PathBuf::from(p)); + } + let mut places: Vec = 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, + 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, + }, + 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) -> 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::(&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, + pub created: String, + pub accepted: bool, + /// Who accepted it (their login name). + pub accepted_by: Option, +} + +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> { + 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, 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 { + 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> { + 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)> { + 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 { + 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 { + 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; diff --git a/launcher/src/tailscale/tests.rs b/launcher/src/tailscale/tests.rs new file mode 100644 index 000000000..98078ddbc --- /dev/null +++ b/launcher/src/tailscale/tests.rs @@ -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>>; + +/// 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::().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); +}