1use 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
20pub enum CloudflareInit {
22 Quick,
24 Named {
26 hostname: String,
28 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
125pub 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}