From 77f9568cb1e064cb8c63e7e6e31df0494b9a43da Mon Sep 17 00:00:00 2001 From: x3 Date: Mon, 7 Sep 2026 14:50:20 +0000 Subject: [PATCH] feat: initial x3-ssh client - GTK3 GUI with VTE terminal emulation - Tabbed SSH sessions - Server management (add/edit/remove) - SSH key management (generate/import/delete) - Quick connect bar - libssh for SSH connections - autotools build system (configure && make) --- .gitignore | 26 ++ Makefile.am | 2 + README.md | 66 +++++ configure.ac | 14 + icons/x3-ssh.png | Bin 0 -> 21564 bytes src/Makefile.am | 24 ++ src/key_store.cpp | 162 ++++++++++ src/key_store.h | 27 ++ src/main.cpp | 11 + src/server_store.cpp | 110 +++++++ src/server_store.h | 28 ++ src/ssh_client.cpp | 681 +++++++++++++++++++++++++++++++++++++++++++ src/ssh_client.h | 58 ++++ src/ssh_terminal.cpp | 228 +++++++++++++++ src/ssh_terminal.h | 39 +++ 15 files changed, 1476 insertions(+) create mode 100644 .gitignore create mode 100644 Makefile.am create mode 100644 README.md create mode 100644 configure.ac create mode 100644 icons/x3-ssh.png create mode 100644 src/Makefile.am create mode 100644 src/key_store.cpp create mode 100644 src/key_store.h create mode 100644 src/main.cpp create mode 100644 src/server_store.cpp create mode 100644 src/server_store.h create mode 100644 src/ssh_client.cpp create mode 100644 src/ssh_client.h create mode 100644 src/ssh_terminal.cpp create mode 100644 src/ssh_terminal.h diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..55d9631 --- /dev/null +++ b/.gitignore @@ -0,0 +1,26 @@ +# Build artifacts +*.o +*.Po +*.Plo +*.lo +x3-ssh + +# Autotools generated +Makefile +Makefile.in +aclocal.m4 +autom4te.cache/ +build-aux/ +config.log +config.status +configure +configure~ + +# Editor files +*~ +*.swp +.vscode/ +.idea/ + +# OS files +.DS_Store diff --git a/Makefile.am b/Makefile.am new file mode 100644 index 0000000..a7b92a7 --- /dev/null +++ b/Makefile.am @@ -0,0 +1,2 @@ +SUBDIRS = src +EXTRA_DIST = README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..27c2805 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# x3-ssh + +A GTK3-based SSH client with a tabbed terminal interface, server management, and SSH key storage. + +![Icon](icons/x3-ssh.png) + +## Features + +- **Tabbed terminal** - Multiple SSH sessions in tabs +- **Server management** - Save, edit, and remove SSH server configurations +- **SSH key management** - Generate, import, and manage SSH keys +- **Quick connect** - Connect directly via host/user/port fields +- **VTE terminal** - Full terminal emulation with 256 color support +- **Resizable pty** - Terminal auto-resizes the remote pty on window resize + +## Dependencies + +- GTK+ 3.0 (>= 3.24) +- VTE 2.91 (>= 0.50) +- libssh (>= 0.9) +- autotools / autoconf / automake / libtool +- pkg-config +- g++ with C++17 support + +## Building + +```bash +# Install dependencies (Debian/Ubuntu) +sudo apt install libgtk-3-dev libvte-2.91-dev libssh-dev \ + autoconf automake libtool pkg-config + +# Build +mkdir -p build-aux +autoreconf -fi +./configure +make + +# Install (optional) +sudo make install +``` + +## Configuration + +Server configurations are stored in: +``` +~/.config/sshclient/servers.conf +``` + +SSH keys are stored in: +``` +~/.config/sshclient/keys.conf +~/.config/sshclient/keys/ +``` + +## Usage + +1. Start the application: `./x3-ssh` +2. **Quick Connect**: Enter host, user, and port in the toolbar, click "Connect" +3. **Saved Servers**: Click "Servers" to add/edit/remove saved server configurations +4. **SSH Keys**: Click "Keys" to generate or import SSH keys +5. **Tabs**: Click "+ Tab" for a new empty tab, "Close Tab" to close the current one +6. Select a saved server from the dropdown to auto-fill connection details + +## License + +MIT diff --git a/configure.ac b/configure.ac new file mode 100644 index 0000000..d56dc4e --- /dev/null +++ b/configure.ac @@ -0,0 +1,14 @@ +AC_INIT([x3-ssh], [1.0.0], [x3@sysdaemon.xyz]) +AC_CONFIG_SRCDIR([src/main.cpp]) +AC_CONFIG_AUX_DIR([build-aux]) +AM_INIT_AUTOMAKE([foreign -Wall -Werror subdir-objects]) + +AC_PROG_CXX +AX_CXX_COMPILE_STDCXX([17], [noext], [mandatory]) + +PKG_CHECK_MODULES([GTK], [gtk+-3.0 >= 3.24]) +PKG_CHECK_MODULES([VTE], [vte-2.91 >= 0.50]) +PKG_CHECK_MODULES([LIBSSH], [libssh >= 0.9]) + +AC_CONFIG_FILES([Makefile src/Makefile]) +AC_OUTPUT diff --git a/icons/x3-ssh.png b/icons/x3-ssh.png new file mode 100644 index 0000000000000000000000000000000000000000..dbf1638a4e4e7a7205937209693749655236cc1e GIT binary patch literal 21564 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%0DWGof)IewyzQqrcPo&VG^Y$EL|9O* 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 +#include +#include +#include +#include +#include + +static std::string trim(const std::string& s) { + size_t a = s.find_first_not_of(" \t\r\n"); + if (a == std::string::npos) return ""; + size_t b = s.find_last_not_of(" \t\r\n"); + return s.substr(a, b - a + 1); +} + +static std::string get_fingerprint(const std::string& pubkey_path) { + std::string cmd = "ssh-keygen -lf \"" + pubkey_path + "\" 2>/dev/null"; + FILE* pipe = popen(cmd.c_str(), "r"); + if (!pipe) return ""; + char buf[512]; + std::string result; + if (fgets(buf, sizeof(buf), pipe)) { + result = trim(buf); + /* Remove the key type prefix like "(RSA)" */ + auto paren = result.find('('); + if (paren != std::string::npos) { + result = trim(result.substr(0, paren)); + } + } + pclose(pipe); + return result; +} + +KeyStore::KeyStore() { + const char* home = getenv("HOME"); + if (!home) home = "."; + config_path_ = std::string(home) + "/.config/sshclient/keys.conf"; + keys_dir_ = std::string(home) + "/.config/sshclient/keys"; + std::string cmd = "mkdir -p \"" + keys_dir_ + "\""; + system(cmd.c_str()); + load(); +} + +void KeyStore::load() { + keys_.clear(); + std::ifstream f(config_path_); + if (!f.is_open()) return; + + SshKey current; + bool in_key = false; + std::string line; + + while (std::getline(f, line)) { + line = trim(line); + if (line.empty() || line[0] == '#') { + if (in_key && !current.name.empty()) { + keys_.push_back(current); + current = SshKey(); + in_key = false; + } + continue; + } + if (line[0] == '[' && line.back() == ']') { + if (in_key && !current.name.empty()) { + keys_.push_back(current); + current = SshKey(); + } + current.name = trim(line.substr(1, line.size() - 2)); + in_key = true; + continue; + } + if (!in_key) continue; + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = trim(line.substr(0, eq)); + std::string val = trim(line.substr(eq + 1)); + if (key == "pubkey_path") current.pubkey_path = val; + else if (key == "privkey_path") current.privkey_path = val; + else if (key == "fingerprint") current.fingerprint = val; + } + if (in_key && !current.name.empty()) { + keys_.push_back(current); + } +} + +void KeyStore::save() const { + std::ofstream f(config_path_); + if (!f.is_open()) return; + + f << "# SSH Client - Stored Keys\n\n"; + for (const auto& k : keys_) { + f << "[" << k.name << "]\n"; + f << "pubkey_path=" << k.pubkey_path << "\n"; + f << "privkey_path=" << k.privkey_path << "\n"; + f << "fingerprint=" << k.fingerprint << "\n\n"; + } +} + +void KeyStore::add(const SshKey& k) { + keys_.push_back(k); + save(); +} + +void KeyStore::remove(int index) { + if (index >= 0 && index < (int)keys_.size()) { + keys_.erase(keys_.begin() + index); + save(); + } +} + +bool KeyStore::generate_keypair(const std::string& name, int bits) { + std::string priv_path = keys_dir_ + "/" + name; + std::string pub_path = priv_path + ".pub"; + + std::string cmd = "ssh-keygen -t rsa -b " + std::to_string(bits) + + " -f \"" + priv_path + "\" -N \"\" -C \"" + name + "\" 2>/dev/null"; + int rc = system(cmd.c_str()); + if (rc != 0) return false; + + SshKey k; + k.name = name; + k.privkey_path = priv_path; + k.pubkey_path = pub_path; + k.fingerprint = get_fingerprint(pub_path); + + add(k); + return true; +} + +bool KeyStore::import_key(const std::string& name, const std::string& privkey_path) { + ssh_key key = nullptr; + if (ssh_pki_import_privkey_file(privkey_path.c_str(), nullptr, nullptr, nullptr, &key) != SSH_OK) { + return false; + } + ssh_key_free(key); + + std::string dest = keys_dir_ + "/" + name; + std::string cp_cmd = "cp \"" + privkey_path + "\" \"" + dest + "\" && chmod 600 \"" + dest + "\""; + int rc = system(cp_cmd.c_str()); + if (rc != 0) return false; + + std::string pub_src = privkey_path + ".pub"; + std::string pub_dest = dest + ".pub"; + std::string cp_pub = "cp \"" + pub_src + "\" \"" + pub_dest + "\" 2>/dev/null"; + system(cp_pub.c_str()); + + SshKey k; + k.name = name; + k.privkey_path = dest; + k.pubkey_path = pub_dest; + k.fingerprint = get_fingerprint(pub_dest); + + add(k); + return true; +} + +SshKey KeyStore::find_by_name(const std::string& name) const { + for (const auto& k : keys_) { + if (k.name == name) return k; + } + return {}; +} diff --git a/src/key_store.h b/src/key_store.h new file mode 100644 index 0000000..67eb3e7 --- /dev/null +++ b/src/key_store.h @@ -0,0 +1,27 @@ +#pragma once +#include +#include + +struct SshKey { + std::string name; + std::string pubkey_path; + std::string privkey_path; + std::string fingerprint; +}; + +class KeyStore { +public: + KeyStore(); + void load(); + void save() const; + const std::vector& keys() const { return keys_; } + void add(const SshKey& k); + void remove(int index); + bool generate_keypair(const std::string& name, int bits = 4096); + bool import_key(const std::string& name, const std::string& privkey_path); + SshKey find_by_name(const std::string& name) const; +private: + std::vector keys_; + std::string config_path_; + std::string keys_dir_; +}; diff --git a/src/main.cpp b/src/main.cpp new file mode 100644 index 0000000..301e326 --- /dev/null +++ b/src/main.cpp @@ -0,0 +1,11 @@ +#include +#include "ssh_client.h" + +int main(int argc, char* argv[]) { + gtk_init(&argc, &argv); + + SshClientWindow window; + window.run(); + + return 0; +} diff --git a/src/server_store.cpp b/src/server_store.cpp new file mode 100644 index 0000000..aaee501 --- /dev/null +++ b/src/server_store.cpp @@ -0,0 +1,110 @@ +#include "server_store.h" +#include +#include +#include +#include + +static std::string trim(const std::string& s) { + size_t a = s.find_first_not_of(" \t\r\n"); + if (a == std::string::npos) return ""; + size_t b = s.find_last_not_of(" \t\r\n"); + return s.substr(a, b - a + 1); +} + +ServerStore::ServerStore() { + const char* home = getenv("HOME"); + if (!home) home = "."; + config_path_ = std::string(home) + "/.config/sshclient/servers.conf"; + load(); +} + +void ServerStore::load() { + servers_.clear(); + std::ifstream f(config_path_); + if (!f.is_open()) return; + + ServerConfig current; + bool in_server = false; + std::string line; + + while (std::getline(f, line)) { + line = trim(line); + if (line.empty() || line[0] == '#') { + if (in_server && !current.name.empty()) { + servers_.push_back(current); + current = ServerConfig(); + in_server = false; + } + continue; + } + if (line[0] == '[' && line.back() == ']') { + if (in_server && !current.name.empty()) { + servers_.push_back(current); + current = ServerConfig(); + } + current.name = trim(line.substr(1, line.size() - 2)); + in_server = true; + continue; + } + if (!in_server) continue; + auto eq = line.find('='); + if (eq == std::string::npos) continue; + std::string key = trim(line.substr(0, eq)); + std::string val = trim(line.substr(eq + 1)); + if (key == "host") current.host = val; + else if (key == "port") current.port = std::stoi(val); + else if (key == "user") current.user = val; + else if (key == "keyfile") current.keyfile = val; + else if (key == "use_key") current.use_key = (val == "true"); + } + if (in_server && !current.name.empty()) { + servers_.push_back(current); + } +} + +void ServerStore::save() const { + std::string dir = config_path_.substr(0, config_path_.rfind('/')); + std::string cmd = "mkdir -p \"" + dir + "\""; + system(cmd.c_str()); + + std::ofstream f(config_path_); + if (!f.is_open()) return; + + f << "# SSH Client - Saved Servers\n"; + f << "# Do not edit manually unless you know what you are doing.\n\n"; + + for (const auto& s : servers_) { + f << "[" << s.name << "]\n"; + f << "host=" << s.host << "\n"; + f << "port=" << s.port << "\n"; + f << "user=" << s.user << "\n"; + f << "keyfile=" << s.keyfile << "\n"; + f << "use_key=" << (s.use_key ? "true" : "false") << "\n\n"; + } +} + +void ServerStore::add(const ServerConfig& s) { + servers_.push_back(s); + save(); +} + +void ServerStore::update(int index, const ServerConfig& s) { + if (index >= 0 && index < (int)servers_.size()) { + servers_[index] = s; + save(); + } +} + +void ServerStore::remove(int index) { + if (index >= 0 && index < (int)servers_.size()) { + servers_.erase(servers_.begin() + index); + save(); + } +} + +ServerConfig ServerStore::find_by_name(const std::string& name) const { + for (const auto& s : servers_) { + if (s.name == name) return s; + } + return {}; +} diff --git a/src/server_store.h b/src/server_store.h new file mode 100644 index 0000000..0c055f1 --- /dev/null +++ b/src/server_store.h @@ -0,0 +1,28 @@ +#pragma once +#include +#include +#include + +struct ServerConfig { + std::string name; + std::string host; + int port = 22; + std::string user; + std::string keyfile; + bool use_key = false; +}; + +class ServerStore { +public: + ServerStore(); + void load(); + void save() const; + const std::vector& servers() const { return servers_; } + void add(const ServerConfig& s); + void update(int index, const ServerConfig& s); + void remove(int index); + ServerConfig find_by_name(const std::string& name) const; +private: + std::vector servers_; + std::string config_path_; +}; diff --git a/src/ssh_client.cpp b/src/ssh_client.cpp new file mode 100644 index 0000000..165c943 --- /dev/null +++ b/src/ssh_client.cpp @@ -0,0 +1,681 @@ +#include "ssh_client.h" +#include +#include + +static GtkWidget* make_button(const char* label, GCallback cb, gpointer data) { + GtkWidget* b = gtk_button_new_with_label(label); + g_signal_connect(b, "clicked", cb, data); + return b; +} + +static void clear_container(GtkWidget* container) { + GList* children = gtk_container_get_children(GTK_CONTAINER(container)); + for (GList* l = children; l != nullptr; l = l->next) + gtk_widget_destroy(GTK_WIDGET(l->data)); + g_list_free(children); +} + +static void refresh_server_list(GtkWidget* listbox, const std::vector& servers) { + clear_container(listbox); + for (const auto& s : servers) { + std::string text = s.name + " | " + s.user + "@" + s.host + ":" + std::to_string(s.port); + if (s.use_key) text += " [key]"; + GtkWidget* row = gtk_label_new(text.c_str()); + gtk_widget_set_halign(row, GTK_ALIGN_START); + gtk_widget_set_margin_start(row, 8); + gtk_widget_set_margin_top(row, 4); + gtk_widget_set_margin_bottom(row, 4); + gtk_list_box_insert(GTK_LIST_BOX(listbox), row, -1); + } +} + +static void refresh_key_list(GtkWidget* listbox, const std::vector& keys) { + clear_container(listbox); + for (const auto& k : keys) { + std::string text = k.name + " | " + k.fingerprint; + GtkWidget* row = gtk_label_new(text.c_str()); + gtk_widget_set_halign(row, GTK_ALIGN_START); + gtk_widget_set_margin_start(row, 8); + gtk_widget_set_margin_top(row, 4); + gtk_widget_set_margin_bottom(row, 4); + gtk_list_box_insert(GTK_LIST_BOX(listbox), row, -1); + } +} + +/* ─── ctor / dtor ─────────────────────────────────────────── */ + +SshClientWindow::SshClientWindow() + : window_(nullptr), notebook_(nullptr), statusbar_(nullptr), + server_combo_(nullptr), quick_entry_(nullptr), + user_entry_(nullptr), port_spin_(nullptr) { + build_ui(); +} + +SshClientWindow::~SshClientWindow() { + for (auto* t : terminals_) delete t; + terminals_.clear(); + if (window_) gtk_widget_destroy(window_); +} + +/* ─── build UI ────────────────────────────────────────────── */ + +void SshClientWindow::build_ui() { + window_ = gtk_window_new(GTK_WINDOW_TOPLEVEL); + gtk_window_set_title(GTK_WINDOW(window_), "x3-ssh"); + gtk_window_set_default_size(GTK_WINDOW(window_), 1100, 700); + g_signal_connect(window_, "destroy", G_CALLBACK(gtk_main_quit), nullptr); + + const char* icon_paths[] = { + "icons/x3-ssh.png", + "/usr/share/icons/x3-ssh.png", + "/usr/local/share/icons/x3-ssh.png", + nullptr + }; + for (int i = 0; icon_paths[i]; ++i) { + if (g_file_test(icon_paths[i], G_FILE_TEST_EXISTS)) { + gtk_window_set_icon_from_file(GTK_WINDOW(window_), icon_paths[i], nullptr); + break; + } + } + + GtkWidget* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + gtk_container_add(GTK_CONTAINER(window_), vbox); + + build_toolbar(); + + GtkWidget* sep = gtk_separator_new(GTK_ORIENTATION_HORIZONTAL); + gtk_box_pack_start(GTK_BOX(vbox), sep, FALSE, FALSE, 0); + + build_notebook(); + build_statusbar(); + + gtk_box_pack_start(GTK_BOX(vbox), notebook_, TRUE, TRUE, 0); + gtk_box_pack_end(GTK_BOX(vbox), statusbar_, FALSE, FALSE, 0); +} + +void SshClientWindow::build_toolbar() { + GtkWidget* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 4); + gtk_widget_set_margin_start(hbox, 6); + gtk_widget_set_margin_end(hbox, 6); + gtk_widget_set_margin_top(hbox, 4); + gtk_widget_set_margin_bottom(hbox, 4); + + server_combo_ = gtk_combo_box_text_new(); + gtk_combo_box_set_wrap_width(GTK_COMBO_BOX(server_combo_), 0); + gtk_widget_set_size_request(server_combo_, 180, -1); + gtk_box_pack_start(GTK_BOX(hbox), server_combo_, FALSE, FALSE, 0); + + quick_entry_ = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(quick_entry_), "host"); + gtk_widget_set_size_request(quick_entry_, 150, -1); + gtk_box_pack_start(GTK_BOX(hbox), quick_entry_, FALSE, FALSE, 0); + + user_entry_ = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(user_entry_), "user"); + gtk_widget_set_size_request(user_entry_, 100, -1); + gtk_box_pack_start(GTK_BOX(hbox), user_entry_, FALSE, FALSE, 0); + + port_spin_ = gtk_spin_button_new_with_range(1, 65535, 1); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(port_spin_), 22); + gtk_widget_set_size_request(port_spin_, 80, -1); + gtk_box_pack_start(GTK_BOX(hbox), port_spin_, FALSE, FALSE, 0); + + gtk_box_pack_start(GTK_BOX(hbox), make_button("Connect", G_CALLBACK(on_quick_connect), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), make_button("+ Tab", G_CALLBACK(on_new_tab), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), make_button("Close Tab", G_CALLBACK(on_close_tab), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), make_button("Disconnect", G_CALLBACK(on_disconnect), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), make_button("Servers", G_CALLBACK(on_manage_servers), this), FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), make_button("Keys", G_CALLBACK(on_manage_keys), this), FALSE, FALSE, 0); + + update_server_combo(); +} + +void SshClientWindow::build_notebook() { + notebook_ = gtk_notebook_new(); + gtk_notebook_set_scrollable(GTK_NOTEBOOK(notebook_), TRUE); + g_signal_connect(notebook_, "page-removed", G_CALLBACK(on_tab_close), this); +} + +void SshClientWindow::build_statusbar() { + statusbar_ = gtk_statusbar_new(); +} + +void SshClientWindow::update_server_combo() { + gtk_combo_box_text_remove_all(GTK_COMBO_BOX_TEXT(server_combo_)); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(server_combo_), "(Quick Connect)"); + for (const auto& s : server_store_.servers()) { + std::string label = s.name + " (" + s.user + "@" + s.host + ":" + std::to_string(s.port) + ")"; + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(server_combo_), label.c_str()); + } + gtk_combo_box_set_active(GTK_COMBO_BOX(server_combo_), 0); +} + +void SshClientWindow::update_status(const std::string& msg) { + if (!statusbar_) return; + guint ctx_id = gtk_statusbar_get_context_id(GTK_STATUSBAR(statusbar_), "main"); + gtk_statusbar_pop(GTK_STATUSBAR(statusbar_), ctx_id); + gtk_statusbar_push(GTK_STATUSBAR(statusbar_), ctx_id, msg.c_str()); +} + +/* ─── tab management ──────────────────────────────────────── */ + +void SshClientWindow::add_new_tab() { + SshTerminal* term = new SshTerminal(); + term->set_server_name("New Tab"); + terminals_.push_back(term); + + GtkWidget* page = term->widget(); + GtkWidget* label = gtk_label_new("New Tab"); + + int idx = gtk_notebook_append_page(GTK_NOTEBOOK(notebook_), page, label); + gtk_notebook_set_tab_reorderable(GTK_NOTEBOOK(notebook_), page, TRUE); + gtk_widget_show_all(page); + gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook_), idx); + update_status("New tab opened"); +} + +void SshClientWindow::close_current_tab() { + int idx = gtk_notebook_get_current_page(GTK_NOTEBOOK(notebook_)); + if (idx < 0) return; + close_tab_at(idx); +} + +void SshClientWindow::close_tab_at(int idx) { + GtkWidget* page = gtk_notebook_get_nth_page(GTK_NOTEBOOK(notebook_), idx); + if (!page) return; + for (auto it = terminals_.begin(); it != terminals_.end(); ++it) { + if ((*it)->widget() == page) { + (*it)->disconnect(); + delete *it; + terminals_.erase(it); + break; + } + } + gtk_notebook_remove_page(GTK_NOTEBOOK(notebook_), idx); +} + +void SshClientWindow::on_tab_close(GtkNotebook* nb, GtkWidget* page, guint idx, gpointer d) { + (void)nb; (void)idx; + auto* self = static_cast(d); + for (auto it = self->terminals_.begin(); it != self->terminals_.end(); ++it) { + if ((*it)->widget() == page) { + (*it)->disconnect(); + delete *it; + self->terminals_.erase(it); + return; + } + } +} + +/* ─── connect / disconnect ─────────────────────────────────── */ + +void SshClientWindow::connect_to_server(const std::string& host, int port, + const std::string& user, const std::string& keyfile, + const std::string& password, const std::string& name) { + + SshTerminal* term = new SshTerminal(); + std::string tab_name = name.empty() ? (user + "@" + host) : name; + term->set_server_name(tab_name); + terminals_.push_back(term); + + GtkWidget* page = term->widget(); + GtkWidget* label = gtk_label_new(tab_name.c_str()); + + int idx = gtk_notebook_append_page(GTK_NOTEBOOK(notebook_), page, label); + gtk_widget_show_all(page); + gtk_notebook_set_current_page(GTK_NOTEBOOK(notebook_), idx); + + update_status("Connecting to " + host + ":" + std::to_string(port) + "..."); + bool ok = term->connect(host, port, user, keyfile, password); + if (!ok) { + update_status("Failed to connect to " + host); + for (auto it = terminals_.begin(); it != terminals_.end(); ++it) { + if (*it == term) { terminals_.erase(it); break; } + } + delete term; + gtk_notebook_remove_page(GTK_NOTEBOOK(notebook_), idx); + } else { + update_status("Connected to " + user + "@" + host); + } +} + +void SshClientWindow::disconnect() { + int idx = gtk_notebook_get_current_page(GTK_NOTEBOOK(notebook_)); + if (idx < 0) return; + GtkWidget* page = gtk_notebook_get_nth_page(GTK_NOTEBOOK(notebook_), idx); + for (auto* t : terminals_) { + if (t->widget() == page) { + t->disconnect(); + update_status("Disconnected"); + return; + } + } +} + +/* ─── quick connect ───────────────────────────────────────── */ + +void SshClientWindow::on_quick_connect(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + const char* host = gtk_entry_get_text(GTK_ENTRY(self->quick_entry_)); + const char* user = gtk_entry_get_text(GTK_ENTRY(self->user_entry_)); + int port = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(self->port_spin_)); + + if (!host || !*host) { + self->update_status("Please enter a hostname"); + return; + } + std::string u = (user && *user) ? user : (getenv("USER") ? getenv("USER") : "root"); + + int sel = gtk_combo_box_get_active(GTK_COMBO_BOX(self->server_combo_)); + if (sel > 0 && sel <= (int)self->server_store_.servers().size()) { + const auto& s = self->server_store_.servers()[sel - 1]; + if (s.use_key && !s.keyfile.empty()) { + self->connect_to_server(s.host, s.port, s.user, s.keyfile, "", s.name); + return; + } + self->show_password_dialog(s.host, s.user, s.keyfile, s.name); + return; + } + + self->show_password_dialog(host, u, "", host); +} + +/* ─── password dialog ─────────────────────────────────────── */ + +void SshClientWindow::show_password_dialog(const std::string& host, const std::string& user, + const std::string& keyfile, const std::string& name) { + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "SSH Authentication", GTK_WINDOW(window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, + "_Connect", GTK_RESPONSE_ACCEPT, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid), 8); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_widget_set_margin_start(grid, 12); + gtk_widget_set_margin_end(grid, 12); + gtk_widget_set_margin_top(grid, 12); + gtk_widget_set_margin_bottom(grid, 12); + + GtkWidget* info_label = gtk_label_new(nullptr); + char markup[512]; + snprintf(markup, sizeof(markup), "Connect to %s@%s", user.c_str(), host.c_str()); + gtk_label_set_markup(GTK_LABEL(info_label), markup); + gtk_grid_attach(GTK_GRID(grid), info_label, 0, 0, 2, 1); + + GtkWidget* pass_label = gtk_label_new("Password:"); + gtk_widget_set_halign(pass_label, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), pass_label, 0, 1, 1, 1); + + GtkWidget* pass_entry = gtk_entry_new(); + gtk_entry_set_visibility(GTK_ENTRY(pass_entry), FALSE); + gtk_entry_set_placeholder_text(GTK_ENTRY(pass_entry), "Enter password"); + gtk_widget_set_hexpand(pass_entry, TRUE); + gtk_grid_attach(GTK_GRID(grid), pass_entry, 1, 1, 1, 1); + + gtk_container_add(GTK_CONTAINER(content), grid); + gtk_widget_show_all(dialog); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + const char* password = gtk_entry_get_text(GTK_ENTRY(pass_entry)); + connect_to_server(host, 22, user, keyfile, password ? password : "", name); + } + gtk_widget_destroy(dialog); +} + +/* ─── server dialog ───────────────────────────────────────── */ + +void SshClientWindow::show_server_dialog(int edit_index) { + bool editing = (edit_index >= 0); + ServerConfig existing; + if (editing && edit_index < (int)server_store_.servers().size()) { + existing = server_store_.servers()[edit_index]; + } + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + editing ? "Edit Server" : "Add Server", GTK_WINDOW(window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, + editing ? "_Save" : "_Add", GTK_RESPONSE_ACCEPT, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* grid = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(grid), 8); + gtk_grid_set_row_spacing(GTK_GRID(grid), 6); + gtk_widget_set_margin_start(grid, 12); + gtk_widget_set_margin_end(grid, 12); + gtk_widget_set_margin_top(grid, 12); + gtk_widget_set_margin_bottom(grid, 12); + + auto add_field = [&](int row, const char* lbl, const char* val) -> GtkWidget* { + GtkWidget* l = gtk_label_new(lbl); + gtk_widget_set_halign(l, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), l, 0, row, 1, 1); + GtkWidget* e = gtk_entry_new(); + if (val && *val) gtk_entry_set_text(GTK_ENTRY(e), val); + gtk_widget_set_hexpand(e, TRUE); + gtk_grid_attach(GTK_GRID(grid), e, 1, row, 1, 1); + return e; + }; + + GtkWidget* name_e = add_field(0, "Name:", editing ? existing.name.c_str() : ""); + GtkWidget* host_e = add_field(1, "Host:", editing ? existing.host.c_str() : ""); + GtkWidget* user_e = add_field(2, "User:", editing ? existing.user.c_str() : ""); + + GtkWidget* port_l = gtk_label_new("Port:"); + gtk_widget_set_halign(port_l, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), port_l, 0, 3, 1, 1); + GtkWidget* port_s = gtk_spin_button_new_with_range(1, 65535, 1); + if (editing) gtk_spin_button_set_value(GTK_SPIN_BUTTON(port_s), existing.port); + else gtk_spin_button_set_value(GTK_SPIN_BUTTON(port_s), 22); + gtk_grid_attach(GTK_GRID(grid), port_s, 1, 3, 1, 1); + + GtkWidget* key_l = gtk_label_new("SSH Key:"); + gtk_widget_set_halign(key_l, GTK_ALIGN_START); + gtk_grid_attach(GTK_GRID(grid), key_l, 0, 4, 1, 1); + + GtkWidget* key_combo = gtk_combo_box_text_new(); + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(key_combo), "(None / Password)"); + for (const auto& k : key_store_.keys()) { + std::string lbl = k.name; + if (!k.fingerprint.empty()) lbl += " [" + k.fingerprint.substr(0, 20) + "]"; + gtk_combo_box_text_append_text(GTK_COMBO_BOX_TEXT(key_combo), lbl.c_str()); + } + if (editing && existing.use_key) { + for (int i = 0; i < (int)key_store_.keys().size(); ++i) { + if (key_store_.keys()[i].privkey_path == existing.keyfile) { + gtk_combo_box_set_active(GTK_COMBO_BOX(key_combo), i + 1); + break; + } + } + } else { + gtk_combo_box_set_active(GTK_COMBO_BOX(key_combo), 0); + } + gtk_widget_set_hexpand(key_combo, TRUE); + gtk_grid_attach(GTK_GRID(grid), key_combo, 1, 4, 1, 1); + + gtk_container_add(GTK_CONTAINER(content), grid); + gtk_widget_show_all(dialog); + + if (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_ACCEPT) { + ServerConfig s; + s.name = gtk_entry_get_text(GTK_ENTRY(name_e)); + s.host = gtk_entry_get_text(GTK_ENTRY(host_e)); + s.user = gtk_entry_get_text(GTK_ENTRY(user_e)); + s.port = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(port_s)); + int key_idx = gtk_combo_box_get_active(GTK_COMBO_BOX(key_combo)); + if (key_idx > 0 && key_idx <= (int)key_store_.keys().size()) { + s.use_key = true; + s.keyfile = key_store_.keys()[key_idx - 1].privkey_path; + } else { + s.use_key = false; + s.keyfile = ""; + } + + if (!s.name.empty() && !s.host.empty() && !s.user.empty()) { + if (editing) server_store_.update(edit_index, s); + else server_store_.add(s); + update_server_combo(); + } + } + gtk_widget_destroy(dialog); +} + +/* ─── key management dialog ───────────────────────────────── */ + +struct KeyDlgCtx { SshClientWindow* self; GtkWidget* listbox; }; + +void SshClientWindow::show_key_dialog() { + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "Manage SSH Keys", GTK_WINDOW(window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Close", GTK_RESPONSE_CLOSE, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_start(vbox, 12); + gtk_widget_set_margin_end(vbox, 12); + gtk_widget_set_margin_top(vbox, 12); + + GtkWidget* scrolled = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(scrolled, 450, 200); + + GtkWidget* listbox = gtk_list_box_new(); + gtk_container_add(GTK_CONTAINER(scrolled), listbox); + gtk_box_pack_start(GTK_BOX(vbox), scrolled, TRUE, TRUE, 0); + + refresh_key_list(listbox, key_store_.keys()); + + GtkWidget* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* gen_btn = gtk_button_new_with_label("Generate New Key"); + GtkWidget* imp_btn = gtk_button_new_with_label("Import Key"); + GtkWidget* del_btn = gtk_button_new_with_label("Delete Selected"); + gtk_box_pack_start(GTK_BOX(hbox), gen_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), imp_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), del_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(vbox), hbox, FALSE, FALSE, 0); + + auto* ctx = new KeyDlgCtx{this, listbox}; + + g_signal_connect(gen_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkWidget* dlg = gtk_dialog_new_with_buttons("Generate Key", GTK_WINDOW(c->self->window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, "_Generate", GTK_RESPONSE_ACCEPT, nullptr); + GtkWidget* ca = gtk_dialog_get_content_area(GTK_DIALOG(dlg)); + GtkWidget* g = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(g), 8); + gtk_widget_set_margin_start(g, 12); + gtk_widget_set_margin_end(g, 12); + gtk_widget_set_margin_top(g, 12); + + GtkWidget* nl = gtk_label_new("Key Name:"); + gtk_grid_attach(GTK_GRID(g), nl, 0, 0, 1, 1); + GtkWidget* ne = gtk_entry_new(); + gtk_entry_set_placeholder_text(GTK_ENTRY(ne), "my-key-name"); + gtk_widget_set_hexpand(ne, TRUE); + gtk_grid_attach(GTK_GRID(g), ne, 1, 0, 1, 1); + + GtkWidget* bl = gtk_label_new("Bits:"); + gtk_grid_attach(GTK_GRID(g), bl, 0, 1, 1, 1); + GtkWidget* bs = gtk_spin_button_new_with_range(2048, 16384, 1024); + gtk_spin_button_set_value(GTK_SPIN_BUTTON(bs), 4096); + gtk_grid_attach(GTK_GRID(g), bs, 1, 1, 1, 1); + + gtk_container_add(GTK_CONTAINER(ca), g); + gtk_widget_show_all(dlg); + + if (gtk_dialog_run(GTK_DIALOG(dlg)) == GTK_RESPONSE_ACCEPT) { + const char* name = gtk_entry_get_text(GTK_ENTRY(ne)); + int bits = gtk_spin_button_get_value_as_int(GTK_SPIN_BUTTON(bs)); + if (name && *name) { + c->self->key_store_.generate_keypair(name, bits); + c->self->update_server_combo(); + refresh_key_list(c->listbox, c->self->key_store_.keys()); + } + } + gtk_widget_destroy(dlg); + }), ctx); + + g_signal_connect(imp_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkWidget* fc = gtk_file_chooser_dialog_new("Import Private Key", + GTK_WINDOW(c->self->window_), GTK_FILE_CHOOSER_ACTION_OPEN, + "_Cancel", GTK_RESPONSE_CANCEL, "_Open", GTK_RESPONSE_ACCEPT, nullptr); + + GtkFileFilter* filter = gtk_file_filter_new(); + gtk_file_filter_set_name(filter, "SSH Keys"); + gtk_file_filter_add_pattern(filter, "id_*"); + gtk_file_filter_add_pattern(filter, "*.pem"); + gtk_file_filter_add_pattern(filter, "*.key"); + gtk_file_chooser_add_filter(GTK_FILE_CHOOSER(fc), filter); + + if (gtk_dialog_run(GTK_DIALOG(fc)) == GTK_RESPONSE_ACCEPT) { + char* path = gtk_file_chooser_get_filename(GTK_FILE_CHOOSER(fc)); + if (path) { + GtkWidget* dlg = gtk_dialog_new_with_buttons("Key Name", GTK_WINDOW(c->self->window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Cancel", GTK_RESPONSE_CANCEL, "_OK", GTK_RESPONSE_ACCEPT, nullptr); + GtkWidget* ca = gtk_dialog_get_content_area(GTK_DIALOG(dlg)); + GtkWidget* g = gtk_grid_new(); + gtk_grid_set_column_spacing(GTK_GRID(g), 8); + gtk_widget_set_margin_start(g, 12); + gtk_widget_set_margin_top(g, 12); + + GtkWidget* nl = gtk_label_new("Name:"); + gtk_grid_attach(GTK_GRID(g), nl, 0, 0, 1, 1); + GtkWidget* ne = gtk_entry_new(); + gtk_widget_set_hexpand(ne, TRUE); + gtk_grid_attach(GTK_GRID(g), ne, 1, 0, 1, 1); + + gtk_container_add(GTK_CONTAINER(ca), g); + gtk_widget_show_all(dlg); + + if (gtk_dialog_run(GTK_DIALOG(dlg)) == GTK_RESPONSE_ACCEPT) { + const char* name = gtk_entry_get_text(GTK_ENTRY(ne)); + if (name && *name) { + c->self->key_store_.import_key(name, path); + refresh_key_list(c->listbox, c->self->key_store_.keys()); + } + } + gtk_widget_destroy(dlg); + g_free(path); + } + } + gtk_widget_destroy(fc); + }), ctx); + + g_signal_connect(del_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkListBoxRow* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(c->listbox)); + if (!row) return; + int idx = gtk_list_box_row_get_index(row); + if (idx >= 0 && idx < (int)c->self->key_store_.keys().size()) { + c->self->key_store_.remove(idx); + refresh_key_list(c->listbox, c->self->key_store_.keys()); + } + }), ctx); + + gtk_container_add(GTK_CONTAINER(content), vbox); + gtk_widget_show_all(dialog); + + while (gtk_dialog_run(GTK_DIALOG(dialog)) == GTK_RESPONSE_NONE) {} + delete ctx; + gtk_widget_destroy(dialog); +} + +/* ─── server management dialog ────────────────────────────── */ + +struct SrvCtx { SshClientWindow* self; GtkWidget* listbox; }; + +void SshClientWindow::on_manage_servers(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + + GtkWidget* dialog = gtk_dialog_new_with_buttons( + "Manage Servers", GTK_WINDOW(self->window_), + (GtkDialogFlags)(GTK_DIALOG_MODAL | GTK_DIALOG_DESTROY_WITH_PARENT), + "_Close", GTK_RESPONSE_CLOSE, + nullptr); + + GtkWidget* content = gtk_dialog_get_content_area(GTK_DIALOG(dialog)); + GtkWidget* vbox = gtk_box_new(GTK_ORIENTATION_VERTICAL, 6); + gtk_widget_set_margin_start(vbox, 12); + gtk_widget_set_margin_end(vbox, 12); + gtk_widget_set_margin_top(vbox, 12); + + GtkWidget* scrolled = gtk_scrolled_window_new(nullptr, nullptr); + gtk_scrolled_window_set_policy(GTK_SCROLLED_WINDOW(scrolled), GTK_POLICY_AUTOMATIC, GTK_POLICY_AUTOMATIC); + gtk_widget_set_size_request(scrolled, 500, 250); + + GtkWidget* listbox = gtk_list_box_new(); + gtk_container_add(GTK_CONTAINER(scrolled), listbox); + gtk_box_pack_start(GTK_BOX(vbox), scrolled, TRUE, TRUE, 0); + + refresh_server_list(listbox, self->server_store_.servers()); + + GtkWidget* hbox = gtk_box_new(GTK_ORIENTATION_HORIZONTAL, 6); + GtkWidget* add_btn = gtk_button_new_with_label("Add"); + GtkWidget* edit_btn = gtk_button_new_with_label("Edit"); + GtkWidget* del_btn = gtk_button_new_with_label("Remove"); + gtk_box_pack_start(GTK_BOX(hbox), add_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), edit_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(hbox), del_btn, FALSE, FALSE, 0); + gtk_box_pack_start(GTK_BOX(vbox), hbox, FALSE, FALSE, 0); + + auto* ctx = new SrvCtx{self, listbox}; + + g_signal_connect(add_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + c->self->show_server_dialog(-1); + c->self->update_server_combo(); + refresh_server_list(c->listbox, c->self->server_store_.servers()); + }), ctx); + + g_signal_connect(edit_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkListBoxRow* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(c->listbox)); + if (!row) return; + int idx = gtk_list_box_row_get_index(row); + if (idx >= 0 && idx < (int)c->self->server_store_.servers().size()) { + c->self->show_server_dialog(idx); + c->self->update_server_combo(); + refresh_server_list(c->listbox, c->self->server_store_.servers()); + } + }), ctx); + + g_signal_connect(del_btn, "clicked", G_CALLBACK(+[](GtkWidget*, gpointer d) { + auto* c = static_cast(d); + GtkListBoxRow* row = gtk_list_box_get_selected_row(GTK_LIST_BOX(c->listbox)); + if (!row) return; + int idx = gtk_list_box_row_get_index(row); + if (idx >= 0 && idx < (int)c->self->server_store_.servers().size()) { + c->self->server_store_.remove(idx); + c->self->update_server_combo(); + refresh_server_list(c->listbox, c->self->server_store_.servers()); + } + }), ctx); + + gtk_container_add(GTK_CONTAINER(content), vbox); + gtk_widget_show_all(dialog); + gtk_dialog_run(GTK_DIALOG(dialog)); + delete ctx; + gtk_widget_destroy(dialog); + self->update_server_combo(); +} + +/* ─── callbacks ───────────────────────────────────────────── */ + +void SshClientWindow::on_new_tab(GtkWidget*, gpointer d) { + static_cast(d)->add_new_tab(); +} + +void SshClientWindow::on_close_tab(GtkWidget*, gpointer d) { + static_cast(d)->close_current_tab(); +} + +void SshClientWindow::on_connect(GtkWidget*, gpointer d) { + auto* self = static_cast(d); + self->show_password_dialog("localhost", "root", "", "Local"); +} + +void SshClientWindow::on_disconnect(GtkWidget*, gpointer d) { + static_cast(d)->disconnect(); +} + +void SshClientWindow::on_manage_keys(GtkWidget*, gpointer d) { + static_cast(d)->show_key_dialog(); +} + +/* ─── main ────────────────────────────────────────────────── */ + +void SshClientWindow::run() { + gtk_widget_show_all(window_); + gtk_main(); +} diff --git a/src/ssh_client.h b/src/ssh_client.h new file mode 100644 index 0000000..ea34020 --- /dev/null +++ b/src/ssh_client.h @@ -0,0 +1,58 @@ +#pragma once +#include +#include +#include +#include "server_store.h" +#include "key_store.h" +#include "ssh_terminal.h" + +class SshClientWindow { +public: + SshClientWindow(); + ~SshClientWindow(); + + GtkWidget* widget() const { return window_; } + void run(); + +private: + void build_ui(); + void build_toolbar(); + void build_notebook(); + void build_statusbar(); + + void add_new_tab(); + void close_current_tab(); + void close_tab_at(int idx); + void disconnect(); + void connect_to_server(const std::string& host, int port, + const std::string& user, const std::string& keyfile, + const std::string& password, const std::string& name); + + void show_server_dialog(int edit_index = -1); + void show_key_dialog(); + void show_password_dialog(const std::string& host, const std::string& user, + const std::string& keyfile, const std::string& name); + void update_server_combo(); + void update_status(const std::string& msg); + + static void on_new_tab(GtkWidget* w, gpointer d); + static void on_close_tab(GtkWidget* w, gpointer d); + static void on_connect(GtkWidget* w, gpointer d); + static void on_disconnect(GtkWidget* w, gpointer d); + static void on_manage_servers(GtkWidget* w, gpointer d); + static void on_manage_keys(GtkWidget* w, gpointer d); + static void on_quick_connect(GtkWidget* w, gpointer d); + static void on_tab_close(GtkNotebook* nb, GtkWidget* page, guint idx, gpointer d); + + GtkWidget* window_; + GtkWidget* notebook_; + GtkWidget* statusbar_; + GtkWidget* server_combo_; + GtkWidget* quick_entry_; + GtkWidget* user_entry_; + GtkWidget* port_spin_; + + ServerStore server_store_; + KeyStore key_store_; + std::vector terminals_; +}; diff --git a/src/ssh_terminal.cpp b/src/ssh_terminal.cpp new file mode 100644 index 0000000..3548514 --- /dev/null +++ b/src/ssh_terminal.cpp @@ -0,0 +1,228 @@ +#include "ssh_terminal.h" +#include +#include +#include +#include +#include + +SshTerminal::SshTerminal() + : terminal_(nullptr), vte_box_(nullptr), session_(nullptr), channel_(nullptr), + pty_master_(-1), pty_slave_(-1), running_(false), connected_(false), thread_(nullptr) { + + vte_box_ = gtk_box_new(GTK_ORIENTATION_VERTICAL, 0); + terminal_ = VTE_TERMINAL(vte_terminal_new()); + gtk_box_pack_start(GTK_BOX(vte_box_), GTK_WIDGET(terminal_), TRUE, TRUE, 0); + gtk_widget_show_all(vte_box_); + + VtePty *pty = vte_terminal_pty_new_sync(terminal_, VTE_PTY_DEFAULT, nullptr, nullptr); + (void)pty; + + g_signal_connect(terminal_, "size-changed", G_CALLBACK(on_size_changed), this); +} + +SshTerminal::~SshTerminal() { + disconnect(); + if (vte_box_) gtk_widget_destroy(vte_box_); +} + +void SshTerminal::on_size_changed(VteTerminal* term, guint cols, guint rows, gpointer data) { + (void)term; + auto* self = static_cast(data); + if (self->channel_ && self->connected_) { + ssh_channel_change_pty_size(self->channel_, cols, rows); + } +} + +void SshTerminal::cleanup() { + if (channel_) { + ssh_channel_close(channel_); + ssh_channel_free(channel_); + channel_ = nullptr; + } + if (session_) { + ssh_disconnect(session_); + ssh_free(session_); + session_ = nullptr; + } + if (pty_master_ >= 0) { close(pty_master_); pty_master_ = -1; } + if (pty_slave_ >= 0) { close(pty_slave_); pty_slave_ = -1; } + connected_ = false; +} + +gpointer SshTerminal::io_thread_func(gpointer data) { + auto* self = static_cast(data); + int ssh_fd = ssh_get_fd(self->session_); + if (ssh_fd < 0) { + self->cleanup(); + g_idle_add([](gpointer d) -> gboolean { + auto* s = static_cast(d); + if (s->on_status_change) s->on_status_change(false, "SSH get_fd failed"); + return G_SOURCE_REMOVE; + }, self); + return nullptr; + } + + char buf[4096]; + fd_set rfds; + struct timeval tv; + + while (self->running_) { + FD_ZERO(&rfds); + FD_SET(self->pty_master_, &rfds); + FD_SET(ssh_fd, &rfds); + int maxfd = (self->pty_master_ > ssh_fd ? self->pty_master_ : ssh_fd) + 1; + + tv.tv_sec = 0; + tv.tv_usec = 50000; + int ret = select(maxfd, &rfds, nullptr, nullptr, &tv); + + if (ret < 0) break; + if (ret == 0) continue; + + if (FD_ISSET(self->pty_master_, &rfds)) { + ssize_t n = read(self->pty_master_, buf, sizeof(buf)); + if (n > 0) { + ssh_channel_write(self->channel_, buf, n); + } else if (n <= 0) { + break; + } + } + + if (FD_ISSET(ssh_fd, &rfds)) { + if (ssh_channel_poll_timeout(self->channel_, 0, 0) > 0) { + ssize_t n = ssh_channel_read_timeout(self->channel_, buf, sizeof(buf), 0, 0); + if (n > 0) { + write(self->pty_master_, buf, n); + } else if (n == SSH_EOF || n == SSH_ERROR) { + break; + } + } else if (ssh_channel_poll_timeout(self->channel_, 0, 0) < 0) { + break; + } + } + + if (ssh_channel_is_eof(self->channel_)) break; + } + + self->connected_ = false; + g_idle_add([](gpointer d) -> gboolean { + auto* s = static_cast(d); + if (s->on_status_change) s->on_status_change(false, "Disconnected"); + return G_SOURCE_REMOVE; + }, self); + + return nullptr; +} + +bool SshTerminal::connect(const std::string& host, int port, const std::string& user, + const std::string& keyfile, const std::string& password) { + if (connected_) disconnect(); + + session_ = ssh_new(); + if (!session_) return false; + + ssh_options_set(session_, SSH_OPTIONS_HOST, host.c_str()); + unsigned int p = port; + ssh_options_set(session_, SSH_OPTIONS_PORT, &p); + ssh_options_set(session_, SSH_OPTIONS_USER, user.c_str()); + int strict = 0; + ssh_options_set(session_, SSH_OPTIONS_STRICTHOSTKEYCHECK, &strict); + + if (ssh_connect(session_) != SSH_OK) { + std::string err = ssh_get_error(session_); + cleanup(); + if (on_status_change) on_status_change(false, "Connection failed: " + err); + return false; + } + + ssh_session_update_known_hosts(session_); + + if (!keyfile.empty()) { + ssh_key key = nullptr; + if (ssh_pki_import_privkey_file(keyfile.c_str(), nullptr, nullptr, nullptr, &key) == SSH_OK) { + if (ssh_userauth_publickey(session_, nullptr, key) == SSH_AUTH_SUCCESS) { + ssh_key_free(key); + goto auth_done; + } + ssh_key_free(key); + } + } + + { + int rc = ssh_userauth_password(session_, nullptr, password.c_str()); + if (rc != SSH_AUTH_SUCCESS) { + std::string err = ssh_get_error(session_); + cleanup(); + if (on_status_change) on_status_change(false, "Auth failed: " + err); + return false; + } + } + +auth_done: + channel_ = ssh_channel_new(session_); + if (!channel_) { + cleanup(); + if (on_status_change) on_status_change(false, "Channel creation failed"); + return false; + } + + if (ssh_channel_open_session(channel_) != SSH_OK) { + cleanup(); + if (on_status_change) on_status_change(false, "Session open failed"); + return false; + } + + guint cols = vte_terminal_get_column_count(terminal_); + guint rows = vte_terminal_get_row_count(terminal_); + if (ssh_channel_request_pty_size(channel_, "xterm-256color", cols, rows) != SSH_OK) { + cleanup(); + if (on_status_change) on_status_change(false, "PTY request failed"); + return false; + } + + if (ssh_channel_request_shell(channel_) != SSH_OK) { + cleanup(); + if (on_status_change) on_status_change(false, "Shell request failed"); + return false; + } + + pty_master_ = openpty(&pty_slave_, nullptr, nullptr, nullptr, nullptr); + if (pty_master_ < 0) { + cleanup(); + if (on_status_change) on_status_change(false, "PTY creation failed"); + return false; + } + + VtePty *pty = vte_terminal_get_pty(terminal_); + if (pty) { + int vte_fd = vte_pty_get_fd(pty); + if (vte_fd >= 0) close(vte_fd); + } + vte_terminal_set_pty(terminal_, nullptr); + + VtePty *new_pty = vte_pty_new_foreign_sync(pty_master_, nullptr, nullptr); + if (new_pty) { + vte_terminal_set_pty(terminal_, new_pty); + } + + connected_ = true; + running_ = true; + thread_ = g_thread_new("ssh_io", io_thread_func, this); + + if (on_status_change) on_status_change(true, "Connected to " + user + "@" + host); + return true; +} + +void SshTerminal::disconnect() { + running_ = false; + + if (pty_master_ >= 0) { close(pty_master_); pty_master_ = -1; } + if (pty_slave_ >= 0) { close(pty_slave_); pty_slave_ = -1; } + + if (thread_) { + g_thread_join(thread_); + thread_ = nullptr; + } + + cleanup(); +} diff --git a/src/ssh_terminal.h b/src/ssh_terminal.h new file mode 100644 index 0000000..af71819 --- /dev/null +++ b/src/ssh_terminal.h @@ -0,0 +1,39 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +class SshTerminal { +public: + SshTerminal(); + ~SshTerminal(); + + bool connect(const std::string& host, int port, const std::string& user, + const std::string& keyfile, const std::string& password); + void disconnect(); + bool is_connected() const { return connected_; } + GtkWidget* widget() const { return GTK_WIDGET(vte_box_); } + const std::string& server_name() const { return server_name_; } + void set_server_name(const std::string& name) { server_name_ = name; } + + std::function on_status_change; + +private: + void cleanup(); + static gpointer io_thread_func(gpointer data); + static void on_size_changed(VteTerminal* term, guint cols, guint rows, gpointer data); + + GtkWidget* vte_box_; + VteTerminal* terminal_; + ssh_session session_; + ssh_channel channel_; + int pty_master_; + int pty_slave_; + std::atomic running_; + std::atomic connected_; + GThread* thread_; + std::string server_name_; +};