1use 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
21pub 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
119pub 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}