Skip to main content

horus_cli/frontend/
cloudflare_setup.rs

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