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