siphon_tui/setup/
wizard.rs

1//! Styled CLI setup wizard for configuring Siphon connection settings
2
3use std::borrow::Cow;
4use std::io;
5
6use crossterm::cursor::MoveUp;
7use crossterm::execute;
8use crossterm::style::{Attribute, Color, Print, ResetColor, SetAttribute, SetForegroundColor};
9use crossterm::terminal::{Clear, ClearType};
10use rustyline::completion::{Completer, FilenameCompleter, Pair};
11use rustyline::error::ReadlineError;
12use rustyline::highlight::Highlighter;
13use rustyline::hint::Hinter;
14use rustyline::history::DefaultHistory;
15use rustyline::validate::Validator;
16use rustyline::{Config, Editor, Helper};
17
18use crate::config::SiphonConfig;
19
20/// Path completer helper for rustyline
21struct PathHelper {
22    completer: FilenameCompleter,
23}
24
25impl PathHelper {
26    fn new() -> Self {
27        Self {
28            completer: FilenameCompleter::new(),
29        }
30    }
31}
32
33impl Completer for PathHelper {
34    type Candidate = Pair;
35
36    fn complete(
37        &self,
38        line: &str,
39        pos: usize,
40        ctx: &rustyline::Context<'_>,
41    ) -> rustyline::Result<(usize, Vec<Pair>)> {
42        self.completer.complete(line, pos, ctx)
43    }
44}
45
46impl Hinter for PathHelper {
47    type Hint = String;
48}
49
50impl Highlighter for PathHelper {
51    fn highlight_prompt<'b, 's: 'b, 'p: 'b>(
52        &'s self,
53        prompt: &'p str,
54        _default: bool,
55    ) -> Cow<'b, str> {
56        Cow::Borrowed(prompt)
57    }
58}
59
60impl Validator for PathHelper {}
61
62impl Helper for PathHelper {}
63
64/// Setup wizard for interactive configuration
65pub struct SetupWizard {
66    config: SiphonConfig,
67}
68
69impl SetupWizard {
70    /// Create a new setup wizard
71    pub fn new() -> Self {
72        Self {
73            config: SiphonConfig::default(),
74        }
75    }
76
77    /// Run the setup wizard
78    pub fn run(&mut self) -> anyhow::Result<Option<SiphonConfig>> {
79        let mut stdout = io::stdout();
80
81        // Create rustyline editors
82        let config = Config::builder().auto_add_history(false).build();
83        let mut text_editor: Editor<(), DefaultHistory> = Editor::with_config(config.clone())?;
84        let mut path_editor: Editor<PathHelper, DefaultHistory> = Editor::with_config(config)?;
85        path_editor.set_helper(Some(PathHelper::new()));
86
87        // Header
88        println!();
89        self.print_header(&mut stdout)?;
90        println!();
91
92        self.print_dim(
93            &mut stdout,
94            "This will configure your connection to the tunnel server.",
95        )?;
96        self.print_dim(
97            &mut stdout,
98            "Runtime options (--local, --subdomain) are provided when starting.",
99        )?;
100        println!();
101        println!();
102
103        // Step 1: Server address
104        self.print_step(&mut stdout, 1, 4, "Server Connection")?;
105        let server_addr = self.prompt_text(
106            &mut stdout,
107            &mut text_editor,
108            "Server address",
109            "tunnel.example.com:4443",
110        )?;
111        let server_addr = match server_addr {
112            Some(addr) => addr,
113            None => return Ok(None),
114        };
115
116        if server_addr.is_empty() {
117            self.print_error(&mut stdout, "Server address is required.")?;
118            return Ok(None);
119        }
120
121        // Add default port if not specified
122        self.config.server_addr = if server_addr.contains(':') {
123            server_addr
124        } else {
125            format!("{}:4443", server_addr)
126        };
127
128        self.clear_prompt_lines(&mut stdout, 2)?;
129        self.print_success(&mut stdout, &format!("Server: {}", self.config.server_addr))?;
130        println!();
131
132        // Step 2: Client certificate
133        self.print_step(&mut stdout, 2, 4, "Client Certificate")?;
134        let cert_path = self.prompt_path(
135            &mut stdout,
136            &mut path_editor,
137            "Certificate path",
138            "~/certs/client.crt",
139        )?;
140        let cert_path = match cert_path {
141            Some(path) => path,
142            None => return Ok(None),
143        };
144
145        if cert_path.is_empty() {
146            self.print_error(&mut stdout, "Certificate is required.")?;
147            return Ok(None);
148        }
149
150        let cert_pem = match self.load_and_validate_cert(&cert_path, "certificate") {
151            Ok(pem) => pem,
152            Err(e) => {
153                self.print_error(&mut stdout, &e.to_string())?;
154                return Ok(None);
155            }
156        };
157
158        self.clear_prompt_lines(&mut stdout, 2)?;
159        self.print_success(&mut stdout, &format!("Certificate: {}", cert_path))?;
160        println!();
161
162        // Step 3: Private key
163        self.print_step(&mut stdout, 3, 4, "Private Key")?;
164        let key_path = self.prompt_path(
165            &mut stdout,
166            &mut path_editor,
167            "Private key path",
168            "~/certs/client.key",
169        )?;
170        let key_path = match key_path {
171            Some(path) => path,
172            None => return Ok(None),
173        };
174
175        if key_path.is_empty() {
176            self.print_error(&mut stdout, "Private key is required.")?;
177            return Ok(None);
178        }
179
180        let key_pem = match self.load_and_validate_key(&key_path) {
181            Ok(pem) => pem,
182            Err(e) => {
183                self.print_error(&mut stdout, &e.to_string())?;
184                return Ok(None);
185            }
186        };
187
188        self.clear_prompt_lines(&mut stdout, 2)?;
189        self.print_success(&mut stdout, &format!("Private key: {}", key_path))?;
190        println!();
191
192        // Step 4: CA certificate
193        self.print_step(&mut stdout, 4, 4, "CA Certificate")?;
194        let ca_path = self.prompt_path(
195            &mut stdout,
196            &mut path_editor,
197            "CA certificate path",
198            "~/certs/ca.crt",
199        )?;
200        let ca_path = match ca_path {
201            Some(path) => path,
202            None => return Ok(None),
203        };
204
205        if ca_path.is_empty() {
206            self.print_error(&mut stdout, "CA certificate is required.")?;
207            return Ok(None);
208        }
209
210        let ca_pem = match self.load_and_validate_cert(&ca_path, "CA certificate") {
211            Ok(pem) => pem,
212            Err(e) => {
213                self.print_error(&mut stdout, &e.to_string())?;
214                return Ok(None);
215            }
216        };
217
218        self.clear_prompt_lines(&mut stdout, 2)?;
219        self.print_success(&mut stdout, &format!("CA certificate: {}", ca_path))?;
220        println!();
221
222        // Try keychain first, fall back to base64 in config
223        self.print_action(&mut stdout, "Storing credentials...")?;
224
225        let keychain_works = self.try_keychain_storage(&cert_pem, &key_pem, &ca_pem);
226
227        self.clear_prompt_lines(&mut stdout, 1)?;
228
229        if keychain_works {
230            // Use keychain references
231            self.config.cert = "keychain://siphon/cert".to_string();
232            self.config.key = "keychain://siphon/key".to_string();
233            self.config.ca_cert = "keychain://siphon/ca".to_string();
234            self.print_success(&mut stdout, "Credentials stored in OS keychain")?;
235        } else {
236            // Fall back to base64 in config
237            use base64::Engine;
238            let engine = base64::engine::general_purpose::STANDARD;
239            self.config.cert = format!("base64://{}", engine.encode(&cert_pem));
240            self.config.key = format!("base64://{}", engine.encode(&key_pem));
241            self.config.ca_cert = format!("base64://{}", engine.encode(&ca_pem));
242            self.print_success(&mut stdout, "Credentials will be stored in config file")?;
243        }
244
245        // Save config
246        let config_path = SiphonConfig::default_path();
247        self.print_action(
248            &mut stdout,
249            &format!("Saving configuration to {:?}...", config_path),
250        )?;
251        if let Err(e) = self.config.save_default() {
252            self.print_error(&mut stdout, &format!("Failed to save config: {}", e))?;
253            return Ok(None);
254        }
255        self.clear_prompt_lines(&mut stdout, 1)?;
256
257        // Verify file was created
258        if !config_path.exists() {
259            self.print_error(&mut stdout, "Config file was not created!")?;
260            return Ok(None);
261        }
262        self.print_success(
263            &mut stdout,
264            &format!("Config saved to {}", config_path.display()),
265        )?;
266
267        println!();
268        self.print_complete(&mut stdout)?;
269
270        Ok(Some(self.config.clone()))
271    }
272
273    fn clear_prompt_lines(&self, stdout: &mut io::Stdout, lines: u16) -> anyhow::Result<()> {
274        for _ in 0..lines {
275            execute!(stdout, MoveUp(1), Clear(ClearType::CurrentLine))?;
276        }
277        Ok(())
278    }
279
280    fn print_header(&self, stdout: &mut io::Stdout) -> anyhow::Result<()> {
281        execute!(
282            stdout,
283            SetForegroundColor(Color::Cyan),
284            SetAttribute(Attribute::Bold),
285            Print("◆ Siphon Setup"),
286            ResetColor,
287            SetAttribute(Attribute::Reset),
288        )?;
289        println!();
290        Ok(())
291    }
292
293    fn print_step(
294        &self,
295        stdout: &mut io::Stdout,
296        current: u8,
297        total: u8,
298        title: &str,
299    ) -> anyhow::Result<()> {
300        execute!(
301            stdout,
302            SetForegroundColor(Color::Blue),
303            Print(format!("[{}/{}] ", current, total)),
304            SetForegroundColor(Color::White),
305            SetAttribute(Attribute::Bold),
306            Print(title),
307            ResetColor,
308            SetAttribute(Attribute::Reset),
309        )?;
310        println!();
311        Ok(())
312    }
313
314    fn print_success(&self, stdout: &mut io::Stdout, message: &str) -> anyhow::Result<()> {
315        execute!(
316            stdout,
317            SetForegroundColor(Color::Green),
318            Print("  ✓ "),
319            ResetColor,
320            Print(message),
321        )?;
322        println!();
323        Ok(())
324    }
325
326    fn print_error(&self, stdout: &mut io::Stdout, message: &str) -> anyhow::Result<()> {
327        execute!(
328            stdout,
329            SetForegroundColor(Color::Red),
330            Print("  ✗ "),
331            ResetColor,
332            Print(message),
333        )?;
334        println!();
335        Ok(())
336    }
337
338    fn print_action(&self, stdout: &mut io::Stdout, message: &str) -> anyhow::Result<()> {
339        execute!(
340            stdout,
341            SetForegroundColor(Color::Cyan),
342            Print("  ● "),
343            ResetColor,
344            Print(message),
345        )?;
346        println!();
347        Ok(())
348    }
349
350    fn print_dim(&self, stdout: &mut io::Stdout, message: &str) -> anyhow::Result<()> {
351        execute!(
352            stdout,
353            SetForegroundColor(Color::DarkGrey),
354            Print(format!("  {}", message)),
355            ResetColor,
356        )?;
357        println!();
358        Ok(())
359    }
360
361    fn print_complete(&self, stdout: &mut io::Stdout) -> anyhow::Result<()> {
362        execute!(
363            stdout,
364            SetForegroundColor(Color::Green),
365            SetAttribute(Attribute::Bold),
366            Print("◆ Setup complete!"),
367            ResetColor,
368            SetAttribute(Attribute::Reset),
369        )?;
370        println!();
371        println!();
372        execute!(
373            stdout,
374            Print("  Start a tunnel with: "),
375            SetForegroundColor(Color::Cyan),
376            Print("siphon --local 127.0.0.1:3000"),
377            ResetColor,
378        )?;
379        println!();
380        println!();
381        Ok(())
382    }
383
384    fn prompt_text(
385        &self,
386        stdout: &mut io::Stdout,
387        editor: &mut Editor<(), DefaultHistory>,
388        label: &str,
389        placeholder: &str,
390    ) -> anyhow::Result<Option<String>> {
391        execute!(
392            stdout,
393            SetForegroundColor(Color::White),
394            Print(format!("  {} ", label)),
395            SetForegroundColor(Color::DarkGrey),
396            Print(format!("({})", placeholder)),
397            ResetColor,
398        )?;
399        println!();
400
401        // Build colored prompt
402        let prompt = "\x1b[36m  › \x1b[0m";
403
404        match editor.readline(prompt) {
405            Ok(line) => Ok(Some(line.trim().to_string())),
406            Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
407            Err(e) => Err(e.into()),
408        }
409    }
410
411    fn prompt_path(
412        &self,
413        stdout: &mut io::Stdout,
414        editor: &mut Editor<PathHelper, DefaultHistory>,
415        label: &str,
416        placeholder: &str,
417    ) -> anyhow::Result<Option<String>> {
418        execute!(
419            stdout,
420            SetForegroundColor(Color::White),
421            Print(format!("  {} ", label)),
422            SetForegroundColor(Color::DarkGrey),
423            Print(format!("({})", placeholder)),
424            ResetColor,
425        )?;
426        println!();
427
428        // Build colored prompt
429        let prompt = "\x1b[36m  › \x1b[0m";
430
431        match editor.readline(prompt) {
432            Ok(line) => Ok(Some(line.trim().to_string())),
433            Err(ReadlineError::Interrupted | ReadlineError::Eof) => Ok(None),
434            Err(e) => Err(e.into()),
435        }
436    }
437
438    /// Try to store credentials in keychain and verify they can be read back
439    fn try_keychain_storage(&self, cert_pem: &str, key_pem: &str, ca_pem: &str) -> bool {
440        // Try to store
441        if siphon_secrets::keychain::store("siphon", "cert", cert_pem).is_err() {
442            return false;
443        }
444        if siphon_secrets::keychain::store("siphon", "key", key_pem).is_err() {
445            return false;
446        }
447        if siphon_secrets::keychain::store("siphon", "ca", ca_pem).is_err() {
448            return false;
449        }
450
451        // Verify we can read them back
452        siphon_secrets::keychain::resolve("siphon", "cert").is_ok()
453    }
454
455    fn load_and_validate_cert(&self, path: &str, name: &str) -> anyhow::Result<String> {
456        let expanded = shellexpand::tilde(path);
457        let content = std::fs::read_to_string(expanded.as_ref())
458            .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path, e))?;
459
460        if !content.contains("-----BEGIN CERTIFICATE-----") {
461            anyhow::bail!("Invalid {}: must be PEM format", name);
462        }
463
464        Ok(content)
465    }
466
467    fn load_and_validate_key(&self, path: &str) -> anyhow::Result<String> {
468        let expanded = shellexpand::tilde(path);
469        let content = std::fs::read_to_string(expanded.as_ref())
470            .map_err(|e| anyhow::anyhow!("Failed to read {}: {}", path, e))?;
471
472        if !content.contains("-----BEGIN") || !content.contains("PRIVATE KEY-----") {
473            anyhow::bail!("Invalid private key: must be PEM format");
474        }
475
476        Ok(content)
477    }
478}
479
480impl Default for SetupWizard {
481    fn default() -> Self {
482        Self::new()
483    }
484}