| 32 |
32 |
|
//! what a user would search for, and it is the word the logged command uses.
|
| 33 |
33 |
|
//! Inventing a friendlier synonym would have the console teach a term nothing
|
| 34 |
34 |
|
//! else in the ecosystem uses.
|
|
35 |
+ |
//!
|
|
36 |
+ |
//! # Enrollment leaves the TUI, deliberately
|
|
37 |
+ |
//!
|
|
38 |
+ |
//! A machine that has not joined a mesh gets the offer screen instead of the
|
|
39 |
+ |
//! peer list, the same way [`sync`](crate::sync) does, because an empty list
|
|
40 |
+ |
//! under a warning line is not an answer to "how do I start".
|
|
41 |
+ |
//!
|
|
42 |
+ |
//! What it offers is different in kind, though. Syncthing enrollment is
|
|
43 |
+ |
//! `systemctl --user enable --now`, which needs no privilege, returns in
|
|
44 |
+ |
//! milliseconds, and can be run under a live ratatui. Signing into a mesh is
|
|
45 |
+ |
//! `run0 tailscale up`, which needs root and then **blocks printing a URL the
|
|
46 |
+ |
//! user has to open in a browser**. Both halves of that are hostile to a
|
|
47 |
+ |
//! full-screen TUI: the polkit prompt wants a real terminal, and an
|
|
48 |
+ |
//! authentication URL rendered into a pane the user cannot select text out of
|
|
49 |
+ |
//! is a URL they cannot use.
|
|
50 |
+ |
//!
|
|
51 |
+ |
//! So enrollment goes out through [`Flow::Suspend`], the same door `distrobox
|
|
52 |
+ |
//! enter` uses. The console tears down, `run0` prompts on a clean terminal,
|
|
53 |
+ |
//! `tailscale up` prints its link where the terminal's own selection and
|
|
54 |
+ |
//! hyperlink handling work, and the console comes back and refreshes into the
|
|
55 |
+ |
//! peer list. This is tier 2 of the ladder in the wiki note `alloy-privilege`,
|
|
56 |
+ |
//! and the interruption is the honest cost of an act performed once per
|
|
57 |
+ |
//! machine.
|
|
58 |
+ |
//!
|
|
59 |
+ |
//! The alternative was scraping the `AuthURL` field out of `status --json` and
|
|
60 |
+ |
//! drawing it. That reads better in a screenshot and worse in use: the URL
|
|
61 |
+ |
//! still is not selectable, the polkit prompt still has nowhere to go, and a
|
|
62 |
+ |
//! spawned `tailscale up` nobody waits on has to be reaped by hand.
|
| 35 |
63 |
|
|
| 36 |
64 |
|
use std::collections::HashMap;
|
| 37 |
65 |
|
|
| 38 |
|
- |
use alloy_tui::{AlloyBlock, AlloyList, Cursor, Hint, Severity, Theme, hint, text};
|
|
66 |
+ |
use alloy_tui::{
|
|
67 |
+ |
AlloyBlock, AlloyList, Cursor, Hint, Severity, TextField, Theme, hint, layout, text,
|
|
68 |
+ |
};
|
| 39 |
69 |
|
use anyhow::{Context, Result};
|
| 40 |
70 |
|
use ratatui::Frame;
|
| 41 |
71 |
|
use ratatui::crossterm::event::{KeyCode, KeyEvent};
|
| 42 |
72 |
|
use ratatui::layout::Rect;
|
|
73 |
+ |
use ratatui::style::{Modifier, Style};
|
| 43 |
74 |
|
use ratatui::text::{Line, Span};
|
| 44 |
75 |
|
use serde::Deserialize;
|
| 45 |
76 |
|
|
| 161 |
192 |
|
ControlPlane::Unknown
|
| 162 |
193 |
|
}
|
| 163 |
194 |
|
|
|
195 |
+ |
/// The command that signs this machine into the mesh.
|
|
196 |
+ |
///
|
|
197 |
+ |
/// Handed back rather than run, because the caller suspends the TUI to run
|
|
198 |
+ |
/// it. See the module docs for why enrollment cannot happen under a live
|
|
199 |
+ |
/// screen: it escalates, and then it blocks on a browser.
|
|
200 |
+ |
///
|
|
201 |
+ |
/// `login_server` is the control server to join, absent for the vendor's.
|
|
202 |
+ |
fn enroll(&self, login_server: Option<&str>) -> Invocation;
|
|
203 |
+ |
|
| 164 |
204 |
|
/// Route traffic through `peer`.
|
| 165 |
205 |
|
fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()>;
|
| 166 |
206 |
|
|
| 168 |
208 |
|
fn clear_exit_node(&self, log: &mut CommandLog) -> Result<()>;
|
| 169 |
209 |
|
}
|
| 170 |
210 |
|
|
|
211 |
+ |
/// Check a login server before spending a suspend on it.
|
|
212 |
+ |
///
|
|
213 |
+ |
/// Shallow, on the same principle as [`sync`](crate::sync)'s draft validation:
|
|
214 |
+ |
/// whether the host resolves, whether it is really a control server, whether
|
|
215 |
+ |
/// its certificate is good are all questions Tailscale answers, and answers
|
|
216 |
+ |
/// better. What this catches is the bare hostname, because `--login-server`
|
|
217 |
+ |
/// wants a full URL and a user typing the name of their Headscale box is the
|
|
218 |
+ |
/// obvious way to get that wrong. Getting it wrong is more expensive here than
|
|
219 |
+ |
/// in a normal field: the price of a rejected value is the whole console
|
|
220 |
+ |
/// tearing down and rebuilding around the error.
|
|
221 |
+ |
///
|
|
222 |
+ |
/// Returns the trimmed value, or `None` for the vendor's control plane, which
|
|
223 |
+ |
/// is what an empty field means.
|
|
224 |
+ |
fn validate_login_server(value: &str) -> Result<Option<String>, String> {
|
|
225 |
+ |
let value = value.trim();
|
|
226 |
+ |
if value.is_empty() {
|
|
227 |
+ |
return Ok(None);
|
|
228 |
+ |
}
|
|
229 |
+ |
if !(value.starts_with("https://") || value.starts_with("http://")) {
|
|
230 |
+ |
return Err(format!("a control server is a URL: try https://{value}"));
|
|
231 |
+ |
}
|
|
232 |
+ |
Ok(Some(value.to_string()))
|
|
233 |
+ |
}
|
|
234 |
+ |
|
| 171 |
235 |
|
/// Pick a backend: `tailscale` when it answers, the mock otherwise.
|
| 172 |
236 |
|
///
|
| 173 |
237 |
|
/// Tailscale is the only real implementation today, and covers Headscale too
|
| 221 |
285 |
|
classify_control_url(prefs.control_url.as_deref().unwrap_or_default())
|
| 222 |
286 |
|
}
|
| 223 |
287 |
|
|
|
288 |
+ |
/// `run0`, not `sudo` and not bare: `tailscale up` drives a system daemon
|
|
289 |
+ |
/// and is refused without root. run0 is what Alloy names when a user needs
|
|
290 |
+ |
/// root (wiki `alloy-privilege`, "run0-first, not sudo"), and it goes
|
|
291 |
+ |
/// through the same polkit authority every writing view already does, so a
|
|
292 |
+ |
/// fingerprint factor configured once covers this too.
|
|
293 |
+ |
///
|
|
294 |
+ |
/// No `--pipe`. That flag is for the build scripts, which capture output;
|
|
295 |
+ |
/// here the pty run0 gives its child by default is exactly what is wanted,
|
|
296 |
+ |
/// since the child is about to print a link for a human to read.
|
|
297 |
+ |
fn enroll(&self, login_server: Option<&str>) -> Invocation {
|
|
298 |
+ |
let invocation = Invocation::new("run0").args(["tailscale", "up"]);
|
|
299 |
+ |
match login_server {
|
|
300 |
+ |
Some(server) => invocation.arg(format!("--login-server={server}")),
|
|
301 |
+ |
None => invocation,
|
|
302 |
+ |
}
|
|
303 |
+ |
}
|
|
304 |
+ |
|
| 224 |
305 |
|
fn set_exit_node(&self, peer: &Peer, log: &mut CommandLog) -> Result<()> {
|
| 225 |
306 |
|
// Addressed by IP rather than hostname: hostnames collide (three
|
| 226 |
307 |
|
// devices on this tailnet answer to "localhost") and MagicDNS may be
|
| 293 |
374 |
|
})
|
| 294 |
375 |
|
}
|
| 295 |
376 |
|
|
|
377 |
+ |
/// The mock reports `Running`, so the offer screen this belongs to is
|
|
378 |
+ |
/// unreachable under it and nothing calls this in normal use. It still has
|
|
379 |
+ |
/// to return something the shell could hand to a suspend, so it returns the
|
|
380 |
+ |
/// command that does nothing and succeeds.
|
|
381 |
+ |
fn enroll(&self, _login_server: Option<&str>) -> Invocation {
|
|
382 |
+ |
Invocation::new("true")
|
|
383 |
+ |
}
|
|
384 |
+ |
|
| 296 |
385 |
|
// The mock is a display fixture, not a simulator.
|
| 297 |
386 |
|
fn set_exit_node(&self, _peer: &Peer, log: &mut CommandLog) -> Result<()> {
|
| 298 |
387 |
|
log.record("# mock backend: exit node unchanged", Severity::Warn);
|
| 468 |
557 |
|
cursor: Cursor,
|
| 469 |
558 |
|
error: Option<String>,
|
| 470 |
559 |
|
ticks: u64,
|
|
560 |
+ |
/// The control-server field, while the enrollment overlay is open.
|
|
561 |
+ |
///
|
|
562 |
+ |
/// One field, so there is no focus ring: the overlay exists to make the
|
|
563 |
+ |
/// Headscale choice visible at the moment it is made, and the vendor's
|
|
564 |
+ |
/// plane is the empty answer.
|
|
565 |
+ |
server: Option<TextField>,
|
| 471 |
566 |
|
}
|
| 472 |
567 |
|
|
| 473 |
568 |
|
impl MeshView {
|
| 483 |
578 |
|
cursor: Cursor::new(),
|
| 484 |
579 |
|
error: None,
|
| 485 |
580 |
|
ticks: 0,
|
|
581 |
+ |
server: None,
|
| 486 |
582 |
|
};
|
| 487 |
583 |
|
view.refresh(log);
|
| 488 |
584 |
|
view
|
| 542 |
638 |
|
}
|
| 543 |
639 |
|
}
|
| 544 |
640 |
|
|
|
641 |
+ |
// ---- enrollment ----
|
|
642 |
+ |
|
|
643 |
+ |
/// Whether this machine is in a mesh at all.
|
|
644 |
+ |
///
|
|
645 |
+ |
/// A status that failed to read is treated as enrolled, so a broken
|
|
646 |
+ |
/// `tailscale status` shows its error rather than inviting a user who is
|
|
647 |
+ |
/// already on the mesh to sign in again.
|
|
648 |
+ |
fn is_enrolled(&self) -> bool {
|
|
649 |
+ |
self.status.as_ref().is_none_or(MeshStatus::is_running)
|
|
650 |
+ |
}
|
|
651 |
+ |
|
|
652 |
+ |
/// Open the enrollment overlay, prefilled with the control server in use.
|
|
653 |
+ |
///
|
|
654 |
+ |
/// Prefilling settles the open question in docs/CONTINUITY.md about whether
|
|
655 |
+ |
/// a Headscale login server survives a `down`/`up` cycle. It does: Tailscale
|
|
656 |
+ |
/// keeps `ControlURL` in its prefs, which is where [`ControlPlane`] is read
|
|
657 |
+ |
/// from in the first place, so re-entry is never *required*. It is prefilled
|
|
658 |
+ |
/// anyway, because a self-hosted user reconnecting should be able to see
|
|
659 |
+ |
/// which server they are about to rejoin rather than trust that it was
|
|
660 |
+ |
/// remembered.
|
|
661 |
+ |
fn open_enrollment(&mut self) {
|
|
662 |
+ |
let mut field = TextField::new();
|
|
663 |
+ |
if let ControlPlane::SelfHosted(host) = &self.control_plane {
|
|
664 |
+ |
// Round-tripped back into a URL. `ControlPlane` keeps only the host,
|
|
665 |
+ |
// since that is all a title needs, and `--login-server` needs the
|
|
666 |
+ |
// scheme back. https, because a control server that answered over
|
|
667 |
+ |
// plain http would not have been reachable to be read here.
|
|
668 |
+ |
field.set(format!("https://{host}"));
|
|
669 |
+ |
}
|
|
670 |
+ |
self.server = Some(field);
|
|
671 |
+ |
}
|
|
672 |
+ |
|
|
673 |
+ |
fn close_enrollment(&mut self) {
|
|
674 |
+ |
self.server = None;
|
|
675 |
+ |
}
|
|
676 |
+ |
|
|
677 |
+ |
/// Hand the terminal to `run0 tailscale up`.
|
|
678 |
+ |
///
|
|
679 |
+ |
/// The overlay closes first. There is nothing to come back to: the console
|
|
680 |
+ |
/// is about to tear down, and on the way back the poll will have found
|
|
681 |
+ |
/// either a mesh or the same offer.
|
|
682 |
+ |
fn submit_enrollment(&mut self, log: &mut CommandLog) -> Flow {
|
|
683 |
+ |
let Some(field) = &self.server else {
|
|
684 |
+ |
return Flow::Continue;
|
|
685 |
+ |
};
|
|
686 |
+ |
let server = match validate_login_server(field.value()) {
|
|
687 |
+ |
Ok(server) => server,
|
|
688 |
+ |
Err(message) => {
|
|
689 |
+ |
self.error = Some(message);
|
|
690 |
+ |
return Flow::Continue;
|
|
691 |
+ |
}
|
|
692 |
+ |
};
|
|
693 |
+ |
let invocation = self.backend.enroll(server.as_deref());
|
|
694 |
+ |
self.close_enrollment();
|
|
695 |
+ |
// Recorded before the handover, as in `alloy pkg`: the pane carries what
|
|
696 |
+ |
// the user is about to be dropped into rather than filling in after.
|
|
697 |
+ |
log.record(invocation.display(), Severity::Info);
|
|
698 |
+ |
Flow::Suspend(invocation.command())
|
|
699 |
+ |
}
|
|
700 |
+ |
|
|
701 |
+ |
/// Keys while the enrollment overlay is open.
|
|
702 |
+ |
///
|
|
703 |
+ |
/// Returns `Some` when the overlay consumed the key, so an `r` typed into a
|
|
704 |
+ |
/// server name never reaches the refresh binding underneath.
|
|
705 |
+ |
fn handle_enrollment(&mut self, key: KeyEvent, log: &mut CommandLog) -> Option<Flow> {
|
|
706 |
+ |
self.server.as_ref()?;
|
|
707 |
+ |
match key.code {
|
|
708 |
+ |
KeyCode::Esc => self.close_enrollment(),
|
|
709 |
+ |
KeyCode::Enter => return Some(self.submit_enrollment(log)),
|
|
710 |
+ |
_ => {
|
|
711 |
+ |
let field = self.server.as_mut()?;
|
|
712 |
+ |
match key.code {
|
|
713 |
+ |
KeyCode::Char(c) => field.insert(c),
|
|
714 |
+ |
KeyCode::Backspace => field.backspace(),
|
|
715 |
+ |
KeyCode::Delete => field.delete(),
|
|
716 |
+ |
KeyCode::Left => field.left(),
|
|
717 |
+ |
KeyCode::Right => field.right(),
|
|
718 |
+ |
KeyCode::Home => field.home(),
|
|
719 |
+ |
KeyCode::End => field.end(),
|
|
720 |
+ |
_ => {}
|
|
721 |
+ |
}
|
|
722 |
+ |
}
|
|
723 |
+ |
}
|
|
724 |
+ |
Some(Flow::Continue)
|
|
725 |
+ |
}
|
|
726 |
+ |
|
| 545 |
727 |
|
fn row<'a>(theme: &Theme, peer: &'a Peer) -> Line<'a> {
|
| 546 |
728 |
|
Line::from(vec![
|
| 547 |
729 |
|
text::bold(theme, format!("{:<18}", truncate(&peer.hostname, 17))),
|
| 554 |
736 |
|
text::muted(theme, peer.state_label()),
|
| 555 |
737 |
|
])
|
| 556 |
738 |
|
}
|
|
739 |
+ |
|
|
740 |
+ |
/// The offer shown when this machine is not in a mesh.
|
|
741 |
+ |
///
|
|
742 |
+ |
/// Names the command it is about to run, as `alloy sync`'s offer does. Here
|
|
743 |
+ |
/// that is not only a teaching move: the command escalates, so a user is
|
|
744 |
+ |
/// owed sight of what they are authorizing before the polkit prompt asks.
|
|
745 |
+ |
fn render_offer(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
746 |
+ |
let state = self
|
|
747 |
+ |
.status
|
|
748 |
+ |
.as_ref()
|
|
749 |
+ |
.map_or("", |status| status.backend_state.as_str());
|
|
750 |
+ |
// "NeedsLogin" is the never-signed-in state and every other non-running
|
|
751 |
+ |
// one means signed in but down. The distinction is worth a sentence:
|
|
752 |
+ |
// the second is a reconnection, and telling someone to sign in when
|
|
753 |
+ |
// they already have reads as the console having lost their account.
|
|
754 |
+ |
let headline = if state == "NeedsLogin" {
|
|
755 |
+ |
"this machine is not signed in to a mesh"
|
|
756 |
+ |
} else {
|
|
757 |
+ |
"the mesh is not connected"
|
|
758 |
+ |
};
|
|
759 |
+ |
let lines = vec![
|
|
760 |
+ |
Line::from(text::muted(theme, headline)),
|
|
761 |
+ |
Line::from(""),
|
|
762 |
+ |
Line::from(text::secondary(
|
|
763 |
+ |
theme,
|
|
764 |
+ |
format!("press e to run: {}", self.backend.enroll(None).display()),
|
|
765 |
+ |
)),
|
|
766 |
+ |
];
|
|
767 |
+ |
frame.render_widget(ratatui::widgets::Paragraph::new(lines), area);
|
|
768 |
+ |
}
|
|
769 |
+ |
|
|
770 |
+ |
/// The one-field enrollment overlay.
|
|
771 |
+ |
///
|
|
772 |
+ |
/// Same shape as `alloy sync`'s add overlay, one row shorter.
|
|
773 |
+ |
fn render_enrollment(&self, frame: &mut Frame, area: Rect, theme: &Theme) {
|
|
774 |
+ |
let Some(field) = &self.server else {
|
|
775 |
+ |
return;
|
|
776 |
+ |
};
|
|
777 |
+ |
// Two rows of border, one of padding either side, one field, and one
|
|
778 |
+ |
// line saying what leaving it empty means.
|
|
779 |
+ |
let overlay = layout::centered(area, 60, 6);
|
|
780 |
+ |
frame.render_widget(ratatui::widgets::Clear, overlay);
|
|
781 |
+ |
|
|
782 |
+ |
let block = AlloyBlock::new(theme)
|
|
783 |
+ |
.focused(true)
|
|
784 |
+ |
.build()
|
|
785 |
+ |
.title(block_title("sign in"));
|
|
786 |
+ |
let inner = block.inner(overlay);
|
|
787 |
+ |
frame.render_widget(block, overlay);
|
|
788 |
+ |
|
|
789 |
+ |
let (before, under, after) = field.split();
|
|
790 |
+ |
let mut spans = vec![
|
|
791 |
+ |
text::bold(theme, format!("{:>10} ", "server")),
|
|
792 |
+ |
text::primary(theme, before.to_string()),
|
|
793 |
+ |
];
|
|
794 |
+ |
// Reversed rather than a block glyph, matching the installer's fields
|
|
795 |
+ |
// and `alloy sync`'s: the caret sits on the character it replaces.
|
|
796 |
+ |
spans.push(Span::styled(
|
|
797 |
+ |
under.unwrap_or(' ').to_string(),
|
|
798 |
+ |
Style::default().add_modifier(Modifier::REVERSED),
|
|
799 |
+ |
));
|
|
800 |
+ |
spans.push(text::primary(theme, after.to_string()));
|
|
801 |
+ |
|
|
802 |
+ |
let lines = vec![
|
|
803 |
+ |
Line::from(spans),
|
|
804 |
+ |
Line::from(""),
|
|
805 |
+ |
// The empty answer is the common one, and an empty field with no
|
|
806 |
+ |
// caption reads as a value the user forgot rather than as a choice.
|
|
807 |
+ |
Line::from(text::muted(
|
|
808 |
+ |
theme,
|
|
809 |
+ |
"empty joins tailscale.com; set a URL for a self-hosted server",
|
|
810 |
+ |
)),
|
|
811 |
+ |
];
|
|
812 |
+ |
frame.render_widget(ratatui::widgets::Paragraph::new(lines), inner);
|
|
813 |
+ |
}
|
| 557 |
814 |
|
}
|
| 558 |
815 |
|
|
| 559 |
816 |
|
impl View for MeshView {
|
| 572 |
829 |
|
}
|
| 573 |
830 |
|
|
| 574 |
831 |
|
fn hints(&self) -> Vec<Hint> {
|
|
832 |
+ |
if self.server.is_some() {
|
|
833 |
+ |
return vec![hint("enter", "sign in"), hint("esc", "cancel")];
|
|
834 |
+ |
}
|
|
835 |
+ |
// `e` means two things across the two screens, which is safe only
|
|
836 |
+ |
// because they are never both on: an un-enrolled machine has no peer
|
|
837 |
+ |
// list to pick an exit node from, and an enrolled one has no offer.
|
|
838 |
+ |
if !self.is_enrolled() {
|
|
839 |
+ |
return vec![hint("e", "sign in"), hint("r", "refresh")];
|
|
840 |
+ |
}
|
| 575 |
841 |
|
vec![
|
| 576 |
842 |
|
hint("j/k", "select"),
|
| 577 |
843 |
|
hint("e", "exit node"),
|
| 602 |
868 |
|
let inner = block.inner(area);
|
| 603 |
869 |
|
frame.render_widget(block, area);
|
| 604 |
870 |
|
|
| 605 |
|
- |
let peers = self.peers();
|
| 606 |
|
- |
if peers.is_empty() {
|
| 607 |
|
- |
frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
|
| 608 |
|
- |
return;
|
|
871 |
+ |
if self.is_enrolled() {
|
|
872 |
+ |
let peers = self.peers();
|
|
873 |
+ |
if peers.is_empty() {
|
|
874 |
+ |
frame.render_widget(Line::from(text::muted(theme, "no peers")), inner);
|
|
875 |
+ |
} else {
|
|
876 |
+ |
let rows: Vec<Line> = peers.iter().map(|peer| Self::row(theme, peer)).collect();
|
|
877 |
+ |
frame.render_widget(
|
|
878 |
+ |
AlloyList::new(theme, rows).selected(self.cursor.selected()),
|
|
879 |
+ |
inner,
|
|
880 |
+ |
);
|
|
881 |
+ |
}
|
|
882 |
+ |
} else {
|
|
883 |
+ |
self.render_offer(frame, inner, theme);
|
| 609 |
884 |
|
}
|
| 610 |
885 |
|
|
| 611 |
|
- |
let rows: Vec<Line> = peers.iter().map(|peer| Self::row(theme, peer)).collect();
|
| 612 |
|
- |
frame.render_widget(
|
| 613 |
|
- |
AlloyList::new(theme, rows).selected(self.cursor.selected()),
|
| 614 |
|
- |
inner,
|
| 615 |
|
- |
);
|
|
886 |
+ |
// Last, and over the whole area rather than the block's inside, so it
|
|
887 |
+ |
// floats above the border as `alloy sync`'s does.
|
|
888 |
+ |
self.render_enrollment(frame, area, theme);
|
|
889 |
+ |
}
|
|
890 |
+ |
|
|
891 |
+ |
/// True while the server field is open, so the shell stops claiming the
|
|
892 |
+ |
/// reserved keys and a control server can live at `https://q.example.org`.
|
|
893 |
+ |
fn text_entry(&self) -> bool {
|
|
894 |
+ |
self.server.is_some()
|
|
895 |
+ |
}
|
|
896 |
+ |
|
|
897 |
+ |
/// Esc backs out of the overlay first, and out of the view only when there
|
|
898 |
+ |
/// is no overlay to close.
|
|
899 |
+ |
fn cancel(&mut self) -> Flow {
|
|
900 |
+ |
if self.server.is_some() {
|
|
901 |
+ |
self.close_enrollment();
|
|
902 |
+ |
return Flow::Continue;
|
|
903 |
+ |
}
|
|
904 |
+ |
Flow::Exit
|
| 616 |
905 |
|
}
|
| 617 |
906 |
|
|
| 618 |
907 |
|
fn handle(&mut self, key: KeyEvent, log: &mut CommandLog) -> Flow {
|
| 619 |
908 |
|
self.error = None;
|
| 620 |
909 |
|
|
|
910 |
+ |
// The overlay eats every key it is given, so an `x` typed into a server
|
|
911 |
+ |
// name never reaches the clear-exit binding underneath.
|
|
912 |
+ |
if let Some(flow) = self.handle_enrollment(key, log) {
|
|
913 |
+ |
return flow;
|
|
914 |
+ |
}
|
|
915 |
+ |
|
|
916 |
+ |
// On the offer screen `e` signs in; on the peer list it picks an exit
|
|
917 |
+ |
// node. Split here rather than inside the actions, so the binding a key
|
|
918 |
+ |
// has is decided in one place.
|
|
919 |
+ |
if !self.is_enrolled() {
|
|
920 |
+ |
match key.code {
|
|
921 |
+ |
KeyCode::Char('e') => self.open_enrollment(),
|
|
922 |
+ |
KeyCode::Char('r') => self.refresh(log),
|
|
923 |
+ |
_ => {}
|
|
924 |
+ |
}
|
|
925 |
+ |
return Flow::Continue;
|
|
926 |
+ |
}
|
|
927 |
+ |
|
| 621 |
928 |
|
match key.code {
|
| 622 |
929 |
|
KeyCode::Char('j') | KeyCode::Down => self.cursor.next(),
|
| 623 |
930 |
|
KeyCode::Char('k') | KeyCode::Up => self.cursor.prev(),
|
| 631 |
938 |
|
|
| 632 |
939 |
|
fn tick(&mut self, log: &mut CommandLog) {
|
| 633 |
940 |
|
self.ticks += 1;
|
|
941 |
+ |
// No background refresh under the overlay: it would redraw the offer
|
|
942 |
+ |
// behind a field someone is typing into, to learn nothing that has
|
|
943 |
+ |
// changed, since what changes it is the command they have not run yet.
|
|
944 |
+ |
if self.server.is_some() {
|
|
945 |
+ |
return;
|
|
946 |
+ |
}
|
| 634 |
947 |
|
if self.ticks % POLL_TICKS == 0 {
|
| 635 |
948 |
|
log.quiet(|log| self.refresh(log));
|
| 636 |
949 |
|
}
|
| 848 |
1161 |
|
// ---- view behavior ----
|
| 849 |
1162 |
|
|
| 850 |
1163 |
|
fn mock_view() -> (MeshView, CommandLog) {
|
| 851 |
|
- |
let mut log = CommandLog::new();
|
| 852 |
|
- |
let mut view = MeshView {
|
| 853 |
|
- |
backend: Box::new(Mock),
|
| 854 |
|
- |
status: None,
|
| 855 |
|
- |
control_plane: ControlPlane::Unknown,
|
| 856 |
|
- |
cursor: Cursor::new(),
|
| 857 |
|
- |
error: None,
|
| 858 |
|
- |
ticks: 0,
|
| 859 |
|
- |
};
|
|
1164 |
+ |
let (mut view, mut log) = bare_view();
|
| 860 |
1165 |
|
view.refresh(&mut log);
|
| 861 |
1166 |
|
(view, log)
|
| 862 |
1167 |
|
}
|
| 863 |
1168 |
|
|
|
1169 |
+ |
/// A view that has not read a status yet.
|
|
1170 |
+ |
fn bare_view() -> (MeshView, CommandLog) {
|
|
1171 |
+ |
(
|
|
1172 |
+ |
MeshView {
|
|
1173 |
+ |
backend: Box::new(Mock),
|
|
1174 |
+ |
status: None,
|
|
1175 |
+ |
control_plane: ControlPlane::Unknown,
|
|
1176 |
+ |
cursor: Cursor::new(),
|
|
1177 |
+ |
error: None,
|
|
1178 |
+ |
ticks: 0,
|
|
1179 |
+ |
server: None,
|
|
1180 |
+ |
},
|
|
1181 |
+ |
CommandLog::new(),
|
|
1182 |
+ |
)
|
|
1183 |
+ |
}
|
|
1184 |
+ |
|
| 864 |
1185 |
|
#[test]
|
| 865 |
1186 |
|
fn routing_through_this_machine_is_refused() {
|
| 866 |
1187 |
|
let (mut view, mut log) = mock_view();
|
| 914 |
1235 |
|
|
| 915 |
1236 |
|
#[test]
|
| 916 |
1237 |
|
fn acting_with_no_selection_is_inert() {
|
| 917 |
|
- |
let mut log = CommandLog::new();
|
| 918 |
|
- |
let mut view = MeshView {
|
| 919 |
|
- |
backend: Box::new(Mock),
|
| 920 |
|
- |
status: None,
|
| 921 |
|
- |
control_plane: ControlPlane::Unknown,
|
| 922 |
|
- |
cursor: Cursor::new(),
|
| 923 |
|
- |
error: None,
|
| 924 |
|
- |
ticks: 0,
|
| 925 |
|
- |
};
|
|
1238 |
+ |
let (mut view, mut log) = bare_view();
|
| 926 |
1239 |
|
view.set_exit_node(&mut log);
|
| 927 |
1240 |
|
assert!(view.error.is_none(), "no selection is not an error");
|
| 928 |
1241 |
|
}
|
| 929 |
1242 |
|
|
|
1243 |
+ |
// ---- enrollment ----
|
|
1244 |
+ |
|
|
1245 |
+ |
fn press(view: &mut MeshView, c: char, log: &mut CommandLog) -> Flow {
|
|
1246 |
+ |
view.handle(KeyEvent::from(KeyCode::Char(c)), log)
|
|
1247 |
+ |
}
|
|
1248 |
+ |
|
|
1249 |
+ |
fn key(view: &mut MeshView, code: KeyCode, log: &mut CommandLog) -> Flow {
|
|
1250 |
+ |
view.handle(KeyEvent::from(code), log)
|
|
1251 |
+ |
}
|
|
1252 |
+ |
|
|
1253 |
+ |
/// A view sitting on a tailnet it has never signed into.
|
|
1254 |
+ |
fn logged_out_view() -> (MeshView, CommandLog) {
|
|
1255 |
+ |
let (mut view, log) = bare_view();
|
|
1256 |
+ |
view.status = Some(parse_status(r#"{"BackendState":"NeedsLogin","Peer":{}}"#).unwrap());
|
|
1257 |
+ |
(view, log)
|
|
1258 |
+ |
}
|
|
1259 |
+ |
|
|
1260 |
+ |
#[test]
|
|
1261 |
+ |
fn an_unset_server_means_the_vendor_plane() {
|
|
1262 |
+ |
assert_eq!(validate_login_server(""), Ok(None));
|
|
1263 |
+ |
assert_eq!(validate_login_server(" "), Ok(None));
|
|
1264 |
+ |
}
|
|
1265 |
+ |
|
|
1266 |
+ |
#[test]
|
|
1267 |
+ |
fn a_server_url_is_trimmed_and_kept() {
|
|
1268 |
+ |
assert_eq!(
|
|
1269 |
+ |
validate_login_server(" https://hs.example.org "),
|
|
1270 |
+ |
Ok(Some("https://hs.example.org".into()))
|