Skip to main content

mobius_cli/frontend/
cloudflare_setup.rs

1//! First-run setup for account-free or stable Cloudflare Tunnel exposure.
2
3use std::io;
4
5use mobius::{Error, Result};
6use mobius_gateway::config::{CloudflareConfig, MAX_CLOUDFLARE_TOKEN_BYTES as MAX_TOKEN_BYTES};
7use ratatui::Terminal;
8use ratatui::backend::CrosstermBackend;
9use ratatui::crossterm::event::{Event, KeyCode, KeyEvent, KeyEventKind, KeyModifiers};
10use ratatui::text::{Line, Span};
11use ratatui::widgets::{Block, Borders, Paragraph, Wrap};
12use tokio::time::MissedTickBehavior;
13
14use super::terminal::{INPUT_POLL, MAX_INPUT_BATCH, TerminalGuard, poll_event};
15use super::terminal_text;
16use super::theme::{Role, current};
17
18const MAX_HOSTNAME_BYTES: usize = 253;
19
20/// Validated values consumed by gateway initialization and never displayed again.
21pub enum CloudflareInit {
22    Quick,
23    Named { hostname: String, token: String },
24}
25
26#[derive(Clone, Copy, PartialEq, Eq)]
27enum Field {
28    Quick,
29    Hostname,
30    Token,
31    Connect,
32}
33
34struct State {
35    field: Field,
36    hostname: String,
37    token: String,
38    error: Option<String>,
39}
40
41impl State {
42    fn new() -> Self {
43        Self {
44            field: Field::Quick,
45            hostname: String::new(),
46            token: String::new(),
47            error: None,
48        }
49    }
50
51    fn move_field(&mut self, delta: isize) {
52        let current = match self.field {
53            Field::Quick => 0,
54            Field::Hostname => 1,
55            Field::Token => 2,
56            Field::Connect => 3,
57        };
58        self.field = match (current + delta).rem_euclid(4) {
59            0 => Field::Quick,
60            1 => Field::Hostname,
61            2 => Field::Token,
62            _ => Field::Connect,
63        };
64        self.error = None;
65    }
66
67    fn push(&mut self, text: &str) {
68        let (target, limit) = match self.field {
69            Field::Quick => return,
70            Field::Hostname => (&mut self.hostname, MAX_HOSTNAME_BYTES),
71            Field::Token => (&mut self.token, MAX_TOKEN_BYTES),
72            Field::Connect => return,
73        };
74        for character in text.chars().filter(|character| !character.is_control()) {
75            if target.len() + character.len_utf8() > limit {
76                self.error = Some(format!("input is limited to {limit} bytes"));
77                return;
78            }
79            target.push(character);
80        }
81        self.error = None;
82    }
83
84    fn backspace(&mut self) {
85        match self.field {
86            Field::Quick => return,
87            Field::Hostname => {
88                self.hostname.pop();
89            }
90            Field::Token => {
91                self.token.pop();
92            }
93            Field::Connect => return,
94        }
95        self.error = None;
96    }
97
98    fn finish(&mut self) -> Result<CloudflareInit> {
99        if self.field == Field::Quick {
100            return Ok(CloudflareInit::Quick);
101        }
102        let cloudflare = CloudflareConfig::named(&self.hostname).map_err(configuration_error)?;
103        CloudflareConfig::validate_token(&self.token).map_err(configuration_error)?;
104        let hostname = cloudflare
105            .hostname()
106            .ok_or_else(|| Error::Config("named Cloudflare hostname is missing".into()))?;
107        Ok(CloudflareInit::Named {
108            hostname: hostname.to_owned(),
109            token: std::mem::take(&mut self.token).trim().to_owned(),
110        })
111    }
112}
113
114fn configuration_error(error: mobius_gateway::Error) -> Error {
115    Error::Config(error.to_string())
116}
117
118/// Collects an existing tunnel hostname and connector token without echoing the token.
119pub async fn run() -> Result<Option<CloudflareInit>> {
120    let mut guard = TerminalGuard::alternate()?;
121    guard.set_mouse_capture(false)?;
122    let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?;
123    let mut state = State::new();
124    terminal.clear()?;
125    let mut tick = tokio::time::interval(INPUT_POLL);
126    tick.set_missed_tick_behavior(MissedTickBehavior::Skip);
127    let mut dirty = true;
128    loop {
129        if dirty {
130            terminal.draw(|frame| render(frame, &state))?;
131            dirty = false;
132        }
133        tick.tick().await;
134        for _ in 0..MAX_INPUT_BATCH {
135            let Some(event) = poll_event()? else {
136                break;
137            };
138            dirty = true;
139            match event {
140                Event::Key(key) => match handle_key(&mut state, key) {
141                    Action::Continue => {}
142                    Action::Cancel => return Ok(None),
143                    Action::Finish => match state.finish() {
144                        Ok(config) => return Ok(Some(config)),
145                        Err(error) => state.error = Some(error.to_string()),
146                    },
147                },
148                Event::Paste(text) => state.push(text.trim()),
149                Event::Resize(_, _) | Event::FocusGained | Event::FocusLost | Event::Mouse(_) => {}
150            }
151        }
152    }
153}
154
155enum Action {
156    Continue,
157    Cancel,
158    Finish,
159}
160
161fn handle_key(state: &mut State, key: KeyEvent) -> Action {
162    if !matches!(key.kind, KeyEventKind::Press | KeyEventKind::Repeat) {
163        return Action::Continue;
164    }
165    if key.modifiers.contains(KeyModifiers::CONTROL) && matches!(key.code, KeyCode::Char('c' | 'd'))
166    {
167        return Action::Cancel;
168    }
169    match key.code {
170        KeyCode::Esc => Action::Cancel,
171        KeyCode::Up | KeyCode::BackTab => {
172            state.move_field(-1);
173            Action::Continue
174        }
175        KeyCode::Down | KeyCode::Tab => {
176            state.move_field(1);
177            Action::Continue
178        }
179        KeyCode::Enter if matches!(state.field, Field::Quick | Field::Connect) => Action::Finish,
180        KeyCode::Enter => {
181            state.move_field(1);
182            Action::Continue
183        }
184        KeyCode::Backspace => {
185            state.backspace();
186            Action::Continue
187        }
188        KeyCode::Char(character)
189            if !key
190                .modifiers
191                .intersects(KeyModifiers::CONTROL | KeyModifiers::ALT) =>
192        {
193            state.push(&character.to_string());
194            Action::Continue
195        }
196        _ => Action::Continue,
197    }
198}
199
200fn render(frame: &mut ratatui::Frame<'_>, state: &State) {
201    let theme = current();
202    let hostname = if state.hostname.is_empty() {
203        "mobius.example.com".into()
204    } else {
205        terminal_text(&state.hostname)
206    };
207    let token = if state.token.is_empty() {
208        "paste the tunnel token".into()
209    } else {
210        masked_token(&state.token)
211    };
212    let selected = |field| {
213        if state.field == field {
214            theme.style(Role::Selection)
215        } else {
216            theme.style(Role::Text)
217        }
218    };
219    let mut lines = vec![
220        Line::from(""),
221        Line::from(Span::styled("  Quick Connect", selected(Field::Quick))),
222        Line::styled(
223            "  No Cloudflare account or route. The address changes when the gateway restarts.",
224            theme.style(Role::Muted),
225        ),
226        Line::from(""),
227        Line::styled("  Stable hostname (advanced)", theme.style(Role::Muted)),
228        Line::styled(
229            "  Start the connector here, then publish the hostname to http://127.0.0.1:8741.",
230            theme.style(Role::Muted),
231        ),
232        Line::from(""),
233        Line::styled("  Public hostname", theme.style(Role::Muted)),
234        Line::from(Span::styled(
235            format!("  {hostname}"),
236            selected(Field::Hostname),
237        )),
238        Line::from(""),
239        Line::styled("  Tunnel token", theme.style(Role::Muted)),
240        Line::from(Span::styled(format!("  {token}"), selected(Field::Token))),
241        Line::from(""),
242        Line::from(Span::styled(
243            "  Connect stable tunnel",
244            selected(Field::Connect),
245        )),
246        Line::from(""),
247    ];
248    if let Some(error) = &state.error {
249        lines.push(Line::styled(
250            format!("  {}", terminal_text(error)),
251            theme.style(Role::Error),
252        ));
253        lines.push(Line::from(""));
254    }
255    lines.push(Line::styled(
256        "  tab/↑↓ select · enter continue · esc cancel",
257        theme.style(Role::Muted),
258    ));
259    frame.render_widget(
260        Paragraph::new(lines)
261            .block(
262                Block::default()
263                    .borders(Borders::ALL)
264                    .title(" Cloudflare Tunnel "),
265            )
266            .style(theme.style(Role::Canvas))
267            .wrap(Wrap { trim: false }),
268        frame.area(),
269    );
270}
271
272fn masked_token(token: &str) -> String {
273    let count = token.chars().count();
274    let mut masked = "•".repeat(count.min(24));
275    if count > 24 {
276        masked.push('…');
277    }
278    masked
279}
280
281#[cfg(test)]
282mod tests {
283    use super::*;
284
285    #[test]
286    fn tunnel_token_is_never_rendered() {
287        let token = "secret-tunnel-token";
288
289        assert!(!masked_token(token).contains(token));
290    }
291
292    #[test]
293    fn finish_normalizes_the_hostname_and_moves_the_token() {
294        let mut state = State {
295            field: Field::Connect,
296            hostname: " mobius.example.com ".into(),
297            token: " secret-tunnel-token ".into(),
298            error: None,
299        };
300
301        let CloudflareInit::Named { hostname, token } = state.finish().expect("valid setup") else {
302            panic!("expected named tunnel");
303        };
304
305        assert_eq!(
306            (hostname.as_str(), token.as_str()),
307            ("mobius.example.com", "secret-tunnel-token")
308        );
309    }
310
311    #[test]
312    fn quick_connect_is_the_default() {
313        let mut state = State::new();
314
315        let config = state.finish().expect("quick setup");
316
317        assert!(matches!(config, CloudflareInit::Quick));
318    }
319}