summaryrefslogtreecommitdiff
path: root/src/clients
diff options
context:
space:
mode:
Diffstat (limited to 'src/clients')
-rw-r--r--src/clients/CMakeLists.txt96
-rw-r--r--src/clients/circuit_client.c294
-rw-r--r--src/clients/client_display.c87
-rw-r--r--src/clients/client_display.h30
-rw-r--r--src/clients/client_line.c495
-rw-r--r--src/clients/client_line.h33
-rw-r--r--src/clients/hybbx_ssh.c495
-rw-r--r--src/clients/hybbx_telnet.c544
-rw-r--r--src/clients/hybbx_terminal.c433
-rw-r--r--src/clients/link_auth.c182
10 files changed, 2689 insertions, 0 deletions
diff --git a/src/clients/CMakeLists.txt b/src/clients/CMakeLists.txt
new file mode 100644
index 0000000..2181fbc
--- /dev/null
+++ b/src/clients/CMakeLists.txt
@@ -0,0 +1,96 @@
+# Standalone CLI clients (hybbx-telnet, hybbx-terminal, hybbx-ssh).
+# No centralized daemon; flags/env only — see src/clients/*.c for options.
+
+if(HYBBX_PLATFORM_AMIGAOS)
+ set(_hybbx_client_sources
+ ${CMAKE_SOURCE_DIR}/src/core/util.c
+ ${CMAKE_SOURCE_DIR}/src/core/traffic.c
+ ${CMAKE_SOURCE_DIR}/src/core/terminal.c
+ client_line.c
+ client_display.c
+ )
+else()
+ set(_hybbx_client_sources
+ ${CMAKE_SOURCE_DIR}/src/core/util.c
+ ${CMAKE_SOURCE_DIR}/src/core/circuit.c
+ ${CMAKE_SOURCE_DIR}/src/core/traffic.c
+ ${CMAKE_SOURCE_DIR}/src/core/terminal.c
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio/ax25.c
+ circuit_client.c
+ link_auth.c
+ client_line.c
+ client_display.c
+ )
+endif()
+
+add_library(hybbx_client STATIC ${_hybbx_client_sources})
+
+target_include_directories(hybbx_client
+ PUBLIC
+ $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+)
+
+target_compile_definitions(hybbx_client PRIVATE HYBBX_CLIENT_BUILD=1)
+hybbx_apply_hardening(hybbx_client)
+
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_compile_options(hybbx_client PRIVATE -mcrt=clib2)
+endif()
+
+add_library(HyBBX::client ALIAS hybbx_client)
+
+if(HYBBX_BUILD_CLIENT_TELNET)
+ add_executable(hybbx-telnet
+ hybbx_telnet.c
+ ${CMAKE_SOURCE_DIR}/plugins/telnet/telnet_proto.c
+ )
+ target_include_directories(hybbx-telnet PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/telnet
+ )
+ target_link_libraries(hybbx-telnet PRIVATE hybbx_client)
+ hybbx_apply_hardening_executable(hybbx-telnet)
+ if(HYBBX_PLATFORM_AMIGAOS)
+ target_compile_options(hybbx-telnet PRIVATE -mcrt=clib2)
+ target_link_options(hybbx-telnet PRIVATE -mcrt=clib2)
+ target_link_libraries(hybbx-telnet PRIVATE net unix)
+ endif()
+ install(TARGETS hybbx-telnet RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_BUILD_CLIENT_TERMINAL)
+ add_executable(hybbx-terminal hybbx_terminal.c)
+ target_include_directories(hybbx-terminal PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+ )
+ target_link_libraries(hybbx-terminal PRIVATE hybbx_client)
+ hybbx_apply_hardening_executable(hybbx-terminal)
+ install(TARGETS hybbx-terminal RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_BUILD_CLIENT_SSH AND HYBBX_HAVE_LIBSSH)
+ find_package(PkgConfig REQUIRED)
+ pkg_check_modules(LIBSSH REQUIRED IMPORTED_TARGET libssh)
+
+ add_executable(hybbx-ssh hybbx_ssh.c)
+ target_include_directories(hybbx-ssh PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+ target_link_libraries(hybbx-ssh PRIVATE hybbx_client PkgConfig::LIBSSH)
+ hybbx_apply_hardening_executable(hybbx-ssh)
+ install(TARGETS hybbx-ssh RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_client
+ EXPORT HyBBXTargets
+ ARCHIVE DESTINATION ${HYBBX_INSTALL_HOME}/lib
+ )
+endif()
diff --git a/src/clients/circuit_client.c b/src/clients/circuit_client.c
new file mode 100644
index 0000000..778ca87
--- /dev/null
+++ b/src/clients/circuit_client.c
@@ -0,0 +1,294 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit.h"
+#include "hybbx/link.h"
+#include "hybbx/socket.h"
+#include "hybbx/util.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_CIRCUIT_LINK_POLL_MS 50
+#define HYBBX_CIRCUIT_AUTH_TIMEOUT_MS 15000
+
+static int set_socket_options(int fd)
+{
+ int on = 1;
+ int flags;
+
+ 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);
+
+ flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+hybbx_result_t hybbx_circuit_link_connect(const char *host, unsigned port,
+ int *out_fd)
+{
+ struct sockaddr_in6 addr6;
+ struct sockaddr_in addr4;
+ int fd;
+ int rc;
+
+ if (host == NULL || out_fd == NULL || port == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (strchr(host, ':') != NULL) {
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fd = socket(AF_INET6, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return HYBBX_ERR_IO;
+ }
+
+ (void)set_socket_options(fd);
+ *out_fd = fd;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_circuit_link_write(int fd, const uint8_t *frame,
+ size_t len)
+{
+ ssize_t sent;
+ size_t off = 0;
+
+ if (fd < 0 || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (off < len) {
+ sent = send(fd, frame + off, len - off, MSG_NOSIGNAL);
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)sent;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_circuit_link_read(int fd, uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ ssize_t n;
+
+ if (fd < 0 || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = recv(fd, buf, buf_len, 0);
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ *read_len = 0;
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+typedef struct link_ack_wait {
+ int done;
+ int ok;
+} link_ack_wait_t;
+
+static void on_link_auth_ack(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ link_ack_wait_t *wait = (link_ack_wait_t *)userdata;
+ size_t i;
+
+ (void)flags;
+
+ if (wait == NULL || wait->done || proto != HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK) {
+ return;
+ }
+
+ if (len == 0 || payload == NULL) {
+ return;
+ }
+
+ for (i = 0; i + 6 <= len; i++) {
+ if (memcmp(payload + i, "ok=yes", 6) == 0) {
+ wait->ok = 1;
+ break;
+ }
+ }
+ wait->done = 1;
+}
+
+hybbx_result_t hybbx_circuit_link_authenticate(int fd,
+ const char *password,
+ const char *role,
+ const char *id)
+{
+ return hybbx_circuit_link_authenticate_ex(fd, password, role, id, NULL);
+}
+
+hybbx_result_t hybbx_circuit_link_authenticate_ex(int fd,
+ const char *password,
+ const char *role,
+ const char *id,
+ const hybbx_circuit_link_qos_t *qos)
+{
+ hybbx_link_auth_t auth;
+ char payload[HYBBX_LINK_AUTH_PAYLOAD_MAX];
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t payload_len;
+ size_t frame_len;
+ hybbx_circuit_decoder_t dec;
+ link_ack_wait_t wait;
+ uint8_t buf[256];
+ unsigned elapsed = 0;
+
+ if (fd < 0 || password == NULL || id == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_link_auth_clear(&auth);
+ hybbx_strlcpy(auth.password, password, sizeof(auth.password));
+ hybbx_strlcpy(auth.id, id, sizeof(auth.id));
+ if (role != NULL && role[0] != '\0') {
+ hybbx_strlcpy(auth.role, role, sizeof(auth.role));
+ } else {
+ hybbx_strlcpy(auth.role, "link", sizeof(auth.role));
+ }
+
+ if (qos != NULL) {
+ if (qos->baud > 0) {
+ auth.baud = qos->baud;
+ }
+ if (qos->duplex > 0) {
+ auth.duplex = qos->duplex;
+ }
+ if (qos->bandwidth != NULL && qos->bandwidth[0] != '\0') {
+ hybbx_strlcpy(auth.bandwidth, qos->bandwidth, sizeof(auth.bandwidth));
+ }
+ if (qos->frequency_mhz != NULL && qos->frequency_mhz[0] != '\0') {
+ hybbx_strlcpy(auth.frequency_mhz, qos->frequency_mhz,
+ sizeof(auth.frequency_mhz));
+ }
+ }
+
+ payload_len = hybbx_link_auth_format(&auth, payload, sizeof(payload));
+ if (payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode_link_msg(HYBBX_CIRCUIT_PROTO_LINK_AUTH,
+ payload, payload_len,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_circuit_link_write(fd, frame, frame_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_circuit_decoder_init(&dec);
+ memset(&wait, 0, sizeof(wait));
+
+ while (!wait.done && elapsed < HYBBX_CIRCUIT_AUTH_TIMEOUT_MS) {
+ struct pollfd pfd;
+ int pr;
+ size_t read_len;
+
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ pr = poll(&pfd, 1, HYBBX_CIRCUIT_LINK_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (pr == 0) {
+ elapsed += HYBBX_CIRCUIT_LINK_POLL_MS;
+ continue;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_circuit_link_read(fd, buf, sizeof(buf), &read_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (read_len == 0) {
+ elapsed += HYBBX_CIRCUIT_LINK_POLL_MS;
+ continue;
+ }
+
+ hybbx_circuit_decoder_feed(&dec, buf, read_len, on_link_auth_ack, &wait);
+ }
+
+ return wait.ok ? HYBBX_OK : HYBBX_ERR_DENIED;
+}
diff --git a/src/clients/client_display.c b/src/clients/client_display.c
new file mode 100644
index 0000000..0f6a183
--- /dev/null
+++ b/src/clients/client_display.c
@@ -0,0 +1,87 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_display.h"
+
+#include "hybbx/terminal.h"
+#include "hybbx/traffic.h"
+
+#include <stdio.h>
+#include <unistd.h>
+
+void hybbx_client_display_init(hybbx_client_display_t *disp,
+ const hybbx_traffic_config_t *traffic)
+{
+ if (disp == NULL) {
+ return;
+ }
+
+ disp->traffic = traffic != NULL ? traffic : hybbx_traffic_config_get();
+ disp->col = 0;
+}
+
+static void pace_byte(unsigned baud)
+{
+ unsigned delay_us;
+
+ if (baud == 0) {
+ return;
+ }
+
+ delay_us = hybbx_traffic_byte_delay_us(baud);
+ if (delay_us > 0) {
+ usleep(delay_us);
+ }
+}
+
+void hybbx_client_display_byte(hybbx_client_display_t *disp, uint8_t byte)
+{
+ const hybbx_traffic_config_t *cfg;
+
+ if (disp == NULL) {
+ return;
+ }
+
+ cfg = disp->traffic;
+ if (cfg == NULL) {
+ fputc((int)byte, stdout);
+ fflush(stdout);
+ return;
+ }
+
+ if (!cfg->ansi && byte == 0x1b) {
+ return;
+ }
+
+ if (cfg->line_width > 0 && (byte == '\n' || byte == '\r')) {
+ disp->col = 0;
+ } else if (cfg->line_width > 0 && byte >= 0x20 && byte != 0x7f) {
+ disp->col++;
+ if (disp->col > cfg->line_width) {
+ fputc('\n', stdout);
+ disp->col = 1;
+ }
+ }
+
+ fputc((int)byte, stdout);
+ fflush(stdout);
+
+ if (cfg->pace_output) {
+ pace_byte(cfg->baud);
+ }
+}
+
+void hybbx_client_display_write(hybbx_client_display_t *disp,
+ const uint8_t *data, size_t len)
+{
+ size_t i;
+
+ if (disp == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ hybbx_client_display_byte(disp, data[i]);
+ }
+}
diff --git a/src/clients/client_display.h b/src/clients/client_display.h
new file mode 100644
index 0000000..a457f4c
--- /dev/null
+++ b/src/clients/client_display.h
@@ -0,0 +1,30 @@
+#ifndef HYBBX_CLIENT_DISPLAY_H
+#define HYBBX_CLIENT_DISPLAY_H
+
+#include "hybbx/traffic.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_client_display {
+ const hybbx_traffic_config_t *traffic;
+ unsigned col;
+} hybbx_client_display_t;
+
+void hybbx_client_display_init(hybbx_client_display_t *disp,
+ const hybbx_traffic_config_t *traffic);
+
+void hybbx_client_display_byte(hybbx_client_display_t *disp, uint8_t byte);
+
+void hybbx_client_display_write(hybbx_client_display_t *disp,
+ const uint8_t *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CLIENT_DISPLAY_H */
diff --git a/src/clients/client_line.c b/src/clients/client_line.c
new file mode 100644
index 0000000..ab8c07b
--- /dev/null
+++ b/src/clients/client_line.c
@@ -0,0 +1,495 @@
+#include "client_line.h"
+
+#include "hybbx/util.h"
+
+#include <errno.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#define CLIENT_ESC_NONE 0u
+#define CLIENT_ESC_ESC 1u
+#define CLIENT_ESC_CSI 2u
+#define CLIENT_ESC_SS3 3u
+
+static int history_should_store(const char *line)
+{
+ if (line == NULL || line[0] == '\0') {
+ return 0;
+ }
+
+ return strncmp(line, "/login ", 7) != 0 &&
+ strncmp(line, "/register ", 10) != 0 &&
+ strncmp(line, "/changeme ", 10) != 0 &&
+ strncmp(line, "/userchange ", 12) != 0;
+}
+
+static hybbx_result_t write_all(int fd, const char *buf, size_t len)
+{
+ size_t off = 0;
+
+ while (off < len) {
+ ssize_t n = write(fd, buf + off, len - off);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t move_back(hybbx_client_line_editor_t *ed, size_t count)
+{
+ char buf[64];
+ size_t sent = 0;
+
+ if (ed == NULL || !ed->raw_enabled || count == 0) {
+ return HYBBX_OK;
+ }
+
+ memset(buf, '\b', sizeof(buf));
+ while (sent < count) {
+ size_t chunk = count - sent;
+
+ if (chunk > sizeof(buf)) {
+ chunk = sizeof(buf);
+ }
+ if (write_all(STDOUT_FILENO, buf, chunk) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ sent += chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t refresh_suffix(hybbx_client_line_editor_t *ed)
+{
+ size_t suffix_len;
+
+ if (ed == NULL || !ed->raw_enabled) {
+ return HYBBX_OK;
+ }
+
+ suffix_len = ed->line_len - ed->line_cursor;
+ if (suffix_len > 0 &&
+ write_all(STDOUT_FILENO, ed->line + ed->line_cursor, suffix_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (write_all(STDOUT_FILENO, "\x1b[K", 3) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return move_back(ed, suffix_len);
+}
+
+static hybbx_result_t cursor_left(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+ ed->line_cursor--;
+ return ed->raw_enabled ? write_all(STDOUT_FILENO, "\b", 1) : HYBBX_OK;
+}
+
+static hybbx_result_t cursor_right(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor >= ed->line_len) {
+ return HYBBX_OK;
+ }
+ if (ed->raw_enabled &&
+ write_all(STDOUT_FILENO, ed->line + ed->line_cursor, 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ ed->line_cursor++;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cursor_home(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ return move_back(ed, ed->line_cursor);
+}
+
+static hybbx_result_t cursor_end(hybbx_client_line_editor_t *ed)
+{
+ while (ed != NULL && ed->line_cursor < ed->line_len) {
+ if (cursor_right(ed) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+ return HYBBX_OK;
+}
+
+static hybbx_result_t delete_forward(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor >= ed->line_len) {
+ return HYBBX_OK;
+ }
+
+ memmove(ed->line + ed->line_cursor, ed->line + ed->line_cursor + 1,
+ ed->line_len - ed->line_cursor);
+ ed->line_len--;
+ ed->line[ed->line_len] = '\0';
+ return refresh_suffix(ed);
+}
+
+static hybbx_result_t backspace_char(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+
+ ed->line_cursor--;
+ memmove(ed->line + ed->line_cursor, ed->line + ed->line_cursor + 1,
+ ed->line_len - ed->line_cursor);
+ ed->line_len--;
+ ed->line[ed->line_len] = '\0';
+ if (ed->raw_enabled && write_all(STDOUT_FILENO, "\b", 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return refresh_suffix(ed);
+}
+
+static hybbx_result_t insert_char(hybbx_client_line_editor_t *ed, char ch)
+{
+ if (ed == NULL || (unsigned char)ch < 0x20u || ch == 0x7f) {
+ return HYBBX_OK;
+ }
+ if (ed->line_len + 1 >= sizeof(ed->line)) {
+ return HYBBX_OK;
+ }
+
+ if (ed->line_cursor < ed->line_len) {
+ memmove(ed->line + ed->line_cursor + 1, ed->line + ed->line_cursor,
+ ed->line_len - ed->line_cursor);
+ }
+ ed->line[ed->line_cursor] = ch;
+ ed->line_len++;
+ ed->line_cursor++;
+ ed->line[ed->line_len] = '\0';
+
+ if (!ed->raw_enabled) {
+ return HYBBX_OK;
+ }
+ if (write_all(STDOUT_FILENO, &ch, 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return refresh_suffix(ed);
+}
+
+static void history_add(hybbx_client_line_editor_t *ed, const char *line)
+{
+ if (ed == NULL || !history_should_store(line)) {
+ return;
+ }
+
+ hybbx_strlcpy(ed->history[ed->history_next], line, sizeof(ed->history[0]));
+ ed->history_next = (ed->history_next + 1u) % HYBBX_HISTORY_MAX;
+ if (ed->history_count < HYBBX_HISTORY_MAX) {
+ ed->history_count++;
+ }
+ ed->history_view = -1;
+ ed->history_saved_line[0] = '\0';
+}
+
+static hybbx_result_t line_replace(hybbx_client_line_editor_t *ed, const char *line)
+{
+ size_t len;
+
+ if (ed == NULL || line == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(line);
+ if (len >= sizeof(ed->line)) {
+ len = sizeof(ed->line) - 1;
+ }
+
+ if (ed->raw_enabled) {
+ if (move_back(ed, ed->line_cursor) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (len > 0 && write_all(STDOUT_FILENO, line, len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (write_all(STDOUT_FILENO, "\x1b[K", 3) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ memcpy(ed->line, line, len);
+ ed->line[len] = '\0';
+ ed->line_len = len;
+ ed->line_cursor = len;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t history_up(hybbx_client_line_editor_t *ed)
+{
+ unsigned slot;
+
+ if (ed == NULL || ed->history_count == 0) {
+ return HYBBX_OK;
+ }
+
+ if (ed->history_view < 0) {
+ hybbx_strlcpy(ed->history_saved_line, ed->line, sizeof(ed->history_saved_line));
+ ed->history_view = 0;
+ } else if ((unsigned)(ed->history_view + 1) < ed->history_count) {
+ ed->history_view++;
+ } else {
+ return HYBBX_OK;
+ }
+
+ slot = (ed->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)ed->history_view) % HYBBX_HISTORY_MAX;
+ return line_replace(ed, ed->history[slot]);
+}
+
+static hybbx_result_t history_down(hybbx_client_line_editor_t *ed)
+{
+ unsigned slot;
+
+ if (ed == NULL || ed->history_view < 0) {
+ return HYBBX_OK;
+ }
+ if (ed->history_view == 0) {
+ ed->history_view = -1;
+ return line_replace(ed, ed->history_saved_line);
+ }
+
+ ed->history_view--;
+ slot = (ed->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)ed->history_view) % HYBBX_HISTORY_MAX;
+ return line_replace(ed, ed->history[slot]);
+}
+
+static void escape_reset(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL) {
+ return;
+ }
+ ed->esc_state = CLIENT_ESC_NONE;
+ ed->csi_len = 0;
+}
+
+static int csi_is_final(unsigned char ch)
+{
+ return ch >= 0x40u && ch <= 0x7eu;
+}
+
+static hybbx_result_t apply_csi(hybbx_client_line_editor_t *ed,
+ const char *params, char final_ch)
+{
+ (void)params;
+
+ if (final_ch == 'A') {
+ return history_up(ed);
+ }
+ if (final_ch == 'B') {
+ return history_down(ed);
+ }
+ if (final_ch == 'C') {
+ return cursor_right(ed);
+ }
+ if (final_ch == 'D') {
+ return cursor_left(ed);
+ }
+ if (final_ch == 'H') {
+ return cursor_home(ed);
+ }
+ if (final_ch == 'F') {
+ return cursor_end(ed);
+ }
+ if (final_ch == '~') {
+ if (strcmp(params, "1") == 0 || strcmp(params, "7") == 0) {
+ return cursor_home(ed);
+ }
+ if (strcmp(params, "3") == 0) {
+ return delete_forward(ed);
+ }
+ if (strcmp(params, "4") == 0 || strcmp(params, "8") == 0) {
+ return cursor_end(ed);
+ }
+ }
+ return HYBBX_OK;
+}
+
+static int filter_escape_byte(hybbx_client_line_editor_t *ed, unsigned char byte)
+{
+ if (ed == NULL) {
+ return 0;
+ }
+ if (ed->esc_state == CLIENT_ESC_NONE && byte == 0x9bu) {
+ ed->esc_state = CLIENT_ESC_CSI;
+ ed->csi_len = 0;
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_NONE && byte == 0x1bu) {
+ ed->esc_state = CLIENT_ESC_ESC;
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_ESC) {
+ if (byte == '[') {
+ ed->esc_state = CLIENT_ESC_CSI;
+ ed->csi_len = 0;
+ return 1;
+ }
+ if (byte == 'O') {
+ ed->esc_state = CLIENT_ESC_SS3;
+ return 1;
+ }
+ escape_reset(ed);
+ return 0;
+ }
+ if (ed->esc_state == CLIENT_ESC_SS3) {
+ if (byte == 'C') {
+ (void)cursor_right(ed);
+ } else if (byte == 'D') {
+ (void)cursor_left(ed);
+ } else if (byte == 'H') {
+ (void)cursor_home(ed);
+ } else if (byte == 'F') {
+ (void)cursor_end(ed);
+ }
+ escape_reset(ed);
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_CSI) {
+ char params[24];
+
+ if (ed->csi_len + 1 >= sizeof(ed->csi_buf)) {
+ escape_reset(ed);
+ return 1;
+ }
+ ed->csi_buf[ed->csi_len++] = (char)byte;
+ if (!csi_is_final(byte)) {
+ return 1;
+ }
+
+ if (ed->csi_len > 1) {
+ memcpy(params, ed->csi_buf, ed->csi_len - 1);
+ params[ed->csi_len - 1] = '\0';
+ } else {
+ params[0] = '\0';
+ }
+ escape_reset(ed);
+ (void)apply_csi(ed, params, (char)byte);
+ return 1;
+ }
+ return 0;
+}
+
+int hybbx_client_line_editor_start(hybbx_client_line_editor_t *ed, int stdin_fd)
+{
+ struct termios raw;
+
+ if (ed == NULL) {
+ return -1;
+ }
+
+ memset(ed, 0, sizeof(*ed));
+ ed->stdin_fd = stdin_fd;
+ ed->history_view = -1;
+
+ if (!isatty(stdin_fd)) {
+ return 0;
+ }
+ if (tcgetattr(stdin_fd, &ed->termios_saved) != 0) {
+ return -1;
+ }
+
+ raw = ed->termios_saved;
+ raw.c_iflag &= ~(ICRNL | IXON);
+ raw.c_lflag &= ~(ECHO | ICANON | IEXTEN);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+
+ if (tcsetattr(stdin_fd, TCSAFLUSH, &raw) != 0) {
+ return -1;
+ }
+
+ ed->raw_enabled = 1;
+ return 0;
+}
+
+void hybbx_client_line_editor_stop(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || !ed->raw_enabled) {
+ return;
+ }
+ (void)tcsetattr(ed->stdin_fd, TCSAFLUSH, &ed->termios_saved);
+ ed->raw_enabled = 0;
+}
+
+hybbx_result_t hybbx_client_line_editor_poll(hybbx_client_line_editor_t *ed,
+ char *out_line, size_t out_len,
+ int *have_line, int *eof_seen)
+{
+ unsigned char buf[64];
+ ssize_t n;
+ size_t i;
+
+ if (ed == NULL || out_line == NULL || out_len == 0 ||
+ have_line == NULL || eof_seen == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *have_line = 0;
+ *eof_seen = 0;
+
+ n = read(ed->stdin_fd, buf, sizeof(buf));
+ if (n < 0) {
+ if (errno == EINTR) {
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ *eof_seen = 1;
+ return HYBBX_OK;
+ }
+
+ for (i = 0; i < (size_t)n; i++) {
+ unsigned char ch = buf[i];
+
+ if (filter_escape_byte(ed, ch)) {
+ continue;
+ }
+ if (ch == '\b' || ch == 127) {
+ if (backspace_char(ed) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ continue;
+ }
+ if (ch == '\r' || ch == '\n') {
+ if (ed->raw_enabled &&
+ write_all(STDOUT_FILENO, "\r\n", 2) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ hybbx_strlcpy(out_line, ed->line, out_len);
+ history_add(ed, ed->line);
+ ed->line[0] = '\0';
+ ed->line_len = 0;
+ ed->line_cursor = 0;
+ ed->history_view = -1;
+ *have_line = 1;
+ return HYBBX_OK;
+ }
+ if (insert_char(ed, (char)ch) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ return HYBBX_OK;
+}
diff --git a/src/clients/client_line.h b/src/clients/client_line.h
new file mode 100644
index 0000000..928efe0
--- /dev/null
+++ b/src/clients/client_line.h
@@ -0,0 +1,33 @@
+#ifndef HYBBX_CLIENT_LINE_H
+#define HYBBX_CLIENT_LINE_H
+
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <termios.h>
+
+typedef struct hybbx_client_line_editor {
+ char line[HYBBX_LINE_MAX];
+ size_t line_len;
+ size_t line_cursor;
+ char history[HYBBX_HISTORY_MAX][HYBBX_LINE_MAX];
+ unsigned history_count;
+ unsigned history_next;
+ int history_view;
+ char history_saved_line[HYBBX_LINE_MAX];
+ int raw_enabled;
+ int stdin_fd;
+ unsigned char esc_state;
+ char csi_buf[24];
+ size_t csi_len;
+ struct termios termios_saved;
+} hybbx_client_line_editor_t;
+
+int hybbx_client_line_editor_start(hybbx_client_line_editor_t *ed, int stdin_fd);
+void hybbx_client_line_editor_stop(hybbx_client_line_editor_t *ed);
+hybbx_result_t hybbx_client_line_editor_poll(hybbx_client_line_editor_t *ed,
+ char *out_line, size_t out_len,
+ int *have_line, int *eof_seen);
+
+#endif /* HYBBX_CLIENT_LINE_H */
diff --git a/src/clients/hybbx_ssh.c b/src/clients/hybbx_ssh.c
new file mode 100644
index 0000000..c2d6d88
--- /dev/null
+++ b/src/clients/hybbx_ssh.c
@@ -0,0 +1,495 @@
+/**
+ * hybbx-ssh — HyBBX-native SSH client (CLI only, no GUI).
+ *
+ * Encrypted transport to HyBBX [transport.ssh] (default port 3232).
+ * Wire SSH username/password satisfy the libssh handshake only; HyBBX
+ * accounts use guest auto-login or /login like telnet.
+ * Host keys are not verified — no known_hosts prompt.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/limits.h"
+#include "hybbx/ssh.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+
+#include <libssh/libssh.h>
+
+#include <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_SSH_POLL_MS 50
+#define HYBBX_SSH_WIRE_USER "hybbx"
+#define HYBBX_SSH_WIRE_PASSWORD "hybbx"
+
+typedef struct hybbx_ssh_client_config {
+ char host[256];
+ unsigned port;
+ char wire_user[64];
+ char wire_password[128];
+ char login_user[64];
+ char login_password[128];
+ unsigned baud;
+ unsigned line_width;
+ int pace_output;
+ int ansi;
+ int verbose;
+ unsigned timeout_sec;
+ int ipv6;
+} hybbx_ssh_client_config_t;
+
+typedef struct ssh_session_ctx {
+ ssh_session session;
+ ssh_channel channel;
+ hybbx_client_display_t display;
+ hybbx_client_line_editor_t editor;
+ int editor_ready;
+} ssh_session_ctx_t;
+
+static void config_defaults(hybbx_ssh_client_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_SSH_DEFAULT_PORT;
+ hybbx_strlcpy(cfg->wire_user, HYBBX_SSH_WIRE_USER, sizeof(cfg->wire_user));
+ hybbx_strlcpy(cfg->wire_password, HYBBX_SSH_WIRE_PASSWORD,
+ sizeof(cfg->wire_password));
+ cfg->baud = HYBBX_BAUD2400;
+ cfg->line_width = HYBBX_LINE_WIDTH;
+ cfg->pace_output = 1;
+ cfg->timeout_sec = 30;
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-ssh — HyBBX SSH client (encrypted transport, CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Remote host (default 127.0.0.1)\n"
+ " -p, --port PORT SSH port (default 3232)\n"
+ " -u, --user USER Send /login USER after connect\n"
+ " -P, --password PASS Password for /login\n"
+ " --wire-user USER SSH wire username (default hybbx)\n"
+ " --wire-pass PASS SSH wire password (default hybbx)\n"
+ " --baud N Pace output like HyBBX (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80, 0=off)\n"
+ " --pace yes|no Output pacing (default yes)\n"
+ " --ansi yes|no Pass ANSI sequences (default no)\n"
+ " -6, --ipv6 Prefer IPv6 when connecting\n"
+ " -t, --timeout SEC Connect timeout (default 30)\n"
+ " -v, --verbose Log SSH progress on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "SSH wire credentials are not HyBBX accounts — any value is accepted\n"
+ "by the server. Use -u/-P for HyBBX /login after connect.\n"
+ "\n"
+ "Environment: HYBBX_HOST, HYBBX_PORT, HYBBX_SSH_WIRE_USER,\n"
+ " HYBBX_SSH_WIRE_PASS\n"
+ "\n",
+ prog);
+}
+
+static void log_ssh_error(ssh_session session, const char *what)
+{
+ const char *msg = ssh_get_error(session);
+
+ if (msg == NULL || msg[0] == '\0') {
+ msg = "unknown error";
+ }
+ fprintf(stderr, "hybbx-ssh: %s: %s\n", what, msg);
+}
+
+static int ssh_connect_session(const hybbx_ssh_client_config_t *cfg,
+ ssh_session *out_session,
+ ssh_channel *out_channel)
+{
+ ssh_session session;
+ ssh_channel channel;
+ char portbuf[16];
+ long timeout_sec;
+ int strict = 0;
+ int process_config = 0;
+ int pubkey_auth = 0;
+ int nodelay = 1;
+ int rc;
+
+ session = ssh_new();
+ if (session == NULL) {
+ fprintf(stderr, "hybbx-ssh: ssh_new failed\n");
+ return -1;
+ }
+
+ snprintf(portbuf, sizeof(portbuf), "%u", cfg->port);
+ timeout_sec = (long)cfg->timeout_sec;
+
+ (void)ssh_options_set(session, SSH_OPTIONS_HOST, cfg->host);
+ (void)ssh_options_set(session, SSH_OPTIONS_PORT_STR, portbuf);
+ (void)ssh_options_set(session, SSH_OPTIONS_USER, cfg->wire_user);
+ (void)ssh_options_set(session, SSH_OPTIONS_STRICTHOSTKEYCHECK, &strict);
+ (void)ssh_options_set(session, SSH_OPTIONS_PROCESS_CONFIG, &process_config);
+ (void)ssh_options_set(session, SSH_OPTIONS_PUBKEY_AUTH, &pubkey_auth);
+ (void)ssh_options_set(session, SSH_OPTIONS_TIMEOUT, &timeout_sec);
+ (void)ssh_options_set(session, SSH_OPTIONS_NODELAY, &nodelay);
+
+ if (cfg->ipv6) {
+ (void)ssh_options_set(session, SSH_OPTIONS_BINDADDR, "::");
+ }
+
+ rc = ssh_connect(session);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "connect");
+ ssh_free(session);
+ return -1;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-ssh: SSH handshake complete with %s:%u\n",
+ cfg->host, cfg->port);
+ }
+
+ rc = ssh_userauth_password(session, NULL, cfg->wire_password);
+ if (rc != SSH_AUTH_SUCCESS) {
+ log_ssh_error(session, "wire authentication");
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ channel = ssh_channel_new(session);
+ if (channel == NULL) {
+ log_ssh_error(session, "channel_new");
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_open_session(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_open_session");
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_request_pty(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_request_pty");
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_request_shell(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_request_shell");
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ ssh_set_blocking(session, 0);
+
+ *out_session = session;
+ *out_channel = channel;
+ return 0;
+}
+
+static hybbx_result_t send_line(ssh_channel channel, const char *line)
+{
+ char buf[HYBBX_LINE_MAX + 2];
+ size_t len;
+ int rc;
+
+ snprintf(buf, sizeof(buf), "%s\n", line);
+ len = strlen(buf);
+
+ rc = ssh_channel_write(channel, buf, (uint32_t)len);
+ if (rc < 0 || (size_t)rc != len) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void maybe_auto_login(ssh_channel channel,
+ const hybbx_ssh_client_config_t *cfg)
+{
+ char line[192];
+
+ if (cfg->login_user[0] == '\0') {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "/login %s", cfg->login_user);
+ (void)send_line(channel, line);
+ if (cfg->login_password[0] != '\0') {
+ (void)send_line(channel, cfg->login_password);
+ }
+}
+
+static int drain_channel(ssh_session_ctx_t *ctx, int verbose)
+{
+ uint8_t buf[512];
+ int n;
+
+ for (;;) {
+ n = ssh_channel_read_nonblocking(ctx->channel, buf, sizeof(buf), 0);
+ if (n == SSH_AGAIN) {
+ return 0;
+ }
+ if (n <= 0) {
+ if (ssh_channel_is_eof(ctx->channel) || ssh_channel_is_closed(ctx->channel)) {
+ return -1;
+ }
+ return 0;
+ }
+
+ if (verbose) {
+ fprintf(stderr, "hybbx-ssh: recv %d bytes\n", n);
+ }
+
+ hybbx_client_display_write(&ctx->display, buf, (size_t)n);
+ }
+}
+
+static int run_session(ssh_session_ctx_t *ctx, const hybbx_ssh_client_config_t *cfg)
+{
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+
+ maybe_auto_login(ctx->channel, cfg);
+
+ while (running) {
+ struct pollfd pfds[2];
+ socket_t ssh_fd;
+ int pr;
+ int nready = 1;
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+
+ ssh_fd = ssh_get_fd(ctx->session);
+ pfds[1].fd = (int)ssh_fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+ nready = 2;
+
+ pr = poll(pfds, (unsigned int)nready, HYBBX_SSH_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (drain_channel(ctx, cfg->verbose) != 0) {
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!ctx->editor_ready ||
+ hybbx_client_line_editor_poll(&ctx->editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(ctx->channel, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0 && drain_channel(ctx, cfg->verbose) != 0) {
+ break;
+ }
+ }
+
+ return 0;
+}
+
+static void session_ctx_init(ssh_session_ctx_t *ctx,
+ ssh_session session,
+ ssh_channel channel,
+ const hybbx_ssh_client_config_t *cfg)
+{
+ hybbx_traffic_config_t traffic;
+
+ memset(ctx, 0, sizeof(*ctx));
+ ctx->session = session;
+ ctx->channel = channel;
+
+ hybbx_traffic_config_defaults(&traffic);
+ traffic.baud = cfg->baud;
+ traffic.line_width = cfg->line_width;
+ traffic.pace_output = cfg->pace_output;
+ traffic.ansi = cfg->ansi;
+ hybbx_client_display_init(&ctx->display, &traffic);
+
+ if (hybbx_client_line_editor_start(&ctx->editor, STDIN_FILENO) == 0) {
+ ctx->editor_ready = 1;
+ }
+}
+
+static void session_ctx_fini(ssh_session_ctx_t *ctx)
+{
+ if (ctx->editor_ready) {
+ hybbx_client_line_editor_stop(&ctx->editor);
+ ctx->editor_ready = 0;
+ }
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_ssh_client_config_t cfg;
+ ssh_session session = NULL;
+ ssh_channel channel = NULL;
+ ssh_session_ctx_t ctx;
+ int opt;
+ int argi = 1;
+ const char *env;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"user", required_argument, NULL, 'u'},
+ {"password", required_argument, NULL, 'P'},
+ {"wire-user", required_argument, NULL, 1004},
+ {"wire-pass", required_argument, NULL, 1005},
+ {"baud", required_argument, NULL, 1000},
+ {"line-width", required_argument, NULL, 1001},
+ {"pace", required_argument, NULL, 1002},
+ {"ansi", required_argument, NULL, 1003},
+ {"ipv6", no_argument, NULL, '6'},
+ {"timeout", required_argument, NULL, 't'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+
+ env = getenv("HYBBX_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+ env = getenv("HYBBX_SSH_WIRE_USER");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.wire_user, env, sizeof(cfg.wire_user));
+ }
+ env = getenv("HYBBX_SSH_WIRE_PASS");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.wire_password, env, sizeof(cfg.wire_password));
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:u:P:6t:vh", long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'u':
+ hybbx_strlcpy(cfg.login_user, optarg, sizeof(cfg.login_user));
+ break;
+ case 'P':
+ hybbx_strlcpy(cfg.login_password, optarg, sizeof(cfg.login_password));
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 't':
+ cfg.timeout_sec = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ cfg.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1001:
+ cfg.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1002:
+ cfg.pace_output = hybbx_parse_bool(optarg, cfg.pace_output);
+ break;
+ case 1003:
+ cfg.ansi = hybbx_parse_bool(optarg, cfg.ansi);
+ break;
+ case 1004:
+ hybbx_strlcpy(cfg.wire_user, optarg, sizeof(cfg.wire_user));
+ break;
+ case 1005:
+ hybbx_strlcpy(cfg.wire_password, optarg, sizeof(cfg.wire_password));
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ if (ssh_connect_session(&cfg, &session, &channel) != 0) {
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr,
+ "hybbx-ssh: shell ready on %s:%u (baud=%u width=%u wire=%s)\n",
+ cfg.host, cfg.port, cfg.baud, cfg.line_width, cfg.wire_user);
+ }
+
+ session_ctx_init(&ctx, session, channel, &cfg);
+ run_session(&ctx, &cfg);
+ session_ctx_fini(&ctx);
+
+ ssh_channel_send_eof(channel);
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/hybbx_telnet.c b/src/clients/hybbx_telnet.c
new file mode 100644
index 0000000..c19e747
--- /dev/null
+++ b/src/clients/hybbx_telnet.c
@@ -0,0 +1,544 @@
+/**
+ * hybbx-telnet — HyBBX-native telnet client (CLI only, no GUI).
+ *
+ * Full telnet negotiation support for testing HyBBX servers.
+ * Optional 2400 baud pacing and 80-column display profile.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/limits.h"
+#include "hybbx/socket.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+#include "telnet_proto.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(__AMIGA__)
+#include <arpa/inet.h>
+#include <netdb.h>
+#include <sys/select.h>
+#include <sys/socket.h>
+#else
+#include <netdb.h>
+#include <poll.h>
+#endif
+
+#define HYBBX_TELNET_DEFAULT_PORT 2323u
+#define HYBBX_TELNET_POLL_MS 50
+
+typedef struct hybbx_telnet_client_config {
+ char host[256];
+ unsigned port;
+ char login_user[64];
+ char login_password[128];
+ unsigned baud;
+ unsigned line_width;
+ int pace_output;
+ int ansi;
+ int verbose;
+ unsigned timeout_sec;
+ int ipv6;
+} hybbx_telnet_client_config_t;
+
+static void config_defaults(hybbx_telnet_client_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_TELNET_DEFAULT_PORT;
+ cfg->baud = HYBBX_BAUD2400;
+ cfg->line_width = HYBBX_LINE_WIDTH;
+ cfg->pace_output = 1;
+ cfg->timeout_sec = 30;
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-telnet — HyBBX telnet client for testing (CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Remote host (default 127.0.0.1)\n"
+ " -p, --port PORT Telnet port (default 2323)\n"
+ " -u, --user USER Send /login USER after connect\n"
+ " -P, --password PASS Password for /login\n"
+ " --baud N Pace output like HyBBX (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80, 0=off)\n"
+ " --pace yes|no Output pacing (default yes)\n"
+ " --ansi yes|no Pass ANSI sequences (default no)\n"
+ " -6, --ipv6 Prefer IPv6 when resolving host\n"
+ " -t, --timeout SEC Connect timeout (default 30)\n"
+ " -v, --verbose Log telnet negotiation on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "Environment: HYBBX_HOST, HYBBX_PORT\n"
+ "\n",
+ prog);
+}
+
+static int tcp_connect(const hybbx_telnet_client_config_t *cfg, int *out_fd)
+{
+#if defined(__AMIGA__)
+ struct sockaddr_in addr;
+ struct hostent *he;
+ int fd;
+ int on = 1;
+
+ if (cfg->ipv6) {
+ fprintf(stderr, "hybbx-telnet: IPv6 is not supported on AmigaOS\n");
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ fprintf(stderr, "hybbx-telnet: socket failed for %s:%u\n",
+ cfg->host, cfg->port);
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)cfg->port);
+
+ if (inet_aton(cfg->host, &addr.sin_addr) == 0) {
+ he = gethostbyname(cfg->host);
+ if (he == NULL || he->h_addrtype != AF_INET || he->h_length == 0) {
+ fprintf(stderr, "hybbx-telnet: unknown host %s\n", cfg->host);
+ close(fd);
+ return -1;
+ }
+ memcpy(&addr.sin_addr, he->h_addr, (size_t)he->h_length);
+ }
+
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
+ hybbx_socket_nosigpipe(fd);
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ fprintf(stderr, "hybbx-telnet: could not connect to %s:%u\n",
+ cfg->host, cfg->port);
+ close(fd);
+ return -1;
+ }
+
+ *out_fd = fd;
+ return 0;
+#else
+ char portbuf[16];
+ struct addrinfo hints;
+ struct addrinfo *res = NULL;
+ struct addrinfo *ai;
+ int fd = -1;
+ int rc;
+
+ snprintf(portbuf, sizeof(portbuf), "%u", cfg->port);
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_socktype = SOCK_STREAM;
+ hints.ai_family = cfg->ipv6 ? AF_INET6 : AF_UNSPEC;
+
+ rc = getaddrinfo(cfg->host, portbuf, &hints, &res);
+ if (rc != 0) {
+ fprintf(stderr, "hybbx-telnet: connect %s:%u failed: %s\n",
+ cfg->host, cfg->port, gai_strerror(rc));
+ return -1;
+ }
+
+ for (ai = res; ai != NULL; ai = ai->ai_next) {
+ int on = 1;
+
+ fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+ if (fd < 0) {
+ continue;
+ }
+
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
+ hybbx_socket_nosigpipe(fd);
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+ break;
+ }
+ close(fd);
+ fd = -1;
+ }
+
+ freeaddrinfo(res);
+
+ if (fd < 0) {
+ fprintf(stderr, "hybbx-telnet: could not connect to %s:%u\n",
+ cfg->host, cfg->port);
+ return -1;
+ }
+
+ *out_fd = fd;
+ return 0;
+#endif
+}
+
+typedef struct display_ctx {
+ hybbx_client_display_t display;
+} display_ctx_t;
+
+static void on_telnet_data(void *userdata, const uint8_t *data, size_t len)
+{
+ display_ctx_t *ctx = (display_ctx_t *)userdata;
+
+ hybbx_client_display_write(&ctx->display, data, len);
+}
+
+static hybbx_result_t send_raw(int fd, const uint8_t *data, size_t len)
+{
+ size_t off = 0;
+
+ while (off < len) {
+ ssize_t n = send(fd, data + off, len - off, MSG_NOSIGNAL);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_line(int fd, const char *line)
+{
+ size_t len = strlen(line);
+ uint8_t stack[256];
+ uint8_t *buf = stack;
+ size_t cap = sizeof(stack);
+ size_t need = len;
+ size_t i;
+ size_t pos = 0;
+ hybbx_result_t rc;
+
+ for (i = 0; i < len; i++) {
+ if (line[i] == (char)255) {
+ need++;
+ }
+ }
+ need += 1;
+
+ if (need > cap) {
+ buf = malloc(need);
+ if (buf == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ cap = need;
+ }
+
+ for (i = 0; i < len; i++) {
+ buf[pos++] = (uint8_t)line[i];
+ if (line[i] == (char)255) {
+ buf[pos++] = 255;
+ }
+ }
+ buf[pos++] = (uint8_t)'\n';
+
+ rc = send_raw(fd, buf, pos);
+ if (buf != stack) {
+ free(buf);
+ }
+ return rc;
+}
+
+static void maybe_auto_login(int fd, const hybbx_telnet_client_config_t *cfg)
+{
+ char line[192];
+
+ if (cfg->login_user[0] == '\0') {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "/login %s", cfg->login_user);
+ (void)send_line(fd, line);
+ if (cfg->login_password[0] != '\0') {
+ (void)send_line(fd, cfg->login_password);
+ }
+}
+
+static int run_session(int fd, const hybbx_telnet_client_config_t *cfg)
+{
+ hybbx_telnet_parser_t parser;
+ display_ctx_t display;
+ hybbx_client_line_editor_t editor;
+ hybbx_traffic_config_t traffic;
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+ int editor_ready = 0;
+
+ hybbx_traffic_config_defaults(&traffic);
+ traffic.baud = cfg->baud;
+ traffic.line_width = cfg->line_width;
+ traffic.pace_output = cfg->pace_output;
+ traffic.ansi = cfg->ansi;
+ hybbx_client_display_init(&display.display, &traffic);
+ hybbx_telnet_parser_init(&parser);
+ if (hybbx_client_line_editor_start(&editor, STDIN_FILENO) == 0) {
+ editor_ready = 1;
+ }
+
+ (void)hybbx_telnet_send_greeting(fd);
+ maybe_auto_login(fd, cfg);
+
+ while (running) {
+#if defined(__AMIGA__)
+ fd_set rfds;
+ struct timeval tv;
+ int maxfd;
+ int pr;
+
+ FD_ZERO(&rfds);
+ FD_SET(STDIN_FILENO, &rfds);
+ FD_SET(fd, &rfds);
+ maxfd = fd;
+ if (STDIN_FILENO > maxfd) {
+ maxfd = STDIN_FILENO;
+ }
+ tv.tv_sec = HYBBX_TELNET_POLL_MS / 1000;
+#if defined(__AMIGA__)
+ tv.tv_usec = (unsigned long)((HYBBX_TELNET_POLL_MS % 1000) * 1000);
+#else
+ tv.tv_usec = (HYBBX_TELNET_POLL_MS % 1000) * 1000;
+#endif
+
+ pr = select(maxfd + 1, &rfds, NULL, NULL, &tv);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (FD_ISSET(STDIN_FILENO, &rfds)) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(fd, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if (FD_ISSET(fd, &rfds)) {
+ uint8_t buf[512];
+ ssize_t n = recv(fd, buf, sizeof(buf), 0);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-telnet: recv %zd bytes\n", n);
+ }
+
+ if (hybbx_telnet_parser_feed(&parser, fd, buf, (size_t)n,
+ on_telnet_data, &display) != HYBBX_OK) {
+ break;
+ }
+ }
+#else
+ struct pollfd pfds[2];
+ int pr;
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ pfds[1].fd = fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+
+ pr = poll(pfds, 2, HYBBX_TELNET_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(fd, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0) {
+ uint8_t buf[512];
+ ssize_t n = recv(fd, buf, sizeof(buf), 0);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-telnet: recv %zd bytes\n", n);
+ }
+
+ if (hybbx_telnet_parser_feed(&parser, fd, buf, (size_t)n,
+ on_telnet_data, &display) != HYBBX_OK) {
+ break;
+ }
+ }
+#endif
+ }
+
+ if (editor_ready) {
+ hybbx_client_line_editor_stop(&editor);
+ }
+
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_telnet_client_config_t cfg;
+ int fd = -1;
+ int opt;
+ int argi = 1;
+ const char *env;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"user", required_argument, NULL, 'u'},
+ {"password", required_argument, NULL, 'P'},
+ {"baud", required_argument, NULL, 1000},
+ {"line-width", required_argument, NULL, 1001},
+ {"pace", required_argument, NULL, 1002},
+ {"ansi", required_argument, NULL, 1003},
+ {"ipv6", no_argument, NULL, '6'},
+ {"timeout", required_argument, NULL, 't'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+
+ env = getenv("HYBBX_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:u:P:6t:vh",
+ long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'u':
+ hybbx_strlcpy(cfg.login_user, optarg, sizeof(cfg.login_user));
+ break;
+ case 'P':
+ hybbx_strlcpy(cfg.login_password, optarg, sizeof(cfg.login_password));
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 't':
+ cfg.timeout_sec = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ cfg.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1001:
+ cfg.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1002:
+ cfg.pace_output = hybbx_parse_bool(optarg, cfg.pace_output);
+ break;
+ case 1003:
+ cfg.ansi = hybbx_parse_bool(optarg, cfg.ansi);
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ (void)cfg.timeout_sec;
+
+ if (tcp_connect(&cfg, &fd) != 0) {
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr, "hybbx-telnet: connected to %s:%u (baud=%u width=%u)\n",
+ cfg.host, cfg.port, cfg.baud, cfg.line_width);
+ }
+
+ run_session(fd, &cfg);
+ close(fd);
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/hybbx_terminal.c b/src/clients/hybbx_terminal.c
new file mode 100644
index 0000000..b6e09e1
--- /dev/null
+++ b/src/clients/hybbx_terminal.c
@@ -0,0 +1,433 @@
+/**
+ * hybbx-terminal — HyBBX AX.25 / HBX circuit terminal client (CLI only).
+ *
+ * Connects to the internal HBX circuit hub, authenticates with link password,
+ * and exchanges TERMINAL and AX.25 UI frames for operator sessions.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/ax25.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/limits.h"
+#include "hybbx/link.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_TERMINAL_POLL_MS 50
+
+typedef struct hybbx_terminal_config {
+ char host[256];
+ unsigned port;
+ char link_id[HYBBX_LINK_ID_MAX];
+ char link_password[128];
+ char link_role[HYBBX_LINK_ROLE_MAX];
+ char mycall[32];
+ char dest[32];
+ char via[128];
+ int ax25_ui;
+ int verbose;
+ int ipv6;
+} hybbx_terminal_config_t;
+
+typedef struct terminal_session {
+ int fd;
+ const hybbx_terminal_config_t *cfg;
+ hybbx_circuit_decoder_t dec;
+ hybbx_client_display_t display;
+ hybbx_ax25_path_t path;
+ int path_ready;
+} terminal_session_t;
+
+static void config_defaults(hybbx_terminal_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ hybbx_strlcpy(cfg->link_id, "terminal-1", sizeof(cfg->link_id));
+ hybbx_strlcpy(cfg->link_role, "gateway", sizeof(cfg->link_role));
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-terminal — HyBBX AX.25 / HBX circuit terminal client (CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Circuit hub host (default 127.0.0.1)\n"
+ " -p, --port PORT Circuit hub port (default 7323)\n"
+ " --link-id ID Link identity for password auth\n"
+ " --link-password P Link password (HBX LINK_AUTH)\n"
+ " --link-role ROLE link/repeater edge role: gateway | repeater | link | digipeater\n"
+ " --mycall CALL AX.25 source (CALL or CALL-SSID)\n"
+ " --dest CALL AX.25 destination\n"
+ " --via LIST Comma-separated digipeater path\n"
+ " --ax25-ui Send lines as AX.25 UI frames (needs mycall/dest)\n"
+ " --baud N Pace output (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80)\n"
+ " --pace yes|no Output pacing\n"
+ " --ansi yes|no Pass ANSI sequences\n"
+ " -6, --ipv6 Prefer IPv6 (reserved)\n"
+ " -v, --verbose Log HBX frames on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "Environment: HYBBX_CIRCUIT_HOST, HYBBX_CIRCUIT_PORT\n"
+ "\n",
+ prog);
+}
+
+static int build_ax25_path(const hybbx_terminal_config_t *cfg,
+ hybbx_ax25_path_t *path)
+{
+ const char *cursor;
+ char token[32];
+
+ memset(path, 0, sizeof(*path));
+
+ if (cfg->dest[0] == '\0' || cfg->mycall[0] == '\0') {
+ return 0;
+ }
+
+ if (hybbx_ax25_address_parse(cfg->dest, &path->dest) != HYBBX_OK) {
+ return 0;
+ }
+ if (hybbx_ax25_address_parse(cfg->mycall, &path->source) != HYBBX_OK) {
+ return 0;
+ }
+
+ cursor = cfg->via;
+ while (cursor != NULL && *cursor != '\0' && path->digi_count < HYBBX_AX25_MAX_DIGI) {
+ const char *comma = strchr(cursor, ',');
+ size_t len;
+
+ if (comma != NULL) {
+ len = (size_t)(comma - cursor);
+ } else {
+ len = strlen(cursor);
+ }
+ if (len >= sizeof(token)) {
+ len = sizeof(token) - 1;
+ }
+ memcpy(token, cursor, len);
+ token[len] = '\0';
+
+ if (hybbx_ax25_address_parse(token, &path->digi[path->digi_count]) == HYBBX_OK) {
+ path->digi_count++;
+ }
+
+ cursor = comma != NULL ? comma + 1 : NULL;
+ }
+
+ return 1;
+}
+
+static void on_circuit_frame(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ terminal_session_t *sess = (terminal_session_t *)userdata;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t ui_len;
+
+ (void)flags;
+
+ if (sess == NULL || len == 0) {
+ return;
+ }
+
+ if (sess->cfg->verbose) {
+ fprintf(stderr, "hybbx-terminal: rx proto=%s (%u bytes)\n",
+ hybbx_circuit_proto_name(proto), (unsigned)len);
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ hybbx_client_display_write(&sess->display, payload, len);
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25_UI:
+ ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path, ui, sizeof(ui));
+ if (ui_len > 0) {
+ hybbx_client_display_write(&sess->display, ui, ui_len);
+ }
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25:
+ ui_len = hybbx_ax25_parse_ui(payload, len, &path, ui, sizeof(ui));
+ if (ui_len > 0) {
+ hybbx_client_display_write(&sess->display, ui, ui_len);
+ }
+ break;
+ default:
+ break;
+ }
+}
+
+static hybbx_result_t send_terminal_line(terminal_session_t *sess,
+ const char *line)
+{
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t len = strlen(line);
+
+ frame_len = hybbx_circuit_encode_terminal(line, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(sess->fd, frame, frame_len);
+}
+
+static hybbx_result_t send_ax25_ui_line(terminal_session_t *sess,
+ const char *line)
+{
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t len = strlen(line);
+
+ if (!sess->path_ready) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode_ax25_ui(&sess->path,
+ (const uint8_t *)line, len,
+ HYBBX_CIRCUIT_FLAG_TX,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(sess->fd, frame, frame_len);
+}
+
+static int run_session(terminal_session_t *sess)
+{
+ hybbx_client_line_editor_t editor;
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+ int editor_ready = 0;
+
+ if (hybbx_client_line_editor_start(&editor, STDIN_FILENO) == 0) {
+ editor_ready = 1;
+ }
+
+ while (running) {
+ struct pollfd pfds[2];
+ int pr;
+ uint8_t buf[512];
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ pfds[1].fd = sess->fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+
+ pr = poll(pfds, 2, HYBBX_TERMINAL_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ hybbx_result_t rc;
+
+ if (sess->cfg->ax25_ui && sess->path_ready) {
+ rc = send_ax25_ui_line(sess, inbuf);
+ } else {
+ rc = send_terminal_line(sess, inbuf);
+ }
+ if (rc != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0) {
+ size_t read_len;
+
+ if (hybbx_circuit_link_read(sess->fd, buf, sizeof(buf),
+ &read_len) != HYBBX_OK) {
+ break;
+ }
+ if (read_len == 0) {
+ continue;
+ }
+
+ hybbx_circuit_decoder_feed(&sess->dec, buf, read_len,
+ on_circuit_frame, sess);
+ }
+ }
+
+ if (editor_ready) {
+ hybbx_client_line_editor_stop(&editor);
+ }
+
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_terminal_config_t cfg;
+ terminal_session_t sess;
+ const char *env;
+ int opt;
+ int argi;
+ hybbx_traffic_config_t traffic;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"link-id", required_argument, NULL, 1000},
+ {"link-password", required_argument, NULL, 1001},
+ {"link-role", required_argument, NULL, 1002},
+ {"mycall", required_argument, NULL, 1003},
+ {"dest", required_argument, NULL, 1004},
+ {"via", required_argument, NULL, 1005},
+ {"ax25-ui", no_argument, NULL, 1006},
+ {"baud", required_argument, NULL, 1007},
+ {"line-width", required_argument, NULL, 1008},
+ {"pace", required_argument, NULL, 1009},
+ {"ansi", required_argument, NULL, 1010},
+ {"ipv6", no_argument, NULL, '6'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+ hybbx_traffic_config_defaults(&traffic);
+
+ env = getenv("HYBBX_CIRCUIT_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_CIRCUIT_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:6vh", long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ hybbx_strlcpy(cfg.link_id, optarg, sizeof(cfg.link_id));
+ break;
+ case 1001:
+ hybbx_strlcpy(cfg.link_password, optarg, sizeof(cfg.link_password));
+ break;
+ case 1002:
+ hybbx_strlcpy(cfg.link_role, optarg, sizeof(cfg.link_role));
+ break;
+ case 1003:
+ hybbx_strlcpy(cfg.mycall, optarg, sizeof(cfg.mycall));
+ break;
+ case 1004:
+ hybbx_strlcpy(cfg.dest, optarg, sizeof(cfg.dest));
+ break;
+ case 1005:
+ hybbx_strlcpy(cfg.via, optarg, sizeof(cfg.via));
+ break;
+ case 1006:
+ cfg.ax25_ui = 1;
+ break;
+ case 1007:
+ traffic.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1008:
+ traffic.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1009:
+ traffic.pace_output = hybbx_parse_bool(optarg, traffic.pace_output);
+ break;
+ case 1010:
+ traffic.ansi = hybbx_parse_bool(optarg, traffic.ansi);
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ memset(&sess, 0, sizeof(sess));
+ sess.cfg = &cfg;
+ hybbx_circuit_decoder_init(&sess.dec);
+ hybbx_client_display_init(&sess.display, &traffic);
+ sess.path_ready = build_ax25_path(&cfg, &sess.path);
+
+ if (hybbx_circuit_link_connect(cfg.host, cfg.port, &sess.fd) != HYBBX_OK) {
+ fprintf(stderr, "hybbx-terminal: connect %s:%u failed\n",
+ cfg.host, cfg.port);
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.link_password[0] != '\0') {
+ if (hybbx_circuit_link_authenticate(sess.fd, cfg.link_password,
+ cfg.link_role, cfg.link_id) != HYBBX_OK) {
+ fprintf(stderr, "hybbx-terminal: link authentication failed\n");
+ close(sess.fd);
+ return EXIT_FAILURE;
+ }
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr, "hybbx-terminal: connected to %s:%u (ax25-ui=%s)\n",
+ cfg.host, cfg.port, cfg.ax25_ui ? "yes" : "no");
+ }
+
+ run_session(&sess);
+ close(sess.fd);
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/link_auth.c b/src/clients/link_auth.c
new file mode 100644
index 0000000..4ba53e5
--- /dev/null
+++ b/src/clients/link_auth.c
@@ -0,0 +1,182 @@
+#include "hybbx/link.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static const char *find_kv_line(const char *payload, size_t len,
+ const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = payload;
+ const char *end = payload + len;
+ size_t key_len = strlen(key);
+
+ while (cursor < end) {
+ const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor));
+ const char *line_stop = line_end != NULL ? line_end : end;
+ const char *eq = memchr(cursor, '=', (size_t)(line_stop - cursor));
+
+ if (eq != NULL && (size_t)(eq - cursor) == key_len &&
+ memcmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(line_stop - value);
+
+ while (value_len > 0 &&
+ (value[value_len - 1] == '\r' ||
+ value[value_len - 1] == '\n' ||
+ value[value_len - 1] == ' ')) {
+ value_len--;
+ }
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (line_end == NULL) {
+ break;
+ }
+ cursor = line_end + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_link_auth_clear(hybbx_link_auth_t *auth)
+{
+ if (auth == NULL) {
+ return;
+ }
+
+ memset(auth, 0, sizeof(*auth));
+}
+
+size_t hybbx_link_auth_format(const hybbx_link_auth_t *auth,
+ char *out, size_t out_cap)
+{
+ int n;
+
+ if (auth == NULL || out == NULL || out_cap == 0) {
+ return 0;
+ }
+
+ if (auth->password[0] == '\0' || auth->id[0] == '\0') {
+ return 0;
+ }
+
+ n = snprintf(out, out_cap,
+ "password=%s\nrole=%s\nid=%s\n",
+ auth->password,
+ auth->role[0] != '\0' ? auth->role : "link",
+ auth->id);
+ if (n < 0 || (size_t)n >= out_cap) {
+ return 0;
+ }
+
+ if (auth->baud > 0) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "baud=%u\n", auth->baud);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->duplex == 1) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "duplex=half\n");
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ } else if (auth->duplex == 2) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "duplex=full\n");
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->bandwidth[0] != '\0') {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "bandwidth=%s\n", auth->bandwidth);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->frequency_mhz[0] != '\0') {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "frequency_mhz=%s\n", auth->frequency_mhz);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ return (size_t)n;
+}
+
+hybbx_result_t hybbx_link_auth_parse(const char *payload, size_t len,
+ hybbx_link_auth_t *auth)
+{
+ char scratch[128];
+
+ if (payload == NULL || auth == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_link_auth_clear(auth);
+
+ if (find_kv_line(payload, len, "password", scratch, sizeof(scratch)) == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ hybbx_strlcpy(auth->password, scratch, sizeof(auth->password));
+
+ if (find_kv_line(payload, len, "id", scratch, sizeof(scratch)) == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ hybbx_strlcpy(auth->id, scratch, sizeof(auth->id));
+
+ if (find_kv_line(payload, len, "role", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->role, scratch, sizeof(auth->role));
+ } else {
+ hybbx_strlcpy(auth->role, "link", sizeof(auth->role));
+ }
+
+ if (find_kv_line(payload, len, "baud", scratch, sizeof(scratch)) != NULL) {
+ unsigned long baud = strtoul(scratch, NULL, 10);
+ if (baud > 0) {
+ auth->baud = (unsigned)baud;
+ }
+ }
+
+ if (find_kv_line(payload, len, "duplex", scratch, sizeof(scratch)) != NULL) {
+ if (strcmp(scratch, "full") == 0 || strcmp(scratch, "full-duplex") == 0) {
+ auth->duplex = 2;
+ } else if (strcmp(scratch, "half") == 0 ||
+ strcmp(scratch, "half-duplex") == 0 ||
+ strcmp(scratch, "simplex") == 0) {
+ auth->duplex = 1;
+ }
+ }
+
+ if (find_kv_line(payload, len, "bandwidth", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->bandwidth, scratch, sizeof(auth->bandwidth));
+ }
+
+ if (find_kv_line(payload, len, "frequency_mhz", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->frequency_mhz, scratch, sizeof(auth->frequency_mhz));
+ } else if (find_kv_line(payload, len, "frequency", scratch,
+ sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->frequency_mhz, scratch, sizeof(auth->frequency_mhz));
+ }
+
+ 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