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:
@@ -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();
|
||||
},
|
||||
)),
|
||||
))
|
||||
}
|
||||
}
|
||||
@@ -7,4 +7,5 @@ pub mod modpacks;
|
||||
pub mod mojang;
|
||||
pub mod servers;
|
||||
pub mod sources;
|
||||
pub mod tailscale;
|
||||
pub mod telemetry;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user