1use 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
20struct 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
64pub struct SetupWizard {
66 config: SiphonConfig,
67}
68
69impl SetupWizard {
70 pub fn new() -> Self {
72 Self {
73 config: SiphonConfig::default(),
74 }
75 }
76
77 pub fn run(&mut self) -> anyhow::Result<Option<SiphonConfig>> {
79 let mut stdout = io::stdout();
80
81 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 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 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 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 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 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 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 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 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 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 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 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 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 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 fn try_keychain_storage(&self, cert_pem: &str, key_pem: &str, ca_pem: &str) -> bool {
440 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 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}