summaryrefslogtreecommitdiff
path: root/plugins/ssh
diff options
context:
space:
mode:
Diffstat (limited to 'plugins/ssh')
-rw-r--r--plugins/ssh/CMakeLists.txt35
-rw-r--r--plugins/ssh/ssh.c728
-rw-r--r--plugins/ssh/ssh_config.c132
-rw-r--r--plugins/ssh/ssh_keys.c146
4 files changed, 1041 insertions, 0 deletions
diff --git a/plugins/ssh/CMakeLists.txt b/plugins/ssh/CMakeLists.txt
new file mode 100644
index 0000000..9c656b8
--- /dev/null
+++ b/plugins/ssh/CMakeLists.txt
@@ -0,0 +1,35 @@
+add_library(hybbx_plugin_ssh STATIC
+ ssh.c
+ ssh_config.c
+ ssh_keys.c
+)
+
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(LIBSSH REQUIRED IMPORTED_TARGET libssh)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_ssh
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_ssh
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_ssh
+ PRIVATE
+ hybbx_core
+ Threads::Threads
+ PkgConfig::LIBSSH
+)
+
+hybbx_link_plugin_instances(hybbx_plugin_ssh HYBBX_HAVE_PLUGIN_SSH)
+hybbx_apply_hardening(hybbx_plugin_ssh)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_ssh
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/ssh/ssh.c b/plugins/ssh/ssh.c
new file mode 100644
index 0000000..442653d
--- /dev/null
+++ b/plugins/ssh/ssh.c
@@ -0,0 +1,728 @@
+/*
+ * ssh — libssh transport plugin (port 3232). INI: [transport.ssh].
+ *
+ * Links dynamically against libssh (LGPL-2.1+). See share/THIRD_PARTY_NOTICES.txt.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/ssh.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <libssh/callbacks.h>
+#include <libssh/libssh.h>
+#include <libssh/server.h>
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define SSH_CLIENT_POLL_MS 30000
+#define SSH_READ_BUF 512
+
+typedef struct ssh_client {
+ int fd;
+ ssh_session session;
+ ssh_channel channel;
+ ssh_event event;
+ hybbx_session_t *hbx_session;
+ int ssh_authenticated;
+ int auth_attempts;
+ hybbx_result_t last_rc;
+ volatile int shell_ready;
+ struct ssh_server_callbacks_struct server_cb;
+ struct ssh_channel_callbacks_struct channel_cb;
+} ssh_client_t;
+
+typedef struct ssh_client_ctx {
+ ssh_client_t *client;
+} ssh_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_ssh;
+
+static hybbx_service_t *g_service;
+static hybbx_ssh_config_t g_config;
+static char g_hostkey_path[HYBBX_PATH_MAX];
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_ssh_running = 0;
+
+static hybbx_result_t ssh_plugin_stop(void);
+
+static int set_socket_options(int fd, int family)
+{
+ int on = 1;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ hybbx_socket_nosigpipe(fd);
+
+#ifdef IPV6_V6ONLY
+ if (family == AF_INET6) {
+ if (setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+ }
+#else
+ (void)family;
+#endif
+
+ return 0;
+}
+
+static int create_listen_socket(int family, const char *bind_addr, unsigned port)
+{
+ int fd;
+ int rc;
+
+ fd = socket(family, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (set_socket_options(fd, family) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (family == AF_INET6) {
+ struct sockaddr_in6 addr6;
+
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET6, bind_addr, &addr6.sin6_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ struct sockaddr_in addr4;
+
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, bind_addr, &addr4.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+/*
+ * SSH wire authentication only — accepts any username/password so the client
+ * can open a shell channel. HyBBX login (guest auto-login or /login prompt)
+ * follows [auth] in hybbx.ini, same as telnet.
+ */
+static int ssh_auth_password(ssh_session session, const char *user,
+ const char *password, void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+ (void)user;
+ (void)password;
+
+ if (client == NULL) {
+ return SSH_AUTH_DENIED;
+ }
+
+ client->auth_attempts++;
+ client->ssh_authenticated = 1;
+ return SSH_AUTH_SUCCESS;
+}
+
+static hybbx_result_t ssh_plugin_stop(void);
+
+static int ssh_channel_pty_request(ssh_session session, ssh_channel channel,
+ const char *term, int cols, int rows,
+ int py, int px, void *userdata)
+{
+ (void)session;
+ (void)channel;
+ (void)term;
+ (void)cols;
+ (void)rows;
+ (void)py;
+ (void)px;
+ (void)userdata;
+ return SSH_OK;
+}
+
+static int ssh_channel_shell_request(ssh_session session, ssh_channel channel,
+ void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+ (void)channel;
+
+ if (client == NULL) {
+ return SSH_ERROR;
+ }
+
+ client->shell_ready = 1;
+ return SSH_OK;
+}
+
+static ssh_channel ssh_channel_open(ssh_session session, void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+
+ if (client == NULL) {
+ return NULL;
+ }
+
+ client->channel = ssh_channel_new(session);
+ if (client->channel == NULL) {
+ return NULL;
+ }
+
+ memset(&client->channel_cb, 0, sizeof(client->channel_cb));
+ client->channel_cb.userdata = client;
+ client->channel_cb.channel_pty_request_function = ssh_channel_pty_request;
+ client->channel_cb.channel_shell_request_function = ssh_channel_shell_request;
+ ssh_callbacks_init(&client->channel_cb);
+ ssh_set_channel_callbacks(client->channel, &client->channel_cb);
+
+ return client->channel;
+}
+
+static hybbx_result_t ssh_plugin_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ ssh_client_t *client;
+ size_t i;
+ int rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (ssh_client_t *)session->transport_data;
+ if (client == NULL || client->channel == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char out[2];
+ size_t out_len = 1;
+
+ out[0] = data[i];
+ if (data[i] == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ out[0] = '\r';
+ out[1] = '\n';
+ out_len = 2;
+ }
+
+ rc = ssh_channel_write(client->channel, out, out_len);
+ if (rc < 0) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void ssh_send_busy(ssh_channel channel)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (channel == NULL) {
+ return;
+ }
+
+ (void)ssh_channel_write(channel, msg, sizeof(msg) - 1u);
+}
+
+static int ssh_wait_auth_and_shell(ssh_client_t *client)
+{
+ int ticks = 0;
+
+ while (g_ssh_running) {
+ if (client->ssh_authenticated && client->channel != NULL &&
+ client->shell_ready) {
+ return 0;
+ }
+
+ if (client->auth_attempts >= 5 || ticks >= 300) {
+ return -1;
+ }
+
+ if (ssh_event_dopoll(client->event, 100) == SSH_ERROR) {
+ return -1;
+ }
+ ticks++;
+ }
+
+ return -1;
+}
+
+static void ssh_on_user_data(const uint8_t *data, size_t len, void *ctx)
+{
+ ssh_client_ctx_t *cctx = (ssh_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (cctx == NULL || cctx->client == NULL ||
+ cctx->client->hbx_session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(cctx->client->hbx_session, data, len);
+ if (rc != HYBBX_OK) {
+ cctx->client->last_rc = rc;
+ }
+}
+
+static int ssh_plugin_service_request(ssh_session session, const char *service,
+ void *userdata)
+{
+ (void)session;
+ (void)userdata;
+
+ if (service == NULL) {
+ return SSH_ERROR;
+ }
+
+ if (strcmp(service, "ssh-userauth") == 0 ||
+ strcmp(service, "ssh-connection") == 0) {
+ return SSH_OK;
+ }
+
+ return SSH_ERROR;
+}
+
+static void *ssh_client_thread(void *arg)
+{
+ ssh_client_t *client = (ssh_client_t *)arg;
+ ssh_bind sshbind = NULL;
+ ssh_client_ctx_t cctx;
+ uint8_t buf[SSH_READ_BUF];
+ hybbx_result_t rc;
+ char remote[64];
+
+ if (client == NULL || g_service == NULL) {
+ free(client);
+ return NULL;
+ }
+
+ remote[0] = '\0';
+ (void)hybbx_socket_peer_name(client->fd, remote, sizeof(remote));
+
+ sshbind = ssh_bind_new();
+ if (sshbind == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_HOSTKEY,
+ g_hostkey_path) != SSH_OK) {
+ hybbx_log_warn("[ssh] host key load failed: %s", g_hostkey_path);
+ goto cleanup;
+ }
+
+ client->session = ssh_new();
+ if (client->session == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_bind_accept_fd(sshbind, client->session, client->fd) != SSH_OK) {
+ goto cleanup;
+ }
+
+ memset(&client->server_cb, 0, sizeof(client->server_cb));
+ client->server_cb.userdata = client;
+ client->server_cb.auth_password_function = ssh_auth_password;
+ client->server_cb.service_request_function = ssh_plugin_service_request;
+ client->server_cb.channel_open_request_session_function = ssh_channel_open;
+ ssh_callbacks_init(&client->server_cb);
+ ssh_set_server_callbacks(client->session, &client->server_cb);
+ ssh_set_auth_methods(client->session, SSH_AUTH_METHOD_PASSWORD);
+
+ if (ssh_server_init_kex(client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ if (ssh_handle_key_exchange(client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ client->event = ssh_event_new();
+ if (client->event == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_event_add_session(client->event, client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ if (ssh_wait_auth_and_shell(client) != 0) {
+ goto cleanup;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_ssh, client,
+ &client->hbx_session);
+ if (rc != HYBBX_OK || client->hbx_session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ ssh_send_busy(client->channel);
+ }
+ goto cleanup;
+ }
+
+ if (remote[0] != '\0') {
+ (void)hybbx_session_set_remote(client->hbx_session, remote);
+ }
+
+ /*
+ * Telnet clients echo locally when the server sends WONT ECHO; SSH PTY
+ * clients do not. Mirror telnet UX by enabling HyBBX input echo unless
+ * [traffic] input_echo=yes already turned it on.
+ */
+ {
+ const hybbx_traffic_config_t *traffic = hybbx_traffic_config_get();
+
+ if (traffic == NULL || !traffic->input_echo) {
+ (void)hybbx_session_set_input_echo(client->hbx_session, 1);
+ }
+ }
+
+ cctx.client = client;
+ client->last_rc = HYBBX_OK;
+
+ while (g_ssh_running && ssh_channel_is_open(client->channel)) {
+ int n;
+ int ev;
+
+ ev = ssh_event_dopoll(client->event, SSH_CLIENT_POLL_MS);
+ if (ev == SSH_ERROR) {
+ break;
+ }
+
+ if (ev == 0) {
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ }
+
+ while ((n = ssh_channel_read(client->channel, buf, sizeof(buf), 0)) > 0) {
+ ssh_on_user_data((const uint8_t *)buf, (size_t)n, &cctx);
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ }
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ if (n < 0 && !ssh_channel_is_open(client->channel)) {
+ break;
+ }
+
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ }
+
+cleanup:
+ if (client->hbx_session != NULL) {
+ hybbx_session_close(client->hbx_session);
+ client->hbx_session = NULL;
+ }
+
+ if (client->channel != NULL) {
+ if (ssh_channel_is_open(client->channel)) {
+ ssh_channel_send_eof(client->channel);
+ ssh_channel_close(client->channel);
+ }
+ ssh_channel_free(client->channel);
+ client->channel = NULL;
+ }
+
+ if (client->event != NULL) {
+ ssh_event_free(client->event);
+ client->event = NULL;
+ }
+
+ if (client->session != NULL) {
+ ssh_disconnect(client->session);
+ ssh_free(client->session);
+ client->session = NULL;
+ }
+
+ if (sshbind != NULL) {
+ ssh_bind_free(sshbind);
+ sshbind = NULL;
+ }
+
+ if (client->fd >= 0) {
+ client->fd = -1;
+ }
+
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ ssh_client_t *client;
+ pthread_t thread;
+ pthread_attr_t attr;
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(client_fd);
+ return;
+ }
+
+ client->fd = client_fd;
+
+ pthread_attr_init(&attr);
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
+
+ if (pthread_create(&thread, &attr, ssh_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *ssh_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_ssh_running) {
+ struct pollfd fds[2];
+ nfds_t nfds = 0;
+ int i;
+ int ready;
+
+ memset(fds, 0, sizeof(fds));
+
+ if (g_listen_v4 >= 0) {
+ fds[nfds].fd = g_listen_v4;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (g_listen_v6 >= 0) {
+ fds[nfds].fd = g_listen_v6;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (nfds == 0) {
+ break;
+ }
+
+ ready = poll(fds, nfds, 500);
+ if (ready < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (ready == 0) {
+ continue;
+ }
+
+ for (i = 0; i < (int)nfds; i++) {
+ if (fds[i].revents & POLLIN) {
+ int client_fd = accept(fds[i].fd, NULL, NULL);
+
+ if (client_fd >= 0) {
+ if (!hybbx_security_ban_accept_fd(client_fd)) {
+ close(client_fd);
+ continue;
+ }
+ accept_client(client_fd);
+ }
+ }
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t ssh_plugin_init(hybbx_service_t *service)
+{
+ int rc;
+
+ g_service = service;
+
+ rc = ssh_init();
+ if (rc < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void ssh_plugin_shutdown(void)
+{
+ ssh_plugin_stop();
+ ssh_finalize();
+}
+
+static hybbx_result_t ssh_plugin_start(const char *config)
+{
+ char keys_resolved[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (g_ssh_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_ssh_config_parse(config, &g_config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (hybbx_path_resolve(keys_resolved, sizeof(keys_resolved),
+ g_config.hostkey_dir) != HYBBX_OK) {
+ hybbx_strlcpy(keys_resolved, g_config.hostkey_dir,
+ sizeof(keys_resolved));
+ }
+
+ rc = hybbx_ssh_keys_ensure(keys_resolved, g_hostkey_path,
+ sizeof(g_hostkey_path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ g_listen_v4 = -1;
+ g_listen_v6 = -1;
+
+ if (g_config.ipv4) {
+ g_listen_v4 = create_listen_socket(AF_INET, g_config.bind_v4,
+ g_config.port);
+ if (g_listen_v4 < 0) {
+ hybbx_socket_log_bind_failure("ssh", g_config.bind_v4,
+ g_config.port);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (g_config.ipv6) {
+ g_listen_v6 = create_listen_socket(AF_INET6, g_config.bind_v6,
+ g_config.port);
+ if (g_listen_v6 < 0) {
+ hybbx_log_warn("[ssh] IPv6 bind [%s]:%u skipped (%s)",
+ g_config.bind_v6, g_config.port, strerror(errno));
+ }
+ }
+
+ if (g_listen_v4 < 0 && g_listen_v6 < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ g_ssh_running = 1;
+
+ if (pthread_create(&g_accept_thread, NULL, ssh_accept_thread, NULL) != 0) {
+ g_ssh_running = 0;
+ if (g_listen_v4 >= 0) {
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+ if (g_listen_v6 >= 0) {
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ char msg[256];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, "[ssh] listening");
+ if (g_listen_v4 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv4 %s:%u",
+ g_config.bind_v4, g_config.port);
+ }
+ if (g_listen_v6 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv6 [%s]:%u",
+ g_config.bind_v6, g_config.port);
+ }
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " (libssh)");
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ssh_plugin_stop(void)
+{
+ if (!g_ssh_running) {
+ return HYBBX_OK;
+ }
+
+ g_ssh_running = 0;
+
+ if (g_listen_v4 >= 0) {
+ shutdown(g_listen_v4, SHUT_RDWR);
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+
+ if (g_listen_v6 >= 0) {
+ shutdown(g_listen_v6, SHUT_RDWR);
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+
+ pthread_join(g_accept_thread, NULL);
+ hybbx_log_info("[ssh] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_ssh = {
+ .name = "ssh",
+ .kind = HYBBX_TRANSPORT_SSH,
+ .version = 1,
+ .init = ssh_plugin_init,
+ .shutdown = ssh_plugin_shutdown,
+ .start = ssh_plugin_start,
+ .stop = ssh_plugin_stop,
+ .write = ssh_plugin_write,
+};
diff --git a/plugins/ssh/ssh_config.c b/plugins/ssh/ssh_config.c
new file mode 100644
index 0000000..77fb7db
--- /dev/null
+++ b/plugins/ssh/ssh_config.c
@@ -0,0 +1,132 @@
+#include "hybbx/ssh.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned int parse_port(const char *value)
+{
+ char *end;
+ unsigned long port;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_SSH_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_SSH_DEFAULT_PORT;
+ }
+
+ return (unsigned int)port;
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (scratch != NULL && scratch_len > 0) {
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ return value;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_ssh_config_defaults(hybbx_ssh_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_SSH_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_SSH_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ hybbx_strlcpy(config->hostkey_dir, HYBBX_SSH_DEFAULT_HOSTKEY_DIR,
+ sizeof(config->hostkey_dir));
+ config->port = HYBBX_SSH_DEFAULT_PORT;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_ssh_config_parse(const char *config,
+ hybbx_ssh_config_t *out)
+{
+ char scratch[HYBBX_SSH_BIND_V6_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ssh_config_defaults(out);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ value = find_kv(config, "port", scratch, sizeof(scratch));
+ out->port = parse_port(value);
+
+ value = find_kv(config, "bind", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ if (strchr(value, ':') != NULL) {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ } else {
+ hybbx_strlcpy(out->bind_v4, value, sizeof(out->bind_v4));
+ }
+ }
+
+ value = find_kv(config, "bind6", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ }
+
+ value = find_kv(config, "hostkey_dir", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->hostkey_dir, value, sizeof(out->hostkey_dir));
+ }
+
+ value = find_kv(config, "ipv4", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv4 = hybbx_parse_bool(value, 1);
+ }
+
+ value = find_kv(config, "ipv6", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv6 = hybbx_parse_bool(value, 1);
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/ssh/ssh_keys.c b/plugins/ssh/ssh_keys.c
new file mode 100644
index 0000000..656b9d4
--- /dev/null
+++ b/plugins/ssh/ssh_keys.c
@@ -0,0 +1,146 @@
+#include "hybbx/ssh.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <libssh/libssh.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+static hybbx_result_t mkdir_keys_dir(const char *keys_dir)
+{
+ char parent[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (keys_dir == NULL || keys_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (stat(keys_dir, &st) == 0) {
+ return S_ISDIR(st.st_mode) ? HYBBX_OK : HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_dirname(keys_dir, parent, sizeof(parent)) == HYBBX_OK &&
+ parent[0] != '\0' && strcmp(parent, keys_dir) != 0 &&
+ stat(parent, &st) != 0) {
+ if (mkdir_keys_dir(parent) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (mkdir(keys_dir, 0700) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t generate_ed25519_keypair(const char *priv_path,
+ const char *pub_path)
+{
+ ssh_key key = NULL;
+ int rc;
+
+ rc = ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key);
+ if (rc != SSH_OK || key == NULL) {
+ hybbx_log_warn("[ssh] Ed25519 generate failed (rc=%d)", rc);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = ssh_pki_export_privkey_file(key, NULL, NULL, NULL, priv_path);
+ if (rc != SSH_OK) {
+ hybbx_log_warn("[ssh] export private key failed: %s",
+ ssh_get_error(key));
+ ssh_key_free(key);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = ssh_pki_export_pubkey_file(key, pub_path);
+ ssh_key_free(key);
+ if (rc != SSH_OK) {
+ hybbx_log_warn("[ssh] export public key failed");
+ return HYBBX_ERR_IO;
+ }
+
+ (void)chmod(priv_path, 0600);
+ (void)chmod(pub_path, 0644);
+ return HYBBX_OK;
+}
+
+static int hostkey_needs_rotation(const char *priv_path)
+{
+ struct stat st;
+ time_t now;
+ time_t age;
+
+ if (stat(priv_path, &st) != 0) {
+ return 0;
+ }
+
+ now = time(NULL);
+ if (now <= st.st_mtime) {
+ return 0;
+ }
+
+ age = now - st.st_mtime;
+ return age > (time_t)HYBBX_SSH_HOSTKEY_VALID_DAYS * 86400L;
+}
+
+hybbx_result_t hybbx_ssh_keys_ensure(const char *keys_dir,
+ char *hostkey_path,
+ size_t hostkey_path_len)
+{
+ char resolved_dir[HYBBX_PATH_MAX];
+ char priv_path[HYBBX_PATH_MAX];
+ char pub_path[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (keys_dir == NULL || hostkey_path == NULL || hostkey_path_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (ssh_init() < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_resolve(resolved_dir, sizeof(resolved_dir),
+ keys_dir) != HYBBX_OK) {
+ hybbx_strlcpy(resolved_dir, keys_dir, sizeof(resolved_dir));
+ }
+
+ if (mkdir_keys_dir(resolved_dir) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(priv_path, sizeof(priv_path), resolved_dir,
+ HYBBX_SSH_HOSTKEY_ED25519) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(pub_path, sizeof(pub_path), resolved_dir,
+ HYBBX_SSH_HOSTKEY_ED25519 ".pub") != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (stat(priv_path, &st) == 0 && hostkey_needs_rotation(priv_path)) {
+ hybbx_log_warn("[ssh] host key older than %u days — rotating %s",
+ HYBBX_SSH_HOSTKEY_VALID_DAYS, priv_path);
+ (void)unlink(priv_path);
+ (void)unlink(pub_path);
+ }
+
+ if (stat(priv_path, &st) != 0) {
+ if (generate_ed25519_keypair(priv_path, pub_path) != HYBBX_OK) {
+ hybbx_log_warn("[ssh] failed to generate host key in %s",
+ resolved_dir);
+ return HYBBX_ERR_IO;
+ }
+ hybbx_log_info("[ssh] generated host key %s (valid %u days)", priv_path,
+ HYBBX_SSH_HOSTKEY_VALID_DAYS);
+ }
+
+ hybbx_strlcpy(hostkey_path, priv_path, hostkey_path_len);
+ return HYBBX_OK;
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com