1use std::io::stdout;
2use std::path::PathBuf;
3use std::sync::Arc;
4use std::time::Duration;
5
6use anyhow::Result;
7use crossterm::cursor::RestorePosition;
8use crossterm::event;
9use crossterm::event::Event;
10use crossterm::event::KeyCode::*;
11use crossterm::event::KeyEventKind;
12use crossterm::event::KeyModifiers;
13use crossterm::execute;
14use crossterm::terminal::Clear;
15use crossterm::terminal::ClearType;
16use ratatui::backend::Backend;
17use ratatui::buffer::Buffer;
18use ratatui::layout::Constraint;
19use ratatui::layout::Direction;
20use ratatui::layout::Layout;
21use ratatui::layout::Rect;
22use ratatui::style::Color;
23use ratatui::style::Modifier;
24use ratatui::style::Style;
25use ratatui::style::Stylize;
26use ratatui::text::Text;
27use ratatui::widgets::Block;
28use ratatui::widgets::Borders;
29use ratatui::widgets::HighlightSpacing;
30use ratatui::widgets::List;
31use ratatui::widgets::ListItem;
32use ratatui::widgets::ListState;
33use ratatui::widgets::Paragraph;
34use ratatui::widgets::StatefulWidget;
35use ratatui::widgets::Widget;
36use ratatui::widgets::Wrap;
37use ratatui::Terminal;
38use russh_keys::key::KeyPair;
39use russh_keys::load_secret_key;
40use tokio::time::sleep;
41
42use crate::config::app_config::Config;
43use crate::config::app_vault::decrypt_password;
44use crate::config::app_vault::EncryptionKey;
45use crate::config::app_vault::Vault;
46use crate::debug_log;
47use crate::helper::convert_to_array;
48use crate::ssh::key_session::KeySession;
49use crate::ssh::password_session::PasswordSession;
50use crate::ssh::ssh_session::{AuthMethod, SshSession};
51use crate::widgets::popup_input_box::PopupInputBox;
52use crate::widgets::server_creator::ServerCreator;
53
54struct ServerItem {
55 name: String,
56 address: String,
57 username: String,
58 id: String,
59 shell: String,
60 port: u16,
61}
62
63struct ServerList {
64 state: ListState,
65 items: Vec<ServerItem>,
66}
67
68impl ServerList {
69 fn with_items(items: Vec<ServerItem>) -> ServerList {
70 ServerList {
71 state: ListState::default(),
72 items,
73 }
74 }
75
76 fn next(&mut self) {
77 let i = match self.state.selected() {
78 Some(i) => {
79 if i >= self.items.len() - 1 {
80 0
81 } else {
82 i + 1
83 }
84 }
85 None => 0,
86 };
87 self.state.select(Some(i));
88 }
89
90 fn previous(&mut self) {
91 let i = match self.state.selected() {
92 Some(i) => {
93 if i == 0 {
94 self.items.len() - 1
95 } else {
96 i - 1
97 }
98 }
99 None => 0,
100 };
101 self.state.select(Some(i));
102 }
103}
104
105pub struct PopupInfo {
106 message: String,
107 popup_type: PopupType,
108}
109
110#[derive(Clone)]
111pub enum PopupType {
112 Info,
113 Error,
114}
115
116pub struct App<'a> {
117 server_list: ServerList,
118 vault: &'a mut Vault,
119 config: &'a mut Config,
120 encryption_key: EncryptionKey,
121 show_popup: bool,
122 popup_info: Option<PopupInfo>,
123 is_connecting: bool,
124}
125
126impl<'a> Widget for &mut App<'a> {
127 fn render(self, area: Rect, buf: &mut Buffer) {
128 let vertical = Layout::vertical([
129 Constraint::Length(1),
130 Constraint::Min(0),
131 Constraint::Length(1),
132 ]);
133 let [head_area, body_area, foot_area] = vertical.areas(area);
134 self.render_header(head_area, buf);
135 self.render_servers(body_area, buf);
136 self.render_footer(foot_area, buf);
137 }
138}
139
140impl<'a> App<'a> {
141 fn render_header(&self, area: Rect, buf: &mut Buffer) {
142 let text = Text::styled(
143 format!(" {:<10} {:<15} {:<20}", "user", "ip", "name"),
144 Style::default().add_modifier(Modifier::BOLD),
145 );
146 Widget::render(text, area, buf);
147 }
148
149 fn render_footer(&self, area: Rect, buf: &mut Buffer) {
150 let text = Text::from(" Add (A), Edit (E), Delete (D), Quit (ESC)").dim();
151 Widget::render(text, area, buf);
152 }
153
154 fn render_servers(&mut self, area: Rect, buf: &mut Buffer) {
155 let items: Vec<ListItem> = self
156 .server_list
157 .items
158 .iter()
159 .map(|item| {
160 ListItem::new(format!(
161 "{:<10} {:<15} {:<20}",
162 item.username, item.address, item.name
163 ))
164 })
165 .collect();
166
167 let items = List::new(items)
168 .highlight_style(
169 Style::default()
170 .add_modifier(Modifier::BOLD)
171 .add_modifier(Modifier::REVERSED),
172 )
173 .highlight_symbol("> ")
174 .highlight_spacing(HighlightSpacing::Always);
175
176 StatefulWidget::render(&items, area, buf, &mut self.server_list.state);
177 }
178}
179
180impl<'a> App<'a> {
181 pub fn new(
182 config: &'a mut Config,
183 vault: &'a mut Vault,
184 encryption_key: EncryptionKey,
185 ) -> Result<Self> {
186 let server_items: Vec<ServerItem> = config
187 .servers
188 .clone()
189 .into_iter()
190 .map(|server| ServerItem {
191 id: server.id,
192 name: server.name,
193 address: server.ip,
194 username: server.user,
195 shell: server.shell,
196 port: server.port,
197 })
198 .collect();
199 let app = Self {
200 server_list: ServerList::with_items(server_items),
201 vault: vault,
202 config: config,
203 encryption_key,
204 show_popup: false,
205 popup_info: None,
206 is_connecting: false,
207 };
208 Ok(app)
209 }
210
211 fn draw(&mut self, terminal: &mut Terminal<impl Backend>) -> Result<()> {
212 terminal.draw(|f| {
213 let show_popup = self.show_popup;
214 let message = self.popup_info.as_ref().map(|info| info.message.clone());
215 let title = match self.popup_info.as_ref().map(|info| info.popup_type.clone()) {
216 Some(PopupType::Info) => "Info".to_string(),
217 Some(PopupType::Error) => "Error".to_string(),
218 None => "Info".to_string(),
219 };
220 let border_color = match self.popup_info.as_ref().map(|info| info.popup_type.clone()) {
221 Some(PopupType::Info) => Color::LightGreen,
222 Some(PopupType::Error) => Color::LightRed,
223 None => Color::LightGreen,
224 };
225 if show_popup {
226 let block = Block::default()
227 .border_style(Style::default().fg(border_color))
228 .title(title)
229 .borders(Borders::ALL);
230 let area = Self::centered_rect(50, 60, f.area());
231 if let Some(message) = message {
232 let text = Paragraph::new(Text::raw(message).fg(Color::White))
233 .style(Style::default())
234 .wrap(Wrap { trim: true })
235 .block(block);
236 f.render_widget(text, area);
237 }
238 } else {
239 f.render_widget(self, f.area());
241 }
242 })?;
243 Ok(())
244 }
245
246 fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
248 let popup_layout = Layout::default()
249 .direction(Direction::Vertical)
250 .constraints(
251 [
252 Constraint::Percentage((100 - percent_y) / 2),
253 Constraint::Percentage(percent_y),
254 Constraint::Percentage((100 - percent_y) / 2),
255 ]
256 .as_ref(),
257 )
258 .split(r);
259
260 Layout::default()
261 .direction(Direction::Horizontal)
262 .constraints(
263 [
264 Constraint::Percentage((100 - percent_x) / 2),
265 Constraint::Percentage(percent_x),
266 Constraint::Percentage((100 - percent_x) / 2),
267 ]
268 .as_ref(),
269 )
270 .split(popup_layout[1])[1]
271 }
272
273 pub async fn run(&mut self, mut terminal: &mut Terminal<impl Backend>) -> Result<()> {
274 loop {
275 self.draw(&mut terminal)?;
276 if let Event::Key(key) = event::read()? {
277 if key.kind == KeyEventKind::Press {
278 if !self.is_connecting && self.show_popup {
279 self.show_popup = false;
280 continue;
281 }
282 if self.is_connecting {
283 continue;
284 }
285 match key.code {
286 Char('q') | Esc => {
287 return Ok(());
288 }
289 Char('j') | Down => self.server_list.next(),
290 Char('k') | Up => self.server_list.previous(),
291 Char('c') => {
292 if key.modifiers == KeyModifiers::CONTROL {
294 return Ok(());
295 }
296 }
297 Char('a') => {
298 let mut server_creator =
300 ServerCreator::new(self.vault, self.config, &self.encryption_key);
301
302 if server_creator.run(&mut terminal)? {
303 self.refresh_serverlist();
304 }
305 }
306 Char('e') => {
307 if let Some(selected_index) = self.server_list.state.selected() {
309 let server = &self.server_list.items[selected_index];
310 let server_id = server.id.clone();
311 let mut server_creator = ServerCreator::new_edit(
312 self.vault,
313 self.config,
314 &self.encryption_key,
315 server_id.as_str(),
316 )?;
317 if server_creator.run(&mut terminal)? {
318 self.refresh_serverlist();
319 }
320 }
321 }
322 Char('d') => {
323 if let Some(selected_index) = self.server_list.state.selected() {
324 let server = &self.server_list.items[selected_index];
325 let server_id = server.id.clone();
326 self.config.delete_server(server_id.as_str())?;
327 self.server_list.items.remove(selected_index);
328 self.vault.delete_server(
329 server_id.as_str(),
330 &convert_to_array(&self.encryption_key)?,
331 )?;
332 }
333 }
334 Enter => {
335 if let Some(selected_index) = self.server_list.state.selected() {
336 let server = &self.server_list.items[selected_index];
337 let server_id = server.id.clone();
338 let server_address = server.address.clone();
339 let server_username = server.username.clone();
340 let server_shell = server.shell.clone();
341 let server_port = server.port.clone();
342 if let Some(password) = self.vault.servers.iter().find_map(|s| {
343 (s.id == server_id).then(|| {
344 decrypt_password(
345 &s.id,
346 &s.password,
347 &convert_to_array(&self.encryption_key).map_err(
348 |e| anyhow::anyhow!("encryption key convert failed: {}", e),
349 )?,
350 )
351 .map_err(|e| anyhow::anyhow!("password decrypt failed: {}", e))
352 })
353 }).transpose()? {
354 if cfg!(debug_assertions) {
355 debug_log!("debug.log", "IP: {}", server.address);
356 debug_log!("debug.log", "Port: {}", server.port);
357 debug_log!("debug.log", "User: {}", server.username);
358 debug_log!("debug.log", "Shell: {}", server.shell);
359 }
360 self.is_connecting = true;
361 self.render_popup(
362 "Connecting...".to_string(),
363 PopupType::Info,
364 )?;
365 self.draw(&mut terminal)?;
366
367 let is_password_empty = password.is_empty();
368 let result: Result<Arc<dyn SshSession>, anyhow::Error> =
369 if is_password_empty {
370 let key_path: Option<PathBuf> = find_best_key();
372 if key_path.is_none() {
373 self.render_popup(
374 "No suitable SSH key found".to_string(),
375 PopupType::Error,
376 )?;
377 self.is_connecting = false;
378 continue;
379 }
380 let key_path = key_path.unwrap(); let key_pair: Result<KeyPair, anyhow::Error> =
382 load_key_with_passphrase(key_path, &mut terminal);
383 let key_pair = match key_pair {
384 Ok(key_pair) => key_pair,
385 Err(_) => {
386 self.render_popup(
387 "Wrong passphrase.".to_string(),
388 PopupType::Error,
389 )?;
390 self.is_connecting = false;
391 continue;
392 }
393 };
394 KeySession::connect(
395 server_username.clone(),
396 AuthMethod::Key(key_pair),
397 (server_address.clone(), server_port),
398 )
399 .await
400 .and_then(|session| Ok(session))
401 .map(|session| Arc::new(session) as Arc<dyn SshSession>)
402 } else {
403 PasswordSession::connect(
405 server_username.clone(),
406 AuthMethod::Password(password.clone()),
407 (server_address.clone(), server_port),
408 )
409 .await
410 .map(|session| Arc::new(session) as Arc<dyn SshSession>)
411 };
412
413 match result {
414 Ok(mut ssh) => {
415 self.render_popup(
416 "Connected!".to_string(),
417 PopupType::Info,
418 )?;
419 self.draw(&mut terminal)?;
420 sleep(Duration::from_millis(1500)).await;
421
422 let code = {
424 terminal.clear()?;
425 execute!(
426 stdout(),
427 RestorePosition,
428 Clear(ClearType::FromCursorDown),
429 crossterm::cursor::Show
430 )?;
431 match Arc::get_mut(&mut ssh)
432 .unwrap()
433 .call(&server_shell)
434 .await
435 {
436 Ok(code) => code,
437 Err(e) => {
438 self.render_popup(
439 e.to_string(),
440 PopupType::Error,
441 )?;
442 self.is_connecting = false;
443 1 }
445 }
446 };
447 match Arc::get_mut(&mut ssh).unwrap().close().await {
448 Ok(_) => {}
449 Err(e) => {
450 self.render_popup(
451 e.to_string(),
452 PopupType::Error,
453 )?;
454 self.is_connecting = false;
455 debug_log!("debug.log", "Close error: {:?}", e);
456 }
457 }
458 terminal.clear()?;
459 debug_log!("debug.log", "Exitcode: {:?}", code);
460 self.is_connecting = false;
461 if code == 0 {
462 self.show_popup = false;
463 }
464 }
465 Err(e) => {
466 self.show_popup = true;
467 let error_message = if e.to_string().is_empty() {
468 "Connection error occurred".to_string()
469 } else {
470 e.to_string()
471 };
472 debug_log!("debug.log", "{}", error_message);
473 self.render_popup(error_message, PopupType::Error)?;
474 self.is_connecting = false;
475 }
476 }
477 } else {
478 self.render_popup(
479 format!("Cannot find password of server {}", server.name),
480 PopupType::Error,
481 )?;
482 }
483 }
484 }
485 _ => {}
486 }
487 }
488 }
489 }
490 }
491
492 fn refresh_serverlist(&mut self) {
493 let server_items: Vec<ServerItem> = self
494 .config
495 .servers
496 .clone()
497 .into_iter()
498 .map(|server| ServerItem {
499 id: server.id,
500 name: server.name,
501 address: server.ip,
502 username: server.user,
503 shell: server.shell,
504 port: server.port,
505 })
506 .collect();
507 self.server_list = ServerList::with_items(server_items);
508 }
509
510 fn render_popup(&mut self, message: String, popup_type: PopupType) -> Result<()> {
511 self.popup_info = Some(PopupInfo {
512 message,
513 popup_type,
514 });
515 self.show_popup = true;
516 Ok(())
517 }
518}
519
520fn find_best_key() -> Option<PathBuf> {
521 let home_dir = dirs::home_dir()?;
522 let ssh_dir = home_dir.join(".ssh");
523
524 let key_priorities = [
525 "id_ecdsa", "id_ecdsa_384", "id_ecdsa_521", "id_ed25519", "id_rsa", ];
531
532 for key_name in key_priorities.iter() {
533 let key_path = ssh_dir.join(key_name);
534 if key_path.exists() {
535 return Some(key_path);
536 }
537 }
538
539 None
540}
541
542fn load_key_with_passphrase(
543 key_path: PathBuf,
544 terminal: &mut Terminal<impl Backend>,
545) -> Result<russh_keys::key::KeyPair> {
546 load_secret_key(key_path.clone(), None).or_else(|e| {
547 if let russh_keys::Error::KeyIsEncrypted = e {
548 let mut input_box = PopupInputBox::new(" Input key's passphrase: ".to_string());
549 let passphrase = input_box
550 .run(terminal)?
551 .ok_or_else(|| anyhow::anyhow!("Input is empty"))?;
552 load_secret_key(key_path, Some(passphrase.as_str())).map_err(|e| e.into())
553 } else {
554 Err(e.into())
555 }
556 })
557}
558
559#[cfg(test)]
560mod tests {
561 use super::*;
562 use std::fs::File;
563 use tempfile::TempDir;
564
565 #[test]
566 fn test_find_best_key() {
567 let temp_dir = TempDir::new().unwrap();
569 let home_dir = temp_dir.path();
570 let ssh_dir = home_dir.join(".ssh");
571 std::fs::create_dir(&ssh_dir).unwrap();
572
573 std::env::set_var("HOME", home_dir.to_str().unwrap());
575
576 assert_eq!(find_best_key(), None);
578
579 File::create(ssh_dir.join("id_rsa")).unwrap();
581 assert_eq!(find_best_key(), Some(ssh_dir.join("id_rsa")));
582
583 File::create(ssh_dir.join("id_ed25519")).unwrap();
585 assert_eq!(find_best_key(), Some(ssh_dir.join("id_ed25519")));
586
587 File::create(ssh_dir.join("id_ecdsa")).unwrap();
589 assert_eq!(find_best_key(), Some(ssh_dir.join("id_ecdsa")));
590
591 temp_dir.close().unwrap();
593 }
594}