ssh_utils_lib/
app.rs

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                // we render the app itself on when there is no popup
240                f.render_widget(self, f.area());
241            }
242        })?;
243        Ok(())
244    }
245
246    /// helper function to create a centered rect using up certain percentage of the available rect `r`
247    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                            // Set this hotkey because of man's habit
293                            if key.modifiers == KeyModifiers::CONTROL {
294                                return Ok(());
295                            }
296                        }
297                        Char('a') => {
298                            // Add server
299                            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                            // Edit server
308                            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                                            // result 1
371                                            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(); // unwrap is safe here
381                                            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                                            // result 2
404                                            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                                            // 处理 SSH 会话
423                                            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 // error occurred
444                                                    }
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",     // ecdsa-sha2-nistp256
526        "id_ecdsa_384", // ecdsa-sha2-nistp384
527        "id_ecdsa_521", // ecdsa-sha2-nistp521
528        "id_ed25519",   // ssh-ed25519
529        "id_rsa",       // rsa-sha2-256, rsa-sha2-512, ssh-rsa
530    ];
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        // Create a temporary directory to simulate the home directory
568        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        // Simulate environment variable
574        std::env::set_var("HOME", home_dir.to_str().unwrap());
575
576        // Test scenario 1: No key files present
577        assert_eq!(find_best_key(), None);
578
579        // Test scenario 2: Only id_rsa present
580        File::create(ssh_dir.join("id_rsa")).unwrap();
581        assert_eq!(find_best_key(), Some(ssh_dir.join("id_rsa")));
582
583        // Test scenario 3: Both id_rsa and id_ed25519 present
584        File::create(ssh_dir.join("id_ed25519")).unwrap();
585        assert_eq!(find_best_key(), Some(ssh_dir.join("id_ed25519")));
586
587        // Test scenario 4: Multiple keys present, should select the highest priority one
588        File::create(ssh_dir.join("id_ecdsa")).unwrap();
589        assert_eq!(find_best_key(), Some(ssh_dir.join("id_ecdsa")));
590
591        // Cleanup
592        temp_dir.close().unwrap();
593    }
594}