commit 77f9568cb1e064cb8c63e7e6e31df0494b9a43da Author: x3 Date: Mon Sep 7 14:50:20 2026 +0000 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) 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 0000000..dbf1638 Binary files /dev/null and b/icons/x3-ssh.png differ diff --git a/src/Makefile.am b/src/Makefile.am new file mode 100644 index 0000000..9e03d25 --- /dev/null +++ b/src/Makefile.am @@ -0,0 +1,24 @@ +bin_PROGRAMS = x3-ssh + +x3_ssh_SOURCES = \ + main.cpp \ + ssh_terminal.cpp \ + server_store.cpp \ + ssh_client.cpp \ + key_store.cpp + +x3_ssh_CXXFLAGS = \ + -std=c++17 \ + $(GTK_CFLAGS) \ + $(VTE_CFLAGS) \ + $(LIBSSH_CFLAGS) \ + -DLOCALEDIR=\"$(localedir)\" \ + -DICONDIR=\"$(datadir)/icons\" + +x3_ssh_LDADD = \ + $(GTK_LIBS) \ + $(VTE_LIBS) \ + $(LIBSSH_LIBS) + +iconsdir = $(datadir)/icons +dist_icons_DATA = ../icons/x3-ssh.png diff --git a/src/key_store.cpp b/src/key_store.cpp new file mode 100644 index 0000000..9635feb --- /dev/null +++ b/src/key_store.cpp @@ -0,0 +1,162 @@ +#include "key_store.h" +#include +#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_; +};