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)
This commit is contained in:
+26
@@ -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
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
SUBDIRS = src
|
||||||
|
EXTRA_DIST = README.md
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
# x3-ssh
|
||||||
|
|
||||||
|
A GTK3-based SSH client with a tabbed terminal interface, server management, and SSH key storage.
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
|
||||||
@@ -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
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
@@ -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
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
#include "key_store.h"
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstdio>
|
||||||
|
#include <cstring>
|
||||||
|
#include <libssh/libssh.h>
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
|
||||||
|
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<SshKey>& 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<SshKey> keys_;
|
||||||
|
std::string config_path_;
|
||||||
|
std::string keys_dir_;
|
||||||
|
};
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
#include <gtk/gtk.h>
|
||||||
|
#include "ssh_client.h"
|
||||||
|
|
||||||
|
int main(int argc, char* argv[]) {
|
||||||
|
gtk_init(&argc, &argv);
|
||||||
|
|
||||||
|
SshClientWindow window;
|
||||||
|
window.run();
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
#include "server_store.h"
|
||||||
|
#include <fstream>
|
||||||
|
#include <sstream>
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <algorithm>
|
||||||
|
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
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<ServerConfig>& 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<ServerConfig> servers_;
|
||||||
|
std::string config_path_;
|
||||||
|
};
|
||||||
@@ -0,0 +1,681 @@
|
|||||||
|
#include "ssh_client.h"
|
||||||
|
#include <cstdlib>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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<ServerConfig>& 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<SshKey>& 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<SshClientWindow*>(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<SshClientWindow*>(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), "<b>Connect to %s@%s</b>", 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<KeyDlgCtx*>(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<KeyDlgCtx*>(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<KeyDlgCtx*>(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<SshClientWindow*>(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<SrvCtx*>(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<SrvCtx*>(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<SrvCtx*>(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<SshClientWindow*>(d)->add_new_tab();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshClientWindow::on_close_tab(GtkWidget*, gpointer d) {
|
||||||
|
static_cast<SshClientWindow*>(d)->close_current_tab();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshClientWindow::on_connect(GtkWidget*, gpointer d) {
|
||||||
|
auto* self = static_cast<SshClientWindow*>(d);
|
||||||
|
self->show_password_dialog("localhost", "root", "", "Local");
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshClientWindow::on_disconnect(GtkWidget*, gpointer d) {
|
||||||
|
static_cast<SshClientWindow*>(d)->disconnect();
|
||||||
|
}
|
||||||
|
|
||||||
|
void SshClientWindow::on_manage_keys(GtkWidget*, gpointer d) {
|
||||||
|
static_cast<SshClientWindow*>(d)->show_key_dialog();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ─── main ────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
void SshClientWindow::run() {
|
||||||
|
gtk_widget_show_all(window_);
|
||||||
|
gtk_main();
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
#include <string>
|
||||||
|
#include <vector>
|
||||||
|
#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<SshTerminal*> terminals_;
|
||||||
|
};
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
#include "ssh_terminal.h"
|
||||||
|
#include <unistd.h>
|
||||||
|
#include <pty.h>
|
||||||
|
#include <sys/select.h>
|
||||||
|
#include <signal.h>
|
||||||
|
#include <cstring>
|
||||||
|
|
||||||
|
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<SshTerminal*>(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<SshTerminal*>(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<SshTerminal*>(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<SshTerminal*>(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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
#pragma once
|
||||||
|
#include <gtk/gtk.h>
|
||||||
|
#include <vte/vte.h>
|
||||||
|
#include <libssh/libssh.h>
|
||||||
|
#include <atomic>
|
||||||
|
#include <string>
|
||||||
|
#include <functional>
|
||||||
|
|
||||||
|
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<void(bool, const std::string&)> 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<bool> running_;
|
||||||
|
std::atomic<bool> connected_;
|
||||||
|
GThread* thread_;
|
||||||
|
std::string server_name_;
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user