summaryrefslogtreecommitdiff
path: root/plugins
diff options
context:
space:
mode:
Diffstat (limited to 'plugins')
-rw-r--r--plugins/CMakeLists.txt44
-rw-r--r--plugins/ardop/CMakeLists.txt57
-rw-r--r--plugins/ardop/ardop.c49
-rw-r--r--plugins/ardop/ardop_config.c186
-rw-r--r--plugins/ardop/ardop_crc.c69
-rw-r--r--plugins/ardop/ardop_crc.h15
-rw-r--r--plugins/ardop/ardop_host.c650
-rw-r--r--plugins/ardop/ardop_host.h47
-rw-r--r--plugins/ardop/ardop_link.c446
-rw-r--r--plugins/ardop/ardop_link.h37
-rw-r--r--plugins/baycom/CMakeLists.txt36
-rw-r--r--plugins/baycom/baycom.c623
-rw-r--r--plugins/baycom/baycom_config.c395
-rw-r--r--plugins/baycom/baycom_kernel.c472
-rw-r--r--plugins/baycom/baycom_kernel.h26
-rw-r--r--plugins/baycom/baycom_kiss.c262
-rw-r--r--plugins/baycom/baycom_kiss.h26
-rw-r--r--plugins/baycom/baycom_modem.c94
-rw-r--r--plugins/baycom/baycom_modem.h31
-rw-r--r--plugins/crdop/CMakeLists.txt26
-rw-r--r--plugins/crdop/crdop.c51
-rw-r--r--plugins/crdop/crdop_config.c186
-rw-r--r--plugins/mains_proxy/CMakeLists.txt23
-rw-r--r--plugins/mains_proxy/mains_proxy.c137
-rw-r--r--plugins/packet_radio/CMakeLists.txt36
-rw-r--r--plugins/packet_radio/ax25.c250
-rw-r--r--plugins/packet_radio/kiss.c152
-rw-r--r--plugins/packet_radio/packet_radio.c1005
-rw-r--r--plugins/packet_radio/packet_radio_config.c786
-rw-r--r--plugins/packet_radio/serial_port.c705
-rw-r--r--plugins/packet_radio/serial_port.h52
-rw-r--r--plugins/packet_radio/sixpack.c146
-rw-r--r--plugins/packet_radio/sixpack.h40
-rw-r--r--plugins/packet_radio/tnc.c1080
-rw-r--r--plugins/packet_radio/tnc_host.c143
-rw-r--r--plugins/packet_radio/tnc_host.h51
-rw-r--r--plugins/packet_radio/tnc_profiles.c399
-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
-rw-r--r--plugins/telnet/CMakeLists.txt26
-rw-r--r--plugins/telnet/telnet.c516
-rw-r--r--plugins/telnet/telnet_config.c125
-rw-r--r--plugins/telnet/telnet_proto.c213
-rw-r--r--plugins/telnet/telnet_proto.h37
-rw-r--r--plugins/websocket/CMakeLists.txt37
-rw-r--r--plugins/websocket/websocket.c723
-rw-r--r--plugins/websocket/ws_config.c163
-rw-r--r--plugins/websocket/ws_proto.c565
-rw-r--r--plugins/websocket/ws_proto.h63
-rw-r--r--plugins/websocket/ws_sha1.c138
-rw-r--r--plugins/websocket/ws_sha1.h9
-rw-r--r--plugins/websocket/ws_tls.c400
-rw-r--r--plugins/websocket/ws_tls.h43
55 files changed, 12932 insertions, 0 deletions
diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt
new file mode 100644
index 0000000..9736302
--- /dev/null
+++ b/plugins/CMakeLists.txt
@@ -0,0 +1,44 @@
+# Link a transport plugin into all HyBBX instance binaries (hybbxd/hybbxsd/hybbxpd).
+function(hybbx_link_plugin_instances plugin_target define_name)
+ foreach(_bin IN ITEMS hybbxd hybbxsd hybbxpd)
+ if(TARGET ${_bin})
+ target_link_libraries(${_bin} PRIVATE ${plugin_target})
+ if(NOT "${define_name}" STREQUAL "")
+ target_compile_definitions(${_bin} PRIVATE ${define_name})
+ endif()
+ endif()
+ endforeach()
+endfunction()
+
+if(HYBBX_PLUGIN_TELNET)
+ add_subdirectory(telnet)
+endif()
+
+if(HYBBX_PLUGIN_PACKET_RADIO)
+ add_subdirectory(packet_radio)
+endif()
+
+if(HYBBX_PLUGIN_ARDOP OR HYBBX_PLUGIN_CRDOP)
+ add_subdirectory(ardop)
+endif()
+
+if(HYBBX_PLUGIN_CRDOP)
+ add_subdirectory(crdop)
+endif()
+
+if(HYBBX_PLUGIN_BAYCOM)
+ add_subdirectory(baycom)
+endif()
+
+if(HYBBX_PLUGIN_MAINS_PROXY)
+ add_subdirectory(mains_proxy)
+ target_compile_definitions(hybbx_core PRIVATE HYBBX_HAVE_PLUGIN_MAINS_PROXY)
+endif()
+
+if(HYBBX_PLUGIN_SSH AND HYBBX_HAVE_LIBSSH)
+ add_subdirectory(ssh)
+endif()
+
+if(HYBBX_PLUGIN_WEBSOCKET)
+ add_subdirectory(websocket)
+endif()
diff --git a/plugins/ardop/CMakeLists.txt b/plugins/ardop/CMakeLists.txt
new file mode 100644
index 0000000..c39ab91
--- /dev/null
+++ b/plugins/ardop/CMakeLists.txt
@@ -0,0 +1,57 @@
+add_library(hybbx_ardop_common STATIC
+ ardop_link.c
+ ardop_host.c
+ ardop_crc.c
+ ardop_config.c
+)
+
+target_include_directories(hybbx_ardop_common
+ PUBLIC
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_ardop_common
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_ardop_common PUBLIC hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_ardop_common PUBLIC amiga)
+endif()
+hybbx_apply_hardening(hybbx_ardop_common)
+
+if(HYBBX_PLUGIN_ARDOP)
+ add_library(hybbx_plugin_ardop STATIC
+ ardop.c
+ )
+
+ target_include_directories(hybbx_plugin_ardop
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+
+ target_compile_definitions(hybbx_plugin_ardop
+ PRIVATE HYBBX_PLUGIN_BUILD
+ )
+
+ target_link_libraries(hybbx_plugin_ardop PRIVATE hybbx_ardop_common)
+ hybbx_link_plugin_instances(hybbx_plugin_ardop HYBBX_HAVE_PLUGIN_ARDOP)
+ hybbx_apply_hardening(hybbx_plugin_ardop)
+
+ if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_plugin_ardop
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ )
+ endif()
+endif()
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_ardop_common
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/ardop/ardop.c b/plugins/ardop/ardop.c
new file mode 100644
index 0000000..e07a158
--- /dev/null
+++ b/plugins/ardop/ardop.c
@@ -0,0 +1,49 @@
+/*
+ * ardop — ARDOP host-client link adapter (external ARDOPC / ardopcf).
+ * INI: [transport.ardop] or [transport.ardopN].
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/ardop.h"
+#include "ardop_link.h"
+
+static const ardop_link_plugin_t g_ardop_link = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .default_link_id = "ardop-link",
+ .modem_hint = "ARDOPC or ardopcf",
+ .parse_config = (ardop_link_config_fn)hybbx_ardop_config_parse,
+ .config_free = (void (*)(void *))hybbx_ardop_config_free,
+ .config_size = sizeof(hybbx_ardop_config_t),
+};
+
+static hybbx_result_t ardop_init(hybbx_service_t *service)
+{
+ return ardop_link_init(service, &g_ardop_link);
+}
+
+static void ardop_shutdown(void)
+{
+ ardop_link_shutdown(&g_ardop_link);
+}
+
+static hybbx_result_t ardop_start(const char *config)
+{
+ return ardop_link_start(&g_ardop_link, config);
+}
+
+static hybbx_result_t ardop_stop(void)
+{
+ return ardop_link_stop(&g_ardop_link);
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_ardop = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .version = 1,
+ .init = ardop_init,
+ .shutdown = ardop_shutdown,
+ .start = ardop_start,
+ .stop = ardop_stop,
+ .write = NULL,
+};
diff --git a/plugins/ardop/ardop_config.c b/plugins/ardop/ardop_config.c
new file mode 100644
index 0000000..71b40dc
--- /dev/null
+++ b/plugins/ardop/ardop_config.c
@@ -0,0 +1,186 @@
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *ardop_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+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 (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_ardop_config_free(hybbx_ardop_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->ardop_host);
+ free(config->mycall);
+ free(config->arq_bandwidth);
+ free(config->peer_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ memset(config, 0, sizeof(*config));
+}
+
+hybbx_result_t hybbx_ardop_config_parse(const char *config,
+ hybbx_ardop_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ardop_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_kv(config, "ardop_host", scratch, sizeof(scratch));
+ out->ardop_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->ardop_port = (unsigned)strtoul(
+ find_kv(config, "ardop_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "8515", NULL, 10);
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ out->mycall = ardop_strdup(value != NULL ? value : "NOCALL");
+
+ value = find_kv(config, "radio_profile", scratch, sizeof(scratch));
+ out->radio_profile = hybbx_crdop_profile_parse(value);
+
+ value = find_kv(config, "arq_bandwidth", scratch, sizeof(scratch));
+ out->arq_bandwidth = ardop_strdup(
+ value != NULL ? value :
+ hybbx_crdop_default_arq_bandwidth(out->radio_profile));
+
+ value = find_kv(config, "peer_call", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->peer_call = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "listen", scratch, sizeof(scratch));
+ out->listen = value == NULL || str_ieq(value, "yes") || str_ieq(value, "1") ||
+ str_ieq(value, "true");
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->circuit_port = (unsigned)strtoul(
+ find_kv(config, "circuit_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "7323", NULL, 10);
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = ardop_strdup(value != NULL ? value : "ardop-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = ardop_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = strtod(value, NULL);
+ }
+
+ if (out->radio_profile == HYBBX_CRDOP_PROFILE_CB &&
+ hybbx_crdop_bandwidth_exceeds_cb(out->arq_bandwidth)) {
+ hybbx_log_warn("[ardop] warning: arq_bandwidth=%s high for CB profile "
+ "(CRDOP Level 2 experimental)",
+ out->arq_bandwidth);
+ }
+
+ if (out->ardop_host == NULL || out->mycall == NULL ||
+ out->arq_bandwidth == NULL || out->circuit_host == NULL ||
+ out->link_id == NULL || out->link_role == NULL) {
+ hybbx_ardop_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_crc.c b/plugins/ardop/ardop_crc.c
new file mode 100644
index 0000000..4cf205a
--- /dev/null
+++ b/plugins/ardop/ardop_crc.c
@@ -0,0 +1,69 @@
+#include "ardop_crc.h"
+
+/* Algorithm from ARDOPC (KN6KB / G8BPQ) — CRC-16-CCITT variant, poly 0x8810. */
+
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length)
+{
+ int int_register = 0xffff;
+ int i;
+ int j;
+ int bit;
+ const int int_poly = 0x8810;
+
+ if (data == NULL || length == 0) {
+ return 0xffffu;
+ }
+
+ for (j = 0; j < (int)length; j++) {
+ int mask = 0x80;
+
+ for (i = 0; i < 8; i++) {
+ bit = data[j] & mask;
+ mask >>= 1;
+
+ if (int_register & 0x8000) {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ int_register = int_register ^ int_poly;
+ } else {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ }
+ }
+ }
+
+ return (unsigned int)int_register;
+}
+
+int ardop_crc16_valid(const unsigned char *data, unsigned short length)
+{
+ unsigned int crc;
+
+ if (data == NULL || length < 3) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)(length - 2));
+ return data[length - 2] == (unsigned char)(crc >> 8) &&
+ data[length - 1] == (unsigned char)(crc & 0xff);
+}
+
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap)
+{
+ unsigned int crc;
+
+ if (data == NULL || length + 2 > cap) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)length);
+ data[length] = (unsigned char)(crc >> 8);
+ data[length + 1] = (unsigned char)(crc & 0xff);
+ return length + 2;
+}
diff --git a/plugins/ardop/ardop_crc.h b/plugins/ardop/ardop_crc.h
new file mode 100644
index 0000000..69c2f07
--- /dev/null
+++ b/plugins/ardop/ardop_crc.h
@@ -0,0 +1,15 @@
+#ifndef ARDOP_CRC_H
+#define ARDOP_CRC_H
+
+#include <stddef.h>
+
+/** ARDOP host/TNC CRC-16 (poly 0x8810, init 0xFFFF) per ARDOPC reference. */
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length);
+
+/** Non-zero when @p length includes trailing 2-byte CRC that validates. */
+int ardop_crc16_valid(const unsigned char *data, unsigned short length);
+
+/** Append CRC16 (MSB, LSB) after @p length bytes; returns new length. */
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap);
+
+#endif /* ARDOP_CRC_H */
diff --git a/plugins/ardop/ardop_host.c b/plugins/ardop/ardop_host.c
new file mode 100644
index 0000000..aeac403
--- /dev/null
+++ b/plugins/ardop/ardop_host.c
@@ -0,0 +1,650 @@
+#include "ardop_host.h"
+#include "ardop_crc.h"
+
+#include "hybbx/socket.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define ARDOP_HOST_RX_MAX 8192u
+#define ARDOP_HOST_CMD_MAX 256u
+#define ARDOP_HOST_FRAME_MAX (HYBBX_ARDOP_DATA_MAX + 64u)
+
+struct ardop_host {
+ hybbx_ardop_config_t cfg;
+ ardop_host_data_fn on_data;
+ ardop_host_event_fn on_event;
+ void *userdata;
+ int ctrl_fd;
+ int data_fd;
+ int tnc_ready;
+ int link_up;
+ char peer_call[16];
+ unsigned char ctrl_rx[ARDOP_HOST_RX_MAX];
+ size_t ctrl_len;
+ unsigned char data_rx[ARDOP_HOST_RX_MAX];
+ size_t data_len;
+ int init_sent;
+ const char *log_tag;
+};
+
+static const char *host_log_tag(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL && host->log_tag[0] != '\0') {
+ return host->log_tag;
+ }
+ return "ardop";
+}
+
+static const char *host_modem_name(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL &&
+ strcmp(host->log_tag, "crdop") == 0) {
+ return "CRDOPC";
+ }
+ return "ARDOPC";
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static void ardop_host_fire_event(ardop_host_t *host, const char *event,
+ const char *detail)
+{
+ if (host != NULL && host->on_event != NULL) {
+ host->on_event(event, detail, host->userdata);
+ }
+}
+
+static int ardop_host_set_socket_opts(int fd)
+{
+ int on = 1;
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int ardop_host_tcp_connect(const char *host, unsigned port)
+{
+ struct sockaddr_in addr;
+ int fd;
+ int rc;
+
+ if (host == NULL || host[0] == '\0' || port == 0) {
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (ardop_host_set_socket_opts(fd) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static hybbx_result_t ardop_host_write_all(int fd, const unsigned char *buf,
+ size_t len)
+{
+ size_t off = 0;
+
+ if (fd < 0 || buf == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (off < len) {
+ ssize_t n = send(fd, buf + 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 ardop_host_send_cmd_frame(ardop_host_t *host,
+ const char *cmd)
+{
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 4];
+ size_t len;
+
+ if (host == NULL || cmd == NULL || host->ctrl_fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(cmd);
+ if (len + 3 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(frame, cmd, len);
+ frame[len++] = '\r';
+ len = ardop_crc16_append(frame, len, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->ctrl_fd, frame, len);
+}
+
+static hybbx_result_t ardop_host_send_rdy(ardop_host_t *host)
+{
+ return ardop_host_send_cmd_frame(host, "RDY");
+}
+
+static void ardop_host_strip_prefix(char *line)
+{
+ if (line == NULL) {
+ return;
+ }
+
+ if (strncmp(line, "c:", 2) == 0 || strncmp(line, "C:", 2) == 0) {
+ memmove(line, line + 2, strlen(line + 2) + 1);
+ }
+}
+
+static void ardop_host_handle_ctrl_line(ardop_host_t *host, char *line)
+{
+ char *sp;
+
+ if (host == NULL || line == NULL) {
+ return;
+ }
+
+ ardop_host_strip_prefix(line);
+
+ if (str_ieq(line, "RDY")) {
+ host->tnc_ready = 1;
+ return;
+ }
+
+ if (strncmp(line, "VERSION", 7) == 0) {
+ hybbx_log_debug("[%s] modem %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "CONNECTED", 9) == 0) {
+ char *tok;
+
+ sp = line + 9;
+ while (*sp == ' ') {
+ sp++;
+ }
+ hybbx_strlcpy(host->peer_call, sp, sizeof(host->peer_call));
+ tok = strchr(host->peer_call, ' ');
+ if (tok != NULL) {
+ *tok = '\0';
+ }
+ host->link_up = 1;
+ ardop_host_fire_event(host, "connected", host->peer_call);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (str_ieq(line, "DISCONNECTED")) {
+ host->link_up = 0;
+ host->peer_call[0] = '\0';
+ ardop_host_fire_event(host, "disconnected", NULL);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "FAULT", 5) == 0 ||
+ strncmp(line, "CRCFAULT", 8) == 0) {
+ hybbx_log_warn("[%s] TNC fault: %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "BUSY", 4) == 0 ||
+ strncmp(line, "NEWSTATE", 8) == 0 ||
+ strncmp(line, "BUFFER", 6) == 0 ||
+ strncmp(line, "STATUS", 6) == 0 ||
+ strncmp(line, "PENDING", 7) == 0 ||
+ strncmp(line, "TARGET", 6) == 0) {
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ (void)ardop_host_send_rdy(host);
+}
+
+static void ardop_host_process_ctrl_rx(ardop_host_t *host)
+{
+ unsigned char *cr;
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 8];
+ size_t msg_len;
+ char line[ARDOP_HOST_CMD_MAX];
+
+ if (host == NULL) {
+ return;
+ }
+
+ for (;;) {
+ cr = memchr(host->ctrl_rx, '\r', host->ctrl_len);
+ if (cr == NULL) {
+ return;
+ }
+
+ msg_len = (size_t)(cr - host->ctrl_rx) + 1u + 2u;
+ if (host->ctrl_len < msg_len) {
+ return;
+ }
+
+ if (msg_len > sizeof(frame)) {
+ host->ctrl_len = 0;
+ return;
+ }
+
+ memcpy(frame, host->ctrl_rx, msg_len);
+ if (!ardop_crc16_valid(frame, (unsigned short)msg_len)) {
+ hybbx_log_warn("[%s] control CRC fault", host_log_tag(host));
+ host->ctrl_len = 0;
+ return;
+ }
+
+ frame[msg_len - 3] = '\0';
+ hybbx_strlcpy(line, (const char *)frame, sizeof(line));
+ ardop_host_handle_ctrl_line(host, line);
+
+ memmove(host->ctrl_rx, host->ctrl_rx + msg_len, host->ctrl_len - msg_len);
+ host->ctrl_len -= msg_len;
+ }
+}
+
+static void ardop_host_process_data_rx(ardop_host_t *host)
+{
+ size_t off = 0;
+
+ if (host == NULL) {
+ return;
+ }
+
+ while (host->data_len >= off + 6u) {
+ const unsigned char *buf = host->data_rx + off;
+ size_t remain = host->data_len - off;
+ size_t payload_len;
+ size_t frame_len;
+ const uint8_t *payload;
+ const char *tag;
+
+ if (buf[0] != 'd' && buf[0] != 'D') {
+ off++;
+ continue;
+ }
+ if (buf[1] != ':') {
+ off++;
+ continue;
+ }
+
+ payload_len = ((size_t)buf[2] << 8) | (size_t)buf[3];
+ if (payload_len < 3 || payload_len > HYBBX_ARDOP_DATA_MAX + 8u) {
+ off++;
+ continue;
+ }
+
+ frame_len = 2u + 2u + payload_len + 2u;
+ if (remain < frame_len) {
+ break;
+ }
+
+ if (!ardop_crc16_valid(buf, (unsigned short)frame_len)) {
+ off++;
+ continue;
+ }
+
+ tag = (const char *)(buf + 4);
+ payload = buf + 7;
+ payload_len -= 3u;
+
+ if (strncmp(tag, "ARQ", 3) == 0 && host->on_data != NULL &&
+ payload_len > 0) {
+ host->on_data(payload, payload_len, host->userdata);
+ }
+
+ (void)ardop_host_send_cmd_frame(host, "RDY");
+ off += frame_len;
+ }
+
+ if (off > 0) {
+ memmove(host->data_rx, host->data_rx + off, host->data_len - off);
+ host->data_len -= off;
+ }
+}
+
+static hybbx_result_t ardop_host_send_init(ardop_host_t *host)
+{
+ char cmd[96];
+ hybbx_result_t rc;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "INITIALIZE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "MYCALL %s",
+ host->cfg.mycall != NULL ? host->cfg.mycall : "NOCALL");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "PROTOCOLMODE ARQ");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "ARQBW %s",
+ host->cfg.arq_bandwidth != NULL ? host->cfg.arq_bandwidth :
+ "500MAX");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host,
+ host->cfg.listen ? "LISTEN TRUE" :
+ "LISTEN FALSE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (host->cfg.peer_call != NULL && host->cfg.peer_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "ARQCALL %s 5", host->cfg.peer_call);
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ host->init_sent = 1;
+ hybbx_log_info("[%s] host init sent (external %s at %s:%u)",
+ host_log_tag(host), host_modem_name(host),
+ host->cfg.ardop_host != NULL ? host->cfg.ardop_host : "?",
+ host->cfg.ardop_port);
+ return HYBBX_OK;
+}
+
+static void ardop_host_read_fd(int fd, unsigned char *buf, size_t *len,
+ size_t cap)
+{
+ ssize_t n;
+
+ if (fd < 0 || buf == NULL || len == NULL || *len >= cap) {
+ return;
+ }
+
+ n = recv(fd, buf + *len, cap - *len, 0);
+ if (n > 0) {
+ *len += (size_t)n;
+ } else if (n == 0) {
+ close(fd);
+ }
+}
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag)
+{
+ ardop_host_t *host;
+
+ if (cfg == NULL) {
+ return NULL;
+ }
+
+ host = calloc(1, sizeof(*host));
+ if (host == NULL) {
+ return NULL;
+ }
+
+ host->cfg = *cfg;
+ host->on_data = on_data;
+ host->on_event = on_event;
+ host->userdata = userdata;
+ host->ctrl_fd = -1;
+ host->data_fd = -1;
+ host->log_tag = log_tag;
+ return host;
+}
+
+void ardop_host_destroy(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ ardop_host_disconnect(host);
+ free(host);
+}
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host)
+{
+ const char *h;
+ unsigned port;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ardop_host_disconnect(host);
+
+ h = host->cfg.ardop_host;
+ if (h == NULL || h[0] == '\0') {
+ h = "127.0.0.1";
+ }
+ port = host->cfg.ardop_port;
+ if (port == 0) {
+ port = HYBBX_ARDOP_DEFAULT_PORT;
+ }
+
+ host->ctrl_fd = ardop_host_tcp_connect(h, port);
+ if (host->ctrl_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s control %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port);
+ return HYBBX_ERR_IO;
+ }
+
+ host->data_fd = ardop_host_tcp_connect(h, port + 1u);
+ if (host->data_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s data %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port + 1u);
+ ardop_host_disconnect(host);
+ return HYBBX_ERR_IO;
+ }
+
+ host->tnc_ready = 0;
+ host->init_sent = 0;
+ hybbx_log_info("[%s] connected to external %s %s:%u (data %u)",
+ host_log_tag(host), host_modem_name(host), h, port, port + 1u);
+
+ (void)ardop_host_send_init(host);
+ {
+ unsigned spin;
+
+ for (spin = 0; spin < 8u; spin++) {
+ ardop_host_poll(host);
+ if (host->tnc_ready) {
+ break;
+ }
+ }
+ }
+ return HYBBX_OK;
+}
+
+void ardop_host_disconnect(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ if (host->ctrl_fd >= 0) {
+ close(host->ctrl_fd);
+ host->ctrl_fd = -1;
+ }
+ if (host->data_fd >= 0) {
+ close(host->data_fd);
+ host->data_fd = -1;
+ }
+
+ host->ctrl_len = 0;
+ host->data_len = 0;
+ host->tnc_ready = 0;
+ host->link_up = 0;
+ host->init_sent = 0;
+ host->peer_call[0] = '\0';
+}
+
+void ardop_host_poll(ardop_host_t *host)
+{
+ struct pollfd pfds[2];
+ int count = 0;
+ int pr;
+
+ if (host == NULL || host->ctrl_fd < 0) {
+ return;
+ }
+
+ pfds[0].fd = host->ctrl_fd;
+ pfds[0].events = POLLIN;
+ count = 1;
+
+ if (host->data_fd >= 0) {
+ pfds[1].fd = host->data_fd;
+ pfds[1].events = POLLIN;
+ count = 2;
+ }
+
+ pr = poll(pfds, (nfds_t)count, 0);
+ if (pr <= 0) {
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+ return;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->ctrl_fd, host->ctrl_rx, &host->ctrl_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_ctrl_rx(host);
+ }
+
+ if (count > 1 && (pfds[1].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->data_fd, host->data_rx, &host->data_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_data_rx(host);
+ }
+
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+}
+
+int ardop_host_link_up(const ardop_host_t *host)
+{
+ return host != NULL && host->link_up;
+}
+
+int ardop_host_modem_connected(const ardop_host_t *host)
+{
+ return host != NULL && host->ctrl_fd >= 0;
+}
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ unsigned char frame[ARDOP_HOST_FRAME_MAX];
+ size_t frame_len;
+
+ if (host == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!host->link_up || host->data_fd < 0) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (len > HYBBX_ARDOP_DATA_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len + 8 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 'D';
+ frame[1] = ':';
+ frame[2] = (unsigned char)((len >> 8) & 0xff);
+ frame[3] = (unsigned char)(len & 0xff);
+ memcpy(frame + 4, data, len);
+
+ frame_len = 4u + len;
+ frame_len = ardop_crc16_append(frame, frame_len, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->data_fd, frame, frame_len);
+}
diff --git a/plugins/ardop/ardop_host.h b/plugins/ardop/ardop_host.h
new file mode 100644
index 0000000..4aee53d
--- /dev/null
+++ b/plugins/ardop/ardop_host.h
@@ -0,0 +1,47 @@
+#ifndef ARDOP_HOST_H
+#define ARDOP_HOST_H
+
+/**
+ * Minimal ARDOP TNC Host Interface client (TCP control + data ports).
+ * Implements the subset HyBBX needs — see docs/MANUAL.md § ARDOP.
+ */
+
+#include "hybbx/types.h"
+#include "hybbx/ardop.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ardop_host ardop_host_t;
+
+typedef void (*ardop_host_data_fn)(const uint8_t *data, size_t len, void *ctx);
+typedef void (*ardop_host_event_fn)(const char *event, const char *detail,
+ void *ctx);
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag);
+void ardop_host_destroy(ardop_host_t *host);
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host);
+void ardop_host_disconnect(ardop_host_t *host);
+
+/** Drive I/O and state machine; call periodically from plugin poll thread. */
+void ardop_host_poll(ardop_host_t *host);
+
+int ardop_host_link_up(const ardop_host_t *host);
+
+/** Control TCP session to external ARDOPC is open. */
+int ardop_host_modem_connected(const ardop_host_t *host);
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_HOST_H */
diff --git a/plugins/ardop/ardop_link.c b/plugins/ardop/ardop_link.c
new file mode 100644
index 0000000..e0500b9
--- /dev/null
+++ b/plugins/ardop/ardop_link.c
@@ -0,0 +1,446 @@
+#include "ardop_link.h"
+#include "ardop_host.h"
+
+#include "hybbx/ardop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/crdop.h"
+#include "hybbx/log.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#endif
+
+static const ardop_link_plugin_t *g_plugin;
+static hybbx_ardop_config_t g_active_config;
+static ardop_host_t *g_host;
+static int g_circuit_fd = -1;
+static hybbx_circuit_decoder_t g_circuit_dec;
+static pthread_t g_poll_thread;
+static volatile int g_running;
+static int g_poll_thread_started;
+static volatile int g_circuit_flow_paused;
+static volatile int g_circuit_flow_cancel;
+static unsigned g_modem_reconnect_backoff_ms;
+
+static const char *link_tag(void)
+{
+ return g_plugin != NULL && g_plugin->name != NULL ? g_plugin->name : "ardop";
+}
+
+static int circuit_uplink_allowed(void)
+{
+ return !g_circuit_flow_paused && !g_circuit_flow_cancel;
+}
+
+static hybbx_result_t circuit_uplink_terminal(const uint8_t *payload, size_t len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ if (g_circuit_fd < 0 || len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!circuit_uplink_allowed()) {
+ return HYBBX_OK;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(g_circuit_fd, hbx, hbx_len);
+}
+
+static void on_modem_data(const uint8_t *data, size_t len, void *userdata)
+{
+ (void)userdata;
+
+ if (len == 0 || g_circuit_fd < 0 || !circuit_uplink_allowed()) {
+ return;
+ }
+
+ (void)circuit_uplink_terminal(data, len);
+}
+
+static void on_modem_event(const char *event, const char *detail, void *userdata)
+{
+ (void)userdata;
+
+ if (event == NULL) {
+ return;
+ }
+
+ if (strcmp(event, "connected") == 0) {
+ if (detail != NULL && detail[0] != '\0') {
+ hybbx_log_stats("[%s] RF link up (%s)", link_tag(), detail);
+ } else {
+ hybbx_log_stats("[%s] RF link up", link_tag());
+ }
+ g_modem_reconnect_backoff_ms = 1000u;
+ return;
+ }
+
+ if (strcmp(event, "disconnected") == 0) {
+ hybbx_log_stats("[%s] RF link down", link_tag());
+ }
+}
+
+static void on_circuit_flow_ctrl(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+ (void)userdata;
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ g_circuit_flow_paused = 1;
+ hybbx_log_stats("[%s] circuit flow pause (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ g_circuit_flow_paused = 0;
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ hybbx_log_stats("[%s] circuit flow break (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ g_circuit_flow_cancel = 1;
+ hybbx_log_warn("[%s] circuit flow cancel (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ g_circuit_flow_paused = 0;
+ hybbx_log_stats("[%s] circuit flow resume (%s)", link_tag(), reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ (void)flags;
+ (void)userdata;
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ on_circuit_flow_ctrl(proto, flags, payload, len, userdata);
+ return;
+ }
+
+ if (g_host == NULL || len == 0 || g_circuit_flow_paused) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_TERMINAL) {
+ return;
+ }
+
+ if (!ardop_host_link_up(g_host)) {
+ return;
+ }
+
+ (void)ardop_host_send_data(g_host, payload, len);
+}
+
+static void link_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t link_connect_circuit(void);
+
+static void link_maybe_reconnect_modem(void)
+{
+ static unsigned ticks;
+
+ if (g_host == NULL) {
+ return;
+ }
+
+ if (ardop_host_modem_connected(g_host)) {
+ ticks = 0;
+ return;
+ }
+
+ ticks++;
+ if (ticks < (g_modem_reconnect_backoff_ms / 20u)) {
+ return;
+ }
+
+ ticks = 0;
+ if (ardop_host_connect(g_host) != HYBBX_OK) {
+ if (g_modem_reconnect_backoff_ms < 30000u) {
+ g_modem_reconnect_backoff_ms += 1000u;
+ }
+ }
+}
+
+static void *link_poll_thread(void *arg)
+{
+ uint8_t buf[512];
+
+ (void)arg;
+
+ while (g_running) {
+ if (g_host != NULL) {
+ ardop_host_poll(g_host);
+ link_maybe_reconnect_modem();
+ }
+
+ if (g_circuit_fd >= 0) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(g_circuit_fd, buf,
+ sizeof(buf), &read_len);
+ if (rc == HYBBX_OK && read_len > 0) {
+ hybbx_circuit_decoder_feed(&g_circuit_dec, buf, read_len,
+ on_circuit_downlink, NULL);
+ }
+ } else {
+ static unsigned circuit_ticks;
+
+ circuit_ticks++;
+ if (circuit_ticks >= 50u) {
+ circuit_ticks = 0;
+ (void)link_connect_circuit();
+ }
+ }
+
+ if (g_circuit_flow_cancel) {
+ g_circuit_flow_cancel = 0;
+ g_circuit_flow_paused = 0;
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+ (void)link_connect_circuit();
+ }
+
+ link_poll_sleep_ms(20);
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t link_connect_circuit(void)
+{
+ const char *host = g_active_config.circuit_host;
+ unsigned port = g_active_config.circuit_port;
+ const char *link_id = g_active_config.link_id;
+ unsigned attempt;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = g_plugin != NULL && g_plugin->default_link_id != NULL ?
+ g_plugin->default_link_id : "ardop-link";
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &g_circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ if (g_active_config.link_password != NULL &&
+ g_active_config.link_password[0] != '\0') {
+ const char *link_role = g_active_config.link_role;
+ hybbx_circuit_link_qos_t qos;
+ char freq_buf[16];
+ const char *freq_ptr = NULL;
+
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ memset(&qos, 0, sizeof(qos));
+ qos.bandwidth = "low";
+ qos.duplex = 1;
+ if (g_active_config.frequency_mhz > 0.0) {
+ snprintf(freq_buf, sizeof(freq_buf), "%.3f",
+ g_active_config.frequency_mhz);
+ freq_ptr = freq_buf;
+ }
+ qos.frequency_mhz = freq_ptr;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ g_circuit_fd, g_active_config.link_password,
+ link_role, link_id, &qos);
+ if (rc != HYBBX_OK) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ link_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[%s] linked to internal circuit %s:%u (HBX)",
+ link_tag(), host, port);
+ return HYBBX_OK;
+ }
+ link_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[%s] could not connect to internal circuit %s:%u",
+ link_tag(), host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void link_shutdown(void)
+{
+ g_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+
+ if (g_host != NULL) {
+ ardop_host_destroy(g_host);
+ g_host = NULL;
+ }
+
+ if (g_plugin != NULL && g_plugin->config_free != NULL) {
+ g_plugin->config_free(&g_active_config);
+ }
+}
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin)
+{
+ (void)service;
+ g_plugin = plugin;
+ g_host = NULL;
+ g_circuit_fd = -1;
+ g_running = 0;
+ g_circuit_flow_paused = 0;
+ g_circuit_flow_cancel = 0;
+ g_modem_reconnect_backoff_ms = 1000u;
+ return HYBBX_OK;
+}
+
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ link_shutdown();
+}
+
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config)
+{
+ hybbx_result_t rc;
+ const char *modem_hint;
+
+ if (plugin == NULL || plugin->parse_config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ g_plugin = plugin;
+ link_shutdown();
+ memset(&g_active_config, 0, sizeof(g_active_config));
+ g_poll_thread = (pthread_t)0;
+
+ rc = plugin->parse_config(config, &g_active_config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] invalid configuration", link_tag());
+ return rc;
+ }
+
+ hybbx_log_info("[%s] external modem %s:%u mycall=%s arq=%s profile=%s circuit=%s:%u "
+ "link_id=%s",
+ link_tag(),
+ g_active_config.ardop_host,
+ g_active_config.ardop_port,
+ g_active_config.mycall,
+ g_active_config.arq_bandwidth,
+ hybbx_crdop_profile_name(g_active_config.radio_profile),
+ g_active_config.circuit_host,
+ g_active_config.circuit_port,
+ g_active_config.link_id != NULL ? g_active_config.link_id : "?");
+
+ if (g_active_config.radio_profile == HYBBX_CRDOP_PROFILE_CB) {
+ hybbx_log_info("[%s] CB half-duplex profile (experimental Level 2)",
+ link_tag());
+ }
+ if (g_active_config.frequency_mhz > 0.0) {
+ hybbx_log_info("[%s] frequency_mhz=%.3f", link_tag(),
+ g_active_config.frequency_mhz);
+ }
+
+ modem_hint = plugin->modem_hint != NULL ? plugin->modem_hint : "ARDOPC";
+ hybbx_log_info("[%s] operator must run external %s separately (e.g. ardopc TCPIP %u)",
+ link_tag(), modem_hint, g_active_config.ardop_port);
+
+ g_host = ardop_host_create(&g_active_config, on_modem_data, on_modem_event,
+ NULL, link_tag());
+ if (g_host == NULL) {
+ link_shutdown();
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = ardop_host_connect(g_host);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] external modem not reachable yet — will retry in poll loop",
+ link_tag());
+ }
+
+ rc = link_connect_circuit();
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] circuit hub not reachable — HBX uplink deferred",
+ link_tag());
+ }
+
+ g_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, link_poll_thread, NULL) != 0) {
+ hybbx_log_warn("[%s] poll thread failed", link_tag());
+ link_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ hybbx_log_info("[%s] stop", link_tag());
+ link_shutdown();
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_link.h b/plugins/ardop/ardop_link.h
new file mode 100644
index 0000000..0f10dba
--- /dev/null
+++ b/plugins/ardop/ardop_link.h
@@ -0,0 +1,37 @@
+#ifndef ARDOP_LINK_H
+#define ARDOP_LINK_H
+
+#include "hybbx/plugin.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+
+typedef hybbx_result_t (*ardop_link_config_fn)(const char *config,
+ void *config_out);
+
+typedef struct ardop_link_plugin {
+ const char *name;
+ hybbx_transport_kind_t kind;
+ const char *default_link_id;
+ const char *modem_hint;
+ ardop_link_config_fn parse_config;
+ void (*config_free)(void *config_out);
+ size_t config_size;
+} ardop_link_plugin_t;
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin);
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin);
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config);
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_LINK_H */
diff --git a/plugins/baycom/CMakeLists.txt b/plugins/baycom/CMakeLists.txt
new file mode 100644
index 0000000..8a2dc2d
--- /dev/null
+++ b/plugins/baycom/CMakeLists.txt
@@ -0,0 +1,36 @@
+add_library(hybbx_plugin_baycom STATIC
+ baycom.c
+ baycom_config.c
+ baycom_modem.c
+ baycom_kernel.c
+ baycom_kiss.c
+ ../packet_radio/ax25.c
+ ../packet_radio/kiss.c
+ ../packet_radio/serial_port.c
+)
+
+target_include_directories(hybbx_plugin_baycom
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_baycom
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_plugin_baycom PRIVATE hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_plugin_baycom PRIVATE amiga)
+endif()
+hybbx_link_plugin_instances(hybbx_plugin_baycom HYBBX_HAVE_PLUGIN_BAYCOM)
+hybbx_apply_hardening(hybbx_plugin_baycom)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_baycom
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/baycom/baycom.c b/plugins/baycom/baycom.c
new file mode 100644
index 0000000..e2fb5c9
--- /dev/null
+++ b/plugins/baycom/baycom.c
@@ -0,0 +1,623 @@
+/*
+ * baycom — BayCom PR-Stack link adapter (Linux kernel SER12/PAR96/EPP or KISS serial).
+ * INI: [transport.baycom] or [transport.baycomN]. See docs/BAYCOM.md.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/baycom.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/limits.h"
+#include "hybbx/max25.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "hybbx/ax25.h"
+
+#include "baycom_modem.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#endif
+
+typedef struct baycom_instance {
+ hybbx_baycom_config_t config;
+ baycom_modem_t *modem;
+ int circuit_fd;
+ hybbx_circuit_decoder_t circuit_dec;
+ volatile int flow_paused;
+ volatile int flow_cancel;
+ unsigned circuit_reconnect_polls;
+ unsigned index;
+} baycom_instance_t;
+
+static hybbx_service_t *g_service;
+
+static baycom_instance_t g_instances[HYBBX_BAYCOM_MAX_INSTANCES];
+static unsigned g_instance_count;
+static pthread_t g_poll_thread;
+static volatile int g_running;
+static int g_poll_thread_started;
+
+static void baycom_poll_sleep_ms(unsigned ms);
+static hybbx_result_t instance_connect_circuit(baycom_instance_t *inst);
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata);
+
+static int instance_circuit_uplink_allowed(const baycom_instance_t *inst)
+{
+ return inst != NULL && !inst->flow_paused && !inst->flow_cancel;
+}
+
+static hybbx_result_t instance_circuit_uplink(baycom_instance_t *inst,
+ const uint8_t *frame,
+ size_t frame_len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ if (inst == NULL || inst->circuit_fd < 0 || frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return HYBBX_OK;
+ }
+
+ hbx_len = hybbx_circuit_encode_ax25(frame, frame_len, HYBBX_CIRCUIT_FLAG_RX,
+ hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+}
+
+static void instance_format_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (addr == NULL || addr->call[0] == '\0') {
+ return;
+ }
+
+ if (addr->ssid > 0u) {
+ unsigned ssid = addr->ssid;
+
+ if (ssid > HYBBX_AX25_SSID_MAX) {
+ ssid = HYBBX_AX25_SSID_MAX;
+ }
+ snprintf(out, out_len, "%s-%u", addr->call, ssid);
+ } else {
+ hybbx_strlcpy(out, addr->call, out_len);
+ }
+}
+
+static void instance_note_rf_activity(const baycom_instance_t *inst)
+{
+ hybbx_circuit_hub_t *hub;
+ const char *link_id;
+
+ if (inst == NULL || g_service == NULL) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(g_service);
+ link_id = inst->config.link_id;
+ if (hub != NULL && link_id != NULL && link_id[0] != '\0') {
+ hybbx_circuit_hub_note_rf_activity(hub, link_id);
+ }
+}
+
+static void instance_log_rf_tx(const baycom_instance_t *inst,
+ hybbx_result_t rc,
+ const hybbx_ax25_path_t *path,
+ size_t payload_len)
+{
+ char src[HYBBX_AX25_CALL_MAX + 8];
+ char dst[HYBBX_AX25_CALL_MAX + 8];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_format_call(path != NULL ? &path->source : NULL, src, sizeof(src));
+ instance_format_call(path != NULL ? &path->dest : NULL, dst, sizeof(dst));
+
+ if (rc == HYBBX_OK) {
+ hybbx_log_stats("[baycom%u] RF TX %s>%s (%zu bytes)",
+ inst->index + 1, src, dst, payload_len);
+ } else {
+ hybbx_log_warn("[baycom%u] RF TX failed %s>%s (%zu bytes, rc=%d)",
+ inst->index + 1, src, dst, payload_len, (int)rc);
+ }
+}
+
+static void instance_circuit_disconnect(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ inst->circuit_reconnect_polls = 0;
+}
+
+static void instance_circuit_reconnect(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_circuit_disconnect(inst);
+ baycom_poll_sleep_ms(250);
+ if (instance_connect_circuit(inst) == HYBBX_OK) {
+ hybbx_log_info("[baycom%u] circuit reconnected", inst->index + 1);
+ }
+}
+
+static void instance_drain_circuit(baycom_instance_t *inst)
+{
+ uint8_t buf[512];
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return;
+ }
+
+ for (;;) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(inst->circuit_fd, buf,
+ sizeof(buf), &read_len);
+
+ if (rc == HYBBX_ERR_IO) {
+ hybbx_log_warn("[baycom%u] circuit disconnected — reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ return;
+ }
+ if (rc != HYBBX_OK || read_len == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&inst->circuit_dec, buf, read_len,
+ on_circuit_downlink, inst);
+ }
+}
+
+static void on_modem_ax25_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ baycom_instance_t *inst = (baycom_instance_t *)userdata;
+
+ if (len == 0 || inst == NULL) {
+ return;
+ }
+
+ (void)instance_circuit_uplink(inst, frame, len);
+}
+
+static void baycom_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t instance_connect_circuit(baycom_instance_t *inst)
+{
+ const char *host;
+ unsigned port;
+ unsigned attempt;
+
+ if (inst == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = inst->config.circuit_host;
+ port = inst->config.circuit_port;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &inst->circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ if (inst->config.link_password != NULL &&
+ inst->config.link_password[0] != '\0') {
+ const char *link_id = inst->config.link_id;
+ const char *link_role = inst->config.link_role;
+ hybbx_circuit_link_qos_t qos;
+
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "baycom-link";
+ }
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ memset(&qos, 0, sizeof(qos));
+ qos.baud = inst->config.radio_baud;
+ qos.duplex = inst->config.full_duplex ? 2 : 1;
+ qos.bandwidth = "low";
+ qos.frequency_mhz = inst->config.frequency_mhz;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ inst->circuit_fd, inst->config.link_password,
+ link_role, link_id, &qos);
+ if (rc != HYBBX_OK) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ baycom_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[baycom%u] linked to internal circuit %s:%u (HBX)",
+ inst->index + 1, host, port);
+ return HYBBX_OK;
+ }
+ baycom_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[baycom%u] could not connect to internal circuit %s:%u",
+ inst->index + 1, host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void instance_on_circuit_flow_ctrl(baycom_instance_t *inst,
+ const uint8_t *payload, size_t len)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ inst->flow_paused = 1;
+ hybbx_log_stats("[baycom%u] circuit flow pause (%s)", inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ inst->flow_paused = 0;
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ hybbx_log_stats("[baycom%u] circuit flow break (%s)", inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ inst->flow_cancel = 1;
+ hybbx_log_warn("[baycom%u] circuit flow cancel (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ inst->flow_paused = 0;
+ hybbx_log_stats("[baycom%u] circuit flow resume (%s)", inst->index + 1, reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ baycom_instance_t *inst = (baycom_instance_t *)userdata;
+
+ (void)flags;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ instance_on_circuit_flow_ctrl(inst, payload, len);
+ return;
+ }
+
+ if (inst->modem == NULL || len == 0 || inst->flow_paused) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_AX25) {
+ hybbx_result_t rc = baycom_modem_send_frame(inst->modem, payload, len);
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path, ui, sizeof(ui));
+
+ if (rc == HYBBX_OK) {
+ instance_note_rf_activity(inst);
+ if (ui_len > 0) {
+ instance_log_rf_tx(inst, rc, &path, ui_len);
+ }
+ } else {
+ hybbx_log_warn("[baycom%u] RF TX frame failed (rc=%d, %zu bytes)",
+ inst->index + 1, (int)rc, len);
+ }
+ }
+}
+
+static void instance_shutdown(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->modem != NULL) {
+ baycom_modem_close(inst->modem);
+ inst->modem = NULL;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+
+ hybbx_baycom_config_free(&inst->config);
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+}
+
+static void *baycom_poll_thread(void *arg)
+{
+ unsigned i;
+
+ (void)arg;
+
+ while (g_running) {
+ for (i = 0; i < g_instance_count; i++) {
+ baycom_instance_t *inst = &g_instances[i];
+
+ if (inst->modem != NULL) {
+ (void)baycom_modem_poll(inst->modem);
+ }
+
+ instance_drain_circuit(inst);
+
+ if (inst->circuit_fd < 0) {
+ inst->circuit_reconnect_polls++;
+ if (inst->circuit_reconnect_polls >= 50u) {
+ inst->circuit_reconnect_polls = 0;
+ instance_circuit_reconnect(inst);
+ }
+ } else {
+ inst->circuit_reconnect_polls = 0;
+ }
+
+ if (inst->flow_cancel) {
+ inst->flow_cancel = 0;
+ inst->flow_paused = 0;
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ (void)instance_connect_circuit(inst);
+ }
+ }
+
+ baycom_poll_sleep_ms(20);
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t instance_start(baycom_instance_t *inst,
+ const char *config,
+ unsigned index)
+{
+ hybbx_result_t rc;
+
+ if (inst == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+ inst->index = index;
+
+ rc = hybbx_baycom_config_parse(config, &inst->config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom%u] invalid configuration", index + 1);
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ {
+ char msg[512];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ "[baycom%u] backend=%s mode=%s",
+ index + 1,
+ hybbx_baycom_backend_name(inst->config.backend),
+ hybbx_baycom_modem_mode_name(inst->config.mode));
+
+ if (inst->config.backend == HYBBX_BAYCOM_BACKEND_KERNEL) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " interface=%s module=%s iobase=0x%x irq=%u radio_baud=%u",
+ inst->config.interface != NULL ? inst->config.interface : "?",
+ inst->config.kernel_module != NULL ? inst->config.kernel_module
+ : "?",
+ inst->config.iobase, inst->config.irq, inst->config.radio_baud);
+ } else {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " device=%s serial_baud=%u",
+ inst->config.device != NULL ? inst->config.device : "?",
+ inst->config.serial_baud);
+ }
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " circuit=%s:%u (HBX over internal TCP)",
+ inst->config.circuit_host, inst->config.circuit_port);
+ hybbx_log_info("%s", msg);
+ }
+
+ if (inst->config.frequency_mhz != NULL &&
+ inst->config.frequency_mhz[0] != '\0') {
+ hybbx_log_info("[baycom%u] frequency_mhz=%s", index + 1,
+ inst->config.frequency_mhz);
+ }
+
+ rc = instance_connect_circuit(inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ rc = baycom_modem_open(&inst->modem, &inst->config, on_modem_ax25_frame,
+ inst);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom%u] modem open failed (%s)", index + 1,
+ hybbx_result_name(rc));
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t baycom_init(hybbx_service_t *service)
+{
+ g_service = service;
+ g_instance_count = 0;
+ g_running = 0;
+ return HYBBX_OK;
+}
+
+static void baycom_shutdown(void)
+{
+ unsigned i;
+
+ g_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ for (i = 0; i < g_instance_count; i++) {
+ instance_shutdown(&g_instances[i]);
+ }
+ g_instance_count = 0;
+}
+
+static hybbx_result_t baycom_start(const char *config)
+{
+ hybbx_max25_config_t max25;
+ hybbx_result_t rc = HYBBX_OK;
+ unsigned started = 0;
+ const char *cursor;
+ const char *cursor_after_max25;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ baycom_shutdown();
+ g_poll_thread = (pthread_t)0;
+
+ cursor_after_max25 = hybbx_max25_config_skip_prefix(config, &max25);
+ if (max25.check && cursor_after_max25 != config) {
+ rc = hybbx_max25_wait_ready(&max25);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] local RF requires max25d — continuing without BayCom");
+ return HYBBX_OK;
+ }
+ }
+
+ cursor = cursor_after_max25;
+ while (*cursor != '\0' && g_instance_count < HYBBX_BAYCOM_MAX_INSTANCES) {
+ char scratch[4096];
+ const char *sep = strchr(cursor, HYBBX_BAYCOM_INSTANCE_SEP);
+ size_t chunk_len;
+
+ if (sep != NULL) {
+ chunk_len = (size_t)(sep - cursor);
+ } else {
+ chunk_len = strlen(cursor);
+ }
+
+ if (chunk_len >= sizeof(scratch)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, cursor, chunk_len);
+ scratch[chunk_len] = '\0';
+
+ rc = instance_start(&g_instances[g_instance_count], scratch,
+ g_instance_count);
+ if (rc == HYBBX_OK) {
+ g_instance_count++;
+ started++;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ if (started == 0) {
+ /* Soft-fail: keep HyBBX up (TNCs/telnet/…) if BayCom/based open fails. */
+ hybbx_log_warn("[baycom] no instances started (%s) — continuing without BayCom",
+ rc != HYBBX_OK ? hybbx_result_name(rc) : "invalid");
+ return HYBBX_OK;
+ }
+
+ g_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, baycom_poll_thread, NULL) != 0) {
+ baycom_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t baycom_stop(void)
+{
+ baycom_shutdown();
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_baycom = {
+ .name = "baycom",
+ .kind = HYBBX_TRANSPORT_BAYCOM,
+ .version = 1,
+ .init = baycom_init,
+ .shutdown = baycom_shutdown,
+ .start = baycom_start,
+ .stop = baycom_stop,
+ .write = NULL,
+};
diff --git a/plugins/baycom/baycom_config.c b/plugins/baycom/baycom_config.c
new file mode 100644
index 0000000..4e740f1
--- /dev/null
+++ b/plugins/baycom/baycom_config.c
@@ -0,0 +1,395 @@
+#include "hybbx/baycom.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static char *baycom_normalize_kiss_path(const char *device)
+{
+ static const struct {
+ const char *from;
+ const char *to;
+ } map[] = {
+ {"/tmp/bcpr/", "/tmp/max25-bcpr/"},
+ {"/tmp/bcpr", "/tmp/max25-bcpr"},
+ {"/var/run/bcpr/", "/tmp/max25-bcpr/"},
+ {"/var/run/bcpr", "/tmp/max25-bcpr"},
+ };
+ size_t i;
+ size_t flen;
+ char *norm;
+
+ if (device == NULL || device[0] == '\0') {
+ return NULL;
+ }
+
+ for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) {
+ flen = strlen(map[i].from);
+ if (strncmp(device, map[i].from, flen) != 0) {
+ continue;
+ }
+ if (device[flen] != '\0' && device[flen] != '/') {
+ continue;
+ }
+ norm = malloc(strlen(map[i].to) + strlen(device + flen) + 1);
+ if (norm == NULL) {
+ return baycom_strdup(device);
+ }
+ snprintf(norm, strlen(map[i].to) + strlen(device + flen) + 1, "%s%s",
+ map[i].to, device + flen);
+ return norm;
+ }
+
+ return baycom_strdup(device);
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+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 (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+const char *hybbx_baycom_backend_name(hybbx_baycom_backend_t backend)
+{
+ switch (backend) {
+ case HYBBX_BAYCOM_BACKEND_KISS:
+ return "kiss";
+ case HYBBX_BAYCOM_BACKEND_KERNEL:
+ default:
+ return "kernel";
+ }
+}
+
+const char *hybbx_baycom_modem_mode_name(hybbx_baycom_modem_mode_t mode)
+{
+ switch (mode) {
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ return "ser12*";
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ return "ser12+";
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ return "ser12hdx";
+ case HYBBX_BAYCOM_MODE_PAR96:
+ return "par96";
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ return "par96*";
+ case HYBBX_BAYCOM_MODE_EPP:
+ return "epp";
+ case HYBBX_BAYCOM_MODE_SER12:
+ default:
+ return "ser12";
+ }
+}
+
+static hybbx_baycom_backend_t parse_backend(const char *value)
+{
+ if (value == NULL || str_ieq(value, "kernel") || str_ieq(value, "hdlc") ||
+ str_ieq(value, "prstack") || str_ieq(value, "l2")) {
+ return HYBBX_BAYCOM_BACKEND_KERNEL;
+ }
+
+ if (str_ieq(value, "kiss") || str_ieq(value, "serial")) {
+ return HYBBX_BAYCOM_BACKEND_KISS;
+ }
+
+ return HYBBX_BAYCOM_BACKEND_KERNEL;
+}
+
+static hybbx_baycom_modem_mode_t parse_mode(const char *value)
+{
+ if (value == NULL || str_ieq(value, "ser12")) {
+ return HYBBX_BAYCOM_MODE_SER12;
+ }
+ if (str_ieq(value, "ser12*") || str_ieq(value, "ser12_softdcd")) {
+ return HYBBX_BAYCOM_MODE_SER12_SOFTDCD;
+ }
+ if (str_ieq(value, "ser12+") || str_ieq(value, "ser12_inv")) {
+ return HYBBX_BAYCOM_MODE_SER12_INV;
+ }
+ if (str_ieq(value, "ser12hdx") || str_ieq(value, "ser12_hdx") ||
+ str_ieq(value, "hdx")) {
+ return HYBBX_BAYCOM_MODE_SER12HDX;
+ }
+ if (str_ieq(value, "par96")) {
+ return HYBBX_BAYCOM_MODE_PAR96;
+ }
+ if (str_ieq(value, "par96*") || str_ieq(value, "par96_softdcd")) {
+ return HYBBX_BAYCOM_MODE_PAR96_SOFTDCD;
+ }
+ if (str_ieq(value, "epp") || str_ieq(value, "par97") ||
+ str_ieq(value, "baycom_epp")) {
+ return HYBBX_BAYCOM_MODE_EPP;
+ }
+
+ return HYBBX_BAYCOM_MODE_SER12_SOFTDCD;
+}
+
+void hybbx_baycom_config_free(hybbx_baycom_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->interface);
+ free(config->kernel_module);
+ free(config->device);
+ free(config->mycall);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ free(config->frequency_mhz);
+ memset(config, 0, sizeof(*config));
+}
+
+static void apply_mode_defaults(hybbx_baycom_config_t *out)
+{
+ if (out->interface == NULL) {
+ switch (out->mode) {
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ out->interface = baycom_strdup("bcsh0");
+ break;
+ case HYBBX_BAYCOM_MODE_PAR96:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ out->interface = baycom_strdup("bcp0");
+ break;
+ case HYBBX_BAYCOM_MODE_EPP:
+ out->interface = baycom_strdup("bce0");
+ break;
+ case HYBBX_BAYCOM_MODE_SER12:
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ default:
+ out->interface = baycom_strdup(HYBBX_BAYCOM_DEFAULT_INTERFACE);
+ break;
+ }
+ }
+
+ if (out->kernel_module == NULL) {
+ switch (out->mode) {
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ out->kernel_module = baycom_strdup("baycom_ser_hdx");
+ break;
+ case HYBBX_BAYCOM_MODE_PAR96:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ out->kernel_module = baycom_strdup("baycom_par");
+ break;
+ case HYBBX_BAYCOM_MODE_EPP:
+ out->kernel_module = baycom_strdup("baycom_epp");
+ break;
+ default:
+ out->kernel_module = baycom_strdup("baycom_ser_fdx");
+ break;
+ }
+ }
+
+ if (out->device == NULL) {
+ if (out->backend == HYBBX_BAYCOM_BACKEND_KISS) {
+ out->device = baycom_strdup("/tmp/max25-bcpr/kiss-bc0");
+ } else {
+ out->device = baycom_strdup("/dev/ttyS0");
+ }
+ }
+}
+
+hybbx_result_t hybbx_baycom_config_parse(const char *config,
+ hybbx_baycom_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_baycom_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_kv(config, "backend", scratch, sizeof(scratch));
+ out->backend = parse_backend(value);
+
+ value = find_kv(config, "mode", scratch, sizeof(scratch));
+ out->mode = parse_mode(value);
+
+ value = find_kv(config, "interface", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->interface = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "kernel_module", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->kernel_module = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "iobase", scratch, sizeof(scratch));
+ out->iobase = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_IOBASE;
+
+ value = find_kv(config, "irq", scratch, sizeof(scratch));
+ out->irq = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_IRQ;
+
+ value = find_kv(config, "radio_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "baud", scratch, sizeof(scratch));
+ }
+ out->radio_baud = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_RADIO_BAUD;
+
+ value = find_kv(config, "kernel_autoload", scratch, sizeof(scratch));
+ out->kernel_autoload = value != NULL ? hybbx_parse_bool(value, 0) : 0;
+
+ value = find_kv(config, "txdelay", scratch, sizeof(scratch));
+ out->txdelay = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 30u;
+
+ value = find_kv(config, "txtail", scratch, sizeof(scratch));
+ out->txtail = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 1u;
+
+ value = find_kv(config, "slot", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "slottime", scratch, sizeof(scratch));
+ }
+ out->slot = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 10u;
+
+ value = find_kv(config, "persist", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "ppersist", scratch, sizeof(scratch));
+ }
+ out->persist = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 128u;
+
+ value = find_kv(config, "full_duplex", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->full_duplex = hybbx_parse_bool(value, 0);
+ } else {
+ value = find_kv(config, "duplex", scratch, sizeof(scratch));
+ out->full_duplex = value != NULL && str_ieq(value, "full");
+ }
+
+ value = find_kv(config, "device", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->device = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "serial_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "host_baud", scratch, sizeof(scratch));
+ }
+ out->serial_baud = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 1200u;
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->mycall = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = baycom_strdup(value != NULL ? value : "127.0.0.1");
+
+ value = find_kv(config, "circuit_port", scratch, sizeof(scratch));
+ out->circuit_port = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_CIRCUIT_DEFAULT_PORT;
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = baycom_strdup(value != NULL ? value : "baycom-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ out->link_password = baycom_strdup(value != NULL ? value : "");
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = baycom_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->frequency_mhz = baycom_strdup(value);
+ }
+
+ apply_mode_defaults(out);
+
+ if (out->backend == HYBBX_BAYCOM_BACKEND_KISS && out->device != NULL) {
+ char *norm = baycom_normalize_kiss_path(out->device);
+
+ if (norm != NULL) {
+ free(out->device);
+ out->device = norm;
+ }
+ }
+
+ if (out->circuit_host == NULL || out->link_id == NULL) {
+ hybbx_baycom_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/baycom/baycom_kernel.c b/plugins/baycom/baycom_kernel.c
new file mode 100644
index 0000000..0babc08
--- /dev/null
+++ b/plugins/baycom/baycom_kernel.c
@@ -0,0 +1,472 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "baycom_kernel.h"
+
+#include "hybbx/ax25.h"
+#include "hybbx/baycom.h"
+#include "hybbx/log.h"
+#include "hybbx/rf_tx_pace.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+
+#include <arpa/inet.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <linux/if_packet.h>
+#include <linux/if_ether.h>
+#include <linux/hdlcdrv.h>
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+struct baycom_kernel_modem {
+ hybbx_baycom_config_t cfg;
+ baycom_modem_frame_cb frame_cb;
+ void *frame_userdata;
+ int ctl_fd;
+ int pkt_fd;
+ int ifindex;
+};
+
+static void baycom_kernel_mode_string(const hybbx_baycom_config_t *cfg,
+ char *out, size_t out_cap)
+{
+ unsigned rate;
+ char suffix = '\0';
+
+ if (out == NULL || out_cap == 0 || cfg == NULL) {
+ return;
+ }
+
+ switch (cfg->mode) {
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ suffix = '*';
+ break;
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ suffix = '+';
+ break;
+ default:
+ break;
+ }
+
+ if (cfg->mode == HYBBX_BAYCOM_MODE_PAR96 ||
+ cfg->mode == HYBBX_BAYCOM_MODE_PAR96_SOFTDCD) {
+ snprintf(out, out_cap, "par96%c", suffix);
+ return;
+ }
+
+ if (cfg->mode == HYBBX_BAYCOM_MODE_EPP) {
+ snprintf(out, out_cap, "epp");
+ return;
+ }
+
+ rate = cfg->radio_baud / 100u;
+ if (rate == 0) {
+ rate = 12;
+ }
+ snprintf(out, out_cap, "ser%u%c", rate, suffix);
+}
+
+static int baycom_kernel_hdlc_ioctl(baycom_kernel_modem_t *modem,
+ struct hdlcdrv_ioctl *hi)
+{
+ struct ifreq ifr;
+
+ if (modem == NULL || hi == NULL || modem->ctl_fd < 0 ||
+ modem->cfg.interface == NULL) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ ifr.ifr_data = (void *)hi;
+
+ if (ioctl(modem->ctl_fd, SIOCDEVPRIVATE, &ifr) < 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int baycom_kernel_interface_flags(baycom_kernel_modem_t *modem,
+ unsigned short *flags_out)
+{
+ struct ifreq ifr;
+
+ if (modem == NULL || flags_out == NULL || modem->ctl_fd < 0) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ if (ioctl(modem->ctl_fd, SIOCGIFFLAGS, &ifr) < 0) {
+ return -1;
+ }
+
+ *flags_out = ifr.ifr_flags;
+ return 0;
+}
+
+static int baycom_kernel_interface_up(baycom_kernel_modem_t *modem, int up)
+{
+ struct ifreq ifr;
+ unsigned short flags = 0;
+
+ if (baycom_kernel_interface_flags(modem, &flags) < 0) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ ifr.ifr_flags = flags;
+ if (up) {
+ ifr.ifr_flags |= IFF_UP;
+ } else {
+ ifr.ifr_flags &= (unsigned short)~IFF_UP;
+ }
+
+ return ioctl(modem->ctl_fd, SIOCSIFFLAGS, &ifr);
+}
+
+static int baycom_kernel_set_modem_params(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETMODEMPAR;
+ hi.data.mp.iobase = (int)modem->cfg.iobase;
+ hi.data.mp.irq = (int)modem->cfg.irq;
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_set_mode(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+ char modestr[32];
+
+ baycom_kernel_mode_string(&modem->cfg, modestr, sizeof(modestr));
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETMODE;
+ strncpy(hi.data.modename, modestr, sizeof(hi.data.modename) - 1);
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_set_channel_params(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETCHANNELPAR;
+ hi.data.cp.tx_delay = (int)modem->cfg.txdelay;
+ hi.data.cp.tx_tail = (int)modem->cfg.txtail;
+ hi.data.cp.slottime = (int)modem->cfg.slot;
+ hi.data.cp.ppersist = (int)modem->cfg.persist;
+ hi.data.cp.fulldup = modem->cfg.full_duplex ? 1 : 0;
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_modprobe(const hybbx_baycom_config_t *cfg)
+{
+ char cmd[384];
+ char modestr[32];
+ int rc;
+
+ if (cfg == NULL || cfg->kernel_module == NULL) {
+ return -1;
+ }
+
+ baycom_kernel_mode_string(cfg, modestr, sizeof(modestr));
+ snprintf(cmd, sizeof(cmd),
+ "modprobe %s mode=%s iobase=0x%x irq=%u baud=%u 2>/dev/null",
+ cfg->kernel_module, modestr, cfg->iobase, cfg->irq,
+ cfg->radio_baud);
+
+ rc = system(cmd);
+ if (rc != 0) {
+ hybbx_log_warn("[baycom] modprobe failed (%s)", cfg->kernel_module);
+ return -1;
+ }
+
+ return 0;
+}
+
+static int baycom_kernel_ifindex(baycom_kernel_modem_t *modem)
+{
+ struct ifreq ifr;
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ if (ioctl(modem->ctl_fd, SIOCGIFINDEX, &ifr) < 0) {
+ return -1;
+ }
+
+ modem->ifindex = ifr.ifr_ifindex;
+ return 0;
+}
+
+static int baycom_kernel_open_packet(baycom_kernel_modem_t *modem)
+{
+ struct sockaddr_ll bind_addr;
+
+ modem->pkt_fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_AX25));
+ if (modem->pkt_fd < 0) {
+ return -1;
+ }
+
+ if (baycom_kernel_ifindex(modem) < 0) {
+ return -1;
+ }
+
+ memset(&bind_addr, 0, sizeof(bind_addr));
+ bind_addr.sll_family = AF_PACKET;
+ bind_addr.sll_protocol = htons(ETH_P_AX25);
+ bind_addr.sll_ifindex = modem->ifindex;
+
+ if (bind(modem->pkt_fd, (struct sockaddr *)&bind_addr,
+ sizeof(bind_addr)) < 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_kernel_modem_t *km;
+ unsigned short flags = 0;
+
+ if (out == NULL || cfg == NULL || cfg->interface == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ km = calloc(1, sizeof(*km));
+ if (km == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ /* Own string copies only — avoid double-free with caller config. */
+ memset(&km->cfg, 0, sizeof(km->cfg));
+ km->cfg.backend = cfg->backend;
+ km->cfg.mode = cfg->mode;
+ km->cfg.iobase = cfg->iobase;
+ km->cfg.irq = cfg->irq;
+ km->cfg.radio_baud = cfg->radio_baud;
+ km->cfg.kernel_autoload = cfg->kernel_autoload;
+ km->cfg.txdelay = cfg->txdelay;
+ km->cfg.txtail = cfg->txtail;
+ km->cfg.slot = cfg->slot;
+ km->cfg.persist = cfg->persist;
+ km->cfg.full_duplex = cfg->full_duplex;
+ km->cfg.serial_baud = cfg->serial_baud;
+ km->cfg.circuit_port = cfg->circuit_port;
+ km->cfg.interface = baycom_strdup(cfg->interface);
+ km->cfg.kernel_module = baycom_strdup(cfg->kernel_module);
+ km->cfg.device = baycom_strdup(cfg->device);
+ km->cfg.mycall = baycom_strdup(cfg->mycall);
+ km->cfg.circuit_host = baycom_strdup(cfg->circuit_host);
+ km->cfg.link_id = baycom_strdup(cfg->link_id);
+ km->cfg.link_password = baycom_strdup(cfg->link_password);
+ km->cfg.link_role = baycom_strdup(cfg->link_role);
+ km->cfg.frequency_mhz = baycom_strdup(cfg->frequency_mhz);
+ km->frame_cb = cb;
+ km->frame_userdata = userdata;
+ km->ctl_fd = -1;
+ km->pkt_fd = -1;
+
+ if (km->cfg.interface == NULL) {
+ free(km);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (cfg->kernel_autoload) {
+ if (baycom_kernel_modprobe(cfg) < 0) {
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ km->ctl_fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (km->ctl_fd < 0) {
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+ return HYBBX_ERR_IO;
+ }
+
+ if (baycom_kernel_interface_flags(km, &flags) < 0) {
+ hybbx_log_warn("[baycom] interface %s not found (load %s first)",
+ km->cfg.interface,
+ km->cfg.kernel_module != NULL ? km->cfg.kernel_module
+ : "baycom module");
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ if (!(flags & IFF_UP)) {
+ if (baycom_kernel_set_modem_params(km) < 0) {
+ hybbx_log_warn("[baycom] SETMODEMPAR failed (need CAP_SYS_RAWIO, iface down)");
+ }
+ if (baycom_kernel_set_mode(km) < 0) {
+ hybbx_log_warn("[baycom] SETMODE failed (need CAP_NET_ADMIN, iface down)");
+ }
+ if (baycom_kernel_interface_up(km, 1) < 0) {
+ hybbx_log_warn("[baycom] failed to bring %s up", km->cfg.interface);
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (baycom_kernel_set_channel_params(km) < 0) {
+ hybbx_log_warn("[baycom] channel params ioctl failed");
+ }
+
+ if (baycom_kernel_open_packet(km) < 0) {
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ *out = km;
+ return HYBBX_OK;
+}
+
+void baycom_kernel_close(baycom_kernel_modem_t *km)
+{
+ if (km == NULL) {
+ return;
+ }
+
+ if (km->pkt_fd >= 0) {
+ close(km->pkt_fd);
+ }
+ if (km->ctl_fd >= 0) {
+ close(km->ctl_fd);
+ }
+
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+}
+
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *km)
+{
+ uint8_t buf[HYBBX_AX25_FRAME_MAX + 64];
+ ssize_t n;
+
+ if (km == NULL || km->pkt_fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ for (;;) {
+ n = recv(km->pkt_fd, buf, sizeof(buf), MSG_DONTWAIT);
+ if (n < 0) {
+ if (errno == EAGAIN
+#if EAGAIN != EWOULDBLOCK
+ || errno == EWOULDBLOCK
+#endif
+ ) {
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_OK;
+ }
+
+ if (km->frame_cb != NULL && (size_t)n > 0) {
+ km->frame_cb(buf, (size_t)n, km->frame_userdata);
+ }
+ }
+}
+
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *km,
+ const uint8_t *frame, size_t len)
+{
+ struct sockaddr_ll dest;
+ ssize_t n;
+
+ if (km == NULL || frame == NULL || len == 0 || km->pkt_fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_pace();
+
+ memset(&dest, 0, sizeof(dest));
+ dest.sll_family = AF_PACKET;
+ dest.sll_protocol = htons(ETH_P_AX25);
+ dest.sll_ifindex = km->ifindex;
+ dest.sll_halen = 0;
+
+ n = sendto(km->pkt_fd, frame, len, 0, (struct sockaddr *)&dest,
+ sizeof(dest));
+ if (n < 0 || (size_t)n != len) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+#else /* !__linux__ */
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ (void)out;
+ (void)cfg;
+ (void)cb;
+ (void)userdata;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void baycom_kernel_close(baycom_kernel_modem_t *modem)
+{
+ (void)modem;
+}
+
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *modem)
+{
+ (void)modem;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *modem,
+ const uint8_t *frame, size_t len)
+{
+ (void)modem;
+ (void)frame;
+ (void)len;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+#endif /* __linux__ */
diff --git a/plugins/baycom/baycom_kernel.h b/plugins/baycom/baycom_kernel.h
new file mode 100644
index 0000000..b35e3b9
--- /dev/null
+++ b/plugins/baycom/baycom_kernel.h
@@ -0,0 +1,26 @@
+#ifndef BAYCOM_KERNEL_H
+#define BAYCOM_KERNEL_H
+
+#include "baycom_modem.h"
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct baycom_kernel_modem baycom_kernel_modem_t;
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_kernel_close(baycom_kernel_modem_t *modem);
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *modem);
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_KERNEL_H */
diff --git a/plugins/baycom/baycom_kiss.c b/plugins/baycom/baycom_kiss.c
new file mode 100644
index 0000000..c66b220
--- /dev/null
+++ b/plugins/baycom/baycom_kiss.c
@@ -0,0 +1,262 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "baycom_kiss.h"
+
+#include "hybbx/kiss.h"
+#include "hybbx/rf_tx_pace.h"
+#include "serial_port.h"
+#include "hybbx/log.h"
+#include "hybbx/limits.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static hybbx_result_t kiss_wait_device(const char *path)
+{
+ unsigned waited_ms = 0;
+ const unsigned step_ms = 500u;
+ const unsigned max_wait_ms = 30000u;
+
+ if (path == NULL || path[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (access(path, F_OK) == 0) {
+ return HYBBX_OK;
+ }
+
+ while (access(path, F_OK) != 0) {
+ if (waited_ms >= max_wait_ms) {
+ hybbx_log_warn("[baycom] kiss device not ready: %s", path);
+ return HYBBX_ERR_IO;
+ }
+ if (waited_ms == 0u) {
+ hybbx_log_info("[baycom] waiting for kiss device %s (up to %u s)",
+ path, max_wait_ms / 1000u);
+ }
+ usleep((useconds_t)step_ms * 1000u);
+ waited_ms += step_ms;
+ }
+
+ return HYBBX_OK;
+}
+
+struct baycom_kiss_modem {
+ hybbx_baycom_config_t cfg;
+ hybbx_serial_port_t *serial;
+ hybbx_kiss_decoder_t kiss_dec;
+ baycom_modem_frame_cb frame_cb;
+ void *frame_userdata;
+};
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static void on_kiss_frame(uint8_t port, const uint8_t *frame, size_t len,
+ void *userdata)
+{
+ baycom_kiss_modem_t *km = (baycom_kiss_modem_t *)userdata;
+
+ (void)port;
+
+ if (km == NULL || km->frame_cb == NULL || len == 0) {
+ return;
+ }
+
+ km->frame_cb(frame, len, km->frame_userdata);
+}
+
+static hybbx_result_t kiss_send_param(baycom_kiss_modem_t *km,
+ hybbx_kiss_command_t cmd, uint8_t value)
+{
+ uint8_t out[16];
+ size_t out_len;
+
+ out_len = hybbx_kiss_encode(0, cmd, &value, 1, out, sizeof(out));
+ if (out_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_serial_write(km->serial, out, out_len);
+}
+
+static hybbx_result_t kiss_apply_channel_params(baycom_kiss_modem_t *km)
+{
+ hybbx_result_t rc;
+
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_TXDELAY, (uint8_t)km->cfg.txdelay);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_PERSIST, (uint8_t)km->cfg.persist);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_SLOTTIME, (uint8_t)km->cfg.slot);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_TXTAIL, (uint8_t)km->cfg.txtail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ return kiss_send_param(km, HYBBX_KISS_CMD_FULLDUPLEX,
+ km->cfg.full_duplex ? 1u : 0u);
+}
+
+hybbx_result_t baycom_kiss_open(baycom_kiss_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_kiss_modem_t *km;
+ hybbx_serial_params_t sparams;
+ hybbx_result_t rc;
+
+ if (out == NULL || cfg == NULL || cfg->device == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ km = calloc(1, sizeof(*km));
+ if (km == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ /* Own string copies only — never shallow-copy pointers from caller config
+ * (hybbx_baycom_config_free on close would double-free inst->config). */
+ memset(&km->cfg, 0, sizeof(km->cfg));
+ km->cfg.backend = cfg->backend;
+ km->cfg.mode = cfg->mode;
+ km->cfg.iobase = cfg->iobase;
+ km->cfg.irq = cfg->irq;
+ km->cfg.radio_baud = cfg->radio_baud;
+ km->cfg.kernel_autoload = cfg->kernel_autoload;
+ km->cfg.txdelay = cfg->txdelay;
+ km->cfg.txtail = cfg->txtail;
+ km->cfg.slot = cfg->slot;
+ km->cfg.persist = cfg->persist;
+ km->cfg.full_duplex = cfg->full_duplex;
+ km->cfg.serial_baud = cfg->serial_baud;
+ km->cfg.circuit_port = cfg->circuit_port;
+ km->cfg.device = baycom_strdup(cfg->device);
+ km->cfg.mycall = baycom_strdup(cfg->mycall);
+ km->cfg.circuit_host = baycom_strdup(cfg->circuit_host);
+ km->cfg.link_id = baycom_strdup(cfg->link_id);
+ km->cfg.link_password = baycom_strdup(cfg->link_password);
+ km->cfg.link_role = baycom_strdup(cfg->link_role);
+ km->cfg.frequency_mhz = baycom_strdup(cfg->frequency_mhz);
+ km->frame_cb = cb;
+ km->frame_userdata = userdata;
+ hybbx_kiss_decoder_init(&km->kiss_dec);
+
+ hybbx_serial_params_default(&sparams, cfg->serial_baud);
+ sparams.data_bits = 8;
+ sparams.parity = HYBBX_SERIAL_PARITY_NONE;
+ sparams.stop_bits = 1;
+ sparams.assert_modem_lines = 0;
+
+ rc = kiss_wait_device(km->cfg.device);
+ if (rc != HYBBX_OK) {
+ baycom_kiss_close(km);
+ return rc;
+ }
+
+ if (access(km->cfg.device, R_OK | W_OK) != 0) {
+ hybbx_log_warn("[baycom] kiss device not readable/writable: %s (%s; user in dialout?)",
+ km->cfg.device, strerror(errno));
+ baycom_kiss_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_serial_open(&km->serial, km->cfg.device, &sparams);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] kiss serial open failed: %s (see [serial] log)",
+ km->cfg.device);
+ baycom_kiss_close(km);
+ return rc;
+ }
+
+ rc = kiss_apply_channel_params(km);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] kiss channel params failed");
+ }
+
+ *out = km;
+ return HYBBX_OK;
+}
+
+void baycom_kiss_close(baycom_kiss_modem_t *km)
+{
+ if (km == NULL) {
+ return;
+ }
+
+ if (km->serial != NULL) {
+ hybbx_serial_close(km->serial);
+ }
+
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+}
+
+hybbx_result_t baycom_kiss_poll(baycom_kiss_modem_t *km)
+{
+ uint8_t buf[256];
+ size_t read_len = 0;
+ hybbx_result_t rc;
+
+ if (km == NULL || km->serial == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_serial_read(km->serial, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (read_len > 0) {
+ hybbx_kiss_decoder_feed(&km->kiss_dec, buf, read_len, on_kiss_frame, km);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t baycom_kiss_send_frame(baycom_kiss_modem_t *km,
+ const uint8_t *frame, size_t len)
+{
+ uint8_t out[HYBBX_KISS_MAX_FRAME + 8];
+ size_t out_len;
+
+ if (km == NULL || km->serial == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_burst_guard();
+
+ out_len = hybbx_kiss_encode(0, HYBBX_KISS_CMD_DATA, frame, len,
+ out, sizeof(out));
+ if (out_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_serial_write(km->serial, out, out_len);
+}
diff --git a/plugins/baycom/baycom_kiss.h b/plugins/baycom/baycom_kiss.h
new file mode 100644
index 0000000..49c7b36
--- /dev/null
+++ b/plugins/baycom/baycom_kiss.h
@@ -0,0 +1,26 @@
+#ifndef BAYCOM_KISS_H
+#define BAYCOM_KISS_H
+
+#include "baycom_modem.h"
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct baycom_kiss_modem baycom_kiss_modem_t;
+
+hybbx_result_t baycom_kiss_open(baycom_kiss_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_kiss_close(baycom_kiss_modem_t *modem);
+hybbx_result_t baycom_kiss_poll(baycom_kiss_modem_t *modem);
+hybbx_result_t baycom_kiss_send_frame(baycom_kiss_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_KISS_H */
diff --git a/plugins/baycom/baycom_modem.c b/plugins/baycom/baycom_modem.c
new file mode 100644
index 0000000..e4e332f
--- /dev/null
+++ b/plugins/baycom/baycom_modem.c
@@ -0,0 +1,94 @@
+#include "baycom_modem.h"
+#include "baycom_kernel.h"
+#include "baycom_kiss.h"
+
+#include <stdlib.h>
+
+struct baycom_modem {
+ hybbx_baycom_backend_t backend;
+ baycom_kernel_modem_t *kernel;
+ baycom_kiss_modem_t *kiss;
+};
+
+hybbx_result_t baycom_modem_open(baycom_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_modem_t *modem;
+ hybbx_result_t rc;
+
+ if (out == NULL || cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ modem = calloc(1, sizeof(*modem));
+ if (modem == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ modem->backend = cfg->backend;
+
+ if (cfg->backend == HYBBX_BAYCOM_BACKEND_KISS) {
+ rc = baycom_kiss_open(&modem->kiss, cfg, cb, userdata);
+ } else {
+ rc = baycom_kernel_open(&modem->kernel, cfg, cb, userdata);
+ }
+
+ if (rc != HYBBX_OK) {
+ baycom_modem_close(modem);
+ return rc;
+ }
+
+ *out = modem;
+ return HYBBX_OK;
+}
+
+void baycom_modem_close(baycom_modem_t *modem)
+{
+ if (modem == NULL) {
+ return;
+ }
+
+ if (modem->kernel != NULL) {
+ baycom_kernel_close(modem->kernel);
+ }
+ if (modem->kiss != NULL) {
+ baycom_kiss_close(modem->kiss);
+ }
+
+ free(modem);
+}
+
+hybbx_result_t baycom_modem_poll(baycom_modem_t *modem)
+{
+ if (modem == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (modem->kiss != NULL) {
+ return baycom_kiss_poll(modem->kiss);
+ }
+ if (modem->kernel != NULL) {
+ return baycom_kernel_poll(modem->kernel);
+ }
+
+ return HYBBX_ERR_IO;
+}
+
+hybbx_result_t baycom_modem_send_frame(baycom_modem_t *modem,
+ const uint8_t *frame, size_t len)
+{
+ if (modem == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (modem->kiss != NULL) {
+ return baycom_kiss_send_frame(modem->kiss, frame, len);
+ }
+ if (modem->kernel != NULL) {
+ return baycom_kernel_send_frame(modem->kernel, frame, len);
+ }
+
+ return HYBBX_ERR_IO;
+}
diff --git a/plugins/baycom/baycom_modem.h b/plugins/baycom/baycom_modem.h
new file mode 100644
index 0000000..2365636
--- /dev/null
+++ b/plugins/baycom/baycom_modem.h
@@ -0,0 +1,31 @@
+#ifndef BAYCOM_MODEM_H
+#define BAYCOM_MODEM_H
+
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void (*baycom_modem_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+typedef struct baycom_modem baycom_modem_t;
+
+hybbx_result_t baycom_modem_open(baycom_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_modem_close(baycom_modem_t *modem);
+hybbx_result_t baycom_modem_poll(baycom_modem_t *modem);
+hybbx_result_t baycom_modem_send_frame(baycom_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_MODEM_H */
diff --git a/plugins/crdop/CMakeLists.txt b/plugins/crdop/CMakeLists.txt
new file mode 100644
index 0000000..03c4f1d
--- /dev/null
+++ b/plugins/crdop/CMakeLists.txt
@@ -0,0 +1,26 @@
+add_library(hybbx_plugin_crdop STATIC
+ crdop.c
+ crdop_config.c
+)
+
+target_include_directories(hybbx_plugin_crdop
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_SOURCE_DIR}/plugins/ardop
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_crdop
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_crdop PRIVATE hybbx_ardop_common)
+hybbx_link_plugin_instances(hybbx_plugin_crdop HYBBX_HAVE_PLUGIN_CRDOP)
+hybbx_apply_hardening(hybbx_plugin_crdop)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_crdop
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/crdop/crdop.c b/plugins/crdop/crdop.c
new file mode 100644
index 0000000..d2c6f3d
--- /dev/null
+++ b/plugins/crdop/crdop.c
@@ -0,0 +1,51 @@
+/*
+ * crdop — CB Radio Digital Open Protocol host-client (external ARDOPC/ardopcf).
+ * INI: [transport.crdop] or [transport.crdopN]. No embedded modem in HyBBX.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+
+#include "../ardop/ardop_link.h"
+
+static const ardop_link_plugin_t g_crdop_link = {
+ .name = "crdop",
+ .kind = HYBBX_TRANSPORT_CRDOP,
+ .default_link_id = "crdop-link",
+ .modem_hint = "CRDOPC (ARDOP-compatible host TCP)",
+ .parse_config = (ardop_link_config_fn)hybbx_crdop_config_parse,
+ .config_free = (void (*)(void *))hybbx_ardop_config_free,
+ .config_size = sizeof(hybbx_ardop_config_t),
+};
+
+static hybbx_result_t crdop_init(hybbx_service_t *service)
+{
+ return ardop_link_init(service, &g_crdop_link);
+}
+
+static void crdop_shutdown(void)
+{
+ ardop_link_shutdown(&g_crdop_link);
+}
+
+static hybbx_result_t crdop_start(const char *config)
+{
+ return ardop_link_start(&g_crdop_link, config);
+}
+
+static hybbx_result_t crdop_stop(void)
+{
+ return ardop_link_stop(&g_crdop_link);
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_crdop = {
+ .name = "crdop",
+ .kind = HYBBX_TRANSPORT_CRDOP,
+ .version = 1,
+ .init = crdop_init,
+ .shutdown = crdop_shutdown,
+ .start = crdop_start,
+ .stop = crdop_stop,
+ .write = NULL,
+};
diff --git a/plugins/crdop/crdop_config.c b/plugins/crdop/crdop_config.c
new file mode 100644
index 0000000..e1393bd
--- /dev/null
+++ b/plugins/crdop/crdop_config.c
@@ -0,0 +1,186 @@
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *crdop_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+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 (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+static const char *find_host(const char *config, char *scratch, size_t len)
+{
+ const char *value;
+
+ value = find_kv(config, "modem_host", scratch, len);
+ if (value != NULL && value[0] != '\0') {
+ return value;
+ }
+ return find_kv(config, "ardop_host", scratch, len);
+}
+
+static const char *find_port_str(const char *config, char *scratch, size_t len)
+{
+ const char *value;
+
+ value = find_kv(config, "modem_port", scratch, len);
+ if (value != NULL && value[0] != '\0') {
+ return value;
+ }
+ return find_kv(config, "ardop_port", scratch, len);
+}
+
+hybbx_result_t hybbx_crdop_config_parse(const char *config,
+ hybbx_ardop_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ardop_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_host(config, scratch, sizeof(scratch));
+ out->ardop_host = crdop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->ardop_port = (unsigned)strtoul(
+ find_port_str(config, scratch, sizeof(scratch)) != NULL ?
+ scratch : "8515", NULL, 10);
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ out->mycall = crdop_strdup(value != NULL ? value : "NOCALL");
+
+ out->radio_profile = HYBBX_CRDOP_PROFILE_CB;
+
+ value = find_kv(config, "arq_bandwidth", scratch, sizeof(scratch));
+ out->arq_bandwidth = crdop_strdup(
+ value != NULL ? value :
+ hybbx_crdop_default_arq_bandwidth(HYBBX_CRDOP_PROFILE_CB));
+
+ value = find_kv(config, "peer_call", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->peer_call = crdop_strdup(value);
+ }
+
+ value = find_kv(config, "listen", scratch, sizeof(scratch));
+ out->listen = value == NULL || str_ieq(value, "yes") || str_ieq(value, "1") ||
+ str_ieq(value, "true");
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = crdop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->circuit_port = (unsigned)strtoul(
+ find_kv(config, "circuit_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "7323", NULL, 10);
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = crdop_strdup(value != NULL ? value : "crdop-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = crdop_strdup(value);
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = crdop_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = strtod(value, NULL);
+ }
+
+ if (hybbx_crdop_bandwidth_exceeds_cb(out->arq_bandwidth)) {
+ hybbx_log_warn("[crdop] warning: arq_bandwidth=%s high for CB profile",
+ out->arq_bandwidth);
+ }
+
+ if (out->ardop_host == NULL || out->mycall == NULL ||
+ out->arq_bandwidth == NULL || out->circuit_host == NULL ||
+ out->link_id == NULL || out->link_role == NULL) {
+ hybbx_ardop_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/mains_proxy/CMakeLists.txt b/plugins/mains_proxy/CMakeLists.txt
new file mode 100644
index 0000000..0d2922e
--- /dev/null
+++ b/plugins/mains_proxy/CMakeLists.txt
@@ -0,0 +1,23 @@
+add_library(hybbx_plugin_mains_proxy STATIC
+ mains_proxy.c
+)
+
+target_include_directories(hybbx_plugin_mains_proxy
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_mains_proxy
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_mains_proxy PRIVATE hybbx_core)
+hybbx_link_plugin_instances(hybbx_plugin_mains_proxy HYBBX_HAVE_PLUGIN_MAINS_PROXY)
+
+hybbx_apply_hardening(hybbx_plugin_mains_proxy)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_mains_proxy
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/mains_proxy/mains_proxy.c b/plugins/mains_proxy/mains_proxy.c
new file mode 100644
index 0000000..8b6edff
--- /dev/null
+++ b/plugins/mains_proxy/mains_proxy.c
@@ -0,0 +1,137 @@
+/*
+ * mains_proxy — Main-to-Main mesh proxy.
+ *
+ * Peers attach via HBX/Circuit only (circuit_host, circuit_port, link_id).
+ * Service linking only — proxymail and proxychat payloads; no user data.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/mains_proxy.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct mains_proxy_plugin_state {
+ hybbx_service_t *service;
+ hybbx_mains_proxy_mesh_t mesh;
+ int loaded;
+} mains_proxy_plugin_state_t;
+
+static mains_proxy_plugin_state_t g_state;
+
+static hybbx_result_t mains_proxy_parse_instances(const char *config,
+ hybbx_mains_proxy_mesh_t *mesh)
+{
+ const char *cursor;
+ const char *end;
+ char section_buf[HYBBX_CONFIG_LINE_MAX];
+ unsigned count = 0;
+
+ if (mesh == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_mains_proxy_mesh_init(mesh);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ cursor = config;
+ while (*cursor != '\0' && count < HYBBX_MAINS_PROXY_MAX_PEERS) {
+ size_t len;
+
+ end = strchr(cursor, HYBBX_MAINS_PROXY_INSTANCE_SEP);
+ if (end == NULL) {
+ len = strlen(cursor);
+ } else {
+ len = (size_t)(end - cursor);
+ }
+
+ if (len >= sizeof(section_buf)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(section_buf, cursor, len);
+ section_buf[len] = '\0';
+
+ if (hybbx_mains_proxy_peer_parse(section_buf, &mesh->peers[count]) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (mesh->peers[count].enabled) {
+ count++;
+ }
+
+ if (end == NULL) {
+ break;
+ }
+ cursor = end + 1;
+ }
+
+ mesh->peer_count = count;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mains_proxy_init(hybbx_service_t *service)
+{
+ memset(&g_state, 0, sizeof(g_state));
+ g_state.service = service;
+ hybbx_mains_proxy_mesh_init(&g_state.mesh);
+ g_state.loaded = 1;
+ return HYBBX_OK;
+}
+
+static void mains_proxy_shutdown(void)
+{
+ hybbx_mains_proxy_mesh_stop(&g_state.mesh);
+ memset(&g_state, 0, sizeof(g_state));
+}
+
+static hybbx_result_t mains_proxy_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (!g_state.loaded) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = mains_proxy_parse_instances(config, &g_state.mesh);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[mains_proxy] invalid peer configuration");
+ return rc;
+ }
+
+ return hybbx_mains_proxy_mesh_start(g_state.service, &g_state.mesh);
+}
+
+static hybbx_result_t mains_proxy_stop(void)
+{
+ hybbx_mains_proxy_mesh_stop(&g_state.mesh);
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_mains_proxy = {
+ .name = "mains_proxy",
+ .kind = HYBBX_TRANSPORT_MAINS_PROXY,
+ .version = 1,
+ .init = mains_proxy_init,
+ .shutdown = mains_proxy_shutdown,
+ .start = mains_proxy_start,
+ .stop = mains_proxy_stop,
+ .write = NULL,
+};
+
+void hybbx_mains_proxy_plugin_tick(void)
+{
+ if (!g_state.loaded || !g_state.mesh.running) {
+ return;
+ }
+
+ hybbx_mains_proxy_mesh_tick(g_state.service, &g_state.mesh);
+} \ No newline at end of file
diff --git a/plugins/packet_radio/CMakeLists.txt b/plugins/packet_radio/CMakeLists.txt
new file mode 100644
index 0000000..1dc07a5
--- /dev/null
+++ b/plugins/packet_radio/CMakeLists.txt
@@ -0,0 +1,36 @@
+add_library(hybbx_plugin_packet_radio STATIC
+ packet_radio.c
+ packet_radio_config.c
+ ax25.c
+ kiss.c
+ serial_port.c
+ sixpack.c
+ tnc.c
+ tnc_host.c
+ tnc_profiles.c
+)
+
+target_include_directories(hybbx_plugin_packet_radio
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_packet_radio
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_plugin_packet_radio PRIVATE hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_plugin_packet_radio PRIVATE amiga)
+endif()
+hybbx_link_plugin_instances(hybbx_plugin_packet_radio HYBBX_HAVE_PLUGIN_PACKET_RADIO)
+hybbx_apply_hardening(hybbx_plugin_packet_radio)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_packet_radio
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/packet_radio/ax25.c b/plugins/packet_radio/ax25.c
new file mode 100644
index 0000000..5cbcfd8
--- /dev/null
+++ b/plugins/packet_radio/ax25.c
@@ -0,0 +1,250 @@
+#include "hybbx/ax25.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+static void address_upper(char *call)
+{
+ size_t i;
+
+ for (i = 0; call[i] != '\0'; i++) {
+ call[i] = (char)toupper((unsigned char)call[i]);
+ }
+}
+
+hybbx_result_t hybbx_ax25_address_parse(const char *text,
+ hybbx_ax25_address_t *out)
+{
+ const char *dash;
+ size_t call_len;
+ char *end = NULL;
+ unsigned long ssid;
+
+ if (text == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ dash = strchr(text, '-');
+ if (dash != NULL) {
+ call_len = (size_t)(dash - text);
+ ssid = strtoul(dash + 1, &end, 10);
+ if (end == dash + 1 || ssid > HYBBX_AX25_SSID_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+ out->ssid = (unsigned)ssid;
+ } else {
+ call_len = strlen(text);
+ }
+
+ if (call_len == 0 || call_len > HYBBX_AX25_CALL_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(out->call, text, call_len);
+ out->call[call_len] = '\0';
+ address_upper(out->call);
+ return HYBBX_OK;
+}
+
+void hybbx_ax25_encode_address(uint8_t *out, const hybbx_ax25_address_t *addr,
+ int last)
+{
+ size_t i;
+ char padded[HYBBX_AX25_CALL_MAX];
+
+ memset(padded, ' ', sizeof(padded));
+ for (i = 0; i < HYBBX_AX25_CALL_MAX && addr->call[i] != '\0'; i++) {
+ padded[i] = addr->call[i];
+ }
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ out[i] = (uint8_t)((unsigned char)padded[i] << 1);
+ }
+
+ out[6] = (uint8_t)(((addr->ssid & 0x0Fu) << 1) | 0x60u);
+ if (last) {
+ out[6] |= 0x01u;
+ }
+}
+
+uint16_t hybbx_ax25_crc(const uint8_t *data, size_t len)
+{
+ uint16_t crc = 0xFFFFu;
+ size_t i;
+ int bit;
+
+ for (i = 0; i < len; i++) {
+ crc ^= (uint16_t)data[i] << 8;
+ for (bit = 0; bit < 8; bit++) {
+ if (crc & 0x8000u) {
+ crc = (uint16_t)((crc << 1) ^ 0x1021u);
+ } else {
+ crc = (uint16_t)(crc << 1);
+ }
+ }
+ }
+
+ return crc;
+}
+
+static size_t append_addresses(const hybbx_ax25_path_t *path,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ unsigned i;
+
+ if (out_cap < (size_t)(2 + path->digi_count) * HYBBX_AX25_ADDR_ENCODED) {
+ return 0;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->dest, 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+
+ for (i = 0; i < path->digi_count; i++) {
+ hybbx_ax25_encode_address(out + pos, &path->digi[i], 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->source, 1);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ return pos;
+}
+
+size_t hybbx_ax25_build_ui(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos;
+ uint16_t crc;
+
+ if (path == NULL || out == NULL ||
+ (payload_len > 0 && payload == NULL)) {
+ return 0;
+ }
+
+ if (payload_len + 32 > out_cap || payload_len > HYBBX_AX25_FRAME_MAX) {
+ return 0;
+ }
+
+ pos = append_addresses(path, out, out_cap);
+ if (pos + 4 + payload_len > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_AX25_CTRL_UI;
+ out[pos++] = HYBBX_AX25_PID_NOL3;
+
+ if (payload_len > 0) {
+ memcpy(out + pos, payload, payload_len);
+ pos += payload_len;
+ }
+
+ crc = hybbx_ax25_crc(out, pos);
+ out[pos++] = (uint8_t)(crc & 0xFFu);
+ out[pos++] = (uint8_t)((crc >> 8) & 0xFFu);
+ return pos;
+}
+
+static void decode_address(const uint8_t *in, hybbx_ax25_address_t *out)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ char ch = (char)((in[i] >> 1) & 0x7Fu);
+ out->call[i] = ch == ' ' ? '\0' : ch;
+ }
+ out->call[HYBBX_AX25_CALL_MAX] = '\0';
+
+ for (i = HYBBX_AX25_CALL_MAX; i > 0; i--) {
+ if (out->call[i - 1] != '\0') {
+ break;
+ }
+ }
+ out->call[i] = '\0';
+ out->ssid = (unsigned)((in[6] >> 1) & 0x0Fu);
+}
+
+size_t hybbx_ax25_parse_ui(const uint8_t *frame, size_t frame_len,
+ hybbx_ax25_path_t *path,
+ uint8_t *payload, size_t payload_cap)
+{
+ size_t pos = 0;
+ unsigned digi = 0;
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (frame == NULL || frame_len < 16) {
+ return 0;
+ }
+
+ if (path != NULL) {
+ memset(path, 0, sizeof(*path));
+ }
+
+ while (pos + HYBBX_AX25_ADDR_ENCODED <= frame_len) {
+ int last = frame[pos + 6] & 0x01;
+ hybbx_ax25_address_t tmp;
+
+ decode_address(frame + pos, &tmp);
+ if (path != NULL) {
+ if (digi == 0) {
+ path->dest = tmp;
+ } else if (!last) {
+ if (digi - 1 < HYBBX_AX25_MAX_DIGI) {
+ path->digi[digi - 1] = tmp;
+ path->digi_count = digi;
+ }
+ } else {
+ path->source = tmp;
+ }
+ }
+
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ digi++;
+
+ if (last) {
+ break;
+ }
+
+ if (digi > HYBBX_AX25_MAX_DIGI + 1) {
+ return 0;
+ }
+ }
+
+ if (pos + 4 > frame_len) {
+ return 0;
+ }
+
+ if (frame[pos] != HYBBX_AX25_CTRL_UI) {
+ return 0;
+ }
+
+ pos += 2; /* control + PID */
+
+ if (frame_len < pos + 2) {
+ return 0;
+ }
+
+ crc_rx = (uint16_t)frame[frame_len - 2] |
+ ((uint16_t)frame[frame_len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, frame_len - 2);
+ if (crc_rx != crc_calc) {
+ return 0;
+ }
+
+ {
+ size_t payload_len = frame_len - pos - 2;
+
+ if (payload_len > payload_cap) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload != NULL) {
+ memcpy(payload, frame + pos, payload_len);
+ }
+
+ return payload_len;
+ }
+}
diff --git a/plugins/packet_radio/kiss.c b/plugins/packet_radio/kiss.c
new file mode 100644
index 0000000..a075442
--- /dev/null
+++ b/plugins/packet_radio/kiss.c
@@ -0,0 +1,152 @@
+#include "hybbx/kiss.h"
+
+#include <string.h>
+
+void hybbx_kiss_decoder_init(hybbx_kiss_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void deliver_frame(hybbx_kiss_decoder_t *dec,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ uint8_t port;
+ const uint8_t *payload;
+ size_t payload_len;
+
+ if (dec->len < 1) {
+ return;
+ }
+
+ port = (uint8_t)((dec->buf[0] >> 4) & 0x0Fu);
+ payload = dec->buf + 1;
+ payload_len = dec->len - 1;
+
+ if (cb != NULL) {
+ cb(port, payload, payload_len, userdata);
+ }
+}
+
+void hybbx_kiss_decoder_feed(hybbx_kiss_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t byte = data[i];
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (dec->in_frame && dec->len > 0) {
+ deliver_frame(dec, cb, userdata);
+ }
+ dec->in_frame = 1;
+ dec->escape = 0;
+ dec->len = 0;
+ continue;
+ }
+
+ if (!dec->in_frame) {
+ continue;
+ }
+
+ if (dec->escape) {
+ if (byte == HYBBX_KISS_TFEND) {
+ byte = HYBBX_KISS_FEND;
+ } else if (byte == HYBBX_KISS_TFESC) {
+ byte = HYBBX_KISS_FESC;
+ }
+ dec->escape = 0;
+ } else if (byte == HYBBX_KISS_FESC) {
+ dec->escape = 1;
+ continue;
+ }
+
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ dec->len = 0;
+ dec->in_frame = 0;
+ continue;
+ }
+
+ dec->buf[dec->len++] = byte;
+ }
+}
+
+static size_t kiss_write_byte(uint8_t byte, uint8_t *out, size_t out_cap, size_t pos)
+{
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFEND;
+ return pos;
+ }
+
+ if (byte == HYBBX_KISS_FESC) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFESC;
+ return pos;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_kiss_encode(uint8_t port, hybbx_kiss_command_t cmd,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ uint8_t cmd_byte;
+ size_t i;
+
+ if (out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload == NULL) {
+ return 0;
+ }
+
+ if (payload_len + 4 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+
+ cmd_byte = (uint8_t)(((port & 0x0Fu) << 4) | ((uint8_t)cmd & 0x0Fu));
+ pos = kiss_write_byte(cmd_byte, out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+
+ for (i = 0; i < payload_len; i++) {
+ pos = kiss_write_byte(payload[i], out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+ return pos;
+}
diff --git a/plugins/packet_radio/packet_radio.c b/plugins/packet_radio/packet_radio.c
new file mode 100644
index 0000000..e8952ae
--- /dev/null
+++ b/plugins/packet_radio/packet_radio.c
@@ -0,0 +1,1005 @@
+/*
+ * packet_radio — link/repeater edge adapter: serial TNC, KISS/AX.25 → HBX client.
+ * INI: [transport.packet_radio] or [transport.packet_radioN]. See share/hybbx.ini.example.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/broadcast.h"
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/traffic.h"
+#include "hybbx/limits.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/ax25.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "hybbx/max25.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <errno.h>
+#include <poll.h>
+#include <sys/select.h>
+#endif
+
+#define PACKET_RADIO_POLL_MS 20u
+
+static hybbx_service_t *g_service;
+
+typedef struct packet_radio_instance {
+ hybbx_packet_radio_config_t config;
+ hybbx_tnc_t *tnc;
+ int circuit_fd;
+ hybbx_circuit_decoder_t circuit_dec;
+ volatile int flow_paused;
+ volatile int flow_cancel;
+ unsigned index;
+ unsigned circuit_reconnect_polls;
+} packet_radio_instance_t;
+
+static packet_radio_instance_t g_instances[HYBBX_PACKET_RADIO_MAX_INSTANCES];
+static unsigned g_instance_count;
+static pthread_t g_poll_thread;
+static volatile int g_radio_running;
+static int g_poll_thread_started;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_packet_radio;
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst);
+
+static unsigned packet_radio_count_local_edges(const char *config)
+{
+ char scratch[4096];
+ const char *cursor = config;
+ unsigned count = 0;
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ return count;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+
+ if (hybbx_packet_radio_section_is_local_edge(scratch)) {
+ count++;
+ }
+
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ return count;
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst);
+static void packet_radio_poll_sleep_ms(unsigned ms);
+
+static const char *device_type_name(hybbx_packet_radio_device_type_t type)
+{
+ return type == HYBBX_PACKET_RADIO_DEVICE_SERIAL ? "serial" : "usb";
+}
+
+static const char *protocol_name(hybbx_packet_radio_protocol_t proto)
+{
+ switch (proto) {
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ return "hostmode";
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ return "6pack";
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ default:
+ return "kiss";
+ }
+}
+
+static const char *tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+static int instance_circuit_uplink_allowed(const packet_radio_instance_t *inst)
+{
+ return inst != NULL && !inst->flow_paused && !inst->flow_cancel;
+}
+
+static int packet_radio_source_banned(const hybbx_ax25_path_t *path)
+{
+ char raw[HYBBX_CALLID_MAX];
+ char norm[HYBBX_CALLID_MAX];
+
+ if (path == NULL || path->source.call[0] == '\0') {
+ return 0;
+ }
+
+ if (path->source.ssid > 0u) {
+ snprintf(raw, sizeof(raw), "%s-%u", path->source.call,
+ path->source.ssid);
+ } else {
+ hybbx_strlcpy(raw, path->source.call, sizeof(raw));
+ }
+
+ if (!hybbx_security_callid_normalize(raw, norm, sizeof(norm))) {
+ return 0;
+ }
+
+ return hybbx_security_ban_callid_is_banned(norm);
+}
+
+static int packet_radio_frame_source_banned(const uint8_t *frame, size_t len)
+{
+ hybbx_ax25_path_t path;
+ uint8_t scratch[HYBBX_AX25_FRAME_MAX];
+
+ if (frame == NULL || len == 0) {
+ return 0;
+ }
+
+ if (hybbx_ax25_parse_ui(frame, len, &path, scratch, sizeof(scratch)) == 0) {
+ return 0;
+ }
+
+ return packet_radio_source_banned(&path);
+}
+
+static hybbx_result_t instance_circuit_uplink(packet_radio_instance_t *inst,
+ const uint8_t *frame,
+ size_t frame_len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+ uint16_t flags = HYBBX_CIRCUIT_FLAG_RX;
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return HYBBX_OK;
+ }
+
+ if (hybbx_packet_radio_modulation_is_g3ruh(inst->config.params.modulation)) {
+ flags |= HYBBX_CIRCUIT_FLAG_G3RUH_FSK;
+ }
+
+ hbx_len = hybbx_circuit_encode_ax25(frame, frame_len, flags,
+ hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+}
+
+static void on_tnc_ax25_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (len == 0 || inst == NULL) {
+ return;
+ }
+
+ if (packet_radio_frame_source_banned(frame, len)) {
+ return;
+ }
+
+ instance_note_rf_activity(inst);
+ (void)instance_circuit_uplink(inst, frame, len);
+}
+
+static void on_tnc_host_ui(const uint8_t *payload, size_t len,
+ const hybbx_ax25_path_t *path, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ (void)path;
+
+ if (inst == NULL || inst->circuit_fd < 0 || len == 0) {
+ return;
+ }
+
+ if (path != NULL && packet_radio_source_banned(path)) {
+ return;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len > 0) {
+ (void)hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+ }
+}
+
+static void instance_format_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (addr == NULL || addr->call[0] == '\0') {
+ return;
+ }
+
+ if (addr->ssid > 0u) {
+ unsigned ssid = addr->ssid;
+
+ if (ssid > HYBBX_AX25_SSID_MAX) {
+ ssid = HYBBX_AX25_SSID_MAX;
+ }
+ snprintf(out, out_len, "%s-%u", addr->call, ssid);
+ } else {
+ hybbx_strlcpy(out, addr->call, out_len);
+ }
+}
+
+static void instance_log_rf_tx(packet_radio_instance_t *inst,
+ hybbx_result_t rc,
+ const hybbx_ax25_path_t *path,
+ size_t payload_len)
+{
+ char src[HYBBX_AX25_CALL_MAX + 8];
+ char dst[HYBBX_AX25_CALL_MAX + 8];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_format_call(path != NULL ? &path->source : NULL, src, sizeof(src));
+ instance_format_call(path != NULL ? &path->dest : NULL, dst, sizeof(dst));
+
+ if (rc == HYBBX_OK) {
+ hybbx_log_stats("[packet_radio%u] RF TX %s>%s (%zu bytes)",
+ inst->index + 1, src, dst, payload_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX failed %s>%s (%zu bytes, rc=%d)",
+ inst->index + 1, src, dst, payload_len, (int)rc);
+ }
+}
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst)
+{
+ hybbx_circuit_hub_t *hub;
+ const char *link_id;
+
+ if (inst == NULL || g_service == NULL) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(g_service);
+ link_id = inst->config.link_id;
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+
+ if (hub != NULL) {
+ hybbx_circuit_hub_note_rf_activity(hub, link_id);
+ }
+}
+
+static hybbx_result_t instance_tx_ax25_ui(packet_radio_instance_t *inst,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+ hybbx_result_t rc;
+
+ if (inst == NULL || inst->tnc == NULL || path == NULL ||
+ payload == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(path, payload, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_tnc_send_frame(inst->tnc, frame, frame_len);
+ if (rc == HYBBX_OK) {
+ instance_note_rf_activity(inst);
+ }
+ instance_log_rf_tx(inst, rc, path, len);
+ return rc;
+}
+
+static hybbx_result_t instance_send_bytes(packet_radio_instance_t *inst,
+ const uint8_t *data, size_t len)
+{
+ if (inst == NULL || inst->tnc == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ if (hybbx_tnc_host_connected(inst->tnc)) {
+ return hybbx_tnc_send_converse(inst->tnc, (const char *)data, len);
+ }
+ return HYBBX_ERR_BUSY;
+ }
+
+ return hybbx_tnc_send_ui(inst->tnc, data, len);
+}
+
+static void instance_circuit_disconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ inst->circuit_reconnect_polls = 0;
+}
+
+static void instance_circuit_reconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_circuit_disconnect(inst);
+ packet_radio_poll_sleep_ms(250);
+ if (instance_connect_circuit(inst) == HYBBX_OK) {
+ hybbx_log_info("[packet_radio%u] circuit reconnected", inst->index + 1);
+ }
+}
+
+static void instance_on_circuit_flow_ctrl(packet_radio_instance_t *inst,
+ hybbx_circuit_proto_t proto,
+ uint16_t flags,
+ const uint8_t *payload, size_t len)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ inst->flow_paused = 1;
+ hybbx_log_stats("[packet_radio%u] circuit flow pause (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ inst->flow_paused = 0;
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ hybbx_log_stats("[packet_radio%u] circuit flow break (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ inst->flow_cancel = 1;
+ hybbx_log_warn("[packet_radio%u] circuit flow cancel (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ inst->flow_paused = 0;
+ hybbx_log_stats("[packet_radio%u] circuit flow resume (%s)",
+ inst->index + 1, reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ instance_on_circuit_flow_ctrl(inst, proto, flags, payload, len);
+ return;
+ }
+
+ if (inst->tnc == NULL || len == 0) {
+ return;
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ (void)instance_send_bytes(inst, payload, len);
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25: {
+ hybbx_result_t rc = hybbx_tnc_send_frame(inst->tnc, payload, len);
+ if (rc == HYBBX_OK) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path,
+ ui, sizeof(ui));
+
+ instance_note_rf_activity(inst);
+ if (ui_len > 0) {
+ instance_log_rf_tx(inst, rc, &path, ui_len);
+ }
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX frame failed (rc=%d, %zu bytes)",
+ inst->index + 1, (int)rc, len);
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_PROTO_AX25_UI: {
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path,
+ ui, sizeof(ui));
+ if (ui_len > 0) {
+ (void)instance_tx_ax25_ui(inst, &path, ui, ui_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] AX25_UI unpack failed (%zu bytes)",
+ inst->index + 1, len);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+static void packet_radio_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst)
+{
+ const char *host;
+ unsigned port;
+ unsigned attempt;
+
+ if (inst == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = inst->config.circuit_host;
+ port = inst->config.circuit_port;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ if (inst->config.link_password == NULL ||
+ inst->config.link_password[0] == '\0') {
+ hybbx_log_warn("[packet_radio%u] missing link_password for HBX LINK_AUTH; "
+ "set link_password in [transport.packet_radioN] "
+ "(hub logs link_auth_fail reason=timeout_no_link_auth)",
+ inst->index + 1);
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &inst->circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ {
+ const char *link_id = inst->config.link_id;
+ const char *link_role = inst->config.link_role;
+
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ {
+ hybbx_circuit_link_qos_t qos;
+ int duplex = (int)inst->config.params.duplex;
+
+ memset(&qos, 0, sizeof(qos));
+ qos.baud = inst->config.params.radio_baud;
+ if (duplex == HYBBX_PACKET_RADIO_DUPLEX_HALF) {
+ qos.duplex = 1;
+ } else if (duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ qos.duplex = 2;
+ }
+ qos.bandwidth = "low";
+ qos.frequency_mhz = inst->config.frequency_mhz;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ inst->circuit_fd, inst->config.link_password,
+ link_role, link_id, &qos);
+ }
+ if (rc != HYBBX_OK) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ packet_radio_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[packet_radio%u] linked to internal circuit %s:%u (HBX)",
+ inst->index + 1, host, port);
+ return HYBBX_OK;
+ }
+ packet_radio_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[packet_radio%u] could not connect to internal circuit %s:%u",
+ inst->index + 1, host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void instance_shutdown(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+
+ if (inst->tnc != NULL) {
+ hybbx_tnc_close(inst->tnc);
+ inst->tnc = NULL;
+ }
+
+ hybbx_packet_radio_config_free(&inst->config);
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+}
+
+typedef enum {
+ PR_POLL_CIRCUIT = 0,
+ PR_POLL_SERIAL = 1
+} packet_radio_poll_kind_t;
+
+typedef struct {
+ unsigned instance_index;
+ packet_radio_poll_kind_t kind;
+} packet_radio_poll_map_t;
+
+static void instance_drain_circuit(packet_radio_instance_t *inst)
+{
+ uint8_t buf[512];
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return;
+ }
+
+ for (;;) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(inst->circuit_fd, buf,
+ sizeof(buf), &read_len);
+
+ if (rc == HYBBX_ERR_IO) {
+ hybbx_log_warn("[packet_radio%u] circuit disconnected — reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ return;
+ }
+ if (rc != HYBBX_OK || read_len == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&inst->circuit_dec, buf, read_len,
+ on_circuit_downlink, inst);
+ }
+}
+
+#if !defined(_WIN32)
+static void packet_radio_poll_instances(void)
+{
+ struct pollfd pfds[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ packet_radio_poll_map_t map[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ nfds_t nfds = 0;
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+ int circuit_fd = inst->circuit_fd;
+ int serial_fd = inst->tnc != NULL ? hybbx_tnc_serial_fd(inst->tnc) : -1;
+
+ if (circuit_fd >= 0) {
+ pfds[nfds].fd = circuit_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_CIRCUIT;
+ nfds++;
+ }
+
+ if (serial_fd >= 0) {
+ pfds[nfds].fd = serial_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_SERIAL;
+ nfds++;
+ }
+ }
+
+ if (nfds > 0) {
+ int pr = poll(pfds, nfds, (int)PACKET_RADIO_POLL_MS);
+
+ if (pr < 0) {
+ if (errno != EINTR) {
+ hybbx_log_warn("[packet_radio] poll failed");
+ }
+ } else if (pr > 0) {
+ nfds_t j;
+
+ for (j = 0; j < nfds; j++) {
+ short rev = pfds[j].revents;
+
+ if (rev == 0) {
+ continue;
+ }
+
+ if ((rev & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ hybbx_log_warn("[packet_radio%u] circuit hangup — "
+ "reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ }
+ continue;
+ }
+
+ if ((rev & POLLIN) == 0) {
+ continue;
+ }
+
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ instance_drain_circuit(&g_instances[map[j].instance_index]);
+ } else {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+ }
+ }
+ }
+ } else {
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+ }
+}
+#endif
+
+static void packet_radio_service_maintenance(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->circuit_fd < 0) {
+ inst->circuit_reconnect_polls++;
+ if (inst->circuit_reconnect_polls >= 50u) {
+ inst->circuit_reconnect_polls = 0;
+ (void)instance_connect_circuit(inst);
+ }
+ }
+
+ if (inst->flow_cancel) {
+ inst->flow_cancel = 0;
+ inst->flow_paused = 0;
+ instance_circuit_reconnect(inst);
+ }
+ }
+}
+
+#if defined(_WIN32)
+static void packet_radio_poll_win32(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+
+ if (inst->circuit_fd >= 0) {
+ instance_drain_circuit(inst);
+ }
+ }
+}
+#endif
+
+static void *packet_radio_poll_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_radio_running) {
+#if !defined(_WIN32)
+ packet_radio_poll_instances();
+#else
+ packet_radio_poll_win32();
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+#endif
+ packet_radio_service_maintenance();
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t instance_start(packet_radio_instance_t *inst,
+ const char *config,
+ unsigned index)
+{
+ hybbx_result_t rc;
+ hybbx_tnc_frame_cb frame_cb = on_tnc_ax25_frame;
+ hybbx_tnc_ui_cb ui_cb = NULL;
+
+ if (inst == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+ inst->index = index;
+
+ rc = hybbx_packet_radio_config_parse(config, &inst->config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[packet_radio%u] invalid configuration", index + 1);
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (g_service != NULL) {
+ const hybbx_broadcast_config_t *bc =
+ hybbx_service_get_broadcast(g_service);
+
+ if (bc != NULL && bc->ax25_mycall[0] != '\0') {
+ size_t n = strlen(bc->ax25_mycall) + 1;
+ char *mc = malloc(n);
+
+ if (mc != NULL) {
+ memcpy(mc, bc->ax25_mycall, n);
+ free(inst->config.mycall);
+ inst->config.mycall = mc;
+ hybbx_log_info("[packet_radio%u] ax25 source=%s (from [broadcast] ax25_mycall)",
+ index + 1, inst->config.mycall);
+ }
+ }
+ }
+
+ hybbx_log_info("[packet_radio%u] tnc=%s protocol=%s device_type=%s device=%s "
+ "band=%s duplex=%s modulation=%s circuit=%s:%u "
+ "(HBX over internal TCP)",
+ index + 1,
+ tnc_name(inst->config.tnc),
+ protocol_name(inst->config.protocol),
+ device_type_name(inst->config.device_type),
+ inst->config.device,
+ hybbx_packet_radio_band_name(inst->config.params.band),
+ hybbx_packet_radio_duplex_name(inst->config.params.duplex),
+ hybbx_packet_radio_modulation_name(inst->config.params.modulation),
+ inst->config.circuit_host,
+ inst->config.circuit_port);
+ if (inst->config.frequency_mhz != NULL &&
+ inst->config.frequency_mhz[0] != '\0') {
+ hybbx_log_info("[packet_radio%u] frequency_mhz=%s", index + 1,
+ inst->config.frequency_mhz);
+ }
+
+ rc = instance_connect_circuit(inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ frame_cb = NULL;
+ ui_cb = on_tnc_host_ui;
+ }
+
+ rc = hybbx_tnc_open(&inst->tnc, &inst->config, frame_cb, ui_cb, inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_init(hybbx_service_t *service)
+{
+ g_service = service;
+ g_instance_count = 0;
+ g_radio_running = 0;
+ return HYBBX_OK;
+}
+
+static void packet_radio_shutdown(void)
+{
+ unsigned i;
+
+ g_radio_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ for (i = 0; i < g_instance_count; i++) {
+ instance_shutdown(&g_instances[i]);
+ }
+ g_instance_count = 0;
+}
+
+static hybbx_result_t packet_radio_start(const char *config)
+{
+ char scratch[4096];
+ const char *cursor;
+ hybbx_max25_config_t max25;
+ hybbx_max25_status_t max25_status;
+ hybbx_result_t rc = HYBBX_OK;
+ unsigned started = 0;
+ unsigned section_num = 0;
+ unsigned local_edges;
+
+ (void)g_service;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ packet_radio_shutdown();
+ g_poll_thread = (pthread_t)0;
+
+ cursor = hybbx_max25_config_skip_prefix(config, &max25);
+ local_edges = packet_radio_count_local_edges(cursor);
+ if (local_edges > 0u) {
+ rc = hybbx_max25_wait_ready(&max25);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (*cursor != '\0' && g_instance_count < HYBBX_PACKET_RADIO_MAX_INSTANCES) {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ packet_radio_shutdown();
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+ section_num++;
+
+ if (!hybbx_packet_radio_section_is_local_edge(scratch)) {
+ hybbx_log_info("[packet_radio%u] bridge registry only — no local TNC",
+ section_num);
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ rc = instance_start(&g_instances[g_instance_count], scratch,
+ g_instance_count);
+ if (rc != HYBBX_OK) {
+ packet_radio_shutdown();
+ return rc;
+ }
+
+ g_instance_count++;
+ started++;
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ if (started == 0) {
+ return HYBBX_OK;
+ }
+
+ g_radio_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, packet_radio_poll_thread,
+ NULL) != 0) {
+ hybbx_log_warn("[packet_radio] poll thread failed");
+ packet_radio_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ hybbx_log_info("[packet_radio] %u TNC instance(s) active", started);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_stop(void)
+{
+ hybbx_log_info("[packet_radio] stop");
+ packet_radio_shutdown();
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_packet_radio = {
+ .name = "packet_radio",
+ .kind = HYBBX_TRANSPORT_PACKET_RADIO,
+ .version = 1,
+ .init = packet_radio_init,
+ .shutdown = packet_radio_shutdown,
+ .start = packet_radio_start,
+ .stop = packet_radio_stop,
+ .write = NULL,
+};
diff --git a/plugins/packet_radio/packet_radio_config.c b/plugins/packet_radio/packet_radio_config.c
new file mode 100644
index 0000000..f8e2d65
--- /dev/null
+++ b/plugins/packet_radio/packet_radio_config.c
@@ -0,0 +1,786 @@
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static char *hybbx_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+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 (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+int hybbx_packet_radio_section_is_local_edge(const char *config)
+{
+ char scratch[64];
+
+ if (config == NULL) {
+ return 0;
+ }
+
+ if (find_kv(config, "circuit_host", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "device", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "tnc", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "protocol", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static hybbx_packet_radio_device_type_t parse_device_type(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "usb") || str_ieq(value, "ax25") ||
+ str_ieq(value, "ax.25")) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "serial") || str_ieq(value, "com") ||
+ str_ieq(value, "rs232") || str_ieq(value, "v24")) {
+ return HYBBX_PACKET_RADIO_DEVICE_SERIAL;
+ }
+
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+}
+
+static const char *default_device_for_type(hybbx_packet_radio_device_type_t type)
+{
+ if (type == HYBBX_PACKET_RADIO_DEVICE_SERIAL) {
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL;
+ }
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_USB;
+}
+
+static hybbx_packet_radio_tnc_t parse_tnc(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tnc2c") || str_ieq(value, "tnc2-c")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+ }
+
+ if (str_ieq(value, "tnc2") || str_ieq(value, "tnc-2") ||
+ str_ieq(value, "pktnc2") || str_ieq(value, "pk-tnc2") ||
+ str_ieq(value, "tapr") || str_ieq(value, "thefirmware") ||
+ str_ieq(value, "tf")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2;
+ }
+
+ if (str_ieq(value, "pk232") || str_ieq(value, "pk-232") ||
+ str_ieq(value, "aea") || str_ieq(value, "pk87") ||
+ str_ieq(value, "pk-87")) {
+ return HYBBX_PACKET_RADIO_TNC_PK232;
+ }
+
+ if (str_ieq(value, "mfj1278") || str_ieq(value, "mfj-1278") ||
+ str_ieq(value, "mfj1278b") || str_ieq(value, "mfj")) {
+ return HYBBX_PACKET_RADIO_TNC_MFJ1278;
+ }
+
+ if (str_ieq(value, "kantronics") || str_ieq(value, "kpc") ||
+ str_ieq(value, "kpc3") || str_ieq(value, "kpc9612")) {
+ return HYBBX_PACKET_RADIO_TNC_KANTRONICS;
+ }
+
+ if (str_ieq(value, "baycom") || str_ieq(value, "bay-com")) {
+ return HYBBX_PACKET_RADIO_TNC_BAYCOM;
+ }
+
+ if (str_ieq(value, "pccom") || str_ieq(value, "pc-com") ||
+ str_ieq(value, "albrecht")) {
+ return HYBBX_PACKET_RADIO_TNC_PCCOM;
+ }
+
+ if (str_ieq(value, "generic") || str_ieq(value, "tnc")) {
+ return HYBBX_PACKET_RADIO_TNC_GENERIC;
+ }
+
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+}
+
+static hybbx_packet_radio_protocol_t parse_protocol(const char *value)
+{
+ if (value == NULL || str_ieq(value, "kiss")) {
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ if (str_ieq(value, "hostmode") || str_ieq(value, "host") ||
+ str_ieq(value, "tnc2")) {
+ return HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+
+ if (str_ieq(value, "6pack") || str_ieq(value, "sixpack")) {
+ return HYBBX_PACKET_RADIO_PROTO_SIXPACK;
+ }
+
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+}
+
+static hybbx_tnc2c_modem_t parse_modem(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ if (str_ieq(value, "9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "19200")) {
+ return HYBBX_TNC2C_MODEM_19200;
+ }
+
+ return HYBBX_TNC2C_MODEM_TCM3105;
+}
+
+static float parse_clock_mhz(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+
+ if (str_ieq(value, "9.8") || str_ieq(value, "9,8")) {
+ return HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+
+ if (str_ieq(value, "10") || str_ieq(value, "10.0")) {
+ return HYBBX_TNC2C_CLOCK_10_MHZ;
+ }
+
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+}
+
+static unsigned int parse_uint(const char *value, unsigned int default_value)
+{
+ char *end = NULL;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || n == 0) {
+ return default_value;
+ }
+
+ return (unsigned int)n;
+}
+
+static hybbx_packet_radio_band_t parse_radio_band(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ if (str_ieq(value, "cb") || str_ieq(value, "11m") ||
+ str_ieq(value, "27mhz") || str_ieq(value, "citizen")) {
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ }
+
+ if (str_ieq(value, "amateur") || str_ieq(value, "ham") ||
+ str_ieq(value, "amateurfunk") || str_ieq(value, "afu")) {
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+}
+
+static hybbx_packet_radio_duplex_t parse_radio_duplex(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ }
+
+ if (str_ieq(value, "full") || str_ieq(value, "full-duplex") ||
+ str_ieq(value, "fullduplex") || str_ieq(value, "fulldup")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_FULL;
+ }
+
+ if (str_ieq(value, "half") || str_ieq(value, "half-duplex") ||
+ str_ieq(value, "duplex") || str_ieq(value, "simplex")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+}
+
+static hybbx_packet_radio_modulation_t parse_modulation(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600") || str_ieq(value, "9600fsk")) {
+ return HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+ }
+
+ if (str_ieq(value, "afsk") || str_ieq(value, "bell202") ||
+ str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+}
+
+static hybbx_tnc_serial_parity_t parse_serial_parity(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+ }
+
+ if (str_ieq(value, "even") || str_ieq(value, "e")) {
+ return HYBBX_TNC_SERIAL_PARITY_EVEN;
+ }
+
+ if (str_ieq(value, "odd") || str_ieq(value, "o")) {
+ return HYBBX_TNC_SERIAL_PARITY_ODD;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "n") || str_ieq(value, "off")) {
+ return HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+}
+
+static void apply_serial_line(hybbx_packet_radio_config_t *out, const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return;
+ }
+
+ if (str_ieq(value, "7e1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "8n1")) {
+ out->params.data_bits = 8;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "7o1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_ODD;
+ out->params.stop_bits = 1;
+ }
+}
+
+static hybbx_kiss_entry_t parse_kiss_entry(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_ENTRY_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_ENTRY_NONE;
+ }
+
+ if (str_ieq(value, "kiss_on") || str_ieq(value, "kiss-on") ||
+ str_ieq(value, "command") || str_ieq(value, "on") ||
+ str_ieq(value, "yes")) {
+ return HYBBX_KISS_ENTRY_KISS_ON;
+ }
+
+ if (str_ieq(value, "esc_at_k") || str_ieq(value, "esc@k") ||
+ str_ieq(value, "esc") || str_ieq(value, "@k")) {
+ return HYBBX_KISS_ENTRY_ESC_AT_K;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_ENTRY_AUTO;
+ }
+
+ return HYBBX_KISS_ENTRY_UNSET;
+}
+
+static hybbx_kiss_exit_t parse_kiss_exit(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_EXIT_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_EXIT_NONE;
+ }
+
+ if (str_ieq(value, "kiss_off") || str_ieq(value, "kiss-off") ||
+ str_ieq(value, "command")) {
+ return HYBBX_KISS_EXIT_KISS_OFF;
+ }
+
+ if (str_ieq(value, "kiss_frame") || str_ieq(value, "frame") ||
+ str_ieq(value, "fend")) {
+ return HYBBX_KISS_EXIT_KISS_FRAME;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_EXIT_AUTO;
+ }
+
+ return HYBBX_KISS_EXIT_UNSET;
+}
+
+static unsigned int default_radio_baud_for_modem(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return 9600;
+ case HYBBX_TNC2C_MODEM_19200:
+ return 19200;
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+}
+
+static hybbx_result_t parse_via_list(hybbx_packet_radio_config_t *out,
+ const char *value)
+{
+ char scratch[256];
+ char *cursor;
+ char *token;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (strlen(value) >= sizeof(scratch)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, value, strlen(value) + 1);
+ cursor = scratch;
+
+ while ((token = strtok(cursor, ",")) != NULL && out->via_count < HYBBX_PACKET_RADIO_VIA_MAX) {
+ char *item = hybbx_strdup(token);
+ if (item == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ out->via[out->via_count++] = item;
+ cursor = NULL;
+ }
+
+ return HYBBX_OK;
+}
+
+int hybbx_packet_radio_baud_valid(unsigned int baud)
+{
+ return baud > 0;
+}
+
+hybbx_result_t hybbx_packet_radio_config_parse(const char *config,
+ hybbx_packet_radio_config_t *out)
+{
+ char scratch[256];
+ const char *value;
+ unsigned long baud;
+ hybbx_result_t rc;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ out->tnc = HYBBX_PACKET_RADIO_TNC_TNC2C;
+ out->protocol = 0; /* profile may choose */
+ out->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ out->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ out->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ out->params.kiss_port = 0;
+ out->params.txdelay = 50;
+ out->params.persist = 0;
+ out->params.slot = 10;
+ out->params.txtail = 0;
+ out->params.band = HYBBX_PACKET_RADIO_BAND_UNSET;
+ out->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ out->params.modulation = HYBBX_PACKET_RADIO_MOD_UNSET;
+ out->params.kiss_entry = HYBBX_KISS_ENTRY_UNSET;
+ out->params.kiss_exit = HYBBX_KISS_EXIT_UNSET;
+ out->params.host_connect_on_start = 0;
+ out->params.assert_modem_lines = -1;
+
+ value = find_kv(config, "device_type", scratch, sizeof(scratch));
+ out->device_type = parse_device_type(value);
+
+ value = find_kv(config, "device", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->device = hybbx_strdup(default_device_for_type(out->device_type));
+ } else {
+ out->device = hybbx_strdup(value);
+ }
+
+ if (out->device == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ value = find_kv(config, "baud", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ out->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ } else {
+ baud = strtoul(value, NULL, 10);
+ if (!hybbx_packet_radio_baud_valid((unsigned int)baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->baud = (unsigned int)baud;
+ }
+
+ value = find_kv(config, "tnc", scratch, sizeof(scratch));
+ out->tnc = parse_tnc(value);
+
+ if (out->tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM ||
+ out->tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ hybbx_log_warn("[packet_radio] tnc=%s is not a packet_radio profile — "
+ "use tnc=tnc2c (or generic) with kiss_entry=none after "
+ "max25d prep; BayCom/based SER12 = MAX25 max25e0 (bcpr), "
+ "optional [networks] baycom=yes on non-UN1TME sites only",
+ hybbx_packet_radio_tnc_name(out->tnc));
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+
+ value = find_kv(config, "protocol", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->protocol = parse_protocol(value);
+ }
+
+ value = find_kv(config, "clock_mhz", scratch, sizeof(scratch));
+ out->params.clock_mhz = parse_clock_mhz(value);
+
+ value = find_kv(config, "modem", scratch, sizeof(scratch));
+ out->params.modem = parse_modem(value);
+
+ value = find_kv(config, "radio_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->params.radio_baud = default_radio_baud_for_modem(out->params.modem);
+ } else {
+ unsigned int radio_baud = parse_uint(value, 0);
+ if (!hybbx_packet_radio_baud_valid(radio_baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->params.radio_baud = radio_baud;
+ }
+
+ value = find_kv(config, "kiss_port", scratch, sizeof(scratch));
+ out->params.kiss_port = parse_uint(value, 0);
+
+ value = find_kv(config, "txdelay", scratch, sizeof(scratch));
+ out->params.txdelay = parse_uint(value, out->params.txdelay);
+
+ value = find_kv(config, "persist", scratch, sizeof(scratch));
+ out->params.persist = parse_uint(value, out->params.persist);
+
+ value = find_kv(config, "slot", scratch, sizeof(scratch));
+ out->params.slot = parse_uint(value, out->params.slot);
+
+ value = find_kv(config, "txtail", scratch, sizeof(scratch));
+ out->params.txtail = parse_uint(value, out->params.txtail);
+
+ value = find_kv(config, "radio_band", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.band = parse_radio_band(value);
+ }
+
+ value = find_kv(config, "radio_duplex", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "duplex", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.duplex = parse_radio_duplex(value);
+ }
+
+ value = find_kv(config, "fullduplex", scratch, sizeof(scratch));
+ if (value != NULL &&
+ out->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ out->params.duplex = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_DUPLEX_FULL :
+ HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ value = find_kv(config, "g3ruh_fsk", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.modulation = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_MOD_G3RUH_FSK :
+ HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ value = find_kv(config, "modulation", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_packet_radio_modulation_t mod = parse_modulation(value);
+ if (mod != HYBBX_PACKET_RADIO_MOD_UNSET) {
+ out->params.modulation = mod;
+ }
+ }
+
+ value = find_kv(config, "kiss_entry", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_entry_t kiss_entry = parse_kiss_entry(value);
+ if (kiss_entry != HYBBX_KISS_ENTRY_UNSET) {
+ out->params.kiss_entry = kiss_entry;
+ }
+ }
+
+ value = find_kv(config, "kiss_exit", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_exit_t kiss_exit = parse_kiss_exit(value);
+ if (kiss_exit != HYBBX_KISS_EXIT_UNSET) {
+ out->params.kiss_exit = kiss_exit;
+ }
+ }
+
+ value = find_kv(config, "host_connect", scratch, sizeof(scratch));
+ out->params.host_connect_on_start = hybbx_parse_bool(value,
+ out->params.host_connect_on_start);
+
+ value = find_kv(config, "serial_line", scratch, sizeof(scratch));
+ if (value != NULL) {
+ apply_serial_line(out, value);
+ }
+
+ value = find_kv(config, "data_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int data_bits = parse_uint(value, 0);
+ if (data_bits == 7 || data_bits == 8) {
+ out->params.data_bits = data_bits;
+ }
+ }
+
+ value = find_kv(config, "parity", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_tnc_serial_parity_t parity = parse_serial_parity(value);
+ if (parity != HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ out->params.serial_parity = parity;
+ }
+ }
+
+ value = find_kv(config, "stop_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int stop_bits = parse_uint(value, 0);
+ if (stop_bits == 1 || stop_bits == 2) {
+ out->params.stop_bits = stop_bits;
+ }
+ }
+
+ value = find_kv(config, "assert_modem_lines", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "rts_dtr", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.assert_modem_lines = hybbx_parse_bool(value, 0) ? 1 : 0;
+ }
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->mycall = hybbx_strdup(value);
+ if (out->mycall == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "dest", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "dest_call", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->dest_call = hybbx_strdup(value);
+ if (out->dest_call == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "via", scratch, sizeof(scratch));
+ rc = parse_via_list(out, value);
+ if (rc != HYBBX_OK) {
+ hybbx_packet_radio_config_free(out);
+ return rc;
+ }
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_host = hybbx_strdup(value);
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "circuit_port", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_port = parse_uint(value, HYBBX_CIRCUIT_DEFAULT_PORT);
+ }
+
+ if (out->circuit_port == 0) {
+ out->circuit_port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (out->circuit_host == NULL) {
+ out->circuit_host = hybbx_strdup("127.0.0.1");
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_id = hybbx_strdup(value);
+ if (out->link_id == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = hybbx_strdup(value);
+ if (out->link_password == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_role = hybbx_strdup(value);
+ if (out->link_role == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = hybbx_strdup(value);
+ if (out->frequency_mhz == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ (void)hybbx_tnc_profile_apply_defaults(out);
+ return HYBBX_OK;
+}
+
+void hybbx_packet_radio_config_free(hybbx_packet_radio_config_t *config)
+{
+ unsigned i;
+
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->device);
+ free(config->mycall);
+ free(config->dest_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ free(config->frequency_mhz);
+ for (i = 0; i < config->via_count; i++) {
+ free(config->via[i]);
+ config->via[i] = NULL;
+ }
+
+ memset(config, 0, sizeof(*config));
+}
diff --git a/plugins/packet_radio/serial_port.c b/plugins/packet_radio/serial_port.c
new file mode 100644
index 0000000..70dd92c
--- /dev/null
+++ b/plugins/packet_radio/serial_port.c
@@ -0,0 +1,705 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "serial_port.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud)
+{
+ if (params == NULL) {
+ return;
+ }
+
+ memset(params, 0, sizeof(*params));
+ params->baud = baud;
+ params->data_bits = 8;
+ params->parity = HYBBX_SERIAL_PARITY_NONE;
+ params->stop_bits = 1;
+ params->assert_modem_lines = 0;
+}
+
+#if defined(_WIN32)
+
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+
+struct hybbx_serial_port {
+ HANDLE handle;
+ unsigned int baud;
+};
+
+static int win32_normalize_device(const char *device, char *out, size_t out_len)
+{
+ const char *name;
+ unsigned com_num = 0;
+
+ if (device == NULL || out_len == 0) {
+ return 0;
+ }
+
+ if (strncmp(device, "\\\\.\\", 4) == 0) {
+ return hybbx_strlcpy(out, device, out_len) < out_len;
+ }
+
+ name = device;
+ if (name[0] == '/' && strncmp(name, "/dev/ttyS", 9) == 0 &&
+ name[9] >= '0' && name[9] <= '9' && name[10] == '\0') {
+ com_num = (unsigned)(name[9] - '0') + 1u;
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+ }
+
+ if ((name[0] == 'C' || name[0] == 'c') &&
+ (name[1] == 'O' || name[1] == 'o') &&
+ (name[2] == 'M' || name[2] == 'm')) {
+ name += 3;
+ }
+
+ if (*name == '\0') {
+ return 0;
+ }
+
+ while (*name >= '0' && *name <= '9') {
+ com_num = com_num * 10u + (unsigned)(*name - '0');
+ name++;
+ }
+ if (*name != '\0' || com_num == 0) {
+ return 0;
+ }
+
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+}
+
+static hybbx_result_t win32_configure(HANDLE handle,
+ const hybbx_serial_params_t *params)
+{
+ DCB dcb;
+ COMMTIMEOUTS timeouts;
+
+ memset(&dcb, 0, sizeof(dcb));
+ dcb.DCBlength = sizeof(dcb);
+ if (!GetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ dcb.BaudRate = (DWORD)params->baud;
+ dcb.ByteSize = (BYTE)(params->data_bits == 7 ? 7 : 8);
+ switch (params->parity) {
+ case HYBBX_SERIAL_PARITY_EVEN:
+ dcb.Parity = EVENPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_ODD:
+ dcb.Parity = ODDPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_NONE:
+ default:
+ dcb.Parity = NOPARITY;
+ dcb.fParity = FALSE;
+ break;
+ }
+ dcb.StopBits = params->stop_bits == 2 ? TWOSTOPBITS : ONESTOPBIT;
+ dcb.fBinary = TRUE;
+ dcb.fOutxCtsFlow = FALSE;
+ dcb.fOutxDsrFlow = FALSE;
+ dcb.fDtrControl = params->assert_modem_lines ?
+ DTR_CONTROL_ENABLE : DTR_CONTROL_DISABLE;
+ dcb.fRtsControl = params->assert_modem_lines ?
+ RTS_CONTROL_ENABLE : RTS_CONTROL_DISABLE;
+ dcb.fOutX = FALSE;
+ dcb.fInX = FALSE;
+ dcb.fNull = FALSE;
+
+ if (!SetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ memset(&timeouts, 0, sizeof(timeouts));
+ timeouts.ReadIntervalTimeout = MAXDWORD;
+ timeouts.ReadTotalTimeoutConstant = 0;
+ timeouts.ReadTotalTimeoutMultiplier = 0;
+ timeouts.WriteTotalTimeoutConstant = 0;
+ timeouts.WriteTotalTimeoutMultiplier = 0;
+ if (!SetCommTimeouts(handle, &timeouts)) {
+ return HYBBX_ERR_IO;
+ }
+
+ PurgeComm(handle, PURGE_RXCLEAR | PURGE_TXCLEAR);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ char path[64];
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!win32_normalize_device(device, path, sizeof(path))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->handle = CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 0, NULL,
+ OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (port->handle == INVALID_HANDLE_VALUE) {
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = win32_configure(port->handle, params);
+ if (rc != HYBBX_OK) {
+ CloseHandle(port->handle);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->handle != INVALID_HANDLE_VALUE) {
+ CloseHandle(port->handle);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (written < len) {
+ DWORD chunk = 0;
+
+ if (!WriteFile(port->handle, data + written,
+ (DWORD)(len - written), &chunk, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+ if (chunk == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ DWORD n = 0;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!ReadFile(port->handle, buf, (DWORD)buf_len, &n, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#elif defined(__AMIGA__)
+
+#include <proto/exec.h>
+#include <devices/serial.h>
+
+struct hybbx_serial_port {
+ struct IOExtSer *io;
+ struct MsgPort *port;
+ char device_name[32];
+ unsigned long unit;
+ unsigned int baud;
+};
+
+static int amiga_parse_device(const char *device, char *name, size_t name_len,
+ unsigned long *unit)
+{
+ const char *colon;
+ char *end;
+
+ if (unit == NULL || name == NULL || name_len == 0) {
+ return 0;
+ }
+
+ *unit = 0;
+ if (device == NULL || device[0] == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+
+ colon = strchr(device, ':');
+ if (colon != NULL) {
+ size_t prefix = (size_t)(colon - device);
+
+ if (prefix == 0 || prefix >= name_len) {
+ return 0;
+ }
+ memcpy(name, device, prefix);
+ name[prefix] = '\0';
+ *unit = strtoul(colon + 1, &end, 10);
+ return end != colon + 1 && *end == '\0';
+ }
+
+ if (device[0] >= '0' && device[0] <= '9') {
+ *unit = strtoul(device, &end, 10);
+ if (end != device && *end == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+ }
+
+ hybbx_strlcpy(name, device, name_len);
+ return 1;
+}
+
+static hybbx_result_t amiga_configure(struct IOExtSer *io,
+ const hybbx_serial_params_t *params)
+{
+ io->IOSer.io_Command = SDCMD_SETPARAMS;
+ io->io_Baud = (ULONG)params->baud;
+ io->io_ReadLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_WriteLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_StopBits = (UBYTE)(params->stop_bits == 2 ? 2 : 1);
+ io->io_SerFlags = (UBYTE)(io->io_SerFlags & (UBYTE)~SERF_PARTY_ON);
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN ||
+ params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ io->io_SerFlags |= SERF_PARTY_ON;
+ }
+ io->io_SerFlags |= SERF_XDISABLED;
+
+ if (DoIO((struct IORequest *)io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (!amiga_parse_device(device, port->device_name, sizeof(port->device_name),
+ &port->unit)) {
+ free(port);
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->port = CreatePort(NULL, 0);
+ if (port->port == NULL) {
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io = (struct IOExtSer *)CreateExtIO(port->port,
+ (LONG)sizeof(*port->io));
+ if (port->io == NULL) {
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io->io_SerFlags = SERF_SHARED | SERF_XDISABLED;
+ if (OpenDevice(port->device_name, port->unit,
+ (struct IORequest *)port->io, 0) != 0) {
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = amiga_configure(port->io, params);
+ if (rc != HYBBX_OK) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->io != NULL) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ }
+ if (port->port != NULL) {
+ DeletePort(port->port);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ if (port == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_WRITE;
+ port->io->IOSer.io_Data = (APTR)data;
+ port->io->IOSer.io_Length = (ULONG)len;
+ port->io->IOSer.io_Flags = 0;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ if (port == NULL || buf == NULL || read_len == NULL || buf_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_READ;
+ port->io->IOSer.io_Data = (APTR)buf;
+ port->io->IOSer.io_Length = (ULONG)buf_len;
+ port->io->IOSer.io_Flags = IOF_QUICK;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+
+ *read_len = (size_t)port->io->IOSer.io_Actual;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#else /* POSIX */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+struct hybbx_serial_port {
+ int fd;
+ unsigned int baud;
+ int assert_modem_lines;
+};
+
+#ifndef cfmakeraw
+static void hybbx_cfmakeraw(struct termios *tty)
+{
+ tty->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR |
+ IGNCR | ICRNL | IXON);
+ tty->c_oflag &= ~OPOST;
+ tty->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
+ tty->c_cflag &= ~(CSIZE | PARENB);
+ tty->c_cflag |= CS8;
+}
+#define cfmakeraw hybbx_cfmakeraw
+#endif
+
+static hybbx_result_t set_baud_rate(struct termios *tty, unsigned int baud)
+{
+#if defined(__linux__) || defined(__GLIBC__)
+ if (cfsetspeed(tty, (speed_t)baud) == 0) {
+ return HYBBX_OK;
+ }
+#endif
+
+ {
+ speed_t speed = B0;
+ switch (baud) {
+ case 50: speed = B50; break;
+ case 75: speed = B75; break;
+ case 110: speed = B110; break;
+ case 134: speed = B134; break;
+ case 150: speed = B150; break;
+ case 200: speed = B200; break;
+ case 300: speed = B300; break;
+ case 600: speed = B600; break;
+ case 1200: speed = B1200; break;
+ case 1800: speed = B1800; break;
+ case 2400: speed = B2400; break;
+ case 4800: speed = B4800; break;
+ case 9600: speed = B9600; break;
+#ifdef B19200
+ case 19200: speed = B19200; break;
+#endif
+#ifdef B38400
+ case 38400: speed = B38400; break;
+#endif
+#ifdef B57600
+ case 57600: speed = B57600; break;
+#endif
+#ifdef B115200
+ case 115200: speed = B115200; break;
+#endif
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ cfsetispeed(tty, speed);
+ cfsetospeed(tty, speed);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t posix_assert_modem_lines(int fd, int on)
+{
+#ifdef TIOCMGET
+ int flags;
+
+ /* BayCom KISS PTY (max25-bcpr): assert_modem_lines=0 — no UART modem bits.
+ * TIOCMGET on /dev/pts/* often fails; skip when not asserting. */
+ if (!on) {
+ return HYBBX_OK;
+ }
+
+ if (ioctl(fd, TIOCMGET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+ flags |= TIOCM_RTS | TIOCM_DTR;
+ if (ioctl(fd, TIOCMSET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+#else
+ (void)fd;
+ (void)on;
+#endif
+ return HYBBX_OK;
+}
+
+static hybbx_result_t configure_port(int fd, const hybbx_serial_params_t *params)
+{
+ struct termios tty;
+ tcflag_t databits;
+
+ if (tcgetattr(fd, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ cfmakeraw(&tty);
+ tty.c_cflag |= (CLOCAL | CREAD);
+ tty.c_cflag &= ~(CSIZE | PARENB | PARODD | CSTOPB);
+ if (params->data_bits == 7) {
+ databits = CS7;
+ } else {
+ databits = CS8;
+ }
+ tty.c_cflag |= databits;
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN) {
+ tty.c_cflag |= PARENB;
+ } else if (params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ tty.c_cflag |= PARENB | PARODD;
+ }
+ if (params->stop_bits == 2) {
+ tty.c_cflag |= CSTOPB;
+ }
+ tty.c_cc[VMIN] = 0;
+ tty.c_cc[VTIME] = 1;
+
+ if (set_baud_rate(&tty, params->baud) != HYBBX_OK) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tcsetattr(fd, TCSANOW, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ tcflush(fd, TCIOFLUSH);
+ return posix_assert_modem_lines(fd, params->assert_modem_lines);
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK);
+ if (port->fd < 0) {
+ hybbx_log_warn("[serial] open %s failed: %s", device, strerror(errno));
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = configure_port(port->fd, params);
+ if (rc != HYBBX_OK) {
+ close(port->fd);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ port->assert_modem_lines = params->assert_modem_lines;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->fd >= 0) {
+ close(port->fd);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (port->assert_modem_lines) {
+ hybbx_result_t rc = posix_assert_modem_lines(port->fd, 1);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (written < len) {
+ ssize_t n = write(port->fd, data + written, len - written);
+
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)n;
+ }
+
+ if (tcdrain(port->fd) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ ssize_t n;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = read(port->fd, buf, buf_len);
+ 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;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return -1;
+ }
+
+ return port->fd;
+}
+
+#endif
diff --git a/plugins/packet_radio/serial_port.h b/plugins/packet_radio/serial_port.h
new file mode 100644
index 0000000..549d3c5
--- /dev/null
+++ b/plugins/packet_radio/serial_port.h
@@ -0,0 +1,52 @@
+#ifndef HYBBX_SERIAL_PORT_H
+#define HYBBX_SERIAL_PORT_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_serial_port hybbx_serial_port_t;
+
+typedef enum hybbx_serial_parity {
+ HYBBX_SERIAL_PARITY_NONE = 0,
+ HYBBX_SERIAL_PARITY_EVEN = 1,
+ HYBBX_SERIAL_PARITY_ODD = 2
+} hybbx_serial_parity_t;
+
+typedef struct hybbx_serial_params {
+ unsigned int baud;
+ unsigned int data_bits;
+ hybbx_serial_parity_t parity;
+ unsigned int stop_bits;
+ int assert_modem_lines;
+} hybbx_serial_params_t;
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud);
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params);
+
+void hybbx_serial_close(hybbx_serial_port_t *port);
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len);
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len);
+
+/** POSIX serial fd for poll/select; returns -1 when unavailable. */
+int hybbx_serial_fd(const hybbx_serial_port_t *port);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SERIAL_PORT_H */
diff --git a/plugins/packet_radio/sixpack.c b/plugins/packet_radio/sixpack.c
new file mode 100644
index 0000000..28c0510
--- /dev/null
+++ b/plugins/packet_radio/sixpack.c
@@ -0,0 +1,146 @@
+#include "sixpack.h"
+
+#include <string.h>
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void sixpack_reset(hybbx_sixpack_decoder_t *dec)
+{
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ dec->in_frame = 0;
+}
+
+static void sixpack_deliver(hybbx_sixpack_decoder_t *dec,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ if (cb != NULL && dec->len > 0) {
+ cb(dec->buf, dec->len, userdata);
+ }
+
+ sixpack_reset(dec);
+}
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t raw = data[i];
+ uint8_t byte = raw;
+
+ if (!dec->in_frame) {
+ if (raw == HYBBX_SIXPACK_LEAD) {
+ dec->in_frame = 1;
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ }
+ continue;
+ }
+
+ if (dec->count == 0) {
+ dec->count = raw;
+ dec->checksum = raw;
+ continue;
+ }
+
+ dec->count--;
+ dec->checksum = (uint8_t)(dec->checksum + raw);
+
+ if (raw < 0x30u) {
+ byte = (uint8_t)(raw + 0x40u);
+ }
+
+ if (dec->count > 0) {
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ sixpack_reset(dec);
+ continue;
+ }
+ dec->buf[dec->len++] = byte;
+ continue;
+ }
+
+ if (dec->checksum == 0) {
+ sixpack_deliver(dec, cb, userdata);
+ } else {
+ sixpack_reset(dec);
+ }
+ }
+}
+
+static size_t sixpack_write_encoded(uint8_t byte, uint8_t *out, size_t out_cap,
+ size_t pos)
+{
+ if (byte < 0x30u) {
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+ out[pos++] = (uint8_t)(byte + 0x40u);
+ return pos;
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap)
+{
+ uint8_t scratch[HYBBX_KISS_MAX_FRAME + 4];
+ size_t enc_len = 0;
+ size_t i;
+ uint8_t count;
+ uint8_t sum;
+ size_t pos = 0;
+
+ if (frame == NULL || out == NULL || frame_len == 0 ||
+ frame_len > HYBBX_KISS_MAX_FRAME) {
+ return 0;
+ }
+
+ for (i = 0; i < frame_len; i++) {
+ enc_len = sixpack_write_encoded(frame[i], scratch, sizeof(scratch),
+ enc_len);
+ if (enc_len == 0) {
+ return 0;
+ }
+ }
+
+ if (enc_len + 4 > out_cap || enc_len + 1 > 255) {
+ return 0;
+ }
+
+ count = (uint8_t)(enc_len + 1);
+ sum = count;
+ for (i = 0; i < enc_len; i++) {
+ sum = (uint8_t)(sum + scratch[i]);
+ }
+
+ out[pos++] = HYBBX_SIXPACK_LEAD;
+ out[pos++] = count;
+ for (i = 0; i < enc_len; i++) {
+ out[pos++] = scratch[i];
+ }
+ out[pos++] = (uint8_t)(0x100u - (unsigned)sum);
+
+ return pos;
+}
diff --git a/plugins/packet_radio/sixpack.h b/plugins/packet_radio/sixpack.h
new file mode 100644
index 0000000..0d60e87
--- /dev/null
+++ b/plugins/packet_radio/sixpack.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_SIXPACK_H
+#define HYBBX_SIXPACK_H
+
+#include "hybbx/kiss.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_SIXPACK_LEAD 0x53u /* 'S' */
+
+typedef void (*hybbx_sixpack_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+typedef struct hybbx_sixpack_decoder {
+ uint8_t buf[HYBBX_KISS_MAX_FRAME];
+ size_t len;
+ int in_frame;
+ uint8_t count;
+ uint8_t checksum;
+} hybbx_sixpack_decoder_t;
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec);
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata);
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SIXPACK_H */
diff --git a/plugins/packet_radio/tnc.c b/plugins/packet_radio/tnc.c
new file mode 100644
index 0000000..a42480d
--- /dev/null
+++ b/plugins/packet_radio/tnc.c
@@ -0,0 +1,1080 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/tnc.h"
+#include "hybbx/kiss.h"
+#include "hybbx/rf_tx_pace.h"
+#include "hybbx/traffic.h"
+#include "serial_port.h"
+#include "sixpack.h"
+#include "tnc_host.h"
+
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#if !defined(_WIN32)
+#include <sys/select.h>
+#endif
+
+struct hybbx_tnc {
+ hybbx_packet_radio_config_t config;
+ hybbx_serial_port_t *serial;
+ hybbx_kiss_decoder_t kiss_dec;
+ hybbx_sixpack_decoder_t sixpack_dec;
+ hybbx_tnc_host_t host;
+ hybbx_ax25_path_t tx_path;
+ int kiss_active;
+ hybbx_kiss_entry_t kiss_entry_used;
+ int host_ready;
+ hybbx_tnc_frame_cb frame_cb;
+ hybbx_tnc_ui_cb ui_cb;
+ void *rx_userdata;
+ unsigned long long last_rx_ms;
+};
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static void config_copy(hybbx_packet_radio_config_t *dst,
+ const hybbx_packet_radio_config_t *src)
+{
+ unsigned i;
+
+ memset(dst, 0, sizeof(*dst));
+ dst->device_type = src->device_type;
+ dst->device = src->device;
+ dst->baud = src->baud;
+ dst->tnc = src->tnc;
+ dst->protocol = src->protocol;
+ dst->params = src->params;
+ dst->mycall = src->mycall;
+ dst->dest_call = src->dest_call;
+ dst->via_count = src->via_count;
+ for (i = 0; i < src->via_count; i++) {
+ dst->via[i] = src->via[i];
+ }
+}
+
+static unsigned long long tnc_monotonic_ms(void)
+{
+#if defined(CLOCK_MONOTONIC)
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) {
+ return (unsigned long long)ts.tv_sec * 1000ull +
+ (unsigned long long)ts.tv_nsec / 1000000ull;
+ }
+#endif
+ return (unsigned long long)time(NULL) * 1000ull;
+}
+
+static void tnc_note_rx(hybbx_tnc_t *tnc)
+{
+ if (tnc != NULL) {
+ tnc->last_rx_ms = tnc_monotonic_ms();
+ }
+}
+
+static hybbx_result_t tnc_wait_half_duplex_clear(hybbx_tnc_t *tnc)
+{
+ unsigned guard_ms;
+ unsigned long long now;
+ unsigned long long elapsed;
+
+ if (tnc == NULL || hybbx_tnc_duplex_is_full(tnc)) {
+ return HYBBX_OK;
+ }
+
+ guard_ms = tnc->config.params.slot * 10u;
+ if (guard_ms < 50u) {
+ guard_ms = 50u;
+ }
+
+ now = tnc_monotonic_ms();
+ if (tnc->last_rx_ms == 0 || now <= tnc->last_rx_ms) {
+ return HYBBX_OK;
+ }
+
+ elapsed = now - tnc->last_rx_ms;
+ if (elapsed >= guard_ms) {
+ return HYBBX_OK;
+ }
+
+#if defined(_WIN32)
+ Sleep((DWORD)(guard_ms - elapsed));
+#else
+ {
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)((guard_ms - elapsed) / 1000u);
+ tv.tv_usec = (suseconds_t)(((guard_ms - elapsed) % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+ }
+#endif
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_raw(hybbx_tnc_t *tnc, const uint8_t *data, size_t len)
+{
+ return hybbx_serial_write(tnc->serial, data, len);
+}
+
+static hybbx_result_t send_cmd(hybbx_tnc_t *tnc, const char *cmd)
+{
+ char line[128];
+ size_t len;
+
+ len = hybbx_tnc_host_format_cmd(cmd, line, sizeof(line));
+ if (len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return send_raw(tnc, (const uint8_t *)line, len);
+}
+
+static hybbx_result_t drain_serial(hybbx_tnc_t *tnc, unsigned rounds)
+{
+ uint8_t buf[128];
+ size_t read_len;
+ unsigned i;
+
+ for (i = 0; i < rounds; i++) {
+ if (hybbx_serial_read(tnc->serial, buf, sizeof(buf),
+ &read_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (read_len == 0) {
+ break;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_kiss_param(hybbx_tnc_t *tnc,
+ hybbx_kiss_command_t cmd,
+ uint8_t value)
+{
+ uint8_t frame[16];
+ size_t len;
+
+ len = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port, cmd,
+ &value, 1, frame, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return send_raw(tnc, frame, len);
+}
+
+static hybbx_result_t tnc_apply_kiss_params(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXDELAY,
+ (uint8_t)tnc->config.params.txdelay);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_SLOTTIME,
+ (uint8_t)tnc->config.params.slot);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXTAIL,
+ (uint8_t)tnc->config.params.txtail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_kiss_param(tnc, HYBBX_KISS_CMD_FULLDUPLEX,
+ (uint8_t)(tnc->config.params.fullduplex ? 1 : 0));
+}
+
+static hybbx_result_t tnc_send_esc_at_k(hybbx_tnc_t *tnc)
+{
+ const uint8_t seq[] = { 0x1b, '@', 'K' };
+
+ return send_raw(tnc, seq, sizeof(seq));
+}
+
+static hybbx_result_t tnc_send_kiss_return_frame(hybbx_tnc_t *tnc)
+{
+ const uint8_t frame[] = { HYBBX_KISS_FEND, 0xFFu, HYBBX_KISS_FEND };
+
+ return send_raw(tnc, frame, sizeof(frame));
+}
+
+static void tnc_serial_pause_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timespec ts;
+
+ ts.tv_sec = (time_t)(ms / 1000u);
+ ts.tv_nsec = (long)((ms % 1000u) * 1000000u);
+ (void)nanosleep(&ts, NULL);
+#endif
+}
+
+static hybbx_result_t tnc_send_jhost0(hybbx_tnc_t *tnc)
+{
+ static const uint8_t nuls[300] = { 0 };
+ static const uint8_t jhost0[] = {
+ 0x00, 0x01, 0x06, 'J', 'H', 'O', 'S', 'T', ' ', '0', '\r'
+ };
+ hybbx_result_t rc;
+
+ rc = send_raw(tnc, nuls, sizeof(nuls));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ tnc_serial_pause_ms(150);
+ return send_raw(tnc, jhost0, sizeof(jhost0));
+}
+
+static hybbx_result_t tnc_send_restart_escape(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+
+ for (i = 0; i < 3u; i++) {
+ if (send_raw(tnc, (const uint8_t *)"\x03", 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ tnc_serial_pause_ms(80);
+ }
+ tnc_serial_pause_ms(300);
+ return send_cmd(tnc, "RESTART");
+}
+
+/** Leave KISS/host/transparency → terminal (TheFirmware ladder). */
+static hybbx_result_t tnc_recover_terminal_mode(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc->kiss_active = 0;
+ (void)tnc_send_kiss_return_frame(tnc);
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 8);
+
+ (void)tnc_send_jhost0(tnc);
+ tnc_serial_pause_ms(800);
+ drain_serial(tnc, 8);
+
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 6);
+ (void)send_cmd(tnc, "INFO");
+ drain_serial(tnc, 12);
+
+ (void)tnc_send_restart_escape(tnc);
+ tnc_serial_pause_ms(1200);
+ drain_serial(tnc, 8);
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(300);
+ drain_serial(tnc, 4);
+
+ return HYBBX_OK;
+}
+
+/** Leave KISS (crash/kill) so host commands (MYCALL, kiss on) work again. */
+static hybbx_result_t tnc_kiss_wakeup_host(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return tnc_recover_terminal_mode(tnc);
+}
+
+static hybbx_result_t tnc_enter_kiss_by_entry(hybbx_tnc_t *tnc,
+ hybbx_kiss_entry_t entry)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ if (tnc_send_esc_at_k(tnc) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ if (send_cmd(tnc, "kiss on") != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_AUTO:
+ if (send_cmd(tnc, "kiss on") == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+ }
+ if (tnc_send_esc_at_k(tnc) == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return HYBBX_ERR_INVALID;
+ }
+}
+
+static void tnc_exit_kiss_mode(hybbx_tnc_t *tnc)
+{
+ hybbx_kiss_exit_t exit_mode;
+
+ if (tnc == NULL || !tnc->kiss_active ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return;
+ }
+
+ exit_mode = tnc->config.params.kiss_exit;
+ if (exit_mode == HYBBX_KISS_EXIT_UNSET) {
+ exit_mode = HYBBX_KISS_EXIT_AUTO;
+ }
+ if (exit_mode == HYBBX_KISS_EXIT_AUTO) {
+ if (tnc->kiss_entry_used == HYBBX_KISS_ENTRY_ESC_AT_K) {
+ exit_mode = HYBBX_KISS_EXIT_KISS_FRAME;
+ } else {
+ exit_mode = HYBBX_KISS_EXIT_KISS_OFF;
+ }
+ }
+
+ switch (exit_mode) {
+ case HYBBX_KISS_EXIT_KISS_FRAME:
+ (void)tnc_send_kiss_return_frame(tnc);
+ break;
+ case HYBBX_KISS_EXIT_KISS_OFF:
+ (void)send_cmd(tnc, "kiss off");
+ break;
+ case HYBBX_KISS_EXIT_NONE:
+ case HYBBX_KISS_EXIT_UNSET:
+ default:
+ break;
+ }
+}
+
+static hybbx_result_t tnc_enter_kiss_mode(hybbx_tnc_t *tnc)
+{
+ return tnc_enter_kiss_by_entry(tnc, tnc->config.params.kiss_entry);
+}
+
+static void tnc_deliver_ui(hybbx_tnc_t *tnc, const uint8_t *frame, size_t len)
+{
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len == 0) {
+ return;
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_kiss_frame(uint8_t port, const uint8_t *frame, size_t len,
+ void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ (void)port;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb == NULL) {
+ return;
+ }
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len > 0) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+}
+
+static void on_sixpack_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc_deliver_ui(tnc, frame, len);
+ }
+}
+
+static void on_host_text(const uint8_t *data, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(data, len, NULL, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_host_event(hybbx_tnc_host_state_t state, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (state == HYBBX_TNC_HOST_CMD) {
+ tnc->host_ready = 1;
+ }
+
+ hybbx_log_debug("[tnc] host state -> %d", (int)state);
+}
+
+static hybbx_result_t tnc_build_tx_path(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+ hybbx_result_t rc;
+
+ memset(&tnc->tx_path, 0, sizeof(tnc->tx_path));
+
+ if (tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.dest_call,
+ &tnc->tx_path.dest);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("QST", &tnc->tx_path.dest);
+ }
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.mycall,
+ &tnc->tx_path.source);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("HYBBX", &tnc->tx_path.source);
+ }
+
+ for (i = 0; i < tnc->config.via_count; i++) {
+ rc = hybbx_ax25_address_parse(tnc->config.via[i],
+ &tnc->tx_path.digi[i]);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ tnc->tx_path.digi_count = tnc->config.via_count;
+
+ return HYBBX_OK;
+}
+
+static const char *serial_parity_letter(hybbx_tnc_serial_parity_t parity)
+{
+ switch (parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ return "E";
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ return "O";
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ return "N";
+ }
+}
+
+static void tnc_serial_params_from_config(const hybbx_packet_radio_config_t *config,
+ hybbx_serial_params_t *out)
+{
+ hybbx_serial_params_default(out, config->baud);
+ out->data_bits = config->params.data_bits;
+ out->stop_bits = config->params.stop_bits;
+ out->assert_modem_lines = config->params.assert_modem_lines;
+
+ switch (config->params.serial_parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ out->parity = HYBBX_SERIAL_PARITY_EVEN;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ out->parity = HYBBX_SERIAL_PARITY_ODD;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ out->parity = HYBBX_SERIAL_PARITY_NONE;
+ break;
+ }
+}
+
+static const char *tnc_profile_name(hybbx_packet_radio_tnc_t tnc)
+{
+ return hybbx_packet_radio_tnc_name(tnc);
+}
+
+static const char *modem_name(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return "9600";
+ case HYBBX_TNC2C_MODEM_19200:
+ return "19200";
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return "tcm3105";
+ }
+}
+
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static hybbx_result_t tnc_profile_init(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+ char cmd[96];
+
+ rc = tnc_build_tx_path(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ if (tnc->config.params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ hybbx_log_info("[tnc] KISS attach (MAX25 prep assumed)");
+ return HYBBX_OK;
+ }
+
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ (void)tnc_recover_terminal_mode(tnc);
+ hybbx_log_info("[tnc] TheFirmware terminal recovery before KISS entry");
+ }
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ tnc->kiss_active = 0;
+ return HYBBX_OK;
+ }
+
+ /* Host mode setup (firmware TNC profiles only; not BayCom/based SER12 / PC-COM modem). */
+ drain_serial(tnc, 4);
+ (void)send_cmd(tnc, "");
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "MYCALL %s", tnc->config.mycall);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ snprintf(cmd, sizeof(cmd), "TXDELAY %u", tnc->config.params.txdelay);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "Persist %u", tnc->config.params.persist);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "SlotTime %u", tnc->config.params.slot);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "TXTAIL %u", tnc->config.params.txtail);
+ (void)send_cmd(tnc, cmd);
+
+ if (tnc->config.params.fullduplex) {
+ (void)send_cmd(tnc, "FULLDUP ON");
+ } else {
+ (void)send_cmd(tnc, "FULLDUP OFF");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ (void)send_cmd(tnc, "MAXFRAME 4");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ }
+
+ if (tnc->config.params.host_connect_on_start &&
+ tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "C %s", tnc->config.dest_call);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ drain_serial(tnc, 6);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_open(hybbx_tnc_t **out,
+ const hybbx_packet_radio_config_t *config,
+ hybbx_tnc_frame_cb frame_cb,
+ hybbx_tnc_ui_cb ui_cb,
+ void *rx_userdata)
+{
+ hybbx_tnc_t *tnc;
+ hybbx_result_t rc;
+
+ if (out == NULL || config == NULL || config->device == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (frame_cb == NULL && ui_cb == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc = calloc(1, sizeof(*tnc));
+ if (tnc == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ config_copy(&tnc->config, config);
+ tnc->frame_cb = frame_cb;
+ tnc->ui_cb = ui_cb;
+ tnc->rx_userdata = rx_userdata;
+
+ hybbx_kiss_decoder_init(&tnc->kiss_dec);
+ hybbx_sixpack_decoder_init(&tnc->sixpack_dec);
+ hybbx_tnc_host_init(&tnc->host, on_host_text, on_host_event, tnc);
+
+ hybbx_serial_params_t serial_params;
+
+ tnc_serial_params_from_config(config, &serial_params);
+
+ rc = hybbx_serial_open(&tnc->serial, config->device, &serial_params);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] cannot open %s at %u baud (%u%c%u) — "
+ "device not available or permission denied",
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits);
+ free(tnc);
+ return rc;
+ }
+
+ hybbx_log_info("[tnc] profile=%s protocol=%u device=%s host=%u %u%c%u rts_dtr=%s "
+ "kiss_entry=%s radio=%u modem=%s modulation=%s band=%s duplex=%s "
+ "txdelay=%u persist=%u",
+ tnc_profile_name(config->tnc), (unsigned)config->protocol,
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits,
+ serial_params.assert_modem_lines ? "on" : "off",
+ hybbx_kiss_entry_name(config->params.kiss_entry),
+ config->params.radio_baud,
+ modem_name(config->params.modem),
+ hybbx_packet_radio_modulation_name(config->params.modulation),
+ hybbx_packet_radio_band_name(config->params.band),
+ hybbx_packet_radio_duplex_name(config->params.duplex),
+ config->params.txdelay,
+ config->params.persist);
+
+ if (config->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ serial_params.data_bits == 8 &&
+ config->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_NONE) {
+ hybbx_log_warn("[tnc] tnc2c %s: 8N1 host line — use serial_line=7E1 if KISS/UNPROTO TX fails",
+ config->device);
+ }
+
+ rc = tnc_profile_init(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_tnc_close(tnc);
+ return rc;
+ }
+
+ if (config->protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ hybbx_log_info("[tnc] KISS active (port %u)", config->params.kiss_port);
+ if (tnc_profile_thefirmware(config->tnc)) {
+ hybbx_log_info("[tnc] UI transmit=TheFirmware UNPROTO (KISS for RX)");
+ }
+ } else if (config->protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ hybbx_log_info("[tnc] 6PACK active");
+ } else {
+ hybbx_log_info("[tnc] TNC2 host mode active");
+ }
+
+ *out = tnc;
+ return HYBBX_OK;
+}
+
+void hybbx_tnc_close(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return;
+ }
+
+ if (tnc->serial != NULL) {
+ tnc_exit_kiss_mode(tnc);
+ hybbx_serial_close(tnc->serial);
+ }
+
+ free(tnc);
+}
+
+int hybbx_tnc_serial_fd(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL || tnc->serial == NULL) {
+ return -1;
+ }
+
+ return hybbx_serial_fd(tnc->serial);
+}
+
+static hybbx_result_t tnc_ensure_kiss_tx(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_OK;
+ }
+
+ if (!tnc->kiss_active) {
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS TX blocked — host mode (re-entry failed)");
+ return rc;
+ }
+ hybbx_log_info("[tnc] KISS re-entered before RF TX");
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ return HYBBX_OK;
+}
+
+/** KISS DATA: host sends AX.25 body only; TheFirmware adds FCS on air. */
+static size_t tnc_kiss_ax25_len(const uint8_t *frame, size_t len)
+{
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (len < 16) {
+ return len;
+ }
+
+ crc_rx = (uint16_t)frame[len - 2] | ((uint16_t)frame[len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, len - 2);
+ if (crc_rx == crc_calc) {
+ return len - 2;
+ }
+
+ return len;
+}
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+static void tnc_format_ax25_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (addr == NULL || out == NULL || out_len == 0) {
+ return;
+ }
+
+ snprintf(out, out_len, "%s", addr->call);
+ if (addr->ssid > 0) {
+ size_t n = strlen(out);
+
+ if (n + 4 < out_len) {
+ snprintf(out + n, out_len - n, "-%u", addr->ssid);
+ }
+ }
+}
+
+/**
+ * TheFirmware (TNC-2 class) often ignores KISS DATA on hybrid host/KISS builds
+ * while UNPROTO from cmd: mode keys PTT reliably. Exit KISS, send, re-enter.
+ */
+static hybbx_result_t tnc_thefirmware_unproto_send(hybbx_tnc_t *tnc,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload,
+ size_t payload_len)
+{
+ char cmd[320];
+ char dest[HYBBX_AX25_CALL_MAX + 8];
+ hybbx_result_t rc;
+
+ if (tnc == NULL || path == NULL || payload == NULL || payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (payload_len + 64 >= sizeof(cmd)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc_format_ax25_call(&path->dest, dest, sizeof(dest));
+
+ rc = tnc_kiss_wakeup_host(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (void)send_cmd(tnc, "MON OFF");
+
+ snprintf(cmd, sizeof(cmd), "UNPROTO %s 0 %.*s",
+ dest, (int)payload_len, (const char *)payload);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] UNPROTO %s host send failed", dest);
+ return rc;
+ }
+
+ drain_serial(tnc, 24);
+ tnc_serial_pause_ms(500);
+
+ hybbx_log_info("[tnc] TheFirmware UNPROTO %s (%zu bytes)", dest, payload_len);
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS re-entry after UNPROTO failed");
+ return rc;
+ }
+
+ return tnc_apply_kiss_params(tnc);
+}
+
+hybbx_result_t hybbx_tnc_send_frame(hybbx_tnc_t *tnc,
+ const uint8_t *frame, size_t len)
+{
+ uint8_t buf[HYBBX_KISS_MAX_FRAME + 8];
+ size_t encoded;
+ hybbx_result_t rc;
+
+ if (tnc == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_pace();
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ encoded = hybbx_sixpack_encode(frame, len, buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+ return send_raw(tnc, buf, encoded);
+ }
+
+ if (!tnc->kiss_active &&
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS &&
+ tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(frame, len, &path, ui, sizeof(ui));
+
+ if (ui_len > 0) {
+ return tnc_thefirmware_unproto_send(tnc, &path, ui, ui_len);
+ }
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ rc = tnc_ensure_kiss_tx(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ const uint8_t *tx_frame = frame;
+ size_t tx_len = len;
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ tx_len = tnc_kiss_ax25_len(frame, len);
+ tx_frame = frame;
+ (void)send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_log_warn("[tnc] UI parse failed (%zu bytes) — KISS DATA fallback",
+ len);
+ }
+ }
+
+ encoded = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port,
+ HYBBX_KISS_CMD_DATA, tx_frame, tx_len,
+ buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_debug("[tnc] KISS DATA ax25=%zu kiss=%zu%s",
+ tx_len, encoded,
+ tx_len + 2 == len ? " (fcs stripped)" : "");
+
+ return send_raw(tnc, buf, encoded);
+ }
+}
+
+hybbx_result_t hybbx_tnc_send_ui(hybbx_tnc_t *tnc,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(&tnc->tx_path, payload, len,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_tnc_send_frame(tnc, frame, frame_len);
+}
+
+hybbx_result_t hybbx_tnc_send_converse(hybbx_tnc_t *tnc,
+ const char *data, size_t len)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_raw(tnc, (const uint8_t *)data, len);
+}
+
+hybbx_packet_radio_duplex_t hybbx_tnc_duplex(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return tnc->config.params.duplex;
+}
+
+hybbx_packet_radio_band_t hybbx_tnc_band(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ return tnc->config.params.band;
+}
+
+int hybbx_tnc_duplex_is_full(const hybbx_tnc_t *tnc)
+{
+ return tnc != NULL &&
+ tnc->config.params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL;
+}
+
+int hybbx_tnc_host_connected(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return 0;
+ }
+
+ return tnc->host.state == HYBBX_TNC_HOST_CONNECTED;
+}
+
+hybbx_result_t hybbx_tnc_poll(hybbx_tnc_t *tnc)
+{
+ uint8_t buf[256];
+ size_t read_len;
+ hybbx_result_t rc;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_serial_read(tnc->serial, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (read_len == 0) {
+ return HYBBX_OK;
+ }
+
+ switch (tnc->config.protocol) {
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ hybbx_kiss_decoder_feed(&tnc->kiss_dec, buf, read_len,
+ on_kiss_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ hybbx_sixpack_decoder_feed(&tnc->sixpack_dec, buf, read_len,
+ on_sixpack_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ hybbx_tnc_host_feed(&tnc->host, buf, read_len);
+ break;
+ default:
+ break;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/packet_radio/tnc_host.c b/plugins/packet_radio/tnc_host.c
new file mode 100644
index 0000000..8700e61
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.c
@@ -0,0 +1,143 @@
+#include "tnc_host.h"
+
+#include <ctype.h>
+#include <string.h>
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ memset(host, 0, sizeof(*host));
+ host->state = HYBBX_TNC_HOST_BOOT;
+ host->text_cb = text_cb;
+ host->event_cb = event_cb;
+ host->userdata = userdata;
+}
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ host->line_len = 0;
+ host->line[0] = '\0';
+ host->state = HYBBX_TNC_HOST_BOOT;
+}
+
+static int host_line_ieq(const char *line, const char *prefix)
+{
+ size_t i;
+ size_t plen = strlen(prefix);
+
+ for (i = 0; i < plen; i++) {
+ char a = (char)tolower((unsigned char)line[i]);
+ char b = (char)tolower((unsigned char)prefix[i]);
+
+ if (a != b) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static void host_emit_event(hybbx_tnc_host_t *host, hybbx_tnc_host_state_t state)
+{
+ if (host->state == state) {
+ return;
+ }
+
+ host->state = state;
+ if (host->event_cb != NULL) {
+ host->event_cb(state, host->userdata);
+ }
+}
+
+static void host_process_line(hybbx_tnc_host_t *host)
+{
+ const char *line = host->line;
+
+ if (host_line_ieq(line, "cmd:") || host_line_ieq(line, "cmd: ")) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** connected") ||
+ host_line_ieq(line, "*** connected ") ||
+ strstr(line, "CONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CONNECTED);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** disconnected") ||
+ strstr(line, "DISCONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (host->state == HYBBX_TNC_HOST_CONNECTED && host->text_cb != NULL &&
+ line[0] != '\0') {
+ host->text_cb((const uint8_t *)line, strlen(line), host->userdata);
+ host->text_cb((const uint8_t *)"\r\n", 2, host->userdata);
+ }
+}
+
+static void host_push_byte(hybbx_tnc_host_t *host, uint8_t byte)
+{
+ if (byte == '\r' || byte == '\n') {
+ if (host->line_len > 0) {
+ host->line[host->line_len] = '\0';
+ host_process_line(host);
+ host->line_len = 0;
+ }
+ return;
+ }
+
+ if (host->line_len + 1 >= sizeof(host->line)) {
+ host->line_len = 0;
+ return;
+ }
+
+ host->line[host->line_len++] = (char)byte;
+}
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ size_t i;
+
+ if (host == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ host_push_byte(host, data[i]);
+ }
+}
+
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap)
+{
+ size_t len;
+
+ if (cmd == NULL || out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ len = strlen(cmd);
+ if (len + 2 >= out_cap) {
+ return 0;
+ }
+
+ memcpy(out, cmd, len);
+ out[len++] = '\r';
+ out[len] = '\0';
+ return len;
+}
diff --git a/plugins/packet_radio/tnc_host.h b/plugins/packet_radio/tnc_host.h
new file mode 100644
index 0000000..af55505
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.h
@@ -0,0 +1,51 @@
+#ifndef HYBBX_TNC_HOST_H
+#define HYBBX_TNC_HOST_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_tnc_host_state {
+ HYBBX_TNC_HOST_BOOT = 0,
+ HYBBX_TNC_HOST_CMD = 1,
+ HYBBX_TNC_HOST_CONNECTED = 2
+} hybbx_tnc_host_state_t;
+
+typedef void (*hybbx_tnc_host_text_cb)(const uint8_t *data, size_t len,
+ void *userdata);
+
+typedef void (*hybbx_tnc_host_event_cb)(hybbx_tnc_host_state_t state,
+ void *userdata);
+
+typedef struct hybbx_tnc_host {
+ hybbx_tnc_host_state_t state;
+ char line[256];
+ size_t line_len;
+ hybbx_tnc_host_text_cb text_cb;
+ hybbx_tnc_host_event_cb event_cb;
+ void *userdata;
+} hybbx_tnc_host_t;
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata);
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host);
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len);
+
+/** Build a TNC2 command line (adds CR). Returns length or 0. */
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TNC_HOST_H */
diff --git a/plugins/packet_radio/tnc_profiles.c b/plugins/packet_radio/tnc_profiles.c
new file mode 100644
index 0000000..05a2397
--- /dev/null
+++ b/plugins/packet_radio/tnc_profiles.c
@@ -0,0 +1,399 @@
+#include "hybbx/tnc.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+
+const char *hybbx_packet_radio_duplex_name(hybbx_packet_radio_duplex_t duplex)
+{
+ switch (duplex) {
+ case HYBBX_PACKET_RADIO_DUPLEX_FULL:
+ return "full";
+ case HYBBX_PACKET_RADIO_DUPLEX_HALF:
+ return "half";
+ case HYBBX_PACKET_RADIO_DUPLEX_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_band_name(hybbx_packet_radio_band_t band)
+{
+ switch (band) {
+ case HYBBX_PACKET_RADIO_BAND_CB:
+ return "cb";
+ case HYBBX_PACKET_RADIO_BAND_AMATEUR:
+ return "amateur";
+ case HYBBX_PACKET_RADIO_BAND_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_modulation_name(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ switch (modulation) {
+ case HYBBX_PACKET_RADIO_MOD_G3RUH_FSK:
+ return "g3ruh_fsk";
+ case HYBBX_PACKET_RADIO_MOD_AFSK:
+ return "afsk";
+ case HYBBX_PACKET_RADIO_MOD_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+int hybbx_packet_radio_modulation_is_g3ruh(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ return modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_modulation(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_UNSET) {
+ cfg->params.modulation = HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_9600;
+ cfg->params.radio_baud = HYBBX_G3RUH_RADIO_BAUD;
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ cfg->params.clock_mhz <= HYBBX_TNC2C_CLOCK_4_9_MHZ) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+ cfg->params.txdelay = 15;
+ cfg->params.persist = 128;
+ cfg->params.slot = 3;
+ return HYBBX_OK;
+ }
+
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_AFSK_RADIO_BAUD;
+ }
+ if (cfg->params.modem == 0) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_packet_radio_band_t default_band_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ default:
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+}
+
+const char *hybbx_packet_radio_tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ return "tnc2";
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ return "pk232";
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ return "mfj1278";
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ return "kantronics";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+const char *hybbx_kiss_entry_name(hybbx_kiss_entry_t entry)
+{
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ return "none";
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ return "kiss_on";
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ return "esc_at_k";
+ case HYBBX_KISS_ENTRY_AUTO:
+ return "auto";
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+static hybbx_kiss_entry_t default_kiss_entry_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ (void)tnc;
+ return HYBBX_KISS_ENTRY_NONE;
+}
+
+hybbx_result_t hybbx_tnc_finalize_kiss_entry(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_UNSET) {
+ cfg->params.kiss_entry = default_kiss_entry_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.kiss_exit == HYBBX_KISS_EXIT_UNSET) {
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_NONE;
+ } else {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_AUTO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_radio_duplex(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = default_band_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ hybbx_log_warn("[tnc] radio_band=cb allows half-duplex only; "
+ "ignoring radio_duplex=full");
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ cfg->params.fullduplex =
+ (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) ? 1 : 0;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_csma(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.persist == 0) {
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB) {
+ cfg->params.persist = 255;
+ } else {
+ cfg->params.persist = 63;
+ }
+ } else if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.persist < 255) {
+ hybbx_log_warn("[tnc] CB band persist=%u overridden to 255 for beacon TX",
+ cfg->params.persist);
+ cfg->params.persist = 255;
+ }
+
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 50;
+ }
+
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_serial_line(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.data_bits == 0) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.data_bits = HYBBX_TNC2C_DATA_BITS;
+ } else {
+ cfg->params.data_bits = 8;
+ }
+ }
+
+ if (cfg->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ } else {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+ }
+
+ if (cfg->params.stop_bits == 0) {
+ cfg->params.stop_bits = 1;
+ }
+
+ if (cfg->params.assert_modem_lines < 0) {
+ cfg->params.assert_modem_lines =
+ (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C) ? 1 : 0;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_profile_apply_defaults(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (cfg->tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 30;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 63;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 25;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 128;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ cfg->params.host_connect_on_start = 1;
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ if (cfg->baud == 0) {
+ cfg->baud = 4800;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->params.clock_mhz == 0.0f) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+ }
+
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ (void)hybbx_tnc_finalize_modulation(cfg);
+ (void)hybbx_tnc_finalize_radio_duplex(cfg);
+ (void)hybbx_tnc_finalize_csma(cfg);
+ (void)hybbx_tnc_finalize_serial_line(cfg);
+ return hybbx_tnc_finalize_kiss_entry(cfg);
+}
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;
+}
diff --git a/plugins/telnet/CMakeLists.txt b/plugins/telnet/CMakeLists.txt
new file mode 100644
index 0000000..144ba1d
--- /dev/null
+++ b/plugins/telnet/CMakeLists.txt
@@ -0,0 +1,26 @@
+add_library(hybbx_plugin_telnet STATIC
+ telnet.c
+ telnet_config.c
+ telnet_proto.c
+)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_telnet
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_telnet
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_telnet PRIVATE hybbx_core Threads::Threads)
+hybbx_link_plugin_instances(hybbx_plugin_telnet HYBBX_HAVE_PLUGIN_TELNET)
+hybbx_apply_hardening(hybbx_plugin_telnet)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_telnet
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/telnet/telnet.c b/plugins/telnet/telnet.c
new file mode 100644
index 0000000..a857b7d
--- /dev/null
+++ b/plugins/telnet/telnet.c
@@ -0,0 +1,516 @@
+/*
+ * telnet — TCP transport plugin (port 2323). INI: [transport.telnet].
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/telnet.h"
+#include "telnet_proto.h"
+#include "hybbx/log.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>
+
+typedef struct telnet_client {
+ int fd;
+ hybbx_telnet_parser_t parser;
+} telnet_client_t;
+
+typedef struct telnet_client_ctx {
+ hybbx_session_t *session;
+ hybbx_result_t last_rc;
+} telnet_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_telnet;
+
+static hybbx_service_t *g_service;
+static hybbx_telnet_config_t g_config;
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_telnet_running = 0;
+
+static hybbx_result_t telnet_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;
+}
+
+static hybbx_result_t telnet_send_byte(int fd, char ch)
+{
+ ssize_t sent;
+ const char *p = &ch;
+
+ sent = send(fd, p, 1, MSG_NOSIGNAL);
+ if (sent < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ telnet_client_t *client;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (telnet_client_t *)session->transport_data;
+ if (client == NULL || client->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char ch = data[i];
+
+ if (ch == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ rc = telnet_send_byte(client->fd, '\r');
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ ssize_t sent = send(client->fd, &ch, 1, MSG_NOSIGNAL);
+
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void telnet_on_user_data(void *ctx, const uint8_t *data, size_t len)
+{
+ telnet_client_ctx_t *tctx = (telnet_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (tctx == NULL || tctx->session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(tctx->session, data, len);
+ if (rc != HYBBX_OK) {
+ tctx->last_rc = rc;
+ }
+}
+
+#define TELNET_CLIENT_POLL_MS 30000
+
+static void telnet_send_busy(int fd)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (fd < 0) {
+ return;
+ }
+
+ (void)send(fd, msg, sizeof(msg) - 1u, 0);
+}
+
+static void *telnet_client_thread(void *arg)
+{
+ telnet_client_t *client = (telnet_client_t *)arg;
+ hybbx_session_t *session = NULL;
+ telnet_client_ctx_t tctx;
+ uint8_t buf[256];
+ ssize_t n;
+ hybbx_result_t rc;
+
+ if (client == NULL || g_service == NULL) {
+ free(client);
+ return NULL;
+ }
+
+ (void)set_socket_options(client->fd, 0);
+
+ rc = hybbx_telnet_send_greeting(client->fd);
+ if (rc != HYBBX_OK) {
+ close(client->fd);
+ free(client);
+ return NULL;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_telnet, client, &session);
+ if (rc != HYBBX_OK || session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ telnet_send_busy(client->fd);
+ }
+ close(client->fd);
+ free(client);
+ return NULL;
+ }
+
+ {
+ char remote[64];
+
+ if (hybbx_socket_peer_name(client->fd, remote, sizeof(remote)) == HYBBX_OK) {
+ (void)hybbx_session_set_remote(session, remote);
+ }
+ }
+
+ hybbx_telnet_parser_init(&client->parser);
+ tctx.session = session;
+ tctx.last_rc = HYBBX_OK;
+
+ while (g_telnet_running) {
+ struct pollfd pfd;
+ int pr;
+
+ pfd.fd = client->fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+
+ pr = poll(&pfd, 1, TELNET_CLIENT_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ rc = hybbx_session_tick(session);
+ if (rc == HYBBX_SESSION_END) {
+ tctx.last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ continue;
+ }
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ n = recv(client->fd, buf, sizeof(buf), 0);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ rc = hybbx_telnet_parser_feed(&client->parser, client->fd, buf, (size_t)n,
+ telnet_on_user_data, &tctx);
+ if (rc != HYBBX_OK) {
+ break;
+ }
+
+ if (tctx.last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ }
+
+ hybbx_session_close(session);
+ shutdown(client->fd, SHUT_RDWR);
+ close(client->fd);
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ telnet_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, telnet_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *telnet_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_telnet_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 telnet_init(hybbx_service_t *service)
+{
+ g_service = service;
+ return HYBBX_OK;
+}
+
+static void telnet_shutdown(void)
+{
+ telnet_stop();
+}
+
+static hybbx_result_t telnet_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (g_telnet_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_telnet_config_parse(config, &g_config);
+ 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("telnet", 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("[telnet] 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_telnet_running = 1;
+
+ if (pthread_create(&g_accept_thread, NULL, telnet_accept_thread, NULL) != 0) {
+ g_telnet_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, "[telnet] 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);
+ }
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_stop(void)
+{
+ if (!g_telnet_running) {
+ return HYBBX_OK;
+ }
+
+ g_telnet_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("[telnet] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_telnet = {
+ .name = "telnet",
+ .kind = HYBBX_TRANSPORT_TELNET,
+ .version = 1,
+ .init = telnet_init,
+ .shutdown = telnet_shutdown,
+ .start = telnet_start,
+ .stop = telnet_stop,
+ .write = telnet_write,
+};
diff --git a/plugins/telnet/telnet_config.c b/plugins/telnet/telnet_config.c
new file mode 100644
index 0000000..d154f88
--- /dev/null
+++ b/plugins/telnet/telnet_config.c
@@ -0,0 +1,125 @@
+#include "hybbx/telnet.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_TELNET_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_TELNET_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_telnet_config_defaults(hybbx_telnet_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_TELNET_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_TELNET_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ config->port = HYBBX_TELNET_DEFAULT_PORT;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_telnet_config_parse(const char *config,
+ hybbx_telnet_config_t *out)
+{
+ char scratch[HYBBX_TELNET_BIND_V6_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_telnet_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, "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/telnet/telnet_proto.c b/plugins/telnet/telnet_proto.c
new file mode 100644
index 0000000..523c68c
--- /dev/null
+++ b/plugins/telnet/telnet_proto.c
@@ -0,0 +1,213 @@
+#include "telnet_proto.h"
+
+#include "hybbx/socket.h"
+
+#include <errno.h>
+#include <string.h>
+#include <unistd.h>
+
+#define TELNET_IAC 255
+#define TELNET_DONT 254
+#define TELNET_DO 253
+#define TELNET_WONT 252
+#define TELNET_WILL 251
+#define TELNET_SB 250
+#define TELNET_GA 249
+#define TELNET_EL 248
+#define TELNET_EC 247
+#define TELNET_AYT 246
+#define TELNET_AO 245
+#define TELNET_IP 244
+#define TELNET_BRK 243
+#define TELNET_DM 242
+#define TELNET_NOP 241
+#define TELNET_SE 240
+
+#define TELNET_OPT_ECHO 1
+#define TELNET_OPT_SGA 3
+#define TELNET_OPT_TTYPE 24
+#define TELNET_OPT_NAWS 31
+#define TELNET_OPT_LINEMODE 34
+#define TELNET_OPT_CHARSET 42
+
+enum telnet_parse_state {
+ TS_DATA = 0,
+ TS_IAC,
+ TS_CMD,
+ TS_SUBNEG,
+ TS_SUBNEG_IAC
+};
+
+static hybbx_result_t telnet_send_raw(int fd, const uint8_t *data, size_t len)
+{
+ size_t sent_total = 0;
+
+ while (sent_total < len) {
+ ssize_t sent;
+
+ sent = send(fd, data + sent_total, len - sent_total, MSG_NOSIGNAL);
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ sent_total += (size_t)sent;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_send_option(int fd, uint8_t cmd, uint8_t option)
+{
+ uint8_t buf[3];
+
+ buf[0] = TELNET_IAC;
+ buf[1] = cmd;
+ buf[2] = option;
+ return telnet_send_raw(fd, buf, sizeof(buf));
+}
+
+static hybbx_result_t telnet_reply_do(int fd, uint8_t option)
+{
+ switch (option) {
+ case TELNET_OPT_ECHO:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ case TELNET_OPT_SGA:
+ return telnet_send_option(fd, TELNET_WILL, option);
+ case TELNET_OPT_TTYPE:
+ case TELNET_OPT_NAWS:
+ case TELNET_OPT_LINEMODE:
+ case TELNET_OPT_CHARSET:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ default:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ }
+}
+
+static hybbx_result_t telnet_reply_will(int fd, uint8_t option)
+{
+ switch (option) {
+ case TELNET_OPT_SGA:
+ return telnet_send_option(fd, TELNET_DO, option);
+ case TELNET_OPT_ECHO:
+ return telnet_send_option(fd, TELNET_DONT, option);
+ default:
+ return telnet_send_option(fd, TELNET_DONT, option);
+ }
+}
+
+static void emit_byte(hybbx_telnet_data_cb on_data, void *ctx, uint8_t byte)
+{
+ if (on_data != NULL) {
+ on_data(ctx, &byte, 1);
+ }
+}
+
+void hybbx_telnet_parser_init(hybbx_telnet_parser_t *parser)
+{
+ if (parser == NULL) {
+ return;
+ }
+
+ parser->state = TS_DATA;
+ parser->command = 0;
+}
+
+hybbx_result_t hybbx_telnet_send_greeting(int fd)
+{
+ static const uint8_t greeting[] = {
+ TELNET_IAC, TELNET_WONT, TELNET_OPT_ECHO,
+ TELNET_IAC, TELNET_WILL, TELNET_OPT_SGA,
+ TELNET_IAC, TELNET_DONT, TELNET_OPT_LINEMODE,
+ };
+
+ if (fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return telnet_send_raw(fd, greeting, sizeof(greeting));
+}
+
+hybbx_result_t hybbx_telnet_parser_feed(hybbx_telnet_parser_t *parser, int fd,
+ const uint8_t *data, size_t len,
+ hybbx_telnet_data_cb on_data,
+ void *ctx)
+{
+ size_t i;
+ hybbx_result_t rc;
+
+ if (parser == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t byte = data[i];
+
+ switch (parser->state) {
+ case TS_DATA:
+ if (byte == TELNET_IAC) {
+ parser->state = TS_IAC;
+ } else {
+ emit_byte(on_data, ctx, byte);
+ }
+ break;
+
+ case TS_IAC:
+ if (byte == TELNET_IAC) {
+ emit_byte(on_data, ctx, TELNET_IAC);
+ parser->state = TS_DATA;
+ } else if (byte == TELNET_WILL || byte == TELNET_WONT ||
+ byte == TELNET_DO || byte == TELNET_DONT) {
+ parser->command = (int)byte;
+ parser->state = TS_CMD;
+ } else if (byte == TELNET_SB) {
+ parser->state = TS_SUBNEG;
+ } else {
+ parser->state = TS_DATA;
+ }
+ break;
+
+ case TS_CMD:
+ if (fd >= 0) {
+ if (parser->command == TELNET_DO) {
+ rc = telnet_reply_do(fd, byte);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else if (parser->command == TELNET_WILL) {
+ rc = telnet_reply_will(fd, byte);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ }
+ parser->state = TS_DATA;
+ break;
+
+ case TS_SUBNEG:
+ if (byte == TELNET_IAC) {
+ parser->state = TS_SUBNEG_IAC;
+ }
+ break;
+
+ case TS_SUBNEG_IAC:
+ if (byte == TELNET_SE) {
+ parser->state = TS_DATA;
+ } else {
+ parser->state = TS_SUBNEG;
+ }
+ break;
+
+ default:
+ parser->state = TS_DATA;
+ break;
+ }
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/telnet/telnet_proto.h b/plugins/telnet/telnet_proto.h
new file mode 100644
index 0000000..aa29122
--- /dev/null
+++ b/plugins/telnet/telnet_proto.h
@@ -0,0 +1,37 @@
+#ifndef HYBBX_TELNET_PROTO_H
+#define HYBBX_TELNET_PROTO_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_telnet_parser {
+ int state;
+ int command;
+} hybbx_telnet_parser_t;
+
+typedef void (*hybbx_telnet_data_cb)(void *ctx, const uint8_t *data, size_t len);
+
+void hybbx_telnet_parser_init(hybbx_telnet_parser_t *parser);
+
+/** Send initial option negotiation (echo, suppress go ahead). */
+hybbx_result_t hybbx_telnet_send_greeting(int fd);
+
+/**
+ * Strip telnet protocol bytes and invoke @p on_data for user payload.
+ */
+hybbx_result_t hybbx_telnet_parser_feed(hybbx_telnet_parser_t *parser, int fd,
+ const uint8_t *data, size_t len,
+ hybbx_telnet_data_cb on_data,
+ void *ctx);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TELNET_PROTO_H */
diff --git a/plugins/websocket/CMakeLists.txt b/plugins/websocket/CMakeLists.txt
new file mode 100644
index 0000000..a63f83d
--- /dev/null
+++ b/plugins/websocket/CMakeLists.txt
@@ -0,0 +1,37 @@
+add_library(hybbx_plugin_websocket STATIC
+ websocket.c
+ ws_config.c
+ ws_proto.c
+ ws_sha1.c
+ ws_tls.c
+)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_websocket
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_websocket
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(OpenSSL QUIET)
+if(OpenSSL_FOUND)
+ target_compile_definitions(hybbx_plugin_websocket PRIVATE HYBBX_WS_HAVE_TLS)
+ target_link_libraries(hybbx_plugin_websocket PRIVATE OpenSSL::SSL OpenSSL::Crypto)
+ message(STATUS "WebSocket plugin: OpenSSL found — wss + self-signed certs")
+else()
+ message(STATUS "WebSocket plugin: OpenSSL not found — plain ws only")
+endif()
+
+target_link_libraries(hybbx_plugin_websocket PRIVATE hybbx_core Threads::Threads)
+hybbx_link_plugin_instances(hybbx_plugin_websocket HYBBX_HAVE_PLUGIN_WEBSOCKET)
+hybbx_apply_hardening(hybbx_plugin_websocket)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_websocket
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/websocket/websocket.c b/plugins/websocket/websocket.c
new file mode 100644
index 0000000..4ff2230
--- /dev/null
+++ b/plugins/websocket/websocket.c
@@ -0,0 +1,723 @@
+/*
+ * websocket — RFC6455 forward-proxy transport. INI: [transport.websocket].
+ *
+ * Raw byte stream to the session core (no HyBBX auth at the wire layer).
+ * Intended behind a TLS reverse proxy; see docs/WEBSOCKET.md.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/websocket.h"
+#include "hybbx/util.h"
+#include "ws_proto.h"
+#include "ws_tls.h"
+#include "hybbx/log.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 <time.h>
+#include <unistd.h>
+
+#define WS_CLIENT_POLL_MS 5000
+#define WS_CLIENT_PING_IDLE_SEC 20
+
+typedef struct ws_client {
+ hybbx_ws_connection_t ws;
+ hybbx_session_t *hbx_session;
+ hybbx_result_t last_rc;
+ int slot_held;
+ time_t last_io_at;
+ uint8_t tx_buf[HYBBX_WS_FRAME_PAYLOAD_MAX];
+ size_t tx_len;
+} ws_client_t;
+
+typedef struct ws_client_ctx {
+ ws_client_t *client;
+} ws_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_websocket;
+
+static hybbx_service_t *g_service;
+static hybbx_websocket_config_t g_config;
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_ws_running = 0;
+static unsigned g_active_clients;
+static pthread_mutex_t g_client_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static hybbx_result_t ws_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;
+}
+
+static void log_bind_failure(const char *bind_addr, unsigned port)
+{
+ int err = errno;
+
+ hybbx_socket_log_bind_failure("websocket", bind_addr, port);
+ if (err == EACCES && port < 1024u) {
+ hybbx_log_warn("[websocket] port %u is privileged — run as root, grant "
+ "bind permission, or set port >= 1024 in hybbx.ini",
+ port);
+ }
+}
+
+static size_t utf8_char_span(const uint8_t *buf, size_t len, size_t pos)
+{
+ uint8_t b;
+ size_t n;
+ size_t i;
+
+ if (pos >= len) {
+ return 0;
+ }
+
+ b = buf[pos];
+ if (b < 0x80u) {
+ return 1;
+ }
+ if ((b & 0xE0u) == 0xC0u) {
+ n = 2;
+ } else if ((b & 0xF0u) == 0xE0u) {
+ n = 3;
+ } else if ((b & 0xF8u) == 0xF0u) {
+ n = 4;
+ } else {
+ return 1;
+ }
+
+ if (pos + n > len) {
+ return 0;
+ }
+
+ for (i = 1; i < n; i++) {
+ if ((buf[pos + i] & 0xC0u) != 0x80u) {
+ return 1;
+ }
+ }
+
+ return n;
+}
+
+static size_t utf8_complete_prefix(const uint8_t *buf, size_t len)
+{
+ size_t pos = 0;
+
+ while (pos < len) {
+ size_t span = utf8_char_span(buf, len, pos);
+
+ if (span == 0) {
+ break;
+ }
+ pos += span;
+ }
+
+ return pos;
+}
+
+static hybbx_result_t ws_tx_flush(ws_client_t *client, int force_tail)
+{
+ size_t n;
+ hybbx_result_t rc;
+
+ if (client == NULL || client->tx_len == 0) {
+ return HYBBX_OK;
+ }
+
+ n = utf8_complete_prefix(client->tx_buf, client->tx_len);
+ if (n == 0 && force_tail) {
+ client->tx_buf[0] = '?';
+ n = 1;
+ }
+ if (n == 0) {
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_ws_write_text(&client->ws, (const char *)client->tx_buf, n);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ client->last_io_at = time(NULL);
+
+ if (n < client->tx_len) {
+ memmove(client->tx_buf, client->tx_buf + n, client->tx_len - n);
+ }
+ client->tx_len -= n;
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_plugin_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ ws_client_t *client;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (ws_client_t *)session->transport_data;
+ if (client == NULL || !client->ws.established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char ch = data[i];
+
+ if (ch == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ if (client->tx_len + 2 > sizeof(client->tx_buf)) {
+ rc = ws_tx_flush(client, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ client->tx_buf[client->tx_len++] = '\r';
+ }
+
+ if (client->tx_len >= sizeof(client->tx_buf)) {
+ rc = ws_tx_flush(client, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ client->tx_buf[client->tx_len++] = (uint8_t)ch;
+
+ rc = ws_tx_flush(client, 0);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void ws_send_busy(ws_client_t *client)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (client == NULL) {
+ return;
+ }
+
+ (void)hybbx_ws_write_text(&client->ws, msg, sizeof(msg) - 1u);
+}
+
+static void ws_send_limit(ws_client_t *client)
+{
+ static const char msg[] =
+ "All WebSocket connections in use. Try later.\r\n";
+
+ if (client == NULL) {
+ return;
+ }
+
+ (void)hybbx_ws_write_text(&client->ws, msg, sizeof(msg) - 1u);
+}
+
+static int ws_client_acquire_slot(ws_client_t *client)
+{
+ if (client == NULL) {
+ return 0;
+ }
+
+ pthread_mutex_lock(&g_client_lock);
+ if (g_active_clients >= g_config.max_connections) {
+ pthread_mutex_unlock(&g_client_lock);
+ return 0;
+ }
+
+ g_active_clients++;
+ client->slot_held = 1;
+ pthread_mutex_unlock(&g_client_lock);
+ return 1;
+}
+
+static void ws_client_release_slot(ws_client_t *client)
+{
+ if (client == NULL || !client->slot_held) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_client_lock);
+ if (g_active_clients > 0) {
+ g_active_clients--;
+ }
+ client->slot_held = 0;
+ pthread_mutex_unlock(&g_client_lock);
+}
+
+static void ws_on_user_data(void *ctx, const uint8_t *data, size_t len)
+{
+ ws_client_ctx_t *wctx = (ws_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (wctx == NULL || wctx->client == NULL ||
+ wctx->client->hbx_session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(wctx->client->hbx_session, data, len);
+ if (rc != HYBBX_OK) {
+ wctx->client->last_rc = rc;
+ }
+}
+
+static void *ws_client_thread(void *arg)
+{
+ ws_client_t *client = (ws_client_t *)arg;
+ ws_client_ctx_t wctx;
+ 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->ws.fd, remote, sizeof(remote));
+
+ if (hybbx_ws_tls_server_enabled()) {
+ void *tls = hybbx_ws_tls_accept(client->ws.fd);
+
+ if (tls == NULL) {
+ goto cleanup;
+ }
+ hybbx_ws_connection_set_tls(&client->ws, tls);
+ }
+
+ rc = hybbx_ws_server_handshake(&client->ws, g_config.path);
+ if (rc != HYBBX_OK) {
+ goto cleanup;
+ }
+
+ if (!ws_client_acquire_slot(client)) {
+ ws_send_limit(client);
+ goto cleanup;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_websocket, client,
+ &client->hbx_session);
+ if (rc != HYBBX_OK || client->hbx_session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ ws_send_busy(client);
+ }
+ goto cleanup;
+ }
+
+ if (remote[0] != '\0') {
+ (void)hybbx_session_set_remote(client->hbx_session, remote);
+ }
+
+ wctx.client = client;
+ client->last_rc = HYBBX_OK;
+ client->last_io_at = time(NULL);
+
+ while (g_ws_running && client->ws.established) {
+ struct pollfd pfd;
+ int pr;
+
+ pfd.fd = client->ws.fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+
+ pr = poll(&pfd, 1, WS_CLIENT_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ time_t now = time(NULL);
+
+ if (now != (time_t)-1 &&
+ (client->last_io_at == 0 ||
+ now - client->last_io_at >= WS_CLIENT_PING_IDLE_SEC)) {
+ rc = hybbx_ws_ping(&client->ws);
+ if (rc != HYBBX_OK) {
+ break;
+ }
+ client->last_io_at = now;
+ }
+
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ continue;
+ }
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ rc = hybbx_ws_read_frames(&client->ws, ws_on_user_data, &wctx);
+ if (rc != HYBBX_OK) {
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ break;
+ }
+ client->last_io_at = time(NULL);
+
+ if (client->last_rc == HYBBX_SESSION_END) {
+ 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;
+ }
+
+ ws_client_release_slot(client);
+
+ (void)ws_tx_flush(client, 1);
+
+ if (client->ws.established) {
+ (void)hybbx_ws_close(&client->ws);
+ }
+
+ hybbx_ws_connection_cleanup(&client->ws);
+
+ if (client->ws.fd >= 0) {
+ shutdown(client->ws.fd, SHUT_RDWR);
+ close(client->ws.fd);
+ client->ws.fd = -1;
+ }
+
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ ws_client_t *client;
+ pthread_t thread;
+ pthread_attr_t attr;
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(client_fd);
+ return;
+ }
+
+ hybbx_ws_connection_init(&client->ws, client_fd);
+
+ pthread_attr_init(&attr);
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
+
+ if (pthread_create(&thread, &attr, ws_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *ws_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_ws_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 ws_plugin_init(hybbx_service_t *service)
+{
+ g_service = service;
+ return HYBBX_OK;
+}
+
+static void ws_plugin_shutdown(void)
+{
+ ws_plugin_stop();
+}
+
+static hybbx_result_t ws_plugin_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (g_ws_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_websocket_config_parse(config, &g_config);
+ 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) {
+ log_bind_failure(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("[websocket] 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;
+ }
+
+ if (hybbx_ws_tls_compiled()) {
+ char cert_dir[HYBBX_PATH_MAX];
+ hybbx_result_t tls_rc;
+
+ if (hybbx_path_resolve(cert_dir, sizeof(cert_dir),
+ g_config.cert_dir) != HYBBX_OK) {
+ hybbx_strlcpy(cert_dir, g_config.cert_dir, sizeof(cert_dir));
+ }
+
+ tls_rc = hybbx_ws_tls_ensure_certs(cert_dir);
+
+ if (tls_rc == HYBBX_OK) {
+ tls_rc = hybbx_ws_tls_server_start(cert_dir);
+ }
+ if (tls_rc != HYBBX_OK) {
+ hybbx_log_warn("[websocket] TLS init failed (%d), plain ws only",
+ (int)tls_rc);
+ }
+ }
+
+ g_ws_running = 1;
+ g_active_clients = 0;
+
+ if (pthread_create(&g_accept_thread, NULL, ws_accept_thread, NULL) != 0) {
+ g_ws_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[384];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, "[websocket] 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,
+ " path=%s max_connections=%u (%s, forward-proxy)",
+ g_config.path, g_config.max_connections,
+ hybbx_ws_tls_server_enabled() ? "wss" : "ws");
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_plugin_stop(void)
+{
+ if (!g_ws_running) {
+ return HYBBX_OK;
+ }
+
+ g_ws_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_ws_tls_server_stop();
+ hybbx_log_info("[websocket] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_websocket = {
+ .name = "websocket",
+ .kind = HYBBX_TRANSPORT_WEBSOCKET,
+ .version = 1,
+ .init = ws_plugin_init,
+ .shutdown = ws_plugin_shutdown,
+ .start = ws_plugin_start,
+ .stop = ws_plugin_stop,
+ .write = ws_plugin_write,
+};
diff --git a/plugins/websocket/ws_config.c b/plugins/websocket/ws_config.c
new file mode 100644
index 0000000..baf88b2
--- /dev/null
+++ b/plugins/websocket/ws_config.c
@@ -0,0 +1,163 @@
+#include "hybbx/websocket.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_WEBSOCKET_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_WEBSOCKET_DEFAULT_PORT;
+ }
+
+ return (unsigned int)port;
+}
+
+static unsigned int parse_max_connections(const char *value)
+{
+ char *end;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || n == 0 || n > 65535u) {
+ return HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ }
+
+ return (unsigned int)n;
+}
+
+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_websocket_config_defaults(hybbx_websocket_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_WEBSOCKET_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_WEBSOCKET_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ hybbx_strlcpy(config->path, HYBBX_WEBSOCKET_DEFAULT_PATH,
+ sizeof(config->path));
+ hybbx_strlcpy(config->cert_dir, HYBBX_WEBSOCKET_DEFAULT_CERT_DIR,
+ sizeof(config->cert_dir));
+ config->port = HYBBX_WEBSOCKET_DEFAULT_PORT;
+ config->max_connections = HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_websocket_config_parse(const char *config,
+ hybbx_websocket_config_t *out)
+{
+ char scratch[HYBBX_WEBSOCKET_PATH_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_websocket_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, "max_connections", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "max_online", scratch, sizeof(scratch));
+ }
+ out->max_connections = parse_max_connections(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, "path", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->path, value, sizeof(out->path));
+ }
+
+ value = find_kv(config, "cert_dir", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->cert_dir, value, sizeof(out->cert_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/websocket/ws_proto.c b/plugins/websocket/ws_proto.c
new file mode 100644
index 0000000..b0e63b5
--- /dev/null
+++ b/plugins/websocket/ws_proto.c
@@ -0,0 +1,565 @@
+#include "ws_proto.h"
+#include "ws_sha1.h"
+#include "ws_tls.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
+
+static int header_has_token(const char *value, const char *token)
+{
+ const char *p;
+
+ if (value == NULL || token == NULL) {
+ return 0;
+ }
+
+ for (p = value; *p != '\0'; p++) {
+ if (strncasecmp(p, token, strlen(token)) == 0) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static int header_value_equals(const char *value, const char *expected)
+{
+ size_t expected_len;
+
+ if (value == NULL || expected == NULL) {
+ return 0;
+ }
+
+ expected_len = strlen(expected);
+ if (strncasecmp(value, expected, expected_len) != 0) {
+ return 0;
+ }
+
+ {
+ char c = value[expected_len];
+
+ return c == '\0' || c == '\r' || c == ' ' || c == '\t' || c == ',';
+ }
+}
+
+static ssize_t recv_some(int fd, void *buf, size_t len)
+{
+ ssize_t n;
+
+ n = recv(fd, buf, len, 0);
+ if (n < 0 && errno == EINTR) {
+ return 0;
+ }
+ return n;
+}
+
+static ssize_t ws_recv_some(hybbx_ws_connection_t *ws, void *buf, size_t len)
+{
+ if (ws == NULL) {
+ return -1;
+ }
+
+ if (ws->tls != NULL) {
+ return hybbx_ws_tls_recv(ws->tls, buf, len);
+ }
+
+ return recv_some(ws->fd, buf, len);
+}
+
+static ssize_t send_all(int fd, const void *buf, size_t len)
+{
+ const uint8_t *p = (const uint8_t *)buf;
+ size_t sent = 0;
+
+ while (sent < len) {
+ ssize_t n = send(fd, p + sent, len - sent, MSG_NOSIGNAL);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return -1;
+ }
+ if (n == 0) {
+ return -1;
+ }
+ sent += (size_t)n;
+ }
+
+ return (ssize_t)sent;
+}
+
+static ssize_t ws_send_all(hybbx_ws_connection_t *ws, const void *buf,
+ size_t len)
+{
+ if (ws == NULL) {
+ return -1;
+ }
+
+ if (ws->tls != NULL) {
+ return hybbx_ws_tls_send(ws->tls, buf, len);
+ }
+
+ return send_all(ws->fd, buf, len);
+}
+
+static int base64_encode(const uint8_t *in, size_t in_len, char *out,
+ size_t out_len)
+{
+ static const char tbl[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t i;
+ size_t o = 0;
+
+ if (out_len < ((in_len + 2) / 3) * 4 + 1) {
+ return -1;
+ }
+
+ for (i = 0; i < in_len; i += 3) {
+ uint32_t v = (uint32_t)in[i] << 16;
+
+ if (i + 1 < in_len) {
+ v |= (uint32_t)in[i + 1] << 8;
+ }
+ if (i + 2 < in_len) {
+ v |= (uint32_t)in[i + 2];
+ }
+
+ out[o++] = tbl[(v >> 18) & 63u];
+ out[o++] = tbl[(v >> 12) & 63u];
+ out[o++] = (i + 1 < in_len) ? tbl[(v >> 6) & 63u] : '=';
+ out[o++] = (i + 2 < in_len) ? tbl[v & 63u] : '=';
+ }
+
+ out[o] = '\0';
+ return 0;
+}
+
+static const char *header_value(const char *headers, const char *name)
+{
+ size_t name_len = strlen(name);
+ const char *p = headers;
+
+ while (p != NULL && *p != '\0') {
+ const char *line_end = strstr(p, "\r\n");
+
+ if (line_end == NULL) {
+ break;
+ }
+
+ if ((size_t)(line_end - p) > name_len + 2 &&
+ strncasecmp(p, name, name_len) == 0 && p[name_len] == ':') {
+ const char *value = p + name_len + 1;
+
+ while (*value == ' ' || *value == '\t') {
+ value++;
+ }
+ return value;
+ }
+
+ p = line_end + 2;
+ }
+
+ return NULL;
+}
+
+static int path_matches(const char *request_line, const char *path)
+{
+ const char *sp1;
+ const char *sp2;
+ size_t path_len;
+
+ if (path == NULL || path[0] == '\0') {
+ return 1;
+ }
+
+ sp1 = strchr(request_line, ' ');
+ if (sp1 == NULL) {
+ return 0;
+ }
+ sp1++;
+ sp2 = strchr(sp1, ' ');
+ if (sp2 == NULL) {
+ return 0;
+ }
+
+ path_len = strlen(path);
+ if ((size_t)(sp2 - sp1) < path_len) {
+ return 0;
+ }
+ if (strncmp(sp1, path, path_len) != 0) {
+ return 0;
+ }
+ if (sp1[path_len] != ' ' && sp1[path_len] != '?' &&
+ sp1[path_len] != '\0') {
+ return 0;
+ }
+
+ return 1;
+}
+
+void hybbx_ws_connection_init(hybbx_ws_connection_t *ws, int fd)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ memset(ws, 0, sizeof(*ws));
+ ws->fd = fd;
+}
+
+void hybbx_ws_connection_set_tls(hybbx_ws_connection_t *ws, void *tls)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ ws->tls = tls;
+}
+
+void hybbx_ws_connection_cleanup(hybbx_ws_connection_t *ws)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ if (ws->tls != NULL) {
+ hybbx_ws_tls_shutdown(ws->tls);
+ hybbx_ws_tls_free(ws->tls);
+ ws->tls = NULL;
+ }
+}
+
+hybbx_result_t hybbx_ws_server_handshake(hybbx_ws_connection_t *ws,
+ const char *path)
+{
+ char buf[HYBBX_WS_HANDSHAKE_MAX];
+ size_t total = 0;
+ const char *key_hdr;
+ char key[128];
+ char accept_src[256];
+ uint8_t digest[20];
+ char accept_b64[64];
+ char response[512];
+ const char *upgrade;
+ const char *connection;
+
+ if (ws == NULL || ws->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (total + 1 < sizeof(buf)) {
+ ssize_t n = ws_recv_some(ws, buf + total, sizeof(buf) - 1 - total);
+
+ if (n < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ total += (size_t)n;
+ buf[total] = '\0';
+
+ if (strstr(buf, "\r\n\r\n") != NULL) {
+ break;
+ }
+ }
+
+ if (strncmp(buf, "GET ", 4) != 0) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ {
+ const char *eol = strstr(buf, "\r\n");
+ char first_line[256];
+ size_t line_len;
+
+ if (eol == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+ line_len = (size_t)(eol - buf);
+ if (line_len >= sizeof(first_line)) {
+ return HYBBX_ERR_DENIED;
+ }
+ memcpy(first_line, buf, line_len);
+ first_line[line_len] = '\0';
+ if (!path_matches(first_line, path)) {
+ return HYBBX_ERR_DENIED;
+ }
+ }
+
+ upgrade = header_value(buf, "Upgrade");
+ connection = header_value(buf, "Connection");
+ if (upgrade == NULL || connection == NULL ||
+ !header_value_equals(upgrade, "websocket") ||
+ !header_has_token(connection, "upgrade")) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ key_hdr = header_value(buf, "Sec-WebSocket-Key");
+ if (key_hdr == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ {
+ const char *key_end = strstr(key_hdr, "\r\n");
+ size_t key_len;
+
+ if (key_end == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+ key_len = (size_t)(key_end - key_hdr);
+ while (key_len > 0 &&
+ (key_hdr[key_len - 1] == ' ' || key_hdr[key_len - 1] == '\t')) {
+ key_len--;
+ }
+ if (key_len >= sizeof(key)) {
+ return HYBBX_ERR_DENIED;
+ }
+ memcpy(key, key_hdr, key_len);
+ key[key_len] = '\0';
+ }
+
+ snprintf(accept_src, sizeof(accept_src), "%s%s", key, WS_GUID);
+ hybbx_ws_sha1((const uint8_t *)accept_src, strlen(accept_src), digest);
+ if (base64_encode(digest, sizeof(digest), accept_b64,
+ sizeof(accept_b64)) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ snprintf(response, sizeof(response),
+ "HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Accept: %s\r\n"
+ "\r\n",
+ accept_b64);
+
+ if (ws_send_all(ws, response, strlen(response)) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ ws->established = 1;
+ ws->rx_len = 0;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_handle_frame(hybbx_ws_connection_t *ws,
+ const uint8_t *frame, size_t frame_len,
+ hybbx_ws_data_cb on_data, void *ctx)
+{
+ uint8_t opcode;
+ uint64_t payload_len;
+ size_t header_len;
+ uint8_t mask[4];
+ uint8_t payload[HYBBX_WS_FRAME_PAYLOAD_MAX];
+ size_t i;
+
+ if (frame_len < 2) {
+ return HYBBX_ERR_IO;
+ }
+
+ opcode = frame[0] & 0x0Fu;
+ if ((frame[0] & 0x80) == 0) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if ((frame[1] & 0x80) == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ payload_len = frame[1] & 0x7Fu;
+ header_len = 2;
+
+ if (payload_len == 126) {
+ if (frame_len < 4) {
+ return HYBBX_ERR_IO;
+ }
+ payload_len = ((uint64_t)frame[2] << 8) | frame[3];
+ header_len = 4;
+ } else if (payload_len == 127) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (payload_len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (frame_len < header_len + 4 + payload_len) {
+ return HYBBX_ERR_IO;
+ }
+
+ memcpy(mask, frame + header_len, 4);
+ header_len += 4;
+
+ for (i = 0; i < payload_len; i++) {
+ payload[i] = frame[header_len + i] ^ mask[i % 4];
+ }
+
+ switch (opcode) {
+ case 0x1:
+ case 0x2:
+ if (on_data != NULL && payload_len > 0) {
+ on_data(ctx, payload, (size_t)payload_len);
+ }
+ return HYBBX_OK;
+ case 0x8:
+ return HYBBX_ERR_IO;
+ case 0x9:
+ {
+ uint8_t pong[2];
+
+ pong[0] = 0x8A;
+ pong[1] = 0x00;
+ (void)ws_send_all(ws, pong, 2);
+ }
+ return HYBBX_OK;
+ case 0xA:
+ return HYBBX_OK;
+ default:
+ return HYBBX_OK;
+ }
+}
+
+hybbx_result_t hybbx_ws_read_frames(hybbx_ws_connection_t *ws,
+ hybbx_ws_data_cb on_data,
+ void *ctx)
+{
+ uint8_t tmp[256];
+ ssize_t n;
+
+ if (ws == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = ws_recv_some(ws, tmp, sizeof(tmp));
+ if (n < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (ws->rx_len + (size_t)n > sizeof(ws->rx_buf)) {
+ ws->rx_len = 0;
+ return HYBBX_ERR_IO;
+ }
+
+ memcpy(ws->rx_buf + ws->rx_len, tmp, (size_t)n);
+ ws->rx_len += (size_t)n;
+
+ while (ws->rx_len >= 2) {
+ uint64_t payload_len = ws->rx_buf[1] & 0x7Fu;
+ size_t header_len = 2;
+ size_t frame_len;
+ hybbx_result_t rc;
+
+ if (payload_len == 126) {
+ if (ws->rx_len < 4) {
+ return HYBBX_OK;
+ }
+ payload_len = ((uint64_t)ws->rx_buf[2] << 8) | ws->rx_buf[3];
+ header_len = 4;
+ } else if (payload_len == 127) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (payload_len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ frame_len = header_len + 4 + (size_t)payload_len;
+ if (ws->rx_len < frame_len) {
+ return HYBBX_OK;
+ }
+
+ rc = ws_handle_frame(ws, ws->rx_buf, frame_len, on_data, ctx);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (ws->rx_len > frame_len) {
+ memmove(ws->rx_buf, ws->rx_buf + frame_len,
+ ws->rx_len - frame_len);
+ }
+ ws->rx_len -= frame_len;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_write_text(hybbx_ws_connection_t *ws,
+ const char *data, size_t len)
+{
+ uint8_t hdr[10];
+ size_t hdr_len = 2;
+
+ if (ws == NULL || data == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hdr[0] = 0x81;
+ if (len < 126) {
+ hdr[1] = (uint8_t)len;
+ hdr_len = 2;
+ } else {
+ hdr[1] = 126;
+ hdr[2] = (uint8_t)((len >> 8) & 0xFFu);
+ hdr[3] = (uint8_t)(len & 0xFFu);
+ hdr_len = 4;
+ }
+
+ if (ws_send_all(ws, hdr, hdr_len) < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (len > 0 && ws_send_all(ws, data, len) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_ping(hybbx_ws_connection_t *ws)
+{
+ uint8_t frame[2];
+
+ if (ws == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 0x89;
+ frame[1] = 0x00;
+
+ if (ws_send_all(ws, frame, sizeof(frame)) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_close(hybbx_ws_connection_t *ws)
+{
+ uint8_t frame[2];
+
+ if (ws == NULL || ws->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 0x88;
+ frame[1] = 0x00;
+ (void)ws_send_all(ws, frame, 2);
+ ws->established = 0;
+ return HYBBX_OK;
+}
diff --git a/plugins/websocket/ws_proto.h b/plugins/websocket/ws_proto.h
new file mode 100644
index 0000000..5a1cb23
--- /dev/null
+++ b/plugins/websocket/ws_proto.h
@@ -0,0 +1,63 @@
+#ifndef HYBBX_WS_PROTO_H
+#define HYBBX_WS_PROTO_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_WS_HANDSHAKE_MAX 4096u
+#define HYBBX_WS_FRAME_PAYLOAD_MAX 4096u
+
+typedef void (*hybbx_ws_data_cb)(void *ctx, const uint8_t *data, size_t len);
+
+typedef struct hybbx_ws_connection {
+ int fd;
+ void *tls;
+ int established;
+ uint8_t rx_buf[HYBBX_WS_FRAME_PAYLOAD_MAX + 16];
+ size_t rx_len;
+} hybbx_ws_connection_t;
+
+void hybbx_ws_connection_init(hybbx_ws_connection_t *ws, int fd);
+
+/** Attach TLS session (opaque SSL*) after accept; plain ws when NULL. */
+void hybbx_ws_connection_set_tls(hybbx_ws_connection_t *ws, void *tls);
+
+/** Release TLS resources; does not close @c fd. */
+void hybbx_ws_connection_cleanup(hybbx_ws_connection_t *ws);
+
+/**
+ * Perform server-side HTTP Upgrade handshake.
+ * @p path must match the request URI path (e.g. /hybbx) when non-empty.
+ */
+hybbx_result_t hybbx_ws_server_handshake(hybbx_ws_connection_t *ws,
+ const char *path);
+
+/**
+ * Read WebSocket frames; invoke @p on_data for each complete text/binary
+ * payload from the client. Returns HYBBX_ERR_IO on disconnect.
+ */
+hybbx_result_t hybbx_ws_read_frames(hybbx_ws_connection_t *ws,
+ hybbx_ws_data_cb on_data,
+ void *ctx);
+
+/** Send one server text frame (unmasked). */
+hybbx_result_t hybbx_ws_write_text(hybbx_ws_connection_t *ws,
+ const char *data, size_t len);
+
+/** Send one server ping frame (unmasked, empty payload). */
+hybbx_result_t hybbx_ws_ping(hybbx_ws_connection_t *ws);
+
+/** Send WebSocket close frame. */
+hybbx_result_t hybbx_ws_close(hybbx_ws_connection_t *ws);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_WS_PROTO_H */
diff --git a/plugins/websocket/ws_sha1.c b/plugins/websocket/ws_sha1.c
new file mode 100644
index 0000000..53ad4c0
--- /dev/null
+++ b/plugins/websocket/ws_sha1.c
@@ -0,0 +1,138 @@
+/*
+ * Minimal SHA-1 for WebSocket handshake (RFC 6455). Public-domain style.
+ */
+#include "ws_sha1.h"
+
+#include <stdint.h>
+#include <string.h>
+#include <string.h>
+
+typedef struct ws_sha1_ctx {
+ uint32_t state[5];
+ uint64_t count;
+ uint8_t buffer[64];
+} ws_sha1_ctx_t;
+
+static uint32_t rol(uint32_t v, unsigned bits)
+{
+ return (v << bits) | (v >> (32u - bits));
+}
+
+static void ws_sha1_transform(ws_sha1_ctx_t *ctx, const uint8_t block[64])
+{
+ uint32_t w[80];
+ uint32_t a, b, c, d, e, f, k, t;
+ unsigned i;
+
+ for (i = 0; i < 16; i++) {
+ w[i] = ((uint32_t)block[i * 4] << 24) |
+ ((uint32_t)block[i * 4 + 1] << 16) |
+ ((uint32_t)block[i * 4 + 2] << 8) |
+ (uint32_t)block[i * 4 + 3];
+ }
+ for (i = 16; i < 80; i++) {
+ w[i] = rol(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
+ }
+
+ a = ctx->state[0];
+ b = ctx->state[1];
+ c = ctx->state[2];
+ d = ctx->state[3];
+ e = ctx->state[4];
+
+ for (i = 0; i < 80; i++) {
+ if (i < 20) {
+ f = (b & c) | ((~b) & d);
+ k = 0x5A827999u;
+ } else if (i < 40) {
+ f = b ^ c ^ d;
+ k = 0x6ED9EBA1u;
+ } else if (i < 60) {
+ f = (b & c) | (b & d) | (c & d);
+ k = 0x8F1BBCDCu;
+ } else {
+ f = b ^ c ^ d;
+ k = 0xCA62C1D6u;
+ }
+ t = rol(a, 5) + f + e + k + w[i];
+ e = d;
+ d = c;
+ c = rol(b, 30);
+ b = a;
+ a = t;
+ }
+
+ ctx->state[0] += a;
+ ctx->state[1] += b;
+ ctx->state[2] += c;
+ ctx->state[3] += d;
+ ctx->state[4] += e;
+}
+
+static void ws_sha1_init(ws_sha1_ctx_t *ctx)
+{
+ ctx->state[0] = 0x67452301u;
+ ctx->state[1] = 0xEFCDAB89u;
+ ctx->state[2] = 0x98BADCFEu;
+ ctx->state[3] = 0x10325476u;
+ ctx->state[4] = 0xC3D2E1F0u;
+ ctx->count = 0;
+}
+
+static void ws_sha1_update(ws_sha1_ctx_t *ctx, const uint8_t *data, size_t len)
+{
+ size_t i = 0;
+ size_t idx = (size_t)((ctx->count >> 3) & 63u);
+
+ ctx->count += (uint64_t)len * 8u;
+
+ if (idx + len > 63) {
+ memcpy(ctx->buffer + idx, data, 64 - idx);
+ ws_sha1_transform(ctx, ctx->buffer);
+ for (i = 64 - idx; i + 63 < len; i += 64) {
+ ws_sha1_transform(ctx, data + i);
+ }
+ idx = 0;
+ } else {
+ i = 0;
+ }
+
+ memcpy(ctx->buffer + idx, data + i, len - i);
+}
+
+static void ws_sha1_final(ws_sha1_ctx_t *ctx, uint8_t out[20])
+{
+ uint8_t final[64];
+ size_t idx = (size_t)((ctx->count >> 3) & 63u);
+ unsigned i;
+
+ memcpy(final, ctx->buffer, idx);
+ final[idx++] = 0x80;
+ if (idx > 56) {
+ memset(final + idx, 0, 64 - idx);
+ ws_sha1_transform(ctx, final);
+ idx = 0;
+ }
+ memset(final + idx, 0, 56 - idx);
+
+ for (i = 0; i < 8; i++) {
+ final[56 + i] = (uint8_t)(ctx->count >> (56 - i * 8));
+ }
+ ws_sha1_transform(ctx, final);
+
+ for (i = 0; i < 5; i++) {
+ out[i * 4] = (uint8_t)(ctx->state[i] >> 24);
+ out[i * 4 + 1] = (uint8_t)(ctx->state[i] >> 16);
+ out[i * 4 + 2] = (uint8_t)(ctx->state[i] >> 8);
+ out[i * 4 + 3] = (uint8_t)(ctx->state[i]);
+ }
+}
+
+void hybbx_ws_sha1(const uint8_t *data, size_t len, uint8_t out[20])
+{
+ ws_sha1_ctx_t ctx;
+
+ ws_sha1_init(&ctx);
+ ws_sha1_update(&ctx, data, len);
+ ws_sha1_final(&ctx, out);
+}
diff --git a/plugins/websocket/ws_sha1.h b/plugins/websocket/ws_sha1.h
new file mode 100644
index 0000000..b685cf7
--- /dev/null
+++ b/plugins/websocket/ws_sha1.h
@@ -0,0 +1,9 @@
+#ifndef HYBBX_WS_SHA1_H
+#define HYBBX_WS_SHA1_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void hybbx_ws_sha1(const uint8_t *data, size_t len, uint8_t out[20]);
+
+#endif /* HYBBX_WS_SHA1_H */
diff --git a/plugins/websocket/ws_tls.c b/plugins/websocket/ws_tls.c
new file mode 100644
index 0000000..679ce59
--- /dev/null
+++ b/plugins/websocket/ws_tls.c
@@ -0,0 +1,400 @@
+#include "ws_tls.h"
+
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/websocket.h"
+#include "hybbx/log.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#ifdef HYBBX_WS_HAVE_TLS
+
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <openssl/pem.h>
+#include <openssl/ssl.h>
+#include <openssl/x509.h>
+
+static SSL_CTX *g_ssl_ctx;
+static int g_tls_enabled;
+
+static hybbx_result_t mkdir_cert_dir(const char *cert_dir)
+{
+ char parent[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (stat(cert_dir, &st) == 0) {
+ return S_ISDIR(st.st_mode) ? HYBBX_OK : HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_dirname(cert_dir, parent, sizeof(parent)) == HYBBX_OK &&
+ parent[0] != '\0' && strcmp(parent, cert_dir) != 0 &&
+ stat(parent, &st) != 0) {
+ if (mkdir_cert_dir(parent) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (mkdir(cert_dir, 0700) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void build_cert_path(const char *cert_dir, const char *name,
+ char *out, size_t out_len)
+{
+ if (cert_dir == NULL || name == NULL || out == NULL || out_len == 0) {
+ if (out != NULL && out_len > 0) {
+ out[0] = '\0';
+ }
+ return;
+ }
+
+ snprintf(out, out_len, "%s/%s", cert_dir, name);
+}
+
+static int cert_files_exist(const char *cert_path, const char *key_path)
+{
+ struct stat st;
+
+ return stat(cert_path, &st) == 0 && S_ISREG(st.st_mode) &&
+ stat(key_path, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static hybbx_result_t generate_self_signed(const char *cert_path,
+ const char *key_path)
+{
+ EVP_PKEY *pkey = NULL;
+ EVP_PKEY_CTX *pctx = NULL;
+ X509 *cert = NULL;
+ X509_NAME *name;
+ FILE *fp;
+ int rc = HYBBX_ERR_IO;
+
+ pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
+ if (pctx == NULL ||
+ EVP_PKEY_keygen_init(pctx) <= 0 ||
+ EVP_PKEY_CTX_set_rsa_keygen_bits(pctx, 2048) <= 0 ||
+ EVP_PKEY_keygen(pctx, &pkey) <= 0 || pkey == NULL) {
+ goto done;
+ }
+
+ cert = X509_new();
+ if (cert == NULL) {
+ goto done;
+ }
+
+ if (X509_set_version(cert, 2) != 1 ||
+ ASN1_INTEGER_set(X509_get_serialNumber(cert), 1) != 1 ||
+ X509_gmtime_adj(X509_get_notBefore(cert), 0) == NULL ||
+ X509_gmtime_adj(X509_get_notAfter(cert),
+ (long)HYBBX_WS_TLS_CERT_VALID_DAYS * 24L * 3600L) ==
+ NULL ||
+ X509_set_pubkey(cert, pkey) != 1) {
+ goto done;
+ }
+
+ name = X509_get_subject_name(cert);
+ if (name == NULL ||
+ X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
+ (const unsigned char *)"hybbx", -1, -1,
+ 0) != 1 ||
+ X509_set_issuer_name(cert, name) != 1 ||
+ X509_sign(cert, pkey, EVP_sha256()) <= 0) {
+ goto done;
+ }
+
+ fp = fopen(key_path, "w");
+ if (fp == NULL ||
+ PEM_write_PrivateKey(fp, pkey, NULL, NULL, 0, NULL, NULL) != 1) {
+ if (fp != NULL) {
+ fclose(fp);
+ unlink(key_path);
+ }
+ goto done;
+ }
+ fclose(fp);
+ (void)chmod(key_path, 0600);
+
+ fp = fopen(cert_path, "w");
+ if (fp == NULL || PEM_write_X509(fp, cert) != 1) {
+ if (fp != NULL) {
+ fclose(fp);
+ unlink(cert_path);
+ }
+ goto done;
+ }
+ fclose(fp);
+ (void)chmod(cert_path, 0644);
+
+ rc = HYBBX_OK;
+
+done:
+ if (cert != NULL) {
+ X509_free(cert);
+ }
+ if (pkey != NULL) {
+ EVP_PKEY_free(pkey);
+ }
+ if (pctx != NULL) {
+ EVP_PKEY_CTX_free(pctx);
+ }
+
+ return rc;
+}
+
+int hybbx_ws_tls_compiled(void)
+{
+ return 1;
+}
+
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir)
+{
+ char cert_path[HYBBX_PATH_MAX];
+ char key_path[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = mkdir_cert_dir(cert_dir);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ build_cert_path(cert_dir, HYBBX_WS_TLS_CERT_FILENAME, cert_path,
+ sizeof(cert_path));
+ build_cert_path(cert_dir, HYBBX_WS_TLS_KEY_FILENAME, key_path,
+ sizeof(key_path));
+
+ if (cert_files_exist(cert_path, key_path)) {
+ return HYBBX_OK;
+ }
+
+ rc = generate_self_signed(cert_path, key_path);
+ if (rc == HYBBX_OK) {
+ hybbx_log_info("[websocket] created self-signed TLS certificate in %s",
+ cert_dir);
+ }
+
+ return rc;
+}
+
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir)
+{
+ char cert_path[HYBBX_PATH_MAX];
+ char key_path[HYBBX_PATH_MAX];
+
+ if (g_tls_enabled) {
+ return HYBBX_OK;
+ }
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ build_cert_path(cert_dir, HYBBX_WS_TLS_CERT_FILENAME, cert_path,
+ sizeof(cert_path));
+ build_cert_path(cert_dir, HYBBX_WS_TLS_KEY_FILENAME, key_path,
+ sizeof(key_path));
+
+ if (!cert_files_exist(cert_path, key_path)) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (OPENSSL_init_ssl(OPENSSL_INIT_LOAD_SSL_STRINGS |
+ OPENSSL_INIT_LOAD_CRYPTO_STRINGS,
+ NULL) != 1) {
+ return HYBBX_ERR_IO;
+ }
+
+ g_ssl_ctx = SSL_CTX_new(TLS_server_method());
+ if (g_ssl_ctx == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ SSL_CTX_set_min_proto_version(g_ssl_ctx, TLS1_2_VERSION);
+
+ if (SSL_CTX_use_certificate_file(g_ssl_ctx, cert_path, SSL_FILETYPE_PEM) !=
+ 1 ||
+ SSL_CTX_use_PrivateKey_file(g_ssl_ctx, key_path, SSL_FILETYPE_PEM) !=
+ 1 ||
+ SSL_CTX_check_private_key(g_ssl_ctx) != 1) {
+ SSL_CTX_free(g_ssl_ctx);
+ g_ssl_ctx = NULL;
+ return HYBBX_ERR_IO;
+ }
+
+ g_tls_enabled = 1;
+ return HYBBX_OK;
+}
+
+void hybbx_ws_tls_server_stop(void)
+{
+ if (g_ssl_ctx != NULL) {
+ SSL_CTX_free(g_ssl_ctx);
+ g_ssl_ctx = NULL;
+ }
+ g_tls_enabled = 0;
+}
+
+int hybbx_ws_tls_server_enabled(void)
+{
+ return g_tls_enabled;
+}
+
+void *hybbx_ws_tls_accept(int fd)
+{
+ SSL *ssl;
+
+ if (!g_tls_enabled || g_ssl_ctx == NULL || fd < 0) {
+ return NULL;
+ }
+
+ ssl = SSL_new(g_ssl_ctx);
+ if (ssl == NULL) {
+ return NULL;
+ }
+
+ if (SSL_set_fd(ssl, fd) != 1 || SSL_accept(ssl) <= 0) {
+ SSL_free(ssl);
+ return NULL;
+ }
+
+ return ssl;
+}
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len)
+{
+ SSL *ssl = (SSL *)tls;
+ int n;
+
+ if (ssl == NULL || buf == NULL || len == 0) {
+ return -1;
+ }
+
+ do {
+ n = SSL_read(ssl, buf, (int)len);
+ } while (n < 0 && SSL_get_error(ssl, n) == SSL_ERROR_SYSCALL &&
+ errno == EINTR);
+
+ return (ssize_t)n;
+}
+
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len)
+{
+ const uint8_t *p = (const uint8_t *)buf;
+ SSL *ssl = (SSL *)tls;
+ size_t sent = 0;
+
+ if (ssl == NULL || buf == NULL) {
+ return -1;
+ }
+
+ while (sent < len) {
+ int n;
+
+ do {
+ n = SSL_write(ssl, p + sent, (int)(len - sent));
+ } while (n < 0 && SSL_get_error(ssl, n) == SSL_ERROR_SYSCALL &&
+ errno == EINTR);
+
+ if (n <= 0) {
+ return -1;
+ }
+ sent += (size_t)n;
+ }
+
+ return (ssize_t)sent;
+}
+
+void hybbx_ws_tls_shutdown(void *tls)
+{
+ SSL *ssl = (SSL *)tls;
+
+ if (ssl != NULL) {
+ (void)SSL_shutdown(ssl);
+ }
+}
+
+void hybbx_ws_tls_free(void *tls)
+{
+ SSL *ssl = (SSL *)tls;
+
+ if (ssl != NULL) {
+ SSL_free(ssl);
+ }
+}
+
+#else /* !HYBBX_WS_HAVE_TLS */
+
+int hybbx_ws_tls_compiled(void)
+{
+ return 0;
+}
+
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir)
+{
+ (void)cert_dir;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir)
+{
+ (void)cert_dir;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void hybbx_ws_tls_server_stop(void)
+{
+}
+
+int hybbx_ws_tls_server_enabled(void)
+{
+ return 0;
+}
+
+void *hybbx_ws_tls_accept(int fd)
+{
+ (void)fd;
+ return NULL;
+}
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len)
+{
+ (void)tls;
+ (void)buf;
+ (void)len;
+ return -1;
+}
+
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len)
+{
+ (void)tls;
+ (void)buf;
+ (void)len;
+ return -1;
+}
+
+void hybbx_ws_tls_shutdown(void *tls)
+{
+ (void)tls;
+}
+
+void hybbx_ws_tls_free(void *tls)
+{
+ (void)tls;
+}
+
+#endif /* HYBBX_WS_HAVE_TLS */
diff --git a/plugins/websocket/ws_tls.h b/plugins/websocket/ws_tls.h
new file mode 100644
index 0000000..2615fab
--- /dev/null
+++ b/plugins/websocket/ws_tls.h
@@ -0,0 +1,43 @@
+#ifndef HYBBX_WS_TLS_H
+#define HYBBX_WS_TLS_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <sys/types.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Return 1 when this build links OpenSSL for WebSocket TLS. */
+int hybbx_ws_tls_compiled(void);
+
+/**
+ * Create @p cert_dir if needed and generate a self-signed certificate on first
+ * start when files are missing. No-op when OpenSSL is not compiled in.
+ */
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir);
+
+/** Load server TLS context after @p cert_dir is ready. */
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir);
+
+void hybbx_ws_tls_server_stop(void);
+
+/** Return 1 when the server accepts TLS (OpenSSL + certs loaded). */
+int hybbx_ws_tls_server_enabled(void);
+
+/** Perform TLS server handshake on @p fd; returns opaque SSL* or NULL. */
+void *hybbx_ws_tls_accept(int fd);
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len);
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len);
+
+void hybbx_ws_tls_shutdown(void *tls);
+void hybbx_ws_tls_free(void *tls);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_WS_TLS_H */
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com