summaryrefslogtreecommitdiff
path: root/src/core
diff options
context:
space:
mode:
Diffstat (limited to 'src/core')
-rw-r--r--src/core/auth.c477
-rw-r--r--src/core/bandwidth_policy.c229
-rw-r--r--src/core/broadcast.c982
-rw-r--r--src/core/chat.c463
-rw-r--r--src/core/circuit.c337
-rw-r--r--src/core/circuit_balance.c545
-rw-r--r--src/core/circuit_bridge.c175
-rw-r--r--src/core/circuit_tcp.c1837
-rw-r--r--src/core/command.c2450
-rw-r--r--src/core/command_parse.c347
-rw-r--r--src/core/commands_registry.c1429
-rw-r--r--src/core/conference.c720
-rw-r--r--src/core/config.c862
-rw-r--r--src/core/crdop.c66
-rw-r--r--src/core/crypto.c302
-rw-r--r--src/core/crypto_backends.c223
-rw-r--r--src/core/crypto_backends.h62
-rw-r--r--src/core/crypto_config.c247
-rw-r--r--src/core/crypto_libsodium.c111
-rw-r--r--src/core/crypto_openssl.c142
-rw-r--r--src/core/daemon_wrap.c320
-rw-r--r--src/core/instance.c272
-rw-r--r--src/core/link.c427
-rw-r--r--src/core/log.c694
-rw-r--r--src/core/mail.c1532
-rw-r--r--src/core/mail_sql.c655
-rw-r--r--src/core/mail_sql.h40
-rw-r--r--src/core/mains_proxy.c1034
-rw-r--r--src/core/max25.c507
-rw-r--r--src/core/messages.c110
-rw-r--r--src/core/monitor.c497
-rw-r--r--src/core/networks.c191
-rw-r--r--src/core/password.c396
-rw-r--r--src/core/privilege.c233
-rw-r--r--src/core/proxychat.c134
-rw-r--r--src/core/proxymail.c676
-rw-r--r--src/core/registry.c83
-rw-r--r--src/core/rf_tx_pace.c100
-rw-r--r--src/core/security.c205
-rw-r--r--src/core/security_ban.c1213
-rw-r--r--src/core/service.c1270
-rw-r--r--src/core/session.c2678
-rw-r--r--src/core/storage.c378
-rw-r--r--src/core/storage_flatfile.c1507
-rw-r--r--src/core/storage_private.h81
-rw-r--r--src/core/storage_sql.c1209
-rw-r--r--src/core/terminal.c98
-rw-r--r--src/core/texts.c470
-rw-r--r--src/core/traffic.c294
-rw-r--r--src/core/util.c832
50 files changed, 30142 insertions, 0 deletions
diff --git a/src/core/auth.c b/src/core/auth.c
new file mode 100644
index 0000000..e68242a
--- /dev/null
+++ b/src/core/auth.c
@@ -0,0 +1,477 @@
+#include "hybbx/auth.h"
+#include "hybbx/storage.h"
+#include "hybbx/util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+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';
+}
+
+void hybbx_auth_config_defaults(hybbx_auth_config_t *auth)
+{
+ if (auth == NULL) {
+ return;
+ }
+
+ auth->auto_login = 1;
+ hybbx_strlcpy(auth->guest_prefix, HYBBX_AUTH_DEFAULT_GUEST_PREFIX,
+ sizeof(auth->guest_prefix));
+}
+
+const char *hybbx_user_level_name(hybbx_user_level_t level)
+{
+ switch (level) {
+ case HYBBX_LEVEL_SYSOP:
+ return "sysop";
+ case HYBBX_LEVEL_ADMIN:
+ return "admin";
+ case HYBBX_LEVEL_MOD:
+ return "mod";
+ case HYBBX_LEVEL_USER:
+ return "user";
+ case HYBBX_LEVEL_GUEST:
+ return "guest";
+ default:
+ return "user";
+ }
+}
+
+hybbx_user_level_t hybbx_user_level_parse(const char *name)
+{
+ if (name == NULL || name[0] == '\0') {
+ return HYBBX_LEVEL_USER;
+ }
+
+ if (str_ieq(name, "sysop")) {
+ return HYBBX_LEVEL_SYSOP;
+ }
+ if (str_ieq(name, "admin")) {
+ return HYBBX_LEVEL_ADMIN;
+ }
+ if (str_ieq(name, "mod") || str_ieq(name, "moderator")) {
+ return HYBBX_LEVEL_MOD;
+ }
+ if (str_ieq(name, "guest")) {
+ return HYBBX_LEVEL_GUEST;
+ }
+
+ return HYBBX_LEVEL_USER;
+}
+
+int hybbx_user_level_is_guest(hybbx_user_level_t level)
+{
+ return level == HYBBX_LEVEL_GUEST;
+}
+
+int hybbx_user_level_is_sysop_or_admin(hybbx_user_level_t level)
+{
+ return level == HYBBX_LEVEL_SYSOP || level == HYBBX_LEVEL_ADMIN;
+}
+
+int hybbx_user_level_is_sysop(hybbx_user_level_t level)
+{
+ return level == HYBBX_LEVEL_SYSOP;
+}
+
+static int name_has_prefix(const char *username, const char *prefix)
+{
+ size_t plen;
+ size_t i;
+
+ if (username == NULL || prefix == NULL) {
+ return 0;
+ }
+
+ plen = strlen(prefix);
+ if (plen == 0) {
+ return 0;
+ }
+
+ for (i = 0; i < plen; i++) {
+ char cu = username[i];
+ char cp = prefix[i];
+
+ if (cu == '\0') {
+ return 0;
+ }
+
+ if (cu >= 'A' && cu <= 'Z') {
+ cu = (char)(cu + 32);
+ }
+ if (cp >= 'A' && cp <= 'Z') {
+ cp = (char)(cp + 32);
+ }
+
+ if (cu != cp) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static int username_char_ok(char ch)
+{
+ return (ch >= 'a' && ch <= 'z') ||
+ (ch >= 'A' && ch <= 'Z') ||
+ (ch >= '0' && ch <= '9') ||
+ ch == '_' || ch == '-';
+}
+
+void hybbx_username_normalize(char *username)
+{
+ size_t i;
+
+ if (username == NULL) {
+ return;
+ }
+
+ for (i = 0; username[i] != '\0'; i++) {
+ if (username[i] >= 'A' && username[i] <= 'Z') {
+ username[i] = (char)(username[i] + 32);
+ }
+ }
+}
+
+const char *hybbx_username_display(const char *username,
+ hybbx_user_level_t level)
+{
+ hybbx_user_record_t user;
+
+ if (username == NULL || username[0] == '\0') {
+ return "";
+ }
+
+ memset(&user, 0, sizeof(user));
+ hybbx_strlcpy(user.username, username, sizeof(user.username));
+ hybbx_username_normalize(user.username);
+ user.level = level;
+ hybbx_nickname_infer(username, user.nickname, sizeof(user.nickname));
+
+ return hybbx_user_display_name(&user);
+}
+
+void hybbx_nickname_infer(const char *stored_username,
+ char *nickname,
+ size_t nickname_len)
+{
+ if (stored_username == NULL || nickname == NULL || nickname_len == 0) {
+ return;
+ }
+
+ if (str_ieq(stored_username, HYBBX_DEFAULT_SYSOP_USERNAME) ||
+ str_ieq(stored_username, "sysop")) {
+ hybbx_strlcpy(nickname, HYBBX_DEFAULT_SYSOP_USERNAME, nickname_len);
+ return;
+ }
+
+ if (stored_username[0] >= 'a' && stored_username[0] <= 'z') {
+ nickname[0] = (char)(stored_username[0] - 32);
+ hybbx_strlcpy(nickname + 1, stored_username + 1, nickname_len - 1);
+ return;
+ }
+
+ hybbx_strlcpy(nickname, stored_username, nickname_len);
+}
+
+const char *hybbx_user_display_name(const hybbx_user_record_t *user)
+{
+ if (user == NULL) {
+ return "";
+ }
+
+ if (user->nickname[0] != '\0') {
+ return user->nickname;
+ }
+
+ if (user->username[0] == '\0') {
+ return "";
+ }
+
+ return user->username;
+}
+
+int hybbx_guest_slot_from_username(const char *guest_prefix,
+ const char *username,
+ unsigned *slot_out)
+{
+ const char *prefix;
+ size_t plen;
+ size_t i;
+ unsigned long slot;
+ char *end;
+
+ if (username == NULL || slot_out == NULL) {
+ return 0;
+ }
+
+ prefix = guest_prefix != NULL && guest_prefix[0] != '\0' ?
+ guest_prefix : HYBBX_AUTH_DEFAULT_GUEST_PREFIX;
+ plen = strlen(prefix);
+ if (plen == 0 || strlen(username) <= plen) {
+ return 0;
+ }
+
+ for (i = 0; i < plen; i++) {
+ char a = (char)(prefix[i] >= 'A' && prefix[i] <= 'Z' ?
+ prefix[i] + 32 : prefix[i]);
+ char b = (char)(username[i] >= 'A' && username[i] <= 'Z' ?
+ username[i] + 32 : username[i]);
+
+ if (a != b) {
+ return 0;
+ }
+ }
+
+ slot = strtoul(username + plen, &end, 10);
+ if (end == username + plen || *end != '\0' || slot < 1 ||
+ slot > HYBBX_GUEST_NUMBER_MAX) {
+ return 0;
+ }
+
+ *slot_out = (unsigned)slot;
+ return 1;
+}
+
+void hybbx_guest_fill_record(const char *guest_prefix,
+ unsigned slot,
+ hybbx_user_record_t *out)
+{
+ const char *prefix;
+ time_t now;
+
+ if (out == NULL || slot < 1 || slot > HYBBX_GUEST_NUMBER_MAX) {
+ return;
+ }
+
+ prefix = guest_prefix != NULL && guest_prefix[0] != '\0' ?
+ guest_prefix : HYBBX_AUTH_DEFAULT_GUEST_PREFIX;
+ now = time(NULL);
+
+ memset(out, 0, sizeof(*out));
+ out->id = HYBBX_GUEST_USER_ID(slot);
+ snprintf(out->username, sizeof(out->username), "%s%u", prefix, slot);
+ hybbx_strlcpy(out->nickname, out->username, sizeof(out->nickname));
+ out->level = HYBBX_LEVEL_GUEST;
+ out->active = 1;
+ out->created_at = now;
+}
+
+int hybbx_username_valid(const char *username, const char *guest_prefix)
+{
+ size_t len;
+ size_t i;
+ size_t digit_count = 0;
+ size_t underscore_count = 0;
+ size_t hyphen_count = 0;
+ const char *prefix;
+
+ if (username == NULL) {
+ return 0;
+ }
+
+ len = strlen(username);
+ if (len < HYBBX_USERNAME_MIN_LEN || len > HYBBX_USERNAME_MAX_LEN) {
+ return 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ if (!username_char_ok(username[i])) {
+ return 0;
+ }
+
+ if (username[i] >= '0' && username[i] <= '9') {
+ digit_count++;
+ } else if (username[i] == '_') {
+ underscore_count++;
+ } else if (username[i] == '-') {
+ hyphen_count++;
+ }
+ }
+
+ if (digit_count > HYBBX_USERNAME_MAX_DIGITS) {
+ return 0;
+ }
+
+ if (underscore_count > 1 || hyphen_count > 1 ||
+ (underscore_count > 0 && hyphen_count > 0)) {
+ return 0;
+ }
+
+ prefix = guest_prefix != NULL && guest_prefix[0] != '\0' ?
+ guest_prefix : HYBBX_AUTH_DEFAULT_GUEST_PREFIX;
+
+ if (name_has_prefix(username, prefix)) {
+ return 0;
+ }
+
+ if (str_ieq(username, "sysop") || str_ieq(username, "admin") ||
+ str_ieq(username, "mod") || str_ieq(username, "guest")) {
+ return 0;
+ }
+
+ return 1;
+}
+
+int hybbx_password_plain_valid(const char *password)
+{
+ size_t len;
+
+ if (password == NULL || password[0] == '\0') {
+ return 0;
+ }
+
+ if (str_ieq(password, "-")) {
+ return 0;
+ }
+
+ len = strlen(password);
+ if (len < HYBBX_PASSWORD_MIN_LEN || len > HYBBX_PASSWORD_MAX_LEN) {
+ return 0;
+ }
+
+ return 1;
+}
+
+static int profile_text_char_ok(char ch)
+{
+ if (ch == '|') {
+ return 0;
+ }
+
+ return (ch >= 'a' && ch <= 'z') ||
+ (ch >= 'A' && ch <= 'Z') ||
+ (ch >= '0' && ch <= '9') ||
+ ch == ' ' || ch == '-' || ch == '\'' || ch == '.' || ch == ',';
+}
+
+static int profile_text_valid(const char *text, size_t min_len, size_t max_len)
+{
+ size_t len;
+ size_t i;
+
+ if (text == NULL) {
+ return 0;
+ }
+
+ len = strlen(text);
+ if (len < min_len || len >= max_len) {
+ return 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ if (!profile_text_char_ok(text[i])) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static int email_valid(const char *email)
+{
+ const char *at;
+ const char *dot;
+ size_t len;
+ size_t i;
+
+ if (email == NULL) {
+ return 0;
+ }
+
+ len = strlen(email);
+ if (len < 5 || len >= HYBBX_USER_EMAIL_MAX) {
+ return 0;
+ }
+
+ at = strchr(email, '@');
+ if (at == NULL || at == email || at[1] == '\0') {
+ return 0;
+ }
+
+ dot = strchr(at + 1, '.');
+ if (dot == NULL || dot[1] == '\0') {
+ return 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ char ch = email[i];
+
+ if (ch == '|' || ch == ' ') {
+ return 0;
+ }
+
+ if (!((ch >= 'a' && ch <= 'z') ||
+ (ch >= 'A' && ch <= 'Z') ||
+ (ch >= '0' && ch <= '9') ||
+ ch == '@' || ch == '.' || ch == '-' || ch == '_' || ch == '+')) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+int hybbx_user_profile_valid(const hybbx_user_registration_t *reg)
+{
+ if (reg == NULL) {
+ return 0;
+ }
+
+ if (!profile_text_valid(reg->full_name, 2, HYBBX_USER_FULL_NAME_MAX)) {
+ return 0;
+ }
+
+ if (!profile_text_valid(reg->country, 2, HYBBX_USER_COUNTRY_MAX)) {
+ return 0;
+ }
+
+ if (!profile_text_valid(reg->location, 2, HYBBX_USER_LOCATION_MAX)) {
+ return 0;
+ }
+
+ if (!email_valid(reg->email)) {
+ return 0;
+ }
+
+ return 1;
+}
+
+int hybbx_registration_valid(const hybbx_user_registration_t *reg,
+ const char *guest_prefix)
+{
+ if (reg == NULL) {
+ return 0;
+ }
+
+ if (!hybbx_username_valid(reg->username, guest_prefix)) {
+ return 0;
+ }
+
+ if (!hybbx_username_valid(reg->nickname, guest_prefix)) {
+ return 0;
+ }
+
+ return hybbx_user_profile_valid(reg);
+}
diff --git a/src/core/bandwidth_policy.c b/src/core/bandwidth_policy.c
new file mode 100644
index 0000000..47a59d7
--- /dev/null
+++ b/src/core/bandwidth_policy.c
@@ -0,0 +1,229 @@
+#include "hybbx/bandwidth_policy.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/plugin.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+#define BW_USER_MAX 128u
+
+/** Lower = sacrificed first under pressure (AX.25 users before full-duplex TCP). */
+#define BW_PRIORITY_AX25_USER 0
+#define BW_PRIORITY_TCP_USER 1
+
+typedef struct bw_user_entry {
+ hybbx_session_t *session;
+ time_t connected_at;
+ int sacrifice_priority;
+} bw_user_entry_t;
+
+typedef struct bw_collect_ctx {
+ bw_user_entry_t entries[BW_USER_MAX];
+ unsigned count;
+} bw_collect_ctx_t;
+
+static int session_sacrifice_priority(const hybbx_session_t *session)
+{
+ const hybbx_transport_plugin_t *transport;
+
+ if (session == NULL) {
+ return -1;
+ }
+
+ transport = session->transport;
+ if (transport == NULL) {
+ return -1;
+ }
+
+ switch (transport->kind) {
+ case HYBBX_TRANSPORT_PACKET_RADIO:
+ return BW_PRIORITY_AX25_USER;
+ case HYBBX_TRANSPORT_TELNET:
+ return BW_PRIORITY_TCP_USER;
+ case HYBBX_TRANSPORT_CIRCUIT:
+ default:
+ return -1;
+ }
+}
+
+static void bw_collect_visitor(hybbx_session_t *session, void *userdata)
+{
+ bw_collect_ctx_t *ctx = (bw_collect_ctx_t *)userdata;
+ int priority;
+
+ if (ctx == NULL || session == NULL) {
+ return;
+ }
+
+ priority = session_sacrifice_priority(session);
+ if (priority < 0 || !hybbx_session_logged_in(session)) {
+ return;
+ }
+
+ if (ctx->count >= BW_USER_MAX) {
+ return;
+ }
+
+ ctx->entries[ctx->count].session = session;
+ ctx->entries[ctx->count].connected_at = hybbx_session_connected_at(session);
+ ctx->entries[ctx->count].sacrifice_priority = priority;
+ ctx->count++;
+}
+
+static void bw_sort_victims_first(bw_collect_ctx_t *ctx)
+{
+ unsigned i;
+ unsigned j;
+
+ if (ctx == NULL) {
+ return;
+ }
+
+ /* Lowest sacrifice_priority first (AX.25), then newest connected_at. */
+ for (i = 0; i + 1 < ctx->count; i++) {
+ for (j = i + 1; j < ctx->count; j++) {
+ int swap = 0;
+
+ if (ctx->entries[j].sacrifice_priority <
+ ctx->entries[i].sacrifice_priority) {
+ swap = 1;
+ } else if (ctx->entries[j].sacrifice_priority ==
+ ctx->entries[i].sacrifice_priority) {
+ if (ctx->entries[j].connected_at >
+ ctx->entries[i].connected_at) {
+ swap = 1;
+ } else if (ctx->entries[j].connected_at ==
+ ctx->entries[i].connected_at &&
+ hybbx_session_id(ctx->entries[j].session) >
+ hybbx_session_id(ctx->entries[i].session)) {
+ swap = 1;
+ }
+ }
+
+ if (swap) {
+ bw_user_entry_t tmp = ctx->entries[i];
+
+ ctx->entries[i] = ctx->entries[j];
+ ctx->entries[j] = tmp;
+ }
+ }
+ }
+}
+
+static hybbx_session_t *bw_first_unpaused(const bw_collect_ctx_t *ctx)
+{
+ unsigned i;
+
+ if (ctx == NULL) {
+ return NULL;
+ }
+
+ for (i = 0; i < ctx->count; i++) {
+ if (!hybbx_session_bandwidth_paused(ctx->entries[i].session)) {
+ return ctx->entries[i].session;
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_session_t *bw_first_paused(const bw_collect_ctx_t *ctx)
+{
+ unsigned i;
+
+ if (ctx == NULL) {
+ return NULL;
+ }
+
+ for (i = 0; i < ctx->count; i++) {
+ if (hybbx_session_bandwidth_paused(ctx->entries[i].session)) {
+ return ctx->entries[i].session;
+ }
+ }
+
+ return NULL;
+}
+
+unsigned hybbx_bandwidth_policy_user_count(hybbx_service_t *service)
+{
+ bw_collect_ctx_t ctx;
+
+ if (service == NULL) {
+ return 0;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ hybbx_service_visit_sessions(service, bw_collect_visitor, &ctx);
+ return ctx.count;
+}
+
+unsigned hybbx_bandwidth_policy_apply(hybbx_service_t *service,
+ hybbx_circuit_balance_action_t action)
+{
+ bw_collect_ctx_t ctx;
+ unsigned i;
+ unsigned affected = 0;
+
+ if (service == NULL) {
+ return 0;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ hybbx_service_visit_sessions(service, bw_collect_visitor, &ctx);
+ if (ctx.count == 0) {
+ return 0;
+ }
+
+ bw_sort_victims_first(&ctx);
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE: {
+ hybbx_session_t *target = bw_first_unpaused(&ctx);
+
+ if (target != NULL) {
+ hybbx_session_set_bandwidth_paused(target, 1);
+ hybbx_log_stats("[bandwidth] paused user %s (QoS: AX.25 before TCP)",
+ hybbx_session_display_name(target));
+ affected = 1;
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_BAL_BREAK: {
+ hybbx_session_t *target = bw_first_paused(&ctx);
+
+ if (target == NULL && ctx.count > 0) {
+ target = ctx.entries[0].session;
+ }
+
+ if (target != NULL) {
+ hybbx_log_stats("[bandwidth] disconnecting user %s (break)",
+ hybbx_session_display_name(target));
+ hybbx_session_disconnect_bandwidth(target);
+ affected = 1;
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ for (i = 0; i + 1 < ctx.count; i++) {
+ hybbx_log_stats("[bandwidth] disconnecting user %s (cancel)",
+ hybbx_session_display_name(ctx.entries[i].session));
+ hybbx_session_disconnect_bandwidth(ctx.entries[i].session);
+ affected++;
+ }
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ for (i = 0; i < ctx.count; i++) {
+ hybbx_session_set_bandwidth_paused(ctx.entries[i].session, 0);
+ }
+ affected = ctx.count;
+ break;
+ case HYBBX_CIRCUIT_BAL_NONE:
+ default:
+ break;
+ }
+
+ return affected;
+}
diff --git a/src/core/broadcast.c b/src/core/broadcast.c
new file mode 100644
index 0000000..6a7cf68
--- /dev/null
+++ b/src/core/broadcast.c
@@ -0,0 +1,982 @@
+#include "hybbx/broadcast.h"
+#include "hybbx/messages.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/plugin.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit.h"
+#include "hybbx/ax25.h"
+#include "hybbx/texts.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+void hybbx_ax25_frequency_table_clear(hybbx_ax25_frequency_table_t *table)
+{
+ if (table == NULL) {
+ return;
+ }
+
+ memset(table, 0, sizeof(*table));
+}
+
+int hybbx_ax25_frequency_match(double a_mhz, double b_mhz)
+{
+ double diff;
+
+ if (a_mhz <= 0.0 || b_mhz <= 0.0) {
+ return 0;
+ }
+
+ diff = a_mhz - b_mhz;
+ if (diff < 0.0) {
+ diff = -diff;
+ }
+
+ return diff < 0.001;
+}
+
+void hybbx_ax25_frequency_apply(hybbx_ax25_frequency_table_t *table,
+ const hybbx_config_t *config)
+{
+ unsigned count;
+ unsigned i;
+ unsigned loaded = 0;
+ char key[24];
+ const char *value;
+
+ if (table == NULL || config == NULL) {
+ return;
+ }
+
+ hybbx_ax25_frequency_table_clear(table);
+
+ count = hybbx_config_get_uint(config, "ax25", "frequency_count",
+ 0u, 0u, HYBBX_AX25_FREQUENCY_MAX);
+
+ for (i = 1; i <= HYBBX_AX25_FREQUENCY_MAX; i++) {
+ snprintf(key, sizeof(key), "frequency%u", i);
+ value = hybbx_config_get(config, "ax25", key, NULL);
+ if (value == NULL || value[0] == '\0') {
+ if (count > 0 && i > count) {
+ break;
+ }
+ continue;
+ }
+
+ {
+ double mhz = strtod(value, NULL);
+
+ if (mhz <= 0.0) {
+ continue;
+ }
+ if (loaded >= HYBBX_AX25_FREQUENCY_MAX) {
+ break;
+ }
+
+ table->mhz[loaded] = mhz;
+
+ snprintf(key, sizeof(key), "frequency%u_label", i);
+ value = hybbx_config_get(config, "ax25", key, NULL);
+ if (value == NULL || value[0] == '\0') {
+ snprintf(key, sizeof(key), "frequency%u_name", i);
+ value = hybbx_config_get(config, "ax25", key, NULL);
+ }
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(table->labels[loaded], value,
+ sizeof(table->labels[loaded]));
+ } else {
+ snprintf(table->labels[loaded], sizeof(table->labels[loaded]),
+ "%.3f MHz", mhz);
+ }
+ loaded++;
+ }
+
+ if (count > 0 && i >= count) {
+ break;
+ }
+ }
+
+ table->count = loaded;
+
+ if (table->count > 0) {
+ hybbx_log_info("[ax25] %u configured frequencies (%.3f … %.3f MHz)",
+ table->count, table->mhz[0], table->mhz[table->count - 1]);
+ } else {
+ hybbx_log_info("[ax25] no frequencies in INI (set frequency1 … in [ax25])");
+ }
+}
+
+void hybbx_broadcast_config_defaults(hybbx_broadcast_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ memset(cfg, 0, sizeof(*cfg));
+ cfg->enabled = 1;
+ cfg->ax25_enabled = 1;
+ cfg->ax25_auto = 1;
+ cfg->ax25_auto_interval_sec = HYBBX_BROADCAST_AX25_INTERVAL_MIN_SEC;
+ cfg->ax25_auto_stagger_sec = 0;
+ hybbx_strlcpy(cfg->ax25_mycall, "HYBBX", sizeof(cfg->ax25_mycall));
+ hybbx_strlcpy(cfg->ax25_dest, "QST", sizeof(cfg->ax25_dest));
+ hybbx_strlcpy(cfg->ax25_auto_message, HYBBX_BROADCAST_AUTO_MESSAGE_DEFAULT,
+ sizeof(cfg->ax25_auto_message));
+ hybbx_ax25_frequency_table_clear(&cfg->frequencies);
+}
+
+void hybbx_broadcast_config_apply(hybbx_broadcast_config_t *cfg,
+ const hybbx_config_t *config)
+{
+ const char *mycall;
+ const char *dest;
+ const char *auto_msg;
+ unsigned interval;
+
+ if (cfg == NULL || config == NULL) {
+ return;
+ }
+
+ hybbx_broadcast_config_defaults(cfg);
+
+ cfg->enabled = hybbx_config_get_bool(config, "broadcast", "enabled", 1);
+ cfg->ax25_enabled = hybbx_config_get_bool(config, "broadcast", "ax25", 1);
+ cfg->ax25_auto = hybbx_config_get_bool(config, "broadcast", "ax25_auto", 1);
+
+ interval = hybbx_config_get_uint(config, "broadcast", "ax25_auto_interval",
+ HYBBX_BROADCAST_AX25_INTERVAL_MIN_SEC,
+ HYBBX_BROADCAST_AX25_INTERVAL_MIN_SEC,
+ 86400u);
+ cfg->ax25_auto_interval_sec = interval;
+ cfg->ax25_auto_stagger_sec = 0;
+
+ mycall = hybbx_config_get(config, "broadcast", "ax25_mycall", NULL);
+ dest = hybbx_config_get(config, "broadcast", "ax25_dest", NULL);
+ auto_msg = hybbx_config_get(config, "broadcast", "ax25_auto_message", NULL);
+ if (mycall != NULL && mycall[0] != '\0') {
+ hybbx_ax25_address_t addr;
+
+ if (hybbx_ax25_address_parse(mycall, &addr) == HYBBX_OK) {
+ hybbx_strlcpy(cfg->ax25_mycall, mycall, sizeof(cfg->ax25_mycall));
+ } else {
+ hybbx_log_warn("[broadcast] ax25_mycall=%s invalid — using HYBBX",
+ mycall);
+ }
+ }
+ if (dest != NULL && dest[0] != '\0') {
+ hybbx_ax25_address_t addr;
+
+ if (hybbx_ax25_address_parse(dest, &addr) == HYBBX_OK) {
+ hybbx_strlcpy(cfg->ax25_dest, dest, sizeof(cfg->ax25_dest));
+ } else {
+ hybbx_log_warn("[broadcast] ax25_dest=%s invalid — using QST", dest);
+ hybbx_strlcpy(cfg->ax25_dest, "QST", sizeof(cfg->ax25_dest));
+ }
+ }
+ if (auto_msg != NULL && auto_msg[0] != '\0') {
+ size_t msg_len = strlen(auto_msg);
+
+ if (msg_len > HYBBX_BROADCAST_AX25_MESSAGE_MAX) {
+ hybbx_log_warn("[broadcast] ax25_auto_message truncated (%zu > %u)",
+ msg_len, (unsigned)HYBBX_BROADCAST_AX25_MESSAGE_MAX);
+ }
+ hybbx_strlcpy(cfg->ax25_auto_message, auto_msg,
+ sizeof(cfg->ax25_auto_message));
+ }
+
+ hybbx_ax25_frequency_apply(&cfg->frequencies, config);
+
+ hybbx_log_info("[broadcast] announce=%s ax25_auto=%s interval=%us "
+ "(min %us, band idle %us, link gap %us, per-link min %us)",
+ cfg->enabled ? "yes" : "no",
+ cfg->ax25_auto ? "yes" : "no",
+ cfg->ax25_auto_interval_sec,
+ (unsigned)HYBBX_BROADCAST_AX25_INTERVAL_MIN_SEC,
+ (unsigned)HYBBX_BROADCAST_AX25_BAND_IDLE_SEC,
+ (unsigned)HYBBX_BROADCAST_AX25_LINK_GAP_SEC,
+ (unsigned)HYBBX_BROADCAST_AX25_LINK_MIN_SEC);
+}
+
+static hybbx_result_t broadcast_build_path(const hybbx_broadcast_config_t *cfg,
+ hybbx_ax25_path_t *path)
+{
+ if (cfg == NULL || path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(path, 0, sizeof(*path));
+
+ if (cfg->ax25_dest[0] == '\0' || strcmp(cfg->ax25_dest, "*") == 0) {
+ hybbx_log_warn("[broadcast] ax25_dest=%s invalid — using QST",
+ cfg->ax25_dest[0] != '\0' ? cfg->ax25_dest : "(empty)");
+ if (hybbx_ax25_address_parse("QST", &path->dest) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+ } else if (hybbx_ax25_address_parse(cfg->ax25_dest, &path->dest) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+ if (hybbx_ax25_address_parse(cfg->ax25_mycall, &path->source) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+static time_t g_ax25_last_sent;
+static time_t g_ax25_link_last_sent[HYBBX_CIRCUIT_MAX_LINKS];
+static unsigned g_ax25_auto_tick;
+static unsigned g_ax25_defer_log_sec;
+
+typedef struct broadcast_ax25_seq {
+ int active;
+ hybbx_service_t *service;
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned link_count;
+ unsigned next_index;
+ char message[HYBBX_BROADCAST_AX25_MESSAGE_MAX + 1];
+ time_t resume_at;
+} broadcast_ax25_seq_t;
+
+static broadcast_ax25_seq_t g_ax25_seq;
+
+static double broadcast_link_target_mhz(const hybbx_broadcast_config_t *cfg,
+ const hybbx_circuit_broadcast_link_t *link,
+ unsigned link_index)
+{
+ (void)cfg;
+ (void)link_index;
+
+ if (link == NULL) {
+ return 0.0;
+ }
+
+ return link->frequency_mhz;
+}
+
+static void broadcast_ax25_log_deferred(double frequency_mhz, const char *reason)
+{
+ if (reason == NULL) {
+ return;
+ }
+
+ g_ax25_defer_log_sec++;
+ if (g_ax25_defer_log_sec < 60u) {
+ return;
+ }
+
+ g_ax25_defer_log_sec = 0;
+ if (frequency_mhz > 0.0) {
+ hybbx_log_stats("[broadcast] ax25 %.3f MHz deferred (%s)",
+ frequency_mhz, reason);
+ } else {
+ hybbx_log_stats("[broadcast] ax25 auto deferred (%s)", reason);
+ }
+}
+
+static void broadcast_ax25_seq_defer_until(time_t resume_at, double target_mhz,
+ const char *reason)
+{
+ time_t now = time(NULL);
+
+ broadcast_ax25_log_deferred(target_mhz, reason);
+ if (now != (time_t)-1 && (resume_at == (time_t)-1 || resume_at <= now)) {
+ resume_at = now + (time_t)HYBBX_BROADCAST_AX25_DEFER_RETRY_SEC;
+ }
+ g_ax25_seq.resume_at = resume_at;
+}
+
+static int broadcast_hub_link_band_idle(hybbx_circuit_hub_t *hub,
+ unsigned slot_index)
+{
+ if (hub == NULL) {
+ return 0;
+ }
+
+ return hybbx_circuit_hub_link_band_idle(hub, slot_index,
+ HYBBX_BROADCAST_AX25_BAND_IDLE_SEC);
+}
+
+static int broadcast_ax25_rate_ok(unsigned interval_sec)
+{
+ time_t now;
+ time_t elapsed;
+
+ if (g_ax25_last_sent == 0) {
+ return 1;
+ }
+
+ now = time(NULL);
+ if (now == (time_t)-1) {
+ return 0;
+ }
+
+ elapsed = now - g_ax25_last_sent;
+ if (elapsed < 0) {
+ return 1;
+ }
+
+ return (unsigned)elapsed >= interval_sec;
+}
+
+static int broadcast_ax25_link_rate_ok(unsigned slot_index,
+ unsigned interval_sec)
+{
+ time_t now;
+ time_t elapsed;
+
+ if (slot_index >= HYBBX_CIRCUIT_MAX_LINKS) {
+ return 0;
+ }
+
+ if (g_ax25_link_last_sent[slot_index] == 0) {
+ return 1;
+ }
+
+ now = time(NULL);
+ if (now == (time_t)-1) {
+ return 0;
+ }
+
+ elapsed = now - g_ax25_link_last_sent[slot_index];
+ if (elapsed < 0) {
+ return 1;
+ }
+
+ return (unsigned)elapsed >= interval_sec;
+}
+
+static void broadcast_ax25_mark_sent(void)
+{
+ time_t now = time(NULL);
+
+ if (now != (time_t)-1) {
+ g_ax25_last_sent = now;
+ }
+}
+
+static void broadcast_ax25_mark_link_sent(unsigned slot_index)
+{
+ time_t now = time(NULL);
+
+ if (slot_index >= HYBBX_CIRCUIT_MAX_LINKS || now == (time_t)-1) {
+ return;
+ }
+
+ g_ax25_link_last_sent[slot_index] = now;
+ g_ax25_last_sent = now;
+}
+
+static size_t broadcast_expand_service_token(char *out, size_t out_len,
+ const char *tmpl,
+ const char *service_name)
+{
+ size_t pos = 0;
+ size_t i = 0;
+
+ if (out == NULL || out_len == 0 || tmpl == NULL) {
+ return 0;
+ }
+
+ if (service_name == NULL || service_name[0] == '\0') {
+ service_name = HYBBX_DEFAULT_SERVICE_NAME;
+ }
+
+ out[0] = '\0';
+
+ while (tmpl[i] != '\0' && pos + 1 < out_len) {
+ if (strncmp(tmpl + i, HYBBX_BANNER_TOKEN_SERVICE,
+ strlen(HYBBX_BANNER_TOKEN_SERVICE)) == 0) {
+ size_t n = strlen(service_name);
+
+ if (pos + n >= out_len) {
+ n = out_len - pos - 1;
+ }
+ memcpy(out + pos, service_name, n);
+ pos += n;
+ i += strlen(HYBBX_BANNER_TOKEN_SERVICE);
+ continue;
+ }
+
+ out[pos++] = tmpl[i++];
+ }
+
+ out[pos] = '\0';
+ return pos;
+}
+
+static hybbx_result_t broadcast_ax25_send_slot(hybbx_service_t *service,
+ unsigned slot_index,
+ const char *message,
+ int enforce_rate_limit)
+{
+ const hybbx_broadcast_config_t *cfg;
+ hybbx_circuit_hub_t *hub;
+ hybbx_ax25_path_t path;
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t msg_len;
+ hybbx_result_t rc;
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned link_count;
+ unsigned i;
+ const char *link_id = "";
+ double target_mhz = 0.0;
+
+ if (service == NULL || message == NULL || message[0] == '\0' ||
+ slot_index >= HYBBX_CIRCUIT_MAX_LINKS) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cfg = hybbx_service_get_broadcast(service);
+ if (cfg == NULL || !cfg->enabled || !cfg->ax25_enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ hub = hybbx_service_circuit_hub(service);
+ if (hub == NULL || !hybbx_circuit_hub_running(hub)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ link_count = hybbx_circuit_hub_broadcast_links(hub, links,
+ HYBBX_CIRCUIT_MAX_LINKS);
+ for (i = 0; i < link_count; i++) {
+ if (links[i].slot_index == slot_index) {
+ link_id = links[i].link_id;
+ target_mhz = links[i].frequency_mhz;
+ break;
+ }
+ }
+ if (link_id[0] == '\0') {
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (!hybbx_circuit_hub_link_band_idle(hub, slot_index,
+ HYBBX_BROADCAST_AX25_BAND_IDLE_SEC)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (enforce_rate_limit &&
+ !broadcast_ax25_link_rate_ok(slot_index,
+ HYBBX_BROADCAST_AX25_LINK_MIN_SEC)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ msg_len = strlen(message);
+ if (msg_len > HYBBX_BROADCAST_AX25_MESSAGE_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_circuit_hub_link_broadcast_qos(hub)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = broadcast_build_path(cfg, &path);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ {
+ uint8_t ax25_frame[HYBBX_AX25_FRAME_MAX];
+ size_t ax25_len = hybbx_ax25_build_ui(&path,
+ (const uint8_t *)message, msg_len,
+ ax25_frame, sizeof(ax25_frame));
+
+ if (ax25_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_debug("[broadcast] ax25 path %s>%s payload=%zu slot=%u",
+ path.source.call, path.dest.call, msg_len, slot_index);
+
+ frame_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_AX25,
+ HYBBX_CIRCUIT_FLAG_TX,
+ ax25_frame, ax25_len,
+ frame, sizeof(frame));
+ }
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_circuit_hub_send_hbx_slot(hub, slot_index, frame, frame_len, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ broadcast_ax25_mark_link_sent(slot_index);
+
+ if (target_mhz > 0.0) {
+ hybbx_log_stats("[broadcast] ax25 link=%s %.3f MHz (slot %u): %s",
+ link_id, target_mhz, slot_index, message);
+ } else {
+ hybbx_log_stats("[broadcast] ax25 link=%s (slot %u): %s",
+ link_id, slot_index, message);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t broadcast_ax25_send(hybbx_service_t *service,
+ double frequency_mhz,
+ const char *message,
+ int enforce_rate_limit)
+{
+ const hybbx_broadcast_config_t *cfg;
+ hybbx_circuit_hub_t *hub;
+ hybbx_ax25_path_t path;
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t msg_len;
+ hybbx_result_t rc;
+ unsigned sent_links;
+ int per_link = 0;
+
+ if (service == NULL || message == NULL || message[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cfg = hybbx_service_get_broadcast(service);
+ if (cfg == NULL || !cfg->enabled || !cfg->ax25_enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ hub = hybbx_service_circuit_hub(service);
+ if (hub == NULL || !hybbx_circuit_hub_running(hub)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (frequency_mhz > 0.0) {
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned link_count;
+ unsigned i;
+ int any_rate_ok = 0;
+
+ link_count = hybbx_circuit_hub_broadcast_links(hub, links,
+ HYBBX_CIRCUIT_MAX_LINKS);
+ for (i = 0; i < link_count; i++) {
+ if (links[i].frequency_mhz <= 0.0 ||
+ !hybbx_ax25_frequency_match(frequency_mhz,
+ links[i].frequency_mhz)) {
+ continue;
+ }
+ if (!enforce_rate_limit ||
+ broadcast_ax25_link_rate_ok(links[i].slot_index,
+ HYBBX_BROADCAST_AX25_LINK_MIN_SEC)) {
+ any_rate_ok = 1;
+ break;
+ }
+ }
+ if (!any_rate_ok) {
+ return HYBBX_ERR_BUSY;
+ }
+ per_link = 1;
+ } else if (enforce_rate_limit &&
+ !broadcast_ax25_rate_ok(cfg->ax25_auto_interval_sec)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ msg_len = strlen(message);
+ if (msg_len > HYBBX_BROADCAST_AX25_MESSAGE_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_circuit_hub_link_broadcast_qos(hub)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = broadcast_build_path(cfg, &path);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ {
+ uint8_t ax25_frame[HYBBX_AX25_FRAME_MAX];
+ size_t ax25_len = hybbx_ax25_build_ui(&path,
+ (const uint8_t *)message, msg_len,
+ ax25_frame, sizeof(ax25_frame));
+
+ if (ax25_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_debug("[broadcast] ax25 path %s>%s payload=%zu",
+ path.source.call, path.dest.call, msg_len);
+
+ frame_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_AX25,
+ HYBBX_CIRCUIT_FLAG_TX,
+ ax25_frame, ax25_len,
+ frame, sizeof(frame));
+ }
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_circuit_hub_multicast_hbx(hub, frame, frame_len,
+ frequency_mhz, 1, &sent_links);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (per_link && frequency_mhz > 0.0) {
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned link_count;
+ unsigned i;
+
+ link_count = hybbx_circuit_hub_broadcast_links(hub, links,
+ HYBBX_CIRCUIT_MAX_LINKS);
+ for (i = 0; i < link_count; i++) {
+ if (links[i].frequency_mhz <= 0.0 ||
+ !hybbx_ax25_frequency_match(frequency_mhz,
+ links[i].frequency_mhz)) {
+ continue;
+ }
+ broadcast_ax25_mark_link_sent(links[i].slot_index);
+ }
+ } else {
+ broadcast_ax25_mark_sent();
+ }
+
+ if (frequency_mhz > 0.0) {
+ hybbx_log_stats("[broadcast] ax25 %.3f MHz (%u hub): %s",
+ frequency_mhz, sent_links, message);
+ } else {
+ hybbx_log_stats("[broadcast] ax25 all qualifying links (%u hub): %s",
+ sent_links, message);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_broadcast_ax25(hybbx_service_t *service,
+ double frequency_mhz,
+ const char *message)
+{
+ return broadcast_ax25_send(service, frequency_mhz, message, 1);
+}
+
+static int broadcast_ax25_seq_start(hybbx_service_t *service,
+ hybbx_circuit_hub_t *hub,
+ const hybbx_circuit_broadcast_link_t *links,
+ unsigned link_count,
+ const char *message)
+{
+ if (g_ax25_seq.active || service == NULL || hub == NULL ||
+ links == NULL || link_count == 0 || message == NULL ||
+ message[0] == '\0' || link_count > HYBBX_CIRCUIT_MAX_LINKS) {
+ return 0;
+ }
+
+ memset(&g_ax25_seq, 0, sizeof(g_ax25_seq));
+ g_ax25_seq.active = 1;
+ g_ax25_seq.service = service;
+ g_ax25_seq.hub = hub;
+ memcpy(g_ax25_seq.links, links,
+ link_count * sizeof(g_ax25_seq.links[0]));
+ g_ax25_seq.link_count = link_count;
+ hybbx_strlcpy(g_ax25_seq.message, message, sizeof(g_ax25_seq.message));
+ return 1;
+}
+
+static void broadcast_ax25_seq_advance(void)
+{
+ const hybbx_broadcast_config_t *cfg;
+ time_t now;
+ unsigned idx;
+ double target_mhz;
+ hybbx_result_t rc;
+ int advance = 0;
+
+ if (!g_ax25_seq.active) {
+ return;
+ }
+
+ if (g_ax25_seq.hub == NULL ||
+ !hybbx_circuit_hub_running(g_ax25_seq.hub)) {
+ g_ax25_seq.active = 0;
+ return;
+ }
+
+ now = time(NULL);
+ if (now == (time_t)-1) {
+ return;
+ }
+
+ if (g_ax25_seq.resume_at != 0 && now < g_ax25_seq.resume_at) {
+ return;
+ }
+
+ if (g_ax25_seq.next_index >= g_ax25_seq.link_count) {
+ g_ax25_seq.active = 0;
+ return;
+ }
+
+ cfg = hybbx_service_get_broadcast(g_ax25_seq.service);
+ if (cfg == NULL) {
+ g_ax25_seq.active = 0;
+ return;
+ }
+
+ idx = g_ax25_seq.next_index;
+ target_mhz = broadcast_link_target_mhz(cfg, &g_ax25_seq.links[idx], idx);
+
+ if (target_mhz <= 0.0) {
+ hybbx_log_stats("[broadcast] ax25 skip link=%s (no MHz)",
+ g_ax25_seq.links[idx].link_id);
+ advance = 1;
+ } else if (!broadcast_hub_link_band_idle(g_ax25_seq.hub,
+ g_ax25_seq.links[idx].slot_index)) {
+ time_t ready = hybbx_circuit_hub_link_band_ready_at(
+ g_ax25_seq.hub, g_ax25_seq.links[idx].slot_index,
+ HYBBX_BROADCAST_AX25_BAND_IDLE_SEC);
+
+ broadcast_ax25_seq_defer_until(ready, target_mhz, "band busy");
+ return;
+ } else if (!broadcast_ax25_link_rate_ok(g_ax25_seq.links[idx].slot_index,
+ HYBBX_BROADCAST_AX25_LINK_MIN_SEC)) {
+ broadcast_ax25_seq_defer_until(
+ now + (time_t)HYBBX_BROADCAST_AX25_DEFER_RETRY_SEC,
+ target_mhz, "link rate");
+ return;
+ } else {
+ rc = broadcast_ax25_send_slot(g_ax25_seq.service,
+ g_ax25_seq.links[idx].slot_index,
+ g_ax25_seq.message, 0);
+ if (rc == HYBBX_OK) {
+ g_ax25_defer_log_sec = 0;
+ advance = 1;
+ } else if (rc == HYBBX_ERR_BUSY) {
+ if (!hybbx_circuit_hub_running(g_ax25_seq.hub)) {
+ g_ax25_seq.active = 0;
+ return;
+ }
+ {
+ time_t ready = hybbx_circuit_hub_link_band_ready_at(
+ g_ax25_seq.hub, g_ax25_seq.links[idx].slot_index,
+ HYBBX_BROADCAST_AX25_BAND_IDLE_SEC);
+
+ broadcast_ax25_seq_defer_until(ready, target_mhz,
+ "band busy");
+ }
+ return;
+ } else if (rc == HYBBX_ERR_DENIED) {
+ broadcast_ax25_seq_defer_until(
+ now + (time_t)HYBBX_BROADCAST_AX25_DEFER_RETRY_SEC,
+ target_mhz, "no qualifying link");
+ return;
+ } else {
+ hybbx_log_warn("[broadcast] ax25 %.3f MHz failed (%d)",
+ target_mhz, (int)rc);
+ advance = 1;
+ }
+ }
+
+ if (!advance) {
+ return;
+ }
+
+ g_ax25_seq.next_index++;
+ if (g_ax25_seq.next_index < g_ax25_seq.link_count) {
+ g_ax25_seq.resume_at = now + (time_t)HYBBX_BROADCAST_AX25_LINK_GAP_SEC;
+ } else {
+ g_ax25_seq.active = 0;
+ g_ax25_seq.resume_at = 0;
+ }
+}
+
+void hybbx_broadcast_ax25_seq_cancel(void)
+{
+ g_ax25_seq.active = 0;
+ g_ax25_seq.resume_at = 0;
+}
+
+hybbx_result_t hybbx_broadcast_ax25_manual(hybbx_service_t *service)
+{
+ const hybbx_broadcast_config_t *cfg;
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ char message[HYBBX_BROADCAST_AX25_MESSAGE_MAX + 1];
+ unsigned link_count;
+
+ if (service == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (g_ax25_seq.active) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ cfg = hybbx_service_get_broadcast(service);
+ if (cfg == NULL || !cfg->enabled || !cfg->ax25_enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ hub = hybbx_service_circuit_hub(service);
+ if (hub == NULL || !hybbx_circuit_hub_running(hub)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ link_count = hybbx_circuit_hub_broadcast_links(hub, links,
+ HYBBX_CIRCUIT_MAX_LINKS);
+ if (link_count == 0) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ broadcast_expand_service_token(message, sizeof(message),
+ cfg->ax25_auto_message,
+ hybbx_service_get_name(service));
+ if (message[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_log_info("[broadcast] ax25 manual sequential (%u link(s)): %s",
+ link_count, message);
+
+ if (!broadcast_ax25_seq_start(service, hub, links, link_count, message)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ broadcast_ax25_seq_advance();
+ return HYBBX_OK;
+}
+
+void hybbx_broadcast_ax25_tick(hybbx_service_t *service)
+{
+ const hybbx_broadcast_config_t *cfg;
+ char message[HYBBX_BROADCAST_AX25_MESSAGE_MAX + 1];
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_broadcast_link_t links[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned link_count;
+ unsigned interval;
+
+ if (service == NULL) {
+ return;
+ }
+
+ if (g_ax25_seq.active) {
+ broadcast_ax25_seq_advance();
+ return;
+ }
+
+ cfg = hybbx_service_get_broadcast(service);
+ if (cfg == NULL || !cfg->enabled || !cfg->ax25_enabled || !cfg->ax25_auto) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(service);
+ if (hub == NULL || !hybbx_circuit_hub_running(hub)) {
+ return;
+ }
+
+ interval = cfg->ax25_auto_interval_sec;
+ if (interval == 0) {
+ return;
+ }
+
+ link_count = hybbx_circuit_hub_broadcast_links(hub, links,
+ HYBBX_CIRCUIT_MAX_LINKS);
+ if (link_count == 0) {
+ return;
+ }
+
+ g_ax25_auto_tick++;
+
+ broadcast_expand_service_token(message, sizeof(message),
+ cfg->ax25_auto_message,
+ hybbx_service_get_name(service));
+ if (message[0] == '\0') {
+ return;
+ }
+
+ if (g_ax25_auto_tick < interval) {
+ return;
+ }
+ if (!broadcast_ax25_rate_ok(interval)) {
+ return;
+ }
+
+ hybbx_log_info("[broadcast] ax25 auto sequential (%u link(s)): %s",
+ link_count, message);
+
+ if (!broadcast_ax25_seq_start(service, hub, links, link_count, message)) {
+ return;
+ }
+
+ g_ax25_auto_tick = 0;
+ broadcast_ax25_seq_advance();
+}
+
+typedef struct broadcast_announce_ctx {
+ hybbx_session_t *from;
+ const char *from_name;
+ const char *message;
+} broadcast_announce_ctx_t;
+
+static void broadcast_announce_visitor(hybbx_session_t *session, void *userdata)
+{
+ broadcast_announce_ctx_t *ctx = (broadcast_announce_ctx_t *)userdata;
+ char line[HYBBX_LINE_MAX];
+
+ if (session == NULL || ctx == NULL || ctx->message == NULL) {
+ return;
+ }
+
+ /*
+ * Local /broadcast is for online human users only. Circuit link adapter
+ * sessions (HBX bridges to TNCs) must not receive terminal fan-out —
+ * it floods low-bandwidth circuit queues and triggers load-balance
+ * pause/break on packet-radio links.
+ */
+ if (!hybbx_session_is_interactive_user(session)) {
+ return;
+ }
+ if (!hybbx_session_logged_in(session)) {
+ return;
+ }
+
+ if (hybbx_msg_format_sysop(line, sizeof(line),
+ ctx->from_name != NULL ? ctx->from_name : "Announce",
+ ctx->message) != HYBBX_OK) {
+ return;
+ }
+ if (ctx->from == NULL || session != ctx->from) {
+ (void)hybbx_session_command_gap(session);
+ }
+ hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_broadcast_announce(hybbx_service_t *service,
+ hybbx_session_t *from,
+ const char *message)
+{
+ broadcast_announce_ctx_t ctx;
+ const hybbx_broadcast_config_t *cfg;
+ size_t msg_len;
+
+ if (service == NULL || message == NULL || message[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cfg = hybbx_service_get_broadcast(service);
+ if (cfg == NULL || !cfg->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ msg_len = strlen(message);
+ if (msg_len > HYBBX_BROADCAST_MESSAGE_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.from = from;
+ ctx.from_name = (from != NULL) ? hybbx_session_display_name(from) : "Announce";
+ ctx.message = message;
+ hybbx_service_visit_sessions(service, broadcast_announce_visitor, &ctx);
+
+ hybbx_log_stats("[broadcast] announce to local Main (%zu bytes): %s",
+ msg_len, message);
+ return HYBBX_OK;
+}
diff --git a/src/core/chat.c b/src/core/chat.c
new file mode 100644
index 0000000..fdd4fa4
--- /dev/null
+++ b/src/core/chat.c
@@ -0,0 +1,463 @@
+#include "hybbx/chat.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/config.h"
+#include "hybbx/util.h"
+#include "hybbx/traffic.h"
+#include "hybbx/log.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+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 int channel_name_char_ok(char ch)
+{
+ return (ch >= 'a' && ch <= 'z') ||
+ (ch >= 'A' && ch <= 'Z') ||
+ (ch >= '0' && ch <= '9') ||
+ ch == ' ' || ch == '-' || ch == '_';
+}
+
+static int channel_name_valid(const char *name)
+{
+ size_t len;
+ size_t i;
+
+ if (name == NULL) {
+ return 0;
+ }
+
+ len = strlen(name);
+ if (len < 2 || len >= HYBBX_CHAT_CHANNEL_NAME_MAX) {
+ return 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ if (!channel_name_char_ok(name[i])) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+void hybbx_chat_config_defaults(hybbx_chat_config_t *chat)
+{
+ unsigned i;
+
+ if (chat == NULL) {
+ return;
+ }
+
+ chat->channel_count = HYBBX_DEFAULT_CHAT_CHANNELS;
+ chat->message_max = HYBBX_CHAT_MESSAGE_MAX;
+
+ for (i = 0; i < HYBBX_CHAT_CHANNEL_MAX; i++) {
+ snprintf(chat->names[i], sizeof(chat->names[i]), "Channel%u", i + 1);
+ }
+}
+
+void hybbx_chat_config_apply(hybbx_chat_config_t *chat,
+ const hybbx_config_t *config)
+{
+ unsigned count;
+ unsigned i;
+ char key[16];
+ const char *value;
+
+ if (chat == NULL || config == NULL) {
+ return;
+ }
+
+ hybbx_chat_config_defaults(chat);
+
+ count = hybbx_config_get_uint(config, "chat", "channels",
+ HYBBX_DEFAULT_CHAT_CHANNELS, 1u,
+ HYBBX_CHAT_CHANNEL_MAX);
+ chat->channel_count = count;
+
+ chat->message_max = hybbx_config_get_uint(config, "chat", "message_max",
+ HYBBX_CHAT_MESSAGE_MAX, 1u,
+ HYBBX_LINE_MAX - 1u);
+
+ for (i = 1; i <= chat->channel_count; i++) {
+ snprintf(key, sizeof(key), "channel%u", i);
+ value = hybbx_config_get(config, "chat", key, NULL);
+ if (value != NULL && value[0] != '\0' && channel_name_valid(value)) {
+ hybbx_strlcpy(chat->names[i - 1], value,
+ sizeof(chat->names[i - 1]));
+ }
+ }
+
+ hybbx_log_info("[chat] channels=%u message_max=%u",
+ chat->channel_count, chat->message_max);
+}
+
+const char *hybbx_chat_channel_name(const hybbx_chat_config_t *chat,
+ unsigned channel_index)
+{
+ if (chat == NULL || channel_index == 0 ||
+ channel_index > chat->channel_count) {
+ return NULL;
+ }
+
+ return chat->names[channel_index - 1];
+}
+
+hybbx_result_t hybbx_chat_resolve_channel(const hybbx_chat_config_t *chat,
+ const char *spec,
+ unsigned *out_index)
+{
+ char *end;
+ unsigned long parsed;
+ unsigned i;
+
+ if (chat == NULL || spec == NULL || out_index == NULL || spec[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ parsed = strtoul(spec, &end, 10);
+ if (end != spec && *end == '\0' && parsed >= 1 &&
+ parsed <= chat->channel_count) {
+ *out_index = (unsigned)parsed;
+ return HYBBX_OK;
+ }
+
+ for (i = 1; i <= chat->channel_count; i++) {
+ if (str_ieq(chat->names[i - 1], spec)) {
+ *out_index = i;
+ return HYBBX_OK;
+ }
+ }
+
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+void hybbx_chat_list_channels(hybbx_session_t *session,
+ const hybbx_chat_config_t *chat)
+{
+ unsigned i;
+ char line[HYBBX_CHAT_CHANNEL_NAME_MAX + 32];
+ unsigned current;
+
+ if (session == NULL || chat == NULL) {
+ return;
+ }
+
+ current = hybbx_session_chat_channel(session);
+
+ hybbx_session_write_line(session, "Channels:");
+ for (i = 1; i <= chat->channel_count; i++) {
+ snprintf(line, sizeof(line), " %u %s", i, chat->names[i - 1]);
+ hybbx_session_write_line(session, line);
+ }
+
+ if (current > 0) {
+ snprintf(line, sizeof(line), "Current: %u %s",
+ current, hybbx_chat_channel_name(chat, current));
+ hybbx_session_write_line(session, line);
+ } else {
+ hybbx_session_write_line(session, "Use /chat <n> or <name>.");
+ }
+}
+
+typedef struct chat_broadcast_ctx {
+ hybbx_session_t *from;
+ const char *message;
+ const char *from_user;
+ unsigned channel;
+} chat_broadcast_ctx_t;
+
+static void chat_broadcast_visitor(hybbx_session_t *session, void *userdata)
+{
+ chat_broadcast_ctx_t *ctx = (chat_broadcast_ctx_t *)userdata;
+ char line[HYBBX_USER_NAME_MAX + HYBBX_LINE_MAX + 16];
+
+ if (session == NULL || ctx == NULL || ctx->message == NULL) {
+ return;
+ }
+
+ if (hybbx_session_area(session) != HYBBX_AREA_CHAT) {
+ return;
+ }
+
+ if (hybbx_session_chat_channel(session) != ctx->channel) {
+ return;
+ }
+
+ if (session == ctx->from) {
+ snprintf(line, sizeof(line), "ME: %s", ctx->message);
+ } else {
+ snprintf(line, sizeof(line), "%s: %s", ctx->from_user, ctx->message);
+ }
+
+ hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_chat_post(hybbx_service_t *service,
+ hybbx_session_t *from,
+ const char *message)
+{
+ chat_broadcast_ctx_t ctx;
+ const hybbx_chat_config_t *chat;
+ unsigned channel;
+
+ if (service == NULL || from == NULL || message == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (message[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ chat = hybbx_service_get_chat(service);
+ if (chat == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (strlen(message) > chat->message_max) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_session_area(from) != HYBBX_AREA_CHAT) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ channel = hybbx_session_chat_channel(from);
+ if (channel == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.from = from;
+ ctx.message = message;
+ ctx.from_user = hybbx_session_display_name(from);
+ ctx.channel = channel;
+
+ hybbx_service_visit_sessions(service, chat_broadcast_visitor, &ctx);
+ return HYBBX_OK;
+}
+
+#define CHAT_SHOWALL_MAX 128u
+
+typedef struct chat_show_entry {
+ unsigned channel;
+ char label[HYBBX_USER_NAME_MAX + HYBBX_CHAT_CHANNEL_NAME_MAX + 4];
+} chat_show_entry_t;
+
+typedef struct chat_show_ctx {
+ hybbx_session_t *requester;
+ unsigned channel;
+ unsigned count;
+ chat_show_entry_t entries[CHAT_SHOWALL_MAX];
+ const hybbx_chat_config_t *chat;
+} chat_show_ctx_t;
+
+static void chat_show_channel_visitor(hybbx_session_t *session, void *userdata)
+{
+ chat_show_ctx_t *ctx = (chat_show_ctx_t *)userdata;
+
+ if (session == NULL || ctx == NULL || session == ctx->requester) {
+ return;
+ }
+
+ if (hybbx_session_area(session) != HYBBX_AREA_CHAT) {
+ return;
+ }
+
+ if (hybbx_session_chat_channel(session) != ctx->channel) {
+ return;
+ }
+
+ if (hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ if (hybbx_session_hidden_from_who(session)) {
+ return;
+ }
+
+ hybbx_session_write_line(ctx->requester, hybbx_session_display_name(session));
+}
+
+void hybbx_chat_show_channel(hybbx_service_t *service, hybbx_session_t *session)
+{
+ chat_show_ctx_t ctx;
+ unsigned channel;
+
+ if (service == NULL || session == NULL) {
+ return;
+ }
+
+ if (hybbx_session_area(session) != HYBBX_AREA_CHAT) {
+ hybbx_session_write_line(session, "Not in a chat channel.");
+ return;
+ }
+
+ channel = hybbx_session_chat_channel(session);
+ if (channel == 0) {
+ hybbx_session_write_line(session, "Not in a chat channel.");
+ return;
+ }
+
+ ctx.requester = session;
+ ctx.channel = channel;
+ ctx.count = 0;
+ ctx.chat = NULL;
+
+ hybbx_service_visit_sessions(service, chat_show_channel_visitor, &ctx);
+}
+
+static void chat_show_all_collect(hybbx_session_t *session, void *userdata)
+{
+ chat_show_ctx_t *ctx = (chat_show_ctx_t *)userdata;
+ unsigned channel;
+ const char *ch_name;
+ chat_show_entry_t *entry;
+
+ if (session == NULL || ctx == NULL || ctx->chat == NULL) {
+ return;
+ }
+
+ if (hybbx_session_area(session) != HYBBX_AREA_CHAT) {
+ return;
+ }
+
+ if (hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ if (hybbx_session_hidden_from_who(session)) {
+ return;
+ }
+
+ channel = hybbx_session_chat_channel(session);
+ if (channel == 0 || channel > ctx->chat->channel_count) {
+ return;
+ }
+
+ if (ctx->count >= CHAT_SHOWALL_MAX) {
+ return;
+ }
+
+ ch_name = hybbx_chat_channel_name(ctx->chat, channel);
+ if (ch_name == NULL) {
+ return;
+ }
+
+ entry = &ctx->entries[ctx->count++];
+ entry->channel = channel;
+ snprintf(entry->label, sizeof(entry->label), "%s@%s",
+ hybbx_session_display_name(session), ch_name);
+}
+
+static int chat_show_entry_cmp(const void *a, const void *b)
+{
+ const chat_show_entry_t *ea = (const chat_show_entry_t *)a;
+ const chat_show_entry_t *eb = (const chat_show_entry_t *)b;
+
+ if (ea->channel != eb->channel) {
+ return (ea->channel < eb->channel) ? -1 : 1;
+ }
+
+ return strcmp(ea->label, eb->label);
+}
+
+static void chat_showall_flush(hybbx_session_t *session, char *line, size_t *pos)
+{
+ if (session == NULL || line == NULL || pos == NULL || *pos == 0) {
+ return;
+ }
+
+ line[*pos] = '\0';
+ hybbx_session_write_line(session, line);
+ line[0] = '\0';
+ *pos = 0;
+}
+
+static void chat_showall_append(hybbx_session_t *session, char *line,
+ size_t line_size, size_t *pos,
+ const char *entry)
+{
+ size_t entry_len;
+ size_t need;
+
+ if (session == NULL || line == NULL || pos == NULL || entry == NULL) {
+ return;
+ }
+
+ entry_len = strlen(entry);
+ need = (*pos > 0) ? (*pos + 1 + entry_len) : entry_len;
+
+ if (*pos > 0 && need > HYBBX_LINE_WIDTH) {
+ chat_showall_flush(session, line, pos);
+ need = entry_len;
+ }
+
+ if (entry_len >= line_size) {
+ return;
+ }
+
+ if (*pos > 0) {
+ line[(*pos)++] = ' ';
+ }
+
+ memcpy(line + *pos, entry, entry_len);
+ *pos += entry_len;
+ line[*pos] = '\0';
+}
+
+void hybbx_chat_show_all(hybbx_service_t *service, hybbx_session_t *session)
+{
+ chat_show_ctx_t ctx;
+ char line[HYBBX_LINE_WIDTH + 1];
+ size_t pos = 0;
+ unsigned i;
+
+ if (service == NULL || session == NULL) {
+ return;
+ }
+
+ ctx.requester = session;
+ ctx.channel = 0;
+ ctx.count = 0;
+ ctx.chat = hybbx_service_get_chat(service);
+ if (ctx.chat == NULL) {
+ return;
+ }
+
+ hybbx_service_visit_sessions(service, chat_show_all_collect, &ctx);
+
+ if (ctx.count == 0) {
+ return;
+ }
+
+ qsort(ctx.entries, ctx.count, sizeof(ctx.entries[0]), chat_show_entry_cmp);
+
+ line[0] = '\0';
+ for (i = 0; i < ctx.count; i++) {
+ chat_showall_append(session, line, sizeof(line), &pos, ctx.entries[i].label);
+ }
+
+ chat_showall_flush(session, line, &pos);
+}
diff --git a/src/core/circuit.c b/src/core/circuit.c
new file mode 100644
index 0000000..04ac264
--- /dev/null
+++ b/src/core/circuit.c
@@ -0,0 +1,337 @@
+#include "hybbx/circuit.h"
+
+#include <string.h>
+
+static uint32_t read_be32(const uint8_t *p)
+{
+ return ((uint32_t)p[0] << 24) | ((uint32_t)p[1] << 16) |
+ ((uint32_t)p[2] << 8) | (uint32_t)p[3];
+}
+
+static uint16_t read_be16(const uint8_t *p)
+{
+ return (uint16_t)(((uint16_t)p[0] << 8) | (uint16_t)p[1]);
+}
+
+static void write_be32(uint8_t *p, uint32_t v)
+{
+ p[0] = (uint8_t)((v >> 24) & 0xFFu);
+ p[1] = (uint8_t)((v >> 16) & 0xFFu);
+ p[2] = (uint8_t)((v >> 8) & 0xFFu);
+ p[3] = (uint8_t)(v & 0xFFu);
+}
+
+static void write_be16(uint8_t *p, uint16_t v)
+{
+ p[0] = (uint8_t)((v >> 8) & 0xFFu);
+ p[1] = (uint8_t)(v & 0xFFu);
+}
+
+const char *hybbx_circuit_proto_name(hybbx_circuit_proto_t proto)
+{
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_AX25:
+ return "ax25";
+ case HYBBX_CIRCUIT_PROTO_AX25_UI:
+ return "ax25_ui";
+ case HYBBX_CIRCUIT_PROTO_LINK_AUTH:
+ return "link_auth";
+ case HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK:
+ return "link_auth_ack";
+ case HYBBX_CIRCUIT_PROTO_FLOW_CTRL:
+ return "flow_ctrl";
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ return "terminal";
+ case HYBBX_CIRCUIT_PROTO_PROXY_MAIL:
+ return "proxy_mail";
+ case HYBBX_CIRCUIT_PROTO_PROXY_CHAT:
+ return "proxy_chat";
+ case HYBBX_CIRCUIT_PROTO_RESERVED_APRS:
+ return "aprs";
+ case HYBBX_CIRCUIT_PROTO_RESERVED_NETROM:
+ return "netrom";
+ default:
+ return "unknown";
+ }
+}
+
+void hybbx_circuit_decoder_init(hybbx_circuit_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void circuit_deliver(hybbx_circuit_decoder_t *dec,
+ hybbx_circuit_frame_cb cb, void *userdata)
+{
+ const uint8_t *payload;
+ size_t payload_len;
+
+ if (dec->len < HYBBX_CIRCUIT_HEADER_SIZE) {
+ dec->len = 0;
+ dec->need = 0;
+ dec->have_header = 0;
+ return;
+ }
+
+ payload = dec->buf + HYBBX_CIRCUIT_HEADER_SIZE;
+ payload_len = dec->len - HYBBX_CIRCUIT_HEADER_SIZE;
+
+ if (cb != NULL) {
+ cb(dec->proto, dec->flags, payload, payload_len, userdata);
+ }
+
+ dec->len = 0;
+ dec->need = 0;
+ dec->have_header = 0;
+}
+
+static int circuit_parse_header(hybbx_circuit_decoder_t *dec)
+{
+ const uint8_t *h = dec->buf;
+
+ if (h[0] != HYBBX_CIRCUIT_MAGIC_0 || h[1] != HYBBX_CIRCUIT_MAGIC_1 ||
+ h[2] != HYBBX_CIRCUIT_MAGIC_2 || h[3] != HYBBX_CIRCUIT_VERSION) {
+ return 0;
+ }
+
+ dec->proto = (hybbx_circuit_proto_t)h[4];
+ dec->flags = read_be16(h + 5);
+ dec->need = (size_t)read_be32(h + 7);
+ if (dec->need > HYBBX_CIRCUIT_MAX_PAYLOAD) {
+ return 0;
+ }
+
+ return 1;
+}
+
+void hybbx_circuit_decoder_feed(hybbx_circuit_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_circuit_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ if (dec->len >= sizeof(dec->buf)) {
+ hybbx_circuit_decoder_init(dec);
+ }
+
+ dec->buf[dec->len++] = data[i];
+
+ if (!dec->have_header) {
+ if (dec->len < HYBBX_CIRCUIT_HEADER_SIZE) {
+ continue;
+ }
+ if (!circuit_parse_header(dec)) {
+ memmove(dec->buf, dec->buf + 1, dec->len - 1);
+ dec->len--;
+ continue;
+ }
+ dec->have_header = 1;
+ }
+
+ if (dec->len < HYBBX_CIRCUIT_HEADER_SIZE + dec->need) {
+ continue;
+ }
+
+ circuit_deliver(dec, cb, userdata);
+ }
+}
+
+size_t hybbx_circuit_encode(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ if (out == NULL || out_cap < HYBBX_CIRCUIT_HEADER_SIZE) {
+ return 0;
+ }
+
+ if (payload_len > HYBBX_CIRCUIT_MAX_PAYLOAD) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload == NULL) {
+ return 0;
+ }
+
+ if (HYBBX_CIRCUIT_HEADER_SIZE + payload_len > out_cap) {
+ return 0;
+ }
+
+ out[0] = HYBBX_CIRCUIT_MAGIC_0;
+ out[1] = HYBBX_CIRCUIT_MAGIC_1;
+ out[2] = HYBBX_CIRCUIT_MAGIC_2;
+ out[3] = HYBBX_CIRCUIT_VERSION;
+ out[4] = (uint8_t)proto;
+ write_be16(out + 5, flags);
+
+ write_be32(out + 7, (uint32_t)payload_len);
+ if (payload_len > 0) {
+ memcpy(out + HYBBX_CIRCUIT_HEADER_SIZE, payload, payload_len);
+ }
+
+ return HYBBX_CIRCUIT_HEADER_SIZE + payload_len;
+}
+
+size_t hybbx_circuit_encode_ax25(const uint8_t *frame, size_t frame_len,
+ uint16_t flags,
+ uint8_t *out, size_t out_cap)
+{
+ return hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_AX25,
+ (uint16_t)(flags | HYBBX_CIRCUIT_FLAG_RX),
+ frame, frame_len, out, out_cap);
+}
+
+static size_t pack_path_payload(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ unsigned i;
+
+ if (path == NULL || out == NULL) {
+ return 0;
+ }
+
+ if (1 + payload_len > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = (uint8_t)path->digi_count;
+ if (pos + HYBBX_AX25_ADDR_ENCODED * (2 + path->digi_count) > out_cap) {
+ 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;
+
+ if (payload_len > 0) {
+ if (pos + payload_len > out_cap) {
+ return 0;
+ }
+ memcpy(out + pos, payload, payload_len);
+ pos += payload_len;
+ }
+
+ return pos;
+}
+
+size_t hybbx_circuit_encode_ax25_ui(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint16_t flags,
+ uint8_t *out, size_t out_cap)
+{
+ uint8_t body[HYBBX_CIRCUIT_MAX_PAYLOAD];
+ size_t body_len;
+
+ body_len = pack_path_payload(path, payload, payload_len, body, sizeof(body));
+ if (body_len == 0 && payload_len > 0) {
+ return 0;
+ }
+
+ return hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_AX25_UI,
+ (uint16_t)(flags | HYBBX_CIRCUIT_FLAG_PATH),
+ body, body_len, out, out_cap);
+}
+
+size_t hybbx_circuit_encode_terminal(const char *data, size_t len,
+ uint8_t *out, size_t out_cap)
+{
+ return hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_TX,
+ (const uint8_t *)data, len, out, out_cap);
+}
+
+size_t hybbx_circuit_encode_link_msg(hybbx_circuit_proto_t proto,
+ const char *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ if (proto != HYBBX_CIRCUIT_PROTO_LINK_AUTH &&
+ proto != HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK) {
+ return 0;
+ }
+
+ return hybbx_circuit_encode(proto, HYBBX_CIRCUIT_FLAG_NONE,
+ (const uint8_t *)payload, payload_len,
+ out, out_cap);
+}
+
+static void decode_address_field(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';
+ out->ssid = (unsigned)((in[6] >> 1) & 0x0Fu);
+}
+
+size_t hybbx_circuit_unpack_ax25_ui(const uint8_t *payload, size_t payload_len,
+ hybbx_ax25_path_t *path,
+ uint8_t *ui, size_t ui_cap)
+{
+ size_t pos = 0;
+ unsigned digi_count;
+ unsigned i;
+
+ if (payload == NULL || payload_len < 1) {
+ return 0;
+ }
+
+ if (path != NULL) {
+ memset(path, 0, sizeof(*path));
+ }
+
+ digi_count = payload[0];
+ if (digi_count > HYBBX_AX25_MAX_DIGI) {
+ return 0;
+ }
+
+ pos = 1;
+ if (pos + HYBBX_AX25_ADDR_ENCODED * (size_t)(2 + digi_count) > payload_len) {
+ return 0;
+ }
+
+ if (path != NULL) {
+ decode_address_field(payload + pos, &path->dest);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ for (i = 0; i < digi_count; i++) {
+ decode_address_field(payload + pos, &path->digi[i]);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ }
+ path->digi_count = digi_count;
+ decode_address_field(payload + pos, &path->source);
+ } else {
+ pos += HYBBX_AX25_ADDR_ENCODED * (size_t)(2 + digi_count);
+ }
+ pos += HYBBX_AX25_ADDR_ENCODED;
+
+ {
+ size_t ui_len = payload_len - pos;
+
+ if (ui_len > ui_cap) {
+ return 0;
+ }
+
+ if (ui_len > 0 && ui != NULL) {
+ memcpy(ui, payload + pos, ui_len);
+ }
+
+ return ui_len;
+ }
+}
diff --git a/src/core/circuit_balance.c b/src/core/circuit_balance.c
new file mode 100644
index 0000000..5571e9d
--- /dev/null
+++ b/src/core/circuit_balance.c
@@ -0,0 +1,545 @@
+#include "hybbx/circuit_balance.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct balance_frame {
+ uint8_t data[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t len;
+} balance_frame_t;
+
+struct hybbx_circuit_balance {
+ hybbx_circuit_balance_config_t cfg;
+ hybbx_circuit_link_profile_t profile;
+ balance_frame_t queue[HYBBX_CIRCUIT_BALANCE_QUEUE_MAX];
+ size_t q_head;
+ size_t q_count;
+ size_t queue_bytes;
+ hybbx_circuit_balance_action_t action;
+ hybbx_circuit_balance_action_t last_sent_action;
+ double drain_accum;
+ time_t oldest_enqueue;
+ int link_low_bw;
+};
+
+static int str_ieq_local(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = *a;
+ char cb = *b;
+
+ if (ca >= 'A' && ca <= 'Z') {
+ ca = (char)(ca - 'A' + 'a');
+ }
+ if (cb >= 'A' && cb <= 'Z') {
+ cb = (char)(cb - 'A' + 'a');
+ }
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv_line(const char *payload, size_t len,
+ const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = payload;
+ const char *end = payload + len;
+ size_t key_len = strlen(key);
+
+ while (cursor < end) {
+ const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor));
+ const char *line_stop = line_end != NULL ? line_end : end;
+ const char *eq = memchr(cursor, '=', (size_t)(line_stop - cursor));
+
+ if (eq != NULL && (size_t)(eq - cursor) == key_len &&
+ memcmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(line_stop - value);
+
+ while (value_len > 0 &&
+ (value[value_len - 1] == '\r' ||
+ value[value_len - 1] == '\n' ||
+ value[value_len - 1] == ' ')) {
+ value_len--;
+ }
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (line_end == NULL) {
+ break;
+ }
+ cursor = line_end + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_circuit_balance_config_defaults(hybbx_circuit_balance_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ memset(cfg, 0, sizeof(*cfg));
+ cfg->enabled = 1;
+ cfg->lag_sec = 5u;
+ cfg->queue_pause = 4096u;
+ cfg->queue_break = 16384u;
+ cfg->queue_cancel = 65536u;
+}
+
+void hybbx_circuit_link_profile_from_auth(const hybbx_link_auth_t *auth,
+ hybbx_circuit_link_profile_t *out)
+{
+ unsigned baud;
+
+ if (out == NULL) {
+ return;
+ }
+
+ memset(out, 0, sizeof(*out));
+ out->bandwidth = HYBBX_CIRCUIT_BW_HIGH;
+ out->duplex = HYBBX_CIRCUIT_DUPLEX_FULL;
+ out->baud = HYBBX_CIRCUIT_BALANCE_DEFAULT_BAUD;
+
+ if (auth == NULL) {
+ return;
+ }
+
+ baud = auth->baud;
+ out->duplex = (hybbx_circuit_duplex_t)auth->duplex;
+
+ if (auth->bandwidth[0] != '\0') {
+ if (str_ieq_local(auth->bandwidth, "low")) {
+ out->bandwidth = HYBBX_CIRCUIT_BW_LOW;
+ } else if (str_ieq_local(auth->bandwidth, "high")) {
+ out->bandwidth = HYBBX_CIRCUIT_BW_HIGH;
+ }
+ } else if (baud > 0 && baud <= HYBBX_CIRCUIT_BALANCE_LOW_BAUD_THRESHOLD) {
+ out->bandwidth = HYBBX_CIRCUIT_BW_LOW;
+ }
+
+ if (baud > 0) {
+ out->baud = baud;
+ } else if (out->bandwidth == HYBBX_CIRCUIT_BW_LOW) {
+ out->baud = 1200u;
+ }
+
+ if (out->duplex == HYBBX_CIRCUIT_DUPLEX_UNSET) {
+ out->duplex = out->bandwidth == HYBBX_CIRCUIT_BW_LOW ?
+ HYBBX_CIRCUIT_DUPLEX_HALF : HYBBX_CIRCUIT_DUPLEX_FULL;
+ }
+
+ if (auth->frequency_mhz[0] != '\0') {
+ out->frequency_mhz = strtod(auth->frequency_mhz, NULL);
+ }
+}
+
+const char *hybbx_circuit_balance_action_name(hybbx_circuit_balance_action_t action)
+{
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ return "pause";
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ return "break";
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ return "cancel";
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ return "resume";
+ case HYBBX_CIRCUIT_BAL_NONE:
+ default:
+ return "none";
+ }
+}
+
+hybbx_result_t hybbx_circuit_flow_ctrl_parse(const char *payload, size_t len,
+ hybbx_circuit_balance_action_t *action_out,
+ char *reason_out, size_t reason_cap)
+{
+ char scratch[128];
+ const char *action_str;
+
+ if (payload == NULL || action_out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ action_str = find_kv_line(payload, len, "action", scratch, sizeof(scratch));
+ if (action_str == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (str_ieq_local(action_str, "pause")) {
+ *action_out = HYBBX_CIRCUIT_BAL_PAUSE;
+ } else if (str_ieq_local(action_str, "break")) {
+ *action_out = HYBBX_CIRCUIT_BAL_BREAK;
+ } else if (str_ieq_local(action_str, "cancel")) {
+ *action_out = HYBBX_CIRCUIT_BAL_CANCEL;
+ } else if (str_ieq_local(action_str, "resume")) {
+ *action_out = HYBBX_CIRCUIT_BAL_RESUME;
+ } else {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (reason_out != NULL && reason_cap > 0) {
+ reason_out[0] = '\0';
+ if (find_kv_line(payload, len, "reason", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(reason_out, scratch, reason_cap);
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+size_t hybbx_circuit_flow_ctrl_format(hybbx_circuit_balance_action_t action,
+ const char *reason,
+ char *out, size_t out_cap)
+{
+ int n;
+ const char *reason_str = reason != NULL && reason[0] != '\0' ? reason : "-";
+
+ if (out == NULL || out_cap == 0) {
+ return 0;
+ }
+
+ n = snprintf(out, out_cap, "action=%s\nreason=%s\n",
+ hybbx_circuit_balance_action_name(action), reason_str);
+ if (n < 0 || (size_t)n >= out_cap) {
+ return 0;
+ }
+
+ return (size_t)n;
+}
+
+hybbx_circuit_balance_t *hybbx_circuit_balance_create(
+ const hybbx_circuit_balance_config_t *cfg)
+{
+ hybbx_circuit_balance_t *bal;
+
+ bal = calloc(1, sizeof(*bal));
+ if (bal == NULL) {
+ return NULL;
+ }
+
+ if (cfg != NULL) {
+ bal->cfg = *cfg;
+ } else {
+ hybbx_circuit_balance_config_defaults(&bal->cfg);
+ }
+
+ hybbx_circuit_link_profile_from_auth(NULL, &bal->profile);
+ bal->link_low_bw = 0;
+ return bal;
+}
+
+void hybbx_circuit_balance_destroy(hybbx_circuit_balance_t *bal)
+{
+ free(bal);
+}
+
+void hybbx_circuit_balance_set_profile(hybbx_circuit_balance_t *bal,
+ const hybbx_circuit_link_profile_t *profile)
+{
+ if (bal == NULL || profile == NULL) {
+ return;
+ }
+
+ bal->profile = *profile;
+ bal->link_low_bw = profile->bandwidth == HYBBX_CIRCUIT_BW_LOW;
+}
+
+hybbx_circuit_balance_action_t hybbx_circuit_balance_action(
+ const hybbx_circuit_balance_t *bal)
+{
+ if (bal == NULL) {
+ return HYBBX_CIRCUIT_BAL_NONE;
+ }
+
+ return bal->action;
+}
+
+size_t hybbx_circuit_balance_queued_bytes(const hybbx_circuit_balance_t *bal)
+{
+ if (bal == NULL) {
+ return 0;
+ }
+
+ return bal->queue_bytes;
+}
+
+void hybbx_circuit_balance_spared_cancel(hybbx_circuit_balance_t *bal)
+{
+ if (bal == NULL) {
+ return;
+ }
+
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ bal->action = HYBBX_CIRCUIT_BAL_PAUSE;
+ bal->last_sent_action = HYBBX_CIRCUIT_BAL_NONE;
+ }
+}
+
+static unsigned balance_effective_baud(const hybbx_circuit_balance_t *bal)
+{
+ unsigned baud;
+
+ if (bal == NULL) {
+ return HYBBX_CIRCUIT_BALANCE_DEFAULT_BAUD;
+ }
+
+ baud = bal->profile.baud;
+ if (baud == 0) {
+ baud = HYBBX_CIRCUIT_BALANCE_DEFAULT_BAUD;
+ }
+ return baud;
+}
+
+static unsigned balance_estimated_lag_sec(const hybbx_circuit_balance_t *bal)
+{
+ unsigned baud;
+ unsigned lag;
+
+ if (bal == NULL || bal->queue_bytes == 0) {
+ return 0;
+ }
+
+ baud = balance_effective_baud(bal);
+ if (baud == 0) {
+ return 0;
+ }
+
+ lag = (unsigned)((bal->queue_bytes * 8u) / baud);
+ if (bal->oldest_enqueue > 0) {
+ time_t now = time(NULL);
+ if (now > bal->oldest_enqueue) {
+ unsigned wall = (unsigned)(now - bal->oldest_enqueue);
+ if (wall > lag) {
+ lag = wall;
+ }
+ }
+ }
+ return lag;
+}
+
+static void balance_send_flow(hybbx_circuit_balance_t *bal,
+ hybbx_circuit_balance_action_t action,
+ const char *reason,
+ hybbx_circuit_balance_flow_fn flow_fn,
+ void *flow_ctx)
+{
+ if (bal == NULL || flow_fn == NULL) {
+ return;
+ }
+ if (action == bal->last_sent_action) {
+ return;
+ }
+
+ flow_fn(flow_ctx, action, reason);
+ bal->last_sent_action = action;
+}
+
+static void balance_clear_queue(hybbx_circuit_balance_t *bal)
+{
+ if (bal == NULL) {
+ return;
+ }
+
+ bal->q_head = 0;
+ bal->q_count = 0;
+ bal->queue_bytes = 0;
+ bal->oldest_enqueue = 0;
+ bal->drain_accum = 0.0;
+}
+
+static void balance_apply_escalation(hybbx_circuit_balance_t *bal,
+ hybbx_circuit_balance_flow_fn flow_fn,
+ void *flow_ctx)
+{
+ unsigned lag;
+ const hybbx_circuit_balance_config_t *cfg;
+
+ if (bal == NULL || !bal->cfg.enabled) {
+ return;
+ }
+
+ cfg = &bal->cfg;
+ lag = balance_estimated_lag_sec(bal);
+
+ if (bal->link_low_bw &&
+ (bal->queue_bytes >= cfg->queue_cancel ||
+ lag >= cfg->lag_sec * 2u)) {
+ if (bal->action != HYBBX_CIRCUIT_BAL_CANCEL) {
+ bal->action = HYBBX_CIRCUIT_BAL_CANCEL;
+ balance_clear_queue(bal);
+ balance_send_flow(bal, HYBBX_CIRCUIT_BAL_CANCEL, "queue_overload",
+ flow_fn, flow_ctx);
+ }
+ return;
+ }
+
+ if (bal->queue_bytes >= cfg->queue_break || lag >= cfg->lag_sec) {
+ if (bal->action != HYBBX_CIRCUIT_BAL_BREAK &&
+ bal->action != HYBBX_CIRCUIT_BAL_CANCEL) {
+ bal->action = HYBBX_CIRCUIT_BAL_BREAK;
+ balance_clear_queue(bal);
+ balance_send_flow(bal, HYBBX_CIRCUIT_BAL_BREAK, "queue_lag",
+ flow_fn, flow_ctx);
+ bal->action = HYBBX_CIRCUIT_BAL_PAUSE;
+ balance_send_flow(bal, HYBBX_CIRCUIT_BAL_PAUSE, "stabilize",
+ flow_fn, flow_ctx);
+ }
+ return;
+ }
+
+ if (bal->queue_bytes >= cfg->queue_pause ||
+ lag >= (cfg->lag_sec > 0 ? cfg->lag_sec / 2u : 0u)) {
+ if (bal->action == HYBBX_CIRCUIT_BAL_NONE) {
+ bal->action = HYBBX_CIRCUIT_BAL_PAUSE;
+ balance_send_flow(bal, HYBBX_CIRCUIT_BAL_PAUSE, "queue_pressure",
+ flow_fn, flow_ctx);
+ }
+ return;
+ }
+
+ if (bal->action == HYBBX_CIRCUIT_BAL_PAUSE &&
+ bal->queue_bytes < cfg->queue_pause / 2u &&
+ lag < (cfg->lag_sec > 0 ? cfg->lag_sec / 2u : 0u)) {
+ bal->action = HYBBX_CIRCUIT_BAL_NONE;
+ balance_send_flow(bal, HYBBX_CIRCUIT_BAL_RESUME, "stabilized",
+ flow_fn, flow_ctx);
+ }
+}
+
+hybbx_result_t hybbx_circuit_balance_submit(hybbx_circuit_balance_t *bal,
+ const uint8_t *frame, size_t len,
+ hybbx_circuit_balance_send_fn send_fn,
+ void *send_ctx,
+ hybbx_circuit_balance_flow_fn flow_fn,
+ void *flow_ctx)
+{
+ size_t slot;
+ balance_frame_t *entry;
+
+ if (bal == NULL || frame == NULL || len == 0 || send_fn == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!bal->cfg.enabled ||
+ bal->profile.bandwidth == HYBBX_CIRCUIT_BW_HIGH) {
+ return send_fn(send_ctx, frame, len);
+ }
+
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (bal->q_count >= HYBBX_CIRCUIT_BALANCE_QUEUE_MAX) {
+ balance_apply_escalation(bal, flow_fn, flow_ctx);
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return HYBBX_ERR_BUSY;
+ }
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (len > sizeof(entry->data)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ slot = (bal->q_head + bal->q_count) % HYBBX_CIRCUIT_BALANCE_QUEUE_MAX;
+ entry = &bal->queue[slot];
+ memcpy(entry->data, frame, len);
+ entry->len = len;
+ bal->q_count++;
+ bal->queue_bytes += len;
+ if (bal->oldest_enqueue == 0) {
+ bal->oldest_enqueue = time(NULL);
+ }
+
+ balance_apply_escalation(bal, flow_fn, flow_ctx);
+ return HYBBX_OK;
+}
+
+hybbx_circuit_balance_tick_result_t hybbx_circuit_balance_tick(
+ hybbx_circuit_balance_t *bal, unsigned poll_ms,
+ hybbx_circuit_balance_send_fn send_fn, void *send_ctx,
+ hybbx_circuit_balance_flow_fn flow_fn, void *flow_ctx)
+{
+ unsigned baud;
+ double budget;
+ size_t sent_budget;
+
+ if (bal == NULL || send_fn == NULL) {
+ return HYBBX_CIRCUIT_BAL_TICK_OK;
+ }
+
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return HYBBX_CIRCUIT_BAL_TICK_CANCEL_LINK;
+ }
+
+ if (!bal->cfg.enabled || bal->q_count == 0) {
+ balance_apply_escalation(bal, flow_fn, flow_ctx);
+ return HYBBX_CIRCUIT_BAL_TICK_OK;
+ }
+
+ if (bal->action == HYBBX_CIRCUIT_BAL_PAUSE) {
+ balance_apply_escalation(bal, flow_fn, flow_ctx);
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return HYBBX_CIRCUIT_BAL_TICK_CANCEL_LINK;
+ }
+ return HYBBX_CIRCUIT_BAL_TICK_OK;
+ }
+
+ baud = balance_effective_baud(bal);
+ if (poll_ms == 0) {
+ poll_ms = 1;
+ }
+
+ budget = ((double)baud * (double)poll_ms) / 8000.0;
+ bal->drain_accum += budget;
+ sent_budget = (size_t)bal->drain_accum;
+ bal->drain_accum -= (double)sent_budget;
+
+ while (sent_budget > 0 && bal->q_count > 0) {
+ balance_frame_t *entry = &bal->queue[bal->q_head];
+
+ if (entry->len > sent_budget) {
+ break;
+ }
+
+ if (send_fn(send_ctx, entry->data, entry->len) != HYBBX_OK) {
+ break;
+ }
+
+ sent_budget -= entry->len;
+ bal->queue_bytes -= entry->len;
+ bal->q_head = (bal->q_head + 1u) % HYBBX_CIRCUIT_BALANCE_QUEUE_MAX;
+ bal->q_count--;
+ }
+
+ if (bal->q_count == 0) {
+ bal->oldest_enqueue = 0;
+ }
+
+ balance_apply_escalation(bal, flow_fn, flow_ctx);
+ if (bal->action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return HYBBX_CIRCUIT_BAL_TICK_CANCEL_LINK;
+ }
+
+ return HYBBX_CIRCUIT_BAL_TICK_OK;
+}
diff --git a/src/core/circuit_bridge.c b/src/core/circuit_bridge.c
new file mode 100644
index 0000000..af3bde4
--- /dev/null
+++ b/src/core/circuit_bridge.c
@@ -0,0 +1,175 @@
+#include "hybbx/circuit_bridge.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct bridge_load_ctx {
+ hybbx_circuit_bridge_registry_t *reg;
+ const hybbx_config_t *reg_config;
+} bridge_load_ctx_t;
+
+static int bridge_section_is_numbered_transport(const char *section,
+ const char *plugin)
+{
+ char prefix[64];
+ size_t prefix_len;
+ const char *suffix;
+
+ if (section == NULL || plugin == NULL) {
+ return 0;
+ }
+
+ snprintf(prefix, sizeof(prefix), "transport.%s", plugin);
+ prefix_len = strlen(prefix);
+ if (strncmp(section, prefix, prefix_len) != 0) {
+ return 0;
+ }
+
+ suffix = section + prefix_len;
+ if (suffix[0] == '\0') {
+ return 0;
+ }
+
+ while (*suffix != '\0') {
+ if (*suffix < '0' || *suffix > '9') {
+ return 0;
+ }
+ suffix++;
+ }
+
+ return 1;
+}
+
+static int bridge_section_is_numbered_packet_radio(const char *section)
+{
+ return bridge_section_is_numbered_transport(section, "packet_radio");
+}
+
+static int bridge_section_is_numbered_ardop(const char *section)
+{
+ return bridge_section_is_numbered_transport(section, "ardop");
+}
+
+static int bridge_section_is_numbered_crdop(const char *section)
+{
+ return bridge_section_is_numbered_transport(section, "crdop");
+}
+
+static int bridge_section_is_numbered_baycom(const char *section)
+{
+ return bridge_section_is_numbered_transport(section, "baycom");
+}
+
+static void bridge_load_section(const char *section, void *userdata)
+{
+ bridge_load_ctx_t *ctx = (bridge_load_ctx_t *)userdata;
+ hybbx_circuit_bridge_entry_t *entry;
+ const char *value;
+ unsigned i;
+
+ if (ctx == NULL || ctx->reg == NULL || section == NULL) {
+ return;
+ }
+
+ if (!bridge_section_is_numbered_packet_radio(section) &&
+ !bridge_section_is_numbered_ardop(section) &&
+ !bridge_section_is_numbered_crdop(section) &&
+ !bridge_section_is_numbered_baycom(section) &&
+ !bridge_section_is_numbered_transport(section, "mains_proxy")) {
+ return;
+ }
+
+ if (ctx->reg->count >= HYBBX_CIRCUIT_MAX_LINKS) {
+ return;
+ }
+
+ entry = &ctx->reg->entries[ctx->reg->count];
+ memset(entry, 0, sizeof(*entry));
+
+ value = hybbx_config_get(ctx->reg_config, section, "link_id", NULL);
+ if (value == NULL || value[0] == '\0') {
+ return;
+ }
+ hybbx_strlcpy(entry->link_id, value, sizeof(entry->link_id));
+
+ for (i = 0; i < ctx->reg->count; i++) {
+ if (strcmp(ctx->reg->entries[i].link_id, entry->link_id) == 0) {
+ fprintf(stderr,
+ "[circuit] bridge: duplicate link_id=%s in [%s] — skipped\n",
+ entry->link_id, section);
+ return;
+ }
+ }
+
+ value = hybbx_config_get(ctx->reg_config, section, "link_password", NULL);
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(entry->link_password, value, sizeof(entry->link_password));
+ }
+
+ value = hybbx_config_get(ctx->reg_config, section, "link_role", NULL);
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(entry->link_role, value, sizeof(entry->link_role));
+ } else if (bridge_section_is_numbered_transport(section, "mains_proxy")) {
+ hybbx_strlcpy(entry->link_role, "proxy", sizeof(entry->link_role));
+ } else {
+ hybbx_strlcpy(entry->link_role, "link", sizeof(entry->link_role));
+ }
+
+ value = hybbx_config_get(ctx->reg_config, section, "frequency_mhz", NULL);
+ if (value == NULL || value[0] == '\0') {
+ value = hybbx_config_get(ctx->reg_config, section, "frequency", NULL);
+ }
+ if (value != NULL && value[0] != '\0') {
+ double mhz = strtod(value, NULL);
+
+ if (mhz > 0.0) {
+ entry->frequency_mhz = mhz;
+ }
+ }
+
+ ctx->reg->count++;
+}
+
+void hybbx_circuit_bridge_clear(hybbx_circuit_bridge_registry_t *reg)
+{
+ if (reg == NULL) {
+ return;
+ }
+ memset(reg, 0, sizeof(*reg));
+}
+
+hybbx_result_t hybbx_circuit_bridge_load(hybbx_circuit_bridge_registry_t *reg,
+ const hybbx_config_t *config)
+{
+ bridge_load_ctx_t ctx;
+
+ if (reg == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_circuit_bridge_clear(reg);
+ ctx.reg = reg;
+ ctx.reg_config = config;
+ hybbx_config_foreach_section(config, bridge_load_section, &ctx);
+ return HYBBX_OK;
+}
+
+const hybbx_circuit_bridge_entry_t *hybbx_circuit_bridge_find(
+ const hybbx_circuit_bridge_registry_t *reg, const char *link_id)
+{
+ unsigned i;
+
+ if (reg == NULL || link_id == NULL || link_id[0] == '\0') {
+ return NULL;
+ }
+
+ for (i = 0; i < reg->count; i++) {
+ if (strcmp(reg->entries[i].link_id, link_id) == 0) {
+ return &reg->entries[i];
+ }
+ }
+
+ return NULL;
+}
diff --git a/src/core/circuit_tcp.c b/src/core/circuit_tcp.c
new file mode 100644
index 0000000..58f10e4
--- /dev/null
+++ b/src/core/circuit_tcp.c
@@ -0,0 +1,1837 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/mains_proxy.h"
+#include "hybbx/circuit.h"
+#include "hybbx/broadcast.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/circuit_bridge.h"
+#include "hybbx/bandwidth_policy.h"
+#include "hybbx/session.h"
+#include "hybbx/service.h"
+#include "hybbx/security.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/storage.h"
+#include "hybbx/traffic.h"
+#include "hybbx/link.h"
+#include "hybbx/password.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 <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/socket.h>
+#include <time.h>
+#include <unistd.h>
+
+#define HYBBX_CIRCUIT_LINK_POLL_MS 50
+#define HYBBX_CIRCUIT_AUTH_TIMEOUT_MS 15000
+
+typedef enum {
+ CIRCUIT_SLOT_FREE = 0,
+ CIRCUIT_SLOT_CONNECTING,
+ CIRCUIT_SLOT_ACTIVE
+} circuit_slot_state_t;
+
+typedef struct hybbx_circuit_link_slot {
+ hybbx_circuit_hub_t *hub;
+ circuit_slot_state_t state;
+ int fd;
+ pthread_t thread;
+ char link_id[HYBBX_LINK_ID_MAX];
+ hybbx_session_t *session;
+ hybbx_circuit_decoder_t decoder;
+ hybbx_circuit_balance_t *balance;
+ hybbx_circuit_link_profile_t profile;
+ int profile_set;
+ time_t last_rf_activity;
+} hybbx_circuit_link_slot_t;
+
+struct hybbx_circuit_hub {
+ hybbx_service_t *service;
+ hybbx_circuit_config_t config;
+ hybbx_link_registry_t links;
+ hybbx_circuit_bridge_registry_t bridge;
+ unsigned max_links;
+ char link_password[128];
+ int link_auth;
+ int listen_v4;
+ int listen_v6;
+ pthread_t accept_thread;
+ pthread_mutex_t lock;
+ volatile int running;
+ hybbx_circuit_link_slot_t slots[HYBBX_CIRCUIT_MAX_LINKS];
+};
+
+void hybbx_circuit_config_defaults(hybbx_circuit_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ memset(cfg, 0, sizeof(*cfg));
+ snprintf(cfg->bind4, sizeof(cfg->bind4), "127.0.0.1");
+ snprintf(cfg->bind6, sizeof(cfg->bind6), "::1");
+ cfg->port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ cfg->ipv4 = 1;
+ cfg->ipv6 = 1;
+ cfg->link_stale_days = HYBBX_LINK_STALE_DAYS;
+ cfg->link_auth = 1;
+ hybbx_circuit_balance_config_defaults(&cfg->balance);
+ cfg->max_links = HYBBX_CIRCUIT_DEFAULT_MAX_LINKS;
+ hybbx_circuit_bridge_clear(&cfg->bridge);
+}
+
+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 backlog)
+{
+ 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, backlog > 0 ? backlog : 8) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static int circuit_slot_broadcast_qos(const hybbx_circuit_link_slot_t *slot)
+{
+ if (slot == NULL || !slot->profile_set) {
+ return 0;
+ }
+
+ return slot->profile.bandwidth == HYBBX_CIRCUIT_BW_LOW &&
+ slot->profile.duplex == HYBBX_CIRCUIT_DUPLEX_HALF;
+}
+
+static int circuit_frame_is_ax25_broadcast_tx(const uint8_t *frame, size_t len)
+{
+ unsigned flags;
+ uint8_t proto;
+
+ if (frame == NULL || len < HYBBX_CIRCUIT_HEADER_SIZE) {
+ return 0;
+ }
+
+ if (frame[0] != HYBBX_CIRCUIT_MAGIC_0 ||
+ frame[1] != HYBBX_CIRCUIT_MAGIC_1 ||
+ frame[2] != HYBBX_CIRCUIT_MAGIC_2 ||
+ frame[3] != HYBBX_CIRCUIT_VERSION) {
+ return 0;
+ }
+
+ proto = frame[4];
+ if (proto != (uint8_t)HYBBX_CIRCUIT_PROTO_AX25_UI &&
+ proto != (uint8_t)HYBBX_CIRCUIT_PROTO_AX25) {
+ return 0;
+ }
+
+ flags = ((unsigned)frame[5] << 8u) | (unsigned)frame[6];
+ return (flags & HYBBX_CIRCUIT_FLAG_TX) != 0;
+}
+
+static int circuit_slot_can_send_low_prio(const hybbx_circuit_hub_t *hub,
+ const hybbx_circuit_link_slot_t *slot)
+{
+ hybbx_circuit_balance_action_t action;
+
+ if (hub == NULL || slot == NULL || slot->balance == NULL ||
+ !hub->config.balance.enabled || !slot->profile_set) {
+ return 1;
+ }
+
+ action = hybbx_circuit_balance_action(slot->balance);
+ if (action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ return 0;
+ }
+
+ /*
+ * Auto-beacon AX.25 uses circuit_slot_send_raw (no queue slot). Do not
+ * block on PAUSE/BREAK backlog — only CANCEL drops the link.
+ */
+ return 1;
+}
+
+static unsigned circuit_count_used_slots(const hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+ unsigned count = 0;
+
+ if (hub == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state != CIRCUIT_SLOT_FREE) {
+ count++;
+ }
+ }
+
+ return count;
+}
+
+static int circuit_find_free_slot(hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+
+ if (hub == NULL) {
+ return -1;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state == CIRCUIT_SLOT_FREE && hub->slots[i].fd < 0) {
+ return (int)i;
+ }
+ }
+
+ return -1;
+}
+
+static int circuit_find_active_slot_by_id(const hybbx_circuit_hub_t *hub,
+ const char *link_id,
+ const hybbx_circuit_link_slot_t *except)
+{
+ unsigned i;
+
+ if (hub == NULL || link_id == NULL || link_id[0] == '\0') {
+ return -1;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ const hybbx_circuit_link_slot_t *slot = &hub->slots[i];
+
+ if (slot == except) {
+ continue;
+ }
+ if (slot->state == CIRCUIT_SLOT_FREE || slot->link_id[0] == '\0') {
+ continue;
+ }
+ if (strcmp(slot->link_id, link_id) == 0) {
+ return (int)i;
+ }
+ }
+
+ return -1;
+}
+
+typedef struct balance_send_ctx {
+ hybbx_circuit_link_slot_t *slot;
+} balance_send_ctx_t;
+
+typedef struct flow_ctrl_ctx {
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_link_slot_t *slot;
+} flow_ctrl_ctx_t;
+
+static hybbx_result_t circuit_slot_send_raw(hybbx_circuit_link_slot_t *slot,
+ const uint8_t *frame, size_t len)
+{
+ hybbx_circuit_hub_t *hub;
+ ssize_t sent;
+ size_t off = 0;
+ int fd;
+
+ if (slot == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hub = slot->hub;
+ if (hub == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ pthread_mutex_lock(&hub->lock);
+ fd = slot->fd;
+ if (fd < 0) {
+ pthread_mutex_unlock(&hub->lock);
+ return HYBBX_ERR_BUSY;
+ }
+
+ while (off < len) {
+ sent = send(fd, frame + off, len - off, MSG_NOSIGNAL);
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ pthread_mutex_unlock(&hub->lock);
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ pthread_mutex_unlock(&hub->lock);
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)sent;
+ }
+
+ pthread_mutex_unlock(&hub->lock);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t balance_send_raw_cb(void *ctx, const uint8_t *frame,
+ size_t len)
+{
+ balance_send_ctx_t *bctx = (balance_send_ctx_t *)ctx;
+
+ if (bctx == NULL || bctx->slot == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return circuit_slot_send_raw(bctx->slot, frame, len);
+}
+
+static int circuit_bandwidth_spare_link(hybbx_circuit_hub_t *hub,
+ hybbx_circuit_link_slot_t *slot,
+ hybbx_circuit_balance_action_t action)
+{
+ unsigned users_before;
+ unsigned affected;
+
+ if (hub == NULL || hub->service == NULL || slot == NULL) {
+ return 0;
+ }
+
+ users_before = hybbx_bandwidth_policy_user_count(hub->service);
+ if (users_before == 0) {
+ return 0;
+ }
+
+ affected = hybbx_bandwidth_policy_apply(hub->service, action);
+ if (affected == 0) {
+ return 0;
+ }
+
+ if (slot->balance != NULL &&
+ hybbx_circuit_balance_action(slot->balance) == HYBBX_CIRCUIT_BAL_CANCEL) {
+ hybbx_circuit_balance_spared_cancel(slot->balance);
+ hybbx_log_stats("[circuit] secondary link %s spared — users sacrificed first",
+ slot->link_id[0] != '\0' ? slot->link_id : "?");
+ }
+
+ return 1;
+}
+
+static void balance_flow_ctrl_cb(void *ctx,
+ hybbx_circuit_balance_action_t action,
+ const char *reason)
+{
+ flow_ctrl_ctx_t *fctx = (flow_ctrl_ctx_t *)ctx;
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_link_slot_t *slot;
+ char payload[128];
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t payload_len;
+ size_t frame_len;
+ const char *reason_str = reason != NULL ? reason : "-";
+
+ if (fctx == NULL || fctx->hub == NULL || fctx->slot == NULL ||
+ action == HYBBX_CIRCUIT_BAL_NONE) {
+ return;
+ }
+
+ hub = fctx->hub;
+ slot = fctx->slot;
+
+ payload_len = hybbx_circuit_flow_ctrl_format(action, reason_str,
+ payload, sizeof(payload));
+ if (payload_len == 0) {
+ return;
+ }
+
+ frame_len = hybbx_circuit_encode_link_msg(HYBBX_CIRCUIT_PROTO_FLOW_CTRL,
+ payload, payload_len,
+ frame, sizeof(frame));
+ if (frame_len > 0) {
+ (void)circuit_slot_send_raw(slot, frame, frame_len);
+ }
+
+ if (action == HYBBX_CIRCUIT_BAL_CANCEL) {
+ if (circuit_bandwidth_spare_link(hub, slot, action)) {
+ return;
+ }
+ hybbx_log_warn("[circuit] load-balance cancelled link %s (%s)",
+ slot->link_id[0] != '\0' ? slot->link_id : "?",
+ reason_str);
+ } else if (action == HYBBX_CIRCUIT_BAL_PAUSE ||
+ action == HYBBX_CIRCUIT_BAL_BREAK ||
+ action == HYBBX_CIRCUIT_BAL_RESUME) {
+ hybbx_log_stats("[circuit] load-balance %s link=%s (%s)",
+ hybbx_circuit_balance_action_name(action),
+ slot->link_id[0] != '\0' ? slot->link_id : "?",
+ reason_str);
+ }
+
+ if (hub->service != NULL &&
+ (action == HYBBX_CIRCUIT_BAL_PAUSE ||
+ action == HYBBX_CIRCUIT_BAL_BREAK ||
+ action == HYBBX_CIRCUIT_BAL_RESUME)) {
+ (void)hybbx_bandwidth_policy_apply(hub->service, action);
+ }
+}
+
+static void circuit_balance_tick_slot(hybbx_circuit_link_slot_t *slot,
+ int *cancel_link)
+{
+ balance_send_ctx_t bctx;
+ flow_ctrl_ctx_t fctx;
+ hybbx_circuit_balance_tick_result_t tr;
+ hybbx_circuit_hub_t *hub;
+
+ if (cancel_link != NULL) {
+ *cancel_link = 0;
+ }
+
+ if (slot == NULL || slot->hub == NULL || slot->balance == NULL ||
+ !slot->profile_set) {
+ return;
+ }
+
+ hub = slot->hub;
+ bctx.slot = slot;
+ fctx.hub = hub;
+ fctx.slot = slot;
+
+ tr = hybbx_circuit_balance_tick(slot->balance, HYBBX_CIRCUIT_LINK_POLL_MS,
+ balance_send_raw_cb, &bctx,
+ balance_flow_ctrl_cb, &fctx);
+ if (slot->balance != NULL && slot->profile_set &&
+ hub->config.balance.enabled &&
+ slot->profile.bandwidth == HYBBX_CIRCUIT_BW_LOW &&
+ hybbx_circuit_balance_action(slot->balance) == HYBBX_CIRCUIT_BAL_PAUSE &&
+ hybbx_circuit_balance_queued_bytes(slot->balance) >=
+ hub->config.balance.queue_pause) {
+ (void)circuit_bandwidth_spare_link(hub, slot, HYBBX_CIRCUIT_BAL_PAUSE);
+ }
+ if (tr == HYBBX_CIRCUIT_BAL_TICK_CANCEL_LINK && cancel_link != NULL) {
+ if (circuit_bandwidth_spare_link(hub, slot, HYBBX_CIRCUIT_BAL_CANCEL)) {
+ tr = HYBBX_CIRCUIT_BAL_TICK_OK;
+ } else {
+ *cancel_link = 1;
+ }
+ }
+}
+
+static hybbx_result_t circuit_slot_send_hbx(hybbx_circuit_link_slot_t *slot,
+ const uint8_t *frame, size_t len)
+{
+ balance_send_ctx_t bctx;
+ flow_ctrl_ctx_t fctx;
+ hybbx_circuit_hub_t *hub;
+
+ if (slot == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hub = slot->hub;
+ if (hub == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (slot->balance != NULL && slot->profile_set &&
+ hub->config.balance.enabled) {
+ bctx.slot = slot;
+ fctx.hub = hub;
+ fctx.slot = slot;
+ return hybbx_circuit_balance_submit(slot->balance, frame, len,
+ balance_send_raw_cb, &bctx,
+ balance_flow_ctrl_cb, &fctx);
+ }
+
+ return circuit_slot_send_raw(slot, frame, len);
+}
+
+hybbx_result_t hybbx_circuit_hub_send_raw(hybbx_circuit_hub_t *hub,
+ const uint8_t *frame, size_t len)
+{
+ unsigned i;
+
+ if (hub == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state != CIRCUIT_SLOT_FREE && hub->slots[i].fd >= 0) {
+ return circuit_slot_send_raw(&hub->slots[i], frame, len);
+ }
+ }
+
+ return HYBBX_ERR_BUSY;
+}
+
+static hybbx_result_t circuit_transport_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ hybbx_circuit_link_slot_t *slot;
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+
+ if (session == NULL || data == NULL || len == 0) {
+ return HYBBX_OK;
+ }
+
+ slot = (hybbx_circuit_link_slot_t *)session->transport_data;
+ if (slot == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode_terminal(data, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return circuit_slot_send_hbx(slot, frame, frame_len);
+}
+
+hybbx_result_t hybbx_circuit_hub_send_hbx(hybbx_circuit_hub_t *hub,
+ const uint8_t *frame, size_t len)
+{
+ if (hub == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_circuit_hub_multicast_hbx(hub, frame, len, 0.0, 0, NULL);
+}
+
+unsigned hybbx_circuit_hub_active_link_count(const hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+ unsigned count = 0;
+
+ if (hub == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state == CIRCUIT_SLOT_ACTIVE) {
+ count++;
+ }
+ }
+
+ return count;
+}
+
+static double circuit_slot_effective_frequency_mhz(
+ const hybbx_circuit_link_slot_t *slot,
+ const hybbx_circuit_hub_t *hub)
+{
+ double mhz;
+
+ if (slot == NULL) {
+ return 0.0;
+ }
+
+ mhz = slot->profile.frequency_mhz;
+ if (mhz <= 0.0 && slot->link_id[0] != '\0' && hub != NULL) {
+ const hybbx_circuit_bridge_entry_t *be =
+ hybbx_circuit_bridge_find(&hub->bridge, slot->link_id);
+
+ if (be != NULL && be->frequency_mhz > 0.0) {
+ mhz = be->frequency_mhz;
+ }
+ }
+
+ return mhz;
+}
+
+hybbx_result_t hybbx_circuit_hub_multicast_hbx(hybbx_circuit_hub_t *hub,
+ const uint8_t *frame, size_t len,
+ double frequency_mhz,
+ int require_broadcast_qos,
+ unsigned *sent_out)
+{
+ unsigned i;
+ int sent = 0;
+ hybbx_result_t last_err = HYBBX_ERR_NOT_FOUND;
+
+ if (sent_out != NULL) {
+ *sent_out = 0;
+ }
+
+ if (hub == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ hybbx_circuit_link_slot_t *slot = &hub->slots[i];
+ hybbx_result_t rc;
+
+ if (slot->state != CIRCUIT_SLOT_ACTIVE || slot->fd < 0) {
+ continue;
+ }
+ if (require_broadcast_qos && !circuit_slot_broadcast_qos(slot)) {
+ continue;
+ }
+ if (frequency_mhz > 0.0) {
+ double slot_mhz = circuit_slot_effective_frequency_mhz(slot, hub);
+
+ if (slot_mhz <= 0.0 ||
+ !hybbx_ax25_frequency_match(frequency_mhz, slot_mhz)) {
+ continue;
+ }
+ }
+ if (require_broadcast_qos && circuit_frame_is_ax25_broadcast_tx(frame, len) &&
+ !circuit_slot_can_send_low_prio(hub, slot)) {
+ last_err = HYBBX_ERR_BUSY;
+ continue;
+ }
+
+ if (require_broadcast_qos && circuit_frame_is_ax25_broadcast_tx(frame, len)) {
+ /*
+ * Low-priority AX.25 broadcast: admission is decided by balancer
+ * flow-control state, then transmit immediately (no queue slot).
+ * This avoids enqueue->break drops that can otherwise log as sent
+ * without reaching RF.
+ */
+ rc = circuit_slot_send_raw(slot, frame, len);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_hub_note_rf_activity(hub, slot->link_id);
+ }
+ } else {
+ rc = circuit_slot_send_hbx(slot, frame, len);
+ }
+ if (rc == HYBBX_OK) {
+ sent++;
+ } else {
+ last_err = rc;
+ }
+ }
+
+ if (sent > 0) {
+ if (sent_out != NULL) {
+ *sent_out = (unsigned)sent;
+ }
+ return HYBBX_OK;
+ }
+
+ if (require_broadcast_qos) {
+ if (last_err != HYBBX_ERR_NOT_FOUND) {
+ return last_err;
+ }
+ return HYBBX_ERR_DENIED;
+ }
+
+ return last_err;
+}
+
+hybbx_result_t hybbx_circuit_hub_send_hbx_slot(hybbx_circuit_hub_t *hub,
+ unsigned slot_index,
+ const uint8_t *frame, size_t len,
+ int require_broadcast_qos)
+{
+ hybbx_circuit_link_slot_t *slot;
+ hybbx_result_t rc;
+
+ if (hub == NULL || frame == NULL || len == 0 ||
+ slot_index >= HYBBX_CIRCUIT_MAX_LINKS) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ slot = &hub->slots[slot_index];
+ if (slot->state != CIRCUIT_SLOT_ACTIVE || slot->fd < 0) {
+ return HYBBX_ERR_DENIED;
+ }
+ if (require_broadcast_qos && !circuit_slot_broadcast_qos(slot)) {
+ return HYBBX_ERR_DENIED;
+ }
+ if (require_broadcast_qos && circuit_frame_is_ax25_broadcast_tx(frame, len) &&
+ !circuit_slot_can_send_low_prio(hub, slot)) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (require_broadcast_qos && circuit_frame_is_ax25_broadcast_tx(frame, len)) {
+ rc = circuit_slot_send_raw(slot, frame, len);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_hub_note_rf_activity(hub, slot->link_id);
+ }
+ } else {
+ rc = circuit_slot_send_hbx(slot, frame, len);
+ }
+
+ return rc;
+}
+
+double hybbx_circuit_hub_link_frequency_mhz(const hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+
+ if (hub == NULL) {
+ return 0.0;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state == CIRCUIT_SLOT_ACTIVE && hub->slots[i].profile_set) {
+ return hub->slots[i].profile.frequency_mhz;
+ }
+ }
+
+ return 0.0;
+}
+
+int hybbx_circuit_hub_link_broadcast_qos(const hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+
+ if (hub == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state == CIRCUIT_SLOT_ACTIVE &&
+ circuit_slot_broadcast_qos(&hub->slots[i])) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static void circuit_broadcast_links_sort(hybbx_circuit_broadcast_link_t *links,
+ unsigned count)
+{
+ unsigned i;
+ unsigned j;
+
+ for (i = 1; i < count; i++) {
+ hybbx_circuit_broadcast_link_t key = links[i];
+
+ j = i;
+ while (j > 0) {
+ double prev_mhz = links[j - 1].frequency_mhz;
+ double key_mhz = key.frequency_mhz;
+ int move = 0;
+
+ if (key_mhz > 0.0 && prev_mhz <= 0.0) {
+ move = 0;
+ } else if (key_mhz <= 0.0 && prev_mhz > 0.0) {
+ move = 1;
+ } else if (key_mhz > 0.0 && prev_mhz > 0.0 &&
+ key_mhz < prev_mhz) {
+ move = 1;
+ } else if (key_mhz <= 0.0 && prev_mhz <= 0.0 &&
+ strcmp(key.link_id, links[j - 1].link_id) < 0) {
+ move = 1;
+ }
+
+ if (!move) {
+ break;
+ }
+
+ links[j] = links[j - 1];
+ j--;
+ }
+ links[j] = key;
+ }
+}
+
+unsigned hybbx_circuit_hub_broadcast_links(const hybbx_circuit_hub_t *hub,
+ hybbx_circuit_broadcast_link_t *out,
+ unsigned out_max)
+{
+ unsigned i;
+ unsigned count = 0;
+
+ if (hub == NULL || out == NULL || out_max == 0) {
+ return 0;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ const hybbx_circuit_link_slot_t *slot = &hub->slots[i];
+
+ if (slot->state != CIRCUIT_SLOT_ACTIVE || slot->fd < 0) {
+ continue;
+ }
+ if (!circuit_slot_broadcast_qos(slot)) {
+ continue;
+ }
+ if (count >= out_max) {
+ break;
+ }
+
+ out[count].frequency_mhz =
+ circuit_slot_effective_frequency_mhz(slot, hub);
+ out[count].slot_index = i;
+ hybbx_strlcpy(out[count].link_id, slot->link_id,
+ sizeof(out[count].link_id));
+ count++;
+ }
+
+ if (count > 1) {
+ circuit_broadcast_links_sort(out, count);
+ }
+
+ return count;
+}
+
+static void on_circuit_frame(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ hybbx_circuit_link_slot_t *slot = (hybbx_circuit_link_slot_t *)userdata;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+
+ (void)flags;
+
+ if (slot == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_PROXY_MAIL ||
+ proto == HYBBX_CIRCUIT_PROTO_PROXY_CHAT) {
+ if (slot->hub != NULL && slot->hub->service != NULL) {
+ hybbx_mains_proxy_inbound_frame(slot->hub->service, proto,
+ payload, len);
+ }
+ return;
+ }
+
+ if (slot->session == NULL || len == 0) {
+ return;
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_AX25: {
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path, ui,
+ sizeof(ui));
+ if (ui_len > 0) {
+ (void)hybbx_session_handle_input(slot->session, ui, ui_len);
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_PROTO_AX25_UI: {
+ size_t ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path,
+ ui, sizeof(ui));
+ if (ui_len > 0) {
+ (void)hybbx_session_handle_input(slot->session, ui, ui_len);
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ (void)hybbx_session_handle_input(slot->session, payload, len);
+ break;
+ default:
+ hybbx_log_debug("[circuit] link=%s ignored proto=%s (%u bytes)",
+ slot->link_id[0] != '\0' ? slot->link_id : "?",
+ hybbx_circuit_proto_name(proto), (unsigned)len);
+ break;
+ }
+}
+
+static void circuit_slot_reset(hybbx_circuit_link_slot_t *slot)
+{
+ if (slot == NULL) {
+ return;
+ }
+
+ if (slot->balance != NULL) {
+ hybbx_circuit_balance_destroy(slot->balance);
+ slot->balance = NULL;
+ }
+
+ if (slot->session != NULL) {
+ hybbx_session_close(slot->session);
+ slot->session = NULL;
+ }
+
+ slot->profile_set = 0;
+ memset(&slot->profile, 0, sizeof(slot->profile));
+ slot->link_id[0] = '\0';
+ slot->last_rf_activity = 0;
+ slot->state = CIRCUIT_SLOT_FREE;
+ slot->thread = (pthread_t)0;
+}
+
+static void circuit_close_slot(hybbx_circuit_link_slot_t *slot)
+{
+ hybbx_circuit_hub_t *hub;
+ int fd;
+
+ if (slot == NULL || slot->hub == NULL) {
+ return;
+ }
+
+ hub = slot->hub;
+
+ pthread_mutex_lock(&hub->lock);
+ fd = slot->fd;
+ if (fd >= 0) {
+ close(fd);
+ slot->fd = -1;
+ }
+ pthread_mutex_unlock(&hub->lock);
+
+ circuit_slot_reset(slot);
+}
+
+static void circuit_close_all_slots(hybbx_circuit_hub_t *hub)
+{
+ unsigned i;
+
+ if (hub == NULL) {
+ return;
+ }
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ if (hub->slots[i].state != CIRCUIT_SLOT_FREE || hub->slots[i].fd >= 0) {
+ circuit_close_slot(&hub->slots[i]);
+ }
+ }
+}
+
+typedef struct circuit_auth_ctx {
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_link_slot_t *slot;
+ int done;
+ int ok;
+ hybbx_link_auth_t auth;
+} circuit_auth_ctx_t;
+
+static void circuit_evict_stale_link(hybbx_circuit_hub_t *hub,
+ hybbx_circuit_link_slot_t *stale,
+ const char *link_id)
+{
+ int fd = -1;
+
+ if (hub == NULL || stale == NULL) {
+ return;
+ }
+
+ /*
+ * Reconnect race: close the stale TCP fd and drop link_id so the new
+ * auth can proceed. Do not reset session/balance here — the stale link
+ * thread still owns those until it exits and calls circuit_close_slot.
+ */
+ pthread_mutex_lock(&hub->lock);
+ fd = stale->fd;
+ if (fd >= 0) {
+ (void)shutdown(fd, SHUT_RDWR);
+ close(fd);
+ stale->fd = -1;
+ }
+ stale->link_id[0] = '\0';
+ pthread_mutex_unlock(&hub->lock);
+
+ if (link_id != NULL && link_id[0] != '\0') {
+ hybbx_log_info("[circuit] replacing stale link id=%s (reconnect)",
+ link_id);
+ }
+}
+
+static int circuit_auth_fail_counts_as_abuse(const char *reason)
+{
+ if (reason == NULL) {
+ return 0;
+ }
+
+ /*
+ * duplicate_id is a reconnect race (stale hub slot), not brute-force.
+ * circuit_validate_link_auth evicts the stale slot; do not ban for it.
+ */
+ if (strcmp(reason, "duplicate_id") == 0) {
+ return 0;
+ }
+
+ return 1;
+}
+
+static void circuit_log_auth_fail(int fd, const char *reason, const char *id)
+{
+ char ip[HYBBX_REMOTE_ADDR_MAX];
+ int count_abuse = circuit_auth_fail_counts_as_abuse(reason);
+
+ if (reason == NULL) {
+ return;
+ }
+
+ if (fd >= 0 && hybbx_socket_peer_name(fd, ip, sizeof(ip)) == HYBBX_OK) {
+ if (id != NULL && id[0] != '\0') {
+ hybbx_security_log_write(
+ "link_auth_fail ip=%s id=%s reason=%s transport=circuit",
+ ip, id, reason);
+ } else {
+ hybbx_security_log_write(
+ "link_auth_fail ip=%s reason=%s transport=circuit",
+ ip, reason);
+ }
+ if (count_abuse) {
+ hybbx_security_ban_link_auth_fail(ip);
+ if (id != NULL && id[0] != '\0' && strcmp(reason, "banned") != 0) {
+ hybbx_security_ban_link_auth_fail_callid(id);
+ }
+ }
+ } else {
+ hybbx_security_log_write(
+ "link_auth_fail ip=? reason=%s transport=circuit", reason);
+ }
+}
+
+static int circuit_auth_password_ok(const hybbx_circuit_hub_t *hub,
+ const hybbx_circuit_bridge_entry_t *entry,
+ const char *password)
+{
+ if (hub == NULL || password == NULL) {
+ return 0;
+ }
+
+ if (entry != NULL && entry->link_password[0] != '\0') {
+ return hybbx_password_match(entry->link_password, password);
+ }
+
+ if (hub->link_password[0] != '\0') {
+ return hybbx_password_match(hub->link_password, password);
+ }
+
+ return 0;
+}
+
+static int circuit_validate_link_auth(circuit_auth_ctx_t *ctx, const char **reason)
+{
+ const hybbx_circuit_bridge_entry_t *entry = NULL;
+ hybbx_circuit_hub_t *hub;
+ hybbx_circuit_link_slot_t *slot;
+
+ if (ctx == NULL || ctx->hub == NULL || ctx->slot == NULL) {
+ if (reason != NULL) {
+ *reason = "invalid";
+ }
+ return 0;
+ }
+
+ hub = ctx->hub;
+ slot = ctx->slot;
+
+ if (!hub->link_auth) {
+ return 1;
+ }
+
+ if (hub->bridge.count > 0) {
+ entry = hybbx_circuit_bridge_find(&hub->bridge, ctx->auth.id);
+ if (entry == NULL) {
+ if (reason != NULL) {
+ *reason = "unknown_id";
+ }
+ return 0;
+ }
+ } else if (hub->link_password[0] == '\0') {
+ if (reason != NULL) {
+ *reason = "no_password";
+ }
+ return 0;
+ }
+
+ if (!circuit_auth_password_ok(hub, entry, ctx->auth.password)) {
+ if (reason != NULL) {
+ *reason = "password";
+ }
+ return 0;
+ }
+
+ {
+ int stale_idx = circuit_find_active_slot_by_id(hub, ctx->auth.id, slot);
+
+ if (stale_idx >= 0) {
+ circuit_evict_stale_link(hub, &hub->slots[(unsigned)stale_idx],
+ ctx->auth.id);
+ if (circuit_find_active_slot_by_id(hub, ctx->auth.id, slot) >= 0) {
+ if (reason != NULL) {
+ *reason = "duplicate_id";
+ }
+ return 0;
+ }
+ }
+ }
+
+ (void)entry;
+ (void)slot;
+ return 1;
+}
+
+static void circuit_on_auth_frame(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ circuit_auth_ctx_t *ctx = (circuit_auth_ctx_t *)userdata;
+ const hybbx_circuit_bridge_entry_t *entry;
+ char code[HYBBX_LINK_CODE_MAX];
+ char ack[HYBBX_LINK_AUTH_PAYLOAD_MAX];
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ const char *fail_reason = "invalid";
+ int fd;
+
+ (void)flags;
+
+ if (ctx == NULL || ctx->done || ctx->slot == NULL) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_LINK_AUTH) {
+ return;
+ }
+
+ fd = ctx->slot->fd;
+
+ if (hybbx_link_auth_parse((const char *)payload, len, &ctx->auth) != HYBBX_OK) {
+ circuit_log_auth_fail(fd, "invalid", NULL);
+ ctx->done = 1;
+ ctx->ok = 0;
+ return;
+ }
+
+ if (!hybbx_security_ban_callid_accept(ctx->auth.id)) {
+ circuit_log_auth_fail(fd, "banned", ctx->auth.id);
+ ctx->done = 1;
+ ctx->ok = 0;
+ return;
+ }
+
+ if (!circuit_validate_link_auth(ctx, &fail_reason)) {
+ hybbx_log_warn("[circuit] link auth failed for id=%s (%s)",
+ ctx->auth.id, fail_reason);
+ circuit_log_auth_fail(fd, fail_reason, ctx->auth.id);
+ ctx->done = 1;
+ ctx->ok = 0;
+ return;
+ }
+
+ code[0] = '\0';
+ (void)hybbx_link_registry_touch(&ctx->hub->links, ctx->auth.id,
+ ctx->auth.role, code, sizeof(code));
+
+ snprintf(ack, sizeof(ack), "ok=yes\nid=%s\ncode=%s\n",
+ ctx->auth.id, code[0] != '\0' ? code : "-");
+ frame_len = hybbx_circuit_encode_link_msg(HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK,
+ ack, strlen(ack),
+ frame, sizeof(frame));
+ if (frame_len > 0) {
+ (void)circuit_slot_send_raw(ctx->slot, frame, frame_len);
+ }
+
+ hybbx_strlcpy(ctx->slot->link_id, ctx->auth.id, sizeof(ctx->slot->link_id));
+
+ hybbx_circuit_link_profile_from_auth(&ctx->auth, &ctx->slot->profile);
+ entry = hybbx_circuit_bridge_find(&ctx->hub->bridge, ctx->auth.id);
+ if (entry != NULL && entry->frequency_mhz > 0.0 &&
+ ctx->slot->profile.frequency_mhz <= 0.0) {
+ ctx->slot->profile.frequency_mhz = entry->frequency_mhz;
+ }
+
+ if (ctx->slot->balance != NULL) {
+ hybbx_circuit_balance_set_profile(ctx->slot->balance, &ctx->slot->profile);
+ }
+ ctx->slot->profile_set = 1;
+
+ hybbx_log_info("[circuit] link authenticated id=%s role=%s code=%s",
+ ctx->auth.id, ctx->auth.role, code[0] != '\0' ? code : "-");
+ {
+ char qos_msg[128];
+ int qos_len;
+
+ qos_len = snprintf(qos_msg, sizeof(qos_msg),
+ "[circuit] link QoS bandwidth=%s baud=%u duplex=%s",
+ ctx->slot->profile.bandwidth == HYBBX_CIRCUIT_BW_LOW ?
+ "low" : "high",
+ ctx->slot->profile.baud,
+ ctx->slot->profile.duplex == HYBBX_CIRCUIT_DUPLEX_HALF ?
+ "half" : "full");
+ if (qos_len > 0 && ctx->slot->profile.frequency_mhz > 0.0) {
+ snprintf(qos_msg + (size_t)qos_len,
+ sizeof(qos_msg) - (size_t)qos_len,
+ " %.3fMHz", ctx->slot->profile.frequency_mhz);
+ }
+ hybbx_log_info("%s", qos_msg);
+ }
+
+ ctx->done = 1;
+ ctx->ok = 1;
+}
+
+static int circuit_wait_link_auth(hybbx_circuit_link_slot_t *slot)
+{
+ circuit_auth_ctx_t ctx;
+ hybbx_circuit_decoder_t dec;
+ uint8_t buf[256];
+ unsigned elapsed = 0;
+ hybbx_circuit_hub_t *hub;
+
+ if (slot == NULL || slot->hub == NULL) {
+ return 0;
+ }
+
+ hub = slot->hub;
+
+ if (!hub->link_auth) {
+ return 1;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.hub = hub;
+ ctx.slot = slot;
+ hybbx_circuit_decoder_init(&dec);
+
+ while (!ctx.done && elapsed < HYBBX_CIRCUIT_AUTH_TIMEOUT_MS) {
+ struct pollfd pfd;
+ ssize_t n;
+ int pr;
+
+ pthread_mutex_lock(&hub->lock);
+ pfd.fd = slot->fd;
+ pthread_mutex_unlock(&hub->lock);
+
+ if (pfd.fd < 0) {
+ return 0;
+ }
+
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ pr = poll(&pfd, 1, HYBBX_CIRCUIT_LINK_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return 0;
+ }
+ if (pr == 0) {
+ elapsed += HYBBX_CIRCUIT_LINK_POLL_MS;
+ continue;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ return 0;
+ }
+
+ n = recv(pfd.fd, buf, sizeof(buf), 0);
+ if (n <= 0) {
+ return 0;
+ }
+
+ hybbx_circuit_decoder_feed(&dec, buf, (size_t)n,
+ circuit_on_auth_frame, &ctx);
+ }
+
+ if (!ctx.done) {
+ circuit_log_auth_fail(slot->fd, "timeout_no_link_auth", NULL);
+ }
+
+ return ctx.ok;
+}
+
+static void *circuit_link_thread(void *arg)
+{
+ hybbx_circuit_link_slot_t *slot = (hybbx_circuit_link_slot_t *)arg;
+ hybbx_circuit_hub_t *hub;
+ uint8_t buf[512];
+ hybbx_result_t rc;
+
+ if (slot == NULL || slot->hub == NULL) {
+ return NULL;
+ }
+
+ hub = slot->hub;
+
+ if (!circuit_wait_link_auth(slot)) {
+ hybbx_log_warn("[circuit] link authentication failed or timed out");
+ circuit_close_slot(slot);
+ return NULL;
+ }
+
+ if (!slot->profile_set) {
+ hybbx_circuit_link_profile_from_auth(NULL, &slot->profile);
+ if (slot->balance != NULL) {
+ hybbx_circuit_balance_set_profile(slot->balance, &slot->profile);
+ }
+ slot->profile_set = 1;
+ }
+
+ hybbx_circuit_decoder_init(&slot->decoder);
+
+ rc = hybbx_session_open(hub->service, &hybbx_plugin_circuit, slot,
+ &slot->session);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[circuit] session open failed for link %s",
+ slot->link_id[0] != '\0' ? slot->link_id : "?");
+ circuit_close_slot(slot);
+ return NULL;
+ }
+
+ {
+ char remote[HYBBX_REMOTE_ADDR_MAX];
+ int fd = slot->fd;
+
+ if (fd >= 0 &&
+ hybbx_socket_peer_name(fd, remote, sizeof(remote)) == HYBBX_OK) {
+ (void)hybbx_session_set_remote(slot->session, remote);
+ }
+ }
+
+ slot->state = CIRCUIT_SLOT_ACTIVE;
+ slot->last_rf_activity = time(NULL);
+ hybbx_log_info("[circuit] link adapter attached id=%s (HBX bridge active)",
+ slot->link_id[0] != '\0' ? slot->link_id : "?");
+
+ while (hub->running) {
+ struct pollfd pfd;
+ ssize_t n;
+ int pr;
+
+ pthread_mutex_lock(&hub->lock);
+ pfd.fd = slot->fd;
+ pthread_mutex_unlock(&hub->lock);
+
+ if (pfd.fd < 0) {
+ break;
+ }
+
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ pr = poll(&pfd, 1, HYBBX_CIRCUIT_LINK_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ int cancel_link = 0;
+
+ circuit_balance_tick_slot(slot, &cancel_link);
+ if (cancel_link) {
+ break;
+ }
+ if (slot->session != NULL) {
+ rc = hybbx_session_tick(slot->session);
+ if (rc == HYBBX_SESSION_END) {
+ break;
+ }
+ }
+ continue;
+ }
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ n = recv(pfd.fd, buf, sizeof(buf), 0);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&slot->decoder, buf, (size_t)n,
+ on_circuit_frame, slot);
+
+ {
+ int cancel_link = 0;
+ circuit_balance_tick_slot(slot, &cancel_link);
+ if (cancel_link) {
+ break;
+ }
+ }
+ }
+
+ hybbx_log_info("[circuit] link detached id=%s",
+ slot->link_id[0] != '\0' ? slot->link_id : "?");
+ circuit_close_slot(slot);
+ return NULL;
+}
+
+static void *circuit_accept_thread(void *arg)
+{
+ hybbx_circuit_hub_t *hub = (hybbx_circuit_hub_t *)arg;
+
+ while (hub->running) {
+ struct pollfd pfds[2];
+ int count = 0;
+ int pr;
+ int i;
+
+ pfds[0].fd = hub->listen_v4;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ if (hub->listen_v4 >= 0) {
+ count = 1;
+ }
+
+ pfds[1].fd = hub->listen_v6;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+ if (hub->listen_v6 >= 0) {
+ count = 2;
+ }
+
+ if (count == 0) {
+ break;
+ }
+
+ pr = poll(pfds, (nfds_t)count, HYBBX_CIRCUIT_LINK_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ continue;
+ }
+
+ for (i = 0; i < count; i++) {
+ if ((pfds[i].revents & POLLIN) == 0) {
+ continue;
+ }
+
+ {
+ int client = accept(pfds[i].fd, NULL, NULL);
+ int slot_idx;
+ hybbx_circuit_link_slot_t *slot;
+
+ if (client < 0) {
+ continue;
+ }
+
+ (void)set_socket_options(client, 0);
+
+ if (!hybbx_security_ban_accept_fd(client)) {
+ close(client);
+ continue;
+ }
+
+ pthread_mutex_lock(&hub->lock);
+ if (circuit_count_used_slots(hub) >= hub->max_links) {
+ pthread_mutex_unlock(&hub->lock);
+ hybbx_log_warn("[circuit] max_links=%u reached — rejecting connection",
+ hub->max_links);
+ close(client);
+ continue;
+ }
+
+ slot_idx = circuit_find_free_slot(hub);
+ if (slot_idx < 0) {
+ pthread_mutex_unlock(&hub->lock);
+ close(client);
+ continue;
+ }
+
+ slot = &hub->slots[slot_idx];
+ slot->fd = client;
+ slot->state = CIRCUIT_SLOT_CONNECTING;
+ slot->balance = hybbx_circuit_balance_create(&hub->config.balance);
+ if (slot->balance == NULL) {
+ hybbx_log_warn("[circuit] balance alloc failed — rejecting link");
+ close(client);
+ slot->fd = -1;
+ slot->state = CIRCUIT_SLOT_FREE;
+ pthread_mutex_unlock(&hub->lock);
+ continue;
+ }
+ pthread_mutex_unlock(&hub->lock);
+
+ if (pthread_create(&slot->thread, NULL, circuit_link_thread,
+ slot) != 0) {
+ hybbx_log_warn("[circuit] link thread failed");
+ circuit_close_slot(slot);
+ } else {
+ pthread_detach(slot->thread);
+ }
+ }
+ }
+ }
+
+ return NULL;
+}
+
+hybbx_circuit_hub_t *hybbx_circuit_hub_create(hybbx_service_t *service)
+{
+ hybbx_circuit_hub_t *hub;
+ unsigned i;
+
+ hub = calloc(1, sizeof(*hub));
+ if (hub == NULL) {
+ return NULL;
+ }
+
+ hub->service = service;
+ hub->listen_v4 = -1;
+ hub->listen_v6 = -1;
+ hub->max_links = HYBBX_CIRCUIT_DEFAULT_MAX_LINKS;
+ pthread_mutex_init(&hub->lock, NULL);
+
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ hub->slots[i].hub = hub;
+ hub->slots[i].fd = -1;
+ hub->slots[i].state = CIRCUIT_SLOT_FREE;
+ }
+
+ return hub;
+}
+
+void hybbx_circuit_hub_destroy(hybbx_circuit_hub_t *hub)
+{
+ if (hub == NULL) {
+ return;
+ }
+
+ hybbx_circuit_hub_stop(hub);
+ pthread_mutex_destroy(&hub->lock);
+ free(hub);
+}
+
+hybbx_result_t hybbx_circuit_hub_start(hybbx_circuit_hub_t *hub,
+ const hybbx_circuit_config_t *cfg)
+{
+ int backlog;
+
+ if (hub == NULL || cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_circuit_hub_stop(hub);
+ hub->config = *cfg;
+ hub->bridge = cfg->bridge;
+ hub->max_links = cfg->max_links;
+ if (hub->max_links == 0 || hub->max_links > HYBBX_CIRCUIT_MAX_LINKS) {
+ hub->max_links = HYBBX_CIRCUIT_DEFAULT_MAX_LINKS;
+ }
+ hybbx_strlcpy(hub->link_password, cfg->link_password, sizeof(hub->link_password));
+ hub->link_auth = cfg->link_auth;
+ hybbx_link_registry_init(&hub->links, cfg->data_path, cfg->config_path,
+ cfg->link_stale_days);
+ (void)hybbx_link_registry_prune(&hub->links);
+ hub->running = 1;
+
+ backlog = (int)hub->max_links;
+ if (backlog < 4) {
+ backlog = 4;
+ }
+
+ if (cfg->ipv4) {
+ hub->listen_v4 = create_listen_socket(AF_INET, cfg->bind4, cfg->port,
+ backlog);
+ if (hub->listen_v4 < 0) {
+ hybbx_socket_log_bind_failure("circuit", cfg->bind4, cfg->port);
+ hybbx_circuit_hub_stop(hub);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (cfg->ipv6) {
+ hub->listen_v6 = create_listen_socket(AF_INET6, cfg->bind6, cfg->port,
+ backlog);
+ if (hub->listen_v6 < 0) {
+ hybbx_log_warn("[circuit] IPv6 bind [%s]:%u skipped (%s)",
+ cfg->bind6, cfg->port, strerror(errno));
+ }
+ }
+
+ if (hub->listen_v4 < 0 && hub->listen_v6 < 0) {
+ hybbx_circuit_hub_stop(hub);
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ char hub_msg[256];
+ size_t off = 0;
+
+ off = (size_t)snprintf(hub_msg, sizeof(hub_msg),
+ "[circuit] internal TCP hub");
+ if (hub->listen_v4 >= 0) {
+ off += (size_t)snprintf(hub_msg + off, sizeof(hub_msg) - off,
+ " %s:%u", cfg->bind4, cfg->port);
+ }
+ if (hub->listen_v6 >= 0) {
+ off += (size_t)snprintf(hub_msg + off, sizeof(hub_msg) - off,
+ " [%s]:%u", cfg->bind6, cfg->port);
+ }
+ snprintf(hub_msg + off, sizeof(hub_msg) - off,
+ " (HBX v%u, max_links=%u, bridge=%u)",
+ (unsigned)HYBBX_CIRCUIT_VERSION, hub->max_links,
+ hub->bridge.count);
+ hybbx_log_info("%s", hub_msg);
+ }
+
+ if (pthread_create(&hub->accept_thread, NULL, circuit_accept_thread,
+ hub) != 0) {
+ hybbx_circuit_hub_stop(hub);
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_circuit_hub_stop(hybbx_circuit_hub_t *hub)
+{
+ if (hub == NULL) {
+ return;
+ }
+
+ hybbx_broadcast_ax25_seq_cancel();
+ hub->running = 0;
+
+ if (hub->listen_v4 >= 0) {
+ close(hub->listen_v4);
+ hub->listen_v4 = -1;
+ }
+ if (hub->listen_v6 >= 0) {
+ close(hub->listen_v6);
+ hub->listen_v6 = -1;
+ }
+
+ circuit_close_all_slots(hub);
+
+ if (hub->accept_thread) {
+ pthread_join(hub->accept_thread, NULL);
+ hub->accept_thread = (pthread_t)0;
+ }
+}
+
+int hybbx_circuit_hub_running(const hybbx_circuit_hub_t *hub)
+{
+ return hub != NULL && hub->running;
+}
+
+unsigned hybbx_circuit_hub_port(const hybbx_circuit_hub_t *hub)
+{
+ if (hub == NULL) {
+ return HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ return hub->config.port;
+}
+
+void hybbx_circuit_hub_prune_links(hybbx_circuit_hub_t *hub)
+{
+ if (hub != NULL) {
+ (void)hybbx_link_registry_prune(&hub->links);
+ }
+}
+
+void hybbx_circuit_hub_note_rf_activity(hybbx_circuit_hub_t *hub,
+ const char *link_id)
+{
+ time_t now;
+ unsigned i;
+
+ if (hub == NULL || link_id == NULL || link_id[0] == '\0') {
+ return;
+ }
+
+ now = time(NULL);
+
+ pthread_mutex_lock(&hub->lock);
+ for (i = 0; i < HYBBX_CIRCUIT_MAX_LINKS; i++) {
+ hybbx_circuit_link_slot_t *slot = &hub->slots[i];
+
+ if (slot->state != CIRCUIT_SLOT_ACTIVE || slot->fd < 0) {
+ continue;
+ }
+ if (strcmp(slot->link_id, link_id) != 0) {
+ continue;
+ }
+
+ slot->last_rf_activity = now;
+ break;
+ }
+ pthread_mutex_unlock(&hub->lock);
+}
+
+int hybbx_circuit_hub_link_band_idle(const hybbx_circuit_hub_t *hub,
+ unsigned slot_index,
+ unsigned min_idle_sec)
+{
+ const hybbx_circuit_link_slot_t *slot;
+ hybbx_circuit_hub_t *mutable_hub;
+ time_t now;
+ time_t last_rf;
+ time_t idle_since;
+ circuit_slot_state_t state;
+ int fd;
+
+ if (hub == NULL || slot_index >= HYBBX_CIRCUIT_MAX_LINKS ||
+ min_idle_sec == 0) {
+ return 0;
+ }
+
+ mutable_hub = (hybbx_circuit_hub_t *)hub;
+ pthread_mutex_lock(&mutable_hub->lock);
+ slot = &hub->slots[slot_index];
+ state = slot->state;
+ fd = slot->fd;
+ last_rf = slot->last_rf_activity;
+ pthread_mutex_unlock(&mutable_hub->lock);
+
+ if (state != CIRCUIT_SLOT_ACTIVE || fd < 0) {
+ return 0;
+ }
+ if (last_rf == 0) {
+ return 1;
+ }
+
+ now = time(NULL);
+ idle_since = now - last_rf;
+ if (idle_since < 0) {
+ return 0;
+ }
+
+ return (unsigned)idle_since >= min_idle_sec;
+}
+
+time_t hybbx_circuit_hub_link_band_ready_at(const hybbx_circuit_hub_t *hub,
+ unsigned slot_index,
+ unsigned min_idle_sec)
+{
+ hybbx_circuit_hub_t *mutable_hub;
+ const hybbx_circuit_link_slot_t *slot;
+ circuit_slot_state_t state;
+ int fd;
+ time_t last_rf;
+ time_t now;
+
+ if (hub == NULL || slot_index >= HYBBX_CIRCUIT_MAX_LINKS ||
+ min_idle_sec == 0) {
+ return (time_t)-1;
+ }
+
+ mutable_hub = (hybbx_circuit_hub_t *)hub;
+ pthread_mutex_lock(&mutable_hub->lock);
+ slot = &hub->slots[slot_index];
+ state = slot->state;
+ fd = slot->fd;
+ last_rf = slot->last_rf_activity;
+ pthread_mutex_unlock(&mutable_hub->lock);
+
+ if (state != CIRCUIT_SLOT_ACTIVE || fd < 0) {
+ return (time_t)-1;
+ }
+
+ now = time(NULL);
+ if (now == (time_t)-1) {
+ return (time_t)-1;
+ }
+ if (last_rf == 0) {
+ return now;
+ }
+
+ if ((unsigned)(now - last_rf) >= min_idle_sec) {
+ return now;
+ }
+
+ return last_rf + (time_t)min_idle_sec;
+}
+
+static hybbx_result_t circuit_plugin_init(hybbx_service_t *service)
+{
+ (void)service;
+ return HYBBX_OK;
+}
+
+static void circuit_plugin_shutdown(void)
+{
+}
+
+static hybbx_result_t circuit_plugin_start(const char *config)
+{
+ (void)config;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t circuit_plugin_stop(void)
+{
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_circuit = {
+ .name = "circuit",
+ .kind = HYBBX_TRANSPORT_CIRCUIT,
+ .version = 1,
+ .init = circuit_plugin_init,
+ .shutdown = circuit_plugin_shutdown,
+ .start = circuit_plugin_start,
+ .stop = circuit_plugin_stop,
+ .write = circuit_transport_write,
+};
diff --git a/src/core/command.c b/src/core/command.c
new file mode 100644
index 0000000..b60ee35
--- /dev/null
+++ b/src/core/command.c
@@ -0,0 +1,2450 @@
+#include "hybbx/hybbx.h"
+#include "hybbx/command.h"
+#include "hybbx/commands_registry.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/texts.h"
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/chat.h"
+#include "hybbx/conference.h"
+#include "hybbx/mail.h"
+#include "hybbx/proxymail.h"
+#include "hybbx/proxychat.h"
+#include "hybbx/broadcast.h"
+#include "hybbx/security.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/service.h"
+#include "hybbx/password.h"
+#include "hybbx/traffic.h"
+#include "hybbx/monitor.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+static hybbx_result_t cmd_proxymail(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd);
+static hybbx_result_t cmd_proxychat(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd);
+
+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 cmd_deny_privilege(hybbx_session_t *session);
+
+static const char *cmd_help_unknown(hybbx_session_t *session)
+{
+ if (hybbx_session_is_guest(session) ||
+ (hybbx_session_login_prompt(session) &&
+ !hybbx_session_logged_in(session))) {
+ return "Unknown command. Try /help.";
+ }
+
+ return "Unknown command. /help for list.";
+}
+
+static int cmd_verb_allowed_login_prompt(const char *verb)
+{
+ if (verb == NULL || verb[0] == '\0') {
+ return 1;
+ }
+
+ if (hybbx_commands_registry_verb_allowed(HYBBX_LEVEL_GUEST, verb)) {
+ return 1;
+ }
+
+ return str_ieq(verb, "exit") || str_ieq(verb, "logout") ||
+ str_ieq(verb, "bye") || str_ieq(verb, "quit");
+}
+
+static hybbx_result_t cmd_check_access(hybbx_session_t *session,
+ const char *verb)
+{
+ hybbx_user_level_t level = hybbx_session_user_level(session);
+
+ if (!hybbx_session_logged_in(session) &&
+ hybbx_session_login_prompt(session)) {
+ if (cmd_verb_allowed_login_prompt(verb)) {
+ return HYBBX_OK;
+ }
+ hybbx_session_write_line(session,
+ "Log in with /login or use /register.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (hybbx_commands_registry_verb_allowed(level, verb)) {
+ return HYBBX_OK;
+ }
+
+ /* Optional [monitor] allow= grants beyond commands.yaml min level. */
+ if ((str_ieq(verb, "monitor") || str_ieq(verb, "mon")) &&
+ hybbx_monitor_enabled() &&
+ hybbx_monitor_user_allowed(hybbx_session_username(session))) {
+ return HYBBX_OK;
+ }
+
+ if (level == HYBBX_LEVEL_GUEST) {
+ hybbx_session_write_line(session,
+ "Not available to guests. Try /help.");
+ } else if (str_ieq(verb, "register")) {
+ hybbx_session_write_line(session,
+ "Only guests may self-register with /register.");
+ } else if (str_ieq(verb, "changeme")) {
+ hybbx_session_write_line(session,
+ "Only registered users may use /changeme.");
+ } else {
+ hybbx_session_write_line(session, "Insufficient privileges.");
+ }
+
+ return HYBBX_ERR_DENIED;
+}
+
+static int cmd_deleteme_confirmed(const char *arg)
+{
+ return arg != NULL && hybbx_bool_is_true(arg);
+}
+
+static void cmd_registry_usage(hybbx_session_t *session, const char *verb)
+{
+ const char *canonical = hybbx_commands_registry_canonical(verb);
+
+ hybbx_commands_registry_show_help(session, canonical);
+}
+
+static hybbx_result_t cmd_help_topic(hybbx_session_t *session, const char *topic)
+{
+ hybbx_user_level_t level = hybbx_session_user_level(session);
+ const char *canonical;
+ const hybbx_command_def_t *def;
+
+ if (topic == NULL || topic[0] == '\0') {
+ hybbx_commands_registry_show_menu(session);
+ return HYBBX_OK;
+ }
+
+ canonical = hybbx_commands_registry_canonical(topic);
+
+ if (!hybbx_commands_registry_help_allowed(level, canonical)) {
+ /* Allow-list grants may read /help monitor without Sysop min. */
+ if (!((str_ieq(canonical, "monitor") || str_ieq(canonical, "mon")) &&
+ hybbx_monitor_session_may_use(session))) {
+ hybbx_session_write_line(session, cmd_help_unknown(session));
+ return HYBBX_ERR_NOT_FOUND;
+ }
+ }
+
+ def = hybbx_commands_registry_find(canonical);
+ if (def == NULL || def->line1[0] == '\0') {
+ hybbx_session_write_line(session, cmd_help_unknown(session));
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ hybbx_commands_registry_show_help(session, canonical);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_help(hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ if (cmd->argc == 0) {
+ hybbx_commands_registry_show_menu(session);
+ return HYBBX_OK;
+ }
+
+ return cmd_help_topic(session, cmd->argv[0]);
+}
+
+static hybbx_result_t cmd_news(hybbx_service_t *service, hybbx_session_t *session)
+{
+ const hybbx_texts_config_t *texts = hybbx_service_get_texts(service);
+
+ if (texts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_texts_send_file(texts, session, HYBBX_TEXT_NEWS) != HYBBX_OK) {
+ hybbx_session_write_line(session, "(no news available)");
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_banner(hybbx_service_t *service, hybbx_session_t *session)
+{
+ const hybbx_texts_config_t *texts = hybbx_service_get_texts(service);
+
+ if (texts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ (void)hybbx_texts_send_banner(texts, session, HYBBX_VERSION_STRING,
+ hybbx_service_get_name(service));
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_motd(hybbx_service_t *service, hybbx_session_t *session)
+{
+ const hybbx_texts_config_t *texts = hybbx_service_get_texts(service);
+
+ if (texts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_texts_send_motd(texts, session) != HYBBX_OK) {
+ hybbx_session_write_line(session, "(no motd available)");
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_rules(hybbx_service_t *service, hybbx_session_t *session)
+{
+ const hybbx_texts_config_t *texts = hybbx_service_get_texts(service);
+
+ if (texts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_texts_send_file(texts, session, HYBBX_TEXT_RULES) != HYBBX_OK) {
+ hybbx_session_write_line(session, "(no rules available)");
+ }
+
+ return HYBBX_OK;
+}
+
+static const char *who_transport_label(const char *transport)
+{
+ if (transport == NULL || transport[0] == '\0') {
+ return "unknown";
+ }
+
+ if (str_ieq(transport, "packet_radio")) {
+ return "ax25";
+ }
+ if (str_ieq(transport, "telnet")) {
+ return "telnet";
+ }
+ if (str_ieq(transport, "circuit")) {
+ return "circuit";
+ }
+ if (str_ieq(transport, "ssh")) {
+ return "ssh";
+ }
+ if (str_ieq(transport, "websocket")) {
+ return "websocket";
+ }
+
+ return transport;
+}
+
+typedef struct who_ctx {
+ hybbx_session_t *requester;
+ unsigned count;
+ int at_plugin;
+} who_ctx_t;
+
+static void who_list_visitor(hybbx_session_t *session, void *userdata)
+{
+ who_ctx_t *ctx = (who_ctx_t *)userdata;
+ const hybbx_session_record_t *rec;
+ const char *name;
+ const char *transport;
+ char line[96];
+ char user_at[80];
+
+ if (ctx == NULL || session == NULL || !hybbx_session_logged_in(session)) {
+ return;
+ }
+
+ if (!hybbx_session_is_interactive_user(session)) {
+ return;
+ }
+
+ if (hybbx_session_hidden_from_who(session)) {
+ return;
+ }
+
+ if (ctx->at_plugin) {
+ if (hybbx_session_format_user_at_plugin(session, user_at,
+ sizeof(user_at)) != HYBBX_OK) {
+ return;
+ }
+ snprintf(line, sizeof(line), " %s", user_at);
+ } else {
+ name = hybbx_session_display_name(session);
+ rec = hybbx_session_record(session);
+ transport = who_transport_label(rec != NULL ? rec->transport : NULL);
+ if (rec == NULL || rec->transport[0] == '\0') {
+ transport = who_transport_label(session->transport != NULL ?
+ session->transport->name : NULL);
+ }
+ snprintf(line, sizeof(line), " %-16s %s", name, transport);
+ }
+
+ hybbx_session_write_line(ctx->requester, line);
+ ctx->count++;
+}
+
+static hybbx_result_t cmd_who(hybbx_service_t *service, hybbx_session_t *session)
+{
+ who_ctx_t ctx;
+ char total[32];
+
+ ctx.requester = session;
+ ctx.count = 0;
+ ctx.at_plugin = hybbx_service_login_announce(service);
+
+ hybbx_session_write_line(session, "Online users:");
+ hybbx_service_visit_sessions(service, who_list_visitor, &ctx);
+
+ if (ctx.count == 0) {
+ hybbx_session_write_line(session, " (none)");
+ }
+
+ snprintf(total, sizeof(total), "Total: %u", ctx.count);
+ hybbx_session_write_line(session, total);
+ return HYBBX_OK;
+}
+
+typedef struct users_stats_ctx {
+ size_t by_level[6];
+ size_t total;
+} users_stats_ctx_t;
+
+static hybbx_result_t users_stats_cb(const hybbx_user_record_t *user, void *ctx)
+{
+ users_stats_ctx_t *stats = (users_stats_ctx_t *)ctx;
+
+ if (user == NULL || stats == NULL) {
+ return HYBBX_OK;
+ }
+
+ if (user->level < HYBBX_LEVEL_SYSOP || user->level > HYBBX_LEVEL_USER) {
+ return HYBBX_OK;
+ }
+
+ stats->by_level[user->level]++;
+ stats->total++;
+ return HYBBX_OK;
+}
+
+static void users_compute_percents(const size_t *counts, size_t total,
+ unsigned *percents)
+{
+ hybbx_user_level_t level;
+ hybbx_user_level_t adjust_level = HYBBX_LEVEL_SYSOP;
+ unsigned sum = 0;
+ unsigned max_remainder = 0;
+
+ if (total == 0) {
+ return;
+ }
+
+ for (level = HYBBX_LEVEL_SYSOP; level <= HYBBX_LEVEL_USER; level++) {
+ unsigned scaled = (unsigned)((counts[level] * 1000u) / total);
+ unsigned rem = scaled % 10u;
+
+ percents[level] = scaled / 10u;
+ sum += percents[level];
+ if (rem > max_remainder) {
+ max_remainder = rem;
+ adjust_level = level;
+ }
+ }
+
+ if (sum < 100u) {
+ percents[adjust_level] += 100u - sum;
+ }
+}
+
+static hybbx_result_t cmd_users(hybbx_service_t *service, hybbx_session_t *session)
+{
+ hybbx_storage_t *storage;
+ users_stats_ctx_t stats;
+ unsigned percents[6];
+ hybbx_user_level_t level;
+ char line[48];
+ char total[40];
+ hybbx_result_t rc;
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ hybbx_session_write_line(session, "User storage unavailable.");
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(&stats, 0, sizeof(stats));
+ memset(percents, 0, sizeof(percents));
+
+ rc = hybbx_storage_foreach_user(storage, users_stats_cb, &stats);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not read user database.");
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Registered users:");
+
+ if (stats.total == 0) {
+ hybbx_session_write_line(session, " (none)");
+ } else {
+ users_compute_percents(stats.by_level, stats.total, percents);
+ for (level = HYBBX_LEVEL_SYSOP; level <= HYBBX_LEVEL_USER; level++) {
+ if (stats.by_level[level] == 0) {
+ continue;
+ }
+
+ snprintf(line, sizeof(line), " %-8s %3u%%",
+ hybbx_user_level_name(level), percents[level]);
+ hybbx_session_write_line(session, line);
+ }
+ }
+
+ snprintf(total, sizeof(total), "Total users: %zu", stats.total);
+ hybbx_session_write_line(session, total);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_session(hybbx_session_t *session)
+{
+ char buf[128];
+ const hybbx_session_record_t *rec = hybbx_session_record(session);
+
+ if (rec == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ snprintf(buf, sizeof(buf), "User: %s", hybbx_session_display_name(session));
+ hybbx_session_write_line(session, buf);
+ snprintf(buf, sizeof(buf), "Level: %s",
+ hybbx_user_level_name(hybbx_session_user_level(session)));
+ hybbx_session_write_line(session, buf);
+ snprintf(buf, sizeof(buf), "Session: %llu",
+ (unsigned long long)rec->session_id);
+ hybbx_session_write_line(session, buf);
+ snprintf(buf, sizeof(buf), "Transport: %s", rec->transport);
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_profile_fields(const hybbx_parsed_command_t *cmd,
+ unsigned name_start,
+ unsigned tail_extra,
+ hybbx_user_registration_t *reg)
+{
+ size_t i;
+ size_t pos = 0;
+ unsigned suffix;
+
+ if (cmd == NULL || reg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ suffix = 3u + tail_extra;
+ if (cmd->argc < name_start + suffix) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(reg->email, cmd->argv[cmd->argc - 1u - tail_extra],
+ sizeof(reg->email));
+ hybbx_strlcpy(reg->location, cmd->argv[cmd->argc - 2u - tail_extra],
+ sizeof(reg->location));
+ hybbx_strlcpy(reg->country, cmd->argv[cmd->argc - 3u - tail_extra],
+ sizeof(reg->country));
+
+ reg->full_name[0] = '\0';
+ for (i = name_start; i + suffix < cmd->argc; i++) {
+ size_t part_len;
+ const char *part = cmd->argv[i];
+
+ if (part == NULL) {
+ continue;
+ }
+
+ part_len = strlen(part);
+ if (pos > 0) {
+ if (pos + 1 >= sizeof(reg->full_name)) {
+ return HYBBX_ERR_INVALID;
+ }
+ reg->full_name[pos++] = ' ';
+ }
+
+ if (pos + part_len >= sizeof(reg->full_name)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(reg->full_name + pos, part, part_len);
+ pos += part_len;
+ }
+
+ reg->full_name[pos] = '\0';
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_registration(const hybbx_parsed_command_t *cmd,
+ hybbx_user_registration_t *reg,
+ int with_password)
+{
+ hybbx_result_t rc;
+ unsigned min_args = with_password ? 6u : 5u;
+ unsigned tail_extra = with_password ? 1u : 0u;
+
+ if (cmd == NULL || reg == NULL || cmd->argc < min_args) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(reg, 0, sizeof(*reg));
+ hybbx_strlcpy(reg->nickname, cmd->argv[0], sizeof(reg->nickname));
+ hybbx_strlcpy(reg->username, cmd->argv[0], sizeof(reg->username));
+ hybbx_username_normalize(reg->username);
+
+ if (with_password) {
+ hybbx_strlcpy(reg->password, cmd->argv[cmd->argc - 1],
+ sizeof(reg->password));
+ }
+
+ rc = parse_profile_fields(cmd, 1, tail_extra, reg);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_changeme(const hybbx_parsed_command_t *cmd,
+ const char *username,
+ hybbx_user_registration_t *reg,
+ const char **old_password,
+ const char **new_password)
+{
+ hybbx_result_t rc;
+
+ if (cmd == NULL || username == NULL || reg == NULL ||
+ old_password == NULL || new_password == NULL || cmd->argc < 6) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(reg, 0, sizeof(*reg));
+ hybbx_strlcpy(reg->username, username, sizeof(reg->username));
+ hybbx_username_normalize(reg->username);
+ *old_password = cmd->argv[0];
+ *new_password = cmd->argv[1];
+
+ rc = parse_profile_fields(cmd, 2, 0, reg);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_userchange(const hybbx_parsed_command_t *cmd,
+ hybbx_user_registration_t *reg,
+ const char **new_password)
+{
+ hybbx_result_t rc;
+
+ if (cmd == NULL || reg == NULL || new_password == NULL || cmd->argc < 7) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(reg, 0, sizeof(*reg));
+ hybbx_strlcpy(reg->username, cmd->argv[0], sizeof(reg->username));
+ hybbx_username_normalize(reg->username);
+ *new_password = cmd->argv[1];
+
+ rc = parse_profile_fields(cmd, 2, 0, reg);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_lookup_user(hybbx_service_t *service,
+ const char *username,
+ hybbx_user_record_t *out)
+{
+ hybbx_storage_t *storage;
+ hybbx_result_t rc;
+
+ if (service == NULL || username == NULL || out == NULL ||
+ username[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_resolve_user(storage, username, out);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ return rc;
+}
+
+static void cmd_deny_privilege(hybbx_session_t *session)
+{
+ hybbx_session_write_line(session, "Insufficient privileges.");
+}
+
+static hybbx_result_t cmd_activate(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_user_record_t target;
+ hybbx_user_level_t actor_level;
+ char buf[128];
+ hybbx_result_t rc;
+
+ if (cmd->argc < 1 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0') {
+ hybbx_session_write_line(session, "Usage: /activate <username>");
+ return HYBBX_OK;
+ }
+
+ actor_level = hybbx_session_user_level(session);
+ if (!hybbx_commands_registry_verb_allowed(actor_level, "activate")) {
+ cmd_deny_privilege(session);
+ return HYBBX_ERR_DENIED;
+ }
+
+ rc = cmd_lookup_user(service, cmd->argv[0], &target);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (target.level != HYBBX_LEVEL_USER) {
+ hybbx_session_write_line(session,
+ "Only pending registered user accounts can be activated.");
+ return HYBBX_OK;
+ }
+
+ if (target.active) {
+ hybbx_session_write_line(session, "Account is already active.");
+ return HYBBX_OK;
+ }
+
+ target.active = 1;
+ rc = hybbx_storage_update_user(hybbx_service_get_storage(service), &target);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Activated '%s'.",
+ hybbx_user_display_name(&target));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_promote(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_user_record_t target;
+ hybbx_user_level_t actor_level;
+ hybbx_user_level_t new_level;
+ char buf[128];
+ hybbx_result_t rc;
+
+ if (cmd->argc < 2 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0' ||
+ cmd->argv[1] == NULL || cmd->argv[1][0] == '\0') {
+ hybbx_session_write_line(session,
+ "Usage: /promote <username> admin|mod");
+ return HYBBX_OK;
+ }
+
+ actor_level = hybbx_session_user_level(session);
+ new_level = hybbx_user_level_parse(cmd->argv[1]);
+ if (new_level != HYBBX_LEVEL_ADMIN && new_level != HYBBX_LEVEL_MOD) {
+ hybbx_session_write_line(session,
+ "Level must be admin or mod.");
+ return HYBBX_OK;
+ }
+
+ rc = cmd_lookup_user(service, cmd->argv[0], &target);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (!hybbx_commands_registry_may_promote(actor_level, target.level, target.active,
+ new_level)) {
+ if (actor_level == HYBBX_LEVEL_MOD || actor_level == HYBBX_LEVEL_USER) {
+ cmd_deny_privilege(session);
+ } else if (new_level == HYBBX_LEVEL_ADMIN) {
+ hybbx_session_write_line(session,
+ "Only the Sysop may add Admins.");
+ } else {
+ hybbx_session_write_line(session,
+ "Only Sysop or Admin may add Mods.");
+ }
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (target.level == new_level) {
+ snprintf(buf, sizeof(buf), "'%s' is already %s.",
+ hybbx_user_display_name(&target),
+ hybbx_user_level_name(new_level));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+ }
+
+ target.level = new_level;
+ rc = hybbx_storage_update_user(hybbx_service_get_storage(service), &target);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Promoted '%s' to %s.",
+ hybbx_user_display_name(&target),
+ hybbx_user_level_name(new_level));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_demote(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_user_record_t target;
+ hybbx_user_level_t actor_level;
+ hybbx_user_level_t old_level;
+ char buf[128];
+ hybbx_result_t rc;
+
+ if (cmd->argc < 1 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0') {
+ hybbx_session_write_line(session, "Usage: /demote <username>");
+ return HYBBX_OK;
+ }
+
+ actor_level = hybbx_session_user_level(session);
+
+ rc = cmd_lookup_user(service, cmd->argv[0], &target);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (str_ieq(target.username, hybbx_session_username(session))) {
+ hybbx_session_write_line(session, "You cannot demote your own account.");
+ return HYBBX_OK;
+ }
+
+ old_level = target.level;
+ if (!hybbx_commands_registry_may_demote(actor_level, target.level)) {
+ if (actor_level == HYBBX_LEVEL_MOD || actor_level == HYBBX_LEVEL_USER) {
+ cmd_deny_privilege(session);
+ } else if (target.level == HYBBX_LEVEL_ADMIN) {
+ hybbx_session_write_line(session,
+ "Only the Sysop may remove Admins.");
+ } else if (target.level == HYBBX_LEVEL_MOD) {
+ hybbx_session_write_line(session,
+ "Only Sysop or Admin may remove Mods.");
+ } else {
+ hybbx_session_write_line(session,
+ "That account is not an Admin or Mod.");
+ }
+ return HYBBX_ERR_DENIED;
+ }
+
+ target.level = HYBBX_LEVEL_USER;
+ rc = hybbx_storage_update_user(hybbx_service_get_storage(service), &target);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Demoted '%s' from %s to user.",
+ hybbx_user_display_name(&target),
+ hybbx_user_level_name(old_level));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_delete(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_user_record_t target;
+ hybbx_user_level_t actor_level;
+ char buf[128];
+ hybbx_result_t rc;
+
+ if (cmd->argc < 1 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0') {
+ hybbx_session_write_line(session, "Usage: /delete <username>");
+ return HYBBX_OK;
+ }
+
+ actor_level = hybbx_session_user_level(session);
+
+ rc = cmd_lookup_user(service, cmd->argv[0], &target);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (str_ieq(target.username, hybbx_session_username(session))) {
+ hybbx_session_write_line(session, "You cannot delete your own account.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_user_level_is_sysop(target.level)) {
+ hybbx_session_write_line(session,
+ "The Sysop account is permanent and cannot be deleted.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_commands_registry_may_delete(actor_level, target.level)) {
+ if (actor_level == HYBBX_LEVEL_MOD || actor_level == HYBBX_LEVEL_USER) {
+ cmd_deny_privilege(session);
+ } else if (target.level == HYBBX_LEVEL_ADMIN) {
+ hybbx_session_write_line(session,
+ "Admins cannot delete other Admins.");
+ } else {
+ cmd_deny_privilege(session);
+ }
+ return HYBBX_ERR_DENIED;
+ }
+
+ rc = hybbx_storage_delete_user(hybbx_service_get_storage(service), target.id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Deleted account '%s'.",
+ hybbx_user_display_name(&target));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_deleteme(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ const hybbx_session_record_t *rec;
+ hybbx_user_level_t level;
+ const char *arg;
+ hybbx_result_t rc;
+
+ (void)service;
+
+ level = hybbx_session_user_level(session);
+ if (hybbx_user_level_is_sysop(level)) {
+ hybbx_session_write_line(session,
+ "The Sysop account is permanent and cannot be deleted.");
+ return HYBBX_OK;
+ }
+
+ arg = cmd->argc > 0 ? cmd->argv[0] : NULL;
+ if (arg != NULL && hybbx_bool_is_false(arg)) {
+ hybbx_session_write_line(session, "Account deletion cancelled.");
+ return HYBBX_OK;
+ }
+
+ if (!cmd_deleteme_confirmed(arg)) {
+ hybbx_session_write_line(session,
+ "Usage: /deleteme yes|no");
+ return HYBBX_OK;
+ }
+
+ rec = hybbx_session_record(session);
+ if (rec == NULL || rec->user_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_delete_user(hybbx_service_get_storage(service),
+ rec->user_id);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Account not found.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Account deleted. Goodbye.");
+ return HYBBX_SESSION_END;
+}
+
+static hybbx_result_t cmd_register_user(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd,
+ int staff_created)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_registration_t reg;
+ hybbx_user_record_t user;
+ char buf[HYBBX_USER_FULL_NAME_MAX + 64];
+ hybbx_result_t rc;
+
+ if (staff_created) {
+ if (cmd->argc < 5) {
+ hybbx_session_write_line(session,
+ "Usage: /usercreate <username> <full-name> <country> <location> <email>");
+ return HYBBX_OK;
+ }
+ } else if (cmd->argc < 6) {
+ hybbx_session_write_line(session,
+ "Usage: /register <username> <full-name> <country> <location> <email> <password>");
+ return HYBBX_OK;
+ }
+
+ rc = parse_registration(cmd, &reg, staff_created ? 0 : 1);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Registration fields too long.");
+ return HYBBX_OK;
+ }
+
+ if (!staff_created && !hybbx_password_plain_valid(reg.password)) {
+ hybbx_session_write_line(session,
+ "Password must be 8-24 characters (- not allowed).");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_register_user(storage, &reg, &user);
+ if (rc == HYBBX_ERR_BUSY) {
+ hybbx_session_write_line(session, "Username or nickname already taken.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_INVALID) {
+ hybbx_session_write_line(session,
+ "Invalid username (4-12 chars, one _ or one -, max 4 digits) or bad profile.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (staff_created) {
+ snprintf(buf, sizeof(buf), "User '%s' created.",
+ hybbx_user_display_name(&user));
+ } else {
+ snprintf(buf, sizeof(buf), "Registration received for '%s'.",
+ hybbx_user_display_name(&user));
+ }
+ hybbx_session_write_line(session, buf);
+ snprintf(buf, sizeof(buf), "Name: %s", user.full_name);
+ hybbx_session_write_line(session, buf);
+ if (staff_created) {
+ hybbx_session_write_line(session,
+ "Account is inactive. Use /activate before the user can log in.");
+ } else {
+ const hybbx_mail_config_t *mail_cfg = hybbx_service_get_mail(service);
+
+ (void)hybbx_mail_notify_staff_registration(service, &reg, &user);
+ hybbx_session_write_line(session,
+ "A Sysop or Admin must activate your account before you can log in.");
+ if (mail_cfg != NULL && mail_cfg->enabled) {
+ hybbx_session_write_line(session,
+ "Sysop and Admin were notified by mail.");
+ }
+ }
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_register(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ if (!hybbx_commands_registry_verb_allowed(hybbx_session_user_level(session),
+ "register") &&
+ !(hybbx_session_login_prompt(session) &&
+ !hybbx_session_logged_in(session))) {
+ hybbx_session_write_line(session,
+ "Only guests or login-prompt sessions may self-register with /register.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ return cmd_register_user(service, session, cmd, 0);
+}
+
+static hybbx_result_t cmd_createuser(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ return cmd_register_user(service, session, cmd, 1);
+}
+
+static hybbx_result_t cmd_changeme(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_registration_t reg;
+ hybbx_user_record_t user;
+ const char *old_password;
+ const char *new_password;
+ const char *username;
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session,
+ "Only registered users may use /changeme.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc < 6) {
+ hybbx_session_write_line(session,
+ "Usage: /changeme <oldpass> <newpass> <full-name> <country> <location> <email>");
+ return HYBBX_OK;
+ }
+
+ username = hybbx_session_username(session);
+ if (username == NULL || username[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = parse_changeme(cmd, username, &reg, &old_password, &new_password);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Profile fields too long.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_password_plain_valid(new_password)) {
+ hybbx_session_write_line(session,
+ "New password must be 8-24 characters (- not allowed).");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_find_user(storage, reg.username, &user);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Account not found.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ {
+ const hybbx_session_record_t *rec = hybbx_session_record(session);
+
+ if (rec == NULL || user.id != rec->user_id) {
+ hybbx_session_write_line(session,
+ "You can only change your own account.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_user_profile_valid(&reg)) {
+ hybbx_session_write_line(session,
+ "Invalid profile (check name, country, location, email).");
+ return HYBBX_OK;
+ }
+ }
+
+ if (user.password[0] == '\0') {
+ hybbx_session_write_line(session,
+ "Account has no password. Ask Sysop or Admin to set one.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_password_match(user.password, old_password)) {
+ hybbx_session_write_line(session, "Old password incorrect.");
+ return HYBBX_OK;
+ }
+
+ hybbx_strlcpy(user.full_name, reg.full_name, sizeof(user.full_name));
+ hybbx_strlcpy(user.country, reg.country, sizeof(user.country));
+ hybbx_strlcpy(user.location, reg.location, sizeof(user.location));
+ hybbx_strlcpy(user.email, reg.email, sizeof(user.email));
+
+ rc = hybbx_password_hash(new_password, user.password, sizeof(user.password));
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not store new password.");
+ return rc;
+ }
+
+ rc = hybbx_storage_update_user(storage, &user);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Account updated.");
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_userchange(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_registration_t reg;
+ hybbx_user_record_t user;
+ const char *new_password;
+ hybbx_user_level_t actor_level;
+ hybbx_result_t rc;
+ char buf[160];
+
+ actor_level = hybbx_session_user_level(session);
+ if (!hybbx_user_level_is_sysop_or_admin(actor_level)) {
+ cmd_deny_privilege(session);
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc < 7) {
+ hybbx_session_write_line(session,
+ "Usage: /changeuser <user> <newpass> <full-name> <country> <location> <email>");
+ return HYBBX_OK;
+ }
+
+ rc = parse_userchange(cmd, &reg, &new_password);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Profile fields too long.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_password_plain_valid(new_password)) {
+ hybbx_session_write_line(session,
+ "New password must be 8-24 characters (- not allowed).");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_user_profile_valid(&reg)) {
+ hybbx_session_write_line(session,
+ "Invalid profile (check name, country, location, email).");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_find_user(storage, reg.username, &user);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (str_ieq(user.username, hybbx_session_username(session))) {
+ hybbx_session_write_line(session,
+ "Use /changeme to update your own account.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_user_level_is_sysop(user.level)) {
+ hybbx_session_write_line(session,
+ "The Sysop account cannot be changed with /changeuser.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_commands_registry_may_userchange(actor_level, user.level)) {
+ if (actor_level == HYBBX_LEVEL_ADMIN &&
+ user.level == HYBBX_LEVEL_ADMIN) {
+ hybbx_session_write_line(session,
+ "Admins cannot change other Admins. Sysop only.");
+ } else {
+ cmd_deny_privilege(session);
+ }
+ return HYBBX_ERR_DENIED;
+ }
+
+ hybbx_strlcpy(user.full_name, reg.full_name, sizeof(user.full_name));
+ hybbx_strlcpy(user.country, reg.country, sizeof(user.country));
+ hybbx_strlcpy(user.location, reg.location, sizeof(user.location));
+ hybbx_strlcpy(user.email, reg.email, sizeof(user.email));
+
+ rc = hybbx_password_hash(new_password, user.password, sizeof(user.password));
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not store new password.");
+ return rc;
+ }
+
+ rc = hybbx_storage_update_user(storage, &user);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Updated account '%s'.",
+ hybbx_user_display_name(&user));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_userdelete(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_user_record_t target;
+ hybbx_user_level_t actor_level;
+ char buf[128];
+ hybbx_result_t rc;
+
+ actor_level = hybbx_session_user_level(session);
+ if (!hybbx_user_level_is_sysop(actor_level)) {
+ cmd_deny_privilege(session);
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc < 1 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0') {
+ hybbx_session_write_line(session, "Usage: /deleteuser <username>");
+ return HYBBX_OK;
+ }
+
+ rc = cmd_lookup_user(service, cmd->argv[0], &target);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (str_ieq(target.username, hybbx_session_username(session))) {
+ hybbx_session_write_line(session, "You cannot delete your own account.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_commands_registry_may_userdelete(actor_level, target.level)) {
+ hybbx_session_write_line(session,
+ "The Sysop account is permanent and cannot be deleted.");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_storage_delete_user(hybbx_service_get_storage(service), target.id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(buf, sizeof(buf), "Deleted account '%s'.",
+ hybbx_user_display_name(&target));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_login(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_record_t user;
+ const hybbx_auth_config_t *auth;
+ const char *guest_prefix;
+ char username[HYBBX_USER_NAME_MAX];
+ unsigned guest_slot;
+ hybbx_result_t rc;
+
+ if (cmd->argc < 1 || cmd->argv[0] == NULL || cmd->argv[0][0] == '\0') {
+ hybbx_session_write_line(session,
+ "Usage: /login <username> <password>");
+ return HYBBX_OK;
+ }
+
+ auth = hybbx_service_get_auth(service);
+ guest_prefix = auth != NULL ? auth->guest_prefix : HYBBX_AUTH_DEFAULT_GUEST_PREFIX;
+
+ hybbx_strlcpy(username, cmd->argv[0], sizeof(username));
+ hybbx_username_normalize(username);
+
+ if (hybbx_guest_slot_from_username(guest_prefix, username, &guest_slot)) {
+ if (auth != NULL && auth->auto_login) {
+ hybbx_session_write_line(session,
+ "Guest access is automatic on connect (auto_login). "
+ "/login is for registered accounts only.");
+ } else {
+ hybbx_session_write_line(session,
+ "/login is for registered accounts only.");
+ }
+ return HYBBX_OK;
+ }
+
+ if (cmd->argc < 2 || cmd->argv[1] == NULL || cmd->argv[1][0] == '\0') {
+ hybbx_session_write_line(session,
+ "Usage: /login <username> <password>");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_find_user(storage, username, &user);
+
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (hybbx_user_level_is_guest(user.level)) {
+ hybbx_session_write_line(session,
+ "Guests are ephemeral and use auto-login on connect.");
+ return HYBBX_OK;
+ }
+
+ if (!user.active) {
+ hybbx_session_write_line(session,
+ "Account pending activation by Sysop or Admin.");
+ return HYBBX_OK;
+ }
+
+ if (user.password[0] == '\0') {
+ hybbx_session_write_line(session,
+ "Account has no password. Contact Sysop or Admin.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_password_match(user.password, cmd->argv[1])) {
+ const hybbx_session_record_t *rec = hybbx_session_record(session);
+
+ hybbx_security_log_write("login_fail ip=%s user=%s transport=%s",
+ rec != NULL && rec->remote[0] != '\0' ?
+ rec->remote : "?",
+ username,
+ rec != NULL && rec->transport[0] != '\0' ?
+ rec->transport : "?");
+ if (rec != NULL && rec->remote[0] != '\0') {
+ hybbx_security_ban_login_fail(rec->remote, rec->transport);
+ }
+ hybbx_session_write_line(session, "Invalid password.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_service_find_registered_session(service, user.id, session) !=
+ NULL) {
+ hybbx_session_write_line(session,
+ "That account is already logged in elsewhere.");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_session_switch_user(session, &user);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Login failed.");
+ return rc;
+ }
+
+ {
+ time_t since_login = user.last_login_at;
+ const hybbx_texts_config_t *texts;
+
+ texts = hybbx_service_get_texts(service);
+ if (texts != NULL) {
+ (void)hybbx_texts_send_motd(texts, session);
+ }
+
+ hybbx_mail_announce_since_last_login(service, session, since_login);
+
+ user.last_login_at = time(NULL);
+ (void)hybbx_storage_update_user(storage, &user);
+ }
+ hybbx_session_show_prompt(session);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_chat(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ const hybbx_chat_config_t *chat;
+ unsigned channel_index;
+ unsigned current;
+ const char *name;
+ char line[HYBBX_CHAT_CHANNEL_NAME_MAX + 48];
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use chat.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc > 0 && str_ieq(cmd->argv[0], "proxychat")) {
+ hybbx_parsed_command_t proxy_cmd;
+ char proxy_verb[] = "proxychat";
+
+ memset(&proxy_cmd, 0, sizeof(proxy_cmd));
+ proxy_cmd.verb = proxy_verb;
+ return cmd_proxychat(service, session, &proxy_cmd);
+ }
+
+ chat = hybbx_service_get_chat(service);
+ if (chat == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cmd->argc == 0) {
+ hybbx_chat_list_channels(session, chat);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "show")) {
+ if (cmd->argc != 1) {
+ hybbx_session_write_line(session, "Usage: /chat show");
+ return HYBBX_OK;
+ }
+ hybbx_chat_show_channel(service, session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "showall")) {
+ if (cmd->argc != 1) {
+ hybbx_session_write_line(session, "Usage: /chat showall");
+ return HYBBX_OK;
+ }
+ hybbx_chat_show_all(service, session);
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_chat_resolve_channel(chat, cmd->argv[0], &channel_index);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown chat channel.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ current = hybbx_session_chat_channel(session);
+ if (current == channel_index) {
+ snprintf(line, sizeof(line), "Already in channel %u %s.",
+ channel_index,
+ hybbx_chat_channel_name(chat, channel_index));
+ hybbx_session_write_line(session, line);
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_session_join_chat_channel(session, channel_index);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ name = hybbx_chat_channel_name(chat, channel_index);
+ snprintf(line, sizeof(line), "Channel %u: %s", channel_index, name);
+ hybbx_session_write_line(session, line);
+ hybbx_session_write_line(session,
+ "Each line is a message; /leave or /main to exit.");
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_conference(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ char topic[HYBBX_CONFERENCE_TOPIC_MAX];
+ const char *user;
+ size_t i;
+ size_t pos = 0;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use conference.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc < 2) {
+ hybbx_session_write_line(session,
+ "Usage: /conference <topic> <user>");
+ return HYBBX_OK;
+ }
+
+ user = cmd->argv[cmd->argc - 1];
+ topic[0] = '\0';
+
+ for (i = 0; i + 1 < cmd->argc; i++) {
+ size_t part_len = strlen(cmd->argv[i]);
+
+ if (i > 0) {
+ if (pos + 1 >= sizeof(topic)) {
+ hybbx_session_write_line(session, "Topic too long.");
+ return HYBBX_OK;
+ }
+ topic[pos++] = ' ';
+ topic[pos] = '\0';
+ }
+
+ if (pos + part_len >= sizeof(topic)) {
+ hybbx_session_write_line(session, "Topic too long.");
+ return HYBBX_OK;
+ }
+
+ memcpy(topic + pos, cmd->argv[i], part_len);
+ pos += part_len;
+ topic[pos] = '\0';
+ }
+
+ return hybbx_conference_start(service, session, topic, user);
+}
+
+static void mail_join_subject(const hybbx_parsed_command_t *cmd,
+ char *out, size_t out_len)
+{
+ size_t i;
+ size_t pos = 0;
+
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (cmd == NULL || cmd->argc < 3) {
+ return;
+ }
+
+ for (i = 2; i < cmd->argc; i++) {
+ size_t part_len;
+
+ if (cmd->argv[i] == NULL) {
+ continue;
+ }
+
+ if (pos > 0) {
+ if (pos + 1 >= out_len) {
+ break;
+ }
+ out[pos++] = ' ';
+ out[pos] = '\0';
+ }
+
+ part_len = strlen(cmd->argv[i]);
+ if (pos + part_len >= out_len) {
+ part_len = out_len - pos - 1;
+ }
+ memcpy(out + pos, cmd->argv[i], part_len);
+ pos += part_len;
+ out[pos] = '\0';
+ }
+}
+
+static hybbx_result_t cmd_proxymail(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ char subject[HYBBX_MAIL_SUBJECT_MAX + 1];
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use proxymail.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc == 0) {
+ (void)hybbx_session_enter_proxymail(session);
+ hybbx_proxymail_list_inbox(service, session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "list")) {
+ unsigned from = 1;
+ unsigned to = 0;
+
+ if (cmd->argc >= 2) {
+ if (!hybbx_mail_parse_list_range(cmd->argv[1], &from, &to)) {
+ hybbx_session_write_line(session,
+ "Usage: /proxymail list [<from>-<to>]");
+ return HYBBX_OK;
+ }
+ }
+
+ (void)hybbx_session_enter_proxymail(session);
+ hybbx_proxymail_list_inbox_range(service, session, from, to);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "delete") || str_ieq(cmd->argv[0], "del")) {
+ unsigned from = 1;
+ unsigned to = 0;
+
+ if (cmd->argc < 2 || cmd->argv[1] == NULL) {
+ hybbx_session_write_line(session,
+ "Usage: /proxymail delete <n|from-to>");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_mail_parse_list_range(cmd->argv[1], &from, &to)) {
+ hybbx_session_write_line(session,
+ "Usage: /proxymail delete <n|from-to>");
+ return HYBBX_OK;
+ }
+
+ (void)hybbx_session_enter_proxymail(session);
+ return hybbx_proxymail_delete_range(service, session, from, to);
+ }
+
+ if (str_ieq(cmd->argv[0], "recycle")) {
+ (void)hybbx_session_enter_proxymail(session);
+ return hybbx_proxymail_recycle_empty(service, session);
+ }
+
+ if (str_ieq(cmd->argv[0], "read")) {
+ unsigned index;
+
+ if (cmd->argc < 2 || cmd->argv[1] == NULL) {
+ hybbx_session_write_line(session, "Usage: /proxymail read <n>");
+ return HYBBX_OK;
+ }
+
+ index = (unsigned)strtoul(cmd->argv[1], NULL, 10);
+ if (index == 0) {
+ hybbx_session_write_line(session, "Usage: /proxymail read <n>");
+ return HYBBX_OK;
+ }
+
+ (void)hybbx_session_enter_proxymail(session);
+ return hybbx_proxymail_read(service, session, index);
+ }
+
+ if (str_ieq(cmd->argv[0], "send")) {
+ if (cmd->argc < 3) {
+ hybbx_session_write_line(session,
+ "Usage: /proxymail send <user>@<main> <subject>");
+ return HYBBX_OK;
+ }
+
+ mail_join_subject(cmd, subject, sizeof(subject));
+ if (subject[0] == '\0') {
+ hybbx_session_write_line(session,
+ "Usage: /proxymail send <user>@<main> <subject>");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_session_proxymail_compose_start(session, cmd->argv[1],
+ subject);
+ if (rc == HYBBX_ERR_INVALID) {
+ hybbx_session_write_line(session,
+ "Invalid address or subject. Use user@remote-main.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_session_write_line(session,
+ "Compose body. /proxymail done to send.");
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "done")) {
+ const char *body;
+ const char *to_address;
+ const char *mail_subject;
+
+ if (!hybbx_session_proxymail_composing(session)) {
+ hybbx_session_write_line(session, "Not composing proxymail.");
+ return HYBBX_OK;
+ }
+
+ to_address = hybbx_session_proxymail_compose_to(session);
+ mail_subject = hybbx_session_proxymail_compose_subject(session);
+ body = hybbx_session_proxymail_compose_body(session);
+
+ rc = hybbx_proxymail_deliver(service, hybbx_session_display_name(session),
+ to_address, mail_subject, body);
+
+ if (rc == HYBBX_ERR_INVALID) {
+ hybbx_session_write_line(session, "Invalid address — use user@mainname.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_UNSUPPORTED) {
+ hybbx_session_write_line(session,
+ "Proxymail is not available — enable mains_proxy and peer links.");
+ hybbx_session_proxymail_compose_cancel(session);
+ hybbx_session_leave_area(session);
+ hybbx_session_show_prompt(session);
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session,
+ "Unknown remote main — check user@mainname and peer_id.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Send failed.");
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Proxymail sent.");
+ hybbx_session_proxymail_compose_cancel(session);
+ hybbx_session_leave_area(session);
+ hybbx_session_show_prompt(session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "cancel")) {
+ if (hybbx_session_proxymail_composing(session)) {
+ hybbx_session_proxymail_compose_cancel(session);
+ hybbx_session_leave_area(session);
+ hybbx_session_write_line(session, "Compose cancelled.");
+ } else {
+ hybbx_session_write_line(session, "Not composing proxymail.");
+ }
+ return HYBBX_OK;
+ }
+
+ hybbx_session_write_line(session,
+ "Unknown /proxymail subcommand. Try /help proxymail.");
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_proxychat(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ hybbx_result_t rc;
+
+ (void)service;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use proxychat.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc > 0) {
+ hybbx_session_write_line(session,
+ "Usage: /proxychat — chat with users on other mains.");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_session_enter_proxychat(session);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_proxychat_show_banner(session);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_mail(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ char subject[HYBBX_MAIL_SUBJECT_MAX + 1];
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use mail.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (cmd->argc > 0 && str_ieq(cmd->argv[0], "proxymail")) {
+ hybbx_parsed_command_t proxy_cmd;
+ char proxy_verb[] = "proxymail";
+ unsigned i;
+
+ memset(&proxy_cmd, 0, sizeof(proxy_cmd));
+ proxy_cmd.verb = proxy_verb;
+ proxy_cmd.argc = cmd->argc > 0 ? cmd->argc - 1 : 0;
+ for (i = 0; i < proxy_cmd.argc && i < HYBBX_CMD_TOKEN_MAX; i++) {
+ proxy_cmd.argv[i] = cmd->argv[i + 1];
+ }
+ return cmd_proxymail(service, session, &proxy_cmd);
+ }
+
+ if (cmd->argc == 0) {
+ hybbx_mail_list_inbox(service, session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "list")) {
+ unsigned from = 1;
+ unsigned to = 0;
+
+ if (cmd->argc >= 2) {
+ if (!hybbx_mail_parse_list_range(cmd->argv[1], &from, &to)) {
+ hybbx_session_write_line(session,
+ "Usage: /mail list [<from>-<to>] e.g. list 1-15 list 5-20");
+ return HYBBX_OK;
+ }
+ }
+
+ hybbx_mail_list_inbox_range(service, session, from, to);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "delete") || str_ieq(cmd->argv[0], "del")) {
+ unsigned from = 1;
+ unsigned to = 0;
+
+ if (cmd->argc < 2 || cmd->argv[1] == NULL) {
+ hybbx_session_write_line(session,
+ "Usage: /mail delete <n|from-to> e.g. delete 3 delete 5-20");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_mail_parse_list_range(cmd->argv[1], &from, &to)) {
+ hybbx_session_write_line(session,
+ "Usage: /mail delete <n|from-to> e.g. delete 3 delete 5-20");
+ return HYBBX_OK;
+ }
+
+ return hybbx_mail_delete_range(service, session, from, to);
+ }
+
+ if (str_ieq(cmd->argv[0], "recycle")) {
+ return hybbx_mail_recycle_empty(service, session);
+ }
+
+ if (str_ieq(cmd->argv[0], "read")) {
+ unsigned index;
+
+ if (cmd->argc < 2 || cmd->argv[1] == NULL) {
+ hybbx_session_write_line(session, "Usage: /mail read <n>");
+ return HYBBX_OK;
+ }
+
+ index = (unsigned)strtoul(cmd->argv[1], NULL, 10);
+ if (index == 0) {
+ hybbx_session_write_line(session, "Usage: /mail read <n>");
+ return HYBBX_OK;
+ }
+
+ return hybbx_mail_read(service, session, index);
+ }
+
+ if (str_ieq(cmd->argv[0], "send")) {
+ if (cmd->argc < 3) {
+ hybbx_session_write_line(session,
+ "Usage: /mail send <user> <subject>");
+ return HYBBX_OK;
+ }
+
+ mail_join_subject(cmd, subject, sizeof(subject));
+ if (subject[0] == '\0') {
+ hybbx_session_write_line(session,
+ "Usage: /mail send <user> <subject>");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_session_mail_compose_start(session, cmd->argv[1], subject);
+ if (rc == HYBBX_ERR_INVALID) {
+ hybbx_session_write_line(session, "Subject too long.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Compose body. /mail done to send.");
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "done")) {
+ const char *body;
+ const char *to_user;
+ const char *mail_subject;
+
+ if (!hybbx_session_mail_composing(session)) {
+ hybbx_session_write_line(session, "Not composing mail.");
+ return HYBBX_OK;
+ }
+
+ to_user = hybbx_session_mail_compose_to(session);
+ mail_subject = hybbx_session_mail_compose_subject(session);
+ body = hybbx_session_mail_compose_body(session);
+
+ rc = hybbx_mail_deliver(service, hybbx_session_display_name(session),
+ to_user, mail_subject, body);
+
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "Unknown recipient.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_DENIED) {
+ hybbx_session_write_line(session, "Cannot mail that user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Send failed.");
+ return rc;
+ }
+
+ hybbx_session_write_line(session, "Message sent.");
+ hybbx_session_leave_area(session);
+ hybbx_session_show_prompt(session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "cancel")) {
+ if (hybbx_session_mail_composing(session)) {
+ hybbx_session_leave_area(session);
+ hybbx_session_write_line(session, "Compose cancelled.");
+ } else {
+ hybbx_session_write_line(session, "Not composing mail.");
+ }
+ return HYBBX_OK;
+ }
+
+ hybbx_session_write_line(session, "Unknown /mail subcommand. Try /help mail.");
+ return HYBBX_OK;
+}
+
+static void cmd_emit_current_area(hybbx_session_t *session)
+{
+ const char *name = hybbx_session_area_name(hybbx_session_area(session));
+ char line[64];
+
+ snprintf(line, sizeof(line), "%c%s.",
+ (char)toupper((unsigned char)name[0]), name + 1);
+ hybbx_session_write_line(session, line);
+ hybbx_session_show_prompt(session);
+}
+
+static hybbx_result_t cmd_leave(hybbx_session_t *session)
+{
+ hybbx_session_area_t before = hybbx_session_area(session);
+
+ hybbx_session_leave_area(session);
+ if (hybbx_session_area(session) != before) {
+ cmd_emit_current_area(session);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_main(hybbx_session_t *session)
+{
+ hybbx_session_area_t before = hybbx_session_area(session);
+
+ hybbx_session_go_main(session);
+ if (before != HYBBX_AREA_MAIN) {
+ cmd_emit_current_area(session);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_exit(hybbx_session_t *session)
+{
+ hybbx_session_write_line(session, "Goodbye.");
+ return HYBBX_SESSION_END;
+}
+
+static hybbx_result_t cmd_broadcast(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ char message[HYBBX_BROADCAST_MESSAGE_MAX + 1];
+ size_t off = 0;
+ int i;
+ hybbx_result_t rc;
+
+ if (cmd->argc < 1) {
+ cmd_registry_usage(session, "broadcast");
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(cmd->argv[0], "ax25")) {
+ if (cmd->argc > 1) {
+ hybbx_session_write_line(session,
+ "RF beacon uses ax25_auto_message from INI. Use: /broadcast ax25");
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_broadcast_ax25_manual(service);
+ if (rc == HYBBX_ERR_UNSUPPORTED) {
+ hybbx_session_write_line(session, "AX.25 broadcast is disabled.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_DENIED) {
+ hybbx_session_write_line(session,
+ "No qualifying packet-radio links for AX.25 broadcast.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_BUSY) {
+ hybbx_session_write_line(session,
+ "AX.25 broadcast deferred (link busy).");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "AX.25 broadcast failed.");
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_session_write_line(session, "AX.25 broadcast sent.");
+ return HYBBX_OK;
+ }
+
+ message[0] = '\0';
+ for (i = 0; i < (int)cmd->argc; i++) {
+ size_t part_len = strlen(cmd->argv[i]);
+
+ if (off > 0 && off < sizeof(message) - 1) {
+ message[off++] = ' ';
+ }
+ if (off + part_len >= sizeof(message)) {
+ part_len = sizeof(message) - off - 1;
+ }
+ if (part_len > 0) {
+ memcpy(message + off, cmd->argv[i], part_len);
+ off += part_len;
+ }
+ }
+ message[off] = '\0';
+
+ if (message[0] == '\0') {
+ hybbx_session_write_line(session, "Missing announce message.");
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_broadcast_announce(service, session, message);
+ if (rc == HYBBX_ERR_UNSUPPORTED) {
+ hybbx_session_write_line(session, "Broadcast is disabled.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_INVALID) {
+ hybbx_session_write_line(session, "Message too long.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Broadcast failed.");
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_session_write_line(session, "Broadcast sent.");
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_shutdown(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ if (!hybbx_user_level_is_sysop(hybbx_session_user_level(session))) {
+ cmd_deny_privilege(session);
+ return HYBBX_ERR_DENIED;
+ }
+
+ hybbx_security_log_write("shutdown ip=%s user=%s transport=%s",
+ hybbx_session_record(session) != NULL &&
+ hybbx_session_record(session)->remote[0] != '\0' ?
+ hybbx_session_record(session)->remote : "?",
+ hybbx_session_display_name(session),
+ hybbx_session_record(session) != NULL ?
+ hybbx_session_record(session)->transport : "?");
+ hybbx_session_write_line(session, "Shutting down HyBBX.");
+ hybbx_service_request_shutdown(service, 0);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_restart(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ if (!hybbx_user_level_is_sysop(hybbx_session_user_level(session))) {
+ cmd_deny_privilege(session);
+ return HYBBX_ERR_DENIED;
+ }
+
+ hybbx_security_log_write("restart ip=%s user=%s transport=%s",
+ hybbx_session_record(session) != NULL &&
+ hybbx_session_record(session)->remote[0] != '\0' ?
+ hybbx_session_record(session)->remote : "?",
+ hybbx_session_display_name(session),
+ hybbx_session_record(session) != NULL ?
+ hybbx_session_record(session)->transport : "?");
+ hybbx_session_write_line(session, "Restarting HyBBX.");
+ hybbx_service_request_shutdown(service, 1);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cmd_version(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ const hybbx_texts_config_t *texts = hybbx_service_get_texts(service);
+
+ if (texts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_texts_send_version(texts, session);
+}
+
+static hybbx_result_t cmd_clear(hybbx_session_t *session)
+{
+ return hybbx_session_clear_terminal(session);
+}
+
+static hybbx_result_t cmd_echo(hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ char buf[48];
+
+ if (cmd->argc < 2) {
+ snprintf(buf, sizeof(buf), "Input echo is %s.",
+ hybbx_bool_to_string(hybbx_session_input_echo(session)));
+ hybbx_session_write_line(session, buf);
+ return HYBBX_OK;
+ }
+
+ if (hybbx_bool_is_true(cmd->argv[1])) {
+ return hybbx_session_set_input_echo(session, 1);
+ }
+
+ if (hybbx_bool_is_false(cmd->argv[1])) {
+ return hybbx_session_set_input_echo(session, 0);
+ }
+
+ hybbx_session_write_line(session, "Usage: /echo yes|no");
+ return HYBBX_ERR_INVALID;
+}
+
+typedef struct monitor_list_ctx {
+ hybbx_session_t *requester;
+ unsigned count;
+} monitor_list_ctx_t;
+
+static void monitor_list_visitor(hybbx_session_t *session, void *userdata)
+{
+ monitor_list_ctx_t *ctx = (monitor_list_ctx_t *)userdata;
+ const hybbx_session_record_t *rec;
+ const char *user;
+ const char *plugin;
+ char line[96];
+
+ if (ctx == NULL || session == NULL || !hybbx_session_logged_in(session)) {
+ return;
+ }
+
+ if (!hybbx_session_is_interactive_user(session)) {
+ return;
+ }
+
+ if (hybbx_session_hidden_from_who(session)) {
+ return;
+ }
+
+ user = hybbx_session_display_name(session);
+ rec = hybbx_session_record(session);
+ plugin = (rec != NULL && rec->transport[0] != '\0')
+ ? rec->transport
+ : (session->transport != NULL ? session->transport->name : "?");
+ snprintf(line, sizeof(line), " %s@%s", user, plugin);
+ hybbx_session_write_line(ctx->requester, line);
+ ctx->count++;
+}
+
+static void monitor_show_cmds(hybbx_session_t *session)
+{
+ hybbx_session_write_line(session, "Monitor admin commands:");
+ hybbx_session_write_line(session,
+ " /monitor meet <user> [force] — conference invite (20s) or force");
+ hybbx_session_write_line(session,
+ "Run any of these as normal /verb (stay invisible while Sysop hidden):");
+ hybbx_commands_registry_show_sysop_cmds(session);
+}
+
+static hybbx_result_t cmd_monitor(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ const char *sub;
+ monitor_list_ctx_t list_ctx;
+ const hybbx_monitor_config_t *mcfg;
+ char line[128];
+
+ if (!hybbx_monitor_enabled()) {
+ hybbx_session_write_line(session, "Monitor is disabled ([monitor] enabled=no).");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (!hybbx_monitor_session_may_use(session) &&
+ !hybbx_commands_registry_verb_allowed(
+ hybbx_session_user_level(session), "monitor")) {
+ hybbx_session_write_line(session, "Monitor access denied.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ sub = (cmd->argc >= 1) ? cmd->argv[0] : "list";
+
+ if (str_ieq(sub, "on")) {
+ return hybbx_monitor_set_active(session, 1);
+ }
+
+ if (str_ieq(sub, "off")) {
+ return hybbx_monitor_set_active(session, 0);
+ }
+
+ if (str_ieq(sub, "cmd") || str_ieq(sub, "cmds") || str_ieq(sub, "commands")) {
+ monitor_show_cmds(session);
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(sub, "meet")) {
+ int force = 0;
+ const char *user;
+
+ if (cmd->argc < 2) {
+ hybbx_session_write_line(session,
+ "Usage: /monitor meet <user> [force]");
+ return HYBBX_ERR_INVALID;
+ }
+ user = cmd->argv[1];
+ if (cmd->argc >= 3 && str_ieq(cmd->argv[2], "force")) {
+ force = 1;
+ }
+ if (!hybbx_monitor_is_active(session)) {
+ hybbx_session_write_line(session,
+ "Turn monitor on first: /monitor on");
+ return HYBBX_ERR_DENIED;
+ }
+ return hybbx_conference_monitor_meet(service, session, user, force);
+ }
+
+ if (!str_ieq(sub, "list") && cmd->argc >= 1) {
+ hybbx_session_write_line(session,
+ "Usage: /monitor on|off|list|cmds|meet <user> [force]");
+ return HYBBX_ERR_INVALID;
+ }
+
+ mcfg = hybbx_monitor_config_get();
+ snprintf(line, sizeof(line), "Monitor: %s",
+ hybbx_monitor_is_active(session) ? "on" : "off");
+ hybbx_session_write_line(session, line);
+ if (mcfg != NULL) {
+ snprintf(line, sizeof(line),
+ "Follow: hybbx=%s security=%s invisible-sysop=%s "
+ "invite_timeout=%us",
+ mcfg->follow_hybbx ? "yes" : "no",
+ mcfg->follow_security ? "yes" : "no",
+ mcfg->invisible_sysop ? "yes" : "no",
+ mcfg->invite_timeout_sec);
+ hybbx_session_write_line(session, line);
+ }
+ if (hybbx_session_hidden_from_who(session)) {
+ hybbx_session_write_line(session, "You are hidden from /who.");
+ }
+ hybbx_session_write_line(session, "Online (visible):");
+ list_ctx.requester = session;
+ list_ctx.count = 0;
+ hybbx_service_visit_sessions(service, monitor_list_visitor, &list_ctx);
+ if (list_ctx.count == 0) {
+ hybbx_session_write_line(session, " (none)");
+ }
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_command_dispatch(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_parsed_command_t *cmd)
+{
+ if (cmd == NULL || service == NULL || session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cmd->scope != HYBBX_CMD_SCOPE_HYBBX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ (void)hybbx_session_command_gap(session);
+
+ if (cmd->verb == NULL || cmd->verb[0] == '\0') {
+ return cmd_help(session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "help") || str_ieq(cmd->verb, "?")) {
+ return cmd_help(session, cmd);
+ }
+
+ {
+ hybbx_result_t access = cmd_check_access(session, cmd->verb);
+ if (access != HYBBX_OK) {
+ return access;
+ }
+ }
+
+ if (str_ieq(cmd->verb, "news")) {
+ return cmd_news(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "banner") || str_ieq(cmd->verb, "loginmsg")) {
+ return cmd_banner(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "motd")) {
+ return cmd_motd(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "rules") || str_ieq(cmd->verb, "legal")) {
+ return cmd_rules(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "who") || str_ieq(cmd->verb, "online")) {
+ return cmd_who(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "monitor") || str_ieq(cmd->verb, "mon")) {
+ return cmd_monitor(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "users")) {
+ return cmd_users(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "session") || str_ieq(cmd->verb, "info")) {
+ return cmd_session(session);
+ }
+
+ if (str_ieq(cmd->verb, "version") || str_ieq(cmd->verb, "ver")) {
+ return cmd_version(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "leave") || str_ieq(cmd->verb, "back")) {
+ return cmd_leave(session);
+ }
+
+ if (str_ieq(cmd->verb, "main")) {
+ return cmd_main(session);
+ }
+
+ if (str_ieq(cmd->verb, "menu")) {
+ if (cmd->argc == 0) {
+ hybbx_commands_registry_show_menu(session);
+ return HYBBX_OK;
+ }
+ return cmd_help_topic(session, cmd->argv[0]);
+ }
+
+ if (str_ieq(cmd->verb, "index")) {
+ if (cmd->argc == 0) {
+ hybbx_commands_registry_show_index(session);
+ return HYBBX_OK;
+ }
+ return cmd_help_topic(session, cmd->argv[0]);
+ }
+
+ if (str_ieq(cmd->verb, "alias")) {
+ if (cmd->argc == 0) {
+ hybbx_commands_registry_show_aliases(session);
+ return HYBBX_OK;
+ }
+ return cmd_help_topic(session, cmd->argv[0]);
+ }
+
+ if (str_ieq(cmd->verb, "clear") || str_ieq(cmd->verb, "cls") ||
+ str_ieq(cmd->verb, "reset")) {
+ return cmd_clear(session);
+ }
+
+ if (str_ieq(cmd->verb, "echo")) {
+ return cmd_echo(session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "chat")) {
+ return cmd_chat(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "conference") || str_ieq(cmd->verb, "meeting")) {
+ return cmd_conference(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "mail")) {
+ return cmd_mail(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "proxymail")) {
+ return cmd_proxymail(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "proxychat")) {
+ return cmd_proxychat(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "login")) {
+ return cmd_login(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "register")) {
+ return cmd_register(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "changeme")) {
+ return cmd_changeme(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "changeuser") || str_ieq(cmd->verb, "userchange")) {
+ return cmd_userchange(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "deleteuser") || str_ieq(cmd->verb, "userdelete")) {
+ return cmd_userdelete(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "usercreate") || str_ieq(cmd->verb, "createuser")) {
+ return cmd_createuser(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "activate")) {
+ return cmd_activate(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "promote")) {
+ return cmd_promote(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "demote")) {
+ return cmd_demote(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "delete") || str_ieq(cmd->verb, "del")) {
+ return cmd_delete(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "deleteme")) {
+ return cmd_deleteme(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "broadcast") || str_ieq(cmd->verb, "announce")) {
+ return cmd_broadcast(service, session, cmd);
+ }
+
+ if (str_ieq(cmd->verb, "shutdown")) {
+ return cmd_shutdown(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "restart")) {
+ return cmd_restart(service, session);
+ }
+
+ if (str_ieq(cmd->verb, "exit") || str_ieq(cmd->verb, "logout") ||
+ str_ieq(cmd->verb, "bye") || str_ieq(cmd->verb, "quit")) {
+ return cmd_exit(session);
+ }
+
+ hybbx_session_write_line(session, cmd_help_unknown(session));
+ return HYBBX_ERR_NOT_FOUND;
+}
diff --git a/src/core/command_parse.c b/src/core/command_parse.c
new file mode 100644
index 0000000..f96bf6c
--- /dev/null
+++ b/src/core/command_parse.c
@@ -0,0 +1,347 @@
+#include "hybbx/command.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+
+#include <ctype.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;
+ if (!hybbx_size_ok(len)) {
+ return NULL;
+ }
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static const char *skip_leading_space(const char *line)
+{
+ if (line == NULL) {
+ return NULL;
+ }
+
+ while (*line != '\0' && isspace((unsigned char)*line)) {
+ line++;
+ }
+
+ return line;
+}
+
+static char *ltrim_copy(char *s)
+{
+ char *start;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ start = (char *)skip_leading_space(s);
+ if (start != s) {
+ memmove(s, start, strlen(start) + 1);
+ }
+
+ return s;
+}
+
+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 int strip_one_command_alias(char *rest, const char *alias)
+{
+ size_t alias_len;
+
+ if (alias == NULL || rest == NULL) {
+ return -1;
+ }
+
+ alias_len = strlen(alias);
+
+ if (str_ieq(rest, alias)) {
+ return 1;
+ }
+
+ if (strncmp(rest, alias, alias_len) == 0 &&
+ isspace((unsigned char)rest[alias_len])) {
+ memmove(rest, rest + alias_len, strlen(rest + alias_len) + 1);
+ ltrim_copy(rest);
+ return rest[0] == '\0' ? 1 : 0;
+ }
+
+ return -1;
+}
+
+static int strip_command_alias(char *rest)
+{
+ static const char *aliases[] = { "commands", "command", "cmd", NULL };
+ int rc;
+ size_t i;
+
+ if (rest == NULL) {
+ return 0;
+ }
+
+ if (rest[0] == '\0') {
+ return 1;
+ }
+
+ for (i = 0; aliases[i] != NULL; i++) {
+ rc = strip_one_command_alias(rest, aliases[i]);
+ if (rc >= 0) {
+ return rc;
+ }
+ }
+
+ return 0;
+}
+
+hybbx_command_scope_t hybbx_command_classify(const char *line)
+{
+ const char *p = skip_leading_space(line);
+
+ if (p == NULL || *p == '\0') {
+ return HYBBX_CMD_SCOPE_LOCAL;
+ }
+
+ if (*p == '/') {
+ return HYBBX_CMD_SCOPE_HYBBX;
+ }
+
+ if (*p == ';' || *p == '#') {
+ return HYBBX_CMD_SCOPE_COMMENT;
+ }
+
+ return HYBBX_CMD_SCOPE_LOCAL;
+}
+
+int hybbx_command_is_comment(const char *line)
+{
+ return hybbx_command_classify(line) == HYBBX_CMD_SCOPE_COMMENT;
+}
+
+int hybbx_command_is_hybbx(const char *line)
+{
+ return hybbx_command_classify(line) == HYBBX_CMD_SCOPE_HYBBX;
+}
+
+static size_t count_tokens(const char *line)
+{
+ size_t count = 0;
+ size_t i = 0;
+ int in_token = 0;
+
+ while (line[i] != '\0') {
+ if (!isspace((unsigned char)line[i])) {
+ if (!in_token) {
+ count++;
+ in_token = 1;
+ }
+ } else {
+ in_token = 0;
+ }
+ i++;
+ }
+
+ return count;
+}
+
+static hybbx_result_t split_tokens(char *line, char **tokens, size_t max)
+{
+ size_t count = 0;
+ size_t i = 0;
+
+ while (line[i] != '\0' && count < max) {
+ while (line[i] != '\0' && isspace((unsigned char)line[i])) {
+ i++;
+ }
+ if (line[i] == '\0') {
+ break;
+ }
+ tokens[count++] = line + i;
+ while (line[i] != '\0' && !isspace((unsigned char)line[i])) {
+ i++;
+ }
+ if (line[i] != '\0') {
+ line[i] = '\0';
+ i++;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_hybbx_line(const char *line, hybbx_parsed_command_t *out)
+{
+ char *rest;
+ char *work;
+ size_t token_count;
+ char **tokens;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (line[0] != '/') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rest = hybbx_strdup(line + 1);
+ if (rest == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ ltrim_copy(rest);
+
+ if (strip_command_alias(rest)) {
+ out->verb = hybbx_strdup("help");
+ if (out->verb == NULL) {
+ free(rest);
+ return HYBBX_ERR_NOMEM;
+ }
+ free(rest);
+ return HYBBX_OK;
+ }
+
+ token_count = count_tokens(rest);
+ if (token_count == 0) {
+ out->verb = hybbx_strdup("help");
+ free(rest);
+ if (out->verb == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ return HYBBX_OK;
+ }
+
+ if (token_count > HYBBX_CMD_TOKEN_MAX) {
+ free(rest);
+ return HYBBX_ERR_INVALID;
+ }
+
+ work = hybbx_strdup(rest);
+ free(rest);
+ if (work == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ tokens = calloc(token_count, sizeof(*tokens));
+ if (tokens == NULL) {
+ free(work);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = split_tokens(work, tokens, token_count);
+ if (rc != HYBBX_OK) {
+ free(tokens);
+ free(work);
+ return rc;
+ }
+
+ out->verb = hybbx_strdup(tokens[0]);
+ if (out->verb == NULL) {
+ free(tokens);
+ free(work);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ out->argc = token_count > 1 ? token_count - 1 : 0;
+ if (out->argc > 0) {
+ out->argv = calloc(out->argc, sizeof(*out->argv));
+ if (out->argv == NULL) {
+ free(tokens);
+ free(work);
+ hybbx_command_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ for (i = 0; i < out->argc; i++) {
+ out->argv[i] = hybbx_strdup(tokens[i + 1]);
+ if (out->argv[i] == NULL) {
+ free(tokens);
+ free(work);
+ hybbx_command_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+ }
+
+ free(tokens);
+ free(work);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_command_parse(const char *line,
+ hybbx_parsed_command_t *out)
+{
+ char *trimmed;
+ hybbx_result_t rc;
+
+ if (line == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ trimmed = hybbx_strdup(skip_leading_space(line));
+ if (trimmed == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ out->scope = hybbx_command_classify(trimmed);
+ out->line = trimmed;
+
+ if (out->scope != HYBBX_CMD_SCOPE_HYBBX) {
+ return HYBBX_OK;
+ }
+
+ rc = parse_hybbx_line(trimmed, out);
+ if (rc != HYBBX_OK) {
+ hybbx_command_free(out);
+ }
+
+ return rc;
+}
+
+void hybbx_command_free(hybbx_parsed_command_t *cmd)
+{
+ size_t i;
+
+ if (cmd == NULL) {
+ return;
+ }
+
+ free(cmd->line);
+ free(cmd->verb);
+
+ for (i = 0; i < cmd->argc; i++) {
+ free(cmd->argv[i]);
+ }
+ free(cmd->argv);
+
+ memset(cmd, 0, sizeof(*cmd));
+}
diff --git a/src/core/commands_registry.c b/src/core/commands_registry.c
new file mode 100644
index 0000000..6f63178
--- /dev/null
+++ b/src/core/commands_registry.c
@@ -0,0 +1,1429 @@
+#include "hybbx/commands_registry.h"
+#include "hybbx/session.h"
+#include "hybbx/monitor.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+
+typedef struct menu_subarea {
+ char verbs[HYBBX_COMMANDS_VERBS_PER_GROUP][HYBBX_CMD_VERB_MAX];
+ unsigned verb_count;
+} menu_subarea_t;
+
+typedef struct menu_area {
+ char label[16];
+ char verbs[HYBBX_COMMANDS_VERBS_PER_GROUP][HYBBX_CMD_VERB_MAX];
+ unsigned verb_count;
+ menu_subarea_t subareas[HYBBX_AREAS_SUB_MAX];
+ unsigned subarea_count;
+} menu_area_t;
+
+typedef struct alias_entry {
+ char canonical[HYBBX_CMD_VERB_MAX];
+ char aliases[HYBBX_COMMANDS_ALIAS_PER][HYBBX_CMD_VERB_MAX];
+ unsigned alias_count;
+} alias_entry_t;
+
+typedef struct target_right_rule {
+ hybbx_user_level_t actor;
+ hybbx_user_level_t targets[5];
+ unsigned target_count;
+} target_right_rule_t;
+
+typedef struct promote_right_rule {
+ hybbx_user_level_t actor;
+ hybbx_user_level_t to_level;
+ hybbx_user_level_t from_levels[4];
+ unsigned from_count;
+} promote_right_rule_t;
+
+typedef struct demote_right_rule {
+ hybbx_user_level_t actor;
+ hybbx_user_level_t from_levels[4];
+ unsigned from_count;
+} demote_right_rule_t;
+
+typedef struct commands_registry {
+ int loaded;
+ char menu_header[HYBBX_COMMANDS_HEADER_MAX];
+ char index_header[HYBBX_COMMANDS_HEADER_MAX];
+ char alias_header[HYBBX_COMMANDS_HEADER_MAX];
+ menu_area_t areas[HYBBX_AREAS_MAX];
+ unsigned area_count;
+ char menu_levels[HYBBX_MENU_LEVELS_MAX][16];
+ char menu_area_labels[HYBBX_MENU_LEVELS_MAX]
+ [HYBBX_MENU_AREAS_PER_LEVEL][16];
+ unsigned menu_area_count[HYBBX_MENU_LEVELS_MAX];
+ unsigned menu_level_count;
+ char index_labels[HYBBX_MENU_AREAS_PER_LEVEL][16];
+ unsigned index_label_count;
+ target_right_rule_t userchange_rules[HYBBX_RIGHTS_TARGET_RULES_MAX];
+ unsigned userchange_rule_count;
+ target_right_rule_t userdelete_rules[HYBBX_RIGHTS_TARGET_RULES_MAX];
+ unsigned userdelete_rule_count;
+ target_right_rule_t delete_rules[HYBBX_RIGHTS_TARGET_RULES_MAX];
+ unsigned delete_rule_count;
+ promote_right_rule_t promote_rules[HYBBX_RIGHTS_PROMOTE_RULES_MAX];
+ unsigned promote_rule_count;
+ demote_right_rule_t demote_rules[HYBBX_RIGHTS_DEMOTE_RULES_MAX];
+ unsigned demote_rule_count;
+ alias_entry_t aliases[HYBBX_COMMANDS_ALIASES_MAX];
+ unsigned alias_count;
+ hybbx_command_def_t commands[HYBBX_COMMANDS_MAX];
+ unsigned command_count;
+ char alias_lines[HYBBX_COMMANDS_ALIAS_LINES][HYBBX_LINE_MAX];
+ unsigned alias_line_count;
+} commands_registry_t;
+
+static commands_registry_t g_registry;
+
+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 trim_inplace(char *s)
+{
+ char *start = s;
+ char *end;
+
+ if (s == NULL) {
+ return;
+ }
+
+ while (*start != '\0' && isspace((unsigned char)*start)) {
+ start++;
+ }
+
+ if (start != s) {
+ memmove(s, start, strlen(start) + 1);
+ }
+
+ end = s + strlen(s);
+ while (end > s && isspace((unsigned char)end[-1])) {
+ end--;
+ }
+ *end = '\0';
+}
+
+static void strip_quotes(char *s)
+{
+ size_t len;
+
+ if (s == NULL) {
+ return;
+ }
+
+ trim_inplace(s);
+ len = strlen(s);
+ if (len >= 2 &&
+ ((s[0] == '"' && s[len - 1] == '"') ||
+ (s[0] == '\'' && s[len - 1] == '\''))) {
+ s[len - 1] = '\0';
+ memmove(s, s + 1, len - 1);
+ }
+}
+
+static unsigned line_indent(const char *line)
+{
+ unsigned n = 0;
+
+ while (line[n] == ' ') {
+ n++;
+ }
+
+ return n;
+}
+
+static const char *line_key(const char *line)
+{
+ if (line == NULL) {
+ return "";
+ }
+
+ while (*line == ' ') {
+ line++;
+ }
+
+ return line;
+}
+
+static int parse_bracket_list(const char *value, char out[][HYBBX_CMD_VERB_MAX],
+ unsigned max, unsigned *count)
+{
+ const char *p;
+ char token[HYBBX_CMD_VERB_MAX];
+ size_t tlen;
+
+ if (value == NULL || count == NULL) {
+ return 0;
+ }
+
+ *count = 0;
+ p = strchr(value, '[');
+ if (p == NULL) {
+ return 0;
+ }
+ p++;
+
+ while (*p != '\0' && *p != ']') {
+ while (*p == ' ' || *p == ',') {
+ p++;
+ }
+ if (*p == ']' || *p == '\0') {
+ break;
+ }
+
+ tlen = 0;
+ while (*p != '\0' && *p != ',' && *p != ']' &&
+ !isspace((unsigned char)*p) && tlen + 1 < sizeof(token)) {
+ token[tlen++] = *p++;
+ }
+ token[tlen] = '\0';
+ if (token[0] == '\0') {
+ continue;
+ }
+
+ if (*count >= max) {
+ return -1;
+ }
+
+ hybbx_strlcpy(out[*count], token, HYBBX_CMD_VERB_MAX);
+ (*count)++;
+ }
+
+ return 0;
+}
+
+static int parse_level_list(const char *value, hybbx_user_level_t *out,
+ unsigned max, unsigned *count)
+{
+ char tokens[8][HYBBX_CMD_VERB_MAX];
+ unsigned n = 0;
+ unsigned i;
+
+ if (value == NULL || count == NULL) {
+ return 0;
+ }
+
+ if (parse_bracket_list(value, tokens, max, &n) != 0) {
+ return -1;
+ }
+
+ *count = 0;
+ for (i = 0; i < n && *count < max; i++) {
+ out[*count] = hybbx_user_level_parse(tokens[i]);
+ (*count)++;
+ }
+
+ return 0;
+}
+
+static hybbx_user_level_t level_from_name(const char *name)
+{
+ return hybbx_user_level_parse(name);
+}
+
+static const char *menu_level_name(hybbx_user_level_t level)
+{
+ switch (level) {
+ case HYBBX_LEVEL_SYSOP:
+ return "Sysop";
+ case HYBBX_LEVEL_ADMIN:
+ return "Admin";
+ case HYBBX_LEVEL_MOD:
+ return "Mod";
+ case HYBBX_LEVEL_USER:
+ return "User";
+ default:
+ return "Guest";
+ }
+}
+
+static const menu_area_t *area_find(const char *label)
+{
+ unsigned i;
+
+ if (label == NULL) {
+ return NULL;
+ }
+
+ for (i = 0; i < g_registry.area_count; i++) {
+ if (str_ieq(g_registry.areas[i].label, label)) {
+ return &g_registry.areas[i];
+ }
+ }
+
+ return NULL;
+}
+
+static int level_in_list(hybbx_user_level_t level,
+ const hybbx_user_level_t *list,
+ unsigned count)
+{
+ unsigned i;
+
+ for (i = 0; i < count; i++) {
+ if (list[i] == level) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static int command_level_allowed(const hybbx_command_def_t *def,
+ hybbx_user_level_t level)
+{
+ if (def == NULL) {
+ return 0;
+ }
+
+ if (def->only_level != 0 && level != (hybbx_user_level_t)def->only_level) {
+ return 0;
+ }
+
+ if (level > def->min_level) {
+ return 0;
+ }
+
+ if (def->max_level != 0 && level < def->max_level) {
+ return 0;
+ }
+
+ return 1;
+}
+
+/** Non-zero when @p session may see @p verb in menu/index/help (level or grant). */
+static int command_verb_visible(const hybbx_session_t *session,
+ hybbx_user_level_t level,
+ const char *verb)
+{
+ if (hybbx_commands_registry_verb_allowed(level, verb)) {
+ return 1;
+ }
+
+ /* [monitor] allow= grants may see /monitor without Sysop min. */
+ if (session != NULL &&
+ (str_ieq(verb, "monitor") || str_ieq(verb, "mon")) &&
+ hybbx_monitor_session_may_use(session)) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static int target_right_allowed(const target_right_rule_t *rules,
+ unsigned rule_count,
+ hybbx_user_level_t actor,
+ hybbx_user_level_t target)
+{
+ unsigned i;
+
+ if (hybbx_user_level_is_sysop(target) ||
+ hybbx_user_level_is_guest(target)) {
+ return 0;
+ }
+
+ for (i = 0; i < rule_count; i++) {
+ if (rules[i].actor == actor &&
+ level_in_list(target, rules[i].targets, rules[i].target_count)) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static hybbx_result_t parse_areas_yaml(commands_registry_t *reg, const char *path)
+{
+ FILE *fp;
+ char buf[HYBBX_CONFIG_LINE_MAX];
+ enum {
+ SEC_NONE,
+ SEC_META,
+ SEC_AREAS,
+ SEC_MENU,
+ SEC_INDEX
+ } section = SEC_NONE;
+ menu_area_t *area = NULL;
+ menu_subarea_t *subarea = NULL;
+ char cur_menu_level[16];
+
+ if (reg == NULL || path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cur_menu_level[0] = '\0';
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ while (fgets(buf, sizeof(buf), fp) != NULL) {
+ char *line = buf;
+ char *colon;
+ unsigned indent;
+
+ indent = line_indent(line);
+ trim_inplace(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+
+ if (indent == 0 && line[strlen(line) - 1] == ':') {
+ line[strlen(line) - 1] = '\0';
+ area = NULL;
+ subarea = NULL;
+ cur_menu_level[0] = '\0';
+
+ if (str_ieq(line, "meta")) {
+ section = SEC_META;
+ } else if (str_ieq(line, "areas")) {
+ section = SEC_AREAS;
+ } else if (str_ieq(line, "menu")) {
+ section = SEC_MENU;
+ } else if (str_ieq(line, "index")) {
+ section = SEC_INDEX;
+ } else {
+ section = SEC_NONE;
+ }
+ continue;
+ }
+
+ if (section == SEC_META && indent >= 2) {
+ colon = strchr(line, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ *colon = '\0';
+ strip_quotes(colon + 1);
+ if (str_ieq(line_key(line), "menu_header")) {
+ hybbx_strlcpy(reg->menu_header, colon + 1,
+ sizeof(reg->menu_header));
+ } else if (str_ieq(line_key(line), "index_header")) {
+ hybbx_strlcpy(reg->index_header, colon + 1,
+ sizeof(reg->index_header));
+ } else if (str_ieq(line_key(line), "alias_header")) {
+ hybbx_strlcpy(reg->alias_header, colon + 1,
+ sizeof(reg->alias_header));
+ }
+ continue;
+ }
+
+ if (section == SEC_AREAS) {
+ if (indent == 2 && line[0] == '-') {
+ if (reg->area_count >= HYBBX_AREAS_MAX) {
+ continue;
+ }
+ area = &reg->areas[reg->area_count++];
+ memset(area, 0, sizeof(*area));
+ subarea = NULL;
+
+ line++;
+ trim_inplace(line);
+ colon = strchr(line, ':');
+ if (colon != NULL) {
+ *colon = '\0';
+ strip_quotes(colon + 1);
+ if (str_ieq(line_key(line), "label")) {
+ hybbx_strlcpy(area->label, colon + 1, sizeof(area->label));
+ } else if (str_ieq(line_key(line), "commands")) {
+ parse_bracket_list(colon + 1, area->verbs,
+ HYBBX_COMMANDS_VERBS_PER_GROUP,
+ &area->verb_count);
+ }
+ }
+ continue;
+ }
+
+ if (area == NULL) {
+ continue;
+ }
+
+ if (indent == 4 && line[0] == '-') {
+ if (area->subarea_count < HYBBX_AREAS_SUB_MAX) {
+ subarea = &area->subareas[area->subarea_count++];
+ memset(subarea, 0, sizeof(*subarea));
+ }
+ continue;
+ }
+
+ if (indent >= 4) {
+ colon = strchr(line, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ *colon = '\0';
+ strip_quotes(colon + 1);
+ if (str_ieq(line_key(line), "label")) {
+ hybbx_strlcpy(area->label, colon + 1, sizeof(area->label));
+ } else if (str_ieq(line_key(line), "commands")) {
+ parse_bracket_list(colon + 1, area->verbs,
+ HYBBX_COMMANDS_VERBS_PER_GROUP,
+ &area->verb_count);
+ }
+ continue;
+ }
+
+ if (indent >= 6 && subarea != NULL) {
+ colon = strchr(line, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ *colon = '\0';
+ strip_quotes(colon + 1);
+ if (str_ieq(line_key(line), "commands")) {
+ parse_bracket_list(colon + 1, subarea->verbs,
+ HYBBX_COMMANDS_VERBS_PER_GROUP,
+ &subarea->verb_count);
+ }
+ }
+ continue;
+ }
+
+ if (section == SEC_MENU) {
+ if (indent == 2 && line[strlen(line) - 1] == ':') {
+ line[strlen(line) - 1] = '\0';
+ if (reg->menu_level_count < HYBBX_MENU_LEVELS_MAX) {
+ hybbx_strlcpy(reg->menu_levels[reg->menu_level_count],
+ line_key(line),
+ sizeof(reg->menu_levels[0]));
+ hybbx_strlcpy(cur_menu_level, line_key(line),
+ sizeof(cur_menu_level));
+ reg->menu_level_count++;
+ }
+ continue;
+ }
+
+ if (indent >= 4 && line[0] == '-' && cur_menu_level[0] != '\0') {
+ unsigned i;
+
+ for (i = 0; i < reg->menu_level_count; i++) {
+ unsigned n;
+
+ if (!str_ieq(reg->menu_levels[i], cur_menu_level)) {
+ continue;
+ }
+
+ n = reg->menu_area_count[i];
+ if (n >= HYBBX_MENU_AREAS_PER_LEVEL) {
+ break;
+ }
+
+ line++;
+ trim_inplace(line);
+ hybbx_strlcpy(reg->menu_area_labels[i][n], line, 16);
+ reg->menu_area_count[i]++;
+ break;
+ }
+ }
+ continue;
+ }
+
+ if (section == SEC_INDEX && indent >= 2 && line[0] == '-') {
+ if (reg->index_label_count < HYBBX_MENU_AREAS_PER_LEVEL) {
+ line++;
+ trim_inplace(line);
+ hybbx_strlcpy(reg->index_labels[reg->index_label_count],
+ line, 16);
+ reg->index_label_count++;
+ }
+ }
+ }
+
+ fclose(fp);
+
+ if (reg->menu_header[0] == '\0' || reg->index_header[0] == '\0' ||
+ reg->area_count == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (reg->index_label_count == 0) {
+ unsigned i;
+
+ for (i = 0; i < reg->area_count &&
+ reg->index_label_count < HYBBX_MENU_AREAS_PER_LEVEL;
+ i++) {
+ hybbx_strlcpy(reg->index_labels[reg->index_label_count],
+ reg->areas[i].label, 16);
+ reg->index_label_count++;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t parse_commands_yaml(commands_registry_t *reg,
+ const char *path)
+{
+ FILE *fp;
+ char buf[HYBBX_CONFIG_LINE_MAX];
+ enum {
+ SEC_NONE,
+ SEC_RIGHTS,
+ SEC_ALIASES,
+ SEC_COMMANDS
+ } section = SEC_NONE;
+ enum {
+ RIGHT_NONE,
+ RIGHT_USERCHANGE,
+ RIGHT_USERDELETE,
+ RIGHT_DELETE,
+ RIGHT_PROMOTE,
+ RIGHT_DEMOTE
+ } right_kind = RIGHT_NONE;
+ char cur_alias_canonical[HYBBX_CMD_VERB_MAX];
+ hybbx_command_def_t *cmd = NULL;
+ target_right_rule_t *target_rule = NULL;
+ promote_right_rule_t *promote_rule = NULL;
+ demote_right_rule_t *demote_rule = NULL;
+
+ if (reg == NULL || path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cur_alias_canonical[0] = '\0';
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ while (fgets(buf, sizeof(buf), fp) != NULL) {
+ char *line = buf;
+ char *colon;
+ unsigned indent;
+
+ indent = line_indent(line);
+ trim_inplace(line);
+ if (line[0] == '\0' || line[0] == '#') {
+ continue;
+ }
+
+ if (indent == 0 && line[strlen(line) - 1] == ':') {
+ line[strlen(line) - 1] = '\0';
+ cmd = NULL;
+ target_rule = NULL;
+ promote_rule = NULL;
+ demote_rule = NULL;
+
+ if (str_ieq(line, "rights")) {
+ section = SEC_RIGHTS;
+ right_kind = RIGHT_NONE;
+ } else if (str_ieq(line, "aliases")) {
+ section = SEC_ALIASES;
+ right_kind = RIGHT_NONE;
+ } else if (str_ieq(line, "commands")) {
+ section = SEC_COMMANDS;
+ right_kind = RIGHT_NONE;
+ } else {
+ section = SEC_NONE;
+ }
+ continue;
+ }
+
+ if (section == SEC_RIGHTS) {
+ if (indent == 2 && line[strlen(line) - 1] == ':') {
+ line[strlen(line) - 1] = '\0';
+ target_rule = NULL;
+ promote_rule = NULL;
+ demote_rule = NULL;
+
+ if (str_ieq(line_key(line), "userchange")) {
+ right_kind = RIGHT_USERCHANGE;
+ } else if (str_ieq(line_key(line), "userdelete")) {
+ right_kind = RIGHT_USERDELETE;
+ } else if (str_ieq(line_key(line), "delete")) {
+ right_kind = RIGHT_DELETE;
+ } else if (str_ieq(line_key(line), "promote")) {
+ right_kind = RIGHT_PROMOTE;
+ } else if (str_ieq(line_key(line), "demote")) {
+ right_kind = RIGHT_DEMOTE;
+ } else {
+ right_kind = RIGHT_NONE;
+ }
+ continue;
+ }
+
+ if (indent == 4 && line[0] == '-') {
+ if (right_kind == RIGHT_USERCHANGE &&
+ reg->userchange_rule_count < HYBBX_RIGHTS_TARGET_RULES_MAX) {
+ target_rule =
+ &reg->userchange_rules[reg->userchange_rule_count++];
+ memset(target_rule, 0, sizeof(*target_rule));
+ } else if (right_kind == RIGHT_USERDELETE &&
+ reg->userdelete_rule_count <
+ HYBBX_RIGHTS_TARGET_RULES_MAX) {
+ target_rule =
+ &reg->userdelete_rules[reg->userdelete_rule_count++];
+ memset(target_rule, 0, sizeof(*target_rule));
+ } else if (right_kind == RIGHT_DELETE &&
+ reg->delete_rule_count <
+ HYBBX_RIGHTS_TARGET_RULES_MAX) {
+ target_rule =
+ &reg->delete_rules[reg->delete_rule_count++];
+ memset(target_rule, 0, sizeof(*target_rule));
+ } else if (right_kind == RIGHT_PROMOTE &&
+ reg->promote_rule_count <
+ HYBBX_RIGHTS_PROMOTE_RULES_MAX) {
+ promote_rule =
+ &reg->promote_rules[reg->promote_rule_count++];
+ memset(promote_rule, 0, sizeof(*promote_rule));
+ } else if (right_kind == RIGHT_DEMOTE &&
+ reg->demote_rule_count <
+ HYBBX_RIGHTS_DEMOTE_RULES_MAX) {
+ demote_rule =
+ &reg->demote_rules[reg->demote_rule_count++];
+ memset(demote_rule, 0, sizeof(*demote_rule));
+ }
+ continue;
+ }
+
+ if (indent >= 6) {
+ colon = strchr(line, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ *colon = '\0';
+ strip_quotes(colon + 1);
+
+ if (target_rule != NULL) {
+ if (str_ieq(line_key(line), "actor")) {
+ target_rule->actor = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "targets")) {
+ parse_level_list(colon + 1, target_rule->targets, 5,
+ &target_rule->target_count);
+ }
+ } else if (promote_rule != NULL) {
+ if (str_ieq(line_key(line), "actor")) {
+ promote_rule->actor = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "to")) {
+ promote_rule->to_level = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "from")) {
+ parse_level_list(colon + 1, promote_rule->from_levels,
+ 4, &promote_rule->from_count);
+ }
+ } else if (demote_rule != NULL) {
+ if (str_ieq(line_key(line), "actor")) {
+ demote_rule->actor = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "from")) {
+ parse_level_list(colon + 1, demote_rule->from_levels,
+ 4, &demote_rule->from_count);
+ }
+ }
+ }
+ continue;
+ }
+
+ if (section == SEC_ALIASES) {
+ if (indent == 2 && line[strlen(line) - 1] == ':') {
+ alias_entry_t *entry;
+
+ line[strlen(line) - 1] = '\0';
+ if (reg->alias_count >= HYBBX_COMMANDS_ALIASES_MAX) {
+ continue;
+ }
+ entry = &reg->aliases[reg->alias_count++];
+ hybbx_strlcpy(entry->canonical, line_key(line),
+ sizeof(entry->canonical));
+ hybbx_strlcpy(cur_alias_canonical, line_key(line),
+ sizeof(cur_alias_canonical));
+ continue;
+ }
+ if (indent >= 4 && line[0] == '-' && cur_alias_canonical[0] != '\0') {
+ alias_entry_t *entry = &reg->aliases[reg->alias_count - 1];
+
+ if (entry->alias_count < HYBBX_COMMANDS_ALIAS_PER) {
+ line++;
+ trim_inplace(line);
+ strip_quotes(line);
+ hybbx_strlcpy(entry->aliases[entry->alias_count], line,
+ HYBBX_CMD_VERB_MAX);
+ entry->alias_count++;
+ }
+ }
+ continue;
+ }
+
+ if (section == SEC_COMMANDS) {
+ if (indent == 2 && line[strlen(line) - 1] == ':') {
+ line[strlen(line) - 1] = '\0';
+ if (reg->command_count < HYBBX_COMMANDS_MAX) {
+ cmd = &reg->commands[reg->command_count++];
+ memset(cmd, 0, sizeof(*cmd));
+ cmd->min_level = HYBBX_LEVEL_GUEST;
+ hybbx_strlcpy(cmd->verb, line_key(line), sizeof(cmd->verb));
+ }
+ continue;
+ }
+ if (indent >= 4 && cmd != NULL) {
+ colon = strchr(line, ':');
+ if (colon == NULL) {
+ continue;
+ }
+ *colon = '\0';
+ strip_quotes(colon + 1);
+ if (str_ieq(line_key(line), "group")) {
+ hybbx_strlcpy(cmd->group, colon + 1, sizeof(cmd->group));
+ } else if (str_ieq(line_key(line), "min")) {
+ cmd->min_level = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "max")) {
+ cmd->max_level = level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "only")) {
+ cmd->only_level = (int)level_from_name(colon + 1);
+ } else if (str_ieq(line_key(line), "line1")) {
+ hybbx_strlcpy(cmd->line1, colon + 1, sizeof(cmd->line1));
+ } else if (str_ieq(line_key(line), "line2")) {
+ hybbx_strlcpy(cmd->line2, colon + 1, sizeof(cmd->line2));
+ }
+ }
+ }
+ }
+
+ fclose(fp);
+
+ if (reg->command_count == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+static void build_alias_lines(commands_registry_t *reg)
+{
+ char line[HYBBX_LINE_MAX];
+ unsigned i;
+ unsigned j;
+ unsigned off;
+
+ reg->alias_line_count = 0;
+
+ for (i = 0; i < reg->alias_count && reg->alias_line_count < HYBBX_COMMANDS_ALIAS_LINES;
+ i++) {
+ const alias_entry_t *entry = &reg->aliases[i];
+
+ off = 0;
+ line[0] = '\0';
+
+ for (j = 0; j < entry->alias_count; j++) {
+ const char *alias = entry->aliases[j];
+ int n;
+
+ if (strchr(alias, ' ') != NULL) {
+ n = snprintf(line + off, sizeof(line) - off, "%s%s",
+ off > 0 ? " " : "", alias);
+ } else {
+ n = snprintf(line + off, sizeof(line) - off, "%s%s -> %s",
+ off > 0 ? " " : "", alias, entry->canonical);
+ }
+
+ if (n < 0 || (size_t)n >= sizeof(line) - off) {
+ if (reg->alias_line_count < HYBBX_COMMANDS_ALIAS_LINES) {
+ hybbx_strlcpy(reg->alias_lines[reg->alias_line_count++],
+ line, sizeof(reg->alias_lines[0]));
+ }
+ off = 0;
+ line[0] = '\0';
+ j--;
+ continue;
+ }
+ off += (unsigned)n;
+ }
+
+ if (off > 0 && reg->alias_line_count < HYBBX_COMMANDS_ALIAS_LINES) {
+ hybbx_strlcpy(reg->alias_lines[reg->alias_line_count++],
+ line, sizeof(reg->alias_lines[0]));
+ }
+ }
+}
+
+static hybbx_result_t load_yaml_file(const char *rel_share,
+ hybbx_result_t (*parse_fn)(commands_registry_t *,
+ const char *),
+ const char *label)
+{
+ char path[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (hybbx_path_resolve(path, sizeof(path), rel_share) == HYBBX_OK) {
+ rc = parse_fn(&g_registry, path);
+ if (rc == HYBBX_OK) {
+ hybbx_log_info("[commands] loaded %s (%s)", path, label);
+ return HYBBX_OK;
+ }
+ }
+
+ return HYBBX_ERR_IO;
+}
+
+hybbx_result_t hybbx_commands_registry_init(void)
+{
+ hybbx_result_t rc;
+
+ hybbx_commands_registry_shutdown();
+
+ rc = load_yaml_file(HYBBX_FILE_AREAS, parse_areas_yaml, "areas");
+ if (rc != HYBBX_OK) {
+ rc = load_yaml_file("share/areas.yaml", parse_areas_yaml, "areas");
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[commands] failed to load " HYBBX_FILE_AREAS);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = load_yaml_file(HYBBX_FILE_COMMANDS, parse_commands_yaml, "commands");
+ if (rc != HYBBX_OK) {
+ rc = load_yaml_file("share/commands.yaml", parse_commands_yaml, "commands");
+ }
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[commands] failed to load " HYBBX_FILE_COMMANDS);
+ hybbx_commands_registry_shutdown();
+ return HYBBX_ERR_IO;
+ }
+
+ build_alias_lines(&g_registry);
+ g_registry.loaded = 1;
+ hybbx_log_info("[commands] registry ready (%u areas, %u verbs)",
+ g_registry.area_count, g_registry.command_count);
+ return HYBBX_OK;
+}
+
+void hybbx_commands_registry_shutdown(void)
+{
+ memset(&g_registry, 0, sizeof(g_registry));
+}
+
+const hybbx_command_def_t *hybbx_commands_registry_find(const char *verb)
+{
+ unsigned i;
+
+ if (!g_registry.loaded || verb == NULL) {
+ return NULL;
+ }
+
+ for (i = 0; i < g_registry.command_count; i++) {
+ if (str_ieq(g_registry.commands[i].verb, verb)) {
+ return &g_registry.commands[i];
+ }
+ }
+
+ return NULL;
+}
+
+const char *hybbx_commands_registry_canonical(const char *topic)
+{
+ unsigned i;
+ unsigned j;
+
+ if (!g_registry.loaded || topic == NULL || topic[0] == '\0') {
+ return topic;
+ }
+
+ if (hybbx_commands_registry_find(topic) != NULL) {
+ return topic;
+ }
+
+ for (i = 0; i < g_registry.alias_count; i++) {
+ for (j = 0; j < g_registry.aliases[i].alias_count; j++) {
+ const char *alias = g_registry.aliases[i].aliases[j];
+
+ if (strchr(alias, ' ') != NULL) {
+ continue;
+ }
+ if (str_ieq(alias, topic)) {
+ return g_registry.aliases[i].canonical;
+ }
+ }
+ }
+
+ return topic;
+}
+
+int hybbx_commands_registry_verb_allowed(hybbx_user_level_t level,
+ const char *verb)
+{
+ const char *canonical;
+ const hybbx_command_def_t *def;
+
+ if (verb == NULL || verb[0] == '\0') {
+ return 1;
+ }
+
+ if (!g_registry.loaded) {
+ return 0;
+ }
+
+ canonical = hybbx_commands_registry_canonical(verb);
+ def = hybbx_commands_registry_find(canonical);
+ if (def == NULL) {
+ return 0;
+ }
+
+ return command_level_allowed(def, level);
+}
+
+int hybbx_commands_registry_help_allowed(hybbx_user_level_t level,
+ const char *verb)
+{
+ return hybbx_commands_registry_verb_allowed(level, verb);
+}
+
+int hybbx_commands_registry_may_userchange(hybbx_user_level_t actor,
+ hybbx_user_level_t target)
+{
+ if (!g_registry.loaded) {
+ return 0;
+ }
+
+ return target_right_allowed(g_registry.userchange_rules,
+ g_registry.userchange_rule_count,
+ actor, target);
+}
+
+int hybbx_commands_registry_may_userdelete(hybbx_user_level_t actor,
+ hybbx_user_level_t target)
+{
+ unsigned i;
+
+ if (!g_registry.loaded || hybbx_user_level_is_sysop(target)) {
+ return 0;
+ }
+
+ for (i = 0; i < g_registry.userdelete_rule_count; i++) {
+ const target_right_rule_t *rule = &g_registry.userdelete_rules[i];
+
+ if (rule->actor == actor &&
+ level_in_list(target, rule->targets, rule->target_count)) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+int hybbx_commands_registry_may_delete(hybbx_user_level_t actor,
+ hybbx_user_level_t target)
+{
+ if (!g_registry.loaded) {
+ return 0;
+ }
+
+ if (target == HYBBX_LEVEL_SYSOP) {
+ return 0;
+ }
+
+ return target_right_allowed(g_registry.delete_rules,
+ g_registry.delete_rule_count,
+ actor, target);
+}
+
+int hybbx_commands_registry_may_promote(hybbx_user_level_t actor,
+ hybbx_user_level_t target,
+ int target_active,
+ hybbx_user_level_t new_level)
+{
+ unsigned i;
+
+ if (!g_registry.loaded || !target_active ||
+ hybbx_user_level_is_guest(target) ||
+ target == HYBBX_LEVEL_SYSOP) {
+ return 0;
+ }
+
+ for (i = 0; i < g_registry.promote_rule_count; i++) {
+ const promote_right_rule_t *rule = &g_registry.promote_rules[i];
+
+ if (rule->actor == actor && rule->to_level == new_level &&
+ level_in_list(target, rule->from_levels, rule->from_count)) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+int hybbx_commands_registry_may_demote(hybbx_user_level_t actor,
+ hybbx_user_level_t target)
+{
+ unsigned i;
+
+ if (!g_registry.loaded) {
+ return 0;
+ }
+
+ for (i = 0; i < g_registry.demote_rule_count; i++) {
+ const demote_right_rule_t *rule = &g_registry.demote_rules[i];
+
+ if (rule->actor == actor &&
+ level_in_list(target, rule->from_levels, rule->from_count)) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+#define MENU_LABEL_PREFIX_LEN 13u
+#define MENU_CMD_FIELD_MAX (HYBBX_LINE_MAX - MENU_LABEL_PREFIX_LEN)
+
+static void emit_menu_line(hybbx_session_t *session, const char *label,
+ const char *cmds, int continuation)
+{
+ char buf[HYBBX_LINE_MAX];
+
+ if (continuation) {
+ snprintf(buf, sizeof(buf), " %s", cmds);
+ } else {
+ snprintf(buf, sizeof(buf), " %-10s %s", label, cmds);
+ }
+ hybbx_session_write_line(session, buf);
+}
+
+static void format_verbs_line(char *out, size_t out_len,
+ const char verbs[][HYBBX_CMD_VERB_MAX],
+ unsigned count)
+{
+ size_t off = 0;
+ unsigned i;
+
+ out[0] = '\0';
+ for (i = 0; i < count; i++) {
+ int n;
+
+ if (verbs[i][0] == '\0') {
+ continue;
+ }
+ n = snprintf(out + off, out_len - off, "%s/%s",
+ off > 0 ? " " : "", verbs[i]);
+ if (n < 0 || (size_t)n >= out_len - off) {
+ break;
+ }
+ off += (size_t)n;
+ }
+}
+
+static void emit_verbs_wrapped(hybbx_session_t *session, const char *label,
+ const char verbs[][HYBBX_CMD_VERB_MAX],
+ unsigned count)
+{
+ char chunk[MENU_CMD_FIELD_MAX + 1];
+ size_t off = 0;
+ unsigned physical_line = 0;
+ unsigned i;
+
+ for (i = 0; i < count; i++) {
+ char token[HYBBX_CMD_VERB_MAX + 4];
+ int n;
+
+ if (verbs[i][0] == '\0') {
+ continue;
+ }
+
+ n = snprintf(token, sizeof(token), "%s/%s",
+ off > 0 ? " " : "", verbs[i]);
+ if (n < 0) {
+ continue;
+ }
+
+ if (off > 0 && (size_t)n + off > MENU_CMD_FIELD_MAX) {
+ chunk[off] = '\0';
+ emit_menu_line(session, physical_line == 0 ? label : "", chunk,
+ physical_line > 0);
+ physical_line++;
+ off = 0;
+ n = snprintf(token, sizeof(token), "/%s", verbs[i]);
+ if (n < 0) {
+ continue;
+ }
+ }
+
+ if ((size_t)n >= sizeof(chunk) - off) {
+ break;
+ }
+
+ memcpy(chunk + off, token, (size_t)n);
+ off += (size_t)n;
+ }
+
+ if (off > 0) {
+ chunk[off] = '\0';
+ emit_menu_line(session, physical_line == 0 ? label : "", chunk,
+ physical_line > 0);
+ }
+}
+
+static unsigned collect_area_verbs(const menu_area_t *area,
+ const hybbx_session_t *session,
+ hybbx_user_level_t level,
+ int filter_access,
+ char out[][HYBBX_CMD_VERB_MAX],
+ unsigned max)
+{
+ unsigned count = 0;
+ unsigned i;
+ unsigned j;
+
+ if (area == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < area->verb_count && count < max; i++) {
+ if (filter_access &&
+ !command_verb_visible(session, level, area->verbs[i])) {
+ continue;
+ }
+ hybbx_strlcpy(out[count], area->verbs[i], HYBBX_CMD_VERB_MAX);
+ count++;
+ }
+
+ for (i = 0; i < area->subarea_count; i++) {
+ const menu_subarea_t *sub = &area->subareas[i];
+
+ for (j = 0; j < sub->verb_count && count < max; j++) {
+ if (filter_access &&
+ !command_verb_visible(session, level, sub->verbs[j])) {
+ continue;
+ }
+ hybbx_strlcpy(out[count], sub->verbs[j], HYBBX_CMD_VERB_MAX);
+ count++;
+ }
+ }
+
+ return count;
+}
+
+static void render_area(hybbx_session_t *session, const menu_area_t *area,
+ hybbx_user_level_t level, int filter_access)
+{
+ char cmds[HYBBX_LINE_MAX];
+ char verbs[HYBBX_COMMANDS_VERBS_PER_GROUP][HYBBX_CMD_VERB_MAX];
+ unsigned count;
+ unsigned i;
+
+ if (area == NULL) {
+ return;
+ }
+
+ count = collect_area_verbs(area, session, level, filter_access, verbs,
+ HYBBX_COMMANDS_VERBS_PER_GROUP);
+ if (count == 0) {
+ return;
+ }
+
+ emit_verbs_wrapped(session, area->label, verbs, count);
+
+ if (!filter_access) {
+ for (i = 0; i < area->subarea_count; i++) {
+ const menu_subarea_t *sub = &area->subareas[i];
+ char sub_verbs[HYBBX_COMMANDS_VERBS_PER_GROUP][HYBBX_CMD_VERB_MAX];
+ unsigned sub_count = 0;
+ unsigned j;
+
+ for (j = 0; j < sub->verb_count; j++) {
+ hybbx_strlcpy(sub_verbs[sub_count], sub->verbs[j],
+ HYBBX_CMD_VERB_MAX);
+ sub_count++;
+ }
+
+ if (sub_count == 0) {
+ continue;
+ }
+
+ format_verbs_line(cmds, sizeof(cmds),
+ (const char (*)[HYBBX_CMD_VERB_MAX])sub_verbs,
+ sub_count);
+ emit_menu_line(session, "", cmds, 1);
+ }
+ }
+}
+
+static void render_area_labels(hybbx_session_t *session,
+ const char labels[][16],
+ unsigned label_count,
+ hybbx_user_level_t level,
+ int filter_access)
+{
+ unsigned i;
+
+ for (i = 0; i < label_count; i++) {
+ const menu_area_t *area = area_find(labels[i]);
+
+ if (area != NULL) {
+ render_area(session, area, level, filter_access);
+ }
+ }
+}
+
+static unsigned menu_layout_index(hybbx_user_level_t level)
+{
+ const char *level_name = menu_level_name(level);
+ unsigned i;
+
+ for (i = 0; i < g_registry.menu_level_count; i++) {
+ if (str_ieq(g_registry.menu_levels[i], level_name)) {
+ return i;
+ }
+ }
+
+ return g_registry.menu_level_count;
+}
+
+void hybbx_commands_registry_show_menu(hybbx_session_t *session)
+{
+ hybbx_user_level_t level;
+ unsigned layout;
+
+ if (session == NULL || !g_registry.loaded) {
+ return;
+ }
+
+ level = hybbx_session_user_level(session);
+ layout = menu_layout_index(level);
+
+ hybbx_session_write_line(session, g_registry.menu_header);
+ if (layout < g_registry.menu_level_count) {
+ render_area_labels(session,
+ (const char (*)[16])
+ g_registry.menu_area_labels[layout],
+ g_registry.menu_area_count[layout],
+ level, 1);
+ }
+
+ /*
+ * Allow-list grants are not Sysop — their menu layout has no Sysop area.
+ * Append filtered Sysop area so /monitor is visible when granted.
+ */
+ if (hybbx_monitor_session_may_use(session) &&
+ !hybbx_user_level_is_sysop(level)) {
+ const menu_area_t *sysop = area_find("Sysop");
+
+ if (sysop != NULL) {
+ render_area(session, sysop, level, 1);
+ }
+ }
+}
+
+void hybbx_commands_registry_show_index(hybbx_session_t *session)
+{
+ hybbx_user_level_t level;
+
+ if (session == NULL || !g_registry.loaded) {
+ return;
+ }
+
+ level = hybbx_session_user_level(session);
+ hybbx_session_write_line(session, g_registry.index_header);
+ /* Filter by access — Sysop cmds (incl. /monitor) only for Sysop + grants. */
+ render_area_labels(session,
+ (const char (*)[16])g_registry.index_labels,
+ g_registry.index_label_count,
+ level, 1);
+}
+
+void hybbx_commands_registry_show_aliases(hybbx_session_t *session)
+{
+ hybbx_user_level_t level;
+ unsigned i;
+
+ if (session == NULL || !g_registry.loaded) {
+ return;
+ }
+
+ level = hybbx_session_user_level(session);
+ hybbx_session_write_line(session, g_registry.alias_header);
+ for (i = 0; i < g_registry.alias_count; i++) {
+ const alias_entry_t *entry = &g_registry.aliases[i];
+ char line[HYBBX_LINE_MAX];
+ unsigned j;
+ unsigned off = 0;
+
+ if (!command_verb_visible(session, level, entry->canonical)) {
+ continue;
+ }
+
+ line[0] = '\0';
+ for (j = 0; j < entry->alias_count; j++) {
+ const char *alias = entry->aliases[j];
+ int n;
+
+ if (strchr(alias, ' ') != NULL) {
+ n = snprintf(line + off, sizeof(line) - off, "%s%s",
+ off > 0 ? " " : "", alias);
+ } else {
+ n = snprintf(line + off, sizeof(line) - off, "%s%s -> %s",
+ off > 0 ? " " : "", alias, entry->canonical);
+ }
+ if (n < 0 || (size_t)n >= sizeof(line) - off) {
+ break;
+ }
+ off += (unsigned)n;
+ }
+ if (off > 0) {
+ hybbx_session_write_line(session, line);
+ }
+ }
+}
+
+void hybbx_commands_registry_show_help(hybbx_session_t *session,
+ const char *canonical)
+{
+ const hybbx_command_def_t *def;
+
+ if (session == NULL || canonical == NULL || !g_registry.loaded) {
+ return;
+ }
+
+ def = hybbx_commands_registry_find(canonical);
+ if (def == NULL || def->line1[0] == '\0') {
+ return;
+ }
+
+ hybbx_session_write_line(session, def->line1);
+ if (def->line2[0] != '\0') {
+ hybbx_session_write_line(session, def->line2);
+ }
+}
+
+void hybbx_commands_registry_show_sysop_cmds(hybbx_session_t *session)
+{
+ unsigned i;
+
+ if (session == NULL || !g_registry.loaded) {
+ return;
+ }
+
+ for (i = 0; i < g_registry.command_count; i++) {
+ const hybbx_command_def_t *def = &g_registry.commands[i];
+
+ /* Sysop=1, Admin=2 — only the admin/sysop privilege set. */
+ if (def->min_level == 0 || def->min_level > HYBBX_LEVEL_ADMIN) {
+ continue;
+ }
+ if (def->line1[0] == '\0') {
+ continue;
+ }
+ hybbx_session_write_line(session, def->line1);
+ }
+}
diff --git a/src/core/conference.c b/src/core/conference.c
new file mode 100644
index 0000000..890348f
--- /dev/null
+++ b/src/core/conference.c
@@ -0,0 +1,720 @@
+#include "hybbx/conference.h"
+#include "hybbx/chat.h"
+#include "hybbx/messages.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/monitor.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+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 int invite_reply_yes(const char *line)
+{
+ return line != NULL &&
+ (str_ieq(line, "y") || str_ieq(line, "yes"));
+}
+
+static int invite_reply_no(const char *line)
+{
+ return line != NULL &&
+ (str_ieq(line, "n") || str_ieq(line, "no"));
+}
+
+static int topic_char_ok(char ch)
+{
+ return (ch >= 'a' && ch <= 'z') ||
+ (ch >= 'A' && ch <= 'Z') ||
+ (ch >= '0' && ch <= '9') ||
+ ch == ' ' || ch == '-' || ch == '_';
+}
+
+static int conference_topic_valid(const char *topic)
+{
+ size_t len;
+ size_t i;
+
+ if (topic == NULL) {
+ return 0;
+ }
+
+ len = strlen(topic);
+ if (len < 2 || len >= HYBBX_CONFERENCE_TOPIC_MAX) {
+ return 0;
+ }
+
+ for (i = 0; i < len; i++) {
+ if (!topic_char_ok(topic[i])) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+typedef struct find_user_session_ctx {
+ const char *username;
+ hybbx_session_t *found;
+} find_user_session_ctx_t;
+
+static void find_user_session_visitor(hybbx_session_t *session, void *userdata)
+{
+ find_user_session_ctx_t *ctx = (find_user_session_ctx_t *)userdata;
+
+ if (ctx == NULL || session == NULL || ctx->found != NULL) {
+ return;
+ }
+
+ if (!hybbx_session_logged_in(session) || hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ if (str_ieq(hybbx_session_username(session), ctx->username)) {
+ ctx->found = session;
+ }
+}
+
+static hybbx_session_t *conference_find_online_user(hybbx_service_t *service,
+ const char *username)
+{
+ find_user_session_ctx_t ctx;
+
+ if (service == NULL || username == NULL || username[0] == '\0') {
+ return NULL;
+ }
+
+ ctx.username = username;
+ ctx.found = NULL;
+ hybbx_service_visit_sessions(service, find_user_session_visitor, &ctx);
+ return ctx.found;
+}
+
+static int conference_session_active(const hybbx_session_t *session)
+{
+ return session != NULL &&
+ hybbx_session_area(session) == HYBBX_AREA_CONFERENCE;
+}
+
+typedef struct invite_cancel_ctx {
+ const char *from_username;
+} invite_cancel_ctx_t;
+
+static void invite_cancel_visitor(hybbx_session_t *session, void *userdata)
+{
+ invite_cancel_ctx_t *ctx = (invite_cancel_ctx_t *)userdata;
+
+ if (session == NULL || ctx == NULL || ctx->from_username == NULL) {
+ return;
+ }
+
+ if (!hybbx_conference_invite_pending(session)) {
+ return;
+ }
+
+ if (!str_ieq(hybbx_session_conference_invite_from(session),
+ ctx->from_username)) {
+ return;
+ }
+
+ hybbx_session_clear_conference_invite(session);
+ hybbx_session_write_line(session, "*** Conference invite cancelled.");
+}
+
+static void conference_cancel_invites_from(hybbx_service_t *service,
+ const char *from_username)
+{
+ invite_cancel_ctx_t ctx;
+
+ if (service == NULL || from_username == NULL || from_username[0] == '\0') {
+ return;
+ }
+
+ ctx.from_username = from_username;
+ hybbx_service_visit_sessions(service, invite_cancel_visitor, &ctx);
+}
+
+static void conference_notify_partner_left(hybbx_session_t *session,
+ const char *partner_username)
+{
+ hybbx_service_t *service;
+ hybbx_session_t *partner;
+ char line[HYBBX_LINE_MAX];
+
+ if (session == NULL || partner_username == NULL ||
+ partner_username[0] == '\0') {
+ return;
+ }
+
+ service = hybbx_session_service(session);
+ if (service == NULL) {
+ return;
+ }
+
+ partner = conference_find_online_user(service, partner_username);
+ if (partner == NULL || !conference_session_active(partner)) {
+ return;
+ }
+
+ if (!str_ieq(hybbx_session_conference_partner(partner),
+ hybbx_session_username(session))) {
+ return;
+ }
+
+ hybbx_session_clear_conference(partner);
+ snprintf(line, sizeof(line), "*** %s left the conference.",
+ hybbx_session_display_name(session));
+ hybbx_session_write_line(partner, line);
+ if (hybbx_session_area(partner) == HYBBX_AREA_CONFERENCE) {
+ (void)hybbx_session_leave_area(partner);
+ }
+}
+
+static hybbx_result_t conference_activate(hybbx_service_t *service,
+ hybbx_session_t *initiator,
+ hybbx_session_t *partner,
+ const char *topic)
+{
+ char line[HYBBX_LINE_MAX];
+ hybbx_result_t rc;
+
+ if (service == NULL || initiator == NULL || partner == NULL ||
+ topic == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_session_join_conference(initiator, topic,
+ hybbx_session_username(partner));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = hybbx_session_join_conference(partner, topic,
+ hybbx_session_username(initiator));
+ if (rc != HYBBX_OK) {
+ hybbx_session_clear_conference(initiator);
+ (void)hybbx_session_leave_area(initiator);
+ return rc;
+ }
+
+ snprintf(line, sizeof(line), "Conference: %s", topic);
+ hybbx_session_write_line(initiator, line);
+ hybbx_session_write_line(initiator,
+ "Each line is a message; /leave or /main to exit.");
+
+ snprintf(line, sizeof(line), "Conference: %s", topic);
+ hybbx_session_write_line(partner, line);
+ hybbx_session_write_line(partner,
+ "Each line is a message; /leave or /main to exit.");
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_conference_start(hybbx_service_t *service,
+ hybbx_session_t *initiator,
+ const char *topic,
+ const char *partner_spec)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_record_t partner_user;
+ hybbx_session_t *partner_session;
+ char line[HYBBX_LINE_MAX];
+ hybbx_result_t rc;
+
+ if (service == NULL || initiator == NULL || topic == NULL ||
+ partner_spec == NULL || topic[0] == '\0' || partner_spec[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_session_is_guest(initiator)) {
+ hybbx_session_write_line(initiator, "Guests cannot use conference.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (!conference_topic_valid(topic)) {
+ hybbx_session_write_line(initiator, "Invalid conference topic.");
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (conference_session_active(initiator)) {
+ hybbx_session_write_line(initiator, "Already in a conference.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_conference_invite_pending(initiator)) {
+ hybbx_session_write_line(initiator,
+ "Answer the pending conference invite first (y/n).");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_resolve_user(storage, partner_spec, &partner_user);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(initiator, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (!partner_user.active) {
+ hybbx_session_write_line(initiator, "User is not active.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_user_level_is_guest(partner_user.level)) {
+ hybbx_session_write_line(initiator, "Cannot conference with a guest.");
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(partner_user.username, hybbx_session_username(initiator))) {
+ hybbx_session_write_line(initiator, "Cannot conference with yourself.");
+ return HYBBX_OK;
+ }
+
+ if (!hybbx_session_conference_may_invite(initiator, partner_user.username)) {
+ hybbx_session_write_line(initiator,
+ "Invite limit: 2 per user per 30 minutes.");
+ return HYBBX_OK;
+ }
+
+ partner_session = conference_find_online_user(service, partner_user.username);
+ if (partner_session == NULL) {
+ hybbx_session_write_line(initiator, "User is not online.");
+ return HYBBX_OK;
+ }
+
+ if (conference_session_active(partner_session)) {
+ hybbx_session_write_line(initiator, "User is already in a conference.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_conference_invite_pending(partner_session)) {
+ hybbx_session_write_line(initiator,
+ "User has a pending conference invite.");
+ return HYBBX_OK;
+ }
+
+ hybbx_session_set_conference_invite(partner_session,
+ hybbx_session_username(initiator),
+ topic);
+ hybbx_session_conference_invite_sent(initiator, partner_user.username);
+
+ snprintf(line, sizeof(line), "Conference invite: %s", topic);
+ (void)hybbx_msg_send_private(partner_session,
+ hybbx_session_display_name(initiator), line);
+ hybbx_session_write_line(partner_session, "Accept? y/n or yes/no");
+
+ snprintf(line, sizeof(line), "Conference invite sent to %s.",
+ hybbx_user_display_name(&partner_user));
+ hybbx_session_write_line(initiator, line);
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_conference_reply_invite(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const char *line)
+{
+ char from_username[HYBBX_USER_NAME_MAX];
+ char topic[HYBBX_CONFERENCE_TOPIC_MAX];
+ hybbx_session_t *initiator;
+ char msg[HYBBX_LINE_MAX];
+
+ if (service == NULL || session == NULL || line == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_conference_invite_pending(session)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!invite_reply_yes(line) && !invite_reply_no(line)) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ {
+ const char *from_ptr = hybbx_session_conference_invite_from(session);
+ const char *topic_ptr = hybbx_session_conference_invite_topic(session);
+
+ if (from_ptr == NULL || from_ptr[0] == '\0' ||
+ topic_ptr == NULL || topic_ptr[0] == '\0') {
+ hybbx_session_clear_conference_invite(session);
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(from_username, from_ptr, sizeof(from_username));
+ hybbx_strlcpy(topic, topic_ptr, sizeof(topic));
+ }
+
+ hybbx_session_clear_conference_invite(session);
+
+ if (invite_reply_no(line)) {
+ initiator = conference_find_online_user(service, from_username);
+ if (initiator != NULL) {
+ snprintf(msg, sizeof(msg),
+ "meet status=declined user=%s",
+ hybbx_session_display_name(session));
+ (void)hybbx_msg_send_system(initiator, msg);
+ }
+ hybbx_session_write_line(session, "*** Conference invite declined.");
+ return HYBBX_OK;
+ }
+
+ if (conference_session_active(session)) {
+ hybbx_session_write_line(session, "Already in a conference.");
+ return HYBBX_OK;
+ }
+
+ initiator = conference_find_online_user(service, from_username);
+ if (initiator == NULL) {
+ hybbx_session_write_line(session, "Inviter is no longer online.");
+ return HYBBX_OK;
+ }
+
+ if (conference_session_active(initiator)) {
+ hybbx_session_write_line(session, "Inviter is already in a conference.");
+ return HYBBX_OK;
+ }
+
+ if (conference_activate(service, initiator, session, topic) != HYBBX_OK) {
+ hybbx_session_write_line(session, "Conference could not be started.");
+ return HYBBX_OK;
+ }
+
+ snprintf(msg, sizeof(msg), "meet status=accepted user=%s",
+ hybbx_session_display_name(session));
+ hybbx_session_write_line(initiator, msg);
+ if (hybbx_monitor_is_active(initiator)) {
+ hybbx_monitor_broadcast(service, msg);
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_conference_invite_tick(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ time_t deadline;
+ time_t now;
+ char from_username[HYBBX_USER_NAME_MAX];
+ const char *from_ptr;
+ hybbx_session_t *initiator;
+ char msg[HYBBX_LINE_MAX];
+
+ if (service == NULL || session == NULL) {
+ return;
+ }
+
+ if (!hybbx_conference_invite_pending(session)) {
+ return;
+ }
+
+ deadline = hybbx_session_conference_invite_deadline(session);
+ if (deadline == 0) {
+ return;
+ }
+
+ now = time(NULL);
+ if (now < deadline) {
+ return;
+ }
+
+ from_ptr = hybbx_session_conference_invite_from(session);
+ if (from_ptr != NULL) {
+ hybbx_strlcpy(from_username, from_ptr, sizeof(from_username));
+ } else {
+ from_username[0] = '\0';
+ }
+
+ hybbx_session_clear_conference_invite(session);
+ hybbx_session_write_line(session, "*** Conference invite timed out.");
+
+ if (from_username[0] != '\0') {
+ initiator = conference_find_online_user(service, from_username);
+ if (initiator != NULL) {
+ snprintf(msg, sizeof(msg), "meet status=timeout user=%s",
+ hybbx_session_display_name(session));
+ hybbx_session_write_line(initiator, msg);
+ if (hybbx_monitor_is_active(initiator)) {
+ hybbx_monitor_broadcast(service, msg);
+ }
+ }
+ }
+}
+
+hybbx_result_t hybbx_conference_monitor_meet(hybbx_service_t *service,
+ hybbx_session_t *initiator,
+ const char *partner_spec,
+ int force)
+{
+ const hybbx_monitor_config_t *mcfg;
+ hybbx_storage_t *storage;
+ hybbx_user_record_t partner_user;
+ hybbx_session_t *partner_session;
+ char line[HYBBX_LINE_MAX];
+ hybbx_result_t rc;
+ const char *topic = "monitor";
+ unsigned timeout_sec = 20u;
+
+ if (service == NULL || initiator == NULL || partner_spec == NULL ||
+ partner_spec[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mcfg = hybbx_monitor_config_get();
+ if (mcfg != NULL) {
+ timeout_sec = mcfg->invite_timeout_sec;
+ }
+
+ if (hybbx_session_is_guest(initiator)) {
+ hybbx_session_write_line(initiator, "Guests cannot use meet.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (conference_session_active(initiator)) {
+ hybbx_session_write_line(initiator, "Already in a conference.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_conference_invite_pending(initiator)) {
+ hybbx_session_write_line(initiator,
+ "Answer the pending conference invite first (y/n).");
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_resolve_user(storage, partner_spec, &partner_user);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(initiator, "Unknown user.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (!partner_user.active) {
+ hybbx_session_write_line(initiator, "User is not active.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_user_level_is_guest(partner_user.level)) {
+ hybbx_session_write_line(initiator, "Cannot conference with a guest.");
+ return HYBBX_OK;
+ }
+
+ if (str_ieq(partner_user.username, hybbx_session_username(initiator))) {
+ hybbx_session_write_line(initiator, "Cannot conference with yourself.");
+ return HYBBX_OK;
+ }
+
+ partner_session = conference_find_online_user(service, partner_user.username);
+ if (partner_session == NULL) {
+ hybbx_session_write_line(initiator, "User is not online.");
+ return HYBBX_OK;
+ }
+
+ if (conference_session_active(partner_session)) {
+ hybbx_session_write_line(initiator, "User is already in a conference.");
+ return HYBBX_OK;
+ }
+
+ if (force) {
+ if (conference_activate(service, initiator, partner_session,
+ topic) != HYBBX_OK) {
+ hybbx_session_write_line(initiator,
+ "Conference could not be started.");
+ return HYBBX_OK;
+ }
+ snprintf(line, sizeof(line), "meet status=forced user=%s",
+ hybbx_user_display_name(&partner_user));
+ hybbx_session_write_line(initiator, line);
+ hybbx_monitor_broadcast(service, line);
+ hybbx_session_write_line(partner_session,
+ "You were placed into a conference by Sysop.");
+ return HYBBX_OK;
+ }
+
+ if (hybbx_conference_invite_pending(partner_session)) {
+ hybbx_session_write_line(initiator,
+ "User has a pending conference invite.");
+ return HYBBX_OK;
+ }
+
+ hybbx_session_set_conference_invite(partner_session,
+ hybbx_session_username(initiator),
+ topic);
+ hybbx_session_set_conference_invite_deadline(
+ partner_session, time(NULL) + (time_t)timeout_sec);
+
+ snprintf(line, sizeof(line),
+ "*** Conference invite from %s: %s (reply in %u s)",
+ hybbx_session_display_name(initiator), topic, timeout_sec);
+ hybbx_session_write_line(partner_session, line);
+ hybbx_session_write_line(partner_session, "*** Accept? y/n or yes/no");
+
+ snprintf(line, sizeof(line),
+ "meet status=pending user=%s timeout=%us",
+ hybbx_user_display_name(&partner_user), timeout_sec);
+ hybbx_session_write_line(initiator, line);
+ hybbx_monitor_broadcast(service, line);
+
+ return HYBBX_OK;
+}
+
+typedef struct conference_post_ctx {
+ hybbx_session_t *from;
+ const char *message;
+ const char *from_user;
+ const char *from_login;
+} conference_post_ctx_t;
+
+static void conference_post_visitor(hybbx_session_t *session, void *userdata)
+{
+ conference_post_ctx_t *ctx = (conference_post_ctx_t *)userdata;
+ char line[HYBBX_USER_NAME_MAX + HYBBX_LINE_MAX + 16];
+ const char *session_login;
+
+ if (session == NULL || ctx == NULL || ctx->message == NULL ||
+ ctx->from == NULL || ctx->from_login == NULL) {
+ return;
+ }
+
+ if (!conference_session_active(session)) {
+ return;
+ }
+
+ if (session == ctx->from) {
+ snprintf(line, sizeof(line), "ME: %s", ctx->message);
+ hybbx_session_write_line(session, line);
+ return;
+ }
+
+ session_login = hybbx_session_username(session);
+ if (session_login == NULL ||
+ !str_ieq(hybbx_session_conference_partner(session), ctx->from_login) ||
+ !str_ieq(hybbx_session_conference_partner(ctx->from), session_login)) {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "%s: %s", ctx->from_user, ctx->message);
+ hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_conference_post(hybbx_service_t *service,
+ hybbx_session_t *from,
+ const char *message)
+{
+ conference_post_ctx_t ctx;
+ const hybbx_chat_config_t *chat;
+
+ if (service == NULL || from == NULL || message == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (message[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (!conference_session_active(from)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ chat = hybbx_service_get_chat(service);
+ if (chat == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (strlen(message) > chat->message_max) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.from = from;
+ ctx.message = message;
+ ctx.from_user = hybbx_session_display_name(from);
+ ctx.from_login = hybbx_session_username(from);
+ if (ctx.from_login == NULL || ctx.from_login[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_service_visit_sessions(service, conference_post_visitor, &ctx);
+ return HYBBX_OK;
+}
+
+void hybbx_conference_area_leaving(hybbx_session_t *session)
+{
+ const char *partner_name;
+
+ if (session == NULL) {
+ return;
+ }
+
+ partner_name = hybbx_session_conference_partner(session);
+ hybbx_session_clear_conference(session);
+
+ if (partner_name != NULL && partner_name[0] != '\0') {
+ conference_notify_partner_left(session, partner_name);
+ }
+}
+
+void hybbx_conference_session_closed(hybbx_session_t *session)
+{
+ hybbx_service_t *service;
+ const char *username;
+
+ if (session == NULL) {
+ return;
+ }
+
+ if (hybbx_conference_invite_pending(session)) {
+ hybbx_session_clear_conference_invite(session);
+ }
+
+ if (!conference_session_active(session)) {
+ service = hybbx_session_service(session);
+ username = hybbx_session_username(session);
+ if (service != NULL && username != NULL && username[0] != '\0') {
+ conference_cancel_invites_from(service, username);
+ }
+ return;
+ }
+
+ hybbx_conference_area_leaving(session);
+
+ service = hybbx_session_service(session);
+ username = hybbx_session_username(session);
+ if (service != NULL && username != NULL && username[0] != '\0') {
+ conference_cancel_invites_from(service, username);
+ }
+}
diff --git a/src/core/config.c b/src/core/config.c
new file mode 100644
index 0000000..9b84d97
--- /dev/null
+++ b/src/core/config.c
@@ -0,0 +1,862 @@
+#include "hybbx/config.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define HYBBX_CONFIG_INITIAL_CAP 32
+
+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 char *trim_inplace(char *s)
+{
+ char *end;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ while (*s != '\0' && isspace((unsigned char)*s)) {
+ s++;
+ }
+
+ if (*s == '\0') {
+ return s;
+ }
+
+ end = s + strlen(s) - 1;
+ while (end > s && isspace((unsigned char)*end)) {
+ *end = '\0';
+ end--;
+ }
+
+ return s;
+}
+
+static void strip_inline_comment_inplace(char *value)
+{
+ size_t i;
+ int in_single = 0;
+ int in_double = 0;
+
+ if (value == NULL) {
+ return;
+ }
+
+ for (i = 0; value[i] != '\0'; i++) {
+ char c = value[i];
+
+ if (!in_single && !in_double && (c == ';' || c == '#')) {
+ value[i] = '\0';
+ break;
+ }
+
+ if (!in_double && c == '\'') {
+ in_single = !in_single;
+ } else if (!in_single && c == '"') {
+ in_double = !in_double;
+ }
+ }
+
+ {
+ char *end = value + strlen(value);
+
+ while (end > value && isspace((unsigned char)*(end - 1))) {
+ *--end = '\0';
+ }
+ }
+}
+
+static int is_comment_line(const char *line)
+{
+ const char *p = line;
+
+ while (*p != '\0' && isspace((unsigned char)*p)) {
+ p++;
+ }
+
+ return *p == ';' || *p == '#';
+}
+
+static hybbx_result_t append_entry(hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ const char *value)
+{
+ hybbx_config_entry_t *entry;
+ hybbx_config_entry_t *grown;
+
+ if (section == NULL || key == NULL || value == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (strlen(section) >= HYBBX_CONFIG_SECTION_MAX ||
+ strlen(key) >= HYBBX_CONFIG_KEY_MAX ||
+ strlen(value) >= HYBBX_CONFIG_VALUE_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (config->count == 0) {
+ config->entries = malloc(HYBBX_CONFIG_INITIAL_CAP * sizeof(*config->entries));
+ if (config->entries == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ } else if ((config->count % HYBBX_CONFIG_INITIAL_CAP) == 0) {
+ grown = realloc(config->entries,
+ (config->count + HYBBX_CONFIG_INITIAL_CAP) *
+ sizeof(*config->entries));
+ if (grown == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ config->entries = grown;
+ }
+
+ entry = &config->entries[config->count];
+ entry->section = hybbx_strdup(section);
+ entry->key = hybbx_strdup(key);
+ entry->value = hybbx_strdup(value);
+
+ if (entry->section == NULL || entry->key == NULL || entry->value == NULL) {
+ free(entry->section);
+ free(entry->key);
+ free(entry->value);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ config->count++;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_config_load(hybbx_config_t *config, const char *path)
+{
+ FILE *fp;
+ char line[HYBBX_CONFIG_LINE_MAX];
+ char current_section[HYBBX_CONFIG_SECTION_MAX] = "";
+ char *eq;
+ char *key;
+ char *value;
+
+ if (config == NULL || path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(config, 0, sizeof(*config));
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *content;
+ size_t len;
+
+ len = strlen(line);
+ if (len > 0 && line[len - 1] != '\n' &&
+ len >= sizeof(line) - 1) {
+ fclose(fp);
+ hybbx_config_free(config);
+ return HYBBX_ERR_INVALID;
+ }
+
+ content = trim_inplace(line);
+
+ if (content[0] == '\0' || is_comment_line(content)) {
+ continue;
+ }
+
+ if (content[0] == '[') {
+ char *closing = strchr(content, ']');
+
+ if (closing == NULL) {
+ fclose(fp);
+ hybbx_config_free(config);
+ return HYBBX_ERR_INVALID;
+ }
+
+ *closing = '\0';
+ strncpy(current_section, content + 1, sizeof(current_section) - 1);
+ current_section[sizeof(current_section) - 1] = '\0';
+ trim_inplace(current_section);
+ continue;
+ }
+
+ if (current_section[0] == '\0') {
+ fclose(fp);
+ hybbx_config_free(config);
+ return HYBBX_ERR_INVALID;
+ }
+
+ eq = strchr(content, '=');
+ if (eq == NULL) {
+ fclose(fp);
+ hybbx_config_free(config);
+ return HYBBX_ERR_INVALID;
+ }
+
+ *eq = '\0';
+ key = trim_inplace(content);
+ value = trim_inplace(eq + 1);
+ strip_inline_comment_inplace(value);
+
+ len = strlen(value);
+ if (len >= 2 &&
+ ((value[0] == '"' && value[len - 1] == '"') ||
+ (value[0] == '\'' && value[len - 1] == '\''))) {
+ value[len - 1] = '\0';
+ value++;
+ }
+
+ if (append_entry(config, current_section, key, value) != HYBBX_OK) {
+ fclose(fp);
+ hybbx_config_free(config);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+void hybbx_config_free(hybbx_config_t *config)
+{
+ size_t i;
+
+ if (config == NULL) {
+ return;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ free(config->entries[i].section);
+ free(config->entries[i].key);
+ free(config->entries[i].value);
+ }
+
+ free(config->entries);
+ config->entries = NULL;
+ config->count = 0;
+}
+
+const char *hybbx_config_get(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ const char *default_value)
+{
+ size_t i;
+
+ if (config == NULL || section == NULL || key == NULL) {
+ return default_value;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ if (strcmp(config->entries[i].section, section) == 0 &&
+ strcmp(config->entries[i].key, key) == 0) {
+ return config->entries[i].value;
+ }
+ }
+
+ return default_value;
+}
+
+int hybbx_config_get_bool(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ int default_value)
+{
+ const char *value = hybbx_config_get(config, section, key, NULL);
+
+ if (value == NULL) {
+ return default_value;
+ }
+
+ return hybbx_parse_bool(value, default_value);
+}
+
+unsigned hybbx_config_get_uint(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ unsigned default_value,
+ unsigned min_value,
+ unsigned max_value)
+{
+ const char *value;
+ char *end;
+ unsigned long parsed;
+
+ value = hybbx_config_get(config, section, key, NULL);
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ parsed = strtoul(value, &end, 10);
+ if (end == value || *end != '\0') {
+ return default_value;
+ }
+
+ if (parsed < min_value) {
+ return min_value;
+ }
+ if (parsed > max_value) {
+ return max_value;
+ }
+
+ return (unsigned)parsed;
+}
+
+static int config_section_has_keys(const hybbx_config_t *config,
+ const char *section)
+{
+ size_t i;
+
+ if (config == NULL || section == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ const hybbx_config_entry_t *entry = &config->entries[i];
+
+ if (strcmp(entry->section, section) != 0) {
+ continue;
+ }
+ if (strcmp(entry->key, "enabled") == 0) {
+ continue;
+ }
+ return 1;
+ }
+
+ return 0;
+}
+
+static int config_suffix_is_digits(const char *suffix)
+{
+ if (suffix == NULL || suffix[0] == '\0') {
+ return 0;
+ }
+
+ while (*suffix != '\0') {
+ if (!isdigit((unsigned char)*suffix)) {
+ return 0;
+ }
+ suffix++;
+ }
+
+ return 1;
+}
+
+int hybbx_config_resolve_transport_section(const hybbx_config_t *config,
+ const char *plugin_name,
+ char *out_section,
+ size_t out_size)
+{
+ char prefix[128];
+ size_t prefix_len;
+ size_t i;
+
+ if (config == NULL || plugin_name == NULL || out_section == NULL ||
+ out_size == 0) {
+ return 0;
+ }
+
+ snprintf(prefix, sizeof(prefix), "transport.%s", plugin_name);
+ if (config_section_has_keys(config, prefix)) {
+ hybbx_strlcpy(out_section, prefix, out_size);
+ return 1;
+ }
+
+ prefix_len = strlen(prefix);
+ for (i = 0; i < config->count; i++) {
+ const char *sec = config->entries[i].section;
+ const char *suffix;
+
+ if (sec == NULL || strncmp(sec, prefix, prefix_len) != 0) {
+ continue;
+ }
+
+ suffix = sec + prefix_len;
+ if (!config_suffix_is_digits(suffix)) {
+ continue;
+ }
+ if (!config_section_has_keys(config, sec)) {
+ continue;
+ }
+
+ hybbx_strlcpy(out_section, sec, out_size);
+ return 1;
+ }
+
+ hybbx_strlcpy(out_section, prefix, out_size);
+ return 0;
+}
+
+char *hybbx_config_format_section(const hybbx_config_t *config,
+ const char *section)
+{
+ size_t i;
+ size_t cap = 64;
+ size_t len = 0;
+ char *out;
+ int first = 1;
+
+ if (config == NULL || section == NULL) {
+ return NULL;
+ }
+
+ out = malloc(cap);
+ if (out == NULL) {
+ return NULL;
+ }
+ out[0] = '\0';
+
+ for (i = 0; i < config->count; i++) {
+ const hybbx_config_entry_t *entry = &config->entries[i];
+ size_t need;
+
+ if (strcmp(entry->section, section) != 0) {
+ continue;
+ }
+
+ if (strcmp(entry->key, "enabled") == 0) {
+ continue;
+ }
+
+ need = strlen(entry->key) + strlen(entry->value) + 2;
+ if (!first) {
+ need++;
+ }
+
+ if (len + need + 1 > cap) {
+ char *grown;
+
+ while (len + need + 1 > cap) {
+ cap *= 2;
+ }
+ grown = realloc(out, cap);
+ if (grown == NULL) {
+ free(out);
+ return NULL;
+ }
+ out = grown;
+ }
+
+ if (!first) {
+ out[len++] = ';';
+ out[len] = '\0';
+ }
+
+ len += (size_t)snprintf(out + len, cap - len, "%s=%s",
+ entry->key, entry->value);
+ first = 0;
+ }
+
+ return out;
+}
+
+typedef struct format_transport_ctx {
+ const hybbx_config_t *config;
+ const char *prefix;
+ size_t prefix_len;
+ char **sections;
+ size_t count;
+ size_t cap;
+} format_transport_ctx_t;
+
+static int format_transport_section_seen(format_transport_ctx_t *ctx,
+ const char *section)
+{
+ size_t i;
+
+ for (i = 0; i < ctx->count; i++) {
+ if (strcmp(ctx->sections[i], section) == 0) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+static hybbx_result_t format_transport_section_add(format_transport_ctx_t *ctx,
+ const char *section)
+{
+ char *copy;
+
+ if (format_transport_section_seen(ctx, section)) {
+ return HYBBX_OK;
+ }
+
+ if (ctx->count >= ctx->cap) {
+ size_t new_cap = ctx->cap == 0 ? 4 : ctx->cap * 2;
+ char **grown = realloc(ctx->sections, new_cap * sizeof(*grown));
+
+ if (grown == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ ctx->sections = grown;
+ ctx->cap = new_cap;
+ }
+
+ copy = hybbx_strdup(section);
+ if (copy == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ ctx->sections[ctx->count++] = copy;
+ return HYBBX_OK;
+}
+
+char *hybbx_config_format_transport_sections(const hybbx_config_t *config,
+ const char *plugin_name)
+{
+ format_transport_ctx_t ctx;
+ char prefix[128];
+ size_t i;
+ size_t len = 0;
+ size_t cap = 64;
+ char *out;
+ hybbx_result_t rc;
+
+ if (config == NULL || plugin_name == NULL) {
+ return NULL;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.config = config;
+ snprintf(prefix, sizeof(prefix), "transport.%s", plugin_name);
+ ctx.prefix = prefix;
+ ctx.prefix_len = strlen(prefix);
+
+ if (config_section_has_keys(config, prefix)) {
+ rc = format_transport_section_add(&ctx, prefix);
+ if (rc != HYBBX_OK) {
+ goto fail;
+ }
+ }
+
+ for (i = 0; i < config->count; i++) {
+ const char *sec = config->entries[i].section;
+ const char *suffix;
+
+ if (sec == NULL || strncmp(sec, prefix, ctx.prefix_len) != 0) {
+ continue;
+ }
+
+ suffix = sec + ctx.prefix_len;
+ if (!config_suffix_is_digits(suffix)) {
+ continue;
+ }
+ if (!config_section_has_keys(config, sec)) {
+ continue;
+ }
+
+ rc = format_transport_section_add(&ctx, sec);
+ if (rc != HYBBX_OK) {
+ goto fail;
+ }
+ }
+
+ if (ctx.count == 0) {
+ for (i = 0; i < ctx.count; i++) {
+ free(ctx.sections[i]);
+ }
+ free(ctx.sections);
+ return hybbx_config_format_section(config, prefix);
+ }
+
+ out = malloc(cap);
+ if (out == NULL) {
+ rc = HYBBX_ERR_NOMEM;
+ goto fail;
+ }
+ out[0] = '\0';
+
+ for (i = 0; i < ctx.count; i++) {
+ char *section_cfg = hybbx_config_format_section(config, ctx.sections[i]);
+ size_t part_len;
+
+ if (section_cfg == NULL) {
+ free(out);
+ rc = HYBBX_ERR_NOMEM;
+ goto fail;
+ }
+
+ part_len = strlen(section_cfg);
+ if (len > 0) {
+ if (len + 1 + part_len + 1 > cap) {
+ while (len + 1 + part_len + 1 > cap) {
+ cap *= 2;
+ }
+ {
+ char *grown = realloc(out, cap);
+
+ if (grown == NULL) {
+ free(section_cfg);
+ free(out);
+ rc = HYBBX_ERR_NOMEM;
+ goto fail;
+ }
+ out = grown;
+ }
+ }
+ out[len++] = HYBBX_PACKET_RADIO_INSTANCE_SEP;
+ out[len] = '\0';
+ }
+
+ if (len + part_len + 1 > cap) {
+ while (len + part_len + 1 > cap) {
+ cap *= 2;
+ }
+ {
+ char *grown = realloc(out, cap);
+
+ if (grown == NULL) {
+ free(section_cfg);
+ free(out);
+ rc = HYBBX_ERR_NOMEM;
+ goto fail;
+ }
+ out = grown;
+ }
+ }
+
+ memcpy(out + len, section_cfg, part_len + 1);
+ len += part_len;
+ free(section_cfg);
+ }
+
+ for (i = 0; i < ctx.count; i++) {
+ free(ctx.sections[i]);
+ }
+ free(ctx.sections);
+ return out;
+
+fail:
+ for (i = 0; i < ctx.count; i++) {
+ free(ctx.sections[i]);
+ }
+ free(ctx.sections);
+ return NULL;
+}
+
+char *hybbx_config_prepend_packet_radio_max25(const hybbx_config_t *config,
+ const char *body)
+{
+ char prefix[HYBBX_CONFIG_VALUE_MAX * 2];
+ char *out;
+ size_t prefix_len;
+ size_t body_len;
+ int check;
+
+ if (config == NULL) {
+ return body != NULL ? hybbx_strdup(body) : NULL;
+ }
+
+ if (body == NULL || body[0] == '\0') {
+ return hybbx_strdup(body != NULL ? body : "");
+ }
+
+ check = hybbx_config_get_bool(config, "max25", "check", 1);
+ snprintf(prefix, sizeof(prefix),
+ "max25_check=%s;max25_host=%s;max25_port=%u;max25_timeout_ms=%u",
+ check ? "yes" : "no",
+ hybbx_config_get(config, "max25", "host", "127.0.0.1"),
+ hybbx_config_get_uint(config, "max25", "port",
+ HYBBX_MAX25_DEFAULT_PORT, 1u, 65535u),
+ hybbx_config_get_uint(config, "max25", "timeout_ms",
+ HYBBX_MAX25_PROBE_TIMEOUT_MS,
+ 100u, 60000u));
+
+ if (body == NULL || body[0] == '\0') {
+ return hybbx_strdup(prefix);
+ }
+
+ prefix_len = strlen(prefix);
+ body_len = strlen(body);
+ out = malloc(prefix_len + 1u + body_len + 1u);
+ if (out == NULL) {
+ return NULL;
+ }
+
+ memcpy(out, prefix, prefix_len);
+ out[prefix_len] = HYBBX_PACKET_RADIO_INSTANCE_SEP;
+ memcpy(out + prefix_len + 1u, body, body_len + 1u);
+ return out;
+}
+
+char *hybbx_config_format_packet_radio_start(const hybbx_config_t *config)
+{
+ char *body;
+
+ body = hybbx_config_format_transport_sections(config, "packet_radio");
+ if (body == NULL) {
+ return NULL;
+ }
+
+ {
+ char *out = hybbx_config_prepend_packet_radio_max25(config, body);
+
+ free(body);
+ return out;
+ }
+}
+
+char *hybbx_config_format_baycom_start(const hybbx_config_t *config)
+{
+ char *body;
+
+ body = hybbx_config_format_transport_sections(config, "baycom");
+ if (body == NULL) {
+ return NULL;
+ }
+
+ {
+ char *out = hybbx_config_prepend_packet_radio_max25(config, body);
+
+ free(body);
+ return out;
+ }
+}
+
+void hybbx_config_foreach(const hybbx_config_t *config,
+ hybbx_config_iter_fn fn, void *ctx)
+{
+ size_t i;
+
+ if (config == NULL || fn == NULL) {
+ return;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ const hybbx_config_entry_t *entry = &config->entries[i];
+
+ fn(entry->section, entry->key, entry->value, ctx);
+ }
+}
+
+void hybbx_config_foreach_section(const hybbx_config_t *config,
+ hybbx_config_section_iter_fn fn, void *ctx)
+{
+ size_t i;
+
+ if (config == NULL || fn == NULL) {
+ return;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ const char *section = config->entries[i].section;
+ size_t j;
+ int seen = 0;
+
+ for (j = 0; j < i; j++) {
+ if (strcmp(config->entries[j].section, section) == 0) {
+ seen = 1;
+ break;
+ }
+ }
+
+ if (!seen) {
+ fn(section, ctx);
+ }
+ }
+}
+
+hybbx_result_t hybbx_config_set(hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ const char *value)
+{
+ size_t i;
+
+ if (config == NULL || section == NULL || key == NULL || value == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ if (strcmp(config->entries[i].section, section) == 0 &&
+ strcmp(config->entries[i].key, key) == 0) {
+ char *copy = hybbx_strdup(value);
+
+ if (copy == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ free(config->entries[i].value);
+ config->entries[i].value = copy;
+ return HYBBX_OK;
+ }
+ }
+
+ return append_entry(config, section, key, value);
+}
+
+void hybbx_config_remove_section(hybbx_config_t *config, const char *section)
+{
+ size_t i;
+
+ if (config == NULL || section == NULL) {
+ return;
+ }
+
+ i = 0;
+ while (i < config->count) {
+ if (strcmp(config->entries[i].section, section) == 0) {
+ free(config->entries[i].section);
+ free(config->entries[i].key);
+ free(config->entries[i].value);
+ if (i + 1 < config->count) {
+ memmove(&config->entries[i], &config->entries[i + 1],
+ (config->count - i - 1) * sizeof(config->entries[i]));
+ }
+ config->count--;
+ } else {
+ i++;
+ }
+ }
+}
+
+hybbx_result_t hybbx_config_save(const hybbx_config_t *config,
+ const char *path)
+{
+ FILE *fp;
+ size_t i;
+ const char *current = NULL;
+
+ if (config == NULL || path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ for (i = 0; i < config->count; i++) {
+ const hybbx_config_entry_t *entry = &config->entries[i];
+
+ if (current == NULL || strcmp(current, entry->section) != 0) {
+ if (current != NULL) {
+ fputc('\n', fp);
+ }
+ fprintf(fp, "[%s]\n", entry->section);
+ current = entry->section;
+ }
+ fprintf(fp, "%s = %s\n", entry->key, entry->value);
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
diff --git a/src/core/crdop.c b/src/core/crdop.c
new file mode 100644
index 0000000..67b3575
--- /dev/null
+++ b/src/core/crdop.c
@@ -0,0 +1,66 @@
+#include "hybbx/crdop.h"
+
+#include <ctype.h>
+#include <string.h>
+
+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';
+}
+
+hybbx_crdop_radio_profile_t hybbx_crdop_profile_parse(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_CRDOP_PROFILE_AMATEUR;
+ }
+
+ if (str_ieq(value, "cb") || str_ieq(value, "11m") ||
+ str_ieq(value, "citizens_band")) {
+ return HYBBX_CRDOP_PROFILE_CB;
+ }
+
+ return HYBBX_CRDOP_PROFILE_AMATEUR;
+}
+
+const char *hybbx_crdop_profile_name(hybbx_crdop_radio_profile_t profile)
+{
+ return profile == HYBBX_CRDOP_PROFILE_CB ? "cb" : "amateur";
+}
+
+const char *hybbx_crdop_default_arq_bandwidth(hybbx_crdop_radio_profile_t profile)
+{
+ return profile == HYBBX_CRDOP_PROFILE_CB ? "500MAX" : "500MAX";
+}
+
+int hybbx_crdop_bandwidth_exceeds_cb(const char *arq_bandwidth)
+{
+ const char *p;
+ unsigned n = 0;
+
+ if (arq_bandwidth == NULL || arq_bandwidth[0] == '\0') {
+ return 0;
+ }
+
+ p = arq_bandwidth;
+ while (*p >= '0' && *p <= '9') {
+ n = n * 10u + (unsigned)(*p - '0');
+ p++;
+ }
+
+ return n > 1000u;
+}
diff --git a/src/core/crypto.c b/src/core/crypto.c
new file mode 100644
index 0000000..dffcea4
--- /dev/null
+++ b/src/core/crypto.c
@@ -0,0 +1,302 @@
+#include "hybbx/crypto.h"
+
+#include "crypto_backends.h"
+#include "monocypher.h"
+
+#include <string.h>
+
+#define HYBBX_CRYPTO_MAX_BYTES (256u * 1024u)
+
+static hybbx_result_t crypto_len_ok(size_t len)
+{
+ if (len > HYBBX_CRYPTO_MAX_BYTES) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_crypto_random(uint8_t *buf, size_t len)
+{
+ if (buf == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_backend_random(buf, len);
+}
+
+const char *hybbx_crypto_alg_name(hybbx_crypto_alg_t alg)
+{
+ switch (alg) {
+ case HYBBX_CRYPTO_AES_256_GCM:
+ return "aes-256-gcm";
+ case HYBBX_CRYPTO_XCHACHA20_POLY1305:
+ return "xchacha20-poly1305";
+ case HYBBX_CRYPTO_X25519_AEAD:
+ return "x25519-xchacha20-poly1305";
+ default:
+ return "unknown";
+ }
+}
+
+size_t hybbx_crypto_nonce_size(hybbx_crypto_alg_t alg)
+{
+ switch (alg) {
+ case HYBBX_CRYPTO_AES_256_GCM:
+ return HYBBX_CRYPTO_AES_GCM_NONCE;
+ case HYBBX_CRYPTO_XCHACHA20_POLY1305:
+ return HYBBX_CRYPTO_XCHACHA_NONCE;
+ default:
+ return 0;
+ }
+}
+
+static hybbx_result_t derive_x25519_key(const uint8_t shared[32],
+ uint8_t key[32])
+{
+ static const uint8_t label[] = "hybbx-x25519-aead-key-v1";
+
+ hybbx_backend_blake2b_keyed(key, 32, shared, 32, label, sizeof(label) - 1);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_crypto_encrypt(hybbx_crypto_alg_t alg,
+ const uint8_t key[HYBBX_CRYPTO_KEY_SIZE],
+ const uint8_t *nonce, size_t nonce_len,
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *plaintext,
+ size_t plaintext_len,
+ uint8_t *ciphertext,
+ uint8_t tag[HYBBX_CRYPTO_TAG_SIZE])
+{
+ hybbx_result_t rc;
+
+ if (key == NULL || nonce == NULL || tag == NULL ||
+ (plaintext_len > 0 && (plaintext == NULL || ciphertext == NULL))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = crypto_len_ok(plaintext_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (alg == HYBBX_CRYPTO_AES_256_GCM) {
+ if (nonce_len != HYBBX_CRYPTO_AES_GCM_NONCE) {
+ return HYBBX_ERR_INVALID;
+ }
+ if (hybbx_backend_aes256gcm_encrypt(key, nonce, aad, aad_len, plaintext,
+ plaintext_len, ciphertext, tag) !=
+ 0) {
+ return HYBBX_ERR_IO;
+ }
+ return HYBBX_OK;
+ }
+
+ if (alg == HYBBX_CRYPTO_XCHACHA20_POLY1305) {
+ uint8_t nonce24[HYBBX_CRYPTO_XCHACHA_NONCE];
+
+ if (nonce_len != HYBBX_CRYPTO_XCHACHA_NONCE) {
+ return HYBBX_ERR_INVALID;
+ }
+ memcpy(nonce24, nonce, sizeof(nonce24));
+ hybbx_backend_chacha_lock(ciphertext, tag, key, nonce24, aad, aad_len,
+ plaintext, plaintext_len);
+ crypto_wipe(nonce24, sizeof(nonce24));
+ return HYBBX_OK;
+ }
+
+ return HYBBX_ERR_INVALID;
+}
+
+hybbx_result_t hybbx_crypto_decrypt(hybbx_crypto_alg_t alg,
+ const uint8_t key[HYBBX_CRYPTO_KEY_SIZE],
+ const uint8_t *nonce, size_t nonce_len,
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *ciphertext,
+ size_t ciphertext_len,
+ uint8_t *plaintext,
+ const uint8_t tag[HYBBX_CRYPTO_TAG_SIZE])
+{
+ hybbx_result_t rc;
+ int ok;
+
+ if (key == NULL || nonce == NULL || tag == NULL ||
+ (ciphertext_len > 0 && (ciphertext == NULL || plaintext == NULL))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = crypto_len_ok(ciphertext_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (alg == HYBBX_CRYPTO_AES_256_GCM) {
+ if (nonce_len != HYBBX_CRYPTO_AES_GCM_NONCE) {
+ return HYBBX_ERR_INVALID;
+ }
+ ok = hybbx_backend_aes256gcm_decrypt(key, nonce, aad, aad_len,
+ ciphertext, ciphertext_len,
+ plaintext, tag);
+ return ok == 0 ? HYBBX_OK : HYBBX_ERR_DENIED;
+ }
+
+ if (alg == HYBBX_CRYPTO_XCHACHA20_POLY1305) {
+ uint8_t nonce24[HYBBX_CRYPTO_XCHACHA_NONCE];
+
+ if (nonce_len != HYBBX_CRYPTO_XCHACHA_NONCE) {
+ return HYBBX_ERR_INVALID;
+ }
+ memcpy(nonce24, nonce, sizeof(nonce24));
+ ok = hybbx_backend_chacha_unlock(plaintext, tag, key, nonce24, aad,
+ aad_len, ciphertext, ciphertext_len);
+ crypto_wipe(nonce24, sizeof(nonce24));
+ return ok == 0 ? HYBBX_OK : HYBBX_ERR_DENIED;
+ }
+
+ return HYBBX_ERR_INVALID;
+}
+
+hybbx_result_t hybbx_crypto_x25519_keypair(
+ uint8_t public_key[HYBBX_CRYPTO_X25519_PUBLIC_KEY],
+ uint8_t secret_key[HYBBX_CRYPTO_X25519_SECRET_KEY])
+{
+ if (public_key == NULL || secret_key == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_crypto_random(secret_key, HYBBX_CRYPTO_X25519_SECRET_KEY) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ secret_key[0] &= 248;
+ secret_key[31] &= 127;
+ secret_key[31] |= 64;
+
+ hybbx_backend_x25519_public_key(public_key, secret_key);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_crypto_x25519_seal(
+ const uint8_t recipient_pk[HYBBX_CRYPTO_X25519_PUBLIC_KEY],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *plaintext, size_t plaintext_len,
+ uint8_t *sealed, size_t *sealed_len, size_t sealed_cap)
+{
+ uint8_t ep_sk[32];
+ uint8_t ep_pk[32];
+ uint8_t shared[32];
+ uint8_t key[32];
+ uint8_t nonce[HYBBX_CRYPTO_XCHACHA_NONCE];
+ uint8_t *cipher;
+ uint8_t *tag;
+ size_t need;
+ hybbx_result_t rc;
+
+ if (recipient_pk == NULL || sealed == NULL || sealed_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = crypto_len_ok(plaintext_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ need = HYBBX_CRYPTO_X25519_SEAL_OVERHEAD + plaintext_len;
+ if (sealed_cap < need) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_crypto_random(ep_sk, sizeof(ep_sk)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ ep_sk[0] &= 248;
+ ep_sk[31] &= 127;
+ ep_sk[31] |= 64;
+
+ hybbx_backend_x25519_public_key(ep_pk, ep_sk);
+ hybbx_backend_x25519_shared(shared, ep_sk, recipient_pk);
+ derive_x25519_key(shared, key);
+
+ if (hybbx_crypto_random(nonce, sizeof(nonce)) != HYBBX_OK) {
+ crypto_wipe(ep_sk, sizeof(ep_sk));
+ crypto_wipe(shared, sizeof(shared));
+ crypto_wipe(key, sizeof(key));
+ return HYBBX_ERR_IO;
+ }
+
+ memcpy(sealed, ep_pk, 32);
+ memcpy(sealed + 32, nonce, sizeof(nonce));
+ cipher = sealed + 32 + sizeof(nonce);
+ tag = sealed + 32 + sizeof(nonce) + plaintext_len;
+
+ rc = hybbx_crypto_encrypt(HYBBX_CRYPTO_XCHACHA20_POLY1305, key, nonce,
+ sizeof(nonce), aad, aad_len, plaintext,
+ plaintext_len, cipher, tag);
+
+ crypto_wipe(ep_sk, sizeof(ep_sk));
+ crypto_wipe(shared, sizeof(shared));
+ crypto_wipe(key, sizeof(key));
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ *sealed_len = need;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_crypto_x25519_open(
+ const uint8_t recipient_sk[HYBBX_CRYPTO_X25519_SECRET_KEY],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *sealed, size_t sealed_len,
+ uint8_t *plaintext, size_t *plaintext_len, size_t plaintext_cap)
+{
+ const uint8_t *ep_pk;
+ const uint8_t *nonce;
+ const uint8_t *cipher;
+ const uint8_t *tag;
+ size_t cipher_len;
+ uint8_t shared[32];
+ uint8_t key[32];
+ uint8_t tag_buf[HYBBX_CRYPTO_TAG_SIZE];
+ hybbx_result_t rc;
+
+ if (recipient_sk == NULL || sealed == NULL || plaintext == NULL ||
+ plaintext_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (sealed_len < HYBBX_CRYPTO_X25519_SEAL_OVERHEAD) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cipher_len = sealed_len - HYBBX_CRYPTO_X25519_SEAL_OVERHEAD;
+ if (cipher_len > plaintext_cap) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ep_pk = sealed;
+ nonce = sealed + 32;
+ cipher = sealed + 32 + HYBBX_CRYPTO_XCHACHA_NONCE;
+ tag = cipher + cipher_len;
+ memcpy(tag_buf, tag, sizeof(tag_buf));
+
+ hybbx_backend_x25519_shared(shared, recipient_sk, ep_pk);
+ derive_x25519_key(shared, key);
+
+ rc = hybbx_crypto_decrypt(HYBBX_CRYPTO_XCHACHA20_POLY1305, key, nonce,
+ HYBBX_CRYPTO_XCHACHA_NONCE, aad, aad_len,
+ cipher, cipher_len, plaintext, tag_buf);
+
+ crypto_wipe(shared, sizeof(shared));
+ crypto_wipe(key, sizeof(key));
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ *plaintext_len = cipher_len;
+ return HYBBX_OK;
+}
diff --git a/src/core/crypto_backends.c b/src/core/crypto_backends.c
new file mode 100644
index 0000000..da25497
--- /dev/null
+++ b/src/core/crypto_backends.c
@@ -0,0 +1,223 @@
+#include "crypto_backends.h"
+#include "hybbx/crypto_config.h"
+
+#include "aes256gcm.h"
+#include "monocypher.h"
+#include "tinysha256.h"
+
+#include <fcntl.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+#include <sys/random.h>
+#endif
+
+#if defined(HYBBX_HAVE_OPENSSL)
+void hybbx_openssl_sha256_hex(const char *data, size_t len, char hex[65]);
+int hybbx_openssl_aes256gcm_encrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *plaintext, size_t plaintext_len,
+ uint8_t *ciphertext, uint8_t tag[16]);
+int hybbx_openssl_aes256gcm_decrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *ciphertext, size_t ciphertext_len,
+ uint8_t *plaintext, const uint8_t tag[16]);
+hybbx_result_t hybbx_openssl_random(uint8_t *buf, size_t len);
+#endif
+
+#if defined(HYBBX_HAVE_LIBSODIUM)
+void hybbx_libsodium_chacha_lock(uint8_t *ciphertext, uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *plaintext, size_t plaintext_len);
+int hybbx_libsodium_chacha_unlock(uint8_t *plaintext, const uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *ciphertext, size_t ciphertext_len);
+void hybbx_libsodium_x25519_public_key(uint8_t public_key[32],
+ const uint8_t secret_key[32]);
+void hybbx_libsodium_x25519_shared(uint8_t shared[32],
+ const uint8_t secret_key[32],
+ const uint8_t peer_public_key[32]);
+#endif
+
+void hybbx_backend_sha256_hex(const char *data, size_t len, char hex[65])
+{
+#if defined(HYBBX_HAVE_OPENSSL)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->password_hash == HYBBX_PASSWORD_HASH_OPENSSL) {
+ hybbx_openssl_sha256_hex(data, len, hex);
+ return;
+ }
+#endif
+
+ tinysha256_hex(data, len, hex);
+}
+
+int hybbx_backend_aes256gcm_encrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *plaintext, size_t plaintext_len,
+ uint8_t *ciphertext, uint8_t tag[16])
+{
+#if defined(HYBBX_HAVE_OPENSSL)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->aes_gcm == HYBBX_AES_GCM_OPENSSL) {
+ return hybbx_openssl_aes256gcm_encrypt(key, nonce, aad, aad_len,
+ plaintext, plaintext_len,
+ ciphertext, tag);
+ }
+#endif
+
+ return hybbx_aes256gcm_encrypt(key, nonce, aad, aad_len, plaintext,
+ plaintext_len, ciphertext, tag);
+}
+
+int hybbx_backend_aes256gcm_decrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *ciphertext, size_t ciphertext_len,
+ uint8_t *plaintext, const uint8_t tag[16])
+{
+#if defined(HYBBX_HAVE_OPENSSL)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->aes_gcm == HYBBX_AES_GCM_OPENSSL) {
+ return hybbx_openssl_aes256gcm_decrypt(key, nonce, aad, aad_len,
+ ciphertext, ciphertext_len,
+ plaintext, tag);
+ }
+#endif
+
+ return hybbx_aes256gcm_decrypt(key, nonce, aad, aad_len, ciphertext,
+ ciphertext_len, plaintext, tag);
+}
+
+void hybbx_backend_chacha_lock(uint8_t *ciphertext, uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *plaintext, size_t plaintext_len)
+{
+#if defined(HYBBX_HAVE_LIBSODIUM)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->chacha == HYBBX_CHACHA_LIBSODIUM) {
+ hybbx_libsodium_chacha_lock(ciphertext, tag, key, nonce, aad, aad_len,
+ plaintext, plaintext_len);
+ return;
+ }
+#endif
+
+ crypto_aead_lock(ciphertext, tag, key, nonce, aad, aad_len, plaintext,
+ plaintext_len);
+}
+
+int hybbx_backend_chacha_unlock(uint8_t *plaintext, const uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *ciphertext, size_t ciphertext_len)
+{
+#if defined(HYBBX_HAVE_LIBSODIUM)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->chacha == HYBBX_CHACHA_LIBSODIUM) {
+ return hybbx_libsodium_chacha_unlock(plaintext, tag, key, nonce, aad,
+ aad_len, ciphertext,
+ ciphertext_len);
+ }
+#endif
+
+ return crypto_aead_unlock(plaintext, tag, key, nonce, aad, aad_len,
+ ciphertext, ciphertext_len);
+}
+
+void hybbx_backend_x25519_public_key(uint8_t public_key[32],
+ const uint8_t secret_key[32])
+{
+#if defined(HYBBX_HAVE_LIBSODIUM)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->x25519 == HYBBX_X25519_LIBSODIUM) {
+ hybbx_libsodium_x25519_public_key(public_key, secret_key);
+ return;
+ }
+#endif
+
+ crypto_x25519_public_key(public_key, secret_key);
+}
+
+void hybbx_backend_x25519_shared(uint8_t shared[32],
+ const uint8_t secret_key[32],
+ const uint8_t peer_public_key[32])
+{
+#if defined(HYBBX_HAVE_LIBSODIUM)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->x25519 == HYBBX_X25519_LIBSODIUM) {
+ hybbx_libsodium_x25519_shared(shared, secret_key, peer_public_key);
+ return;
+ }
+#endif
+
+ crypto_x25519(shared, secret_key, peer_public_key);
+}
+
+void hybbx_backend_blake2b_keyed(uint8_t *hash, size_t hash_size,
+ const uint8_t *message, size_t message_size,
+ const uint8_t *key, size_t key_size)
+{
+ crypto_blake2b_keyed(hash, hash_size, message, message_size, key, key_size);
+}
+
+static hybbx_result_t system_random(uint8_t *buf, size_t len)
+{
+ size_t done = 0;
+
+#if defined(__linux__)
+ while (done < len) {
+ ssize_t n = getrandom(buf + done, len - done, 0);
+
+ if (n < 0) {
+ break;
+ }
+ done += (size_t)n;
+ }
+#endif
+
+ if (done < len) {
+ int fd = open("/dev/urandom", O_RDONLY);
+
+ if (fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ while (done < len) {
+ ssize_t n = read(fd, buf + done, len - done);
+
+ if (n <= 0) {
+ close(fd);
+ return HYBBX_ERR_IO;
+ }
+ done += (size_t)n;
+ }
+
+ close(fd);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_backend_random(uint8_t *buf, size_t len)
+{
+#if defined(HYBBX_HAVE_OPENSSL)
+ const hybbx_crypto_config_t *cfg = hybbx_crypto_config_get();
+
+ if (cfg->random == HYBBX_RANDOM_OPENSSL) {
+ return hybbx_openssl_random(buf, len);
+ }
+#endif
+
+ return system_random(buf, len);
+}
diff --git a/src/core/crypto_backends.h b/src/core/crypto_backends.h
new file mode 100644
index 0000000..0a7f1a1
--- /dev/null
+++ b/src/core/crypto_backends.h
@@ -0,0 +1,62 @@
+#ifndef HYBBX_CRYPTO_BACKENDS_H
+#define HYBBX_CRYPTO_BACKENDS_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_BACKEND_SHA256_HEX_SIZE 65
+#define HYBBX_BACKEND_AES_GCM_KEY_SIZE 32
+#define HYBBX_BACKEND_AES_GCM_NONCE_SIZE 12
+#define HYBBX_BACKEND_AES_GCM_TAG_SIZE 16
+#define HYBBX_BACKEND_XCHACHA_NONCE_SIZE 24
+#define HYBBX_BACKEND_TAG_SIZE 16
+
+void hybbx_backend_sha256_hex(const char *data, size_t len, char hex[65]);
+
+int hybbx_backend_aes256gcm_encrypt(
+ const uint8_t key[HYBBX_BACKEND_AES_GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_BACKEND_AES_GCM_NONCE_SIZE],
+ const uint8_t *aad, size_t aad_len, const uint8_t *plaintext,
+ size_t plaintext_len, uint8_t *ciphertext,
+ uint8_t tag[HYBBX_BACKEND_AES_GCM_TAG_SIZE]);
+
+int hybbx_backend_aes256gcm_decrypt(
+ const uint8_t key[HYBBX_BACKEND_AES_GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_BACKEND_AES_GCM_NONCE_SIZE],
+ const uint8_t *aad, size_t aad_len, const uint8_t *ciphertext,
+ size_t ciphertext_len, uint8_t *plaintext,
+ const uint8_t tag[HYBBX_BACKEND_AES_GCM_TAG_SIZE]);
+
+void hybbx_backend_chacha_lock(uint8_t *ciphertext, uint8_t tag[16],
+ const uint8_t key[32],
+ const uint8_t nonce[24], const uint8_t *aad,
+ size_t aad_len, const uint8_t *plaintext,
+ size_t plaintext_len);
+
+int hybbx_backend_chacha_unlock(uint8_t *plaintext, const uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *ciphertext, size_t ciphertext_len);
+
+void hybbx_backend_x25519_public_key(uint8_t public_key[32],
+ const uint8_t secret_key[32]);
+void hybbx_backend_x25519_shared(uint8_t shared[32],
+ const uint8_t secret_key[32],
+ const uint8_t peer_public_key[32]);
+void hybbx_backend_blake2b_keyed(uint8_t *hash, size_t hash_size,
+ const uint8_t *message, size_t message_size,
+ const uint8_t *key, size_t key_size);
+
+hybbx_result_t hybbx_backend_random(uint8_t *buf, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CRYPTO_BACKENDS_H */
diff --git a/src/core/crypto_config.c b/src/core/crypto_config.c
new file mode 100644
index 0000000..65b64ef
--- /dev/null
+++ b/src/core/crypto_config.c
@@ -0,0 +1,247 @@
+#include "hybbx/crypto_config.h"
+#include "hybbx/config.h"
+#include "hybbx/log.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <string.h>
+
+static hybbx_crypto_config_t g_crypto_config;
+static int g_crypto_config_ready;
+
+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 hybbx_password_hash_backend_t parse_password_hash(const char *value)
+{
+ if (value == NULL || value[0] == '\0' ||
+ str_ieq(value, "tinysha256") || str_ieq(value, "bundled") ||
+ str_ieq(value, "default")) {
+ return HYBBX_PASSWORD_HASH_TINYSHA256;
+ }
+
+ if (str_ieq(value, "openssl") || str_ieq(value, "libcrypto")) {
+ return HYBBX_PASSWORD_HASH_OPENSSL;
+ }
+
+ return HYBBX_PASSWORD_HASH_TINYSHA256;
+}
+
+static hybbx_aes_gcm_backend_t parse_aes_gcm(const char *value)
+{
+ if (value == NULL || value[0] == '\0' ||
+ str_ieq(value, "tinyaes") || str_ieq(value, "bundled") ||
+ str_ieq(value, "default")) {
+ return HYBBX_AES_GCM_TINYAES;
+ }
+
+ if (str_ieq(value, "openssl") || str_ieq(value, "libcrypto")) {
+ return HYBBX_AES_GCM_OPENSSL;
+ }
+
+ return HYBBX_AES_GCM_TINYAES;
+}
+
+static hybbx_chacha_backend_t parse_chacha(const char *value)
+{
+ if (value == NULL || value[0] == '\0' ||
+ str_ieq(value, "monocypher") || str_ieq(value, "bundled") ||
+ str_ieq(value, "default")) {
+ return HYBBX_CHACHA_MONOCYPHER;
+ }
+
+ if (str_ieq(value, "libsodium") || str_ieq(value, "sodium")) {
+ return HYBBX_CHACHA_LIBSODIUM;
+ }
+
+ return HYBBX_CHACHA_MONOCYPHER;
+}
+
+static hybbx_x25519_backend_t parse_x25519(const char *value)
+{
+ if (value == NULL || value[0] == '\0' ||
+ str_ieq(value, "monocypher") || str_ieq(value, "bundled") ||
+ str_ieq(value, "default")) {
+ return HYBBX_X25519_MONOCYPHER;
+ }
+
+ if (str_ieq(value, "libsodium") || str_ieq(value, "sodium")) {
+ return HYBBX_X25519_LIBSODIUM;
+ }
+
+ return HYBBX_X25519_MONOCYPHER;
+}
+
+static hybbx_random_backend_t parse_random(const char *value)
+{
+ if (value == NULL || value[0] == '\0' ||
+ str_ieq(value, "system") || str_ieq(value, "bundled") ||
+ str_ieq(value, "default") || str_ieq(value, "getrandom") ||
+ str_ieq(value, "urandom")) {
+ return HYBBX_RANDOM_SYSTEM;
+ }
+
+ if (str_ieq(value, "openssl") || str_ieq(value, "libcrypto")) {
+ return HYBBX_RANDOM_OPENSSL;
+ }
+
+ return HYBBX_RANDOM_SYSTEM;
+}
+
+static void resolve_backends(hybbx_crypto_config_t *cfg)
+{
+#if !defined(HYBBX_HAVE_OPENSSL)
+ if (cfg->password_hash == HYBBX_PASSWORD_HASH_OPENSSL) {
+ hybbx_log_warn("[crypto] password_hash=openssl requested but HyBBX was not "
+ "built with OpenSSL; using tinysha256");
+ cfg->password_hash = HYBBX_PASSWORD_HASH_TINYSHA256;
+ }
+ if (cfg->aes_gcm == HYBBX_AES_GCM_OPENSSL) {
+ hybbx_log_warn("[crypto] aes_gcm=openssl requested but HyBBX was not built "
+ "with OpenSSL; using tinyaes");
+ cfg->aes_gcm = HYBBX_AES_GCM_TINYAES;
+ }
+ if (cfg->random == HYBBX_RANDOM_OPENSSL) {
+ hybbx_log_warn("[crypto] random=openssl requested but HyBBX was not built "
+ "with OpenSSL; using system");
+ cfg->random = HYBBX_RANDOM_SYSTEM;
+ }
+#endif
+
+#if !defined(HYBBX_HAVE_LIBSODIUM)
+ if (cfg->chacha == HYBBX_CHACHA_LIBSODIUM) {
+ hybbx_log_warn("[crypto] chacha=libsodium requested but HyBBX was not built "
+ "with libsodium; using monocypher");
+ cfg->chacha = HYBBX_CHACHA_MONOCYPHER;
+ }
+ if (cfg->x25519 == HYBBX_X25519_LIBSODIUM) {
+ hybbx_log_warn("[crypto] x25519=libsodium requested but HyBBX was not built "
+ "with libsodium; using monocypher");
+ cfg->x25519 = HYBBX_X25519_MONOCYPHER;
+ }
+#endif
+}
+
+void hybbx_crypto_config_defaults(hybbx_crypto_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ cfg->password_hash = HYBBX_PASSWORD_HASH_TINYSHA256;
+ cfg->aes_gcm = HYBBX_AES_GCM_TINYAES;
+ cfg->chacha = HYBBX_CHACHA_MONOCYPHER;
+ cfg->x25519 = HYBBX_X25519_MONOCYPHER;
+ cfg->random = HYBBX_RANDOM_SYSTEM;
+}
+
+void hybbx_crypto_config_apply(const hybbx_config_t *config)
+{
+ const char *value;
+
+ hybbx_crypto_config_defaults(&g_crypto_config);
+
+ if (config != NULL) {
+ value = hybbx_config_get(config, "crypto", "password_hash", NULL);
+ g_crypto_config.password_hash = parse_password_hash(value);
+
+ value = hybbx_config_get(config, "crypto", "aes_gcm", NULL);
+ g_crypto_config.aes_gcm = parse_aes_gcm(value);
+
+ value = hybbx_config_get(config, "crypto", "chacha", NULL);
+ g_crypto_config.chacha = parse_chacha(value);
+
+ value = hybbx_config_get(config, "crypto", "x25519", NULL);
+ g_crypto_config.x25519 = parse_x25519(value);
+
+ value = hybbx_config_get(config, "crypto", "random", NULL);
+ g_crypto_config.random = parse_random(value);
+ }
+
+ resolve_backends(&g_crypto_config);
+ g_crypto_config_ready = 1;
+
+ hybbx_log_info("[crypto] password_hash=%s aes_gcm=%s chacha=%s x25519=%s random=%s",
+ hybbx_password_hash_backend_name(g_crypto_config.password_hash),
+ hybbx_aes_gcm_backend_name(g_crypto_config.aes_gcm),
+ hybbx_chacha_backend_name(g_crypto_config.chacha),
+ hybbx_x25519_backend_name(g_crypto_config.x25519),
+ hybbx_random_backend_name(g_crypto_config.random));
+}
+
+const hybbx_crypto_config_t *hybbx_crypto_config_get(void)
+{
+ if (!g_crypto_config_ready) {
+ hybbx_crypto_config_defaults(&g_crypto_config);
+ g_crypto_config_ready = 1;
+ }
+
+ return &g_crypto_config;
+}
+
+const char *hybbx_password_hash_backend_name(hybbx_password_hash_backend_t b)
+{
+ switch (b) {
+ case HYBBX_PASSWORD_HASH_OPENSSL:
+ return "openssl";
+ default:
+ return "tinysha256";
+ }
+}
+
+const char *hybbx_aes_gcm_backend_name(hybbx_aes_gcm_backend_t b)
+{
+ switch (b) {
+ case HYBBX_AES_GCM_OPENSSL:
+ return "openssl";
+ default:
+ return "tinyaes";
+ }
+}
+
+const char *hybbx_chacha_backend_name(hybbx_chacha_backend_t b)
+{
+ switch (b) {
+ case HYBBX_CHACHA_LIBSODIUM:
+ return "libsodium";
+ default:
+ return "monocypher";
+ }
+}
+
+const char *hybbx_x25519_backend_name(hybbx_x25519_backend_t b)
+{
+ switch (b) {
+ case HYBBX_X25519_LIBSODIUM:
+ return "libsodium";
+ default:
+ return "monocypher";
+ }
+}
+
+const char *hybbx_random_backend_name(hybbx_random_backend_t b)
+{
+ switch (b) {
+ case HYBBX_RANDOM_OPENSSL:
+ return "openssl";
+ default:
+ return "system";
+ }
+}
diff --git a/src/core/crypto_libsodium.c b/src/core/crypto_libsodium.c
new file mode 100644
index 0000000..85cf537
--- /dev/null
+++ b/src/core/crypto_libsodium.c
@@ -0,0 +1,111 @@
+#include "hybbx/types.h"
+
+#include <sodium.h>
+
+#include <stdlib.h>
+#include <string.h>
+
+static int sodium_ready;
+
+static int ensure_sodium(void)
+{
+ if (!sodium_ready) {
+ if (sodium_init() < 0) {
+ return -1;
+ }
+ sodium_ready = 1;
+ }
+
+ return 0;
+}
+
+void hybbx_libsodium_chacha_lock(uint8_t *ciphertext, uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *plaintext, size_t plaintext_len)
+{
+ unsigned long long out_len = 0;
+ uint8_t *packed;
+ size_t packed_cap;
+
+ if (ensure_sodium() != 0) {
+ return;
+ }
+
+ packed_cap = plaintext_len +
+ crypto_aead_xchacha20poly1305_ietf_ABYTES;
+ packed = malloc(packed_cap);
+ if (packed == NULL) {
+ return;
+ }
+
+ if (crypto_aead_xchacha20poly1305_ietf_encrypt(
+ packed, &out_len, plaintext, plaintext_len, aad, aad_len, NULL,
+ nonce, key) != 0) {
+ free(packed);
+ return;
+ }
+
+ if (plaintext_len > 0 && ciphertext != NULL) {
+ memcpy(ciphertext, packed, plaintext_len);
+ }
+ memcpy(tag, packed + plaintext_len,
+ crypto_aead_xchacha20poly1305_ietf_ABYTES);
+ free(packed);
+}
+
+int hybbx_libsodium_chacha_unlock(uint8_t *plaintext, const uint8_t tag[16],
+ const uint8_t key[32], const uint8_t nonce[24],
+ const uint8_t *aad, size_t aad_len,
+ const uint8_t *ciphertext, size_t ciphertext_len)
+{
+ unsigned long long pt_len = 0;
+ uint8_t *packed;
+ size_t packed_len;
+ int rc;
+
+ if (ensure_sodium() != 0) {
+ return -1;
+ }
+
+ packed_len = ciphertext_len +
+ crypto_aead_xchacha20poly1305_ietf_ABYTES;
+ packed = malloc(packed_len);
+ if (packed == NULL) {
+ return -1;
+ }
+
+ if (ciphertext_len > 0 && ciphertext != NULL) {
+ memcpy(packed, ciphertext, ciphertext_len);
+ }
+ memcpy(packed + ciphertext_len, tag,
+ crypto_aead_xchacha20poly1305_ietf_ABYTES);
+
+ rc = crypto_aead_xchacha20poly1305_ietf_decrypt(
+ plaintext, &pt_len, NULL, packed, packed_len, aad, aad_len, nonce,
+ key);
+
+ free(packed);
+ return rc;
+}
+
+void hybbx_libsodium_x25519_public_key(uint8_t public_key[32],
+ const uint8_t secret_key[32])
+{
+ if (ensure_sodium() != 0) {
+ return;
+ }
+
+ crypto_scalarmult_base(public_key, secret_key);
+}
+
+void hybbx_libsodium_x25519_shared(uint8_t shared[32],
+ const uint8_t secret_key[32],
+ const uint8_t peer_public_key[32])
+{
+ if (ensure_sodium() != 0) {
+ return;
+ }
+
+ crypto_scalarmult(shared, secret_key, peer_public_key);
+}
diff --git a/src/core/crypto_openssl.c b/src/core/crypto_openssl.c
new file mode 100644
index 0000000..551ea83
--- /dev/null
+++ b/src/core/crypto_openssl.c
@@ -0,0 +1,142 @@
+#include "hybbx/types.h"
+
+#include <openssl/evp.h>
+#include <openssl/rand.h>
+
+#include <stdio.h>
+#include <string.h>
+
+void hybbx_openssl_sha256_hex(const char *data, size_t len, char hex[65])
+{
+ unsigned char digest[32];
+ unsigned int digest_len = sizeof(digest);
+ EVP_MD_CTX *ctx = EVP_MD_CTX_new();
+
+ if (ctx == NULL) {
+ hex[0] = '\0';
+ return;
+ }
+
+ if (EVP_DigestInit_ex(ctx, EVP_sha256(), NULL) != 1 ||
+ EVP_DigestUpdate(ctx, data, len) != 1 ||
+ EVP_DigestFinal_ex(ctx, digest, &digest_len) != 1) {
+ EVP_MD_CTX_free(ctx);
+ hex[0] = '\0';
+ return;
+ }
+
+ EVP_MD_CTX_free(ctx);
+
+ for (size_t i = 0; i < digest_len; i++) {
+ snprintf(hex + i * 2, 3, "%02x", digest[i]);
+ }
+}
+
+static int openssl_gcm_crypt(int encrypt, const uint8_t key[32],
+ const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *in, size_t in_len,
+ uint8_t *out, const uint8_t tag_in[16],
+ uint8_t tag_out[16])
+{
+ EVP_CIPHER_CTX *ctx = NULL;
+ int out_len = 0;
+ int total = 0;
+ int rc = -1;
+
+ ctx = EVP_CIPHER_CTX_new();
+ if (ctx == NULL) {
+ return -1;
+ }
+
+ if (EVP_CipherInit_ex(ctx, EVP_aes_256_gcm(), NULL, NULL, NULL, encrypt) !=
+ 1 ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_IVLEN, 12, NULL) != 1 ||
+ EVP_CipherInit_ex(ctx, NULL, NULL, key, nonce, encrypt) != 1) {
+ goto done;
+ }
+
+ if (aad_len > 0 && aad != NULL) {
+ if (EVP_CipherUpdate(ctx, NULL, &out_len, aad, (int)aad_len) != 1) {
+ goto done;
+ }
+ }
+
+ if (in_len > 0) {
+ if (EVP_CipherUpdate(ctx, out, &out_len, in, (int)in_len) != 1) {
+ goto done;
+ }
+ total = out_len;
+ }
+
+ if (encrypt) {
+ if (EVP_EncryptFinal_ex(ctx, out + total, &out_len) != 1) {
+ goto done;
+ }
+ if (tag_out == NULL ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_GET_TAG, 16, tag_out) != 1) {
+ goto done;
+ }
+ rc = 0;
+ goto done;
+ }
+
+ if (tag_in == NULL ||
+ EVP_CIPHER_CTX_ctrl(ctx, EVP_CTRL_GCM_SET_TAG, 16,
+ (void *)(uintptr_t)tag_in) != 1) {
+ goto done;
+ }
+
+ if (EVP_DecryptFinal_ex(ctx, out + total, &out_len) != 1) {
+ goto done;
+ }
+
+ rc = 0;
+done:
+ EVP_CIPHER_CTX_free(ctx);
+ return rc;
+}
+
+int hybbx_openssl_aes256gcm_encrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *plaintext, size_t plaintext_len,
+ uint8_t *ciphertext, uint8_t tag[16])
+{
+ if (key == NULL || nonce == NULL || tag == NULL) {
+ return -1;
+ }
+ if (plaintext_len > 0 && (plaintext == NULL || ciphertext == NULL)) {
+ return -1;
+ }
+
+ return openssl_gcm_crypt(1, key, nonce, aad, aad_len, plaintext,
+ plaintext_len, ciphertext, NULL, tag);
+}
+
+int hybbx_openssl_aes256gcm_decrypt(
+ const uint8_t key[32], const uint8_t nonce[12], const uint8_t *aad,
+ size_t aad_len, const uint8_t *ciphertext, size_t ciphertext_len,
+ uint8_t *plaintext, const uint8_t tag[16])
+{
+ if (key == NULL || nonce == NULL || tag == NULL) {
+ return -1;
+ }
+ if (ciphertext_len > 0 && (ciphertext == NULL || plaintext == NULL)) {
+ return -1;
+ }
+
+ return openssl_gcm_crypt(0, key, nonce, aad, aad_len, ciphertext,
+ ciphertext_len, plaintext, tag, NULL);
+}
+
+hybbx_result_t hybbx_openssl_random(uint8_t *buf, size_t len)
+{
+ if (buf == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (RAND_bytes(buf, (int)len) == 1) {
+ return HYBBX_OK;
+ }
+
+ return HYBBX_ERR_IO;
+}
diff --git a/src/core/daemon_wrap.c b/src/core/daemon_wrap.c
new file mode 100644
index 0000000..04c990a
--- /dev/null
+++ b/src/core/daemon_wrap.c
@@ -0,0 +1,320 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/daemon_wrap.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+void hybbx_daemon_launch_opts_defaults(hybbx_daemon_launch_opts_t *opts)
+{
+ if (opts == NULL) {
+ return;
+ }
+
+ memset(opts, 0, sizeof(*opts));
+ hybbx_strlcpy(opts->session, HYBBX_DAEMON_DEFAULT_SESSION,
+ sizeof(opts->session));
+}
+
+static int path_executable(const char *path)
+{
+ return path != NULL && path[0] != '\0' && access(path, X_OK) == 0;
+}
+
+static int find_in_path(const char *name, char *out, size_t out_len)
+{
+ const char *path_env;
+ char copy[HYBBX_PATH_MAX];
+ char *save = NULL;
+ char *dir;
+
+ if (name == NULL || out == NULL || out_len == 0) {
+ return 0;
+ }
+
+ path_env = getenv("PATH");
+ if (path_env == NULL || path_env[0] == '\0') {
+ return 0;
+ }
+
+ snprintf(copy, sizeof(copy), "%s", path_env);
+ for (dir = strtok_r(copy, ":", &save); dir != NULL;
+ dir = strtok_r(NULL, ":", &save)) {
+ if (hybbx_path_join(out, out_len, dir, name) != HYBBX_OK) {
+ continue;
+ }
+ if (path_executable(out)) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static int wrapped_child(void)
+{
+ const char *env = getenv(HYBBX_DAEMON_WRAP_ENV);
+
+ return env != NULL && env[0] == '1';
+}
+
+static int in_screen(void)
+{
+ const char *sty = getenv("STY");
+
+ return sty != NULL && sty[0] != '\0';
+}
+
+static int in_tmux(void)
+{
+ const char *tmux = getenv("TMUX");
+
+ return tmux != NULL && tmux[0] != '\0';
+}
+
+static int arg_is_launch_flag(const char *arg)
+{
+ if (arg == NULL) {
+ return 0;
+ }
+
+ return strcmp(arg, "-f") == 0 || strcmp(arg, "--foreground") == 0 ||
+ strcmp(arg, "--attach") == 0 || strcmp(arg, "--screen") == 0 ||
+ strcmp(arg, "--tmux") == 0;
+}
+
+static int build_inner_argv(char **out, int out_max,
+ const char *binary,
+ int argc, char **argv)
+{
+ int outc = 0;
+ int i;
+
+ if (out == NULL || out_max < 2 || binary == NULL) {
+ return -1;
+ }
+
+ out[outc++] = (char *)binary;
+
+ if (!wrapped_child()) {
+ out[outc++] = "-f";
+ }
+
+ for (i = 1; i < argc && outc < out_max - 1; i++) {
+ if (strcmp(argv[i], "--screen") == 0 || strcmp(argv[i], "--tmux") == 0) {
+ if (i + 1 < argc && argv[i + 1][0] != '-' &&
+ !arg_is_launch_flag(argv[i + 1])) {
+ i++;
+ }
+ continue;
+ }
+ if (strcmp(argv[i], "-f") == 0 || strcmp(argv[i], "--foreground") == 0 ||
+ strcmp(argv[i], "--attach") == 0) {
+ continue;
+ }
+
+ out[outc++] = argv[i];
+ }
+
+ out[outc] = NULL;
+ return outc;
+}
+
+static hybbx_result_t exec_screen_attach(const char *session)
+{
+ char screen_path[HYBBX_PATH_MAX];
+ char *args[8];
+ int n = 0;
+
+ if (!find_in_path("screen", screen_path, sizeof(screen_path))) {
+ fprintf(stderr, "hybbxd: GNU screen not found in PATH\n");
+ return HYBBX_ERR_IO;
+ }
+
+ args[n++] = screen_path;
+ args[n++] = "-r";
+ args[n++] = (char *)session;
+ args[n++] = NULL;
+
+ execv(screen_path, args);
+ fprintf(stderr, "hybbxd: screen attach failed\n");
+ return HYBBX_ERR_IO;
+}
+
+static hybbx_result_t exec_tmux_attach(const char *session)
+{
+ char tmux_path[HYBBX_PATH_MAX];
+ char *args[8];
+ int n = 0;
+
+ if (!find_in_path("tmux", tmux_path, sizeof(tmux_path))) {
+ fprintf(stderr, "hybbxd: tmux not found in PATH\n");
+ return HYBBX_ERR_IO;
+ }
+
+ args[n++] = tmux_path;
+ args[n++] = "attach";
+ args[n++] = "-t";
+ args[n++] = (char *)session;
+ args[n++] = NULL;
+
+ execv(tmux_path, args);
+ fprintf(stderr, "hybbxd: tmux attach failed\n");
+ return HYBBX_ERR_IO;
+}
+
+static hybbx_result_t spawn_screen(const char *session, const char *binary,
+ int argc, char **argv)
+{
+ char screen_path[HYBBX_PATH_MAX];
+ char *inner[HYBBX_CMD_TOKEN_MAX + 4];
+ char wrap_env[64];
+ int innerc;
+ char *args[HYBBX_CMD_TOKEN_MAX + 8];
+ int n = 0;
+
+ if (!find_in_path("screen", screen_path, sizeof(screen_path))) {
+ fprintf(stderr, "hybbxd: GNU screen not found in PATH\n");
+ return HYBBX_ERR_IO;
+ }
+
+ innerc = build_inner_argv(inner, (int)(sizeof(inner) / sizeof(inner[0])),
+ binary, argc, argv);
+ if (innerc < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ snprintf(wrap_env, sizeof(wrap_env), "%s=1", HYBBX_DAEMON_WRAP_ENV);
+
+ args[n++] = screen_path;
+ args[n++] = "-dmS";
+ args[n++] = (char *)session;
+ args[n++] = "env";
+ args[n++] = wrap_env;
+ {
+ int i;
+
+ for (i = 0; i < innerc; i++) {
+ args[n++] = inner[i];
+ }
+ }
+ args[n++] = NULL;
+
+ execv(screen_path, args);
+ fprintf(stderr, "hybbxd: screen spawn failed\n");
+ return HYBBX_ERR_IO;
+}
+
+static hybbx_result_t spawn_tmux(const char *session, const char *binary,
+ int argc, char **argv)
+{
+ char tmux_path[HYBBX_PATH_MAX];
+ char *inner[HYBBX_CMD_TOKEN_MAX + 4];
+ char wrap_env[64];
+ int innerc;
+ char *args[HYBBX_CMD_TOKEN_MAX + 8];
+ int n = 0;
+
+ if (!find_in_path("tmux", tmux_path, sizeof(tmux_path))) {
+ fprintf(stderr, "hybbxd: tmux not found in PATH\n");
+ return HYBBX_ERR_IO;
+ }
+
+ innerc = build_inner_argv(inner, (int)(sizeof(inner) / sizeof(inner[0])),
+ binary, argc, argv);
+ if (innerc < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ snprintf(wrap_env, sizeof(wrap_env), "%s=1", HYBBX_DAEMON_WRAP_ENV);
+
+ args[n++] = tmux_path;
+ args[n++] = "new-session";
+ args[n++] = "-d";
+ args[n++] = "-s";
+ args[n++] = (char *)session;
+ args[n++] = "env";
+ args[n++] = wrap_env;
+ {
+ int i;
+
+ for (i = 0; i < innerc; i++) {
+ args[n++] = inner[i];
+ }
+ }
+ args[n++] = NULL;
+
+ execv(tmux_path, args);
+ fprintf(stderr, "hybbxd: tmux spawn failed\n");
+ return HYBBX_ERR_IO;
+}
+
+hybbx_result_t hybbx_daemon_apply_launch_opts(const hybbx_daemon_launch_opts_t *opts,
+ const char *binary_path,
+ int argc, char **argv)
+{
+ const char *binary = binary_path;
+
+ if (opts == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (binary == NULL || binary[0] == '\0') {
+ binary = HYBBX_DAEMON_BINARY;
+ }
+
+ if (opts->use_screen && opts->use_tmux) {
+ fprintf(stderr, "hybbxd: use --screen or --tmux, not both\n");
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (opts->attach) {
+ if (opts->use_screen) {
+ return exec_screen_attach(opts->session);
+ }
+ if (opts->use_tmux) {
+ return exec_tmux_attach(opts->session);
+ }
+
+ fprintf(stderr, "hybbxd: --attach requires --screen or --tmux\n");
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (wrapped_child() || opts->foreground) {
+ return HYBBX_OK;
+ }
+
+ if (opts->use_screen) {
+ if (in_screen()) {
+ return HYBBX_OK;
+ }
+
+ if (spawn_screen(opts->session, binary, argc, argv) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ printf("hybbxd: started in screen session '%s'\n", opts->session);
+ printf("attach: screen -r %s\n", opts->session);
+ exit(EXIT_SUCCESS);
+ }
+
+ if (opts->use_tmux) {
+ if (in_tmux()) {
+ return HYBBX_OK;
+ }
+
+ if (spawn_tmux(opts->session, binary, argc, argv) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ printf("hybbxd: started in tmux session '%s'\n", opts->session);
+ printf("attach: tmux attach -t %s\n", opts->session);
+ exit(EXIT_SUCCESS);
+ }
+
+ return HYBBX_OK;
+}
diff --git a/src/core/instance.c b/src/core/instance.c
new file mode 100644
index 0000000..4640678
--- /dev/null
+++ b/src/core/instance.c
@@ -0,0 +1,272 @@
+#include "hybbx/instance.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <string.h>
+
+static int g_instance_standalone;
+
+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';
+}
+
+#if defined(__GNUC__) || defined(__clang__)
+__attribute__((weak))
+#endif
+hybbx_instance_role_t hybbx_instance_role(void)
+{
+ /* Overridden by instance_role_{main,secondary,proxy}.c on each binary. */
+ return HYBBX_INSTANCE_MAIN;
+}
+
+const char *hybbx_instance_binary_name(void)
+{
+ switch (hybbx_instance_role()) {
+ case HYBBX_INSTANCE_SECONDARY:
+ return "hybbxsd";
+ case HYBBX_INSTANCE_PROXY:
+ return "hybbxpd";
+ case HYBBX_INSTANCE_MAIN:
+ default:
+ return "hybbxd";
+ }
+}
+
+const char *hybbx_instance_role_name(void)
+{
+ switch (hybbx_instance_role()) {
+ case HYBBX_INSTANCE_SECONDARY:
+ return "Secondary";
+ case HYBBX_INSTANCE_PROXY:
+ return "Proxy";
+ case HYBBX_INSTANCE_MAIN:
+ default:
+ return "Main";
+ }
+}
+
+int hybbx_instance_offers_user_bbx(void)
+{
+ return hybbx_instance_role() == HYBBX_INSTANCE_MAIN;
+}
+
+void hybbx_instance_set_standalone(int enabled)
+{
+ g_instance_standalone = enabled != 0;
+}
+
+int hybbx_instance_standalone(void)
+{
+ return g_instance_standalone;
+}
+
+static int instance_main_standalone(void)
+{
+ return hybbx_instance_role() == HYBBX_INSTANCE_MAIN &&
+ hybbx_instance_standalone();
+}
+
+int hybbx_instance_plugin_allowed(const char *plugin_name)
+{
+ hybbx_instance_role_t role;
+
+ if (plugin_name == NULL || plugin_name[0] == '\0') {
+ return 0;
+ }
+
+ role = hybbx_instance_role();
+
+ if (instance_main_standalone()) {
+ if (str_ieq(plugin_name, "circuit")) {
+ return 1;
+ }
+ if (str_ieq(plugin_name, "mains_proxy")) {
+ return 1;
+ }
+ return 1;
+ }
+
+ /* Split layout: packet_radio stays in HyBBX only via MAX25 prep on Secondary/Proxy;
+ * hub Main uses circuit peers instead. */
+ if (str_ieq(plugin_name, "packet_radio")) {
+ return role != HYBBX_INSTANCE_MAIN;
+ }
+
+ switch (role) {
+ case HYBBX_INSTANCE_MAIN:
+ if (str_ieq(plugin_name, "baycom") ||
+ str_ieq(plugin_name, "ardop") ||
+ str_ieq(plugin_name, "crdop")) {
+ return 0;
+ }
+ if (str_ieq(plugin_name, "telnet") || str_ieq(plugin_name, "ssh")) {
+ return 0;
+ }
+ return 1;
+
+ case HYBBX_INSTANCE_PROXY:
+ if (str_ieq(plugin_name, "websocket")) {
+ return 0;
+ }
+ return 1;
+
+ case HYBBX_INSTANCE_SECONDARY:
+ if (str_ieq(plugin_name, "telnet") ||
+ str_ieq(plugin_name, "ssh") ||
+ str_ieq(plugin_name, "websocket")) {
+ return 0;
+ }
+ return 1;
+
+ default:
+ return 0;
+ }
+}
+
+hybbx_result_t hybbx_networks_enforce_instance(hybbx_networks_config_t *networks)
+{
+ hybbx_instance_role_t role;
+
+ if (networks == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ role = hybbx_instance_role();
+
+ if (instance_main_standalone()) {
+ hybbx_log_info("[instance] standalone=yes — %s keeps INI transports "
+ "(local RF via MAX25 prep when configured)",
+ hybbx_instance_binary_name());
+ hybbx_log_info("[instance] binary=%s role=%s standalone=yes user_bbx=%s "
+ "telnet=%s ssh=%s ax25=%s baycom=%s ardop=%s crdop=%s "
+ "websocket=%s circuit=%s mains_proxy=%s",
+ hybbx_instance_binary_name(),
+ hybbx_instance_role_name(),
+ hybbx_instance_offers_user_bbx() ? "yes" : "no",
+ hybbx_bool_to_string(networks->telnet),
+ hybbx_bool_to_string(networks->ssh),
+ hybbx_bool_to_string(networks->ax25),
+ hybbx_bool_to_string(networks->baycom),
+ hybbx_bool_to_string(networks->ardop),
+ hybbx_bool_to_string(networks->crdop),
+ hybbx_bool_to_string(networks->websocket),
+ hybbx_bool_to_string(networks->circuit),
+ hybbx_bool_to_string(networks->mains_proxy));
+ return HYBBX_OK;
+ }
+
+ if (networks->ax25 && role == HYBBX_INSTANCE_MAIN) {
+ hybbx_log_warn("[instance] %s: ax25/packet_radio on hub Main — forcing off "
+ "(use standalone=yes or Secondary RF host)",
+ hybbx_instance_binary_name());
+ networks->ax25 = 0;
+ }
+
+ switch (role) {
+ case HYBBX_INSTANCE_MAIN:
+ if (networks->baycom || networks->ardop || networks->crdop) {
+ hybbx_log_warn("[instance] hybbxd: RF adapters forbidden on Main "
+ "— forcing baycom/ardop/crdop off (use hybbxpd + MAX25)");
+ networks->baycom = 0;
+ networks->ardop = 0;
+ networks->crdop = 0;
+ }
+ if (networks->ssh) {
+ hybbx_log_warn("[instance] hybbxd: SSH user path forbidden — "
+ "WebSocket/reverse-proxy only; forcing ssh=no");
+ networks->ssh = 0;
+ }
+ if (networks->telnet) {
+ hybbx_log_warn("[instance] hybbxd: telnet user path forbidden — "
+ "WebSocket/reverse-proxy only; forcing telnet=no");
+ networks->telnet = 0;
+ }
+ /* WebSocket is the sole remote user path. */
+ if (!networks->websocket) {
+ hybbx_log_warn("[instance] hybbxd: enabling websocket "
+ "(required user path)");
+ networks->websocket = 1;
+ }
+ /* Circuit hub for Proxy attach. */
+ if (!networks->circuit) {
+ hybbx_log_warn("[instance] hybbxd: enabling circuit hub "
+ "(Proxy in/out)");
+ networks->circuit = 1;
+ }
+ break;
+
+ case HYBBX_INSTANCE_SECONDARY:
+ if (networks->telnet) {
+ hybbx_log_warn("[instance] hybbxsd: no user telnet — forcing off");
+ networks->telnet = 0;
+ }
+ if (networks->websocket) {
+ hybbx_log_warn("[instance] hybbxsd: no user WebSocket — forcing off");
+ networks->websocket = 0;
+ }
+ if (networks->ssh) {
+ hybbx_log_warn("[instance] hybbxsd: no user SSH — forcing off");
+ networks->ssh = 0;
+ }
+ /* Peer other Mains — mains_proxy required. */
+ if (!networks->mains_proxy) {
+ hybbx_log_warn("[instance] hybbxsd: enabling mains_proxy "
+ "(Secondary = other-Main peers)");
+ networks->mains_proxy = 1;
+ }
+ break;
+
+ case HYBBX_INSTANCE_PROXY:
+ if (networks->websocket) {
+ hybbx_log_warn("[instance] hybbxpd: WebSocket forbidden — forcing off");
+ networks->websocket = 0;
+ }
+ if (networks->baycom) {
+ hybbx_log_warn("[instance] hybbxpd: baycom enabled — prefer MAX25 "
+ "relay/gateway/digipeater for hyBBX RF");
+ }
+ if (!networks->circuit) {
+ hybbx_log_warn("[instance] hybbxpd: enabling circuit "
+ "(attach toward Main)");
+ networks->circuit = 1;
+ }
+ break;
+
+ default:
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_log_info("[instance] binary=%s role=%s user_bbx=%s "
+ "telnet=%s ssh=%s ax25=%s baycom=%s ardop=%s crdop=%s "
+ "websocket=%s circuit=%s mains_proxy=%s",
+ hybbx_instance_binary_name(),
+ hybbx_instance_role_name(),
+ hybbx_instance_offers_user_bbx() ? "yes" : "no",
+ hybbx_bool_to_string(networks->telnet),
+ hybbx_bool_to_string(networks->ssh),
+ hybbx_bool_to_string(networks->ax25),
+ hybbx_bool_to_string(networks->baycom),
+ hybbx_bool_to_string(networks->ardop),
+ hybbx_bool_to_string(networks->crdop),
+ hybbx_bool_to_string(networks->websocket),
+ hybbx_bool_to_string(networks->circuit),
+ hybbx_bool_to_string(networks->mains_proxy));
+
+ return HYBBX_OK;
+}
diff --git a/src/core/link.c b/src/core/link.c
new file mode 100644
index 0000000..b9a38cd
--- /dev/null
+++ b/src/core/link.c
@@ -0,0 +1,427 @@
+/*
+ * Link/repeater edge registry: data/links/<id>.ini, [link.<id>], stale prune.
+ */
+#include "hybbx/link.h"
+#include "hybbx/config.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+
+static char *link_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 const char *LINK_CODE_CHARS = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+
+static int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ len = strlen(path);
+ if (len >= sizeof(buf)) {
+ return -1;
+ }
+
+ memcpy(buf, path, len + 1);
+ for (i = 1; i < len; i++) {
+ if (buf[i] == '/') {
+ buf[i] = '\0';
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+void hybbx_link_generate_code(char *out, size_t out_cap)
+{
+ static unsigned seed;
+ size_t i;
+ size_t n;
+
+ if (out == NULL || out_cap < 2) {
+ return;
+ }
+
+ if (seed == 0) {
+ seed = (unsigned)time(NULL) ^ 0x48594242u;
+ }
+
+ n = out_cap - 1;
+ if (n > HYBBX_LINK_CODE_MAX - 1) {
+ n = HYBBX_LINK_CODE_MAX - 1;
+ }
+
+ for (i = 0; i < n; i++) {
+ seed = seed * 1103515245u + 12345u;
+ out[i] = LINK_CODE_CHARS[(seed >> 16) % 32];
+ }
+ out[n] = '\0';
+}
+
+void hybbx_link_registry_init(hybbx_link_registry_t *reg,
+ const char *data_dir,
+ const char *config_path,
+ unsigned stale_days)
+{
+ if (reg == NULL) {
+ return;
+ }
+
+ memset(reg, 0, sizeof(*reg));
+ if (data_dir != NULL && data_dir[0] != '\0') {
+ snprintf(reg->dir, sizeof(reg->dir), "%s/links", data_dir);
+ }
+ if (config_path != NULL) {
+ hybbx_strlcpy(reg->config_path, config_path, sizeof(reg->config_path));
+ }
+ reg->stale_days = stale_days > 0 ? stale_days : HYBBX_LINK_STALE_DAYS;
+}
+
+static hybbx_result_t link_shard_path(const hybbx_link_registry_t *reg,
+ const char *id,
+ char *out, size_t out_cap)
+{
+ if (reg == NULL || id == NULL || id[0] == '\0' || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (snprintf(out, out_cap, "%s/%s.ini", reg->dir, id) >= (int)out_cap) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t read_link_last_seen(const char *path, time_t *last_seen)
+{
+ FILE *fp;
+ char line[256];
+ time_t value = 0;
+
+ if (path == NULL || last_seen == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ *last_seen = 0;
+ return HYBBX_OK;
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ if (strncmp(line, "last_seen=", 10) == 0) {
+ value = (time_t)strtol(line + 10, NULL, 10);
+ }
+ }
+
+ fclose(fp);
+ *last_seen = value;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t write_link_shard(const char *path,
+ const char *id,
+ const char *role,
+ const char *link_code,
+ time_t now,
+ int is_new)
+{
+ FILE *fp;
+ time_t created = now;
+ char existing_code[HYBBX_LINK_CODE_MAX];
+
+ existing_code[0] = '\0';
+ if (!is_new) {
+ FILE *old = fopen(path, "r");
+ char line[256];
+
+ if (old != NULL) {
+ while (fgets(line, sizeof(line), old) != NULL) {
+ if (strncmp(line, "created=", 8) == 0) {
+ created = (time_t)strtol(line + 8, NULL, 10);
+ } else if (strncmp(line, "link_code=", 10) == 0) {
+ size_t n = strcspn(line + 10, "\r\n");
+ if (n >= sizeof(existing_code)) {
+ n = sizeof(existing_code) - 1;
+ }
+ memcpy(existing_code, line + 10, n);
+ existing_code[n] = '\0';
+ }
+ }
+ fclose(old);
+ }
+ }
+
+ if (link_code != NULL && link_code[0] != '\0') {
+ hybbx_strlcpy(existing_code, link_code, sizeof(existing_code));
+ } else if (existing_code[0] == '\0') {
+ hybbx_link_generate_code(existing_code, sizeof(existing_code));
+ }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "id=%s\n", id);
+ fprintf(fp, "role=%s\n", role != NULL && role[0] != '\0' ? role : "link");
+ fprintf(fp, "link_code=%s\n", existing_code);
+ fprintf(fp, "created=%ld\n", (long)created);
+ fprintf(fp, "last_seen=%ld\n", (long)now);
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t config_upsert_link_section(const char *config_path,
+ const char *id,
+ const char *role,
+ const char *link_code,
+ time_t last_seen)
+{
+ hybbx_config_t cfg;
+ char section[HYBBX_CONFIG_SECTION_MAX];
+ char value[64];
+ size_t i;
+ hybbx_result_t rc;
+ int found_role = 0;
+ int found_code = 0;
+ int found_seen = 0;
+
+ if (config_path == NULL || config_path[0] == '\0' || id == NULL) {
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_config_load(&cfg, config_path);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(section, sizeof(section), "link.%s", id);
+ snprintf(value, sizeof(value), "%ld", (long)last_seen);
+
+ for (i = 0; i < cfg.count; i++) {
+ if (cfg.entries[i].section == NULL || cfg.entries[i].key == NULL) {
+ continue;
+ }
+ if (strcmp(cfg.entries[i].section, section) != 0) {
+ continue;
+ }
+ if (strcmp(cfg.entries[i].key, "role") == 0) {
+ free(cfg.entries[i].value);
+ cfg.entries[i].value = link_strdup(role != NULL ? role : "link");
+ found_role = 1;
+ } else if (strcmp(cfg.entries[i].key, "link_code") == 0) {
+ free(cfg.entries[i].value);
+ cfg.entries[i].value = link_strdup(link_code != NULL ? link_code : "");
+ found_code = 1;
+ } else if (strcmp(cfg.entries[i].key, "last_seen") == 0) {
+ free(cfg.entries[i].value);
+ cfg.entries[i].value = link_strdup(value);
+ found_seen = 1;
+ }
+ }
+
+ if (!found_role) {
+ rc = hybbx_config_set(&cfg, section, "role",
+ role != NULL ? role : "link");
+ if (rc != HYBBX_OK) {
+ hybbx_config_free(&cfg);
+ return rc;
+ }
+ }
+ if (!found_code && link_code != NULL) {
+ rc = hybbx_config_set(&cfg, section, "link_code", link_code);
+ if (rc != HYBBX_OK) {
+ hybbx_config_free(&cfg);
+ return rc;
+ }
+ }
+ if (!found_seen) {
+ rc = hybbx_config_set(&cfg, section, "last_seen", value);
+ if (rc != HYBBX_OK) {
+ hybbx_config_free(&cfg);
+ return rc;
+ }
+ }
+
+ rc = hybbx_config_save(&cfg, config_path);
+ hybbx_config_free(&cfg);
+ return rc;
+}
+
+hybbx_result_t hybbx_link_registry_touch(hybbx_link_registry_t *reg,
+ const char *id,
+ const char *role,
+ char *link_code_out,
+ size_t link_code_cap)
+{
+ char path[HYBBX_PATH_MAX];
+ char code[HYBBX_LINK_CODE_MAX];
+ time_t now = time(NULL);
+ struct stat st;
+ hybbx_result_t rc;
+ int is_new;
+
+ if (reg == NULL || id == NULL || id[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (reg->dir[0] != '\0') {
+ mkdir_p(reg->dir);
+ }
+
+ rc = link_shard_path(reg, id, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ is_new = stat(path, &st) != 0;
+ code[0] = '\0';
+ if (!is_new) {
+ FILE *fp = fopen(path, "r");
+ char line[256];
+
+ if (fp != NULL) {
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ if (strncmp(line, "link_code=", 10) == 0) {
+ size_t n = strcspn(line + 10, "\r\n");
+ if (n >= sizeof(code)) {
+ n = sizeof(code) - 1;
+ }
+ memcpy(code, line + 10, n);
+ code[n] = '\0';
+ }
+ }
+ fclose(fp);
+ }
+ }
+ if (code[0] == '\0') {
+ hybbx_link_generate_code(code, sizeof(code));
+ }
+
+ rc = write_link_shard(path, id, role, code, now, is_new);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (link_code_out != NULL && link_code_cap > 0) {
+ hybbx_strlcpy(link_code_out, code, link_code_cap);
+ }
+
+ return config_upsert_link_section(reg->config_path, id, role, code, now);
+}
+
+static hybbx_result_t remove_link_files(const hybbx_link_registry_t *reg,
+ const char *id)
+{
+ char path[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ rc = link_shard_path(reg, id, path, sizeof(path));
+ if (rc == HYBBX_OK) {
+ remove(path);
+ }
+
+ if (reg->config_path[0] != '\0') {
+ hybbx_config_t cfg;
+ char section[HYBBX_CONFIG_SECTION_MAX];
+
+ snprintf(section, sizeof(section), "link.%s", id);
+ if (hybbx_config_load(&cfg, reg->config_path) == HYBBX_OK) {
+ hybbx_config_remove_section(&cfg, section);
+ hybbx_config_save(&cfg, reg->config_path);
+ hybbx_config_free(&cfg);
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_link_registry_prune(hybbx_link_registry_t *reg)
+{
+ DIR *dir;
+ struct dirent *ent;
+ time_t now = time(NULL);
+ time_t cutoff;
+ char id[HYBBX_LINK_ID_MAX];
+
+ if (reg == NULL || reg->dir[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ cutoff = now - (time_t)reg->stale_days * 24 * 60 * 60;
+
+ dir = opendir(reg->dir);
+ if (dir == NULL) {
+ return HYBBX_OK;
+ }
+
+ while ((ent = readdir(dir)) != NULL) {
+ const char *name = ent->d_name;
+ size_t nlen = strlen(name);
+ time_t last_seen;
+ char path[HYBBX_PATH_MAX];
+
+ if (nlen < 5 || strcmp(name + nlen - 4, ".ini") != 0) {
+ continue;
+ }
+
+ nlen -= 4;
+ if (nlen >= sizeof(id)) {
+ continue;
+ }
+ memcpy(id, name, nlen);
+ id[nlen] = '\0';
+
+ if (snprintf(path, sizeof(path), "%s/%s", reg->dir, name) >= (int)sizeof(path)) {
+ continue;
+ }
+
+ if (read_link_last_seen(path, &last_seen) != HYBBX_OK) {
+ continue;
+ }
+
+ if (last_seen > 0 && last_seen < cutoff) {
+ hybbx_log_info("[links] removing stale link '%s' (last auth %ld days ago)",
+ id, (long)((now - last_seen) / (24 * 60 * 60)));
+ remove_link_files(reg, id);
+ }
+ }
+
+ closedir(dir);
+ return HYBBX_OK;
+}
diff --git a/src/core/log.c b/src/core/log.c
new file mode 100644
index 0000000..7812c00
--- /dev/null
+++ b/src/core/log.c
@@ -0,0 +1,694 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/log.h"
+#if !defined(HYBBX_CLIENT_BUILD)
+#include "hybbx/config.h"
+#endif
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <pthread.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <time.h>
+#include <unistd.h>
+
+#define HYBBX_LOG_LINE_MAX 1024u
+
+/*
+ * Weekly HyBBX file log (replaces stuck month-keyed "daily" open handle).
+ *
+ * Naming: YYYYMMDD-week-hybbx.log
+ * YYYYMMDD = ISO week start (Monday, local time). One file per 7-day week.
+ *
+ * Cycle (forever):
+ * Days 1–7 → append to the current week file.
+ * Day 8 → switch to the new week file (new Monday), then pack every
+ * closed/legacy packable log under [log] dir into
+ * logs/arc/<YYYYMMDD>-week-hybbx.tar.bz2 (bzip2) and delete
+ * those sources only after tar succeeds.
+ * Pack batches of up to 7 members per archive (oldest first); repeat until
+ * fewer than 1 packable remain. Legacy daily names (YYYYMMDD-hybbx.log)
+ * are packable and are swept on day-8 switch (and on startup when ≥7).
+ *
+ * security.log is never packed or deleted here.
+ */
+
+#define HYBBX_LOG_ARC_SUBDIR "arc"
+#define HYBBX_LOG_PACK_BATCH 7
+#define HYBBX_LOG_PACKABLE_MAX 64
+
+static hybbx_log_config_t g_log_config;
+static int g_log_ready;
+static FILE *g_log_file;
+/* ISO-Monday yyyymmdd of the file currently open (0 = none). */
+static int g_log_open_week_yyyymmdd;
+static char g_log_open_name[64];
+static pthread_mutex_t g_log_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ hybbx_strlcpy(buf, path, sizeof(buf));
+ len = strlen(buf);
+ while (len > 0 && buf[len - 1] == '/') {
+ buf[--len] = '\0';
+ }
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] != '/') {
+ continue;
+ }
+ buf[i] = '\0';
+ if (buf[0] != '\0' && mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+void hybbx_log_config_defaults(hybbx_log_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ cfg->enabled = 1;
+ cfg->dir[0] = '\0';
+ cfg->level = HYBBX_LOG_WARN;
+}
+
+hybbx_log_level_t hybbx_log_parse_level(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_LOG_WARN;
+ }
+
+ if (strcasecmp(value, "debug") == 0) {
+ return HYBBX_LOG_DEBUG;
+ }
+ if (strcasecmp(value, "stats") == 0) {
+ return HYBBX_LOG_STATS;
+ }
+ if (strcasecmp(value, "info") == 0) {
+ return HYBBX_LOG_INFO;
+ }
+ if (strcasecmp(value, "warn") == 0) {
+ return HYBBX_LOG_WARN;
+ }
+
+ return HYBBX_LOG_WARN;
+}
+
+const char *hybbx_log_level_name(hybbx_log_level_t level)
+{
+ switch (level) {
+ case HYBBX_LOG_DEBUG:
+ return "debug";
+ case HYBBX_LOG_STATS:
+ return "stats";
+ case HYBBX_LOG_INFO:
+ return "info";
+ case HYBBX_LOG_WARN:
+ return "warn";
+ default:
+ return "?";
+ }
+}
+
+int hybbx_log_level_visible(hybbx_log_level_t level)
+{
+ if (!g_log_ready) {
+ return 1;
+ }
+
+ return level >= g_log_config.level;
+}
+
+const hybbx_log_config_t *hybbx_log_config_get(void)
+{
+ return g_log_ready ? &g_log_config : NULL;
+}
+
+int hybbx_log_enabled(void)
+{
+ return g_log_ready && g_log_config.enabled && g_log_file != NULL;
+}
+
+static void log_close_file(void)
+{
+ if (g_log_file != NULL) {
+ fclose(g_log_file);
+ g_log_file = NULL;
+ }
+ g_log_open_week_yyyymmdd = 0;
+ g_log_open_name[0] = '\0';
+}
+
+/* Monday of the ISO week containing @p tm (local wall calendar). */
+static int log_week_start_tm(const struct tm *tm, struct tm *out)
+{
+ time_t t;
+ int from_monday;
+
+ if (tm == NULL || out == NULL) {
+ return -1;
+ }
+
+ *out = *tm;
+ /* tm_wday: 0=Sun … 6=Sat → days since Monday */
+ from_monday = (out->tm_wday + 6) % 7;
+ out->tm_mday -= from_monday;
+ out->tm_hour = 12; /* avoid DST midnight edge when normalizing */
+ out->tm_min = 0;
+ out->tm_sec = 0;
+ out->tm_isdst = -1;
+ t = mktime(out);
+ if (t == (time_t)-1) {
+ return -1;
+ }
+ if (localtime_r(&t, out) == NULL) {
+ return -1;
+ }
+ return 0;
+}
+
+static int log_week_yyyymmdd(const struct tm *tm)
+{
+ struct tm week;
+
+ if (log_week_start_tm(tm, &week) != 0) {
+ return 0;
+ }
+ return (week.tm_year + 1900) * 10000
+ + (week.tm_mon + 1) * 100
+ + week.tm_mday;
+}
+
+static void log_week_basename(int week_yyyymmdd, char *name, size_t name_len)
+{
+ snprintf(name, name_len, "%08d-week-hybbx.log", week_yyyymmdd);
+}
+
+static int log_build_path_for_week(char *out, size_t out_len, int week_yyyymmdd)
+{
+ char name[64];
+
+ log_week_basename(week_yyyymmdd, name, sizeof(name));
+ return hybbx_path_join(out, out_len, g_log_config.dir, name) == HYBBX_OK
+ ? 0
+ : -1;
+}
+
+static int log_is_eight_digits(const char *s)
+{
+ size_t i;
+
+ for (i = 0; i < 8; i++) {
+ if (s[i] < '0' || s[i] > '9') {
+ return 0;
+ }
+ }
+ return 1;
+}
+
+/*
+ * Packable: current-scheme week files, or legacy daily YYYYMMDD-hybbx.log.
+ * Never security.log, arc/, or the active week file.
+ */
+static int log_name_is_packable(const char *name)
+{
+ size_t len;
+
+ if (name == NULL || name[0] == '\0' || name[0] == '.') {
+ return 0;
+ }
+ if (strcmp(name, g_log_open_name) == 0) {
+ return 0;
+ }
+
+ len = strlen(name);
+ if (len == 22 && log_is_eight_digits(name) &&
+ strcmp(name + 8, "-week-hybbx.log") == 0) {
+ return 1;
+ }
+ /* Legacy daily: 20260720-hybbx.log (8 digits + 10). */
+ if (len == 18 && log_is_eight_digits(name) &&
+ strcmp(name + 8, "-hybbx.log") == 0) {
+ return 1;
+ }
+ return 0;
+}
+
+static int log_cmp_names(const void *a, const void *b)
+{
+ return strcmp(*(const char *const *)a, *(const char *const *)b);
+}
+
+static int log_run_tar_bz2(const char *arc_path, char **basenames, int count)
+{
+ pid_t pid;
+ int status;
+ int i;
+ char *argv[HYBBX_LOG_PACKABLE_MAX + 8];
+ int argc = 0;
+
+ if (arc_path == NULL || basenames == NULL || count <= 0 ||
+ count > HYBBX_LOG_PACKABLE_MAX) {
+ return -1;
+ }
+
+ argv[argc++] = "tar";
+ argv[argc++] = "-C";
+ argv[argc++] = (char *)g_log_config.dir;
+ argv[argc++] = "-cjf";
+ argv[argc++] = (char *)arc_path;
+ for (i = 0; i < count; i++) {
+ argv[argc++] = basenames[i];
+ }
+ argv[argc] = NULL;
+
+ pid = fork();
+ if (pid < 0) {
+ return -1;
+ }
+ if (pid == 0) {
+ execvp("tar", argv);
+ _exit(127);
+ }
+
+ if (waitpid(pid, &status, 0) < 0) {
+ return -1;
+ }
+ if (!WIFEXITED(status) || WEXITSTATUS(status) != 0) {
+ return -1;
+ }
+ return 0;
+}
+
+static int log_collect_packable(char namebuf[][64], char **names, int max)
+{
+ DIR *dir;
+ struct dirent *ent;
+ int count = 0;
+
+ dir = opendir(g_log_config.dir);
+ if (dir == NULL) {
+ return 0;
+ }
+
+ while ((ent = readdir(dir)) != NULL && count < max) {
+ if (!log_name_is_packable(ent->d_name)) {
+ continue;
+ }
+ if (strlen(ent->d_name) >= 64) {
+ continue;
+ }
+ hybbx_strlcpy(namebuf[count], ent->d_name, 64);
+ names[count] = namebuf[count];
+ count++;
+ }
+ closedir(dir);
+ return count;
+}
+
+/* Pack up to HYBBX_LOG_PACK_BATCH oldest packable files. Returns packed count. */
+static int log_archive_one_batch(void)
+{
+ char *names[HYBBX_LOG_PACKABLE_MAX];
+ char namebuf[HYBBX_LOG_PACKABLE_MAX][64];
+ int count;
+ int pack_n;
+ int i;
+ char arc_dir[HYBBX_PATH_MAX];
+ char arc_path[HYBBX_PATH_MAX];
+ char arc_name[80];
+ struct stat st;
+
+ count = log_collect_packable(namebuf, names, HYBBX_LOG_PACKABLE_MAX);
+ if (count <= 0) {
+ return 0;
+ }
+
+ qsort(names, (size_t)count, sizeof(names[0]), log_cmp_names);
+ pack_n = count < HYBBX_LOG_PACK_BATCH ? count : HYBBX_LOG_PACK_BATCH;
+
+ if (hybbx_path_join(arc_dir, sizeof(arc_dir), g_log_config.dir,
+ HYBBX_LOG_ARC_SUBDIR) != HYBBX_OK) {
+ fprintf(stderr, "[log] archive path too long\n");
+ return -1;
+ }
+ if (mkdir_p(arc_dir) != 0) {
+ fprintf(stderr, "[log] cannot create %s\n", arc_dir);
+ return -1;
+ }
+
+ snprintf(arc_name, sizeof(arc_name), "%.8s-week-hybbx.tar.bz2", names[0]);
+ if (hybbx_path_join(arc_path, sizeof(arc_path), arc_dir, arc_name) !=
+ HYBBX_OK) {
+ fprintf(stderr, "[log] archive file path too long\n");
+ return -1;
+ }
+ if (stat(arc_path, &st) == 0) {
+ snprintf(arc_name, sizeof(arc_name),
+ "%.8s-week-hybbx-%ld.tar.bz2", names[0], (long)time(NULL));
+ if (hybbx_path_join(arc_path, sizeof(arc_path), arc_dir, arc_name) !=
+ HYBBX_OK) {
+ return -1;
+ }
+ }
+
+ if (log_run_tar_bz2(arc_path, names, pack_n) != 0) {
+ fprintf(stderr, "[log] tar.bz2 failed for %s — sources kept\n",
+ arc_path);
+ return -1;
+ }
+
+ for (i = 0; i < pack_n; i++) {
+ char full[HYBBX_PATH_MAX];
+ if (hybbx_path_join(full, sizeof(full), g_log_config.dir, names[i]) !=
+ HYBBX_OK) {
+ continue;
+ }
+ if (unlink(full) != 0) {
+ fprintf(stderr, "[log] cannot remove packed %s\n", full);
+ }
+ }
+
+ fprintf(stderr, "[log] archived %d file(s) → %s\n", pack_n, arc_path);
+ return pack_n;
+}
+
+/*
+ * @p on_week_switch: day-8 path — pack every closed/legacy file (batches of 7).
+ * Otherwise (startup): pack only when ≥7 packable (legacy daily sweep).
+ */
+static void log_archive_packable(int on_week_switch)
+{
+ char *names[HYBBX_LOG_PACKABLE_MAX];
+ char namebuf[HYBBX_LOG_PACKABLE_MAX][64];
+ int count;
+ int guard;
+
+ if (g_log_config.dir[0] == '\0') {
+ return;
+ }
+
+ count = log_collect_packable(namebuf, names, HYBBX_LOG_PACKABLE_MAX);
+ if (count <= 0) {
+ return;
+ }
+ if (!on_week_switch && count < HYBBX_LOG_PACK_BATCH) {
+ return;
+ }
+
+ for (guard = 0; guard < HYBBX_LOG_PACKABLE_MAX; guard++) {
+ int packed = log_archive_one_batch();
+ if (packed <= 0) {
+ break;
+ }
+ count = log_collect_packable(namebuf, names, HYBBX_LOG_PACKABLE_MAX);
+ if (count <= 0) {
+ break;
+ }
+ if (!on_week_switch && count < HYBBX_LOG_PACK_BATCH) {
+ break;
+ }
+ }
+}
+
+hybbx_result_t hybbx_log_current_path(char *out, size_t out_len)
+{
+ time_t now;
+ struct tm tm_buf;
+ struct tm *tm;
+ int week;
+
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ out[0] = '\0';
+ if (!g_log_ready || !g_log_config.enabled || g_log_config.dir[0] == '\0') {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ now = time(NULL);
+ tm = localtime_r(&now, &tm_buf);
+ if (tm == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ week = log_week_yyyymmdd(tm);
+ if (week == 0 || log_build_path_for_week(out, out_len, week) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static int log_open_for_time(const struct tm *tm)
+{
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ int week;
+ int rotated = 0;
+
+ if (tm == NULL) {
+ return -1;
+ }
+
+ week = log_week_yyyymmdd(tm);
+ if (week == 0) {
+ return -1;
+ }
+
+ if (g_log_file != NULL && g_log_open_week_yyyymmdd == week) {
+ return 0;
+ }
+
+ if (g_log_file != NULL) {
+ rotated = 1;
+ }
+
+ log_close_file();
+
+ if (mkdir_p(g_log_config.dir) != 0) {
+ fprintf(stderr, "[log] cannot create directory %s\n", g_log_config.dir);
+ return -1;
+ }
+
+ if (log_build_path_for_week(path, sizeof(path), week) != 0) {
+ fprintf(stderr, "[log] path too long for log file\n");
+ return -1;
+ }
+
+ fp = fopen(path, "a");
+ if (fp == NULL) {
+ fprintf(stderr, "[log] cannot open %s\n", path);
+ return -1;
+ }
+
+ g_log_file = fp;
+ g_log_open_week_yyyymmdd = week;
+ log_week_basename(week, g_log_open_name, sizeof(g_log_open_name));
+
+ /* Day-8 switch packs closed week (+ legacy). Startup sweeps if ≥7. */
+ log_archive_packable(rotated);
+
+ return 0;
+}
+
+static int log_ensure_open(void)
+{
+ time_t now;
+ struct tm tm_buf;
+ struct tm *tm;
+
+ if (!g_log_config.enabled || g_log_config.dir[0] == '\0') {
+ return -1;
+ }
+
+ now = time(NULL);
+ tm = localtime_r(&now, &tm_buf);
+ if (tm == NULL) {
+ return -1;
+ }
+
+ return log_open_for_time(tm);
+}
+
+#if !defined(HYBBX_CLIENT_BUILD)
+void hybbx_log_config_apply(const struct hybbx_config *config)
+{
+ const char *dir_raw;
+ const char *level_raw;
+ hybbx_log_level_t parsed;
+
+ hybbx_log_shutdown();
+ hybbx_log_config_defaults(&g_log_config);
+
+ if (config != NULL) {
+ g_log_config.enabled =
+ hybbx_config_get_bool(config, "log", "enabled", 1);
+ dir_raw = hybbx_config_get(config, "log", "dir", NULL);
+ level_raw = hybbx_config_get(config, "log", "level", "warn");
+
+ parsed = hybbx_log_parse_level(level_raw);
+ if (level_raw != NULL && level_raw[0] != '\0' &&
+ strcmp(level_raw, hybbx_log_level_name(parsed)) != 0 &&
+ strcasecmp(level_raw, hybbx_log_level_name(parsed)) != 0) {
+ fprintf(stderr,
+ "[log] unknown level '%s' — using warn (debug|stats|info|warn)\n",
+ level_raw);
+ }
+ g_log_config.level = parsed;
+
+ if (dir_raw != NULL && dir_raw[0] != '\0') {
+ if (hybbx_path_resolve(g_log_config.dir, sizeof(g_log_config.dir),
+ dir_raw) != HYBBX_OK) {
+ fprintf(stderr, "[log] invalid dir path\n");
+ g_log_config.enabled = 0;
+ }
+ } else if (g_log_config.enabled) {
+ if (hybbx_path_resolve(g_log_config.dir, sizeof(g_log_config.dir),
+ HYBBX_DIR_LOGS) != HYBBX_OK) {
+ fprintf(stderr, "[log] cannot resolve default log directory\n");
+ g_log_config.enabled = 0;
+ }
+ }
+ } else {
+ if (hybbx_path_resolve(g_log_config.dir, sizeof(g_log_config.dir),
+ HYBBX_DIR_LOGS) != HYBBX_OK) {
+ g_log_config.enabled = 0;
+ }
+ }
+
+ g_log_ready = 1;
+
+ if (g_log_config.enabled) {
+ if (log_ensure_open() != 0) {
+ g_log_config.enabled = 0;
+ }
+ }
+
+ printf("[log] enabled=%s dir=%s level=%s week=%s\n",
+ hybbx_bool_to_string(g_log_config.enabled),
+ g_log_config.dir[0] != '\0' ? g_log_config.dir : "-",
+ hybbx_log_level_name(g_log_config.level),
+ g_log_open_name[0] != '\0' ? g_log_open_name : "-");
+}
+#else
+void hybbx_log_config_apply(const struct hybbx_config *config)
+{
+ (void)config;
+ hybbx_log_config_defaults(&g_log_config);
+ g_log_ready = 1;
+}
+#endif
+
+static void log_write_console(hybbx_log_level_t level, const char *message)
+{
+ FILE *out = (level == HYBBX_LOG_WARN) ? stderr : stdout;
+
+ fputs(message, out);
+ fputc('\n', out);
+ fflush(out);
+}
+
+static void log_write_file(hybbx_log_level_t level, const char *message)
+{
+ char line[HYBBX_LOG_LINE_MAX + 64];
+ time_t now;
+ struct tm tm_buf;
+ struct tm *tm;
+
+ if (!g_log_config.enabled) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_log_lock);
+
+ if (log_ensure_open() != 0) {
+ pthread_mutex_unlock(&g_log_lock);
+ return;
+ }
+
+ now = time(NULL);
+ tm = localtime_r(&now, &tm_buf);
+ if (tm == NULL) {
+ pthread_mutex_unlock(&g_log_lock);
+ return;
+ }
+
+ if (hybbx_time_format_stamp(line, sizeof(line), tm, NULL) != HYBBX_OK) {
+ pthread_mutex_unlock(&g_log_lock);
+ return;
+ }
+
+ {
+ size_t stamp_len = strlen(line);
+ snprintf(line + stamp_len, sizeof(line) - stamp_len,
+ " [%s] %s\n", hybbx_log_level_name(level), message);
+ }
+
+ fputs(line, g_log_file);
+ fflush(g_log_file);
+
+ pthread_mutex_unlock(&g_log_lock);
+}
+
+void hybbx_log_write(hybbx_log_level_t level, const char *fmt, ...)
+{
+ char message[HYBBX_LOG_LINE_MAX];
+ va_list ap;
+
+ if (fmt == NULL) {
+ return;
+ }
+
+ if (g_log_ready && !hybbx_log_level_visible(level)) {
+ return;
+ }
+
+ va_start(ap, fmt);
+ vsnprintf(message, sizeof(message), fmt, ap);
+ va_end(ap);
+
+ log_write_console(level, message);
+ if (g_log_ready) {
+ log_write_file(level, message);
+ }
+}
+
+void hybbx_log_shutdown(void)
+{
+ pthread_mutex_lock(&g_log_lock);
+ log_close_file();
+ g_log_ready = 0;
+ hybbx_log_config_defaults(&g_log_config);
+ pthread_mutex_unlock(&g_log_lock);
+}
diff --git a/src/core/mail.c b/src/core/mail.c
new file mode 100644
index 0000000..b3aff97
--- /dev/null
+++ b/src/core/mail.c
@@ -0,0 +1,1532 @@
+#include "hybbx/mail.h"
+#include "hybbx/messages.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+#include "hybbx/log.h"
+#include "mail_sql.h"
+
+#include <ctype.h>
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#define HYBBX_MAIL_DIR_NAME "mail"
+#define HYBBX_MAIL_INBOX_NAME "inbox"
+#define HYBBX_MAIL_RECYCLE_NAME "recycle"
+#define HYBBX_MAIL_NEXT_FILE "mail.next"
+
+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 int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ len = strlen(path);
+ if (len >= sizeof(buf)) {
+ return -1;
+ }
+
+ memcpy(buf, path, len + 1);
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] == '/') {
+ buf[i] = '\0';
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static hybbx_result_t read_counter(const char *path, uint64_t *value)
+{
+ FILE *fp;
+ unsigned long long n = 0;
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ *value = 0;
+ return HYBBX_OK;
+ }
+
+ if (fscanf(fp, "%llu", &n) != 1) {
+ fclose(fp);
+ return HYBBX_ERR_IO;
+ }
+
+ fclose(fp);
+ *value = (uint64_t)n;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t write_counter(const char *path, uint64_t value)
+{
+ FILE *fp;
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "%llu\n", (unsigned long long)value);
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_user_inbox_path(const hybbx_mail_config_t *mail,
+ const char *username,
+ char *out, size_t out_len)
+{
+ char user_dir[HYBBX_PATH_MAX];
+ char user_norm[HYBBX_USER_NAME_MAX];
+
+ if (mail == NULL || username == NULL || username[0] == '\0' ||
+ out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(user_norm, username, sizeof(user_norm));
+ hybbx_username_normalize(user_norm);
+
+ if (hybbx_path_join(user_dir, sizeof(user_dir), mail->root, user_norm) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, user_dir, HYBBX_MAIL_INBOX_NAME);
+}
+
+static hybbx_result_t mail_user_recycle_path(const hybbx_mail_config_t *mail,
+ const char *username,
+ char *out, size_t out_len)
+{
+ char user_dir[HYBBX_PATH_MAX];
+ char user_norm[HYBBX_USER_NAME_MAX];
+
+ if (mail == NULL || username == NULL || username[0] == '\0' ||
+ out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(user_norm, username, sizeof(user_norm));
+ hybbx_username_normalize(user_norm);
+
+ if (hybbx_path_join(user_dir, sizeof(user_dir), mail->root, user_norm) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, user_dir, HYBBX_MAIL_RECYCLE_NAME);
+}
+
+static int parse_msg_filename(const char *name, uint64_t *out_id)
+{
+ char *end;
+ unsigned long long id;
+
+ if (name == NULL || out_id == NULL) {
+ return 0;
+ }
+
+ if (strlen(name) < 5 || strcmp(name + strlen(name) - 4, ".msg") != 0) {
+ return 0;
+ }
+
+ id = strtoull(name, &end, 10);
+ if (end == name || strcmp(end, ".msg") != 0) {
+ return 0;
+ }
+
+ *out_id = (uint64_t)id;
+ return 1;
+}
+
+static int mail_entry_compare(const void *a, const void *b)
+{
+ const hybbx_mail_entry_t *ea = (const hybbx_mail_entry_t *)a;
+ const hybbx_mail_entry_t *eb = (const hybbx_mail_entry_t *)b;
+
+ if (ea->received_at > eb->received_at) {
+ return -1;
+ }
+ if (ea->received_at < eb->received_at) {
+ return 1;
+ }
+ if (ea->id > eb->id) {
+ return -1;
+ }
+ if (ea->id < eb->id) {
+ return 1;
+ }
+ return 0;
+}
+
+static hybbx_result_t load_inbox(const char *inbox_path,
+ hybbx_mail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count)
+{
+ DIR *dir;
+ struct dirent *ent;
+ size_t count = 0;
+
+ if (inbox_path == NULL || entries == NULL || out_count == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out_count = 0;
+
+ dir = opendir(inbox_path);
+ if (dir == NULL) {
+ if (errno == ENOENT) {
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ while ((ent = readdir(dir)) != NULL) {
+ uint64_t id;
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ hybbx_mail_entry_t entry;
+
+ if (!parse_msg_filename(ent->d_name, &id)) {
+ continue;
+ }
+
+ if (count >= max_entries) {
+ break;
+ }
+
+ memset(&entry, 0, sizeof(entry));
+ entry.id = id;
+
+ if (hybbx_path_join(path, sizeof(path), inbox_path, ent->d_name) !=
+ HYBBX_OK) {
+ continue;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ continue;
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *eq;
+ char *key;
+ char *value;
+
+ if (line[0] == '-' && line[1] == '-' && line[2] == '-') {
+ break;
+ }
+
+ eq = strchr(line, '=');
+ if (eq == NULL) {
+ continue;
+ }
+
+ *eq = '\0';
+ key = line;
+ value = eq + 1;
+
+ while (*value == ' ' || *value == '\t') {
+ value++;
+ }
+
+ {
+ size_t vlen = strlen(value);
+
+ while (vlen > 0 && (value[vlen - 1] == '\n' ||
+ value[vlen - 1] == '\r')) {
+ value[--vlen] = '\0';
+ }
+ }
+
+ if (str_ieq(key, "from")) {
+ hybbx_strlcpy(entry.from, value, sizeof(entry.from));
+ } else if (str_ieq(key, "subject")) {
+ hybbx_strlcpy(entry.subject, value, sizeof(entry.subject));
+ } else if (str_ieq(key, "time")) {
+ entry.received_at = (time_t)strtol(value, NULL, 10);
+ } else if (str_ieq(key, "read")) {
+ entry.read = hybbx_bool_is_true(value);
+ }
+ }
+
+ fclose(fp);
+ entries[count++] = entry;
+ }
+
+ closedir(dir);
+
+ if (count > 1) {
+ qsort(entries, count, sizeof(entries[0]), mail_entry_compare);
+ }
+
+ *out_count = count;
+ return HYBBX_OK;
+}
+
+static int mail_uses_sqlite(hybbx_service_t *service)
+{
+ hybbx_storage_t *storage;
+
+ if (service == NULL) {
+ return 0;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ return storage != NULL &&
+ hybbx_storage_backend(storage) == HYBBX_STORAGE_SQLITE;
+}
+
+static hybbx_result_t mail_load_inbox(hybbx_service_t *service,
+ const hybbx_mail_config_t *mail,
+ const char *username,
+ hybbx_mail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count)
+{
+ hybbx_storage_t *storage;
+ char inbox[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (mail_uses_sqlite(service)) {
+ storage = hybbx_service_get_storage(service);
+ return hybbx_mail_sql_load_inbox(storage, username, entries,
+ max_entries, out_count);
+ }
+
+ rc = mail_user_inbox_path(mail, username, inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return load_inbox(inbox, entries, max_entries, out_count);
+}
+
+static hybbx_result_t msg_file_path(const char *inbox_path, uint64_t id,
+ char *out, size_t out_len)
+{
+ char name[32];
+
+ snprintf(name, sizeof(name), "%06llu.msg",
+ (unsigned long long)id);
+ return hybbx_path_join(out, out_len, inbox_path, name);
+}
+
+void hybbx_mail_config_defaults(hybbx_mail_config_t *mail)
+{
+ if (mail == NULL) {
+ return;
+ }
+
+ mail->enabled = 1;
+ mail->max_messages = HYBBX_MAIL_MAX_MESSAGES;
+ mail->subject_max = HYBBX_MAIL_SUBJECT_MAX;
+ mail->body_max = HYBBX_MAIL_BODY_MAX;
+ mail->recycle_days = HYBBX_MAIL_DEFAULT_RECYCLE_DAYS;
+ mail->root[0] = '\0';
+}
+
+void hybbx_mail_config_apply(hybbx_mail_config_t *mail,
+ const hybbx_config_t *config,
+ const char *storage_path)
+{
+ if (mail == NULL) {
+ return;
+ }
+
+ hybbx_mail_config_defaults(mail);
+
+ if (config != NULL) {
+ mail->enabled = hybbx_config_get_bool(config, "mail", "enabled", 1);
+ mail->max_messages = hybbx_config_get_uint(
+ config, "mail", "max_messages", HYBBX_MAIL_MAX_MESSAGES, 1u,
+ HYBBX_MAIL_MAX_MESSAGES);
+ mail->subject_max = hybbx_config_get_uint(
+ config, "mail", "subject_max", HYBBX_MAIL_SUBJECT_MAX, 8u,
+ HYBBX_MAIL_SUBJECT_MAX);
+ mail->body_max = hybbx_config_get_uint(
+ config, "mail", "body_max", HYBBX_MAIL_BODY_MAX, 64u,
+ HYBBX_MAIL_BODY_MAX);
+ mail->recycle_days = hybbx_config_get_uint(
+ config, "mail", "recycle_days", HYBBX_MAIL_DEFAULT_RECYCLE_DAYS,
+ 1u, 365u);
+ }
+
+ mail->root[0] = '\0';
+ if (storage_path != NULL && storage_path[0] != '\0') {
+ const char *subdir = "mail";
+ const char *custom;
+
+ if (config != NULL) {
+ custom = hybbx_config_get(config, "mail", "path", NULL);
+ if (custom != NULL && custom[0] != '\0') {
+ subdir = custom;
+ }
+ }
+
+ if (hybbx_path_join(mail->root, sizeof(mail->root), storage_path,
+ subdir) != HYBBX_OK) {
+ mail->root[0] = '\0';
+ }
+ }
+
+ hybbx_log_info("[mail] enabled=%s max_messages=%u recycle_days=%u root=%s",
+ hybbx_bool_to_string(mail->enabled), mail->max_messages,
+ mail->recycle_days,
+ mail->root[0] != '\0' ? mail->root : "(unset)");
+}
+
+static hybbx_result_t mail_ensure_root(const hybbx_mail_config_t *mail)
+{
+ char next_path[HYBBX_PATH_MAX];
+
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (mail->root[0] == '\0') {
+ return HYBBX_ERR_IO;
+ }
+
+ if (mkdir_p(mail->root) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(next_path, sizeof(next_path), mail->root,
+ HYBBX_MAIL_NEXT_FILE) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ FILE *fp = fopen(next_path, "r");
+
+ if (fp == NULL) {
+ return write_counter(next_path, 0);
+ }
+ fclose(fp);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_next_id(const hybbx_mail_config_t *mail,
+ uint64_t *out_id)
+{
+ char next_path[HYBBX_PATH_MAX];
+ uint64_t id;
+ hybbx_result_t rc;
+
+ if (hybbx_path_join(next_path, sizeof(next_path), mail->root,
+ HYBBX_MAIL_NEXT_FILE) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = read_counter(next_path, &id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ id++;
+ rc = write_counter(next_path, id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ *out_id = id;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_trim_inbox(hybbx_service_t *service,
+ const hybbx_mail_config_t *mail,
+ const char *username,
+ unsigned max_messages)
+{
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count;
+ size_t i;
+ hybbx_result_t rc;
+ char inbox[HYBBX_PATH_MAX];
+
+ if (mail_uses_sqlite(service)) {
+ return HYBBX_OK;
+ }
+
+ rc = mail_user_inbox_path(mail, username, inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = load_inbox(inbox, entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (count <= max_messages) {
+ return HYBBX_OK;
+ }
+
+ for (i = max_messages; i < count; i++) {
+ char path[HYBBX_PATH_MAX];
+
+ if (msg_file_path(inbox, entries[i].id, path, sizeof(path)) ==
+ HYBBX_OK) {
+ remove(path);
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_ensure_storage(hybbx_service_t *service,
+ const hybbx_mail_config_t *mail)
+{
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (mail_uses_sqlite(service)) {
+ return HYBBX_OK;
+ }
+
+ return mail_ensure_root(mail);
+}
+
+void hybbx_mail_list_inbox(hybbx_service_t *service, hybbx_session_t *session)
+{
+ hybbx_mail_list_inbox_range(service, session, 1, 0);
+}
+
+static const char *mail_display_name(hybbx_service_t *service,
+ const char *name,
+ char *buf, size_t buf_len)
+{
+ hybbx_storage_t *storage;
+ hybbx_user_record_t user;
+
+ if (name == NULL || name[0] == '\0') {
+ return "";
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage != NULL &&
+ hybbx_storage_resolve_user(storage, name, &user) == HYBBX_OK) {
+ return hybbx_user_display_name(&user);
+ }
+
+ hybbx_strlcpy(buf, name, buf_len);
+ return buf;
+}
+
+static void mail_list_print(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const hybbx_mail_entry_t *entries,
+ size_t count,
+ unsigned from,
+ unsigned to)
+{
+ size_t i;
+ size_t start;
+ size_t end;
+ char line[HYBBX_MAIL_SUBJECT_MAX + 64];
+ char header[48];
+ char from_name[HYBBX_USER_NAME_MAX];
+
+ if (to == 0 || to > count) {
+ to = (unsigned)count;
+ }
+
+ if (count == 0) {
+ hybbx_session_write_line(session, "Inbox empty.");
+ hybbx_session_write_line(session,
+ "Use /mail send <user> <subject> to compose.");
+ return;
+ }
+
+ if (from == 0 || from > count || from > to) {
+ hybbx_session_write_line(session, "No messages in that range.");
+ return;
+ }
+
+ start = (size_t)(from - 1);
+ end = (size_t)to;
+
+ if (from == 1 && to == (unsigned)count) {
+ hybbx_session_write_line(session, "Inbox (newest first):");
+ } else {
+ snprintf(header, sizeof(header), "Inbox %u-%u (newest first):",
+ from, to);
+ hybbx_session_write_line(session, header);
+ }
+
+ for (i = start; i < end; i++) {
+ snprintf(line, sizeof(line), " %zu %s%s %s",
+ i + 1,
+ entries[i].read ? " " : "* ",
+ mail_display_name(service, entries[i].from, from_name,
+ sizeof(from_name)),
+ entries[i].subject[0] != '\0' ? entries[i].subject
+ : "(no subject)");
+ hybbx_session_write_line(session, line);
+ }
+
+ hybbx_session_write_line(session,
+ " /mail read <n> delete <n|from-to> list <from-to> recycle");
+}
+
+static unsigned mail_recycle_purge_expired(const hybbx_mail_config_t *mail,
+ const char *recycle_path)
+{
+ DIR *dir;
+ struct dirent *ent;
+ time_t now = time(NULL);
+ time_t max_age;
+ unsigned removed = 0;
+
+ if (mail == NULL || recycle_path == NULL || recycle_path[0] == '\0') {
+ return 0;
+ }
+
+ max_age = (time_t)mail->recycle_days * 86400;
+
+ dir = opendir(recycle_path);
+ if (dir == NULL) {
+ if (errno == ENOENT) {
+ return 0;
+ }
+ return 0;
+ }
+
+ while ((ent = readdir(dir)) != NULL) {
+ uint64_t id;
+ char path[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (!parse_msg_filename(ent->d_name, &id)) {
+ continue;
+ }
+
+ if (hybbx_path_join(path, sizeof(path), recycle_path, ent->d_name) !=
+ HYBBX_OK) {
+ continue;
+ }
+
+ if (stat(path, &st) != 0) {
+ continue;
+ }
+
+ if (now - st.st_mtime >= max_age) {
+ if (remove(path) == 0) {
+ removed++;
+ }
+ }
+ }
+
+ closedir(dir);
+ return removed;
+}
+
+static hybbx_result_t mail_move_id_to_recycle(const char *inbox_path,
+ const char *recycle_path,
+ uint64_t id)
+{
+ char src[HYBBX_PATH_MAX];
+ char dst[HYBBX_PATH_MAX];
+ FILE *in_fp;
+ FILE *out_fp;
+ char line[HYBBX_LINE_MAX];
+ char tmp[HYBBX_PATH_MAX + 8];
+ int has_deleted = 0;
+
+ if (msg_file_path(inbox_path, id, src, sizeof(src)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (mkdir_p(recycle_path) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (msg_file_path(recycle_path, id, dst, sizeof(dst)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (strlen(dst) + 4 >= sizeof(tmp)) {
+ return HYBBX_ERR_IO;
+ }
+ snprintf(tmp, sizeof(tmp), "%s.tmp", dst);
+
+ in_fp = fopen(src, "r");
+ out_fp = fopen(tmp, "w");
+ if (in_fp == NULL || out_fp == NULL) {
+ if (in_fp != NULL) {
+ fclose(in_fp);
+ }
+ if (out_fp != NULL) {
+ fclose(out_fp);
+ }
+ remove(tmp);
+ return HYBBX_ERR_IO;
+ }
+
+ while (fgets(line, sizeof(line), in_fp) != NULL) {
+ if (strncmp(line, "deleted=", 8) == 0) {
+ continue;
+ }
+ if (!has_deleted && line[0] == '-' && line[1] == '-' && line[2] == '-') {
+ fprintf(out_fp, "deleted=%ld\n", (long)time(NULL));
+ has_deleted = 1;
+ }
+ fputs(line, out_fp);
+ }
+
+ if (!has_deleted) {
+ fprintf(out_fp, "deleted=%ld\n", (long)time(NULL));
+ }
+
+ fclose(in_fp);
+ fclose(out_fp);
+
+ if (remove(src) != 0) {
+ remove(tmp);
+ return HYBBX_ERR_IO;
+ }
+
+ if (rename(tmp, dst) != 0) {
+ remove(tmp);
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_purge_user_recycle(hybbx_service_t *service,
+ const hybbx_mail_config_t *mail,
+ const char *username)
+{
+ char recycle[HYBBX_PATH_MAX];
+
+ if (mail_uses_sqlite(service)) {
+ (void)hybbx_mail_sql_purge_recycle(hybbx_service_get_storage(service),
+ mail, username);
+ return HYBBX_OK;
+ }
+
+ if (mail_user_recycle_path(mail, username, recycle, sizeof(recycle)) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ (void)mail_recycle_purge_expired(mail, recycle);
+ return HYBBX_OK;
+}
+
+void hybbx_mail_list_inbox_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from,
+ unsigned to)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ char inbox[HYBBX_PATH_MAX];
+ size_t count;
+ hybbx_result_t rc;
+
+ if (service == NULL || session == NULL) {
+ return;
+ }
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use mail.");
+ return;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ hybbx_session_write_line(session, "Mail is disabled.");
+ return;
+ }
+
+ rc = mail_ensure_storage(service, mail);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Mail storage unavailable.");
+ return;
+ }
+
+ if (!mail_uses_sqlite(service)) {
+ rc = mail_user_inbox_path(mail, hybbx_session_username(session),
+ inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Mail path error.");
+ return;
+ }
+ }
+
+ (void)mail_purge_user_recycle(service, mail, hybbx_session_username(session));
+
+ rc = mail_load_inbox(service, mail, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Cannot read inbox.");
+ return;
+ }
+
+ mail_list_print(service, session, entries, count, from, to);
+}
+
+void hybbx_mail_announce_since_last_login(hybbx_service_t *service,
+ hybbx_session_t *session,
+ time_t since_login)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ char inbox[HYBBX_PATH_MAX];
+ size_t count;
+ size_t i;
+ size_t new_count = 0;
+ hybbx_result_t rc;
+
+ if (service == NULL || session == NULL) {
+ return;
+ }
+
+ if (hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ return;
+ }
+
+ rc = mail_ensure_storage(service, mail);
+ if (rc != HYBBX_OK) {
+ return;
+ }
+
+ if (!mail_uses_sqlite(service)) {
+ rc = mail_user_inbox_path(mail, hybbx_session_username(session),
+ inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ return;
+ }
+ }
+
+ (void)mail_purge_user_recycle(service, mail, hybbx_session_username(session));
+
+ rc = mail_load_inbox(service, mail, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK || count == 0) {
+ return;
+ }
+
+ for (i = 0; i < count; i++) {
+ if (since_login == 0 || entries[i].received_at > since_login) {
+ new_count++;
+ }
+ }
+
+ if (new_count == 0) {
+ return;
+ }
+
+ if (new_count == 1) {
+ (void)hybbx_msg_send_system(session,
+ "You have 1 new message since your last login. (/mail)");
+ } else {
+ char body[96];
+ snprintf(body, sizeof(body),
+ "You have %zu new messages since your last login. (/mail)",
+ new_count);
+ (void)hybbx_msg_send_system(session, body);
+ }
+}
+
+int hybbx_mail_parse_list_range(const char *spec,
+ unsigned *from, unsigned *to)
+{
+ const char *dash;
+ char *end;
+ unsigned long start;
+ unsigned long end_num;
+
+ if (from == NULL || to == NULL) {
+ return 0;
+ }
+
+ if (spec == NULL || spec[0] == '\0') {
+ *from = 1;
+ *to = 0;
+ return 1;
+ }
+
+ dash = strchr(spec, '-');
+ if (dash == NULL) {
+ start = strtoul(spec, &end, 10);
+ if (end == spec || *end != '\0' || start == 0) {
+ return 0;
+ }
+ *from = (unsigned)start;
+ *to = (unsigned)start;
+ return 1;
+ }
+
+ start = strtoul(spec, &end, 10);
+ if (end != dash || start == 0) {
+ return 0;
+ }
+
+ end_num = strtoul(dash + 1, &end, 10);
+ if (end == dash + 1 || *end != '\0' || end_num == 0) {
+ return 0;
+ }
+
+ if (start > end_num) {
+ return 0;
+ }
+
+ *from = (unsigned)start;
+ *to = (unsigned)end_num;
+ return 1;
+}
+
+static hybbx_result_t mail_resolve_index(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index,
+ uint64_t *out_id,
+ char *inbox_path, size_t inbox_len)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count;
+ hybbx_result_t rc;
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ rc = mail_ensure_storage(service, mail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (!mail_uses_sqlite(service)) {
+ rc = mail_user_inbox_path(mail, hybbx_session_username(session),
+ inbox_path, inbox_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ rc = mail_load_inbox(service, mail, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (list_index == 0 || list_index > count) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ *out_id = entries[list_index - 1].id;
+ return HYBBX_OK;
+}
+
+static void mail_print_header_line(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const char *line)
+{
+ char out_line[HYBBX_LINE_MAX];
+ char name_buf[HYBBX_USER_NAME_MAX];
+ char fallback[HYBBX_USER_NAME_MAX];
+ const char *value;
+ const char *display;
+ size_t vlen;
+
+ if (line == NULL || session == NULL) {
+ return;
+ }
+
+ if (strncmp(line, "from=", 5) == 0) {
+ value = line + 5;
+ vlen = strlen(value);
+ while (vlen > 0 && (value[vlen - 1] == '\n' || value[vlen - 1] == '\r')) {
+ vlen--;
+ }
+ memcpy(name_buf, value, vlen);
+ name_buf[vlen] = '\0';
+ display = mail_display_name(service, name_buf, fallback, sizeof(fallback));
+ snprintf(out_line, sizeof(out_line), "from=%s", display);
+ hybbx_session_write_line(session, out_line);
+ return;
+ }
+
+ if (strncmp(line, "to=", 3) == 0) {
+ value = line + 3;
+ vlen = strlen(value);
+ while (vlen > 0 && (value[vlen - 1] == '\n' || value[vlen - 1] == '\r')) {
+ vlen--;
+ }
+ memcpy(name_buf, value, vlen);
+ name_buf[vlen] = '\0';
+ display = mail_display_name(service, name_buf, fallback, sizeof(fallback));
+ snprintf(out_line, sizeof(out_line), "to=%s", display);
+ hybbx_session_write_line(session, out_line);
+ return;
+ }
+
+ hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_mail_read(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index)
+{
+ char inbox[HYBBX_PATH_MAX];
+ char path[HYBBX_PATH_MAX];
+ uint64_t id;
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ int in_body = 0;
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use mail.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (mail_uses_sqlite(service)) {
+ (void)hybbx_mail_sql_purge_recycle(hybbx_service_get_storage(service),
+ hybbx_service_get_mail(service),
+ hybbx_session_username(session));
+ return hybbx_mail_sql_read(service, session, list_index);
+ }
+
+ (void)mail_purge_user_recycle(service, hybbx_service_get_mail(service),
+ hybbx_session_username(session));
+
+ rc = mail_resolve_index(service, session, list_index, &id,
+ inbox, sizeof(inbox));
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "No such message.");
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (msg_file_path(inbox, id, path, sizeof(path)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ hybbx_session_write_line(session, "Cannot open message.");
+ return HYBBX_ERR_IO;
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ if (!in_body) {
+ if (line[0] == '-' && line[1] == '-' && line[2] == '-') {
+ in_body = 1;
+ continue;
+ }
+ if (strncmp(line, "read=", 5) == 0) {
+ continue;
+ }
+ if (strncmp(line, "from=", 5) == 0 ||
+ strncmp(line, "to=", 3) == 0) {
+ mail_print_header_line(service, session, line);
+ continue;
+ }
+ }
+ hybbx_session_write_line(session, line);
+ }
+
+ fclose(fp);
+
+ {
+ char tmp_path[HYBBX_PATH_MAX + 8];
+ FILE *in_fp;
+ FILE *out_fp;
+ char buf[HYBBX_LINE_MAX];
+
+ if (strlen(path) + 4 >= sizeof(tmp_path)) {
+ return HYBBX_OK;
+ }
+ snprintf(tmp_path, sizeof(tmp_path), "%s.tmp", path);
+ in_fp = fopen(path, "r");
+ out_fp = fopen(tmp_path, "w");
+ if (in_fp != NULL && out_fp != NULL) {
+ while (fgets(buf, sizeof(buf), in_fp) != NULL) {
+ if (strncmp(buf, "read=", 5) == 0) {
+ fputs("read=yes\n", out_fp);
+ } else {
+ fputs(buf, out_fp);
+ }
+ }
+ fclose(in_fp);
+ fclose(out_fp);
+ remove(path);
+ rename(tmp_path, path);
+ } else {
+ if (in_fp != NULL) {
+ fclose(in_fp);
+ }
+ if (out_fp != NULL) {
+ fclose(out_fp);
+ }
+ remove(tmp_path);
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_delete_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from,
+ unsigned to)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ char inbox[HYBBX_PATH_MAX];
+ char recycle[HYBBX_PATH_MAX];
+ size_t count;
+ size_t i;
+ unsigned moved = 0;
+ hybbx_result_t rc;
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use mail.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ hybbx_session_write_line(session, "Mail is disabled.");
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (to == 0) {
+ to = from;
+ }
+ if (from == 0 || from > to) {
+ hybbx_session_write_line(session, "Usage: /mail delete <n|from-to>");
+ return HYBBX_OK;
+ }
+
+ if (mail_uses_sqlite(service)) {
+ return hybbx_mail_sql_delete_range(service, session, from, to);
+ }
+
+ rc = mail_ensure_root(mail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = mail_user_inbox_path(mail, hybbx_session_username(session),
+ inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = mail_user_recycle_path(mail, hybbx_session_username(session),
+ recycle, sizeof(recycle));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (void)mail_purge_user_recycle(service, mail, hybbx_session_username(session));
+
+ rc = mail_load_inbox(service, mail, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (from > count || to > count) {
+ hybbx_session_write_line(session, "No such message.");
+ return HYBBX_OK;
+ }
+
+ for (i = (size_t)(from - 1); i < (size_t)to; i++) {
+ rc = mail_move_id_to_recycle(inbox, recycle, entries[i].id);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(session, "Delete failed.");
+ return rc;
+ }
+ moved++;
+ }
+
+ if (moved == 1) {
+ hybbx_session_write_line(session,
+ "Message moved to recycle (auto-purge after configured days).");
+ } else {
+ char buf[64];
+
+ snprintf(buf, sizeof(buf),
+ "%u messages moved to recycle (auto-purge after %u days).",
+ moved, mail->recycle_days);
+ hybbx_session_write_line(session, buf);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_delete(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index)
+{
+ return hybbx_mail_delete_range(service, session, list_index, list_index);
+}
+
+hybbx_result_t hybbx_mail_recycle_empty(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ const hybbx_mail_config_t *mail;
+ char recycle[HYBBX_PATH_MAX];
+ DIR *dir;
+ struct dirent *ent;
+ unsigned removed = 0;
+ hybbx_result_t rc;
+ char buf[64];
+
+ if (hybbx_session_is_guest(session)) {
+ hybbx_session_write_line(session, "Guests cannot use mail.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ hybbx_session_write_line(session, "Mail is disabled.");
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (mail_uses_sqlite(service)) {
+ return hybbx_mail_sql_recycle_empty(service, session);
+ }
+
+ rc = mail_ensure_root(mail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = mail_user_recycle_path(mail, hybbx_session_username(session),
+ recycle, sizeof(recycle));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (void)mail_recycle_purge_expired(mail, recycle);
+
+ dir = opendir(recycle);
+ if (dir == NULL) {
+ if (errno == ENOENT) {
+ hybbx_session_write_line(session, "Recycle bin empty.");
+ return HYBBX_OK;
+ }
+ hybbx_session_write_line(session, "Cannot read recycle bin.");
+ return HYBBX_ERR_IO;
+ }
+
+ while ((ent = readdir(dir)) != NULL) {
+ uint64_t id;
+ char path[HYBBX_PATH_MAX];
+
+ if (!parse_msg_filename(ent->d_name, &id)) {
+ continue;
+ }
+
+ if (hybbx_path_join(path, sizeof(path), recycle, ent->d_name) !=
+ HYBBX_OK) {
+ continue;
+ }
+
+ if (remove(path) == 0) {
+ removed++;
+ }
+ }
+
+ closedir(dir);
+
+ if (removed == 0) {
+ hybbx_session_write_line(session, "Recycle bin empty.");
+ } else {
+ snprintf(buf, sizeof(buf), "Recycle bin emptied (%u message(s)).",
+ removed);
+ hybbx_session_write_line(session, buf);
+ }
+
+ return HYBBX_OK;
+}
+
+#define HYBBX_MAIL_SYSTEM_FROM "system"
+
+static void mail_format_utc_time(time_t when, char *buf, size_t len)
+{
+ if (buf == NULL || len == 0) {
+ return;
+ }
+
+ buf[0] = '\0';
+ if (when <= 0) {
+ return;
+ }
+
+ {
+ const struct tm *tm = gmtime(&when);
+
+ if (tm != NULL) {
+ (void)strftime(buf, len, "%Y-%m-%d %H:%M:%S UTC", tm);
+ }
+ }
+}
+
+typedef struct mail_notify_staff_ctx {
+ hybbx_service_t *service;
+ char subject[HYBBX_MAIL_SUBJECT_MAX];
+ char body[HYBBX_MAIL_BODY_MAX];
+} mail_notify_staff_ctx_t;
+
+static hybbx_result_t mail_notify_staff_cb(const hybbx_user_record_t *user,
+ void *ctx)
+{
+ mail_notify_staff_ctx_t *nctx = (mail_notify_staff_ctx_t *)ctx;
+
+ if (user == NULL || nctx == NULL) {
+ return HYBBX_OK;
+ }
+
+ if (!user->active || !hybbx_user_level_is_sysop_or_admin(user->level)) {
+ return HYBBX_OK;
+ }
+
+ (void)hybbx_mail_deliver(nctx->service, HYBBX_MAIL_SYSTEM_FROM,
+ user->username, nctx->subject, nctx->body);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_notify_staff_registration(hybbx_service_t *service,
+ const hybbx_user_registration_t *reg,
+ const hybbx_user_record_t *user)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_storage_t *storage;
+ mail_notify_staff_ctx_t ctx;
+ char created[48];
+ int n;
+
+ if (service == NULL || reg == NULL || user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_OK;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = snprintf(ctx.subject, sizeof(ctx.subject),
+ "Registration pending: %s", reg->nickname);
+ if (n < 0 || (size_t)n >= sizeof(ctx.subject)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mail_format_utc_time(user->created_at, created, sizeof(created));
+ if (created[0] == '\0') {
+ hybbx_strlcpy(created, "(unknown)", sizeof(created));
+ }
+
+ n = snprintf(ctx.body, sizeof(ctx.body),
+ "Guest self-registration pending approval.\n"
+ "/register data submitted:\n\n"
+ " Nickname: %s\n"
+ " Login: %s\n"
+ " Full name: %s\n"
+ " Country: %s\n"
+ " Location: %s\n"
+ " Email: %s\n\n"
+ "Account record:\n\n"
+ " User ID: %llu\n"
+ " Level: %s\n"
+ " Active: %s\n"
+ " Created: %s\n\n"
+ "Review all fields. If correct, activate with:\n"
+ " /activate %s\n",
+ reg->nickname,
+ reg->username,
+ reg->full_name,
+ reg->country,
+ reg->location,
+ reg->email,
+ (unsigned long long)user->id,
+ hybbx_user_level_name(user->level),
+ hybbx_bool_to_string(user->active),
+ created,
+ reg->username);
+ if (n < 0 || (size_t)n >= sizeof(ctx.body)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.service = service;
+
+ (void)hybbx_storage_foreach_user(storage, mail_notify_staff_cb, &ctx);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_deliver(hybbx_service_t *service,
+ const char *from_user,
+ const char *to_user,
+ const char *subject,
+ const char *body)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_storage_t *storage;
+ hybbx_user_record_t recipient;
+ char inbox[HYBBX_PATH_MAX];
+ char path[HYBBX_PATH_MAX];
+ uint64_t id;
+ FILE *fp;
+ hybbx_result_t rc;
+ char to_norm[HYBBX_USER_NAME_MAX];
+ size_t body_len;
+
+ if (service == NULL || from_user == NULL || to_user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (subject == NULL) {
+ subject = "";
+ }
+ if (body == NULL) {
+ body = "";
+ }
+
+ body_len = strlen(body);
+ if (strlen(subject) > mail->subject_max || body_len > mail->body_max) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(to_norm, to_user, sizeof(to_norm));
+ hybbx_username_normalize(to_norm);
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_resolve_user(storage, to_user, &recipient);
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_strlcpy(to_norm, recipient.username, sizeof(to_norm));
+
+ if (hybbx_user_level_is_guest(recipient.level) || !recipient.active) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (mail_uses_sqlite(service)) {
+ return hybbx_mail_sql_deliver(storage, mail, from_user, to_norm,
+ subject, body);
+ }
+
+ rc = mail_ensure_root(mail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = mail_user_inbox_path(mail, to_norm, inbox, sizeof(inbox));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (mkdir_p(inbox) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = mail_next_id(mail, &id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (msg_file_path(inbox, id, path, sizeof(path)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "id=%llu\n", (unsigned long long)id);
+ fprintf(fp, "from=%s\n", from_user);
+ fprintf(fp, "to=%s\n", to_norm);
+ fprintf(fp, "subject=%s\n", subject);
+ fprintf(fp, "time=%ld\n", (long)time(NULL));
+ fprintf(fp, "read=no\n");
+ fprintf(fp, "---\n");
+ fputs(body, fp);
+ if (body_len == 0 || body[body_len - 1] != '\n') {
+ fputc('\n', fp);
+ }
+ fclose(fp);
+
+ (void)mail_trim_inbox(service, mail, to_norm, mail->max_messages);
+ return HYBBX_OK;
+}
diff --git a/src/core/mail_sql.c b/src/core/mail_sql.c
new file mode 100644
index 0000000..95b93f9
--- /dev/null
+++ b/src/core/mail_sql.c
@@ -0,0 +1,655 @@
+#include "mail_sql.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+#include "storage_private.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#ifdef HYBBX_HAVE_SQLITE
+#include <sqlite3.h>
+
+#define MAIL_FOLDER_INBOX 0
+#define MAIL_FOLDER_RECYCLE 1
+
+static int mail_entry_compare(const void *a, const void *b)
+{
+ const hybbx_mail_entry_t *ea = (const hybbx_mail_entry_t *)a;
+ const hybbx_mail_entry_t *eb = (const hybbx_mail_entry_t *)b;
+
+ if (ea->received_at > eb->received_at) {
+ return -1;
+ }
+ if (ea->received_at < eb->received_at) {
+ return 1;
+ }
+ if (ea->id > eb->id) {
+ return -1;
+ }
+ if (ea->id < eb->id) {
+ return 1;
+ }
+ return 0;
+}
+
+static hybbx_result_t mail_sql_meta_bump(sqlite3 *db, const char *key,
+ uint64_t *value)
+{
+ sqlite3_stmt *stmt;
+ int rc;
+
+ *value = 0;
+
+ rc = sqlite3_prepare_v2(db,
+ "SELECT value FROM meta WHERE key=?1;",
+ -1, &stmt, NULL);
+ if (rc == SQLITE_OK) {
+ sqlite3_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
+ if (sqlite3_step(stmt) == SQLITE_ROW) {
+ *value = (uint64_t)sqlite3_column_int64(stmt, 0);
+ }
+ sqlite3_finalize(stmt);
+ }
+
+ (*value)++;
+
+ rc = sqlite3_prepare_v2(db,
+ "INSERT INTO meta(key,value) VALUES(?1,?2) "
+ "ON CONFLICT(key) DO UPDATE SET value=?2;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 2, (sqlite3_int64)*value);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? HYBBX_OK : HYBBX_ERR_IO;
+}
+
+static void mail_sql_owner_norm(char *owner, size_t len, const char *username)
+{
+ hybbx_strlcpy(owner, username, len);
+ hybbx_username_normalize(owner);
+}
+
+hybbx_result_t hybbx_mail_sql_load_inbox(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_mail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count)
+{
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ char owner[HYBBX_USER_NAME_MAX];
+ int rc;
+ size_t count = 0;
+
+ if (storage == NULL || username == NULL || entries == NULL ||
+ out_count == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), username);
+ *out_count = 0;
+
+ rc = sqlite3_prepare_v2(db,
+ "SELECT id,from_user,subject,received_at,read_flag "
+ "FROM messages WHERE owner=?1 AND folder=?2 "
+ "ORDER BY received_at DESC, id DESC;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, MAIL_FOLDER_INBOX);
+
+ while (sqlite3_step(stmt) == SQLITE_ROW && count < max_entries) {
+ hybbx_mail_entry_t *entry = &entries[count];
+
+ memset(entry, 0, sizeof(*entry));
+ entry->id = (uint64_t)sqlite3_column_int64(stmt, 0);
+ hybbx_strlcpy(entry->from, (const char *)sqlite3_column_text(stmt, 1),
+ sizeof(entry->from));
+ hybbx_strlcpy(entry->subject,
+ (const char *)sqlite3_column_text(stmt, 2),
+ sizeof(entry->subject));
+ entry->received_at = (time_t)sqlite3_column_int64(stmt, 3);
+ entry->read = sqlite3_column_int(stmt, 4);
+ count++;
+ }
+
+ sqlite3_finalize(stmt);
+
+ if (count > 1) {
+ qsort(entries, count, sizeof(entries[0]), mail_entry_compare);
+ }
+
+ *out_count = count;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mail_sql_trim_inbox(sqlite3 *db, const char *owner,
+ unsigned max_messages)
+{
+ sqlite3_stmt *stmt;
+ int rc;
+ size_t count = 0;
+
+ rc = sqlite3_prepare_v2(db,
+ "SELECT COUNT(*) FROM messages "
+ "WHERE owner=?1 AND folder=?2;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, MAIL_FOLDER_INBOX);
+ if (sqlite3_step(stmt) == SQLITE_ROW) {
+ count = (size_t)sqlite3_column_int64(stmt, 0);
+ }
+ sqlite3_finalize(stmt);
+
+ if (count <= max_messages) {
+ return HYBBX_OK;
+ }
+
+ rc = sqlite3_prepare_v2(db,
+ "DELETE FROM messages WHERE id IN ("
+ "SELECT id FROM messages WHERE owner=?1 "
+ "AND folder=?2 ORDER BY received_at ASC, id ASC "
+ "LIMIT ?3);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, MAIL_FOLDER_INBOX);
+ sqlite3_bind_int64(stmt, 3, (sqlite3_int64)(count - max_messages));
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? HYBBX_OK : HYBBX_ERR_IO;
+}
+
+hybbx_result_t hybbx_mail_sql_deliver(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *from_user,
+ const char *to_user,
+ const char *subject,
+ const char *body)
+{
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ char owner[HYBBX_USER_NAME_MAX];
+ uint64_t id;
+ int rc;
+ hybbx_result_t hres;
+ time_t now = time(NULL);
+
+ if (storage == NULL || mail == NULL || from_user == NULL ||
+ to_user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (subject == NULL) {
+ subject = "";
+ }
+ if (body == NULL) {
+ body = "";
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), to_user);
+
+ hres = mail_sql_meta_bump(db, "mail_next", &id);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ rc = sqlite3_prepare_v2(db,
+ "INSERT INTO messages(id,owner,from_user,subject,"
+ "body,received_at,read_flag,deleted_at,folder) "
+ "VALUES(?1,?2,?3,?4,?5,?6,0,0,?7);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)id);
+ sqlite3_bind_text(stmt, 2, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 3, from_user, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 4, subject, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 5, body, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 6, (sqlite3_int64)now);
+ sqlite3_bind_int(stmt, 7, MAIL_FOLDER_INBOX);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+
+ return mail_sql_trim_inbox(db, owner, mail->max_messages);
+}
+
+unsigned hybbx_mail_sql_purge_recycle(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *username)
+{
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ char owner[HYBBX_USER_NAME_MAX];
+ time_t cutoff;
+ int rc;
+
+ if (storage == NULL || mail == NULL || username == NULL) {
+ return 0;
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return 0;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), username);
+ cutoff = time(NULL) - (time_t)mail->recycle_days * 86400;
+
+ rc = sqlite3_prepare_v2(db,
+ "DELETE FROM messages WHERE owner=?1 AND folder=?2 "
+ "AND deleted_at > 0 AND deleted_at < ?3;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return 0;
+ }
+
+ sqlite3_bind_text(stmt, 1, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, MAIL_FOLDER_RECYCLE);
+ sqlite3_bind_int64(stmt, 3, (sqlite3_int64)cutoff);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? (unsigned)sqlite3_changes(db) : 0;
+}
+
+static hybbx_result_t mail_sql_resolve_index(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index,
+ uint64_t *out_id)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count;
+ hybbx_result_t rc;
+
+ mail = hybbx_service_get_mail(service);
+ if (mail == NULL || !mail->enabled) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ rc = hybbx_mail_sql_load_inbox(hybbx_service_get_storage(service),
+ hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (list_index == 0 || list_index > count) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ *out_id = entries[list_index - 1].id;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_sql_read(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index)
+{
+ hybbx_storage_t *storage;
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ uint64_t id;
+ char owner[HYBBX_USER_NAME_MAX];
+ char line[HYBBX_LINE_MAX];
+ int rc;
+ hybbx_result_t hres;
+ const char *from_user;
+ const char *subject;
+ const char *body;
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hres = mail_sql_resolve_index(service, session, list_index, &id);
+ if (hres == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session, "No such message.");
+ return HYBBX_OK;
+ }
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), hybbx_session_username(session));
+
+ rc = sqlite3_prepare_v2(db,
+ "SELECT from_user,subject,body FROM messages "
+ "WHERE id=?1 AND owner=?2 AND folder=?3;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)id);
+ sqlite3_bind_text(stmt, 2, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 3, MAIL_FOLDER_INBOX);
+ rc = sqlite3_step(stmt);
+ if (rc != SQLITE_ROW) {
+ sqlite3_finalize(stmt);
+ hybbx_session_write_line(session, "Cannot open message.");
+ return HYBBX_ERR_IO;
+ }
+
+ from_user = (const char *)sqlite3_column_text(stmt, 0);
+ subject = (const char *)sqlite3_column_text(stmt, 1);
+ body = (const char *)sqlite3_column_text(stmt, 2);
+ if (from_user == NULL) {
+ from_user = "";
+ }
+ if (subject == NULL) {
+ subject = "";
+ }
+ if (body == NULL) {
+ body = "";
+ }
+
+ snprintf(line, sizeof(line), "From: %s", from_user);
+ hybbx_session_write_line(session, line);
+ snprintf(line, sizeof(line), "Subject: %s", subject);
+ hybbx_session_write_line(session, line);
+
+ {
+ const char *p = body;
+ const char *nl;
+
+ while (*p != '\0') {
+ nl = strchr(p, '\n');
+ if (nl == NULL) {
+ hybbx_session_write_line(session, p);
+ break;
+ }
+ if (nl > p) {
+ size_t chunk = (size_t)(nl - p);
+
+ if (chunk >= sizeof(line)) {
+ chunk = sizeof(line) - 1;
+ }
+ memcpy(line, p, chunk);
+ line[chunk] = '\0';
+ hybbx_session_write_line(session, line);
+ } else {
+ hybbx_session_write_line(session, "");
+ }
+ p = nl + 1;
+ }
+ }
+
+ sqlite3_finalize(stmt);
+
+ rc = sqlite3_prepare_v2(db,
+ "UPDATE messages SET read_flag=1 "
+ "WHERE id=?1 AND owner=?2;",
+ -1, &stmt, NULL);
+ if (rc == SQLITE_OK) {
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)id);
+ sqlite3_bind_text(stmt, 2, owner, -1, SQLITE_STATIC);
+ (void)sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_sql_delete_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from,
+ unsigned to)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_storage_t *storage;
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ hybbx_mail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ char owner[HYBBX_USER_NAME_MAX];
+ size_t count;
+ size_t i;
+ unsigned moved = 0;
+ time_t now = time(NULL);
+ int rc;
+ hybbx_result_t hres;
+
+ mail = hybbx_service_get_mail(service);
+ storage = hybbx_service_get_storage(service);
+ if (mail == NULL || !mail->enabled || storage == NULL) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (to == 0) {
+ to = from;
+ }
+
+ (void)hybbx_mail_sql_purge_recycle(storage, mail,
+ hybbx_session_username(session));
+
+ hres = hybbx_mail_sql_load_inbox(storage, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES, &count);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ if (from > count || to > count) {
+ hybbx_session_write_line(session, "No such message.");
+ return HYBBX_OK;
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), hybbx_session_username(session));
+
+ rc = sqlite3_prepare_v2(db,
+ "UPDATE messages SET folder=?4, deleted_at=?3 "
+ "WHERE id=?1 AND owner=?2 AND folder=?5;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ for (i = (size_t)(from - 1); i < (size_t)to; i++) {
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)entries[i].id);
+ sqlite3_bind_text(stmt, 2, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 3, (sqlite3_int64)now);
+ sqlite3_bind_int(stmt, 4, MAIL_FOLDER_RECYCLE);
+ sqlite3_bind_int(stmt, 5, MAIL_FOLDER_INBOX);
+ rc = sqlite3_step(stmt);
+ sqlite3_reset(stmt);
+ if (rc == SQLITE_DONE && sqlite3_changes(db) > 0) {
+ moved++;
+ }
+ }
+
+ sqlite3_finalize(stmt);
+
+ if (moved == 0) {
+ hybbx_session_write_line(session, "Delete failed.");
+ return HYBBX_ERR_IO;
+ }
+
+ if (moved == 1) {
+ hybbx_session_write_line(session,
+ "Message moved to recycle (auto-purge after configured days).");
+ } else {
+ char buf[64];
+
+ snprintf(buf, sizeof(buf),
+ "%u messages moved to recycle (auto-purge after %u days).",
+ moved, mail->recycle_days);
+ hybbx_session_write_line(session, buf);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_mail_sql_recycle_empty(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ const hybbx_mail_config_t *mail;
+ hybbx_storage_t *storage;
+ sqlite3 *db;
+ sqlite3_stmt *stmt;
+ char owner[HYBBX_USER_NAME_MAX];
+ int rc;
+ char buf[64];
+
+ mail = hybbx_service_get_mail(service);
+ storage = hybbx_service_get_storage(service);
+ if (mail == NULL || !mail->enabled || storage == NULL) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ db = hybbx_storage_sql_mail_db(storage);
+ if (db == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ mail_sql_owner_norm(owner, sizeof(owner), hybbx_session_username(session));
+ (void)hybbx_mail_sql_purge_recycle(storage, mail, owner);
+
+ rc = sqlite3_prepare_v2(db,
+ "DELETE FROM messages WHERE owner=?1 AND folder=?2;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ hybbx_session_write_line(session, "Cannot read recycle bin.");
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, owner, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 2, MAIL_FOLDER_RECYCLE);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ if (rc != SQLITE_DONE) {
+ hybbx_session_write_line(session, "Cannot read recycle bin.");
+ return HYBBX_ERR_IO;
+ }
+
+ if (sqlite3_changes(db) == 0) {
+ hybbx_session_write_line(session, "Recycle bin empty.");
+ } else {
+ snprintf(buf, sizeof(buf), "Recycle bin emptied (%d message(s)).",
+ sqlite3_changes(db));
+ hybbx_session_write_line(session, buf);
+ }
+
+ return HYBBX_OK;
+}
+
+#else /* HYBBX_HAVE_SQLITE */
+
+hybbx_result_t hybbx_mail_sql_load_inbox(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_mail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count)
+{
+ (void)storage;
+ (void)username;
+ (void)entries;
+ (void)max_entries;
+ (void)out_count;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_mail_sql_deliver(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *from_user,
+ const char *to_user,
+ const char *subject,
+ const char *body)
+{
+ (void)storage;
+ (void)mail;
+ (void)from_user;
+ (void)to_user;
+ (void)subject;
+ (void)body;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_mail_sql_read(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index)
+{
+ (void)service;
+ (void)session;
+ (void)list_index;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_mail_sql_delete_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from,
+ unsigned to)
+{
+ (void)service;
+ (void)session;
+ (void)from;
+ (void)to;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_mail_sql_recycle_empty(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ (void)service;
+ (void)session;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+unsigned hybbx_mail_sql_purge_recycle(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *username)
+{
+ (void)storage;
+ (void)mail;
+ (void)username;
+ return 0;
+}
+
+#endif /* HYBBX_HAVE_SQLITE */
diff --git a/src/core/mail_sql.h b/src/core/mail_sql.h
new file mode 100644
index 0000000..cddd856
--- /dev/null
+++ b/src/core/mail_sql.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_MAIL_SQL_H
+#define HYBBX_MAIL_SQL_H
+
+#include "hybbx/mail.h"
+#include "hybbx/storage.h"
+#include "hybbx/types.h"
+
+struct hybbx_service;
+struct hybbx_session;
+
+hybbx_result_t hybbx_mail_sql_load_inbox(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_mail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count);
+
+hybbx_result_t hybbx_mail_sql_deliver(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *from_user,
+ const char *to_user,
+ const char *subject,
+ const char *body);
+
+hybbx_result_t hybbx_mail_sql_read(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned list_index);
+
+hybbx_result_t hybbx_mail_sql_delete_range(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned from,
+ unsigned to);
+
+hybbx_result_t hybbx_mail_sql_recycle_empty(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+unsigned hybbx_mail_sql_purge_recycle(hybbx_storage_t *storage,
+ const hybbx_mail_config_t *mail,
+ const char *username);
+
+#endif /* HYBBX_MAIL_SQL_H */
diff --git a/src/core/mains_proxy.c b/src/core/mains_proxy.c
new file mode 100644
index 0000000..f3475fe
--- /dev/null
+++ b/src/core/mains_proxy.c
@@ -0,0 +1,1034 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/mains_proxy.h"
+#include "hybbx/service.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/proxymail.h"
+#include "hybbx/proxychat.h"
+#include "hybbx/log.h"
+
+#include <errno.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+/* Inter-node mesh: hybbx_circuit_link_connect() only — never peer Main TCP. */
+
+#define MAINS_PROXY_RECONNECT_MS 5000u
+#define MAINS_PROXY_CONNECT_ATTEMPTS 30u
+#define MAINS_PROXY_DEFAULT_TTL 8u
+
+typedef struct mains_proxy_peer_runtime {
+ hybbx_mains_proxy_peer_config_t config;
+ int fd;
+ hybbx_circuit_decoder_t dec;
+ unsigned reconnect_ms;
+} mains_proxy_peer_runtime_t;
+
+typedef struct mains_proxy_runtime {
+ hybbx_service_t *service;
+ hybbx_mains_proxy_mesh_t mesh;
+ mains_proxy_peer_runtime_t peers[HYBBX_MAINS_PROXY_MAX_PEERS];
+ unsigned live_links;
+} mains_proxy_runtime_t;
+
+static mains_proxy_runtime_t g_rt;
+
+static const char *mains_proxy_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 (scratch != NULL && scratch_len > 0) {
+ scratch[0] = '\0';
+ }
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *line = cursor;
+ const char *eq;
+ const char *end = cursor;
+
+ /*
+ * Config sections are serialized with ';' between key=value pairs
+ * (hybbx_config_format_section) but the parser used to look for '\n'
+ * only. Accept both separators so a single section can be parsed
+ * regardless of how it was produced.
+ */
+ while (*end != '\0' && *end != ';' && *end != '\n') {
+ end++;
+ }
+
+ if (line[0] == '[') {
+ cursor = (*end == '\0') ? end : end + 1;
+ continue;
+ }
+
+ eq = strchr(line, '=');
+ if (eq == NULL || eq >= end) {
+ cursor = (*end == '\0') ? end : end + 1;
+ continue;
+ }
+
+ if ((size_t)(eq - line) == key_len &&
+ strncmp(line, key, key_len) == 0) {
+ const char *value = eq + 1;
+
+ while (value < end && (*value == ' ' || *value == '\t')) {
+ value++;
+ }
+
+ if (scratch != NULL && scratch_len > 0) {
+ size_t vlen = (size_t)(end - value);
+
+ while (vlen > 0 &&
+ (value[vlen - 1] == ' ' || value[vlen - 1] == '\t' ||
+ value[vlen - 1] == '\r')) {
+ vlen--;
+ }
+
+ if (vlen >= scratch_len) {
+ vlen = scratch_len - 1;
+ }
+ memcpy(scratch, value, vlen);
+ scratch[vlen] = '\0';
+ return scratch;
+ }
+
+ return value;
+ }
+
+ cursor = (*end == '\0') ? end : end + 1;
+ }
+
+ return NULL;
+}
+
+static int str_ieq_local(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 int proxy_payload_get_line(const char *payload, size_t len,
+ const char *key, char *out, size_t out_len)
+{
+ size_t key_len;
+ const char *cursor;
+ const char *end;
+
+ if (payload == NULL || key == NULL || out == NULL || out_len == 0) {
+ return 0;
+ }
+
+ out[0] = '\0';
+ key_len = strlen(key);
+ cursor = payload;
+ end = payload + len;
+
+ while (cursor < end) {
+ const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor));
+ size_t line_len;
+
+ if (line_end == NULL) {
+ line_end = end;
+ }
+
+ line_len = (size_t)(line_end - cursor);
+ if (line_len >= key_len + 1 && memcmp(cursor, key, key_len) == 0 &&
+ cursor[key_len] == '=') {
+ const char *value = cursor + key_len + 1;
+ size_t vlen = line_len - key_len - 1;
+
+ while (vlen > 0 && (*value == ' ' || *value == '\t')) {
+ value++;
+ vlen--;
+ }
+ if (vlen >= out_len) {
+ vlen = out_len - 1;
+ }
+ memcpy(out, value, vlen);
+ out[vlen] = '\0';
+ return 1;
+ }
+
+ if (line_end >= end) {
+ break;
+ }
+ cursor = line_end + 1;
+ }
+
+ return 0;
+}
+
+static const char *proxy_payload_body(const char *payload, size_t len,
+ size_t *body_len)
+{
+ size_t off;
+
+ if (payload == NULL || body_len == NULL) {
+ return NULL;
+ }
+
+ *body_len = 0;
+
+ if (len >= 5 && memcmp(payload, "---\r\n", 5) == 0) {
+ off = 5;
+ } else if (len >= 4 && memcmp(payload, "---\n", 4) == 0) {
+ off = 4;
+ } else {
+ return NULL;
+ }
+
+ while (off < len && (payload[off] == '\r' || payload[off] == '\n')) {
+ off++;
+ }
+
+ *body_len = len - off;
+ return payload + off;
+}
+
+static unsigned proxy_payload_get_ttl(const char *payload, size_t len)
+{
+ char scratch[HYBBX_CONFIG_LINE_MAX];
+ const char *value;
+ unsigned long ttl;
+
+ if (payload == NULL || len == 0 || len >= sizeof(scratch)) {
+ return 0;
+ }
+
+ memcpy(scratch, payload, len);
+ scratch[len] = '\0';
+
+ value = NULL;
+ if (proxy_payload_get_line(scratch, len, "ttl", scratch, sizeof(scratch))) {
+ value = scratch;
+ }
+
+ if (value == NULL || value[0] == '\0') {
+ return 0;
+ }
+
+ ttl = strtoul(value, NULL, 10);
+ if (ttl > 255u) {
+ ttl = 255u;
+ }
+ return (unsigned)ttl;
+}
+
+static size_t proxy_build_mail_payload(char *out, size_t out_cap,
+ const char *from_address,
+ const char *to_address,
+ const char *subject,
+ const char *body,
+ unsigned ttl)
+{
+ int n;
+
+ if (out == NULL || out_cap == 0 || from_address == NULL ||
+ to_address == NULL || subject == NULL || body == NULL) {
+ return 0;
+ }
+
+ n = snprintf(out, out_cap, "from=%s\nto=%s\nsub=%s\nttl=%u\n---\n%s",
+ from_address, to_address, subject, ttl, body);
+ if (n < 0 || (size_t)n >= out_cap) {
+ return 0;
+ }
+
+ return (size_t)n;
+}
+
+static size_t proxy_build_chat_payload(char *out, size_t out_cap,
+ const char *from_address,
+ const char *line,
+ unsigned ttl)
+{
+ int n;
+
+ if (out == NULL || out_cap == 0 || from_address == NULL ||
+ line == NULL) {
+ return 0;
+ }
+
+ n = snprintf(out, out_cap, "from=%s\nttl=%u\n---\n%s", from_address, ttl, line);
+ if (n < 0 || (size_t)n >= out_cap) {
+ return 0;
+ }
+
+ return (size_t)n;
+}
+
+static void proxy_dispatch_payload(hybbx_service_t *service,
+ hybbx_circuit_proto_t proto,
+ const uint8_t *payload, size_t len)
+{
+ char from[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ char to[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ char subj[HYBBX_MAIL_SUBJECT_MAX + 1];
+ const char *body;
+ size_t body_len;
+ char scratch[HYBBX_CIRCUIT_MAX_PAYLOAD + 1];
+
+ if (service == NULL || payload == NULL || len == 0 ||
+ len > HYBBX_CIRCUIT_MAX_PAYLOAD) {
+ return;
+ }
+
+ memcpy(scratch, payload, len);
+ scratch[len] = '\0';
+
+ if (!proxy_payload_get_line(scratch, len, "from", from, sizeof(from))) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_PROXY_MAIL) {
+ if (!proxy_payload_get_line(scratch, len, "to", to, sizeof(to))) {
+ return;
+ }
+ if (!proxy_payload_get_line(scratch, len, "sub", subj, sizeof(subj))) {
+ subj[0] = '\0';
+ }
+ body = proxy_payload_body(scratch, len, &body_len);
+ if (body == NULL) {
+ return;
+ }
+ scratch[0] = '\0';
+ if (body_len > 0) {
+ if (body_len >= sizeof(scratch)) {
+ body_len = sizeof(scratch) - 1;
+ }
+ memcpy(scratch, body, body_len);
+ scratch[body_len] = '\0';
+ }
+ hybbx_proxymail_receive(service, from, to, subj, scratch);
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_PROXY_CHAT) {
+ body = proxy_payload_body(scratch, len, &body_len);
+ if (body == NULL) {
+ return;
+ }
+ scratch[0] = '\0';
+ if (body_len > 0) {
+ if (body_len >= sizeof(scratch)) {
+ body_len = sizeof(scratch) - 1;
+ }
+ memcpy(scratch, body, body_len);
+ scratch[body_len] = '\0';
+ }
+ hybbx_proxychat_receive(service, from, scratch);
+ }
+}
+
+static hybbx_result_t proxy_peer_send(int fd, hybbx_circuit_proto_t proto,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+
+ if (fd < 0 || payload == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode(proto, HYBBX_CIRCUIT_FLAG_NONE,
+ payload, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(fd, frame, frame_len);
+}
+
+static void proxy_peer_disconnect(mains_proxy_peer_runtime_t *peer)
+{
+ if (peer == NULL) {
+ return;
+ }
+
+ if (peer->fd >= 0) {
+ close(peer->fd);
+ peer->fd = -1;
+ }
+
+ if (g_rt.live_links > 0) {
+ g_rt.live_links--;
+ }
+
+ peer->reconnect_ms = MAINS_PROXY_RECONNECT_MS;
+}
+
+static void on_mesh_frame(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ mains_proxy_peer_runtime_t *from_peer = (mains_proxy_peer_runtime_t *)userdata;
+ char relay_buf[HYBBX_CIRCUIT_MAX_PAYLOAD + 1];
+ unsigned ttl;
+ unsigned i;
+
+ (void)flags;
+
+ if (g_rt.service == NULL || payload == NULL || len == 0 ||
+ len > HYBBX_CIRCUIT_MAX_PAYLOAD) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_PROXY_MAIL &&
+ proto != HYBBX_CIRCUIT_PROTO_PROXY_CHAT) {
+ return;
+ }
+
+ /* Always deliver locally first. */
+ proxy_dispatch_payload(g_rt.service, proto, payload, len);
+
+ /* Relay chat to all other live peers with TTL loop prevention. */
+ if (proto != HYBBX_CIRCUIT_PROTO_PROXY_CHAT) {
+ return;
+ }
+
+ ttl = proxy_payload_get_ttl((const char *)payload, len);
+ if (ttl <= 1) {
+ return;
+ }
+ ttl--;
+
+ if (len >= sizeof(relay_buf)) {
+ return;
+ }
+ memcpy(relay_buf, payload, len);
+ relay_buf[len] = '\0';
+
+ {
+ char ttl_str[32];
+ char *ttl_pos;
+ int n;
+
+ n = snprintf(ttl_str, sizeof(ttl_str), "ttl=%u", ttl);
+ if (n < 0 || (size_t)n >= sizeof(ttl_str)) {
+ return;
+ }
+
+ ttl_pos = strstr(relay_buf, "\nttl=");
+ if (ttl_pos == NULL) {
+ /*
+ * Also accept ttl= at the very beginning of the payload
+ * (proxy_build_chat_payload puts it as the second line).
+ */
+ if (strncmp(relay_buf, "ttl=", 4) == 0) {
+ ttl_pos = relay_buf;
+ } else {
+ /* No TTL field; drop rather than inject into a foreign payload. */
+ return;
+ }
+ } else {
+ /* Point at the 't' of "ttl=", not the leading newline. */
+ ttl_pos++;
+ }
+
+ {
+ char *start = ttl_pos + 4;
+ char *end = start;
+ size_t old_len;
+ size_t new_len;
+ size_t tail_len;
+
+ if (start == ttl_pos || *start == '\0' || *start == '\n' ||
+ *start == '\r') {
+ return;
+ }
+
+ /* Find end of current ttl line. */
+ while (*end != '\0' && *end != '\n' && *end != '\r') {
+ end++;
+ }
+
+ old_len = (size_t)(end - start);
+ new_len = strlen(ttl_str + 4);
+ tail_len = strlen(end);
+
+ /*
+ * New value is shorter or equal because TTL only decreases,
+ * so in-place replacement with memmove is safe.
+ */
+ if (new_len != old_len) {
+ memmove(start + new_len, end, tail_len + 1);
+ }
+ memcpy(start, ttl_str + 4, new_len);
+ }
+ }
+
+ for (i = 0; i < g_rt.mesh.peer_count; i++) {
+ mains_proxy_peer_runtime_t *target = &g_rt.peers[i];
+
+ if (target->fd < 0) {
+ continue;
+ }
+ if (target == from_peer) {
+ continue;
+ }
+ (void)proxy_peer_send(target->fd, proto,
+ (const uint8_t *)relay_buf, strlen(relay_buf));
+ }
+}
+
+static int peer_name_matches(const hybbx_mains_proxy_peer_config_t *peer,
+ const char *remote_service)
+{
+ if (peer == NULL || remote_service == NULL || remote_service[0] == '\0') {
+ return 0;
+ }
+
+ if (peer->peer_id[0] != '\0' &&
+ str_ieq_local(peer->peer_id, remote_service)) {
+ return 1;
+ }
+
+ if (peer->link_id[0] != '\0' &&
+ str_ieq_local(peer->link_id, remote_service)) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static mains_proxy_peer_runtime_t *proxy_find_peer_for_service(
+ const char *remote_service)
+{
+ unsigned i;
+
+ for (i = 0; i < g_rt.mesh.peer_count; i++) {
+ if (g_rt.peers[i].fd < 0) {
+ continue;
+ }
+ if (peer_name_matches(&g_rt.peers[i].config, remote_service)) {
+ return &g_rt.peers[i];
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t proxy_peer_connect(mains_proxy_peer_runtime_t *peer)
+{
+ const hybbx_mains_proxy_peer_config_t *cfg;
+ const char *host;
+ unsigned port;
+ const char *link_id;
+ const char *peer_label;
+ unsigned attempt;
+ hybbx_result_t rc;
+
+ if (peer == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ cfg = &peer->config;
+ peer_label = cfg->peer_id[0] != '\0' ? cfg->peer_id : "(unnamed)";
+
+ if (!cfg->enabled) {
+ return HYBBX_OK;
+ }
+
+ if (cfg->wire == HYBBX_MAINS_PROXY_WIRE_AX25) {
+ hybbx_log_warn("[mains_proxy] peer '%s': wire=ax25 not active — use circuit",
+ peer_label);
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (cfg->circuit_host[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (cfg->link_id[0] == '\0' || cfg->link_password[0] == '\0') {
+ hybbx_log_warn("[mains_proxy] peer '%s' missing link_id or link_password",
+ peer_label);
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = cfg->circuit_host;
+ port = cfg->circuit_port;
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ link_id = cfg->link_id;
+
+ for (attempt = 0; attempt < MAINS_PROXY_CONNECT_ATTEMPTS; attempt++) {
+ rc = hybbx_circuit_link_connect(host, port, &peer->fd);
+ if (rc != HYBBX_OK) {
+ usleep(100000);
+ continue;
+ }
+
+ hybbx_circuit_decoder_init(&peer->dec);
+ rc = hybbx_circuit_link_authenticate(peer->fd, cfg->link_password,
+ "proxy", link_id);
+ if (rc != HYBBX_OK) {
+ close(peer->fd);
+ peer->fd = -1;
+ usleep(100000);
+ continue;
+ }
+
+ g_rt.live_links++;
+ peer->reconnect_ms = 0;
+ hybbx_log_info("[mains_proxy] linked to peer %s via HBX %s:%u link_id=%s",
+ peer_label, host, port, link_id);
+ return HYBBX_OK;
+ }
+
+ hybbx_log_warn("[mains_proxy] could not link peer '%s' at %s:%u",
+ peer_label, host, port);
+ return HYBBX_ERR_IO;
+}
+
+hybbx_mains_proxy_wire_t hybbx_mains_proxy_wire_parse(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_MAINS_PROXY_WIRE_CIRCUIT;
+ }
+
+ if (str_ieq_local(value, "circuit") || str_ieq_local(value, "tcp") ||
+ str_ieq_local(value, "tcpip") || str_ieq_local(value, "tcp/ip")) {
+ return HYBBX_MAINS_PROXY_WIRE_CIRCUIT;
+ }
+
+ if (str_ieq_local(value, "ax25") || str_ieq_local(value, "packet_radio")) {
+ return HYBBX_MAINS_PROXY_WIRE_AX25;
+ }
+
+ return HYBBX_MAINS_PROXY_WIRE_CIRCUIT;
+}
+
+const char *hybbx_mains_proxy_wire_name(hybbx_mains_proxy_wire_t wire)
+{
+ switch (wire) {
+ case HYBBX_MAINS_PROXY_WIRE_AX25:
+ return "ax25";
+ case HYBBX_MAINS_PROXY_WIRE_CIRCUIT:
+ default:
+ return "circuit";
+ }
+}
+
+hybbx_mains_proxy_duplex_t hybbx_mains_proxy_duplex_parse(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_MAINS_PROXY_DUPLEX_FULL;
+ }
+
+ if (str_ieq_local(value, "half") || str_ieq_local(value, "half-duplex") ||
+ str_ieq_local(value, "half_duplex")) {
+ return HYBBX_MAINS_PROXY_DUPLEX_HALF;
+ }
+
+ return HYBBX_MAINS_PROXY_DUPLEX_FULL;
+}
+
+const char *hybbx_mains_proxy_duplex_name(hybbx_mains_proxy_duplex_t duplex)
+{
+ return duplex == HYBBX_MAINS_PROXY_DUPLEX_HALF ? "half" : "full";
+}
+
+void hybbx_mains_proxy_peer_defaults(hybbx_mains_proxy_peer_config_t *peer)
+{
+ if (peer == NULL) {
+ return;
+ }
+
+ memset(peer, 0, sizeof(*peer));
+ peer->circuit_port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ peer->wire = HYBBX_MAINS_PROXY_WIRE_CIRCUIT;
+ peer->duplex = HYBBX_MAINS_PROXY_DUPLEX_FULL;
+ peer->use_secondary = 1;
+ peer->enabled = 1;
+}
+
+hybbx_result_t hybbx_mains_proxy_peer_parse(const char *config,
+ hybbx_mains_proxy_peer_config_t *out)
+{
+ char scratch[HYBBX_CONFIG_LINE_MAX];
+ const char *value;
+ int legacy_host = 0;
+ int legacy_port = 0;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_mains_proxy_peer_defaults(out);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ value = mains_proxy_find_kv(config, "enabled", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->enabled = hybbx_parse_bool(value, 1);
+ }
+
+ value = mains_proxy_find_kv(config, "peer_id", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->peer_id, value, sizeof(out->peer_id));
+ }
+
+ value = mains_proxy_find_kv(config, "circuit_host", scratch,
+ sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->circuit_host, value, sizeof(out->circuit_host));
+ }
+
+ value = mains_proxy_find_kv(config, "circuit_port", scratch,
+ sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned long port = strtoul(value, NULL, 10);
+
+ if (port >= 1u && port <= 65535u) {
+ out->circuit_port = (unsigned)port;
+ }
+ }
+
+ value = mains_proxy_find_kv(config, "link_id", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->link_id, value, sizeof(out->link_id));
+ }
+
+ value = mains_proxy_find_kv(config, "link_password", scratch,
+ sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->link_password, value, sizeof(out->link_password));
+ }
+
+ value = mains_proxy_find_kv(config, "host", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->host, value, sizeof(out->host));
+ legacy_host = 1;
+ }
+
+ value = mains_proxy_find_kv(config, "port", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned long port = strtoul(value, NULL, 10);
+
+ if (port >= 1u && port <= 65535u) {
+ out->port = (unsigned)port;
+ }
+ legacy_port = 1;
+ }
+
+ if (legacy_host && out->circuit_host[0] == '\0') {
+ hybbx_strlcpy(out->circuit_host, out->host,
+ sizeof(out->circuit_host));
+ hybbx_log_warn("[mains_proxy] peer '%s': deprecated key host= — use "
+ "circuit_host= (mapped for now)",
+ out->peer_id[0] != '\0' ? out->peer_id : "(unnamed)");
+ } else if (legacy_host) {
+ hybbx_log_warn("[mains_proxy] peer '%s': deprecated key host= ignored "
+ "(circuit_host set)",
+ out->peer_id[0] != '\0' ? out->peer_id : "(unnamed)");
+ }
+
+ if (legacy_port) {
+ hybbx_log_warn("[mains_proxy] peer '%s': deprecated key port= ignored — "
+ "use circuit_port= (HBX hub, default %u)",
+ out->peer_id[0] != '\0' ? out->peer_id : "(unnamed)",
+ (unsigned)HYBBX_CIRCUIT_DEFAULT_PORT);
+ }
+
+ value = mains_proxy_find_kv(config, "wire", scratch, sizeof(scratch));
+ out->wire = hybbx_mains_proxy_wire_parse(value);
+
+ value = mains_proxy_find_kv(config, "duplex", scratch, sizeof(scratch));
+ out->duplex = hybbx_mains_proxy_duplex_parse(value);
+
+ value = mains_proxy_find_kv(config, "use_secondary", scratch,
+ sizeof(scratch));
+ if (value != NULL) {
+ out->use_secondary = hybbx_parse_bool(value, 1);
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_mains_proxy_mesh_init(hybbx_mains_proxy_mesh_t *mesh)
+{
+ if (mesh == NULL) {
+ return;
+ }
+
+ memset(mesh, 0, sizeof(*mesh));
+}
+
+hybbx_result_t hybbx_mains_proxy_mesh_start(hybbx_service_t *service,
+ hybbx_mains_proxy_mesh_t *mesh)
+{
+ unsigned i;
+ unsigned configured = 0;
+ unsigned linked = 0;
+
+ if (mesh == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_mains_proxy_mesh_stop(mesh);
+
+ memset(&g_rt, 0, sizeof(g_rt));
+ g_rt.service = service;
+ g_rt.mesh = *mesh;
+
+ for (i = 0; i < mesh->peer_count; i++) {
+ g_rt.peers[i].config = mesh->peers[i];
+ g_rt.peers[i].fd = -1;
+ hybbx_circuit_decoder_init(&g_rt.peers[i].dec);
+
+ if (!mesh->peers[i].enabled) {
+ continue;
+ }
+
+ configured++;
+
+ if (mesh->peers[i].circuit_host[0] == '\0') {
+ continue;
+ }
+
+ if (proxy_peer_connect(&g_rt.peers[i]) == HYBBX_OK &&
+ g_rt.peers[i].fd >= 0) {
+ linked++;
+ }
+ }
+
+ if (configured == 0) {
+ hybbx_log_warn("[mains_proxy] no active peers configured");
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ mesh->running = 1;
+ g_rt.mesh.running = 1;
+
+ hybbx_log_info("[mains_proxy] mesh started (%u peer(s) configured, %u HBX link(s))",
+ configured, linked);
+
+ return HYBBX_OK;
+}
+
+void hybbx_mains_proxy_mesh_stop(hybbx_mains_proxy_mesh_t *mesh)
+{
+ unsigned i;
+
+ if (mesh != NULL && mesh->running) {
+ hybbx_log_info("[mains_proxy] mesh stopped");
+ mesh->running = 0;
+ }
+
+ for (i = 0; i < HYBBX_MAINS_PROXY_MAX_PEERS; i++) {
+ proxy_peer_disconnect(&g_rt.peers[i]);
+ }
+
+ memset(&g_rt, 0, sizeof(g_rt));
+}
+
+void hybbx_mains_proxy_mesh_tick(hybbx_service_t *service,
+ hybbx_mains_proxy_mesh_t *mesh)
+{
+ unsigned i;
+ static unsigned tick_ms;
+
+ (void)service;
+
+ if (mesh == NULL || !mesh->running) {
+ return;
+ }
+
+ tick_ms += 50;
+ if (tick_ms >= 1000) {
+ tick_ms = 0;
+ }
+
+ for (i = 0; i < mesh->peer_count; i++) {
+ mains_proxy_peer_runtime_t *peer = &g_rt.peers[i];
+ struct pollfd pfd;
+ uint8_t buf[512];
+ size_t read_len;
+ hybbx_result_t rc;
+ int pr;
+
+ if (!peer->config.enabled) {
+ continue;
+ }
+
+ if (peer->fd < 0) {
+ if (peer->config.circuit_host[0] == '\0') {
+ continue;
+ }
+ if (peer->reconnect_ms > 0) {
+ if (tick_ms != 0) {
+ continue;
+ }
+ if (peer->reconnect_ms > 50) {
+ peer->reconnect_ms -= 50;
+ continue;
+ }
+ peer->reconnect_ms = 0;
+ }
+ (void)proxy_peer_connect(peer);
+ continue;
+ }
+
+ pfd.fd = peer->fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ pr = poll(&pfd, 1, 0);
+ if (pr <= 0) {
+ continue;
+ }
+
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ hybbx_log_stats("[mains_proxy] peer '%s' disconnected",
+ peer->config.peer_id[0] != '\0' ? peer->config.peer_id
+ : "(unnamed)");
+ proxy_peer_disconnect(peer);
+ continue;
+ }
+
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ rc = hybbx_circuit_link_read(peer->fd, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK || read_len == 0) {
+ proxy_peer_disconnect(peer);
+ continue;
+ }
+
+ hybbx_circuit_decoder_feed(&peer->dec, buf, read_len,
+ on_mesh_frame, peer);
+ }
+}
+
+int hybbx_mains_proxy_mesh_active(void)
+{
+ return g_rt.mesh.running && g_rt.live_links > 0;
+}
+
+hybbx_result_t hybbx_mains_proxy_send_mail(hybbx_service_t *service,
+ const char *from_address,
+ const char *to_address,
+ const char *subject,
+ const char *body)
+{
+ char user[HYBBX_USER_NAME_MAX];
+ char remote[HYBBX_PROXYMAIL_SERVICE_NAME_MAX];
+ char payload[HYBBX_CIRCUIT_MAX_PAYLOAD + 1];
+ size_t payload_len;
+ mains_proxy_peer_runtime_t *peer;
+
+ (void)service;
+
+ if (!g_rt.mesh.running || g_rt.live_links == 0) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (!hybbx_proxymail_parse_address(to_address, user, sizeof(user),
+ remote, sizeof(remote))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ peer = proxy_find_peer_for_service(remote);
+ if (peer == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ payload_len = proxy_build_mail_payload(payload, sizeof(payload),
+ from_address, to_address,
+ subject != NULL ? subject : "",
+ body != NULL ? body : "",
+ MAINS_PROXY_DEFAULT_TTL);
+ if (payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return proxy_peer_send(peer->fd, HYBBX_CIRCUIT_PROTO_PROXY_MAIL,
+ (const uint8_t *)payload, payload_len);
+}
+
+hybbx_result_t hybbx_mains_proxy_send_chat(hybbx_service_t *service,
+ const char *from_address,
+ const char *line)
+{
+ char payload[HYBBX_CIRCUIT_MAX_PAYLOAD + 1];
+ size_t payload_len;
+ unsigned i;
+ hybbx_result_t rc = HYBBX_ERR_NOT_FOUND;
+ int sent = 0;
+
+ (void)service;
+
+ if (!g_rt.mesh.running || g_rt.live_links == 0 || line == NULL ||
+ line[0] == '\0') {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ payload_len = proxy_build_chat_payload(payload, sizeof(payload),
+ from_address, line,
+ MAINS_PROXY_DEFAULT_TTL);
+ if (payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < g_rt.mesh.peer_count; i++) {
+ if (g_rt.peers[i].fd < 0) {
+ continue;
+ }
+
+ if (proxy_peer_send(g_rt.peers[i].fd, HYBBX_CIRCUIT_PROTO_PROXY_CHAT,
+ (const uint8_t *)payload,
+ payload_len) == HYBBX_OK) {
+ sent = 1;
+ rc = HYBBX_OK;
+ }
+ }
+
+ return sent ? rc : HYBBX_ERR_NOT_FOUND;
+}
+
+void hybbx_mains_proxy_inbound_frame(hybbx_service_t *service,
+ hybbx_circuit_proto_t proto,
+ const uint8_t *payload, size_t len)
+{
+ if (service == NULL || payload == NULL || len == 0) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_PROXY_MAIL &&
+ proto != HYBBX_CIRCUIT_PROTO_PROXY_CHAT) {
+ return;
+ }
+
+ proxy_dispatch_payload(service, proto, payload, len);
+}
diff --git a/src/core/max25.c b/src/core/max25.c
new file mode 100644
index 0000000..2770102
--- /dev/null
+++ b/src/core/max25.c
@@ -0,0 +1,507 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/max25.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if !defined(_WIN32) && !defined(__AMIGA__)
+#include <arpa/inet.h>
+#include <fcntl.h>
+#include <netinet/in.h>
+#include <poll.h>
+#include <sys/socket.h>
+#include <unistd.h>
+#endif
+
+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_prefixed_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ char prefixed[HYBBX_CONFIG_KEY_MAX + 8];
+
+ snprintf(prefixed, sizeof(prefixed), "max25_%s", key);
+ return find_kv(config, prefixed, scratch, scratch_len);
+}
+
+void hybbx_max25_config_defaults(hybbx_max25_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ memset(cfg, 0, sizeof(*cfg));
+ cfg->check = 1;
+ hybbx_strlcpy(cfg->host, HYBBX_MAX25_DEFAULT_HOST, sizeof(cfg->host));
+ cfg->port = HYBBX_MAX25_DEFAULT_PORT;
+ cfg->timeout_ms = HYBBX_MAX25_PROBE_TIMEOUT_MS;
+}
+
+void hybbx_max25_config_apply(hybbx_max25_config_t *cfg,
+ const hybbx_config_t *config)
+{
+ const char *host;
+
+ if (cfg == NULL || config == NULL) {
+ return;
+ }
+
+ hybbx_max25_config_defaults(cfg);
+ cfg->check = hybbx_config_get_bool(config, "max25", "check", 1);
+ host = hybbx_config_get(config, "max25", "host", HYBBX_MAX25_DEFAULT_HOST);
+ hybbx_strlcpy(cfg->host, host, sizeof(cfg->host));
+ cfg->port = hybbx_config_get_uint(config, "max25", "port",
+ HYBBX_MAX25_DEFAULT_PORT, 1u, 65535u);
+ cfg->timeout_ms = hybbx_config_get_uint(config, "max25", "timeout_ms",
+ HYBBX_MAX25_PROBE_TIMEOUT_MS,
+ 100u, 60000u);
+}
+
+void hybbx_max25_config_parse_kv(const char *max25_kv,
+ hybbx_max25_config_t *cfg)
+{
+ char scratch[HYBBX_CONFIG_VALUE_MAX];
+ const char *value;
+ unsigned long n;
+ char *end;
+
+ if (cfg == NULL) {
+ return;
+ }
+
+ hybbx_max25_config_defaults(cfg);
+ if (max25_kv == NULL || max25_kv[0] == '\0') {
+ cfg->check = 0;
+ return;
+ }
+
+ value = find_prefixed_kv(max25_kv, "check", scratch, sizeof(scratch));
+ if (value != NULL) {
+ cfg->check = hybbx_parse_bool(value, 1);
+ } else {
+ cfg->check = 0;
+ }
+
+ value = find_prefixed_kv(max25_kv, "host", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(cfg->host, value, sizeof(cfg->host));
+ }
+
+ value = find_prefixed_kv(max25_kv, "port", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ n = strtoul(value, &end, 10);
+ if (end != value && n > 0 && n <= 65535u) {
+ cfg->port = (unsigned)n;
+ }
+ }
+
+ value = find_prefixed_kv(max25_kv, "timeout_ms", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ n = strtoul(value, &end, 10);
+ if (end != value && n >= 100 && n <= 60000) {
+ cfg->timeout_ms = (unsigned)n;
+ }
+ }
+}
+
+const char *hybbx_max25_config_skip_prefix(const char *config,
+ hybbx_max25_config_t *cfg)
+{
+ char scratch[512];
+ const char *sep;
+ size_t len;
+
+ if (config == NULL || cfg == NULL) {
+ return config;
+ }
+
+ sep = strchr(config, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ len = sep != NULL ? (size_t)(sep - config) : strlen(config);
+ if (len == 0 || len >= sizeof(scratch)) {
+ hybbx_max25_config_defaults(cfg);
+ cfg->check = 0;
+ return config;
+ }
+
+ memcpy(scratch, config, len);
+ scratch[len] = '\0';
+
+ if (find_prefixed_kv(scratch, "check", scratch, sizeof(scratch)) == NULL) {
+ hybbx_max25_config_defaults(cfg);
+ cfg->check = 0;
+ return config;
+ }
+
+ hybbx_max25_config_parse_kv(scratch, cfg);
+ return sep != NULL ? sep + 1 : config + len;
+}
+
+void hybbx_max25_status_clear(hybbx_max25_status_t *status)
+{
+ if (status == NULL) {
+ return;
+ }
+
+ memset(status, 0, sizeof(*status));
+}
+
+#if !defined(_WIN32) && !defined(__AMIGA__)
+
+static void max25_status_assign(char *dst, size_t dst_len, const char *value)
+{
+ if (dst == NULL || dst_len == 0) {
+ return;
+ }
+
+ dst[0] = '\0';
+ if (value == NULL || value[0] == '\0') {
+ return;
+ }
+
+ hybbx_strlcpy(dst, value, dst_len);
+}
+
+static void max25_status_parse_line(const char *line,
+ hybbx_max25_status_t *status)
+{
+ char buf[512];
+ char *save = NULL;
+ char *tok;
+ const char *payload;
+
+ if (status == NULL || line == NULL) {
+ return;
+ }
+
+ if (strncmp(line, "STATUS ", 7) != 0) {
+ return;
+ }
+
+ payload = line + 7;
+ if (strlen(payload) >= sizeof(buf)) {
+ return;
+ }
+
+ hybbx_strlcpy(buf, payload, sizeof(buf));
+
+ for (tok = strtok_r(buf, " ", &save); tok != NULL;
+ tok = strtok_r(NULL, " ", &save)) {
+ char *eq = strchr(tok, '=');
+
+ if (eq == NULL) {
+ continue;
+ }
+
+ *eq = '\0';
+ if (strcmp(tok, "error") == 0) {
+ max25_status_assign(status->error, sizeof(status->error), eq + 1);
+ } else if (strcmp(tok, "voice") == 0) {
+ max25_status_assign(status->voice, sizeof(status->voice), eq + 1);
+ } else if (strcmp(tok, "stack") == 0) {
+ max25_status_assign(status->stack, sizeof(status->stack), eq + 1);
+ } else if (strcmp(tok, "serial") == 0) {
+ max25_status_assign(status->serial, sizeof(status->serial), eq + 1);
+ }
+ }
+}
+
+static int max25_read_line(int fd, char *buf, size_t buflen,
+ unsigned timeout_ms)
+{
+ size_t pos = 0;
+ unsigned elapsed = 0;
+ const unsigned step_ms = 50u;
+
+ if (buf == NULL || buflen == 0) {
+ return -1;
+ }
+
+ buf[0] = '\0';
+
+ while (pos + 1 < buflen && elapsed <= timeout_ms) {
+ struct pollfd pfd;
+ char ch;
+ ssize_t n;
+
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+
+ if (poll(&pfd, 1, (int)step_ms) <= 0) {
+ elapsed += step_ms;
+ continue;
+ }
+
+ n = recv(fd, &ch, 1, 0);
+ if (n <= 0) {
+ return -1;
+ }
+
+ if (ch == '\n') {
+ buf[pos] = '\0';
+ while (pos > 0 && (buf[pos - 1] == '\r' || buf[pos - 1] == ' ')) {
+ buf[--pos] = '\0';
+ }
+ return 0;
+ }
+
+ if (ch != '\r') {
+ buf[pos++] = ch;
+ }
+ }
+
+ return -1;
+}
+
+static int max25_tcp_connect(const char *host, unsigned port,
+ unsigned timeout_ms, int *out_fd)
+{
+ struct sockaddr_in6 addr6;
+ struct sockaddr_in addr4;
+ struct pollfd pfd;
+ const struct sockaddr *addr;
+ socklen_t addr_len;
+ int fd;
+ int flags;
+ int rc;
+ int so_error = 0;
+ socklen_t so_len = sizeof(so_error);
+
+ if (out_fd == NULL) {
+ return -1;
+ }
+
+ *out_fd = -1;
+
+ if (host == NULL || host[0] == '\0' || port == 0u) {
+ return -1;
+ }
+
+ if (strchr(host, ':') != NULL) {
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
+ return -1;
+ }
+ fd = socket(AF_INET6, SOCK_STREAM, 0);
+ addr = (const struct sockaddr *)&addr6;
+ addr_len = sizeof(addr6);
+ } else {
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
+ return -1;
+ }
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ addr = (const struct sockaddr *)&addr4;
+ addr_len = sizeof(addr4);
+ }
+
+ if (fd < 0) {
+ return -1;
+ }
+
+ flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ rc = connect(fd, addr, addr_len);
+ if (rc != 0 && errno != EINPROGRESS) {
+ close(fd);
+ return -1;
+ }
+ if (rc != 0) {
+ pfd.fd = fd;
+ pfd.events = POLLOUT;
+ pfd.revents = 0;
+
+ rc = poll(&pfd, 1, (int)timeout_ms);
+ if (rc <= 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (getsockopt(fd, SOL_SOCKET, SO_ERROR, &so_error, &so_len) != 0 ||
+ so_error != 0) {
+ close(fd);
+ return -1;
+ }
+ }
+
+ if (fcntl(fd, F_SETFL, flags & ~O_NONBLOCK) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ *out_fd = fd;
+ return 0;
+}
+
+static void max25_log_accepted_status(const hybbx_max25_config_t *cfg,
+ const hybbx_max25_status_t *status)
+{
+ const char *error = status->error[0] != '\0' ? status->error : "n/a";
+ const char *voice = status->voice[0] != '\0' ? status->voice : "n/a";
+ const char *stack = status->stack[0] != '\0' ? status->stack : "n/a";
+ const char *serial = status->serial[0] != '\0' ? status->serial : "n/a";
+
+ hybbx_log_info("[max25] max25d at %s:%u — STATUS accepted "
+ "(error=%s voice=%s stack=%s serial=%s; MAX25 reporting only)",
+ cfg->host, cfg->port, error, voice, stack, serial);
+}
+
+static hybbx_result_t max25_handshake(const hybbx_max25_config_t *cfg,
+ hybbx_max25_status_t *status)
+{
+ char line[512];
+ int fd = -1;
+
+ if (max25_tcp_connect(cfg->host, cfg->port, cfg->timeout_ms, &fd) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (max25_read_line(fd, line, sizeof(line), cfg->timeout_ms) != 0 ||
+ strcmp(line, "OK") != 0) {
+ close(fd);
+ return HYBBX_ERR_IO;
+ }
+
+ if (max25_read_line(fd, line, sizeof(line), cfg->timeout_ms) != 0 ||
+ strncmp(line, "STATUS ", 7) != 0) {
+ close(fd);
+ return HYBBX_ERR_IO;
+ }
+
+ max25_status_parse_line(line, status);
+ status->handshake_ok = 1;
+ close(fd);
+ return HYBBX_OK;
+}
+
+#endif /* !WIN32 && !AMIGA */
+
+hybbx_result_t hybbx_max25_probe(const hybbx_max25_config_t *cfg,
+ hybbx_max25_status_t *status_out)
+{
+#if defined(_WIN32) || defined(__AMIGA__)
+ (void)cfg;
+ if (status_out != NULL) {
+ hybbx_max25_status_clear(status_out);
+ }
+ return HYBBX_OK;
+#else
+ hybbx_result_t rc;
+
+ if (cfg == NULL || !cfg->check) {
+ if (status_out != NULL) {
+ hybbx_max25_status_clear(status_out);
+ }
+ return HYBBX_OK;
+ }
+
+ if (cfg->host[0] == '\0' || cfg->port == 0u) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (status_out != NULL) {
+ hybbx_max25_status_clear(status_out);
+ }
+
+ rc = max25_handshake(cfg, status_out);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[max25] max25d unreachable at %s:%u (timeout %u ms)",
+ cfg->host, cfg->port, cfg->timeout_ms);
+ return rc;
+ }
+
+ if (status_out != NULL) {
+ max25_log_accepted_status(cfg, status_out);
+ } else {
+ hybbx_log_info("[max25] max25d reachable at %s:%u — M25/1 handshake OK",
+ cfg->host, cfg->port);
+ }
+
+ return HYBBX_OK;
+#endif
+}
+
+hybbx_result_t hybbx_max25_wait_ready(const hybbx_max25_config_t *cfg)
+{
+#if defined(_WIN32) || defined(__AMIGA__)
+ (void)cfg;
+ return HYBBX_OK;
+#else
+ hybbx_max25_status_t status;
+ unsigned waited_ms = 0;
+ const unsigned step_ms = 2000u;
+
+ if (cfg == NULL || !cfg->check) {
+ return HYBBX_OK;
+ }
+
+ while (hybbx_max25_probe(cfg, &status) != HYBBX_OK) {
+ if (waited_ms >= HYBBX_MAX25_PROBE_WAIT_MS) {
+ hybbx_log_warn("[max25] max25d unreachable at %s:%u after %u s "
+ "— start MAX25 or set [max25] check=no",
+ cfg->host, cfg->port,
+ HYBBX_MAX25_PROBE_WAIT_MS / 1000u);
+ return HYBBX_ERR_IO;
+ }
+ if (waited_ms == 0u) {
+ hybbx_log_info("[max25] waiting for max25d at %s:%u (up to %u s)",
+ cfg->host, cfg->port,
+ HYBBX_MAX25_PROBE_WAIT_MS / 1000u);
+ }
+ usleep((useconds_t)step_ms * 1000u);
+ waited_ms += step_ms;
+ }
+
+ return HYBBX_OK;
+#endif
+}
diff --git a/src/core/messages.c b/src/core/messages.c
new file mode 100644
index 0000000..582d31d
--- /dev/null
+++ b/src/core/messages.c
@@ -0,0 +1,110 @@
+#include "hybbx/messages.h"
+#include "hybbx/session.h"
+#include "hybbx/traffic.h"
+
+#include <stdio.h>
+#include <string.h>
+
+static hybbx_result_t msg_format_prefixed(char *out, size_t out_len,
+ const char *prefix,
+ const char *from_label,
+ const char *body)
+{
+ int n;
+
+ if (out == NULL || out_len == 0 || prefix == NULL || body == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (from_label != NULL && from_label[0] != '\0') {
+ n = snprintf(out, out_len, "%s%s: %s", prefix, from_label, body);
+ } else {
+ n = snprintf(out, out_len, "%s%s", prefix, body);
+ }
+
+ if (n < 0 || (size_t)n >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_msg_format_system(char *out, size_t out_len,
+ const char *body)
+{
+ return msg_format_prefixed(out, out_len, HYBBX_MSG_PREFIX_SYSTEM,
+ NULL, body);
+}
+
+hybbx_result_t hybbx_msg_format_sysop(char *out, size_t out_len,
+ const char *from_label,
+ const char *body)
+{
+ return msg_format_prefixed(out, out_len, HYBBX_MSG_PREFIX_SYSOP,
+ from_label, body);
+}
+
+hybbx_result_t hybbx_msg_format_private(char *out, size_t out_len,
+ const char *from_label,
+ const char *body)
+{
+ return msg_format_prefixed(out, out_len, HYBBX_MSG_PREFIX_PRIVATE,
+ from_label, body);
+}
+
+static hybbx_result_t msg_send_formatted(struct hybbx_session *session,
+ hybbx_result_t (*format_fn)(char *,
+ size_t,
+ const char *,
+ const char *),
+ const char *from_label,
+ const char *body)
+{
+ char line[HYBBX_LINE_MAX];
+ hybbx_result_t rc;
+
+ if (session == NULL || body == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = format_fn(line, sizeof(line), from_label, body);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_msg_send_system(struct hybbx_session *session,
+ const char *body)
+{
+ char line[HYBBX_LINE_MAX];
+ hybbx_result_t rc;
+
+ if (session == NULL || body == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_msg_format_system(line, sizeof(line), body);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return hybbx_session_write_line(session, line);
+}
+
+hybbx_result_t hybbx_msg_send_sysop(struct hybbx_session *session,
+ const char *from_label,
+ const char *body)
+{
+ return msg_send_formatted(session, hybbx_msg_format_sysop,
+ from_label, body);
+}
+
+hybbx_result_t hybbx_msg_send_private(struct hybbx_session *session,
+ const char *from_label,
+ const char *body)
+{
+ return msg_send_formatted(session, hybbx_msg_format_private,
+ from_label, body);
+}
diff --git a/src/core/monitor.c b/src/core/monitor.c
new file mode 100644
index 0000000..0b6d0c5
--- /dev/null
+++ b/src/core/monitor.c
@@ -0,0 +1,497 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/monitor.h"
+#include "hybbx/auth.h"
+#include "hybbx/config.h"
+#include "hybbx/log.h"
+#include "hybbx/security.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+
+#define MONITOR_LINE_MAX 512u
+#define MONITOR_READ_CHUNK 4096u
+
+typedef struct monitor_tail {
+ char path[HYBBX_PATH_MAX];
+ off_t offset;
+ int active;
+ char carry[MONITOR_LINE_MAX];
+ size_t carry_len;
+} monitor_tail_t;
+
+static hybbx_monitor_config_t g_mon_cfg;
+static int g_mon_ready;
+static monitor_tail_t g_tail_hybbx;
+static monitor_tail_t g_tail_security;
+static int g_any_monitor_active;
+
+void hybbx_monitor_config_defaults(hybbx_monitor_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ memset(cfg, 0, sizeof(*cfg));
+ cfg->enabled = 1;
+ cfg->follow_hybbx = 1;
+ cfg->follow_security = 1;
+ cfg->invisible_sysop = 0;
+ cfg->invite_timeout_sec = 20u;
+}
+
+static void monitor_parse_allow(hybbx_monitor_config_t *cfg, const char *raw)
+{
+ char buf[HYBBX_CONFIG_VALUE_MAX];
+ char *p;
+ char *comma;
+
+ cfg->allow_count = 0;
+ if (raw == NULL || raw[0] == '\0') {
+ return;
+ }
+
+ hybbx_strlcpy(buf, raw, sizeof(buf));
+ p = buf;
+ while (p != NULL && *p != '\0' &&
+ cfg->allow_count < HYBBX_MONITOR_ALLOW_MAX) {
+ char *tok = p;
+ comma = strchr(p, ',');
+ if (comma != NULL) {
+ *comma = '\0';
+ p = comma + 1;
+ } else {
+ p = NULL;
+ }
+ while (*tok == ' ' || *tok == '\t') {
+ tok++;
+ }
+ if (*tok == '\0') {
+ continue;
+ }
+ {
+ char *end = tok + strlen(tok);
+ while (end > tok && (end[-1] == ' ' || end[-1] == '\t')) {
+ *--end = '\0';
+ }
+ }
+ if (tok[0] == '\0') {
+ continue;
+ }
+ hybbx_strlcpy(cfg->allow[cfg->allow_count], tok,
+ sizeof(cfg->allow[cfg->allow_count]));
+ cfg->allow_count++;
+ }
+}
+
+void hybbx_monitor_config_apply(const struct hybbx_config *config)
+{
+ const char *allow_raw;
+
+ hybbx_monitor_shutdown();
+ hybbx_monitor_config_defaults(&g_mon_cfg);
+
+ if (config != NULL) {
+ g_mon_cfg.enabled =
+ hybbx_config_get_bool(config, "monitor", "enabled", 1);
+ g_mon_cfg.follow_hybbx =
+ hybbx_config_get_bool(config, "monitor", "follow_hybbx", 1);
+ g_mon_cfg.follow_security =
+ hybbx_config_get_bool(config, "monitor", "follow_security", 1);
+ /* Prefer hyphen key; accept underscore alias. */
+ if (hybbx_config_get(config, "monitor", "invisible-sysop", NULL) !=
+ NULL) {
+ g_mon_cfg.invisible_sysop = hybbx_config_get_bool(
+ config, "monitor", "invisible-sysop", 0);
+ } else {
+ g_mon_cfg.invisible_sysop = hybbx_config_get_bool(
+ config, "monitor", "invisible_sysop", 0);
+ }
+ g_mon_cfg.invite_timeout_sec = hybbx_config_get_uint(
+ config, "monitor", "invite_timeout_sec", 20u, 5u, 300u);
+ allow_raw = hybbx_config_get(config, "monitor", "allow", NULL);
+ monitor_parse_allow(&g_mon_cfg, allow_raw);
+ }
+
+ g_mon_ready = 1;
+ hybbx_log_info("[monitor] enabled=%s follow_hybbx=%s follow_security=%s "
+ "invisible-sysop=%s invite_timeout_sec=%u allow=%u",
+ g_mon_cfg.enabled ? "yes" : "no",
+ g_mon_cfg.follow_hybbx ? "yes" : "no",
+ g_mon_cfg.follow_security ? "yes" : "no",
+ g_mon_cfg.invisible_sysop ? "yes" : "no",
+ g_mon_cfg.invite_timeout_sec,
+ g_mon_cfg.allow_count);
+}
+
+const hybbx_monitor_config_t *hybbx_monitor_config_get(void)
+{
+ return g_mon_ready ? &g_mon_cfg : NULL;
+}
+
+int hybbx_monitor_enabled(void)
+{
+ return g_mon_ready && g_mon_cfg.enabled;
+}
+
+int hybbx_monitor_invisible_sysop(void)
+{
+ return g_mon_ready && g_mon_cfg.invisible_sysop;
+}
+
+int hybbx_monitor_user_allowed(const char *username)
+{
+ unsigned i;
+
+ if (!g_mon_ready || username == NULL || username[0] == '\0') {
+ return 0;
+ }
+
+ for (i = 0; i < g_mon_cfg.allow_count; i++) {
+ if (strcasecmp(g_mon_cfg.allow[i], username) == 0) {
+ return 1;
+ }
+ }
+ return 0;
+}
+
+int hybbx_monitor_session_may_use(const struct hybbx_session *session)
+{
+ const char *user;
+
+ if (!hybbx_monitor_enabled() || session == NULL) {
+ return 0;
+ }
+
+ if (hybbx_user_level_is_sysop(hybbx_session_user_level(session))) {
+ return 1;
+ }
+
+ user = hybbx_session_username(session);
+ return hybbx_monitor_user_allowed(user);
+}
+
+typedef struct mon_count_ctx {
+ unsigned count;
+} mon_count_ctx_t;
+
+static void mon_count_visitor(hybbx_session_t *session, void *userdata)
+{
+ mon_count_ctx_t *ctx = (mon_count_ctx_t *)userdata;
+
+ if (session != NULL && hybbx_monitor_is_active(session)) {
+ ctx->count++;
+ }
+}
+
+static void monitor_refresh_active_flag(hybbx_service_t *service)
+{
+ mon_count_ctx_t ctx;
+
+ ctx.count = 0;
+ if (service != NULL) {
+ hybbx_service_visit_sessions(service, mon_count_visitor, &ctx);
+ }
+ g_any_monitor_active = ctx.count > 0;
+}
+
+static void monitor_tail_seek_end(monitor_tail_t *tail, const char *path)
+{
+ struct stat st;
+
+ tail->path[0] = '\0';
+ tail->offset = 0;
+ tail->active = 0;
+
+ if (path == NULL || path[0] == '\0') {
+ return;
+ }
+
+ hybbx_strlcpy(tail->path, path, sizeof(tail->path));
+ if (stat(tail->path, &st) != 0) {
+ return;
+ }
+
+ tail->offset = st.st_size;
+ tail->active = 1;
+}
+
+static void monitor_reset_tails(void)
+{
+ char path[HYBBX_PATH_MAX];
+
+ memset(&g_tail_hybbx, 0, sizeof(g_tail_hybbx));
+ memset(&g_tail_security, 0, sizeof(g_tail_security));
+
+ if (g_mon_cfg.follow_hybbx &&
+ hybbx_log_current_path(path, sizeof(path)) == HYBBX_OK) {
+ monitor_tail_seek_end(&g_tail_hybbx, path);
+ }
+ if (g_mon_cfg.follow_security &&
+ hybbx_security_log_current_path(path, sizeof(path)) == HYBBX_OK) {
+ monitor_tail_seek_end(&g_tail_security, path);
+ }
+}
+
+hybbx_result_t hybbx_monitor_set_active(hybbx_session_t *session, int on)
+{
+ hybbx_service_t *service;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_monitor_enabled()) {
+ hybbx_session_write_line(session, "Monitor is disabled in config.");
+ return HYBBX_ERR_DENIED;
+ }
+
+ hybbx_session_set_monitor_active(session, on ? 1 : 0);
+ service = hybbx_session_service(session);
+ monitor_refresh_active_flag(service);
+
+ if (on) {
+ /* Fresh follow from current EOF — no backlog. */
+ monitor_reset_tails();
+ hybbx_session_write_line(session, "Monitor on.");
+ if (hybbx_user_level_is_sysop(hybbx_session_user_level(session))) {
+ if (hybbx_monitor_invisible_sysop()) {
+ hybbx_session_write_line(session,
+ "Sysop: hidden from /who (/monitor invisible-sysop=yes).");
+ } else {
+ hybbx_session_write_line(session,
+ "Sysop: hidden from /who and /online while monitor is on.");
+ }
+ }
+ } else {
+ hybbx_session_write_line(session, "Monitor off.");
+ monitor_refresh_active_flag(service);
+ if (!g_any_monitor_active) {
+ memset(&g_tail_hybbx, 0, sizeof(g_tail_hybbx));
+ memset(&g_tail_security, 0, sizeof(g_tail_security));
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+int hybbx_monitor_is_active(const hybbx_session_t *session)
+{
+ return hybbx_session_monitor_active(session);
+}
+
+typedef struct mon_bcast_ctx {
+ const char *line;
+ hybbx_session_t *skip;
+} mon_bcast_ctx_t;
+
+static void mon_bcast_visitor(hybbx_session_t *session, void *userdata)
+{
+ mon_bcast_ctx_t *ctx = (mon_bcast_ctx_t *)userdata;
+ char out[MONITOR_LINE_MAX + 16];
+
+ if (session == NULL || ctx == NULL || ctx->line == NULL) {
+ return;
+ }
+ if (session == ctx->skip) {
+ return;
+ }
+ if (!hybbx_monitor_is_active(session)) {
+ return;
+ }
+
+ snprintf(out, sizeof(out), "[monitor] %s", ctx->line);
+ hybbx_session_write_line(session, out);
+}
+
+void hybbx_monitor_broadcast(hybbx_service_t *service, const char *line)
+{
+ mon_bcast_ctx_t ctx;
+
+ if (service == NULL || line == NULL || line[0] == '\0') {
+ return;
+ }
+
+ ctx.line = line;
+ ctx.skip = NULL;
+ hybbx_service_visit_sessions(service, mon_bcast_visitor, &ctx);
+}
+
+void hybbx_monitor_event(hybbx_service_t *service,
+ const char *username,
+ const char *plugin,
+ const char *detail)
+{
+ char line[MONITOR_LINE_MAX];
+ const char *user = (username != NULL && username[0] != '\0') ? username : "?";
+ const char *plug = (plugin != NULL && plugin[0] != '\0') ? plugin : "?";
+
+ if (service == NULL || !g_any_monitor_active) {
+ /* Still compute flag lazily */
+ monitor_refresh_active_flag(service);
+ if (!g_any_monitor_active) {
+ return;
+ }
+ }
+
+ if (detail != NULL && detail[0] != '\0') {
+ snprintf(line, sizeof(line), "%s@%s %s", user, plug, detail);
+ } else {
+ snprintf(line, sizeof(line), "%s@%s", user, plug);
+ }
+
+ hybbx_monitor_broadcast(service, line);
+}
+
+static void monitor_push_raw_line(hybbx_service_t *service, const char *tag,
+ const char *text)
+{
+ char line[MONITOR_LINE_MAX];
+
+ if (text == NULL) {
+ return;
+ }
+ while (*text == '\r' || *text == '\n') {
+ text++;
+ }
+ if (*text == '\0') {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "%s %s", tag, text);
+ hybbx_monitor_broadcast(service, line);
+}
+
+static void monitor_tail_read(hybbx_service_t *service, monitor_tail_t *tail,
+ const char *tag)
+{
+ FILE *fp;
+ struct stat st;
+ char chunk[MONITOR_READ_CHUNK];
+ size_t n;
+ char *p;
+ char *nl;
+
+ if (tail == NULL || !tail->active || tail->path[0] == '\0') {
+ return;
+ }
+
+ if (stat(tail->path, &st) != 0) {
+ return;
+ }
+
+ if (st.st_size < tail->offset) {
+ /* Truncated / rotated — follow from start of new content. */
+ tail->offset = 0;
+ tail->carry_len = 0;
+ }
+
+ if (st.st_size == tail->offset) {
+ return;
+ }
+
+ fp = fopen(tail->path, "r");
+ if (fp == NULL) {
+ return;
+ }
+
+ if (fseeko(fp, tail->offset, SEEK_SET) != 0) {
+ fclose(fp);
+ return;
+ }
+
+ while ((n = fread(chunk, 1, sizeof(chunk) - 1, fp)) > 0) {
+ chunk[n] = '\0';
+ p = chunk;
+ while (*p != '\0') {
+ nl = strchr(p, '\n');
+ if (nl == NULL) {
+ size_t left = strlen(p);
+ if (tail->carry_len + left >= sizeof(tail->carry)) {
+ tail->carry_len = 0;
+ }
+ memcpy(tail->carry + tail->carry_len, p, left);
+ tail->carry_len += left;
+ tail->carry[tail->carry_len] = '\0';
+ break;
+ }
+ *nl = '\0';
+ if (tail->carry_len > 0) {
+ size_t room = sizeof(tail->carry) - 1u - tail->carry_len;
+ size_t take = strlen(p);
+ if (take > room) {
+ take = room;
+ }
+ memcpy(tail->carry + tail->carry_len, p, take);
+ tail->carry_len += take;
+ tail->carry[tail->carry_len] = '\0';
+ monitor_push_raw_line(service, tag, tail->carry);
+ tail->carry_len = 0;
+ } else {
+ monitor_push_raw_line(service, tag, p);
+ }
+ p = nl + 1;
+ }
+ tail->offset = ftello(fp);
+ }
+
+ fclose(fp);
+
+ /* Refresh path if weekly rotate changed hybbx log name. */
+ if (tail == &g_tail_hybbx) {
+ char path[HYBBX_PATH_MAX];
+ if (hybbx_log_current_path(path, sizeof(path)) == HYBBX_OK &&
+ strcmp(path, tail->path) != 0) {
+ monitor_tail_seek_end(tail, path);
+ }
+ }
+}
+
+void hybbx_monitor_tick(hybbx_service_t *service)
+{
+ if (!g_mon_ready || !g_mon_cfg.enabled || service == NULL) {
+ return;
+ }
+
+ monitor_refresh_active_flag(service);
+ if (!g_any_monitor_active) {
+ return;
+ }
+
+ if (!g_tail_hybbx.active && g_mon_cfg.follow_hybbx) {
+ char path[HYBBX_PATH_MAX];
+ if (hybbx_log_current_path(path, sizeof(path)) == HYBBX_OK) {
+ monitor_tail_seek_end(&g_tail_hybbx, path);
+ }
+ }
+ if (!g_tail_security.active && g_mon_cfg.follow_security) {
+ char path[HYBBX_PATH_MAX];
+ if (hybbx_security_log_current_path(path, sizeof(path)) == HYBBX_OK) {
+ monitor_tail_seek_end(&g_tail_security, path);
+ }
+ }
+
+ if (g_mon_cfg.follow_hybbx) {
+ monitor_tail_read(service, &g_tail_hybbx, "hybbx:");
+ }
+ if (g_mon_cfg.follow_security) {
+ monitor_tail_read(service, &g_tail_security, "security:");
+ }
+}
+
+void hybbx_monitor_shutdown(void)
+{
+ memset(&g_mon_cfg, 0, sizeof(g_mon_cfg));
+ memset(&g_tail_hybbx, 0, sizeof(g_tail_hybbx));
+ memset(&g_tail_security, 0, sizeof(g_tail_security));
+ g_mon_ready = 0;
+ g_any_monitor_active = 0;
+}
diff --git a/src/core/networks.c b/src/core/networks.c
new file mode 100644
index 0000000..c52554d
--- /dev/null
+++ b/src/core/networks.c
@@ -0,0 +1,191 @@
+#include "hybbx/networks.h"
+#include "hybbx/instance.h"
+#include "hybbx/config.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <string.h>
+
+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 int networks_has_key(const hybbx_config_t *config, const char *key)
+{
+ const char *value;
+
+ if (config == NULL || key == NULL) {
+ return 0;
+ }
+
+ value = hybbx_config_get(config, "networks", key, NULL);
+ return value != NULL && value[0] != '\0';
+}
+
+void hybbx_networks_config_defaults(hybbx_networks_config_t *networks)
+{
+ if (networks == NULL) {
+ return;
+ }
+
+ networks->telnet = 1;
+ networks->ax25 = 0;
+ networks->baycom = 0;
+ networks->ardop = 0;
+ networks->crdop = 0;
+ networks->ssh = 0;
+ networks->websocket = 0;
+ networks->circuit = 1;
+ networks->mains_proxy = 0;
+}
+
+void hybbx_networks_config_apply(hybbx_networks_config_t *networks,
+ const hybbx_config_t *config)
+{
+ if (networks == NULL) {
+ return;
+ }
+
+ hybbx_networks_config_defaults(networks);
+
+ if (config == NULL) {
+ return;
+ }
+
+ if (networks_has_key(config, "telnet")) {
+ networks->telnet = hybbx_config_get_bool(config, "networks",
+ "telnet", 1);
+ }
+
+ networks->ax25 = hybbx_config_get_bool(config, "networks", "ax25", 0);
+
+ if (networks_has_key(config, "baycom")) {
+ networks->baycom = hybbx_config_get_bool(config, "networks", "baycom", 0);
+ }
+
+ if (networks_has_key(config, "ardop")) {
+ networks->ardop = hybbx_config_get_bool(config, "networks", "ardop", 0);
+ }
+
+ if (networks_has_key(config, "crdop")) {
+ networks->crdop = hybbx_config_get_bool(config, "networks", "crdop", 0);
+ }
+
+ networks->ssh = hybbx_config_get_bool(config, "networks", "ssh", 0);
+
+ networks->websocket = hybbx_config_get_bool(config, "networks",
+ "websocket", 0);
+
+ if (networks_has_key(config, "circuit")) {
+ networks->circuit = hybbx_config_get_bool(config, "networks",
+ "circuit", 1);
+ } else {
+ networks->circuit = hybbx_config_get_bool(config, "circuit", "enabled",
+ 1);
+ }
+
+ if (networks_has_key(config, "mains_proxy")) {
+ networks->mains_proxy = hybbx_config_get_bool(config, "networks",
+ "mains_proxy", 0);
+ }
+
+ hybbx_log_info("[networks] telnet=%s ssh=%s ax25=%s baycom=%s ardop=%s "
+ "crdop=%s websocket=%s circuit=%s mains_proxy=%s",
+ hybbx_bool_to_string(networks->telnet),
+ hybbx_bool_to_string(networks->ssh),
+ hybbx_bool_to_string(networks->ax25),
+ hybbx_bool_to_string(networks->baycom),
+ hybbx_bool_to_string(networks->ardop),
+ hybbx_bool_to_string(networks->crdop),
+ hybbx_bool_to_string(networks->websocket),
+ hybbx_bool_to_string(networks->circuit),
+ hybbx_bool_to_string(networks->mains_proxy));
+}
+
+int hybbx_networks_is_static_transport(const char *plugin_name)
+{
+ if (plugin_name == NULL || plugin_name[0] == '\0') {
+ return 0;
+ }
+
+ switch (hybbx_instance_role()) {
+ case HYBBX_INSTANCE_MAIN:
+ return str_ieq(plugin_name, "websocket");
+ case HYBBX_INSTANCE_SECONDARY:
+ return str_ieq(plugin_name, "mains_proxy");
+ case HYBBX_INSTANCE_PROXY:
+ default:
+ return 0;
+ }
+}
+
+int hybbx_networks_transport_wanted(const char *plugin_name,
+ const hybbx_networks_config_t *networks)
+{
+ if (plugin_name == NULL || plugin_name[0] == '\0') {
+ return 0;
+ }
+
+ if (!hybbx_instance_plugin_allowed(plugin_name)) {
+ return 0;
+ }
+
+ if (hybbx_networks_is_static_transport(plugin_name)) {
+ return 1;
+ }
+
+ if (networks == NULL) {
+ return 0;
+ }
+
+ if (str_ieq(plugin_name, "telnet")) {
+ return networks->telnet;
+ }
+
+ if (str_ieq(plugin_name, "packet_radio")) {
+ return networks->ax25;
+ }
+
+ if (str_ieq(plugin_name, "baycom")) {
+ return networks->baycom;
+ }
+
+ if (str_ieq(plugin_name, "ardop")) {
+ return networks->ardop;
+ }
+
+ if (str_ieq(plugin_name, "crdop")) {
+ return networks->crdop;
+ }
+
+ if (str_ieq(plugin_name, "ssh")) {
+ return networks->ssh;
+ }
+
+ if (str_ieq(plugin_name, "websocket")) {
+ return networks->websocket;
+ }
+
+ if (str_ieq(plugin_name, "mains_proxy")) {
+ return networks->mains_proxy;
+ }
+
+ return 0;
+}
diff --git a/src/core/password.c b/src/core/password.c
new file mode 100644
index 0000000..1e8854a
--- /dev/null
+++ b/src/core/password.c
@@ -0,0 +1,396 @@
+#include "hybbx/password.h"
+#include "hybbx/crypto.h"
+#include "hybbx/util.h"
+
+#include "crypto_backends.h"
+
+#include <ctype.h>
+#include <stdio.h>
+#include <stdint.h>
+#include <string.h>
+
+#define MD5_HEX_SIZE (32 + 1)
+
+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 int hex_digit(char ch)
+{
+ if (ch >= '0' && ch <= '9') {
+ return ch - '0';
+ }
+ if (ch >= 'a' && ch <= 'f') {
+ return 10 + (ch - 'a');
+ }
+ if (ch >= 'A' && ch <= 'F') {
+ return 10 + (ch - 'A');
+ }
+ return -1;
+}
+
+static int hex_string_eq(const char *a, const char *b, size_t hex_len)
+{
+ size_t i;
+
+ for (i = 0; i < hex_len; i++) {
+ if (tolower((unsigned char)a[i]) != tolower((unsigned char)b[i])) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static int hex_string_valid(const char *s, size_t hex_len)
+{
+ size_t i;
+
+ if (s == NULL) {
+ return 0;
+ }
+
+ for (i = 0; i < hex_len; i++) {
+ if (hex_digit(s[i]) < 0) {
+ return 0;
+ }
+ }
+
+ return s[hex_len] == '\0';
+}
+
+int hybbx_password_is_hashed(const char *stored)
+{
+ if (stored == NULL || stored[0] == '\0') {
+ return 0;
+ }
+
+ if (strncmp(stored, HYBBX_PASSWORD_SHA256_PREFIX, 8) == 0) {
+ return hex_string_valid(stored + 8, 64);
+ }
+
+ if (strncmp(stored, HYBBX_PASSWORD_MD5_PREFIX, 5) == 0) {
+ return hex_string_valid(stored + 5, 32);
+ }
+
+ return 0;
+}
+
+int hybbx_password_is_plain(const char *stored)
+{
+ if (stored == NULL || stored[0] == '\0') {
+ return 0;
+ }
+
+ return !hybbx_password_is_hashed(stored);
+}
+
+hybbx_result_t hybbx_password_hash_sha256(const char *plain,
+ char *out,
+ size_t out_size)
+{
+ char hex[HYBBX_BACKEND_SHA256_HEX_SIZE];
+ size_t need;
+
+ if (plain == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ need = strlen(HYBBX_PASSWORD_SHA256_PREFIX) + 64 + 1;
+ if (out_size < need) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_backend_sha256_hex(plain, strlen(plain), hex);
+ snprintf(out, out_size, "%s%s", HYBBX_PASSWORD_SHA256_PREFIX, hex);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_password_hash(const char *plain, char *out, size_t out_size)
+{
+ return hybbx_password_hash_sha256(plain, out, out_size);
+}
+
+/* Minimal MD5 (RFC 1321) for legacy {md5} verification only. */
+typedef struct md5_ctx {
+ uint32_t state[4];
+ uint32_t count[2];
+ uint8_t buffer[64];
+} md5_ctx_t;
+
+#define MD5_F(x, y, z) (((x) & (y)) | ((~x) & (z)))
+#define MD5_G(x, y, z) (((x) & (z)) | ((y) & (~z)))
+#define MD5_H(x, y, z) ((x) ^ (y) ^ (z))
+#define MD5_I(x, y, z) ((y) ^ ((x) | (~z)))
+#define MD5_ROTATE_LEFT(x, n) (((x) << (n)) | ((x) >> (32 - (n))))
+
+#define MD5_FF(a, b, c, d, x, s, ac) \
+ do { \
+ (a) += MD5_F((b), (c), (d)) + (x) + (uint32_t)(ac); \
+ (a) = MD5_ROTATE_LEFT((a), (s)); \
+ (a) += (b); \
+ } while (0)
+
+#define MD5_GG(a, b, c, d, x, s, ac) \
+ do { \
+ (a) += MD5_G((b), (c), (d)) + (x) + (uint32_t)(ac); \
+ (a) = MD5_ROTATE_LEFT((a), (s)); \
+ (a) += (b); \
+ } while (0)
+
+#define MD5_HH(a, b, c, d, x, s, ac) \
+ do { \
+ (a) += MD5_H((b), (c), (d)) + (x) + (uint32_t)(ac); \
+ (a) = MD5_ROTATE_LEFT((a), (s)); \
+ (a) += (b); \
+ } while (0)
+
+#define MD5_II(a, b, c, d, x, s, ac) \
+ do { \
+ (a) += MD5_I((b), (c), (d)) + (x) + (uint32_t)(ac); \
+ (a) = MD5_ROTATE_LEFT((a), (s)); \
+ (a) += (b); \
+ } while (0)
+
+static void md5_encode(uint8_t *out, const uint32_t *in, size_t len)
+{
+ size_t i;
+ size_t j;
+
+ for (i = 0, j = 0; j < len; i++, j += 4) {
+ out[j] = (uint8_t)(in[i] & 0xff);
+ out[j + 1] = (uint8_t)((in[i] >> 8) & 0xff);
+ out[j + 2] = (uint8_t)((in[i] >> 16) & 0xff);
+ out[j + 3] = (uint8_t)((in[i] >> 24) & 0xff);
+ }
+}
+
+static void md5_transform(uint32_t state[4], const uint8_t block[64])
+{
+ uint32_t a = state[0];
+ uint32_t b = state[1];
+ uint32_t c = state[2];
+ uint32_t d = state[3];
+ uint32_t x[16];
+ size_t i;
+
+ for (i = 0; i < 16; i++) {
+ x[i] = ((uint32_t)block[i * 4]) |
+ ((uint32_t)block[i * 4 + 1] << 8) |
+ ((uint32_t)block[i * 4 + 2] << 16) |
+ ((uint32_t)block[i * 4 + 3] << 24);
+ }
+
+ MD5_FF(a, b, c, d, x[0], 7, 0xd76aa478);
+ MD5_FF(d, a, b, c, x[1], 12, 0xe8c7b756);
+ MD5_FF(c, d, a, b, x[2], 17, 0x242070db);
+ MD5_FF(b, c, d, a, x[3], 22, 0xc1bdceee);
+ MD5_FF(a, b, c, d, x[4], 7, 0xf57c0faf);
+ MD5_FF(d, a, b, c, x[5], 12, 0x4787c62a);
+ MD5_FF(c, d, a, b, x[6], 17, 0xa8304613);
+ MD5_FF(b, c, d, a, x[7], 22, 0xfd469501);
+ MD5_FF(a, b, c, d, x[8], 7, 0x698098d8);
+ MD5_FF(d, a, b, c, x[9], 12, 0x8b44f7af);
+ MD5_FF(c, d, a, b, x[10], 17, 0xffff5bb1);
+ MD5_FF(b, c, d, a, x[11], 22, 0x895cd7be);
+ MD5_FF(a, b, c, d, x[12], 7, 0x6b901122);
+ MD5_FF(d, a, b, c, x[13], 12, 0xfd987193);
+ MD5_FF(c, d, a, b, x[14], 17, 0xa679438e);
+ MD5_FF(b, c, d, a, x[15], 22, 0x49b40821);
+ MD5_GG(a, b, c, d, x[1], 5, 0xf61e2562);
+ MD5_GG(d, a, b, c, x[6], 9, 0xc040b340);
+ MD5_GG(c, d, a, b, x[11], 14, 0x265e5a51);
+ MD5_GG(b, c, d, a, x[0], 20, 0xe9b6c7aa);
+ MD5_GG(a, b, c, d, x[5], 5, 0xd62f105d);
+ MD5_GG(d, a, b, c, x[10], 9, 0x02441453);
+ MD5_GG(c, d, a, b, x[15], 14, 0xd8a1e681);
+ MD5_GG(b, c, d, a, x[4], 20, 0xe7d3fbc8);
+ MD5_GG(a, b, c, d, x[9], 5, 0x21e1cde6);
+ MD5_GG(d, a, b, c, x[14], 9, 0xc33707d6);
+ MD5_GG(c, d, a, b, x[3], 14, 0xf4d50d87);
+ MD5_GG(b, c, d, a, x[8], 20, 0x455a14ed);
+ MD5_GG(a, b, c, d, x[13], 5, 0xa9e3e905);
+ MD5_GG(d, a, b, c, x[2], 9, 0xfcefa3f8);
+ MD5_GG(c, d, a, b, x[7], 14, 0x676f02d9);
+ MD5_GG(b, c, d, a, x[12], 20, 0x8d2a4c8a);
+ MD5_HH(a, b, c, d, x[5], 4, 0xfffa3942);
+ MD5_HH(d, a, b, c, x[8], 11, 0x8771f681);
+ MD5_HH(c, d, a, b, x[11], 16, 0x6d9d6122);
+ MD5_HH(b, c, d, a, x[14], 23, 0xfde5380c);
+ MD5_HH(a, b, c, d, x[1], 4, 0xa4beea44);
+ MD5_HH(d, a, b, c, x[4], 11, 0x4bdecfa9);
+ MD5_HH(c, d, a, b, x[7], 16, 0xf6bb4b60);
+ MD5_HH(b, c, d, a, x[10], 23, 0xbebfbc70);
+ MD5_HH(a, b, c, d, x[13], 4, 0x289b7ec6);
+ MD5_HH(d, a, b, c, x[0], 11, 0xeaa127fa);
+ MD5_HH(c, d, a, b, x[3], 16, 0xd4ef3085);
+ MD5_HH(b, c, d, a, x[6], 23, 0x04881d05);
+ MD5_HH(a, b, c, d, x[9], 4, 0xd9d4d039);
+ MD5_HH(d, a, b, c, x[12], 11, 0xe6db99e5);
+ MD5_HH(c, d, a, b, x[15], 16, 0x1fa27cf8);
+ MD5_HH(b, c, d, a, x[2], 23, 0xc4ac5665);
+ MD5_II(a, b, c, d, x[0], 6, 0xf4292244);
+ MD5_II(d, a, b, c, x[7], 10, 0x432aff97);
+ MD5_II(c, d, a, b, x[14], 15, 0xab9423a7);
+ MD5_II(b, c, d, a, x[5], 21, 0xfc93a039);
+ MD5_II(a, b, c, d, x[12], 6, 0x655b59c3);
+ MD5_II(d, a, b, c, x[3], 10, 0x8f0ccc92);
+ MD5_II(c, d, a, b, x[10], 15, 0xffeff47d);
+ MD5_II(b, c, d, a, x[1], 21, 0x85845dd1);
+ MD5_II(a, b, c, d, x[8], 6, 0x6fa87e4f);
+ MD5_II(d, a, b, c, x[15], 10, 0xfe2ce6e0);
+ MD5_II(c, d, a, b, x[6], 15, 0xa3014314);
+ MD5_II(b, c, d, a, x[13], 21, 0x4e0811a1);
+ MD5_II(a, b, c, d, x[4], 6, 0xf7537e82);
+ MD5_II(d, a, b, c, x[11], 10, 0xbd3af235);
+ MD5_II(c, d, a, b, x[2], 15, 0x2ad7d2bb);
+ MD5_II(b, c, d, a, x[9], 21, 0xeb86d391);
+
+ state[0] += a;
+ state[1] += b;
+ state[2] += c;
+ state[3] += d;
+}
+
+static void md5_init(md5_ctx_t *ctx)
+{
+ ctx->count[0] = 0;
+ ctx->count[1] = 0;
+ ctx->state[0] = 0x67452301;
+ ctx->state[1] = 0xefcdab89;
+ ctx->state[2] = 0x98badcfe;
+ ctx->state[3] = 0x10325476;
+}
+
+static void md5_update(md5_ctx_t *ctx, const uint8_t *data, size_t len)
+{
+ size_t i;
+ size_t index = (size_t)((ctx->count[0] >> 3) & 0x3f);
+
+ if ((ctx->count[0] += (uint32_t)(len << 3)) < (uint32_t)(len << 3)) {
+ ctx->count[1]++;
+ }
+ ctx->count[1] += (uint32_t)(len >> 29);
+
+ size_t part_len = 64 - index;
+
+ if (len >= part_len) {
+ memcpy(&ctx->buffer[index], data, part_len);
+ md5_transform(ctx->state, ctx->buffer);
+ for (i = part_len; i + 63 < len; i += 64) {
+ memcpy(ctx->buffer, &data[i], 64);
+ md5_transform(ctx->state, ctx->buffer);
+ }
+ index = 0;
+ } else {
+ i = 0;
+ }
+
+ memcpy(&ctx->buffer[index], &data[i], len - i);
+}
+
+static void md5_finalize_hex(md5_ctx_t *ctx, char *hex)
+{
+ uint8_t bits[8];
+ uint8_t digest[16];
+ size_t index = (size_t)((ctx->count[0] >> 3) & 0x3f);
+ size_t pad_len = (index < 56) ? (56 - index) : (120 - index);
+ static const uint8_t padding[64] = { 0x80 };
+ size_t i;
+
+ md5_encode(bits, ctx->count, 8);
+ md5_update(ctx, padding, pad_len);
+ md5_update(ctx, bits, 8);
+ md5_encode(digest, ctx->state, 16);
+
+ for (i = 0; i < 16; i++) {
+ snprintf(hex + i * 2, 3, "%02x", digest[i]);
+ }
+}
+
+static void md5_hex(const char *plain, char *hex)
+{
+ md5_ctx_t ctx;
+
+ md5_init(&ctx);
+ md5_update(&ctx, (const uint8_t *)plain, strlen(plain));
+ md5_finalize_hex(&ctx, hex);
+}
+
+int hybbx_password_match(const char *stored, const char *provided)
+{
+ char hex[HYBBX_BACKEND_SHA256_HEX_SIZE];
+ char md5[MD5_HEX_SIZE];
+
+ if (provided == NULL || provided[0] == '\0') {
+ return 0;
+ }
+
+ if (stored == NULL || stored[0] == '\0') {
+ return 0;
+ }
+
+ if (strncmp(stored, HYBBX_PASSWORD_SHA256_PREFIX, 8) == 0) {
+ hybbx_backend_sha256_hex(provided, strlen(provided), hex);
+ return hex_string_eq(stored + 8, hex, 64);
+ }
+
+ if (strncmp(stored, HYBBX_PASSWORD_MD5_PREFIX, 5) == 0) {
+ md5_hex(provided, md5);
+ return hex_string_eq(stored + 5, md5, 32);
+ }
+
+ return str_ieq(stored, provided);
+}
+
+hybbx_result_t hybbx_password_generate_alnum(char *out,
+ size_t out_size,
+ size_t min_len,
+ size_t max_len)
+{
+ static const char alphabet[] = "abcdefghijklmnopqrstuvwxyz0123456789";
+ uint8_t rnd[32];
+ size_t len;
+ size_t span;
+ size_t i;
+ size_t rnd_off = sizeof(rnd);
+
+ if (out == NULL || out_size == 0 || min_len == 0 || min_len > max_len ||
+ max_len >= out_size) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ span = max_len - min_len + 1u;
+ if (hybbx_crypto_random(rnd, sizeof(rnd)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ len = min_len + (size_t)(rnd[0] % (unsigned)span);
+ for (i = 0; i < len; i++) {
+ if (rnd_off >= sizeof(rnd)) {
+ if (hybbx_crypto_random(rnd, sizeof(rnd)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ rnd_off = 0;
+ }
+ out[i] = alphabet[rnd[rnd_off++] % 36u];
+ }
+ out[len] = '\0';
+ return HYBBX_OK;
+}
diff --git a/src/core/privilege.c b/src/core/privilege.c
new file mode 100644
index 0000000..254dbc6
--- /dev/null
+++ b/src/core/privilege.c
@@ -0,0 +1,233 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/privilege.h"
+#include "hybbx/config.h"
+#include "hybbx/log.h"
+#include "hybbx/types.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#if !defined(_WIN32)
+#include <errno.h>
+#include <grp.h>
+#include <pwd.h>
+#include <sys/types.h>
+#include <unistd.h>
+#endif
+
+#if !defined(_WIN32)
+
+static int parse_optional_uid(const char *raw, uid_t *out)
+{
+ char *end;
+ unsigned long value;
+
+ if (raw == NULL || raw[0] == '\0') {
+ return 0;
+ }
+ errno = 0;
+ value = strtoul(raw, &end, 10);
+ if (end == raw || *end != '\0' || errno != 0) {
+ return -1;
+ }
+ *out = (uid_t)value;
+ return 1;
+}
+
+static int parse_optional_gid(const char *raw, gid_t *out)
+{
+ char *end;
+ unsigned long value;
+
+ if (raw == NULL || raw[0] == '\0') {
+ return 0;
+ }
+ errno = 0;
+ value = strtoul(raw, &end, 10);
+ if (end == raw || *end != '\0' || errno != 0) {
+ return -1;
+ }
+ *out = (gid_t)value;
+ return 1;
+}
+
+static hybbx_result_t resolve_target(
+ const hybbx_config_t *config,
+ uid_t *uid_out,
+ gid_t *gid_out,
+ char name_buf[64],
+ size_t name_buf_len)
+{
+ const char *user_name;
+ const char *group_name;
+ const char *uid_raw;
+ const char *gid_raw;
+ struct passwd *pw = NULL;
+ struct group *gr = NULL;
+ uid_t uid_override = 0;
+ gid_t gid_override = 0;
+ int have_uid = 0;
+ int have_gid = 0;
+
+ user_name = hybbx_config_get(config, "service", "user", NULL);
+ group_name = hybbx_config_get(config, "service", "group", NULL);
+ uid_raw = hybbx_config_get(config, "service", "uid", NULL);
+ gid_raw = hybbx_config_get(config, "service", "gid", NULL);
+
+ have_uid = parse_optional_uid(uid_raw, &uid_override);
+ if (have_uid < 0) {
+ hybbx_log_warn("[service] invalid uid=%s", uid_raw);
+ return HYBBX_ERR_INVALID;
+ }
+ have_gid = parse_optional_gid(gid_raw, &gid_override);
+ if (have_gid < 0) {
+ hybbx_log_warn("[service] invalid gid=%s", gid_raw);
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (user_name != NULL && user_name[0] != '\0') {
+ pw = getpwnam(user_name);
+ if (pw == NULL) {
+ hybbx_log_warn("[service] unknown user=%s", user_name);
+ return HYBBX_ERR_INVALID;
+ }
+ *uid_out = pw->pw_uid;
+ *gid_out = pw->pw_gid;
+ if (name_buf_len > 0) {
+ snprintf(name_buf, name_buf_len, "%s", pw->pw_name);
+ }
+ } else if (have_uid) {
+ pw = getpwuid(uid_override);
+ if (pw == NULL) {
+ hybbx_log_warn("[service] unknown uid=%s", uid_raw);
+ return HYBBX_ERR_INVALID;
+ }
+ *uid_out = pw->pw_uid;
+ *gid_out = pw->pw_gid;
+ if (name_buf_len > 0) {
+ snprintf(name_buf, name_buf_len, "%s", pw->pw_name);
+ }
+ } else {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (have_uid) {
+ *uid_out = uid_override;
+ }
+ if (group_name != NULL && group_name[0] != '\0') {
+ gr = getgrnam(group_name);
+ if (gr == NULL) {
+ hybbx_log_warn("[service] unknown group=%s", group_name);
+ return HYBBX_ERR_INVALID;
+ }
+ *gid_out = gr->gr_gid;
+ } else if (have_gid) {
+ if (getgrgid(gid_override) == NULL) {
+ hybbx_log_warn("[service] unknown gid=%s", gid_raw);
+ return HYBBX_ERR_INVALID;
+ }
+ *gid_out = gid_override;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t drop_to(uid_t uid, gid_t gid, const char *name)
+{
+ if (getuid() != 0) {
+ hybbx_log_warn("[service] privilege drop requires root start (euid=0)");
+ return HYBBX_ERR_DENIED;
+ }
+
+ if (name != NULL && name[0] != '\0') {
+ if (initgroups(name, gid) != 0) {
+ hybbx_log_warn("[service] initgroups(%s) failed: %s",
+ name, strerror(errno));
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (setgid(gid) != 0) {
+ hybbx_log_warn("[service] setgid(%u) failed: %s",
+ (unsigned)gid, strerror(errno));
+ return HYBBX_ERR_IO;
+ }
+
+ if (setuid(uid) != 0) {
+ hybbx_log_warn("[service] setuid(%u) failed: %s",
+ (unsigned)uid, strerror(errno));
+ return HYBBX_ERR_IO;
+ }
+
+ if (setuid(0) == 0 || seteuid(0) == 0) {
+ hybbx_log_warn("[service] privilege drop incomplete — still root");
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_info("[service] dropped privileges to uid=%u gid=%u (%s)",
+ (unsigned)uid, (unsigned)gid,
+ name != NULL && name[0] != '\0' ? name : "?");
+ return HYBBX_OK;
+}
+
+#endif /* !_WIN32 */
+
+hybbx_result_t hybbx_privilege_apply_from_config(const hybbx_config_t *config)
+{
+#if defined(_WIN32)
+ (void)config;
+ return HYBBX_OK;
+#else
+ const char *user_name;
+ uid_t target_uid = 0;
+ gid_t target_gid = 0;
+ char resolved_name[64];
+ hybbx_result_t rc;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ resolved_name[0] = '\0';
+ user_name = hybbx_config_get(config, "service", "user", NULL);
+
+ if (geteuid() == 0) {
+ if ((user_name == NULL || user_name[0] == '\0') &&
+ hybbx_config_get(config, "service", "uid", NULL) == NULL) {
+ hybbx_log_warn(
+ "[service] refusing to run as root — set [service] user= "
+ "(and optional group=)");
+ return HYBBX_ERR_DENIED;
+ }
+
+ rc = resolve_target(config, &target_uid, &target_gid,
+ resolved_name, sizeof(resolved_name));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return drop_to(target_uid, target_gid,
+ resolved_name[0] != '\0' ? resolved_name : user_name);
+ }
+
+ if (user_name != NULL && user_name[0] != '\0') {
+ struct passwd *pw = getpwnam(user_name);
+
+ if (pw == NULL) {
+ hybbx_log_warn("[service] unknown configured user=%s", user_name);
+ return HYBBX_OK;
+ }
+ if ((uid_t)geteuid() != pw->pw_uid) {
+ hybbx_log_warn(
+ "[service] running as uid=%u but [service] user=%s (uid=%u)",
+ (unsigned)geteuid(), user_name, (unsigned)pw->pw_uid);
+ }
+ }
+
+ return HYBBX_OK;
+#endif
+}
diff --git a/src/core/proxychat.c b/src/core/proxychat.c
new file mode 100644
index 0000000..d26a178
--- /dev/null
+++ b/src/core/proxychat.c
@@ -0,0 +1,134 @@
+#include "hybbx/proxychat.h"
+#include "hybbx/mains_proxy.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+
+#include <stdio.h>
+#include <string.h>
+
+typedef struct proxychat_fanout_ctx {
+ hybbx_session_t *from;
+ const char *line;
+ const char *from_address;
+} proxychat_fanout_ctx_t;
+
+static void proxychat_local_visitor(hybbx_session_t *session, void *userdata)
+{
+ proxychat_fanout_ctx_t *ctx = (proxychat_fanout_ctx_t *)userdata;
+ char line[HYBBX_PROXYMAIL_ADDRESS_MAX + HYBBX_LINE_MAX + 8];
+
+ if (session == NULL || ctx == NULL || ctx->line == NULL) {
+ return;
+ }
+
+ if (session == ctx->from) {
+ return;
+ }
+
+ if (hybbx_session_area(session) != HYBBX_AREA_PROXYCHAT) {
+ return;
+ }
+
+ if (hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "<%s> %s",
+ ctx->from_address != NULL ? ctx->from_address : "?",
+ ctx->line);
+ hybbx_session_write_line(session, line);
+}
+
+static void proxychat_show_local(hybbx_session_t *session,
+ const char *from_address,
+ const char *line)
+{
+ char out[HYBBX_PROXYMAIL_ADDRESS_MAX + HYBBX_LINE_MAX + 8];
+
+ snprintf(out, sizeof(out), "<%s> %s",
+ from_address != NULL ? from_address : "?", line);
+ hybbx_session_write_line(session, out);
+}
+
+hybbx_result_t hybbx_proxychat_post(hybbx_service_t *service,
+ hybbx_session_t *session,
+ const char *line)
+{
+ char from_address[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ proxychat_fanout_ctx_t ctx;
+ hybbx_result_t rc;
+
+ if (session == NULL || line == NULL || line[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (service == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ snprintf(from_address, sizeof(from_address), "%s@%s",
+ hybbx_session_display_name(session),
+ hybbx_service_get_name(service));
+
+ proxychat_show_local(session, from_address, line);
+
+ if (!hybbx_mains_proxy_mesh_active()) {
+ hybbx_session_write_line(session,
+ "Mains proxy is not running — message shown locally only.");
+ return HYBBX_OK;
+ }
+
+ ctx.from = session;
+ ctx.line = line;
+ ctx.from_address = from_address;
+ hybbx_service_visit_sessions(service, proxychat_local_visitor, &ctx);
+
+ rc = hybbx_mains_proxy_send_chat(service, from_address, line);
+ if (rc == HYBBX_ERR_UNSUPPORTED) {
+ hybbx_session_write_line(session,
+ "No outbound peer links — message shown locally only.");
+ return HYBBX_OK;
+ }
+ if (rc == HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(session,
+ "No peer links available to relay your message.");
+ return HYBBX_OK;
+ }
+
+ return rc;
+}
+
+void hybbx_proxychat_receive(hybbx_service_t *service,
+ const char *from_address,
+ const char *line)
+{
+ proxychat_fanout_ctx_t ctx;
+
+ if (service == NULL || line == NULL || line[0] == '\0') {
+ return;
+ }
+
+ ctx.from = NULL;
+ ctx.line = line;
+ ctx.from_address = from_address;
+ hybbx_service_visit_sessions(service, proxychat_local_visitor, &ctx);
+}
+
+void hybbx_proxychat_show_banner(hybbx_session_t *session)
+{
+ if (session == NULL) {
+ return;
+ }
+
+ hybbx_session_write_line(session,
+ "Proxychat — talk with users on linked mains.");
+ if (hybbx_mains_proxy_mesh_active()) {
+ hybbx_session_write_line(session,
+ "Type a line to send; /leave or /main to exit.");
+ } else {
+ hybbx_session_write_line(session,
+ "No peer links active — check mains_proxy configuration.");
+ hybbx_session_write_line(session,
+ "Type a line to try; /leave or /main to exit.");
+ }
+}
diff --git a/src/core/proxymail.c b/src/core/proxymail.c
new file mode 100644
index 0000000..3981dde
--- /dev/null
+++ b/src/core/proxymail.c
@@ -0,0 +1,676 @@
+#include "hybbx/proxymail.h"
+#include "hybbx/mains_proxy.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/storage.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+
+#include <dirent.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+
+#define HYBBX_PROXYMAIL_DIR_NAME "proxymail"
+#define HYBBX_PROXYMAIL_INBOX_NAME "inbox"
+#define HYBBX_PROXYMAIL_RECYCLE_NAME "recycle"
+#define HYBBX_PROXYMAIL_NEXT_FILE "proxymail.next"
+
+typedef struct proxymail_entry {
+ uint64_t id;
+ char from[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ char subject[HYBBX_MAIL_SUBJECT_MAX + 1];
+ time_t received_at;
+ int read;
+} proxymail_entry_t;
+
+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 int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ len = strlen(path);
+ if (len >= sizeof(buf)) {
+ return -1;
+ }
+
+ memcpy(buf, path, len + 1);
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] == '/') {
+ buf[i] = '\0';
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static hybbx_result_t proxymail_root_path(hybbx_service_t *service,
+ char *out, size_t out_len)
+{
+ hybbx_storage_t *storage;
+ const char *root;
+
+ storage = hybbx_service_get_storage(service);
+ root = hybbx_storage_root_path(storage);
+ if (root == NULL || root[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, root, HYBBX_PROXYMAIL_DIR_NAME);
+}
+
+static hybbx_result_t proxymail_user_inbox_path(hybbx_service_t *service,
+ const char *username,
+ char *out, size_t out_len)
+{
+ char root[HYBBX_PATH_MAX];
+ char user_dir[HYBBX_PATH_MAX];
+ char user_norm[HYBBX_USER_NAME_MAX];
+
+ if (username == NULL || username[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (proxymail_root_path(service, root, sizeof(root)) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(user_norm, username, sizeof(user_norm));
+ hybbx_username_normalize(user_norm);
+
+ if (hybbx_path_join(user_dir, sizeof(user_dir), root, user_norm) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, user_dir, HYBBX_PROXYMAIL_INBOX_NAME);
+}
+
+static int parse_msg_filename(const char *name, uint64_t *id)
+{
+ char *end;
+
+ if (name == NULL || id == NULL) {
+ return 0;
+ }
+
+ if (strlen(name) < 5 || strcmp(name + strlen(name) - 4, ".msg") != 0) {
+ return 0;
+ }
+
+ *id = (uint64_t)strtoull(name, &end, 10);
+ return end != name && strcmp(end, ".msg") == 0;
+}
+
+static int proxymail_entry_cmp(const void *a, const void *b)
+{
+ const proxymail_entry_t *ea = (const proxymail_entry_t *)a;
+ const proxymail_entry_t *eb = (const proxymail_entry_t *)b;
+
+ if (ea->id < eb->id) {
+ return 1;
+ }
+ if (ea->id > eb->id) {
+ return -1;
+ }
+ return 0;
+}
+
+static hybbx_result_t proxymail_load_inbox(hybbx_service_t *service,
+ const char *username,
+ proxymail_entry_t *entries,
+ size_t max_entries,
+ size_t *out_count)
+{
+ char inbox_path[HYBBX_PATH_MAX];
+ DIR *dir;
+ struct dirent *ent;
+ size_t count = 0;
+
+ if (proxymail_user_inbox_path(service, username, inbox_path,
+ sizeof(inbox_path)) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out_count = 0;
+
+ dir = opendir(inbox_path);
+ if (dir == NULL) {
+ return errno == ENOENT ? HYBBX_OK : HYBBX_ERR_IO;
+ }
+
+ while ((ent = readdir(dir)) != NULL) {
+ uint64_t id;
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ proxymail_entry_t entry;
+
+ if (!parse_msg_filename(ent->d_name, &id)) {
+ continue;
+ }
+
+ if (count >= max_entries) {
+ break;
+ }
+
+ memset(&entry, 0, sizeof(entry));
+ entry.id = id;
+
+ if (hybbx_path_join(path, sizeof(path), inbox_path, ent->d_name) !=
+ HYBBX_OK) {
+ continue;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ continue;
+ }
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ char *eq = strchr(line, '=');
+ char *key;
+ char *value;
+
+ if (line[0] == '-' && line[1] == '-' && line[2] == '-') {
+ break;
+ }
+
+ if (eq == NULL) {
+ continue;
+ }
+
+ *eq = '\0';
+ key = line;
+ value = eq + 1;
+
+ while (*value == ' ' || *value == '\t') {
+ value++;
+ }
+
+ {
+ size_t vlen = strlen(value);
+
+ while (vlen > 0 && (value[vlen - 1] == '\n' ||
+ value[vlen - 1] == '\r')) {
+ value[--vlen] = '\0';
+ }
+ }
+
+ if (str_ieq(key, "from")) {
+ hybbx_strlcpy(entry.from, value, sizeof(entry.from));
+ } else if (str_ieq(key, "subject")) {
+ hybbx_strlcpy(entry.subject, value, sizeof(entry.subject));
+ } else if (str_ieq(key, "time")) {
+ entry.received_at = (time_t)strtol(value, NULL, 10);
+ } else if (str_ieq(key, "read")) {
+ entry.read = hybbx_bool_is_true(value);
+ }
+ }
+
+ fclose(fp);
+ entries[count++] = entry;
+ }
+
+ closedir(dir);
+ qsort(entries, count, sizeof(entries[0]), proxymail_entry_cmp);
+ *out_count = count;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t proxymail_next_id(hybbx_service_t *service,
+ uint64_t *out_id)
+{
+ char root[HYBBX_PATH_MAX];
+ char counter_path[HYBBX_PATH_MAX];
+ FILE *fp;
+ unsigned long long n = 0;
+
+ if (proxymail_root_path(service, root, sizeof(root)) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (mkdir_p(root) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(counter_path, sizeof(counter_path), root,
+ HYBBX_PROXYMAIL_NEXT_FILE) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fp = fopen(counter_path, "r");
+ if (fp != NULL) {
+ if (fscanf(fp, "%llu", &n) != 1) {
+ n = 0;
+ }
+ fclose(fp);
+ }
+
+ n++;
+ *out_id = (uint64_t)n;
+
+ fp = fopen(counter_path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "%llu\n", n);
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t proxymail_store_message(hybbx_service_t *service,
+ const char *username,
+ const char *from_address,
+ const char *subject,
+ const char *body)
+{
+ char inbox_path[HYBBX_PATH_MAX];
+ char msg_path[HYBBX_PATH_MAX];
+ char msg_name[32];
+ uint64_t id;
+ FILE *fp;
+ hybbx_result_t rc;
+
+ rc = proxymail_next_id(service, &id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (proxymail_user_inbox_path(service, username, inbox_path,
+ sizeof(inbox_path)) != HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (mkdir_p(inbox_path) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ snprintf(msg_name, sizeof(msg_name), "%06llu.msg",
+ (unsigned long long)id);
+ if (hybbx_path_join(msg_path, sizeof(msg_path), inbox_path, msg_name) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fp = fopen(msg_path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "from=%s\n", from_address);
+ fprintf(fp, "subject=%s\n", subject != NULL ? subject : "");
+ fprintf(fp, "time=%ld\n", (long)time(NULL));
+ fprintf(fp, "read=no\n");
+ fprintf(fp, "---\n%s\n", body != NULL ? body : "");
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+int hybbx_proxymail_parse_address(const char *address,
+ char *user, size_t user_len,
+ char *remote_service, size_t remote_len)
+{
+ const char *at;
+ size_t ulen;
+ size_t slen;
+
+ if (user != NULL && user_len > 0) {
+ user[0] = '\0';
+ }
+ if (remote_service != NULL && remote_len > 0) {
+ remote_service[0] = '\0';
+ }
+
+ if (address == NULL || address[0] == '\0') {
+ return 0;
+ }
+
+ at = strchr(address, '@');
+ if (at == NULL || at == address || at[1] == '\0') {
+ return 0;
+ }
+
+ ulen = (size_t)(at - address);
+ slen = strlen(at + 1);
+ if (ulen == 0 || slen == 0 || ulen >= HYBBX_USER_NAME_MAX ||
+ slen >= HYBBX_PROXYMAIL_SERVICE_NAME_MAX) {
+ return 0;
+ }
+
+ if (user != NULL && user_len > 0) {
+ memcpy(user, address, ulen);
+ user[ulen] = '\0';
+ hybbx_username_normalize(user);
+ if (user[0] == '\0') {
+ return 0;
+ }
+ }
+
+ if (remote_service != NULL && remote_len > 0) {
+ hybbx_strlcpy(remote_service, at + 1, remote_len);
+ }
+
+ return 1;
+}
+
+static void proxymail_list_print(hybbx_session_t *session,
+ const proxymail_entry_t *entries,
+ size_t count,
+ unsigned from,
+ unsigned to)
+{
+ size_t i;
+ size_t start;
+ size_t end;
+ char line[HYBBX_MAIL_SUBJECT_MAX + 96];
+ char header[48];
+
+ if (to == 0 || to > count) {
+ to = (unsigned)count;
+ }
+
+ if (count == 0) {
+ hybbx_session_write_line(session, "Inbox empty.");
+ hybbx_session_write_line(session,
+ "Try: /proxymail send user@mainname Your subject");
+ return;
+ }
+
+ if (from == 0 || from > count || from > to) {
+ hybbx_session_write_line(session, "No messages in that range.");
+ return;
+ }
+
+ start = (size_t)(from - 1);
+ end = (size_t)to;
+
+ if (from == 1 && to == (unsigned)count) {
+ hybbx_session_write_line(session, "Proxymail inbox (newest first):");
+ } else {
+ snprintf(header, sizeof(header), "Proxymail %u-%u (newest first):",
+ from, to);
+ hybbx_session_write_line(session, header);
+ }
+
+ for (i = start; i < end; i++) {
+ snprintf(line, sizeof(line), " %zu %s%s %s",
+ i + 1,
+ entries[i].read ? " " : "* ",
+ entries[i].from,
+ entries[i].subject[0] != '\0' ? entries[i].subject
+ : "(no subject)");
+ hybbx_session_write_line(session, line);
+ }
+
+ hybbx_session_write_line(session,
+ " /proxymail read <n> delete <n|from-to> list <from-to>");
+}
+
+void hybbx_proxymail_list_inbox(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ proxymail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count = 0;
+
+ if (!hybbx_mains_proxy_mesh_active()) {
+ hybbx_session_write_line(session,
+ "Proxymail — send mail to user@another-main.");
+ hybbx_session_write_line(session,
+ "No peer links active — check mains_proxy configuration.");
+ }
+
+ if (proxymail_load_inbox(service, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES,
+ &count) != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not read proxymail inbox.");
+ return;
+ }
+
+ proxymail_list_print(session, entries, count, 1, (unsigned)count);
+}
+
+void hybbx_proxymail_list_inbox_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from, unsigned to)
+{
+ proxymail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count = 0;
+
+ if (proxymail_load_inbox(service, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES,
+ &count) != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not read proxymail inbox.");
+ return;
+ }
+
+ proxymail_list_print(session, entries, count, from, to);
+}
+
+hybbx_result_t hybbx_proxymail_read(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned list_index)
+{
+ proxymail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count = 0;
+ const proxymail_entry_t *entry;
+ char inbox_path[HYBBX_PATH_MAX];
+ char msg_path[HYBBX_PATH_MAX];
+ char msg_name[32];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ int in_body = 0;
+
+ if (list_index == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (proxymail_load_inbox(service, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES,
+ &count) != HYBBX_OK) {
+ hybbx_session_write_line(session, "Could not read proxymail inbox.");
+ return HYBBX_ERR_IO;
+ }
+
+ if ((size_t)list_index > count) {
+ hybbx_session_write_line(session, "No message at that index.");
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ entry = &entries[list_index - 1];
+
+ if (proxymail_user_inbox_path(service, hybbx_session_username(session),
+ inbox_path, sizeof(inbox_path)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ snprintf(msg_name, sizeof(msg_name), "%06llu.msg",
+ (unsigned long long)entry->id);
+ if (hybbx_path_join(msg_path, sizeof(msg_path), inbox_path, msg_name) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ fp = fopen(msg_path, "r");
+ if (fp == NULL) {
+ hybbx_session_write_line(session, "Could not open message.");
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_session_write_line(session, entry->subject[0] != '\0'
+ ? entry->subject : "(no subject)");
+ hybbx_session_write_line(session, entry->from);
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ if (!in_body) {
+ if (line[0] == '-' && line[1] == '-' && line[2] == '-') {
+ in_body = 1;
+ }
+ continue;
+ }
+
+ while (line[0] != '\0' &&
+ (line[strlen(line) - 1] == '\n' ||
+ line[strlen(line) - 1] == '\r')) {
+ line[strlen(line) - 1] = '\0';
+ }
+
+ hybbx_session_write_line(session, line);
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_proxymail_delete_range(hybbx_service_t *service,
+ hybbx_session_t *session,
+ unsigned from, unsigned to)
+{
+ proxymail_entry_t entries[HYBBX_MAIL_MAX_MESSAGES];
+ size_t count = 0;
+ char inbox_path[HYBBX_PATH_MAX];
+ size_t i;
+ unsigned removed = 0;
+
+ if (proxymail_load_inbox(service, hybbx_session_username(session),
+ entries, HYBBX_MAIL_MAX_MESSAGES,
+ &count) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (to == 0 || to > count) {
+ to = (unsigned)count;
+ }
+
+ if (from == 0 || from > count || from > to) {
+ hybbx_session_write_line(session, "Nothing to delete.");
+ return HYBBX_OK;
+ }
+
+ if (proxymail_user_inbox_path(service, hybbx_session_username(session),
+ inbox_path, sizeof(inbox_path)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ for (i = (size_t)(from - 1); i < (size_t)to; i++) {
+ char msg_path[HYBBX_PATH_MAX];
+ char msg_name[32];
+
+ snprintf(msg_name, sizeof(msg_name), "%06llu.msg",
+ (unsigned long long)entries[i].id);
+ if (hybbx_path_join(msg_path, sizeof(msg_path), inbox_path,
+ msg_name) == HYBBX_OK) {
+ if (remove(msg_path) == 0) {
+ removed++;
+ }
+ }
+ }
+
+ if (removed == 0) {
+ hybbx_session_write_line(session, "Nothing to delete.");
+ } else {
+ char buf[48];
+
+ snprintf(buf, sizeof(buf), "Deleted %u proxymail message(s).", removed);
+ hybbx_session_write_line(session, buf);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_proxymail_recycle_empty(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ (void)service;
+ hybbx_session_write_line(session, "Recycle bin empty.");
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_proxymail_deliver(hybbx_service_t *service,
+ const char *from_user,
+ const char *to_address,
+ const char *subject,
+ const char *body)
+{
+ char from_address[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ const char *local_name;
+
+ if (!hybbx_proxymail_parse_address(to_address, NULL, 0, NULL, 0)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ local_name = hybbx_service_get_name(service);
+ snprintf(from_address, sizeof(from_address), "%s@%s",
+ from_user != NULL ? from_user : "user", local_name);
+
+ return hybbx_mains_proxy_send_mail(service, from_address, to_address,
+ subject, body);
+}
+
+void hybbx_proxymail_receive(hybbx_service_t *service,
+ const char *from_address,
+ const char *to_address,
+ const char *subject,
+ const char *body)
+{
+ char user[HYBBX_USER_NAME_MAX];
+ char remote[HYBBX_PROXYMAIL_SERVICE_NAME_MAX];
+ const char *local_name;
+
+ if (service == NULL || to_address == NULL) {
+ return;
+ }
+
+ if (!hybbx_proxymail_parse_address(to_address, user, sizeof(user),
+ remote, sizeof(remote))) {
+ return;
+ }
+
+ local_name = hybbx_service_get_name(service);
+ if (!str_ieq(remote, local_name)) {
+ return;
+ }
+
+ (void)proxymail_store_message(service, user,
+ from_address != NULL ? from_address
+ : "unknown",
+ subject, body);
+}
diff --git a/src/core/registry.c b/src/core/registry.c
new file mode 100644
index 0000000..107cc66
--- /dev/null
+++ b/src/core/registry.c
@@ -0,0 +1,83 @@
+#include "hybbx/registry.h"
+#include "hybbx/limits.h"
+
+#include <string.h>
+
+typedef struct registry_entry {
+ const hybbx_transport_plugin_t *plugin;
+} registry_entry_t;
+
+static registry_entry_t g_registry[HYBBX_MAX_PLUGINS];
+static size_t g_registry_count = 0;
+
+hybbx_result_t hybbx_registry_register(const hybbx_transport_plugin_t *plugin)
+{
+ if (plugin == NULL || plugin->name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_registry_find(plugin->name) != NULL) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (g_registry_count >= HYBBX_MAX_PLUGINS) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ g_registry[g_registry_count].plugin = plugin;
+ g_registry_count++;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_registry_unregister(const char *name)
+{
+ size_t i;
+
+ if (name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < g_registry_count; i++) {
+ if (strcmp(g_registry[i].plugin->name, name) == 0) {
+ size_t remaining = g_registry_count - i - 1;
+ if (remaining > 0) {
+ memmove(&g_registry[i], &g_registry[i + 1],
+ remaining * sizeof(registry_entry_t));
+ }
+ g_registry_count--;
+ return HYBBX_OK;
+ }
+ }
+
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+const hybbx_transport_plugin_t *hybbx_registry_find(const char *name)
+{
+ size_t i;
+
+ if (name == NULL) {
+ return NULL;
+ }
+
+ for (i = 0; i < g_registry_count; i++) {
+ if (strcmp(g_registry[i].plugin->name, name) == 0) {
+ return g_registry[i].plugin;
+ }
+ }
+
+ return NULL;
+}
+
+void hybbx_registry_foreach(hybbx_registry_iter_fn fn, void *userdata)
+{
+ size_t i;
+
+ if (fn == NULL) {
+ return;
+ }
+
+ for (i = 0; i < g_registry_count; i++) {
+ fn(g_registry[i].plugin, userdata);
+ }
+}
diff --git a/src/core/rf_tx_pace.c b/src/core/rf_tx_pace.c
new file mode 100644
index 0000000..ec88a0e
--- /dev/null
+++ b/src/core/rf_tx_pace.c
@@ -0,0 +1,100 @@
+#include "hybbx/rf_tx_pace.h"
+#include "hybbx/posix_time.h"
+
+#include <pthread.h>
+
+static pthread_mutex_t g_pace_lock = PTHREAD_MUTEX_INITIALIZER;
+static struct timespec g_last_tx;
+static struct timespec g_burst_start;
+static int g_have_last_tx;
+
+static void timespec_now(struct timespec *ts)
+{
+ clock_gettime(CLOCK_MONOTONIC, ts);
+}
+
+static long timespec_diff_ms(const struct timespec *later,
+ const struct timespec *earlier)
+{
+ return (later->tv_sec - earlier->tv_sec) * 1000L
+ + (later->tv_nsec - earlier->tv_nsec) / 1000000L;
+}
+
+static long gap_ms_since_last(const struct timespec *now)
+{
+ long elapsed_ms;
+
+ if (!g_have_last_tx) {
+ return (long)HYBBX_RF_TX_MIN_GAP_MS;
+ }
+ elapsed_ms = timespec_diff_ms(now, &g_last_tx);
+ if (elapsed_ms >= (long)HYBBX_RF_TX_MIN_GAP_MS) {
+ return 0;
+ }
+ return (long)HYBBX_RF_TX_MIN_GAP_MS - elapsed_ms;
+}
+
+static void sleep_ms(long wait_ms)
+{
+ if (wait_ms > 0) {
+ usleep((useconds_t)wait_ms * 1000u);
+ }
+}
+
+static void enforce_burst_limit(struct timespec *now)
+{
+ long since_burst;
+ long wait_ms;
+
+ if (!g_have_last_tx) {
+ g_burst_start = *now;
+ return;
+ }
+
+ if (gap_ms_since_last(now) == 0) {
+ g_burst_start = *now;
+ return;
+ }
+
+ since_burst = timespec_diff_ms(now, &g_burst_start);
+ if (since_burst < (long)HYBBX_RF_TX_MAX_BURST_MS) {
+ return;
+ }
+
+ wait_ms = gap_ms_since_last(now);
+ sleep_ms(wait_ms);
+ timespec_now(now);
+ g_burst_start = *now;
+}
+
+static void mark_tx(const struct timespec *now)
+{
+ g_last_tx = *now;
+ g_have_last_tx = 1;
+}
+
+void hybbx_rf_tx_burst_guard(void)
+{
+ struct timespec now;
+
+ pthread_mutex_lock(&g_pace_lock);
+ timespec_now(&now);
+ enforce_burst_limit(&now);
+ mark_tx(&now);
+ pthread_mutex_unlock(&g_pace_lock);
+}
+
+void hybbx_rf_tx_pace(void)
+{
+ struct timespec now;
+ long wait_ms;
+
+ pthread_mutex_lock(&g_pace_lock);
+ timespec_now(&now);
+ enforce_burst_limit(&now);
+ wait_ms = gap_ms_since_last(&now);
+ sleep_ms(wait_ms);
+ timespec_now(&now);
+ mark_tx(&now);
+ pthread_mutex_unlock(&g_pace_lock);
+}
diff --git a/src/core/security.c b/src/core/security.c
new file mode 100644
index 0000000..1a06600
--- /dev/null
+++ b/src/core/security.c
@@ -0,0 +1,205 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/security.h"
+#if !defined(HYBBX_CLIENT_BUILD)
+#include "hybbx/config.h"
+#endif
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <errno.h>
+#include <pthread.h>
+#include <stdarg.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+
+#define HYBBX_SECURITY_LINE_MAX 512u
+
+static char g_security_dir[HYBBX_PATH_MAX];
+static int g_security_ready;
+static FILE *g_security_file;
+static pthread_mutex_t g_security_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ hybbx_strlcpy(buf, path, sizeof(buf));
+ len = strlen(buf);
+ while (len > 0 && buf[len - 1] == '/') {
+ buf[--len] = '\0';
+ }
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] != '/') {
+ continue;
+ }
+ buf[i] = '\0';
+ if (buf[0] != '\0' && mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int security_open_file(void)
+{
+ char path[HYBBX_PATH_MAX];
+
+ if (g_security_dir[0] == '\0') {
+ return -1;
+ }
+
+ if (g_security_file != NULL) {
+ return 0;
+ }
+
+ if (mkdir_p(g_security_dir) != 0) {
+ hybbx_log_warn("[security] cannot create directory %s",
+ g_security_dir);
+ return -1;
+ }
+
+ if (hybbx_path_join(path, sizeof(path), g_security_dir,
+ HYBBX_SECURITY_LOG_FILE) != HYBBX_OK) {
+ return -1;
+ }
+
+ g_security_file = fopen(path, "a");
+ if (g_security_file == NULL) {
+ hybbx_log_warn("[security] cannot open %s", path);
+ return -1;
+ }
+
+ return 0;
+}
+
+#if !defined(HYBBX_CLIENT_BUILD)
+void hybbx_security_log_config_apply(const struct hybbx_config *config)
+{
+ const char *dir_raw;
+
+ hybbx_security_log_shutdown();
+ g_security_dir[0] = '\0';
+ g_security_ready = 0;
+
+ if (config != NULL) {
+ dir_raw = hybbx_config_get(config, "log", "dir", NULL);
+ if (dir_raw != NULL && dir_raw[0] != '\0') {
+ if (hybbx_path_resolve(g_security_dir, sizeof(g_security_dir),
+ dir_raw) != HYBBX_OK) {
+ hybbx_log_warn("[security] invalid log dir path");
+ return;
+ }
+ } else {
+ if (hybbx_path_resolve(g_security_dir, sizeof(g_security_dir),
+ HYBBX_DIR_LOGS) != HYBBX_OK) {
+ hybbx_log_warn("[security] cannot resolve default log dir");
+ return;
+ }
+ }
+ } else if (hybbx_path_resolve(g_security_dir, sizeof(g_security_dir),
+ HYBBX_DIR_LOGS) != HYBBX_OK) {
+ return;
+ }
+
+ g_security_ready = 1;
+
+ if (security_open_file() != 0) {
+ g_security_ready = 0;
+ return;
+ }
+
+ hybbx_log_info("[security] log=%s/%s", g_security_dir, HYBBX_SECURITY_LOG_FILE);
+ hybbx_security_log_write("startup");
+}
+#else
+void hybbx_security_log_config_apply(const struct hybbx_config *config)
+{
+ (void)config;
+}
+#endif
+
+void hybbx_security_log_write(const char *fmt, ...)
+{
+ char message[HYBBX_SECURITY_LINE_MAX];
+ char line[HYBBX_SECURITY_LINE_MAX + 64];
+ char stamp[32];
+ va_list ap;
+ time_t now;
+ struct tm tm_buf;
+ struct tm *tm;
+
+ if (!g_security_ready || fmt == NULL) {
+ return;
+ }
+
+ va_start(ap, fmt);
+ vsnprintf(message, sizeof(message), fmt, ap);
+ va_end(ap);
+
+ now = time(NULL);
+ tm = localtime_r(&now, &tm_buf);
+ if (tm == NULL) {
+ return;
+ }
+
+ if (strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", tm) == 0) {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "%s %s\n", stamp, message);
+
+ pthread_mutex_lock(&g_security_lock);
+
+ if (security_open_file() == 0) {
+ fputs(line, g_security_file);
+ fflush(g_security_file);
+ }
+
+ pthread_mutex_unlock(&g_security_lock);
+}
+
+hybbx_result_t hybbx_security_log_current_path(char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ out[0] = '\0';
+ if (!g_security_ready || g_security_dir[0] == '\0') {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ return hybbx_path_join(out, out_len, g_security_dir, HYBBX_SECURITY_LOG_FILE);
+}
+
+void hybbx_security_log_shutdown(void)
+{
+ pthread_mutex_lock(&g_security_lock);
+
+ if (g_security_file != NULL) {
+ fclose(g_security_file);
+ g_security_file = NULL;
+ }
+
+ pthread_mutex_unlock(&g_security_lock);
+ g_security_ready = 0;
+}
diff --git a/src/core/security_ban.c b/src/core/security_ban.c
new file mode 100644
index 0000000..8fc660f
--- /dev/null
+++ b/src/core/security_ban.c
@@ -0,0 +1,1213 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/security_ban.h"
+#include "hybbx/security.h"
+#include "hybbx/config.h"
+#include "hybbx/limits.h"
+#include "hybbx/socket.h"
+#include "hybbx/storage.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <ctype.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+
+typedef struct hybbx_security_cfg {
+ int enabled;
+ unsigned maxretry;
+ unsigned findtime_sec;
+ unsigned bantime_sec;
+ unsigned abuse_maxretry;
+ unsigned abuse_findtime_sec;
+ int telnet;
+ int ssh;
+ int websocket;
+ int circuit;
+ unsigned rate_limit;
+ unsigned rate_window_sec;
+ hybbx_ban_backend_t backend;
+} hybbx_security_cfg_t;
+
+typedef struct ban_entry {
+ char ip[HYBBX_REMOTE_ADDR_MAX];
+ time_t expire_at;
+ int active;
+} ban_entry_t;
+
+typedef struct fail_entry {
+ char ip[HYBBX_REMOTE_ADDR_MAX];
+ time_t stamps[HYBBX_SECURITY_DEFAULT_MAXRETRY];
+ unsigned count;
+ int active;
+} fail_entry_t;
+
+typedef struct rate_entry {
+ char ip[HYBBX_REMOTE_ADDR_MAX];
+ time_t stamps[32];
+ unsigned count;
+ int active;
+} rate_entry_t;
+
+typedef struct ban_callid_entry {
+ char callid[HYBBX_CALLID_MAX];
+ time_t expire_at;
+ int active;
+ int permanent;
+} ban_callid_entry_t;
+
+typedef struct callid_track_entry {
+ char callid[HYBBX_CALLID_MAX];
+ time_t stamps[HYBBX_SECURITY_DEFAULT_ABUSE_MAXRETRY];
+ unsigned count;
+ int active;
+} callid_track_entry_t;
+
+static hybbx_security_cfg_t g_cfg;
+static ban_entry_t g_bans[HYBBX_SECURITY_BAN_MAX];
+static ban_callid_entry_t g_callid_bans[HYBBX_SECURITY_BAN_MAX];
+static fail_entry_t g_fails[HYBBX_SECURITY_TRACK_MAX];
+static fail_entry_t g_abuse[HYBBX_SECURITY_TRACK_MAX];
+static callid_track_entry_t g_callid_fails[HYBBX_SECURITY_TRACK_MAX];
+static callid_track_entry_t g_callid_abuse[HYBBX_SECURITY_TRACK_MAX];
+static rate_entry_t g_rates[HYBBX_SECURITY_TRACK_MAX];
+static pthread_mutex_t g_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static void security_cfg_defaults(hybbx_security_cfg_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ cfg->enabled = 1;
+ cfg->maxretry = HYBBX_SECURITY_DEFAULT_MAXRETRY;
+ cfg->findtime_sec = HYBBX_SECURITY_DEFAULT_FINDTIME_SEC;
+ cfg->bantime_sec = HYBBX_SECURITY_DEFAULT_BANTIME_SEC;
+ cfg->abuse_maxretry = HYBBX_SECURITY_DEFAULT_ABUSE_MAXRETRY;
+ cfg->abuse_findtime_sec = HYBBX_SECURITY_DEFAULT_ABUSE_FINDTIME_SEC;
+ cfg->telnet = 1;
+ cfg->ssh = 1;
+ cfg->websocket = 1;
+ cfg->circuit = 1;
+ cfg->rate_limit = HYBBX_SECURITY_DEFAULT_RATE_LIMIT;
+ cfg->rate_window_sec = HYBBX_SECURITY_DEFAULT_RATE_WINDOW_SEC;
+ cfg->backend = HYBBX_BAN_BACKEND_INTERNAL;
+}
+
+static hybbx_ban_backend_t parse_ban_backend(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_BAN_BACKEND_INTERNAL;
+ }
+
+ if (strcasecmp(value, "log") == 0) {
+ return HYBBX_BAN_BACKEND_LOG;
+ }
+ if (strcasecmp(value, "iptables") == 0) {
+ return HYBBX_BAN_BACKEND_IPTABLES;
+ }
+ if (strcasecmp(value, "nftables") == 0) {
+ return HYBBX_BAN_BACKEND_NFTABLES;
+ }
+ if (strcasecmp(value, "hosts") == 0) {
+ return HYBBX_BAN_BACKEND_HOSTS;
+ }
+
+ return HYBBX_BAN_BACKEND_INTERNAL;
+}
+
+static unsigned parse_uint_clamp(const char *value, unsigned default_value,
+ unsigned max_value)
+{
+ char *end = NULL;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || (end != NULL && *end != '\0')) {
+ return default_value;
+ }
+
+ if (n > max_value) {
+ return max_value;
+ }
+
+ return (unsigned)n;
+}
+
+static int ip_valid(const char *ip)
+{
+ return ip != NULL && ip[0] != '\0' && strcmp(ip, "?") != 0;
+}
+
+int hybbx_security_callid_normalize(const char *in, char *out, size_t out_cap)
+{
+ size_t len;
+ size_t i;
+ int has_alpha = 0;
+
+ if (in == NULL || out == NULL || out_cap < 2u) {
+ return 0;
+ }
+
+ while (*in == ' ' || *in == '\t') {
+ in++;
+ }
+
+ len = 0;
+ for (i = 0; in[i] != '\0'; i++) {
+ unsigned char ch = (unsigned char)in[i];
+
+ if (ch == ' ' || ch == '\t') {
+ break;
+ }
+
+ if (len + 1 >= out_cap) {
+ return 0;
+ }
+
+ if ((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')) {
+ out[len++] = (char)toupper(ch);
+ has_alpha = 1;
+ } else if (ch >= '0' && ch <= '9') {
+ out[len++] = (char)ch;
+ } else if (ch == '-' || ch == '_' || ch == '.') {
+ out[len++] = (char)ch;
+ } else {
+ return 0;
+ }
+ }
+
+ out[len] = '\0';
+ return len > 0 && has_alpha;
+}
+
+static ban_callid_entry_t *callid_ban_find(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (g_callid_bans[i].active &&
+ strcmp(g_callid_bans[i].callid, callid) == 0) {
+ return &g_callid_bans[i];
+ }
+ }
+
+ return NULL;
+}
+
+static ban_callid_entry_t *callid_ban_alloc(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (!g_callid_bans[i].active) {
+ hybbx_strlcpy(g_callid_bans[i].callid, callid,
+ sizeof(g_callid_bans[i].callid));
+ g_callid_bans[i].active = 1;
+ return &g_callid_bans[i];
+ }
+ }
+
+ return NULL;
+}
+
+static callid_track_entry_t *callid_fail_find(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_callid_fails[i].active &&
+ strcmp(g_callid_fails[i].callid, callid) == 0) {
+ return &g_callid_fails[i];
+ }
+ }
+
+ return NULL;
+}
+
+static callid_track_entry_t *callid_fail_alloc(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (!g_callid_fails[i].active) {
+ hybbx_strlcpy(g_callid_fails[i].callid, callid,
+ sizeof(g_callid_fails[i].callid));
+ g_callid_fails[i].count = 0;
+ g_callid_fails[i].active = 1;
+ return &g_callid_fails[i];
+ }
+ }
+
+ return NULL;
+}
+
+static callid_track_entry_t *callid_abuse_find(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_callid_abuse[i].active &&
+ strcmp(g_callid_abuse[i].callid, callid) == 0) {
+ return &g_callid_abuse[i];
+ }
+ }
+
+ return NULL;
+}
+
+static callid_track_entry_t *callid_abuse_alloc(const char *callid)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (!g_callid_abuse[i].active) {
+ hybbx_strlcpy(g_callid_abuse[i].callid, callid,
+ sizeof(g_callid_abuse[i].callid));
+ g_callid_abuse[i].count = 0;
+ g_callid_abuse[i].active = 1;
+ return &g_callid_abuse[i];
+ }
+ }
+
+ return NULL;
+}
+
+static void prune_callid_fail_window(callid_track_entry_t *entry, time_t now)
+{
+ unsigned i;
+ unsigned kept = 0;
+
+ if (entry == NULL) {
+ return;
+ }
+
+ for (i = 0; i < entry->count; i++) {
+ if ((time_t)(now - entry->stamps[i]) <= (time_t)g_cfg.findtime_sec) {
+ entry->stamps[kept++] = entry->stamps[i];
+ }
+ }
+
+ entry->count = kept;
+ if (entry->count == 0) {
+ entry->active = 0;
+ entry->callid[0] = '\0';
+ }
+}
+
+static void prune_callid_abuse_window(callid_track_entry_t *entry, time_t now)
+{
+ unsigned i;
+ unsigned kept = 0;
+
+ if (entry == NULL) {
+ return;
+ }
+
+ for (i = 0; i < entry->count; i++) {
+ if ((time_t)(now - entry->stamps[i]) <=
+ (time_t)g_cfg.abuse_findtime_sec) {
+ entry->stamps[kept++] = entry->stamps[i];
+ }
+ }
+
+ entry->count = kept;
+ if (entry->count == 0) {
+ entry->active = 0;
+ entry->callid[0] = '\0';
+ }
+}
+
+static void backend_apply_callid(const char *callid, const char *reason)
+{
+ hybbx_security_log_write("ban callid=%s reason=%s backend=internal",
+ callid, reason != NULL ? reason : "abuse");
+ (void)g_cfg.backend;
+}
+
+static void apply_callid_ban_locked(const char *callid, const char *reason,
+ time_t now, int permanent)
+{
+ ban_callid_entry_t *ban;
+
+ ban = callid_ban_find(callid);
+ if (ban == NULL) {
+ ban = callid_ban_alloc(callid);
+ }
+
+ if (ban == NULL) {
+ return;
+ }
+
+ ban->permanent = permanent ? 1 : 0;
+ ban->expire_at = permanent ? (time_t)0 :
+ now + (time_t)g_cfg.bantime_sec;
+ backend_apply_callid(callid, reason);
+}
+
+static void config_clear_permanent_callid_bans_locked(void)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (g_callid_bans[i].active && g_callid_bans[i].permanent) {
+ g_callid_bans[i].active = 0;
+ g_callid_bans[i].callid[0] = '\0';
+ g_callid_bans[i].permanent = 0;
+ }
+ }
+}
+
+static void config_load_callid_bans_locked(const char *list)
+{
+ char buf[HYBBX_PATH_MAX];
+ char norm[HYBBX_CALLID_MAX];
+ char *save = NULL;
+ char *token;
+
+ if (list == NULL || list[0] == '\0') {
+ return;
+ }
+
+ hybbx_strlcpy(buf, list, sizeof(buf));
+ token = strtok_r(buf, ",", &save);
+ while (token != NULL) {
+ while (*token == ' ' || *token == '\t') {
+ token++;
+ }
+ if (hybbx_security_callid_normalize(token, norm, sizeof(norm))) {
+ apply_callid_ban_locked(norm, "config", time(NULL), 1);
+ }
+ token = strtok_r(NULL, ",", &save);
+ }
+}
+
+static void record_callid_failure_locked(const char *callid, time_t now)
+{
+ callid_track_entry_t *entry;
+
+ entry = callid_fail_find(callid);
+ if (entry == NULL) {
+ entry = callid_fail_alloc(callid);
+ }
+
+ if (entry == NULL) {
+ return;
+ }
+
+ prune_callid_fail_window(entry, now);
+
+ if (!entry->active) {
+ entry = callid_fail_alloc(callid);
+ if (entry == NULL) {
+ return;
+ }
+ }
+
+ if (entry->count < HYBBX_SECURITY_DEFAULT_MAXRETRY) {
+ entry->stamps[entry->count++] = now;
+ } else {
+ memmove(entry->stamps, entry->stamps + 1,
+ (entry->count - 1) * sizeof(entry->stamps[0]));
+ entry->stamps[entry->count - 1] = now;
+ }
+
+ if (entry->count >= g_cfg.maxretry) {
+ apply_callid_ban_locked(callid, "link_auth_fail", now, 0);
+ entry->active = 0;
+ entry->count = 0;
+ entry->callid[0] = '\0';
+ }
+}
+
+static void record_callid_abuse_locked(const char *callid, const char *category,
+ time_t now)
+{
+ callid_track_entry_t *entry;
+ char reason[64];
+
+ entry = callid_abuse_find(callid);
+ if (entry == NULL) {
+ entry = callid_abuse_alloc(callid);
+ }
+
+ if (entry == NULL) {
+ return;
+ }
+
+ prune_callid_abuse_window(entry, now);
+
+ if (!entry->active) {
+ entry = callid_abuse_alloc(callid);
+ if (entry == NULL) {
+ return;
+ }
+ }
+
+ if (entry->count < HYBBX_SECURITY_DEFAULT_ABUSE_MAXRETRY) {
+ entry->stamps[entry->count++] = now;
+ } else {
+ memmove(entry->stamps, entry->stamps + 1,
+ (entry->count - 1) * sizeof(entry->stamps[0]));
+ entry->stamps[entry->count - 1] = now;
+ }
+
+ if (entry->count >= g_cfg.abuse_maxretry) {
+ snprintf(reason, sizeof(reason), "abuse:%s",
+ category != NULL && category[0] != '\0' ? category : "flood");
+ apply_callid_ban_locked(callid, reason, now, 0);
+ entry->active = 0;
+ entry->count = 0;
+ entry->callid[0] = '\0';
+ }
+}
+
+static int callid_is_banned_locked(const char *callid, time_t now)
+{
+ ban_callid_entry_t *ban;
+
+ ban = callid_ban_find(callid);
+ if (ban == NULL) {
+ return 0;
+ }
+
+ if (ban->permanent) {
+ return 1;
+ }
+
+ if (now < ban->expire_at) {
+ return 1;
+ }
+
+ ban->active = 0;
+ ban->callid[0] = '\0';
+ ban->permanent = 0;
+ return 0;
+}
+
+static int transport_enabled(const char *transport)
+{
+ if (!g_cfg.enabled || transport == NULL || transport[0] == '\0') {
+ return 0;
+ }
+
+ if (strcmp(transport, "telnet") == 0) {
+ return g_cfg.telnet;
+ }
+ if (strcmp(transport, "ssh") == 0) {
+ return g_cfg.ssh;
+ }
+ if (strcmp(transport, "websocket") == 0) {
+ return g_cfg.websocket;
+ }
+ if (strcmp(transport, "circuit") == 0) {
+ return g_cfg.circuit;
+ }
+
+ return 1;
+}
+
+static ban_entry_t *ban_find(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (g_bans[i].active && strcmp(g_bans[i].ip, ip) == 0) {
+ return &g_bans[i];
+ }
+ }
+
+ return NULL;
+}
+
+static ban_entry_t *ban_alloc(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (!g_bans[i].active) {
+ hybbx_strlcpy(g_bans[i].ip, ip, sizeof(g_bans[i].ip));
+ g_bans[i].active = 1;
+ return &g_bans[i];
+ }
+ }
+
+ return NULL;
+}
+
+static fail_entry_t *fail_find(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_fails[i].active && strcmp(g_fails[i].ip, ip) == 0) {
+ return &g_fails[i];
+ }
+ }
+
+ return NULL;
+}
+
+static fail_entry_t *fail_alloc(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (!g_fails[i].active) {
+ hybbx_strlcpy(g_fails[i].ip, ip, sizeof(g_fails[i].ip));
+ g_fails[i].count = 0;
+ g_fails[i].active = 1;
+ return &g_fails[i];
+ }
+ }
+
+ return NULL;
+}
+
+static fail_entry_t *abuse_find(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_abuse[i].active && strcmp(g_abuse[i].ip, ip) == 0) {
+ return &g_abuse[i];
+ }
+ }
+
+ return NULL;
+}
+
+static fail_entry_t *abuse_alloc(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (!g_abuse[i].active) {
+ hybbx_strlcpy(g_abuse[i].ip, ip, sizeof(g_abuse[i].ip));
+ g_abuse[i].count = 0;
+ g_abuse[i].active = 1;
+ return &g_abuse[i];
+ }
+ }
+
+ return NULL;
+}
+
+static rate_entry_t *rate_find(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_rates[i].active && strcmp(g_rates[i].ip, ip) == 0) {
+ return &g_rates[i];
+ }
+ }
+
+ return NULL;
+}
+
+static rate_entry_t *rate_alloc(const char *ip)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (!g_rates[i].active) {
+ hybbx_strlcpy(g_rates[i].ip, ip, sizeof(g_rates[i].ip));
+ g_rates[i].count = 0;
+ g_rates[i].active = 1;
+ return &g_rates[i];
+ }
+ }
+
+ return NULL;
+}
+
+static void prune_fail_window(fail_entry_t *entry, time_t now)
+{
+ unsigned i;
+ unsigned kept = 0;
+
+ if (entry == NULL) {
+ return;
+ }
+
+ for (i = 0; i < entry->count; i++) {
+ if ((time_t)(now - entry->stamps[i]) <= (time_t)g_cfg.findtime_sec) {
+ entry->stamps[kept++] = entry->stamps[i];
+ }
+ }
+
+ entry->count = kept;
+ if (entry->count == 0) {
+ entry->active = 0;
+ entry->ip[0] = '\0';
+ }
+}
+
+static void prune_abuse_window(fail_entry_t *entry, time_t now)
+{
+ unsigned i;
+ unsigned kept = 0;
+
+ if (entry == NULL) {
+ return;
+ }
+
+ for (i = 0; i < entry->count; i++) {
+ if ((time_t)(now - entry->stamps[i]) <=
+ (time_t)g_cfg.abuse_findtime_sec) {
+ entry->stamps[kept++] = entry->stamps[i];
+ }
+ }
+
+ entry->count = kept;
+ if (entry->count == 0) {
+ entry->active = 0;
+ entry->ip[0] = '\0';
+ }
+}
+
+static void prune_rate_window(rate_entry_t *entry, time_t now)
+{
+ unsigned i;
+ unsigned kept = 0;
+
+ if (entry == NULL) {
+ return;
+ }
+
+ for (i = 0; i < entry->count; i++) {
+ if ((time_t)(now - entry->stamps[i]) <= (time_t)g_cfg.rate_window_sec) {
+ entry->stamps[kept++] = entry->stamps[i];
+ }
+ }
+
+ entry->count = kept;
+ if (entry->count == 0) {
+ entry->active = 0;
+ entry->ip[0] = '\0';
+ }
+}
+
+static int run_backend_cmd(const char *cmd)
+{
+ int rc;
+
+ if (cmd == NULL || cmd[0] == '\0') {
+ return -1;
+ }
+
+ rc = system(cmd);
+ return rc;
+}
+
+static void backend_apply(const char *ip, const char *reason)
+{
+ char cmd[HYBBX_PATH_MAX];
+
+ switch (g_cfg.backend) {
+ case HYBBX_BAN_BACKEND_LOG:
+ hybbx_security_log_write("ban ip=%s reason=%s backend=log",
+ ip, reason != NULL ? reason : "abuse");
+ break;
+
+ case HYBBX_BAN_BACKEND_IPTABLES:
+ snprintf(cmd, sizeof(cmd),
+ "iptables -I INPUT -s %s -j DROP 2>/dev/null", ip);
+ if (run_backend_cmd(cmd) != 0) {
+ hybbx_security_log_write(
+ "ban_backend_fail ip=%s backend=iptables", ip);
+ } else {
+ hybbx_security_log_write(
+ "ban ip=%s reason=%s backend=iptables",
+ ip, reason != NULL ? reason : "abuse");
+ }
+ break;
+
+ case HYBBX_BAN_BACKEND_NFTABLES:
+ snprintf(cmd, sizeof(cmd),
+ "nft add rule inet filter input ip saddr %s drop "
+ "2>/dev/null",
+ ip);
+ if (run_backend_cmd(cmd) != 0) {
+ hybbx_security_log_write(
+ "ban_backend_fail ip=%s backend=nftables", ip);
+ } else {
+ hybbx_security_log_write(
+ "ban ip=%s reason=%s backend=nftables",
+ ip, reason != NULL ? reason : "abuse");
+ }
+ break;
+
+ case HYBBX_BAN_BACKEND_HOSTS:
+ hybbx_security_log_write(
+ "ban ip=%s reason=%s backend=hosts (stub — internal only)",
+ ip, reason != NULL ? reason : "abuse");
+ break;
+
+ case HYBBX_BAN_BACKEND_INTERNAL:
+ default:
+ hybbx_security_log_write("ban ip=%s reason=%s backend=internal",
+ ip, reason != NULL ? reason : "abuse");
+ break;
+ }
+}
+
+static void apply_ban_locked(const char *ip, const char *reason, time_t now)
+{
+ ban_entry_t *ban;
+
+ ban = ban_find(ip);
+ if (ban == NULL) {
+ ban = ban_alloc(ip);
+ }
+
+ if (ban == NULL) {
+ return;
+ }
+
+ ban->expire_at = now + (time_t)g_cfg.bantime_sec;
+ backend_apply(ip, reason);
+}
+
+static void record_failure_locked(const char *ip, time_t now)
+{
+ fail_entry_t *entry;
+
+ entry = fail_find(ip);
+ if (entry == NULL) {
+ entry = fail_alloc(ip);
+ }
+
+ if (entry == NULL) {
+ return;
+ }
+
+ prune_fail_window(entry, now);
+
+ if (!entry->active) {
+ entry = fail_alloc(ip);
+ if (entry == NULL) {
+ return;
+ }
+ }
+
+ if (entry->count < HYBBX_SECURITY_DEFAULT_MAXRETRY) {
+ entry->stamps[entry->count++] = now;
+ } else {
+ memmove(entry->stamps, entry->stamps + 1,
+ (entry->count - 1) * sizeof(entry->stamps[0]));
+ entry->stamps[entry->count - 1] = now;
+ }
+
+ if (entry->count >= g_cfg.maxretry) {
+ apply_ban_locked(ip, "login_fail", now);
+ entry->active = 0;
+ entry->count = 0;
+ entry->ip[0] = '\0';
+ }
+}
+
+static void record_abuse_locked(const char *ip, const char *category, time_t now)
+{
+ fail_entry_t *entry;
+ char reason[64];
+
+ entry = abuse_find(ip);
+ if (entry == NULL) {
+ entry = abuse_alloc(ip);
+ }
+
+ if (entry == NULL) {
+ return;
+ }
+
+ prune_abuse_window(entry, now);
+
+ if (!entry->active) {
+ entry = abuse_alloc(ip);
+ if (entry == NULL) {
+ return;
+ }
+ }
+
+ if (entry->count < HYBBX_SECURITY_DEFAULT_ABUSE_MAXRETRY) {
+ entry->stamps[entry->count++] = now;
+ } else {
+ memmove(entry->stamps, entry->stamps + 1,
+ (entry->count - 1) * sizeof(entry->stamps[0]));
+ entry->stamps[entry->count - 1] = now;
+ }
+
+ if (entry->count >= g_cfg.abuse_maxretry) {
+ snprintf(reason, sizeof(reason), "abuse:%s",
+ category != NULL && category[0] != '\0' ? category : "flood");
+ apply_ban_locked(ip, reason, now);
+ entry->active = 0;
+ entry->count = 0;
+ entry->ip[0] = '\0';
+ }
+}
+
+static void record_rate_locked(const char *ip, time_t now)
+{
+ rate_entry_t *entry;
+
+ entry = rate_find(ip);
+ if (entry == NULL) {
+ entry = rate_alloc(ip);
+ }
+
+ if (entry == NULL) {
+ return;
+ }
+
+ prune_rate_window(entry, now);
+
+ if (!entry->active) {
+ entry = rate_alloc(ip);
+ if (entry == NULL) {
+ return;
+ }
+ }
+
+ if (entry->count < (unsigned)(sizeof(entry->stamps) / sizeof(entry->stamps[0]))) {
+ entry->stamps[entry->count++] = now;
+ }
+}
+
+void hybbx_security_ban_config_apply(const struct hybbx_config *config)
+{
+ const char *value;
+
+ pthread_mutex_lock(&g_lock);
+
+ security_cfg_defaults(&g_cfg);
+
+ if (config != NULL) {
+ value = hybbx_config_get(config, "security", "enabled", NULL);
+ if (value != NULL) {
+ g_cfg.enabled = hybbx_parse_bool(value, g_cfg.enabled);
+ }
+
+ value = hybbx_config_get(config, "security", "maxretry", NULL);
+ g_cfg.maxretry = parse_uint_clamp(value, g_cfg.maxretry, 100u);
+ if (g_cfg.maxretry < 1u) {
+ g_cfg.maxretry = 1u;
+ }
+
+ value = hybbx_config_get(config, "security", "findtime", NULL);
+ g_cfg.findtime_sec =
+ parse_uint_clamp(value, g_cfg.findtime_sec, 86400u);
+
+ value = hybbx_config_get(config, "security", "bantime", NULL);
+ g_cfg.bantime_sec =
+ parse_uint_clamp(value, g_cfg.bantime_sec, 86400u);
+
+ value = hybbx_config_get(config, "security", "abuse_maxretry", NULL);
+ g_cfg.abuse_maxretry =
+ parse_uint_clamp(value, g_cfg.abuse_maxretry, 1000u);
+ if (g_cfg.abuse_maxretry < 1u) {
+ g_cfg.abuse_maxretry = 1u;
+ }
+
+ value = hybbx_config_get(config, "security", "abuse_findtime", NULL);
+ g_cfg.abuse_findtime_sec =
+ parse_uint_clamp(value, g_cfg.abuse_findtime_sec, 86400u);
+
+ value = hybbx_config_get(config, "security", "telnet", NULL);
+ if (value != NULL) {
+ g_cfg.telnet = hybbx_parse_bool(value, g_cfg.telnet);
+ }
+
+ value = hybbx_config_get(config, "security", "ssh", NULL);
+ if (value != NULL) {
+ g_cfg.ssh = hybbx_parse_bool(value, g_cfg.ssh);
+ }
+
+ value = hybbx_config_get(config, "security", "websocket", NULL);
+ if (value != NULL) {
+ g_cfg.websocket = hybbx_parse_bool(value, g_cfg.websocket);
+ }
+
+ value = hybbx_config_get(config, "security", "circuit", NULL);
+ if (value != NULL) {
+ g_cfg.circuit = hybbx_parse_bool(value, g_cfg.circuit);
+ }
+
+ value = hybbx_config_get(config, "security", "rate_limit", NULL);
+ g_cfg.rate_limit =
+ parse_uint_clamp(value, g_cfg.rate_limit, 10000u);
+
+ value = hybbx_config_get(config, "security", "rate_window", NULL);
+ g_cfg.rate_window_sec =
+ parse_uint_clamp(value, g_cfg.rate_window_sec, 3600u);
+
+ value = hybbx_config_get(config, "security", "ban_backend", NULL);
+ g_cfg.backend = parse_ban_backend(value);
+
+ config_clear_permanent_callid_bans_locked();
+ value = hybbx_config_get(config, "security", "ban_callid", NULL);
+ config_load_callid_bans_locked(value);
+ }
+
+ pthread_mutex_unlock(&g_lock);
+
+ if (g_cfg.enabled) {
+ hybbx_log_info("[security] ban enabled maxretry=%u findtime=%us bantime=%us "
+ "abuse_maxretry=%u abuse_findtime=%us "
+ "rate_limit=%u/%us backend=%d",
+ g_cfg.maxretry, g_cfg.findtime_sec, g_cfg.bantime_sec,
+ g_cfg.abuse_maxretry, g_cfg.abuse_findtime_sec,
+ g_cfg.rate_limit, g_cfg.rate_window_sec, (int)g_cfg.backend);
+ }
+}
+
+void hybbx_security_ban_shutdown(void)
+{
+ pthread_mutex_lock(&g_lock);
+
+ memset(g_bans, 0, sizeof(g_bans));
+ memset(g_callid_bans, 0, sizeof(g_callid_bans));
+ memset(g_fails, 0, sizeof(g_fails));
+ memset(g_abuse, 0, sizeof(g_abuse));
+ memset(g_callid_fails, 0, sizeof(g_callid_fails));
+ memset(g_callid_abuse, 0, sizeof(g_callid_abuse));
+ memset(g_rates, 0, sizeof(g_rates));
+ security_cfg_defaults(&g_cfg);
+
+ pthread_mutex_unlock(&g_lock);
+}
+
+void hybbx_security_ban_tick(void)
+{
+ time_t now = time(NULL);
+ size_t i;
+
+ if (!g_cfg.enabled) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+
+ for (i = 0; i < HYBBX_SECURITY_BAN_MAX; i++) {
+ if (g_bans[i].active && now >= g_bans[i].expire_at) {
+ hybbx_security_log_write("unban ip=%s", g_bans[i].ip);
+ g_bans[i].active = 0;
+ g_bans[i].ip[0] = '\0';
+ }
+ if (g_callid_bans[i].active && !g_callid_bans[i].permanent &&
+ g_callid_bans[i].expire_at > (time_t)0 &&
+ now >= g_callid_bans[i].expire_at) {
+ hybbx_security_log_write("unban callid=%s", g_callid_bans[i].callid);
+ g_callid_bans[i].active = 0;
+ g_callid_bans[i].callid[0] = '\0';
+ g_callid_bans[i].permanent = 0;
+ }
+ }
+
+ for (i = 0; i < HYBBX_SECURITY_TRACK_MAX; i++) {
+ if (g_fails[i].active) {
+ prune_fail_window(&g_fails[i], now);
+ }
+ if (g_abuse[i].active) {
+ prune_abuse_window(&g_abuse[i], now);
+ }
+ if (g_callid_fails[i].active) {
+ prune_callid_fail_window(&g_callid_fails[i], now);
+ }
+ if (g_callid_abuse[i].active) {
+ prune_callid_abuse_window(&g_callid_abuse[i], now);
+ }
+ if (g_rates[i].active) {
+ prune_rate_window(&g_rates[i], now);
+ }
+ }
+
+ pthread_mutex_unlock(&g_lock);
+}
+
+int hybbx_security_ban_is_banned(const char *ip)
+{
+ ban_entry_t *ban;
+ time_t now = time(NULL);
+ int banned = 0;
+
+ if (!g_cfg.enabled || !ip_valid(ip)) {
+ return 0;
+ }
+
+ pthread_mutex_lock(&g_lock);
+
+ ban = ban_find(ip);
+ if (ban != NULL) {
+ if (now < ban->expire_at) {
+ banned = 1;
+ } else {
+ ban->active = 0;
+ ban->ip[0] = '\0';
+ }
+ }
+
+ pthread_mutex_unlock(&g_lock);
+ return banned;
+}
+
+int hybbx_security_ban_accept(const char *ip)
+{
+ rate_entry_t *entry;
+ ban_entry_t *ban;
+ time_t now = time(NULL);
+ int allow = 1;
+
+ if (!g_cfg.enabled || !ip_valid(ip)) {
+ return 1;
+ }
+
+ pthread_mutex_lock(&g_lock);
+
+ ban = ban_find(ip);
+ if (ban != NULL) {
+ if (now < ban->expire_at) {
+ allow = 0;
+ } else {
+ ban->active = 0;
+ ban->ip[0] = '\0';
+ }
+ }
+
+ if (allow && g_cfg.rate_limit > 0u && g_cfg.rate_window_sec > 0u) {
+ entry = rate_find(ip);
+ if (entry != NULL) {
+ prune_rate_window(entry, now);
+ }
+
+ if (entry != NULL && entry->active &&
+ entry->count >= g_cfg.rate_limit) {
+ apply_ban_locked(ip, "rate_limit", now);
+ allow = 0;
+ } else {
+ record_rate_locked(ip, now);
+ }
+ }
+
+ pthread_mutex_unlock(&g_lock);
+ return allow;
+}
+
+int hybbx_security_ban_accept_fd(int fd)
+{
+ char ip[HYBBX_REMOTE_ADDR_MAX];
+
+ if (fd < 0) {
+ return 1;
+ }
+
+ if (hybbx_socket_peer_name(fd, ip, sizeof(ip)) != HYBBX_OK) {
+ return 1;
+ }
+
+ return hybbx_security_ban_accept(ip);
+}
+
+void hybbx_security_ban_login_fail(const char *ip, const char *transport)
+{
+ time_t now = time(NULL);
+
+ if (!g_cfg.enabled || !ip_valid(ip) || !transport_enabled(transport)) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ record_failure_locked(ip, now);
+ pthread_mutex_unlock(&g_lock);
+}
+
+void hybbx_security_ban_link_auth_fail(const char *ip)
+{
+ time_t now = time(NULL);
+
+ if (!g_cfg.enabled || !ip_valid(ip) || !g_cfg.circuit) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ record_failure_locked(ip, now);
+ pthread_mutex_unlock(&g_lock);
+}
+
+void hybbx_security_ban_abuse_report(const char *ip, const char *category)
+{
+ time_t now = time(NULL);
+
+ if (!g_cfg.enabled || !ip_valid(ip)) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ record_abuse_locked(ip, category, now);
+ pthread_mutex_unlock(&g_lock);
+}
+
+int hybbx_security_ban_callid_is_banned(const char *callid)
+{
+ char norm[HYBBX_CALLID_MAX];
+ time_t now = time(NULL);
+ int banned = 0;
+
+ if (!g_cfg.enabled || !hybbx_security_callid_normalize(callid, norm,
+ sizeof(norm))) {
+ return 0;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ banned = callid_is_banned_locked(norm, now);
+ pthread_mutex_unlock(&g_lock);
+ return banned;
+}
+
+int hybbx_security_ban_callid_accept(const char *callid)
+{
+ return !hybbx_security_ban_callid_is_banned(callid);
+}
+
+void hybbx_security_ban_link_auth_fail_callid(const char *callid)
+{
+ char norm[HYBBX_CALLID_MAX];
+ time_t now = time(NULL);
+
+ if (!g_cfg.enabled || !g_cfg.circuit ||
+ !hybbx_security_callid_normalize(callid, norm, sizeof(norm))) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ record_callid_failure_locked(norm, now);
+ pthread_mutex_unlock(&g_lock);
+}
+
+void hybbx_security_ban_callid_abuse_report(const char *callid,
+ const char *category)
+{
+ char norm[HYBBX_CALLID_MAX];
+ time_t now = time(NULL);
+
+ if (!g_cfg.enabled || !hybbx_security_callid_normalize(callid, norm,
+ sizeof(norm))) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_lock);
+ record_callid_abuse_locked(norm, category, now);
+ pthread_mutex_unlock(&g_lock);
+}
diff --git a/src/core/service.c b/src/core/service.c
new file mode 100644
index 0000000..a8bd402
--- /dev/null
+++ b/src/core/service.c
@@ -0,0 +1,1270 @@
+/*
+ * Centralized daemon: INI apply, plugin lifecycle, HBX circuit hub, sessions,
+ * link registry prune. Wire protocols stay in plugins/ (telnet, packet_radio).
+ */
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "storage_private.h"
+#include "hybbx/registry.h"
+#include "hybbx/config.h"
+#include "hybbx/crypto_config.h"
+#include "hybbx/traffic.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_bridge.h"
+#include "hybbx/link.h"
+#include "hybbx/auth.h"
+#include "hybbx/storage.h"
+#include "hybbx/texts.h"
+#include "hybbx/chat.h"
+#include "hybbx/mail.h"
+#include "hybbx/broadcast.h"
+
+#ifdef HYBBX_HAVE_PLUGIN_MAINS_PROXY
+void hybbx_mains_proxy_plugin_tick(void);
+#endif
+#include "hybbx/networks.h"
+#include "hybbx/instance.h"
+#include "hybbx/log.h"
+#include "hybbx/monitor.h"
+#include "hybbx/security.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <unistd.h>
+#endif
+
+#define HYBBX_MAX_ACTIVE_TRANSPORTS 8
+
+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;
+}
+
+typedef struct active_transport {
+ const hybbx_transport_plugin_t *plugin;
+ int running;
+} active_transport_t;
+
+typedef struct hybbx_attached_session {
+ struct hybbx_session *session;
+ struct hybbx_attached_session *next;
+} hybbx_attached_session_t;
+
+#define HYBBX_DEFAULT_DATA_PATH "data"
+
+struct hybbx_service_internal {
+ char *name;
+ char config_path[HYBBX_PATH_MAX];
+ char prompt[HYBBX_PROMPT_MAX];
+ unsigned max_online;
+ unsigned guest_timeout_minutes;
+ int login_announce;
+ unsigned active_nodes;
+ pthread_mutex_t node_lock;
+ pthread_mutex_t session_lock;
+ hybbx_attached_session_t *sessions;
+ hybbx_storage_t *storage;
+ hybbx_auth_config_t auth;
+ hybbx_texts_config_t texts;
+ hybbx_chat_config_t chat;
+ hybbx_mail_config_t mail;
+ hybbx_broadcast_config_t broadcast;
+ hybbx_networks_config_t networks;
+ active_transport_t transports[HYBBX_MAX_ACTIVE_TRANSPORTS];
+ size_t transport_count;
+ hybbx_circuit_hub_t *circuit_hub;
+ int running;
+ hybbx_shutdown_mode_t shutdown_mode;
+ char launch_binary[HYBBX_PATH_MAX];
+ pthread_mutex_t guest_lock;
+ unsigned char guest_in_use[HYBBX_GUEST_NUMBER_MAX + 1];
+};
+
+hybbx_service_t *hybbx_service_create(const char *name)
+{
+ struct hybbx_service_internal *svc;
+
+ svc = calloc(1, sizeof(*svc));
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ hybbx_auth_config_defaults(&svc->auth);
+ hybbx_texts_config_defaults(&svc->texts);
+ hybbx_chat_config_defaults(&svc->chat);
+ hybbx_mail_config_defaults(&svc->mail);
+ hybbx_networks_config_defaults(&svc->networks);
+ svc->max_online = HYBBX_DEFAULT_MAX_ONLINE;
+ svc->guest_timeout_minutes = HYBBX_DEFAULT_GUEST_TIMEOUT_MINUTES;
+ svc->login_announce = 0;
+ svc->active_nodes = 0;
+ pthread_mutex_init(&svc->node_lock, NULL);
+ pthread_mutex_init(&svc->session_lock, NULL);
+ pthread_mutex_init(&svc->guest_lock, NULL);
+
+ svc->name = hybbx_strdup(name != NULL && name[0] != '\0' ?
+ name : HYBBX_DEFAULT_SERVICE_NAME);
+ if (svc->name == NULL) {
+ free(svc);
+ return NULL;
+ }
+
+ return (hybbx_service_t *)svc;
+}
+
+void hybbx_service_destroy(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ size_t i;
+
+ if (svc == NULL) {
+ return;
+ }
+
+ hybbx_service_stop(service);
+
+ if (svc->circuit_hub != NULL) {
+ hybbx_circuit_hub_destroy(svc->circuit_hub);
+ svc->circuit_hub = NULL;
+ }
+
+ for (i = 0; i < svc->transport_count; i++) {
+ if (svc->transports[i].running &&
+ svc->transports[i].plugin->stop != NULL) {
+ svc->transports[i].plugin->stop();
+ }
+ if (svc->transports[i].plugin->shutdown != NULL) {
+ svc->transports[i].plugin->shutdown();
+ }
+ }
+
+ if (svc->storage != NULL) {
+ hybbx_storage_close(svc->storage);
+ svc->storage = NULL;
+ }
+
+ hybbx_monitor_shutdown();
+ hybbx_log_shutdown();
+ hybbx_security_log_shutdown();
+ hybbx_security_ban_shutdown();
+
+ {
+ hybbx_attached_session_t *node = svc->sessions;
+
+ while (node != NULL) {
+ hybbx_attached_session_t *next = node->next;
+
+ free(node);
+ node = next;
+ }
+ svc->sessions = NULL;
+ }
+
+ pthread_mutex_destroy(&svc->session_lock);
+ pthread_mutex_destroy(&svc->guest_lock);
+ pthread_mutex_destroy(&svc->node_lock);
+ free(svc->name);
+ free(svc);
+}
+
+hybbx_storage_t *hybbx_service_get_storage(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return svc->storage;
+}
+
+const hybbx_auth_config_t *hybbx_service_get_auth(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return &svc->auth;
+}
+
+const hybbx_texts_config_t *hybbx_service_get_texts(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return &svc->texts;
+}
+
+const hybbx_chat_config_t *hybbx_service_get_chat(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return &svc->chat;
+}
+
+const hybbx_mail_config_t *hybbx_service_get_mail(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return &svc->mail;
+}
+
+const hybbx_broadcast_config_t *hybbx_service_get_broadcast(
+ const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return &svc->broadcast;
+}
+
+hybbx_circuit_hub_t *hybbx_service_circuit_hub(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return NULL;
+ }
+
+ return svc->circuit_hub;
+}
+
+const char *hybbx_service_get_prompt(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return "";
+ }
+
+ return svc->prompt;
+}
+
+const char *hybbx_service_get_name(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL || svc->name == NULL || svc->name[0] == '\0') {
+ return HYBBX_DEFAULT_SERVICE_NAME;
+ }
+
+ return svc->name;
+}
+
+unsigned hybbx_service_max_online(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return HYBBX_DEFAULT_MAX_ONLINE;
+ }
+
+ return svc->max_online;
+}
+
+int hybbx_service_login_announce(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ return svc != NULL && svc->login_announce != 0;
+}
+
+unsigned hybbx_service_active_nodes(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+ unsigned count;
+
+ if (svc == NULL) {
+ return 0;
+ }
+
+ pthread_mutex_lock((pthread_mutex_t *)&svc->node_lock);
+ count = svc->active_nodes;
+ pthread_mutex_unlock((pthread_mutex_t *)&svc->node_lock);
+
+ return count;
+}
+
+unsigned hybbx_service_guest_timeout_seconds(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return HYBBX_DEFAULT_GUEST_TIMEOUT_MINUTES * 60u;
+ }
+
+ return svc->guest_timeout_minutes * 60u;
+}
+
+hybbx_result_t hybbx_service_guest_assign(hybbx_service_t *service,
+ const char *guest_prefix,
+ hybbx_user_record_t *out,
+ unsigned *slot_out)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ unsigned slot;
+
+ if (svc == NULL || out == NULL || slot_out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ pthread_mutex_lock(&svc->guest_lock);
+ for (slot = 1; slot <= HYBBX_GUEST_NUMBER_MAX; slot++) {
+ if (!svc->guest_in_use[slot]) {
+ svc->guest_in_use[slot] = 1;
+ pthread_mutex_unlock(&svc->guest_lock);
+
+ hybbx_guest_fill_record(guest_prefix, slot, out);
+ *slot_out = slot;
+ return HYBBX_OK;
+ }
+ }
+ pthread_mutex_unlock(&svc->guest_lock);
+
+ return HYBBX_ERR_BUSY;
+}
+
+void hybbx_service_guest_release(hybbx_service_t *service, unsigned slot)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL || slot < 1 || slot > HYBBX_GUEST_NUMBER_MAX) {
+ return;
+ }
+
+ pthread_mutex_lock(&svc->guest_lock);
+ svc->guest_in_use[slot] = 0;
+ pthread_mutex_unlock(&svc->guest_lock);
+}
+
+hybbx_result_t hybbx_service_acquire_node(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ pthread_mutex_lock(&svc->node_lock);
+ if (svc->active_nodes >= svc->max_online) {
+ pthread_mutex_unlock(&svc->node_lock);
+ return HYBBX_ERR_BUSY;
+ }
+
+ svc->active_nodes++;
+ pthread_mutex_unlock(&svc->node_lock);
+ return HYBBX_OK;
+}
+
+void hybbx_service_release_node(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return;
+ }
+
+ pthread_mutex_lock(&svc->node_lock);
+ if (svc->active_nodes > 0) {
+ svc->active_nodes--;
+ }
+ pthread_mutex_unlock(&svc->node_lock);
+}
+
+hybbx_result_t hybbx_service_attach_session(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ hybbx_attached_session_t *node;
+
+ if (svc == NULL || session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ node = calloc(1, sizeof(*node));
+ if (node == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ node->session = session;
+
+ pthread_mutex_lock(&svc->session_lock);
+ node->next = svc->sessions;
+ svc->sessions = node;
+ pthread_mutex_unlock(&svc->session_lock);
+
+ return HYBBX_OK;
+}
+
+void hybbx_service_detach_session(hybbx_service_t *service,
+ hybbx_session_t *session)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ hybbx_attached_session_t *prev;
+ hybbx_attached_session_t *node;
+
+ if (svc == NULL || session == NULL) {
+ return;
+ }
+
+ pthread_mutex_lock(&svc->session_lock);
+ prev = NULL;
+ node = svc->sessions;
+ while (node != NULL) {
+ if (node->session == session) {
+ if (prev != NULL) {
+ prev->next = node->next;
+ } else {
+ svc->sessions = node->next;
+ }
+ free(node);
+ break;
+ }
+ prev = node;
+ node = node->next;
+ }
+ pthread_mutex_unlock(&svc->session_lock);
+}
+
+void hybbx_service_visit_sessions(hybbx_service_t *service,
+ hybbx_service_session_visit_fn fn,
+ void *userdata)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ hybbx_attached_session_t *node;
+ hybbx_session_t **snapshot = NULL;
+ size_t count = 0;
+ size_t i;
+
+ if (svc == NULL || fn == NULL) {
+ return;
+ }
+
+ /*
+ * Snapshot session pointers under session_lock, then invoke callbacks
+ * without holding the lock. Visitors may write to circuit sessions,
+ * which can re-enter via load-balance -> bandwidth policy.
+ */
+ pthread_mutex_lock(&svc->session_lock);
+ for (node = svc->sessions; node != NULL; node = node->next) {
+ if (node->session != NULL) {
+ count++;
+ }
+ }
+
+ if (count > 0) {
+ snapshot = calloc(count, sizeof(*snapshot));
+ if (snapshot == NULL) {
+ hybbx_log_warn("[service] session snapshot OOM (%zu sessions) — skipped",
+ count);
+ } else {
+ i = 0;
+ for (node = svc->sessions; node != NULL; node = node->next) {
+ if (node->session != NULL) {
+ snapshot[i++] = node->session;
+ }
+ }
+ }
+ }
+ pthread_mutex_unlock(&svc->session_lock);
+
+ if (snapshot == NULL) {
+ return;
+ }
+
+ for (i = 0; i < count; i++) {
+ fn(snapshot[i], userdata);
+ }
+ free(snapshot);
+}
+
+typedef struct find_registered_session_ctx {
+ uint64_t user_id;
+ hybbx_session_t *exclude;
+ hybbx_session_t *found;
+} find_registered_session_ctx_t;
+
+static void find_registered_session_visitor(hybbx_session_t *session,
+ void *userdata)
+{
+ find_registered_session_ctx_t *ctx = (find_registered_session_ctx_t *)userdata;
+ const hybbx_session_record_t *rec;
+
+ if (ctx == NULL || ctx->found != NULL || session == NULL ||
+ session == ctx->exclude) {
+ return;
+ }
+
+ if (!hybbx_session_logged_in(session) || hybbx_session_is_guest(session)) {
+ return;
+ }
+
+ rec = hybbx_session_record(session);
+ if (rec == NULL || rec->user_id != ctx->user_id) {
+ return;
+ }
+
+ ctx->found = session;
+}
+
+hybbx_session_t *hybbx_service_find_registered_session(
+ hybbx_service_t *service,
+ uint64_t user_id,
+ hybbx_session_t *exclude)
+{
+ find_registered_session_ctx_t ctx;
+
+ if (service == NULL || user_id == 0) {
+ return NULL;
+ }
+
+ ctx.user_id = user_id;
+ ctx.exclude = exclude;
+ ctx.found = NULL;
+ hybbx_service_visit_sessions(service, find_registered_session_visitor, &ctx);
+ return ctx.found;
+}
+
+static void service_apply_service(struct hybbx_service_internal *svc,
+ const hybbx_config_t *config)
+{
+ const char *prompt;
+ const char *max_online_value;
+ const char *nodes_value;
+
+ svc->prompt[0] = '\0';
+
+ prompt = hybbx_config_get(config, "service", "prompt", NULL);
+ if (prompt != NULL && prompt[0] != '\0') {
+ hybbx_strlcpy(svc->prompt, prompt, sizeof(svc->prompt));
+ }
+
+ max_online_value = hybbx_config_get(config, "service", "max_online", NULL);
+ nodes_value = hybbx_config_get(config, "service", "nodes", NULL);
+ if (max_online_value != NULL && max_online_value[0] != '\0') {
+ svc->max_online = hybbx_config_get_uint(config, "service", "max_online",
+ HYBBX_DEFAULT_MAX_ONLINE, 1u,
+ 999u);
+ } else if (nodes_value != NULL && nodes_value[0] != '\0') {
+ svc->max_online = hybbx_config_get_uint(config, "service", "nodes",
+ HYBBX_DEFAULT_MAX_ONLINE, 1u,
+ 999u);
+ } else {
+ svc->max_online = HYBBX_DEFAULT_MAX_ONLINE;
+ }
+
+ svc->login_announce =
+ hybbx_config_get_bool(config, "service", "login_announce", 0);
+
+ hybbx_log_info("[service] max_online=%u login_announce=%s",
+ svc->max_online,
+ svc->login_announce ? "yes" : "no");
+}
+
+static int str_ieq_local(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 service_apply_texts(struct hybbx_service_internal *svc,
+ const hybbx_config_t *config)
+{
+ const char *path;
+ char resolved[HYBBX_PATH_MAX];
+
+ hybbx_texts_config_defaults(&svc->texts);
+ path = hybbx_config_get(config, "texts", "path", HYBBX_DIR_TEXT);
+ if (hybbx_path_resolve(resolved, sizeof(resolved), path) == HYBBX_OK) {
+ hybbx_strlcpy(svc->texts.path, resolved, sizeof(svc->texts.path));
+ } else if (path != NULL && path[0] != '\0') {
+ hybbx_strlcpy(svc->texts.path, path, sizeof(svc->texts.path));
+ }
+}
+
+static hybbx_storage_backend_kind_t parse_storage_backend(const char *value)
+{
+ if (value == NULL || str_ieq_local(value, "flatfile") ||
+ str_ieq_local(value, "flat") || str_ieq_local(value, "files")) {
+ return HYBBX_STORAGE_FLATFILE;
+ }
+
+ if (str_ieq_local(value, "sqlite")) {
+ return HYBBX_STORAGE_SQLITE;
+ }
+
+ if (str_ieq_local(value, "mysql")) {
+ return HYBBX_STORAGE_MYSQL;
+ }
+
+ if (str_ieq_local(value, "mariadb")) {
+ return HYBBX_STORAGE_MARIADB;
+ }
+
+ return HYBBX_STORAGE_FLATFILE;
+}
+
+static hybbx_result_t service_open_storage(struct hybbx_service_internal *svc,
+ const hybbx_config_t *config)
+{
+ hybbx_storage_options_t options;
+ hybbx_storage_sql_config_t sql_cfg;
+ const char *backend_str;
+ const char *path_raw;
+ char path_resolved[HYBBX_PATH_MAX];
+ const char *guest_prefix;
+ hybbx_result_t rc;
+
+ if (svc->storage != NULL) {
+ hybbx_storage_close(svc->storage);
+ svc->storage = NULL;
+ }
+
+ backend_str = hybbx_config_get(config, "storage", "backend", "flatfile");
+ path_raw = hybbx_config_get(config, "storage", "path", HYBBX_DEFAULT_DATA_PATH);
+
+ rc = hybbx_path_resolve(path_resolved, sizeof(path_resolved), path_raw);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[storage] invalid path '%s'",
+ path_raw != NULL ? path_raw : "");
+ return HYBBX_ERR_IO;
+ }
+
+ guest_prefix = hybbx_config_get(config, "auth", "guest_prefix", NULL);
+
+ options.backend = parse_storage_backend(backend_str);
+ options.path = path_resolved;
+ options.guest_prefix = guest_prefix != NULL ? guest_prefix :
+ svc->auth.guest_prefix;
+ options.sql_cfg = NULL;
+
+ if (options.backend == HYBBX_STORAGE_SQLITE) {
+ hybbx_storage_sql_config_apply(&sql_cfg, config, path_resolved);
+ options.sql_cfg = &sql_cfg;
+ }
+
+ svc->storage = hybbx_storage_open(&options);
+ if (svc->storage == NULL) {
+ hybbx_log_warn("[storage] cannot open '%s' (writable?)",
+ path_resolved);
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_info("[storage] backend=%s path=%s", backend_str, path_resolved);
+ return HYBBX_OK;
+}
+
+static void service_apply_auth(struct hybbx_service_internal *svc,
+ const hybbx_config_t *config)
+{
+ const char *prefix;
+
+ hybbx_auth_config_defaults(&svc->auth);
+ svc->auth.auto_login =
+ hybbx_config_get_bool(config, "auth", "auto_login", 1);
+
+ prefix = hybbx_config_get(config, "auth", "guest_prefix", NULL);
+ if (prefix != NULL && prefix[0] != '\0') {
+ hybbx_strlcpy(svc->auth.guest_prefix, prefix,
+ sizeof(svc->auth.guest_prefix));
+ }
+
+ svc->guest_timeout_minutes = hybbx_config_get_uint(
+ config, "auth", "guest_timeout_minutes",
+ HYBBX_DEFAULT_GUEST_TIMEOUT_MINUTES, 1u, 24u * 60u);
+
+ hybbx_log_info("[service] guest_timeout_minutes=%u", svc->guest_timeout_minutes);
+}
+
+static hybbx_result_t service_apply_circuit(struct hybbx_service_internal *svc,
+ const hybbx_config_t *config)
+{
+ hybbx_circuit_config_t cfg;
+ const char *bind4;
+ const char *bind6;
+ hybbx_result_t rc;
+
+ if (!hybbx_config_get_bool(config, "circuit", "enabled", 1)) {
+ if (svc->circuit_hub != NULL) {
+ hybbx_circuit_hub_stop(svc->circuit_hub);
+ }
+ return HYBBX_OK;
+ }
+
+ if (!svc->networks.circuit) {
+ if (svc->circuit_hub != NULL) {
+ hybbx_circuit_hub_stop(svc->circuit_hub);
+ }
+ return HYBBX_OK;
+ }
+
+ hybbx_circuit_config_defaults(&cfg);
+ bind4 = hybbx_config_get(config, "circuit", "bind", NULL);
+ bind6 = hybbx_config_get(config, "circuit", "bind6", NULL);
+ if (bind4 != NULL && bind4[0] != '\0') {
+ hybbx_strlcpy(cfg.bind4, bind4, sizeof(cfg.bind4));
+ }
+ if (bind6 != NULL && bind6[0] != '\0') {
+ hybbx_strlcpy(cfg.bind6, bind6, sizeof(cfg.bind6));
+ }
+
+ cfg.port = hybbx_config_get_uint(config, "circuit", "port",
+ HYBBX_CIRCUIT_DEFAULT_PORT, 1u, 65535u);
+ cfg.ipv4 = hybbx_config_get_bool(config, "circuit", "ipv4", 1);
+ cfg.ipv6 = hybbx_config_get_bool(config, "circuit", "ipv6", 1);
+ cfg.link_auth = hybbx_config_get_bool(config, "circuit", "link_auth", 1);
+ cfg.link_stale_days = hybbx_config_get_uint(
+ config, "circuit", "link_stale_days", HYBBX_LINK_STALE_DAYS, 1u, 365u);
+ cfg.balance.enabled = hybbx_config_get_bool(config, "circuit", "balance", 1);
+ cfg.balance.lag_sec = hybbx_config_get_uint(
+ config, "circuit", "balance_lag_sec", 8u, 1u, 120u);
+ cfg.balance.queue_pause = (size_t)hybbx_config_get_uint(
+ config, "circuit", "balance_queue_pause", 8192u, 256u, 1048576u);
+ cfg.balance.queue_break = (size_t)hybbx_config_get_uint(
+ config, "circuit", "balance_queue_break", 32768u, 1024u, 1048576u);
+ cfg.balance.queue_cancel = (size_t)hybbx_config_get_uint(
+ config, "circuit", "balance_queue_cancel", 131072u, 4096u, 4194304u);
+ if (cfg.balance.queue_pause > cfg.balance.queue_break) {
+ cfg.balance.queue_break = cfg.balance.queue_pause;
+ }
+ if (cfg.balance.queue_break > cfg.balance.queue_cancel) {
+ cfg.balance.queue_cancel = cfg.balance.queue_break;
+ }
+ cfg.max_links = hybbx_config_get_uint(
+ config, "circuit", "max_links", HYBBX_CIRCUIT_DEFAULT_MAX_LINKS,
+ 1u, HYBBX_CIRCUIT_MAX_LINKS);
+ (void)hybbx_circuit_bridge_load(&cfg.bridge, config);
+
+ {
+ const char *link_pw = hybbx_config_get(config, "circuit", "link_password", NULL);
+
+ if (link_pw != NULL) {
+ hybbx_strlcpy(cfg.link_password, link_pw, sizeof(cfg.link_password));
+ }
+ if (svc->storage != NULL && svc->storage->path != NULL &&
+ svc->storage->path[0] != '\0') {
+ hybbx_strlcpy(cfg.data_path, svc->storage->path, sizeof(cfg.data_path));
+ } else {
+ char data_resolved[HYBBX_PATH_MAX];
+ const char *path_raw = hybbx_config_get(config, "storage", "path",
+ HYBBX_DEFAULT_DATA_PATH);
+
+ if (hybbx_path_resolve(data_resolved, sizeof(data_resolved),
+ path_raw) == HYBBX_OK) {
+ hybbx_strlcpy(cfg.data_path, data_resolved, sizeof(cfg.data_path));
+ }
+ }
+ if (svc->config_path[0] != '\0') {
+ hybbx_strlcpy(cfg.config_path, svc->config_path, sizeof(cfg.config_path));
+ }
+ }
+
+ if (svc->circuit_hub == NULL) {
+ svc->circuit_hub = hybbx_circuit_hub_create((hybbx_service_t *)svc);
+ if (svc->circuit_hub == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ rc = hybbx_circuit_hub_start(svc->circuit_hub, &cfg);
+ return rc;
+}
+
+hybbx_result_t hybbx_service_load_transport(hybbx_service_t *service,
+ const char *plugin_name)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ const hybbx_transport_plugin_t *plugin;
+ size_t i;
+
+ if (svc == NULL || plugin_name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ plugin = hybbx_registry_find(plugin_name);
+ if (plugin == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ for (i = 0; i < svc->transport_count; i++) {
+ if (svc->transports[i].plugin == plugin) {
+ return HYBBX_OK;
+ }
+ }
+
+ if (svc->transport_count >= HYBBX_MAX_ACTIVE_TRANSPORTS) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (plugin->init != NULL) {
+ hybbx_result_t rc = plugin->init(service);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ svc->transports[svc->transport_count].plugin = plugin;
+ svc->transports[svc->transport_count].running = 0;
+ svc->transport_count++;
+ return HYBBX_OK;
+}
+
+static active_transport_t *find_active(struct hybbx_service_internal *svc,
+ const char *plugin_name)
+{
+ size_t i;
+
+ for (i = 0; i < svc->transport_count; i++) {
+ if (strcmp(svc->transports[i].plugin->name, plugin_name) == 0) {
+ return &svc->transports[i];
+ }
+ }
+ return NULL;
+}
+
+hybbx_result_t hybbx_service_start_transport(hybbx_service_t *service,
+ const char *plugin_name,
+ const char *config)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ active_transport_t *active;
+
+ if (svc == NULL || plugin_name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ active = find_active(svc, plugin_name);
+ if (active == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ if (active->running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (active->plugin->start == NULL) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ {
+ hybbx_result_t rc = active->plugin->start(config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ active->running = 1;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_service_stop_transport(hybbx_service_t *service,
+ const char *plugin_name)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ active_transport_t *active;
+
+ if (svc == NULL || plugin_name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ active = find_active(svc, plugin_name);
+ if (active == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ if (!active->running) {
+ return HYBBX_OK;
+ }
+
+ if (active->plugin->stop != NULL) {
+ hybbx_result_t rc = active->plugin->stop();
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ active->running = 0;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_service_run(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ svc->running = 1;
+
+ while (svc->running) {
+#if defined(_WIN32)
+ Sleep(1000);
+#else
+ static unsigned prune_tick;
+
+ sleep(1);
+ hybbx_security_ban_tick();
+ hybbx_monitor_tick(service);
+ hybbx_broadcast_ax25_tick(service);
+#ifdef HYBBX_HAVE_PLUGIN_MAINS_PROXY
+ hybbx_mains_proxy_plugin_tick();
+#endif
+ if (svc->storage != NULL) {
+ hybbx_storage_backup_tick(svc->storage);
+ }
+ prune_tick++;
+ if (prune_tick >= 3600u) {
+ prune_tick = 0;
+ if (svc->circuit_hub != NULL) {
+ hybbx_circuit_hub_prune_links(svc->circuit_hub);
+ }
+ }
+#endif
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_service_stop(hybbx_service_t *service)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc != NULL) {
+ svc->running = 0;
+ }
+}
+
+void hybbx_service_set_launch_binary(hybbx_service_t *service, const char *argv0)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return;
+ }
+
+ svc->launch_binary[0] = '\0';
+ if (argv0 != NULL && argv0[0] != '\0') {
+ hybbx_strlcpy(svc->launch_binary, argv0, sizeof(svc->launch_binary));
+ }
+}
+
+void hybbx_service_request_shutdown(hybbx_service_t *service, int restart)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return;
+ }
+
+ svc->shutdown_mode = restart ? HYBBX_SHUTDOWN_RESTART : HYBBX_SHUTDOWN_STOP;
+ hybbx_service_stop(service);
+}
+
+hybbx_shutdown_mode_t hybbx_service_shutdown_mode(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL) {
+ return HYBBX_SHUTDOWN_NONE;
+ }
+
+ return svc->shutdown_mode;
+}
+
+const char *hybbx_service_config_path(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+
+ if (svc == NULL || svc->config_path[0] == '\0') {
+ return NULL;
+ }
+
+ return svc->config_path;
+}
+
+void hybbx_service_restart_exec(const hybbx_service_t *service)
+{
+ const struct hybbx_service_internal *svc =
+ (const struct hybbx_service_internal *)service;
+ const char *binary;
+ const char *config_path;
+ char *argv[4];
+ char opt_c[] = "-c";
+
+ if (svc == NULL) {
+ return;
+ }
+
+ binary = svc->launch_binary[0] != '\0' ? svc->launch_binary : HYBBX_DAEMON_BINARY;
+ config_path = svc->config_path[0] != '\0' ? svc->config_path : NULL;
+
+ argv[0] = (char *)binary;
+ if (config_path != NULL) {
+ argv[1] = opt_c;
+ argv[2] = (char *)config_path;
+ argv[3] = NULL;
+ } else {
+ argv[1] = NULL;
+ }
+
+ fflush(NULL);
+ execv(binary, argv);
+ perror("hybbx restart failed");
+}
+
+typedef struct apply_transport_ctx {
+ hybbx_service_t *service;
+ const hybbx_config_t *config;
+ const hybbx_networks_config_t *networks;
+ hybbx_result_t last_error;
+} apply_transport_ctx_t;
+
+static int transport_start_failure_is_fatal(const char *plugin_name)
+{
+ /* Role static transport must start; others may fail independently. */
+ return hybbx_networks_is_static_transport(plugin_name);
+}
+
+static void apply_transport_cb(const hybbx_transport_plugin_t *plugin,
+ void *userdata)
+{
+ apply_transport_ctx_t *ctx = (apply_transport_ctx_t *)userdata;
+ char section[128];
+ char *transport_config;
+ hybbx_result_t rc;
+ int transport_enabled;
+
+ if (ctx->networks == NULL ||
+ !hybbx_networks_transport_wanted(plugin->name, ctx->networks)) {
+ return;
+ }
+
+ if (!hybbx_config_resolve_transport_section(ctx->config, plugin->name,
+ section, sizeof(section))) {
+ snprintf(section, sizeof(section), "transport.%s", plugin->name);
+ }
+
+ if (hybbx_networks_is_static_transport(plugin->name)) {
+ transport_enabled = 1;
+ } else {
+ transport_enabled = hybbx_config_get_bool(ctx->config, section,
+ "enabled", 1);
+ }
+
+ if (!transport_enabled) {
+ return;
+ }
+
+ rc = hybbx_service_load_transport(ctx->service, plugin->name);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[service] %s: plugin load failed (%s)",
+ plugin->name, hybbx_result_name(rc));
+ if (transport_start_failure_is_fatal(plugin->name)) {
+ ctx->last_error = rc;
+ }
+ return;
+ }
+
+ if (strcmp(plugin->name, "packet_radio") == 0) {
+ transport_config =
+ hybbx_config_format_packet_radio_start(ctx->config);
+ } else if (strcmp(plugin->name, "baycom") == 0) {
+ transport_config = hybbx_config_format_baycom_start(ctx->config);
+ } else if (strcmp(plugin->name, "mains_proxy") == 0) {
+ transport_config = hybbx_config_format_transport_sections(ctx->config,
+ plugin->name);
+ } else {
+ transport_config = NULL;
+ }
+ if (transport_config == NULL) {
+ transport_config = hybbx_config_format_section(ctx->config, section);
+ }
+ rc = hybbx_service_start_transport(ctx->service, plugin->name,
+ transport_config);
+ free(transport_config);
+
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[service] %s: start failed (%s)", plugin->name,
+ hybbx_result_name(rc));
+ if (transport_start_failure_is_fatal(plugin->name)) {
+ ctx->last_error = rc;
+ }
+ }
+}
+
+hybbx_result_t hybbx_service_apply_config(hybbx_service_t *service,
+ const hybbx_config_t *config,
+ const char *config_path)
+{
+ struct hybbx_service_internal *svc =
+ (struct hybbx_service_internal *)service;
+ const char *service_name;
+ apply_transport_ctx_t ctx;
+ char *new_name;
+ hybbx_result_t rc;
+
+ if (svc == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ svc->config_path[0] = '\0';
+ if (config_path != NULL && config_path[0] != '\0') {
+ hybbx_strlcpy(svc->config_path, config_path, sizeof(svc->config_path));
+ }
+
+ service_name = hybbx_config_get(config, "service", "name", NULL);
+ if (service_name != NULL) {
+ new_name = hybbx_strdup(service_name);
+ if (new_name == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ free(svc->name);
+ svc->name = new_name;
+ }
+
+ service_apply_auth(svc, config);
+ hybbx_time_config_apply(config);
+ hybbx_log_config_apply(config);
+ hybbx_security_log_config_apply(config);
+ hybbx_monitor_config_apply(config);
+ hybbx_security_ban_config_apply(config);
+ service_apply_texts(svc, config);
+ hybbx_chat_config_apply(&svc->chat, config);
+ hybbx_broadcast_config_apply(&svc->broadcast, config);
+ service_apply_service(svc, config);
+ hybbx_crypto_config_apply(config);
+ hybbx_traffic_config_apply(config);
+
+ rc = service_open_storage(svc, config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ {
+ const char *storage_path = HYBBX_DEFAULT_DATA_PATH;
+ char storage_resolved[HYBBX_PATH_MAX];
+
+ if (svc->storage != NULL && svc->storage->path != NULL &&
+ svc->storage->path[0] != '\0') {
+ storage_path = svc->storage->path;
+ } else if (hybbx_path_resolve(storage_resolved, sizeof(storage_resolved),
+ storage_path) == HYBBX_OK) {
+ storage_path = storage_resolved;
+ }
+ hybbx_mail_config_apply(&svc->mail, config, storage_path);
+ }
+
+ hybbx_networks_config_apply(&svc->networks, config);
+
+ {
+ int standalone = hybbx_config_get_bool(config, "instance", "standalone", 0);
+
+ if (!standalone && hybbx_instance_role() == HYBBX_INSTANCE_MAIN) {
+ if (svc->networks.ax25 || svc->networks.baycom ||
+ svc->networks.ardop || svc->networks.crdop) {
+ standalone = 1;
+ }
+ }
+ hybbx_instance_set_standalone(standalone);
+ }
+
+ rc = hybbx_networks_enforce_instance(&svc->networks);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = service_apply_circuit(svc, config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ ctx.service = service;
+ ctx.config = config;
+ ctx.networks = &svc->networks;
+ ctx.last_error = HYBBX_OK;
+
+ hybbx_registry_foreach(apply_transport_cb, &ctx);
+ return ctx.last_error;
+}
diff --git a/src/core/session.c b/src/core/session.c
new file mode 100644
index 0000000..9f5d368
--- /dev/null
+++ b/src/core/session.c
@@ -0,0 +1,2678 @@
+#include "hybbx/session.h"
+#include "hybbx/service.h"
+#include "hybbx/command.h"
+#include "hybbx/storage.h"
+#include "hybbx/texts.h"
+#include "hybbx/auth.h"
+#include "hybbx/chat.h"
+#include "hybbx/conference.h"
+#include "hybbx/mail.h"
+#include "hybbx/proxymail.h"
+#include "hybbx/proxychat.h"
+#include "hybbx/terminal.h"
+#include "hybbx/traffic.h"
+#include "hybbx/log.h"
+#include "hybbx/monitor.h"
+#include "hybbx/instance.h"
+#include "hybbx/hybbx.h"
+#include "hybbx/util.h"
+#include "hybbx/bandwidth_policy.h"
+#include "hybbx/instance.h"
+#include "hybbx/messages.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <ctype.h>
+#include <time.h>
+
+static int session_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';
+}
+
+typedef struct hybbx_session_core {
+ hybbx_session_t pub;
+ hybbx_session_record_t record;
+ hybbx_user_record_t user;
+ hybbx_service_t *service;
+ char line_buf[HYBBX_LINE_MAX];
+ size_t line_len;
+ size_t line_cursor;
+ char history[HYBBX_HISTORY_MAX][HYBBX_LINE_MAX];
+ unsigned history_count;
+ unsigned history_next;
+ int history_view;
+ char history_saved_line[HYBBX_LINE_MAX];
+ int expect_lf;
+ int logged_in;
+ int login_prompt;
+ unsigned guest_slot;
+ time_t guest_expires_at;
+ hybbx_session_area_t area;
+ hybbx_session_area_t area_stack[HYBBX_AREA_STACK_MAX];
+ unsigned area_depth;
+ unsigned chat_channel;
+ int chat_max_notice_shown;
+ char conference_topic[HYBBX_CONFERENCE_TOPIC_MAX];
+ char conference_partner[HYBBX_USER_NAME_MAX];
+ int conference_invite_pending;
+ char conference_invite_from[HYBBX_USER_NAME_MAX];
+ char conference_invite_topic[HYBBX_CONFERENCE_TOPIC_MAX];
+ time_t conference_invite_deadline;
+ struct {
+ char target[HYBBX_USER_NAME_MAX];
+ time_t sent_at[HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET];
+ } conference_invite_rates[8];
+ int monitor_active;
+ int mail_composing;
+ char mail_compose_to[HYBBX_USER_NAME_MAX];
+ char mail_compose_subject[HYBBX_MAIL_SUBJECT_MAX + 1];
+ char mail_compose_body[HYBBX_MAIL_BODY_MAX + 1];
+ size_t mail_compose_body_len;
+ int proxymail_composing;
+ char proxymail_compose_to[HYBBX_PROXYMAIL_ADDRESS_MAX];
+ char proxymail_compose_subject[HYBBX_MAIL_SUBJECT_MAX + 1];
+ char proxymail_compose_body[HYBBX_MAIL_BODY_MAX + 1];
+ size_t proxymail_compose_body_len;
+ unsigned out_col;
+ int input_echo;
+ int bandwidth_paused;
+ int bandwidth_disconnect;
+ unsigned char esc_state;
+ char csi_buf[24];
+ size_t csi_len;
+} hybbx_session_core_t;
+
+#define SESSION_ESC_NONE 0u
+#define SESSION_ESC_ESC 1u
+#define SESSION_ESC_CSI 2u
+#define SESSION_ESC_SS3 3u
+
+#define SESSION_CSI_BUF_MAX 24u
+
+static hybbx_result_t session_handle_user_byte(hybbx_session_core_t *core,
+ unsigned char byte);
+
+static size_t session_line_max_len(const hybbx_session_core_t *core);
+static unsigned session_chat_message_max(const hybbx_session_core_t *core);
+static hybbx_result_t session_write_transport(hybbx_session_core_t *core,
+ const char *data, size_t len);
+static hybbx_result_t session_line_refresh_suffix(hybbx_session_core_t *core);
+static hybbx_result_t session_line_cursor_left(hybbx_session_core_t *core);
+static hybbx_result_t session_line_cursor_right(hybbx_session_core_t *core);
+static hybbx_result_t session_line_cursor_home(hybbx_session_core_t *core);
+static hybbx_result_t session_line_cursor_end(hybbx_session_core_t *core);
+static hybbx_result_t session_line_backspace(hybbx_session_core_t *core);
+static hybbx_result_t session_line_delete_forward(hybbx_session_core_t *core);
+static hybbx_result_t session_line_insert_char(hybbx_session_core_t *core,
+ char ch);
+static hybbx_result_t session_history_up(hybbx_session_core_t *core);
+static hybbx_result_t session_history_down(hybbx_session_core_t *core);
+static void session_maybe_announce_login(hybbx_session_core_t *core);
+
+static void session_escape_reset(hybbx_session_core_t *core)
+{
+ if (core == NULL) {
+ return;
+ }
+
+ core->esc_state = SESSION_ESC_NONE;
+ core->csi_len = 0;
+}
+
+static int session_csi_is_final(unsigned char ch)
+{
+ return ch >= 0x40u && ch <= 0x7eu;
+}
+
+static size_t session_line_max_len(const hybbx_session_core_t *core)
+{
+ size_t line_max = sizeof(core->line_buf) - 1;
+
+ if (core == NULL) {
+ return HYBBX_LINE_MAX - 1;
+ }
+
+ if (core->area == HYBBX_AREA_CHAT || core->area == HYBBX_AREA_CONFERENCE ||
+ core->area == HYBBX_AREA_PROXYCHAT) {
+ line_max = session_chat_message_max(core);
+ } else if (core->area == HYBBX_AREA_MAIL && core->mail_composing) {
+ line_max = HYBBX_LINE_MAX - 1;
+ } else if (core->area == HYBBX_AREA_PROXYMAIL && core->proxymail_composing) {
+ line_max = HYBBX_LINE_MAX - 1;
+ }
+
+ return line_max;
+}
+
+static int session_char_echoable(char ch)
+{
+ unsigned char byte = (unsigned char)ch;
+
+ return byte >= 0x20 && byte != 0x7f;
+}
+
+static hybbx_result_t session_write_transport(hybbx_session_core_t *core,
+ const char *data, size_t len)
+{
+ if (core == NULL || data == NULL || len == 0) {
+ return HYBBX_OK;
+ }
+
+ if (core->pub.transport != NULL && core->pub.transport->write != NULL) {
+ return core->pub.transport->write(&core->pub, data, len);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_line_move_back(hybbx_session_core_t *core,
+ size_t count)
+{
+ char buf[64];
+ size_t chunk;
+ size_t sent = 0;
+
+ if (core == NULL || count == 0) {
+ return HYBBX_OK;
+ }
+
+ memset(buf, '\b', sizeof(buf));
+
+ while (sent < count) {
+ hybbx_result_t rc;
+
+ chunk = count - sent;
+ if (chunk > sizeof(buf)) {
+ chunk = sizeof(buf);
+ }
+
+ rc = session_write_transport(core, buf, chunk);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ sent += chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_line_refresh_suffix(hybbx_session_core_t *core)
+{
+ size_t suffix_len;
+ hybbx_result_t rc;
+
+ if (core == NULL || !core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ suffix_len = core->line_len - core->line_cursor;
+ if (suffix_len > 0) {
+ rc = session_write_transport(core, core->line_buf + core->line_cursor,
+ suffix_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ rc = session_write_transport(core, "\x1b[K", 3);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return session_line_move_back(core, suffix_len);
+}
+
+static hybbx_result_t session_line_cursor_left(hybbx_session_core_t *core)
+{
+ if (core == NULL || core->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+
+ core->line_cursor--;
+ if (!core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ return session_write_transport(core, "\b", 1);
+}
+
+static hybbx_result_t session_line_cursor_right(hybbx_session_core_t *core)
+{
+ hybbx_result_t rc;
+
+ if (core == NULL || core->line_cursor >= core->line_len) {
+ return HYBBX_OK;
+ }
+
+ if (core->input_echo) {
+ char out[2];
+
+ out[0] = core->line_buf[core->line_cursor];
+ out[1] = '\0';
+ rc = session_write_transport(core, out, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ core->line_cursor++;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_line_cursor_home(hybbx_session_core_t *core)
+{
+ hybbx_result_t rc = HYBBX_OK;
+
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (core->line_cursor > 0) {
+ rc = session_line_cursor_left(core);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_line_cursor_end(hybbx_session_core_t *core)
+{
+ hybbx_result_t rc = HYBBX_OK;
+
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (core->line_cursor < core->line_len) {
+ rc = session_line_cursor_right(core);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_line_backspace(hybbx_session_core_t *core)
+{
+ hybbx_result_t rc;
+
+ if (core == NULL || core->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+
+ core->line_cursor--;
+ memmove(core->line_buf + core->line_cursor,
+ core->line_buf + core->line_cursor + 1,
+ core->line_len - core->line_cursor - 1);
+ core->line_len--;
+ core->line_buf[core->line_len] = '\0';
+
+ if (!core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ rc = session_write_transport(core, "\b", 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return session_line_refresh_suffix(core);
+}
+
+static hybbx_result_t session_line_delete_forward(hybbx_session_core_t *core)
+{
+ if (core == NULL || core->line_cursor >= core->line_len) {
+ return HYBBX_OK;
+ }
+
+ memmove(core->line_buf + core->line_cursor,
+ core->line_buf + core->line_cursor + 1,
+ core->line_len - core->line_cursor - 1);
+ core->line_len--;
+ core->line_buf[core->line_len] = '\0';
+
+ if (!core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ return session_line_refresh_suffix(core);
+}
+
+static hybbx_result_t session_line_insert_char(hybbx_session_core_t *core,
+ char ch)
+{
+ size_t line_max;
+ hybbx_result_t rc;
+ char out[2];
+
+ if (core == NULL || !session_char_echoable(ch)) {
+ return HYBBX_OK;
+ }
+
+ line_max = session_line_max_len(core);
+ if (core->line_len >= line_max) {
+ return HYBBX_OK;
+ }
+
+ if (core->line_cursor < core->line_len) {
+ memmove(core->line_buf + core->line_cursor + 1,
+ core->line_buf + core->line_cursor,
+ core->line_len - core->line_cursor);
+ }
+
+ core->line_buf[core->line_cursor] = ch;
+ core->line_len++;
+ core->line_cursor++;
+ core->line_buf[core->line_len] = '\0';
+
+ if (!core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ out[0] = ch;
+ out[1] = '\0';
+ rc = session_write_transport(core, out, 1);
+ if (rc != HYBBX_OK) {
+ core->line_cursor--;
+ core->line_len--;
+ core->line_buf[core->line_len] = '\0';
+ return rc;
+ }
+
+ return session_line_refresh_suffix(core);
+}
+
+static int session_history_should_store(const char *line)
+{
+ if (line == NULL || line[0] == '\0') {
+ return 0;
+ }
+
+ return strncmp(line, "/login ", 7) != 0 &&
+ strncmp(line, "/register ", 10) != 0 &&
+ strncmp(line, "/changeme ", 10) != 0 &&
+ strncmp(line, "/userchange ", 12) != 0;
+}
+
+static void session_history_add(hybbx_session_core_t *core, const char *line)
+{
+ if (core == NULL || !session_history_should_store(line)) {
+ return;
+ }
+
+ hybbx_strlcpy(core->history[core->history_next], line,
+ sizeof(core->history[0]));
+ core->history_next = (core->history_next + 1u) % HYBBX_HISTORY_MAX;
+ if (core->history_count < HYBBX_HISTORY_MAX) {
+ core->history_count++;
+ }
+ core->history_view = -1;
+ core->history_saved_line[0] = '\0';
+}
+
+static hybbx_result_t session_line_replace(hybbx_session_core_t *core,
+ const char *line)
+{
+ size_t len;
+ hybbx_result_t rc;
+
+ if (core == NULL || line == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(line);
+ if (len >= sizeof(core->line_buf)) {
+ len = sizeof(core->line_buf) - 1;
+ }
+
+ if (core->input_echo) {
+ rc = session_line_move_back(core, core->line_cursor);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ if (len > 0) {
+ rc = session_write_transport(core, line, len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ rc = session_write_transport(core, "\x1b[K", 3);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ memcpy(core->line_buf, line, len);
+ core->line_buf[len] = '\0';
+ core->line_len = len;
+ core->line_cursor = len;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_history_up(hybbx_session_core_t *core)
+{
+ unsigned slot;
+
+ if (core == NULL || core->history_count == 0) {
+ return HYBBX_OK;
+ }
+
+ if (core->history_view < 0) {
+ hybbx_strlcpy(core->history_saved_line, core->line_buf,
+ sizeof(core->history_saved_line));
+ core->history_view = 0;
+ } else if ((unsigned)(core->history_view + 1) < core->history_count) {
+ core->history_view++;
+ } else {
+ return HYBBX_OK;
+ }
+
+ slot = (core->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)core->history_view) % HYBBX_HISTORY_MAX;
+ return session_line_replace(core, core->history[slot]);
+}
+
+static hybbx_result_t session_history_down(hybbx_session_core_t *core)
+{
+ unsigned slot;
+
+ if (core == NULL || core->history_view < 0) {
+ return HYBBX_OK;
+ }
+
+ if (core->history_view == 0) {
+ core->history_view = -1;
+ return session_line_replace(core, core->history_saved_line);
+ }
+
+ core->history_view--;
+ slot = (core->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)core->history_view) % HYBBX_HISTORY_MAX;
+ return session_line_replace(core, core->history[slot]);
+}
+
+static hybbx_result_t session_apply_csi(hybbx_session_core_t *core,
+ const char *params, char final_ch)
+{
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (final_ch == 'A') {
+ return session_history_up(core);
+ }
+
+ if (final_ch == 'B') {
+ return session_history_down(core);
+ }
+
+ if (final_ch == 'C') {
+ return session_line_cursor_right(core);
+ }
+
+ if (final_ch == 'D') {
+ return session_line_cursor_left(core);
+ }
+
+ if (final_ch == 'H') {
+ return session_line_cursor_home(core);
+ }
+
+ if (final_ch == 'F') {
+ return session_line_cursor_end(core);
+ }
+
+ if (final_ch == '~') {
+ if (strcmp(params, "1") == 0 || strcmp(params, "7") == 0) {
+ return session_line_cursor_home(core);
+ }
+ if (strcmp(params, "3") == 0) {
+ return session_line_delete_forward(core);
+ }
+ if (strcmp(params, "4") == 0 || strcmp(params, "8") == 0) {
+ return session_line_cursor_end(core);
+ }
+ return HYBBX_OK;
+ }
+
+ if (final_ch == 'K' || final_ch == 'P') {
+ return HYBBX_OK;
+ }
+
+ return HYBBX_OK;
+}
+
+static int session_filter_escape_byte(hybbx_session_core_t *core, unsigned char byte)
+{
+ if (core == NULL) {
+ return 0;
+ }
+
+ if (core->esc_state == SESSION_ESC_NONE && byte == 0x9bu) {
+ core->esc_state = SESSION_ESC_CSI;
+ core->csi_len = 0;
+ return 1;
+ }
+
+ if (core->esc_state == SESSION_ESC_NONE && byte == 0x1bu) {
+ core->esc_state = SESSION_ESC_ESC;
+ return 1;
+ }
+
+ if (core->esc_state == SESSION_ESC_ESC) {
+ if (byte == '[') {
+ core->esc_state = SESSION_ESC_CSI;
+ core->csi_len = 0;
+ return 1;
+ }
+ if (byte == 'O') {
+ core->esc_state = SESSION_ESC_SS3;
+ return 1;
+ }
+
+ session_escape_reset(core);
+ return 0;
+ }
+
+ if (core->esc_state == SESSION_ESC_SS3) {
+ hybbx_result_t rc = HYBBX_OK;
+
+ if (byte == 'C') {
+ rc = session_line_cursor_right(core);
+ } else if (byte == 'D') {
+ rc = session_line_cursor_left(core);
+ } else if (byte == 'H') {
+ rc = session_line_cursor_home(core);
+ } else if (byte == 'F') {
+ rc = session_line_cursor_end(core);
+ }
+
+ session_escape_reset(core);
+ (void)rc;
+ return 1;
+ }
+
+ if (core->esc_state == SESSION_ESC_CSI) {
+ if (core->csi_len + 1 >= SESSION_CSI_BUF_MAX) {
+ session_escape_reset(core);
+ return 1;
+ }
+
+ core->csi_buf[core->csi_len++] = (char)byte;
+
+ if (session_csi_is_final(byte)) {
+ char final_ch = (char)byte;
+ char params[SESSION_CSI_BUF_MAX];
+
+ if (core->csi_len > 1) {
+ memcpy(params, core->csi_buf, core->csi_len - 1);
+ params[core->csi_len - 1] = '\0';
+ } else {
+ params[0] = '\0';
+ }
+
+ session_escape_reset(core);
+ (void)session_apply_csi(core, params, final_ch);
+ return 1;
+ }
+
+ return 1;
+ }
+
+ return 0;
+}
+
+static unsigned session_chat_message_max(const hybbx_session_core_t *core)
+{
+ const hybbx_chat_config_t *chat;
+
+ if (core == NULL || core->service == NULL) {
+ return HYBBX_CHAT_MESSAGE_MAX;
+ }
+
+ chat = hybbx_service_get_chat(core->service);
+ if (chat == NULL) {
+ return HYBBX_CHAT_MESSAGE_MAX;
+ }
+
+ return chat->message_max;
+}
+
+static void session_area_clear_state(hybbx_session_core_t *core,
+ hybbx_session_area_t area)
+{
+ if (core == NULL) {
+ return;
+ }
+
+ if (area == HYBBX_AREA_CHAT) {
+ core->chat_channel = 0;
+ }
+
+ if (area == HYBBX_AREA_CONFERENCE) {
+ hybbx_conference_area_leaving(&core->pub);
+ }
+
+ if (area == HYBBX_AREA_MAIL) {
+ core->mail_composing = 0;
+ core->mail_compose_body[0] = '\0';
+ core->mail_compose_body_len = 0;
+ core->mail_compose_to[0] = '\0';
+ core->mail_compose_subject[0] = '\0';
+ }
+
+ if (area == HYBBX_AREA_PROXYMAIL) {
+ core->proxymail_composing = 0;
+ core->proxymail_compose_body[0] = '\0';
+ core->proxymail_compose_body_len = 0;
+ core->proxymail_compose_to[0] = '\0';
+ core->proxymail_compose_subject[0] = '\0';
+ }
+}
+
+static void session_area_sync(hybbx_session_core_t *core)
+{
+ if (core == NULL || core->area_depth == 0) {
+ return;
+ }
+
+ core->area = core->area_stack[core->area_depth - 1];
+}
+
+static hybbx_result_t session_area_push(hybbx_session_core_t *core,
+ hybbx_session_area_t area)
+{
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->area_depth >= HYBBX_AREA_STACK_MAX) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (core->area_depth > 0 &&
+ core->area_stack[core->area_depth - 1] == area) {
+ core->area = area;
+ return HYBBX_OK;
+ }
+
+ core->area_stack[core->area_depth] = area;
+ core->area_depth++;
+ core->area = area;
+ return HYBBX_OK;
+}
+
+static void session_release_guest_slot(hybbx_session_core_t *core)
+{
+ if (core == NULL || core->guest_slot == 0 || core->service == NULL) {
+ return;
+ }
+
+ hybbx_service_guest_release(core->service, core->guest_slot);
+ core->guest_slot = 0;
+}
+
+static void session_arm_guest_timer(hybbx_session_core_t *core)
+{
+ unsigned timeout_sec;
+
+ if (core == NULL || !hybbx_user_level_is_guest(core->user.level)) {
+ if (core != NULL) {
+ core->guest_expires_at = 0;
+ }
+ return;
+ }
+
+ timeout_sec = hybbx_service_guest_timeout_seconds(core->service);
+ if (timeout_sec == 0) {
+ core->guest_expires_at = 0;
+ return;
+ }
+
+ core->guest_expires_at = time(NULL) + (time_t)timeout_sec;
+}
+
+static hybbx_result_t session_activate_guest(hybbx_session_core_t *core,
+ const hybbx_user_record_t *guest,
+ unsigned guest_slot)
+{
+ hybbx_storage_t *storage;
+ const char *transport_name;
+ hybbx_result_t rc;
+
+ if (core == NULL || guest == NULL || guest_slot < 1) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ storage = hybbx_service_get_storage(core->service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->guest_slot != 0 && core->guest_slot != guest_slot) {
+ session_release_guest_slot(core);
+ }
+
+ transport_name = core->pub.transport != NULL ?
+ core->pub.transport->name : "unknown";
+
+ if (core->record.session_id != 0) {
+ hybbx_storage_session_end(storage, core->record.session_id);
+ memset(&core->record, 0, sizeof(core->record));
+ }
+
+ rc = hybbx_storage_session_begin(storage, guest, transport_name,
+ &core->record);
+ if (rc != HYBBX_OK) {
+ hybbx_service_guest_release(core->service, guest_slot);
+ return rc;
+ }
+
+ core->user = *guest;
+ core->guest_slot = guest_slot;
+ core->logged_in = 1;
+ core->login_prompt = 0;
+ session_arm_guest_timer(core);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t session_check_guest_expiry(hybbx_session_core_t *core)
+{
+ time_t now;
+
+ if (core == NULL || core->guest_expires_at == 0) {
+ return HYBBX_OK;
+ }
+
+ now = time(NULL);
+ if (now < core->guest_expires_at) {
+ return HYBBX_OK;
+ }
+
+ (void)hybbx_msg_send_system(&core->pub, "Guest time limit. Goodbye.");
+ return HYBBX_SESSION_END;
+}
+
+static void session_process_line(hybbx_session_core_t *core, const char *line);
+
+hybbx_result_t hybbx_session_write(hybbx_session_t *session, const char *text)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL || text == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core != NULL && core->bandwidth_paused) {
+ return HYBBX_OK;
+ }
+
+ return hybbx_traffic_emit(session,
+ core != NULL ? &core->out_col : NULL,
+ text, strlen(text));
+}
+
+hybbx_result_t hybbx_session_write_line(hybbx_session_t *session,
+ const char *text)
+{
+ hybbx_result_t rc;
+
+ if (text != NULL) {
+ rc = hybbx_session_write(session, text);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return hybbx_session_write(session, "\n");
+}
+
+hybbx_result_t hybbx_session_command_gap(hybbx_session_t *session)
+{
+ return hybbx_session_write(session, "\n");
+}
+
+static int session_accepts_input(const hybbx_session_core_t *core)
+{
+ return core != NULL && (core->logged_in != 0 || core->login_prompt != 0);
+}
+
+hybbx_result_t hybbx_session_show_prompt(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ const char *prompt;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!core->logged_in && !core->login_prompt) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ prompt = hybbx_service_get_prompt(core->service);
+ if (prompt == NULL || prompt[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ return hybbx_session_write(session, prompt);
+}
+
+hybbx_result_t hybbx_session_clear_terminal(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ hybbx_result_t rc;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !session_accepts_input(core)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_term_clear_screen(session);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ core->line_len = 0;
+ core->line_cursor = 0;
+ core->line_buf[0] = '\0';
+ core->out_col = 0;
+ core->expect_lf = 0;
+
+ return hybbx_session_show_prompt(session);
+}
+
+int hybbx_session_input_echo(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->input_echo != 0;
+}
+
+hybbx_result_t hybbx_session_set_input_echo(hybbx_session_t *session, int enabled)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !session_accepts_input(core)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core->input_echo = enabled ? 1 : 0;
+ return HYBBX_OK;
+}
+
+static void session_submit_line(hybbx_session_core_t *core)
+{
+ core->line_buf[core->line_len] = '\0';
+ session_history_add(core, core->line_buf);
+ session_process_line(core, core->line_buf);
+ core->line_len = 0;
+ core->line_cursor = 0;
+ core->history_view = -1;
+ if ((core->logged_in || core->login_prompt) &&
+ core->area != HYBBX_AREA_CHAT &&
+ core->area != HYBBX_AREA_CONFERENCE &&
+ core->area != HYBBX_AREA_PROXYCHAT &&
+ !(core->area == HYBBX_AREA_MAIL && core->mail_composing) &&
+ !(core->area == HYBBX_AREA_PROXYMAIL && core->proxymail_composing)) {
+ hybbx_session_show_prompt(&core->pub);
+ }
+}
+
+static hybbx_result_t session_echo_newline(hybbx_session_core_t *core)
+{
+ if (core == NULL || !core->input_echo) {
+ return HYBBX_OK;
+ }
+
+ return hybbx_session_write(&core->pub, "\n");
+}
+
+static hybbx_result_t session_handle_user_byte(hybbx_session_core_t *core,
+ unsigned char byte)
+{
+ char ch = (char)byte;
+
+ if (ch == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (ch == '\b' || ch == 127) {
+ return session_line_backspace(core);
+ }
+
+ if (ch == '\r') {
+ {
+ hybbx_result_t rc = session_echo_newline(core);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ session_submit_line(core);
+ if (!core->logged_in && !core->login_prompt) {
+ return HYBBX_SESSION_END;
+ }
+ core->expect_lf = 1;
+ return HYBBX_OK;
+ }
+
+ if (ch == '\n') {
+ if (core->expect_lf) {
+ core->expect_lf = 0;
+ return HYBBX_OK;
+ }
+
+ {
+ hybbx_result_t rc = session_echo_newline(core);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ session_submit_line(core);
+ if (!core->logged_in && !core->login_prompt) {
+ return HYBBX_SESSION_END;
+ }
+ return HYBBX_OK;
+ }
+
+ core->expect_lf = 0;
+
+ return session_line_insert_char(core, ch);
+}
+
+static void session_send_banner(hybbx_session_core_t *core)
+{
+ const hybbx_texts_config_t *texts;
+ const char *service_name;
+
+ texts = hybbx_service_get_texts(core->service);
+ if (texts == NULL) {
+ return;
+ }
+
+ service_name = hybbx_service_get_name(core->service);
+ hybbx_texts_send_banner(texts, &core->pub, HYBBX_VERSION_STRING, service_name);
+}
+
+static void session_process_line(hybbx_session_core_t *core, const char *line)
+{
+ hybbx_parsed_command_t cmd;
+ hybbx_command_scope_t scope;
+ hybbx_result_t rc;
+
+ if (line[0] == '\0') {
+ return;
+ }
+
+ scope = hybbx_command_classify(line);
+ if (scope == HYBBX_CMD_SCOPE_COMMENT) {
+ return;
+ }
+
+ if (hybbx_conference_invite_pending(&core->pub) &&
+ scope == HYBBX_CMD_SCOPE_LOCAL) {
+ if (hybbx_conference_reply_invite(core->service, &core->pub, line) ==
+ HYBBX_OK) {
+ return;
+ }
+ hybbx_session_write_line(&core->pub, "Reply y/n or yes/no.");
+ return;
+ }
+
+ if (core->area == HYBBX_AREA_MAIL && core->mail_composing &&
+ scope == HYBBX_CMD_SCOPE_LOCAL) {
+ const hybbx_mail_config_t *mail;
+ size_t line_len;
+ size_t body_max;
+
+ mail = hybbx_service_get_mail(core->service);
+ body_max = mail != NULL ? mail->body_max : HYBBX_MAIL_BODY_MAX;
+ line_len = strlen(line);
+
+ if (core->mail_compose_body_len + line_len + 2 > body_max) {
+ hybbx_session_write_line(&core->pub, "Message body too long.");
+ return;
+ }
+
+ if (core->mail_compose_body_len > 0) {
+ core->mail_compose_body[core->mail_compose_body_len++] = '\n';
+ }
+ memcpy(core->mail_compose_body + core->mail_compose_body_len,
+ line, line_len + 1);
+ core->mail_compose_body_len += line_len;
+ return;
+ }
+
+ if (core->area == HYBBX_AREA_PROXYMAIL && core->proxymail_composing &&
+ scope == HYBBX_CMD_SCOPE_LOCAL) {
+ const hybbx_mail_config_t *mail;
+ size_t line_len;
+ size_t body_max;
+
+ mail = hybbx_service_get_mail(core->service);
+ body_max = mail != NULL ? mail->body_max : HYBBX_MAIL_BODY_MAX;
+ line_len = strlen(line);
+
+ if (core->proxymail_compose_body_len + line_len + 2 > body_max) {
+ hybbx_session_write_line(&core->pub, "Message body too long.");
+ return;
+ }
+
+ if (core->proxymail_compose_body_len > 0) {
+ core->proxymail_compose_body[core->proxymail_compose_body_len++] =
+ '\n';
+ }
+ memcpy(core->proxymail_compose_body + core->proxymail_compose_body_len,
+ line, line_len + 1);
+ core->proxymail_compose_body_len += line_len;
+ return;
+ }
+
+ if (core->area == HYBBX_AREA_PROXYCHAT && scope == HYBBX_CMD_SCOPE_LOCAL) {
+ if (hybbx_session_is_guest(&core->pub)) {
+ hybbx_session_write_line(&core->pub, "Guests cannot use proxychat.");
+ return;
+ }
+
+ if (strlen(line) > session_chat_message_max(core)) {
+ hybbx_session_write_line(&core->pub, "Message too long.");
+ return;
+ }
+
+ (void)hybbx_proxychat_post(hybbx_session_service(&core->pub), &core->pub,
+ line);
+ return;
+ }
+
+ if (core->area == HYBBX_AREA_CHAT && scope == HYBBX_CMD_SCOPE_LOCAL) {
+ if (hybbx_session_is_guest(&core->pub)) {
+ hybbx_session_write_line(&core->pub, "Guests cannot use chat.");
+ return;
+ }
+
+ if (strlen(line) > session_chat_message_max(core)) {
+ hybbx_session_write_line(&core->pub, "Message too long.");
+ return;
+ }
+
+ (void)hybbx_chat_post(core->service, &core->pub, line);
+ return;
+ }
+
+ if (core->area == HYBBX_AREA_CONFERENCE && scope == HYBBX_CMD_SCOPE_LOCAL) {
+ if (hybbx_session_is_guest(&core->pub)) {
+ hybbx_session_write_line(&core->pub, "Guests cannot use conference.");
+ return;
+ }
+
+ if (strlen(line) > session_chat_message_max(core)) {
+ hybbx_session_write_line(&core->pub, "Message too long.");
+ return;
+ }
+
+ (void)hybbx_conference_post(core->service, &core->pub, line);
+ return;
+ }
+
+ if (scope == HYBBX_CMD_SCOPE_LOCAL) {
+ return;
+ }
+
+ rc = hybbx_command_parse(line, &cmd);
+ if (rc != HYBBX_OK) {
+ hybbx_session_write_line(&core->pub, "Command parse error.");
+ return;
+ }
+
+ rc = hybbx_command_dispatch(core->service, &core->pub, &cmd);
+ if (rc == HYBBX_SESSION_END) {
+ core->logged_in = 0;
+ } else if (rc == HYBBX_ERR_DENIED) {
+ /* access message already sent */
+ } else if (rc != HYBBX_OK && rc != HYBBX_ERR_NOT_FOUND) {
+ hybbx_session_write_line(&core->pub, "Command failed.");
+ }
+
+ hybbx_command_free(&cmd);
+}
+
+hybbx_result_t hybbx_session_open(hybbx_service_t *service,
+ const hybbx_transport_plugin_t *transport,
+ void *transport_data,
+ hybbx_session_t **out)
+{
+ hybbx_session_core_t *core;
+ hybbx_storage_t *storage;
+ const hybbx_auth_config_t *auth;
+ hybbx_result_t rc;
+ int suppress_startup_text;
+ int interactive;
+
+ if (service == NULL || transport == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ interactive = (transport->kind == HYBBX_TRANSPORT_TELNET ||
+ transport->kind == HYBBX_TRANSPORT_SSH ||
+ transport->kind == HYBBX_TRANSPORT_WEBSOCKET);
+
+ if (interactive && !hybbx_instance_offers_user_bbx()) {
+ hybbx_log_warn("[session] reject %s on %s (no user BBX on this instance)",
+ transport->name, hybbx_instance_role_name());
+ return HYBBX_ERR_DENIED;
+ }
+
+ rc = hybbx_service_acquire_node(service);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ storage = hybbx_service_get_storage(service);
+ if (storage == NULL) {
+ hybbx_service_release_node(service);
+ return HYBBX_ERR_INVALID;
+ }
+
+ auth = hybbx_service_get_auth(service);
+ if (auth == NULL) {
+ hybbx_service_release_node(service);
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = calloc(1, sizeof(*core));
+ if (core == NULL) {
+ hybbx_service_release_node(service);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ core->service = service;
+ core->pub.transport = transport;
+ core->pub.transport_data = transport_data;
+ core->pub.core_data = core;
+ core->area = HYBBX_AREA_MAIN;
+ core->area_stack[0] = HYBBX_AREA_MAIN;
+ core->area_depth = 1;
+
+ {
+ const hybbx_traffic_config_t *traffic = hybbx_traffic_config_get();
+
+ core->input_echo = traffic != NULL && traffic->input_echo;
+ }
+
+ suppress_startup_text = (transport->kind == HYBBX_TRANSPORT_CIRCUIT);
+
+ (void)hybbx_term_init_session(&core->pub);
+
+ if (!suppress_startup_text) {
+ session_send_banner(core);
+ }
+
+ if (auth->auto_login) {
+ unsigned guest_slot = 0;
+
+ rc = hybbx_service_guest_assign(service, auth->guest_prefix,
+ &core->user, &guest_slot);
+ if (rc != HYBBX_OK) {
+ free(core);
+ hybbx_service_release_node(service);
+ return rc;
+ }
+
+ rc = session_activate_guest(core, &core->user, guest_slot);
+ if (rc != HYBBX_OK) {
+ free(core);
+ hybbx_service_release_node(service);
+ return rc;
+ }
+
+ if (!suppress_startup_text) {
+ hybbx_log_info("%s@%s connected (session %llu)",
+ core->record.username, transport->name,
+ (unsigned long long)core->record.session_id);
+ hybbx_monitor_event(service, core->record.username, transport->name,
+ "connected");
+ }
+
+ if (!suppress_startup_text) {
+ hybbx_session_show_prompt(&core->pub);
+ }
+ } else {
+ core->login_prompt = 1;
+ if (!suppress_startup_text) {
+ hybbx_session_show_prompt(&core->pub);
+ }
+ }
+
+ *out = &core->pub;
+
+ if (hybbx_service_attach_session(service, &core->pub) != HYBBX_OK) {
+ if (core->record.session_id != 0) {
+ hybbx_storage_session_end(storage, core->record.session_id);
+ }
+ session_release_guest_slot(core);
+ free(core);
+ hybbx_service_release_node(service);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_session_close(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ hybbx_storage_t *storage;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ if (hybbx_conference_invite_pending(session) ||
+ hybbx_session_area(session) == HYBBX_AREA_CONFERENCE) {
+ hybbx_conference_session_closed(session);
+ }
+
+ storage = hybbx_service_get_storage(core->service);
+ if (storage != NULL && core->record.session_id != 0) {
+ const char *plugin = core->record.transport[0] != '\0'
+ ? core->record.transport
+ : (session->transport != NULL
+ ? session->transport->name
+ : "?");
+ hybbx_storage_session_end(storage, core->record.session_id);
+ hybbx_log_info("%s@%s disconnected (session %llu)",
+ core->record.username, plugin,
+ (unsigned long long)core->record.session_id);
+ hybbx_monitor_event(core->service, core->record.username, plugin,
+ "disconnected");
+ }
+
+ session_release_guest_slot(core);
+
+ if (core->service != NULL) {
+ hybbx_service_detach_session(core->service, session);
+ hybbx_service_release_node(core->service);
+ }
+
+ free(core);
+}
+
+hybbx_result_t hybbx_session_switch_user(hybbx_session_t *session,
+ const hybbx_user_record_t *user)
+{
+ hybbx_session_core_t *core;
+ hybbx_storage_t *storage;
+ hybbx_session_record_t new_record;
+ const char *transport_name;
+ hybbx_result_t rc;
+
+ if (session == NULL || user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_user_level_is_guest(user->level)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || core->service == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ session_release_guest_slot(core);
+
+ storage = hybbx_service_get_storage(core->service);
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ transport_name = core->record.transport;
+ if (transport_name[0] == '\0' && session->transport != NULL) {
+ transport_name = session->transport->name;
+ }
+ if (transport_name == NULL || transport_name[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->record.session_id != 0) {
+ hybbx_storage_session_end(storage, core->record.session_id);
+ }
+
+ rc = hybbx_storage_session_begin(storage, user, transport_name, &new_record);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ core->user = *user;
+ core->record = new_record;
+ core->guest_expires_at = 0;
+ core->logged_in = 1;
+ core->login_prompt = 0;
+
+ hybbx_log_info("%s@%s login (session %llu)",
+ core->record.username, transport_name,
+ (unsigned long long)core->record.session_id);
+ hybbx_monitor_event(core->service, core->record.username, transport_name,
+ "login");
+
+ session_maybe_announce_login(core);
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_session_tick(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->logged_in) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->bandwidth_disconnect) {
+ return HYBBX_SESSION_END;
+ }
+
+ if (core->conference_invite_pending &&
+ core->conference_invite_deadline > 0) {
+ hybbx_conference_invite_tick(core->service, session);
+ }
+
+ return session_check_guest_expiry(core);
+}
+
+hybbx_result_t hybbx_session_handle_input(hybbx_session_t *session,
+ const uint8_t *data, size_t len)
+{
+ hybbx_session_core_t *core;
+ size_t i;
+ hybbx_result_t expiry_rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !session_accepts_input(core)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->bandwidth_paused) {
+ return HYBBX_OK;
+ }
+
+ if (core->logged_in) {
+ expiry_rc = session_check_guest_expiry(core);
+ if (expiry_rc != HYBBX_OK) {
+ return expiry_rc;
+ }
+ }
+
+ for (i = 0; i < len; i++) {
+ hybbx_result_t rc;
+
+ if (session_filter_escape_byte(core, data[i])) {
+ continue;
+ }
+
+ rc = session_handle_user_byte(core, data[i]);
+
+ if (rc == HYBBX_SESSION_END) {
+ return HYBBX_SESSION_END;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+const char *hybbx_session_display_name(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return "";
+ }
+
+ return hybbx_user_display_name(&core->user);
+}
+
+hybbx_result_t hybbx_session_format_user_at_plugin(const hybbx_session_t *session,
+ char *out, size_t out_len)
+{
+ const hybbx_session_record_t *rec;
+ const char *user;
+ const char *plugin;
+
+ if (session == NULL || out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ user = hybbx_session_display_name(session);
+ if (user == NULL || user[0] == '\0') {
+ user = hybbx_session_username(session);
+ }
+ if (user == NULL || user[0] == '\0') {
+ user = "?";
+ }
+
+ rec = hybbx_session_record(session);
+ plugin = (rec != NULL && rec->transport[0] != '\0') ? rec->transport : NULL;
+ if (plugin == NULL || plugin[0] == '\0') {
+ plugin = (session->transport != NULL && session->transport->name != NULL)
+ ? session->transport->name
+ : "?";
+ }
+
+ snprintf(out, out_len, "%s@%s", user, plugin);
+ return HYBBX_OK;
+}
+
+typedef struct login_announce_ctx {
+ const char *line;
+} login_announce_ctx_t;
+
+static void login_announce_visitor(hybbx_session_t *session, void *userdata)
+{
+ login_announce_ctx_t *ctx = (login_announce_ctx_t *)userdata;
+
+ if (session == NULL || ctx == NULL || ctx->line == NULL) {
+ return;
+ }
+
+ if (!hybbx_session_is_interactive_user(session) ||
+ !hybbx_session_logged_in(session)) {
+ return;
+ }
+
+ hybbx_session_write_line(session, ctx->line);
+}
+
+static void session_maybe_announce_login(hybbx_session_core_t *core)
+{
+ hybbx_service_t *service;
+ char user_at[96];
+ char line[128];
+ login_announce_ctx_t ctx;
+
+ if (core == NULL || core->service == NULL) {
+ return;
+ }
+
+ service = core->service;
+ if (!hybbx_service_login_announce(service)) {
+ return;
+ }
+
+ /* Guests: never announce. Hidden Sysop (invisible-sysop / monitor): skip. */
+ if (hybbx_user_level_is_guest(core->user.level) ||
+ hybbx_session_hidden_from_who(&core->pub)) {
+ return;
+ }
+
+ if (hybbx_session_format_user_at_plugin(&core->pub, user_at,
+ sizeof(user_at)) != HYBBX_OK) {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "*** User login: %s", user_at);
+ ctx.line = line;
+ hybbx_service_visit_sessions(service, login_announce_visitor, &ctx);
+}
+
+const char *hybbx_session_username(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return "";
+ }
+
+ return core->record.username;
+}
+
+uint64_t hybbx_session_id(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->record.session_id;
+}
+
+const hybbx_session_record_t *hybbx_session_record(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return NULL;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return NULL;
+ }
+
+ return &core->record;
+}
+
+hybbx_result_t hybbx_session_set_remote(hybbx_session_t *session,
+ const char *remote)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL || remote == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(core->record.remote, remote, sizeof(core->record.remote));
+ return HYBBX_OK;
+}
+
+hybbx_user_level_t hybbx_session_user_level(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_LEVEL_GUEST;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_LEVEL_GUEST;
+ }
+
+ return core->user.level;
+}
+
+int hybbx_session_is_guest(const hybbx_session_t *session)
+{
+ return hybbx_user_level_is_guest(hybbx_session_user_level(session));
+}
+
+int hybbx_session_is_interactive_user(const hybbx_session_t *session)
+{
+ hybbx_transport_kind_t kind;
+
+ if (session == NULL || session->transport == NULL) {
+ return 0;
+ }
+
+ kind = session->transport->kind;
+ return kind == HYBBX_TRANSPORT_TELNET ||
+ kind == HYBBX_TRANSPORT_SSH ||
+ kind == HYBBX_TRANSPORT_WEBSOCKET;
+}
+
+int hybbx_session_monitor_active(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ return core != NULL && core->monitor_active != 0;
+}
+
+void hybbx_session_set_monitor_active(hybbx_session_t *session, int on)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->monitor_active = on ? 1 : 0;
+}
+
+int hybbx_session_hidden_from_who(const hybbx_session_t *session)
+{
+ if (session == NULL) {
+ return 0;
+ }
+
+ if (!hybbx_user_level_is_sysop(hybbx_session_user_level(session))) {
+ return 0;
+ }
+
+ if (hybbx_monitor_invisible_sysop()) {
+ return 1;
+ }
+
+ return hybbx_session_monitor_active(session) ? 1 : 0;
+}
+
+int hybbx_session_logged_in(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->logged_in != 0;
+}
+
+int hybbx_session_login_prompt(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->login_prompt != 0;
+}
+
+hybbx_session_area_t hybbx_session_area(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_AREA_MAIN;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_AREA_MAIN;
+ }
+
+ return core->area;
+}
+
+const char *hybbx_session_area_name(hybbx_session_area_t area)
+{
+ switch (area) {
+ case HYBBX_AREA_MAIN:
+ return "main";
+ case HYBBX_AREA_MAIL:
+ return "mail";
+ case HYBBX_AREA_CHAT:
+ return "chat";
+ case HYBBX_AREA_CONFERENCE:
+ return "conference";
+ case HYBBX_AREA_PROXYMAIL:
+ return "proxymail";
+ case HYBBX_AREA_PROXYCHAT:
+ return "proxychat";
+ default:
+ return "main";
+ }
+}
+
+hybbx_session_area_t hybbx_session_area_parse(const char *name)
+{
+ if (name == NULL || name[0] == '\0') {
+ return HYBBX_AREA_MAIN;
+ }
+
+ if (session_str_ieq(name, "main")) {
+ return HYBBX_AREA_MAIN;
+ }
+
+ if (session_str_ieq(name, "mail")) {
+ return HYBBX_AREA_MAIL;
+ }
+
+ if (session_str_ieq(name, "chat")) {
+ return HYBBX_AREA_CHAT;
+ }
+
+ if (session_str_ieq(name, "conference")) {
+ return HYBBX_AREA_CONFERENCE;
+ }
+
+ if (session_str_ieq(name, "proxymail")) {
+ return HYBBX_AREA_PROXYMAIL;
+ }
+
+ if (session_str_ieq(name, "proxychat")) {
+ return HYBBX_AREA_PROXYCHAT;
+ }
+
+ return HYBBX_AREA_MAIN;
+}
+
+hybbx_result_t hybbx_session_enter_area(hybbx_session_t *session,
+ hybbx_session_area_t area)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return session_area_push(core, area);
+}
+
+hybbx_result_t hybbx_session_leave_area(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ hybbx_session_area_t leaving;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->area_depth <= 1) {
+ return HYBBX_OK;
+ }
+
+ leaving = core->area_stack[core->area_depth - 1];
+ session_area_clear_state(core, leaving);
+ core->area_depth--;
+ session_area_sync(core);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_session_go_main(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (core->area_depth > 1) {
+ hybbx_session_area_t leaving = core->area_stack[core->area_depth - 1];
+
+ session_area_clear_state(core, leaving);
+ core->area_depth--;
+ }
+
+ core->area_stack[0] = HYBBX_AREA_MAIN;
+ core->area_depth = 1;
+ core->area = HYBBX_AREA_MAIN;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_session_join_chat_channel(hybbx_session_t *session,
+ unsigned channel_index)
+{
+ hybbx_session_core_t *core;
+ const hybbx_chat_config_t *chat;
+ const char *name;
+
+ if (session == NULL || channel_index == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || core->service == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ chat = hybbx_service_get_chat(core->service);
+ name = hybbx_chat_channel_name(chat, channel_index);
+ if (name == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ if (core->area != HYBBX_AREA_CHAT) {
+ hybbx_result_t push_rc = session_area_push(core, HYBBX_AREA_CHAT);
+
+ if (push_rc != HYBBX_OK) {
+ return push_rc;
+ }
+ }
+
+ core->chat_channel = channel_index;
+
+ if (!core->chat_max_notice_shown) {
+ char notice[96];
+
+ snprintf(notice, sizeof(notice), "Max %u chars per message.",
+ chat->message_max);
+ hybbx_session_write_line(session, notice);
+ core->chat_max_notice_shown = 1;
+ }
+
+ return HYBBX_OK;
+}
+
+unsigned hybbx_session_chat_channel(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || core->area != HYBBX_AREA_CHAT) {
+ return 0;
+ }
+
+ return core->chat_channel;
+}
+
+hybbx_service_t *hybbx_session_service(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return NULL;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return NULL;
+ }
+
+ return core->service;
+}
+
+static int session_str_ieq_local(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';
+}
+
+int hybbx_session_conference_may_invite(hybbx_session_t *session,
+ const char *target)
+{
+ hybbx_session_core_t *core;
+ size_t s;
+ size_t k;
+ time_t now;
+ unsigned count;
+
+ if (session == NULL || target == NULL || target[0] == '\0') {
+ return 0;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ now = time(NULL);
+ for (s = 0; s < sizeof(core->conference_invite_rates) /
+ sizeof(core->conference_invite_rates[0]); s++) {
+ if (!session_str_ieq_local(core->conference_invite_rates[s].target,
+ target)) {
+ continue;
+ }
+
+ count = 0;
+ for (k = 0; k < HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET; k++) {
+ time_t sent = core->conference_invite_rates[s].sent_at[k];
+
+ if (sent != 0 &&
+ (now - sent) < (time_t)HYBBX_CONFERENCE_INVITE_WINDOW_SEC) {
+ count++;
+ }
+ }
+
+ return count < HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET;
+ }
+
+ return 1;
+}
+
+void hybbx_session_conference_invite_sent(hybbx_session_t *session,
+ const char *target)
+{
+ hybbx_session_core_t *core;
+ size_t s;
+ size_t slot = (size_t)-1;
+ size_t empty = (size_t)-1;
+ size_t k;
+ time_t now;
+
+ if (session == NULL || target == NULL || target[0] == '\0') {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ now = time(NULL);
+
+ for (s = 0; s < sizeof(core->conference_invite_rates) /
+ sizeof(core->conference_invite_rates[0]); s++) {
+ if (core->conference_invite_rates[s].target[0] == '\0') {
+ if (empty == (size_t)-1) {
+ empty = s;
+ }
+ continue;
+ }
+
+ if (session_str_ieq_local(core->conference_invite_rates[s].target,
+ target)) {
+ slot = s;
+ break;
+ }
+ }
+
+ if (slot == (size_t)-1) {
+ slot = empty;
+ }
+
+ if (slot == (size_t)-1) {
+ slot = 0;
+ }
+
+ if (core->conference_invite_rates[slot].target[0] == '\0') {
+ hybbx_strlcpy(core->conference_invite_rates[slot].target, target,
+ sizeof(core->conference_invite_rates[slot].target));
+ }
+
+ for (k = 0; k < HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET - 1u; k++) {
+ core->conference_invite_rates[slot].sent_at[k] =
+ core->conference_invite_rates[slot].sent_at[k + 1];
+ }
+
+ core->conference_invite_rates[slot].sent_at[
+ HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET - 1u] = now;
+}
+
+void hybbx_session_set_conference_invite(hybbx_session_t *session,
+ const char *from_username,
+ const char *topic)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL || from_username == NULL || topic == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->conference_invite_pending = 1;
+ core->conference_invite_deadline = 0;
+ hybbx_strlcpy(core->conference_invite_from, from_username,
+ sizeof(core->conference_invite_from));
+ hybbx_strlcpy(core->conference_invite_topic, topic,
+ sizeof(core->conference_invite_topic));
+}
+
+void hybbx_session_set_conference_invite_deadline(hybbx_session_t *session,
+ time_t deadline)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->conference_invite_deadline = deadline;
+}
+
+time_t hybbx_session_conference_invite_deadline(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->conference_invite_pending) {
+ return 0;
+ }
+
+ return core->conference_invite_deadline;
+}
+
+void hybbx_session_clear_conference_invite(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->conference_invite_pending = 0;
+ core->conference_invite_deadline = 0;
+ core->conference_invite_from[0] = '\0';
+ core->conference_invite_topic[0] = '\0';
+}
+
+int hybbx_conference_invite_pending(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->conference_invite_pending != 0;
+}
+
+const char *hybbx_session_conference_invite_from(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return NULL;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->conference_invite_pending) {
+ return NULL;
+ }
+
+ return core->conference_invite_from;
+}
+
+const char *hybbx_session_conference_invite_topic(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return NULL;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->conference_invite_pending) {
+ return NULL;
+ }
+
+ return core->conference_invite_topic;
+}
+
+hybbx_result_t hybbx_session_join_conference(hybbx_session_t *session,
+ const char *topic,
+ const char *partner_username)
+{
+ hybbx_session_core_t *core;
+ const hybbx_chat_config_t *chat;
+
+ if (session == NULL || topic == NULL || partner_username == NULL ||
+ topic[0] == '\0' || partner_username[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || core->service == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->area != HYBBX_AREA_CONFERENCE) {
+ hybbx_result_t push_rc = session_area_push(core, HYBBX_AREA_CONFERENCE);
+
+ if (push_rc != HYBBX_OK) {
+ return push_rc;
+ }
+ }
+
+ hybbx_strlcpy(core->conference_topic, topic, sizeof(core->conference_topic));
+ hybbx_strlcpy(core->conference_partner, partner_username,
+ sizeof(core->conference_partner));
+
+ chat = hybbx_service_get_chat(core->service);
+ if (chat != NULL && !core->chat_max_notice_shown) {
+ char notice[96];
+
+ snprintf(notice, sizeof(notice), "Max %u chars per message.",
+ chat->message_max);
+ hybbx_session_write_line(session, notice);
+ core->chat_max_notice_shown = 1;
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_session_clear_conference(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->conference_topic[0] = '\0';
+ core->conference_partner[0] = '\0';
+}
+
+const char *hybbx_session_conference_partner(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return NULL;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || core->conference_partner[0] == '\0') {
+ return NULL;
+ }
+
+ return core->conference_partner;
+}
+
+int hybbx_session_mail_composing(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->mail_composing;
+}
+
+hybbx_result_t hybbx_session_mail_compose_start(hybbx_session_t *session,
+ const char *to_user,
+ const char *subject)
+{
+ hybbx_session_core_t *core;
+ const hybbx_mail_config_t *mail;
+ hybbx_user_record_t recipient;
+ hybbx_storage_t *storage;
+ size_t subject_len;
+
+ if (session == NULL || to_user == NULL || subject == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mail = hybbx_service_get_mail(core->service);
+ subject_len = strlen(subject);
+ if (mail != NULL && subject_len > mail->subject_max) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ storage = hybbx_service_get_storage(core->service);
+ if (storage != NULL &&
+ hybbx_storage_resolve_user(storage, to_user, &recipient) == HYBBX_OK) {
+ hybbx_strlcpy(core->mail_compose_to, recipient.username,
+ sizeof(core->mail_compose_to));
+ } else {
+ hybbx_strlcpy(core->mail_compose_to, to_user,
+ sizeof(core->mail_compose_to));
+ hybbx_username_normalize(core->mail_compose_to);
+ }
+ hybbx_strlcpy(core->mail_compose_subject, subject,
+ sizeof(core->mail_compose_subject));
+ core->mail_compose_body[0] = '\0';
+ core->mail_compose_body_len = 0;
+ core->mail_composing = 1;
+
+ if (core->area != HYBBX_AREA_MAIL) {
+ return session_area_push(core, HYBBX_AREA_MAIL);
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_session_mail_compose_cancel(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->mail_composing = 0;
+ core->mail_compose_body[0] = '\0';
+ core->mail_compose_body_len = 0;
+ core->mail_compose_to[0] = '\0';
+ core->mail_compose_subject[0] = '\0';
+}
+
+const char *hybbx_session_mail_compose_body(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->mail_composing) {
+ return "";
+ }
+
+ return core->mail_compose_body;
+}
+
+const char *hybbx_session_mail_compose_to(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->mail_composing) {
+ return "";
+ }
+
+ return core->mail_compose_to;
+}
+
+const char *hybbx_session_mail_compose_subject(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->mail_composing) {
+ return "";
+ }
+
+ return core->mail_compose_subject;
+}
+
+hybbx_result_t hybbx_session_enter_mail(hybbx_session_t *session)
+{
+ return hybbx_session_enter_area(session, HYBBX_AREA_MAIL);
+}
+
+hybbx_result_t hybbx_session_enter_chat(hybbx_session_t *session)
+{
+ return hybbx_session_enter_area(session, HYBBX_AREA_CHAT);
+}
+
+hybbx_result_t hybbx_session_enter_proxymail(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ hybbx_result_t rc;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->area != HYBBX_AREA_MAIL &&
+ core->area != HYBBX_AREA_PROXYMAIL) {
+ rc = session_area_push(core, HYBBX_AREA_MAIL);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return session_area_push(core, HYBBX_AREA_PROXYMAIL);
+}
+
+hybbx_result_t hybbx_session_enter_proxychat(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+ hybbx_result_t rc;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (core->area != HYBBX_AREA_CHAT &&
+ core->area != HYBBX_AREA_PROXYCHAT) {
+ rc = session_area_push(core, HYBBX_AREA_CHAT);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return session_area_push(core, HYBBX_AREA_PROXYCHAT);
+}
+
+int hybbx_session_proxymail_composing(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->proxymail_composing;
+}
+
+hybbx_result_t hybbx_session_proxymail_compose_start(hybbx_session_t *session,
+ const char *to_address,
+ const char *subject)
+{
+ hybbx_session_core_t *core;
+ const hybbx_mail_config_t *mail;
+ size_t subject_len;
+
+ if (session == NULL || to_address == NULL || subject == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_proxymail_parse_address(to_address, NULL, 0, NULL, 0)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ mail = hybbx_service_get_mail(core->service);
+ subject_len = strlen(subject);
+ if (mail != NULL && subject_len > mail->subject_max) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(core->proxymail_compose_to, to_address,
+ sizeof(core->proxymail_compose_to));
+ hybbx_strlcpy(core->proxymail_compose_subject, subject,
+ sizeof(core->proxymail_compose_subject));
+ core->proxymail_compose_body[0] = '\0';
+ core->proxymail_compose_body_len = 0;
+ core->proxymail_composing = 1;
+
+ return hybbx_session_enter_proxymail(session);
+}
+
+void hybbx_session_proxymail_compose_cancel(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->proxymail_composing = 0;
+ core->proxymail_compose_body[0] = '\0';
+ core->proxymail_compose_body_len = 0;
+ core->proxymail_compose_to[0] = '\0';
+ core->proxymail_compose_subject[0] = '\0';
+}
+
+const char *hybbx_session_proxymail_compose_body(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->proxymail_composing) {
+ return "";
+ }
+
+ return core->proxymail_compose_body;
+}
+
+const char *hybbx_session_proxymail_compose_to(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->proxymail_composing) {
+ return "";
+ }
+
+ return core->proxymail_compose_to;
+}
+
+const char *hybbx_session_proxymail_compose_subject(
+ const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return "";
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->proxymail_composing) {
+ return "";
+ }
+
+ return core->proxymail_compose_subject;
+}
+
+time_t hybbx_session_connected_at(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->record.connected_at;
+}
+
+int hybbx_session_bandwidth_paused(const hybbx_session_t *session)
+{
+ const hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return 0;
+ }
+
+ core = (const hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return 0;
+ }
+
+ return core->bandwidth_paused;
+}
+
+void hybbx_session_set_bandwidth_paused(hybbx_session_t *session, int paused)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL) {
+ return;
+ }
+
+ core->bandwidth_paused = paused ? 1 : 0;
+}
+
+void hybbx_session_disconnect_bandwidth(hybbx_session_t *session)
+{
+ hybbx_session_core_t *core;
+
+ if (session == NULL) {
+ return;
+ }
+
+ core = (hybbx_session_core_t *)session->core_data;
+ if (core == NULL || !core->logged_in) {
+ return;
+ }
+
+ core->bandwidth_paused = 0;
+ (void)hybbx_traffic_emit(session, &core->out_col,
+ HYBBX_BANDWIDTH_DISCONNECT_MSG,
+ strlen(HYBBX_BANDWIDTH_DISCONNECT_MSG));
+ (void)hybbx_traffic_emit(session, &core->out_col, "\n", 1);
+ core->bandwidth_disconnect = 1;
+}
diff --git a/src/core/storage.c b/src/core/storage.c
new file mode 100644
index 0000000..8217dd9
--- /dev/null
+++ b/src/core/storage.c
@@ -0,0 +1,378 @@
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/config.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "storage_private.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+#define HYBBX_SQL_DEFAULT_USER_DB "users.db"
+#define HYBBX_SQL_DEFAULT_MAIL_DB "mail.db"
+
+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;
+}
+
+void hybbx_storage_sql_config_defaults(hybbx_storage_sql_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ cfg->user_db[0] = '\0';
+ cfg->mail_db[0] = '\0';
+ cfg->backup_interval_sec = HYBBX_STORAGE_BACKUP_INTERVAL_DEFAULT_SEC;
+ cfg->backup_path[0] = '\0';
+}
+
+void hybbx_storage_sql_config_apply(hybbx_storage_sql_config_t *cfg,
+ const hybbx_config_t *config,
+ const char *storage_path)
+{
+ const char *user_db;
+ const char *mail_db;
+ const char *backup_path;
+
+ hybbx_storage_sql_config_defaults(cfg);
+ if (storage_path == NULL || storage_path[0] == '\0') {
+ return;
+ }
+
+ user_db = HYBBX_SQL_DEFAULT_USER_DB;
+ mail_db = HYBBX_SQL_DEFAULT_MAIL_DB;
+ backup_path = "";
+
+ if (config != NULL) {
+ user_db = hybbx_config_get(config, "storage", "user_db",
+ HYBBX_SQL_DEFAULT_USER_DB);
+ mail_db = hybbx_config_get(config, "storage", "mail_db",
+ HYBBX_SQL_DEFAULT_MAIL_DB);
+ backup_path = hybbx_config_get(config, "storage", "backup_path", "");
+ cfg->backup_interval_sec = hybbx_config_get_uint(
+ config, "storage", "backup_interval",
+ HYBBX_STORAGE_BACKUP_INTERVAL_DEFAULT_SEC, 60u, 86400u);
+ }
+
+ (void)hybbx_path_join(cfg->user_db, sizeof(cfg->user_db), storage_path,
+ user_db);
+ (void)hybbx_path_join(cfg->mail_db, sizeof(cfg->mail_db), storage_path,
+ mail_db);
+
+ if (backup_path != NULL && backup_path[0] != '\0') {
+ char resolved[HYBBX_PATH_MAX];
+
+ if (hybbx_path_resolve(resolved, sizeof(resolved), backup_path) ==
+ HYBBX_OK) {
+ hybbx_strlcpy(cfg->backup_path, resolved, sizeof(cfg->backup_path));
+ } else {
+ (void)hybbx_path_join(cfg->backup_path, sizeof(cfg->backup_path),
+ storage_path, backup_path);
+ }
+ }
+}
+
+const hybbx_storage_sql_config_t *hybbx_storage_sql_config(
+ const hybbx_storage_t *storage)
+{
+ if (storage == NULL) {
+ return NULL;
+ }
+
+ return &storage->sql_cfg;
+}
+
+void hybbx_storage_backup_tick(hybbx_storage_t *storage)
+{
+ hybbx_storage_sql_backup_tick(storage);
+}
+
+static void storage_dispatch_sqlite(hybbx_storage_t *storage,
+ const hybbx_storage_sql_config_t *sql_cfg)
+{
+ hybbx_storage_sql_config_defaults(&storage->sql_cfg);
+ if (sql_cfg != NULL) {
+ storage->sql_cfg = *sql_cfg;
+ } else if (storage->path != NULL) {
+ char user_db[HYBBX_PATH_MAX];
+ char mail_db[HYBBX_PATH_MAX];
+
+ (void)hybbx_path_join(user_db, sizeof(user_db), storage->path,
+ HYBBX_SQL_DEFAULT_USER_DB);
+ (void)hybbx_path_join(mail_db, sizeof(mail_db), storage->path,
+ HYBBX_SQL_DEFAULT_MAIL_DB);
+ hybbx_strlcpy(storage->sql_cfg.user_db, user_db,
+ sizeof(storage->sql_cfg.user_db));
+ hybbx_strlcpy(storage->sql_cfg.mail_db, mail_db,
+ sizeof(storage->sql_cfg.mail_db));
+ }
+}
+
+hybbx_storage_t *hybbx_storage_open(const hybbx_storage_options_t *options)
+{
+ hybbx_storage_t *storage;
+ hybbx_result_t rc;
+
+ if (options == NULL || options->path == NULL) {
+ return NULL;
+ }
+
+ storage = calloc(1, sizeof(*storage));
+ if (storage == NULL) {
+ return NULL;
+ }
+
+ storage->backend = options->backend;
+ storage->path = hybbx_strdup(options->path);
+ if (storage->path == NULL) {
+ free(storage);
+ return NULL;
+ }
+
+ if (options->guest_prefix != NULL && options->guest_prefix[0] != '\0') {
+ hybbx_strlcpy(storage->guest_prefix, options->guest_prefix,
+ sizeof(storage->guest_prefix));
+ } else {
+ hybbx_strlcpy(storage->guest_prefix, HYBBX_AUTH_DEFAULT_GUEST_PREFIX,
+ sizeof(storage->guest_prefix));
+ }
+
+ if (storage->backend == HYBBX_STORAGE_SQLITE) {
+ storage_dispatch_sqlite(storage, options->sql_cfg);
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ rc = hybbx_storage_flatfile_open(storage);
+ break;
+ case HYBBX_STORAGE_SQLITE:
+ case HYBBX_STORAGE_MYSQL:
+ case HYBBX_STORAGE_MARIADB:
+ rc = hybbx_storage_sql_open(storage);
+ break;
+ default:
+ rc = HYBBX_ERR_UNSUPPORTED;
+ break;
+ }
+
+ if (rc != HYBBX_OK) {
+ hybbx_storage_close(storage);
+ return NULL;
+ }
+
+ return storage;
+}
+
+void hybbx_storage_close(hybbx_storage_t *storage)
+{
+ if (storage == NULL) {
+ return;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ hybbx_storage_flatfile_close(storage);
+ break;
+ case HYBBX_STORAGE_SQLITE:
+ case HYBBX_STORAGE_MYSQL:
+ case HYBBX_STORAGE_MARIADB:
+ hybbx_storage_sql_close(storage);
+ break;
+ default:
+ break;
+ }
+
+ free(storage->path);
+ free(storage);
+}
+
+hybbx_storage_backend_kind_t hybbx_storage_backend(const hybbx_storage_t *storage)
+{
+ if (storage == NULL) {
+ return HYBBX_STORAGE_FLATFILE;
+ }
+ return storage->backend;
+}
+
+const char *hybbx_storage_root_path(const hybbx_storage_t *storage)
+{
+ if (storage == NULL || storage->path == NULL || storage->path[0] == '\0') {
+ return NULL;
+ }
+ return storage->path;
+}
+
+hybbx_result_t hybbx_storage_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out)
+{
+ if (storage == NULL || reg == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_register_user(storage, reg, out);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_register_user(storage, reg, out);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out)
+{
+ if (storage == NULL || username == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_find_user(storage, username, out);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_find_user(storage, username, out);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out)
+{
+ if (storage == NULL || name == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_resolve_user(storage, name, out);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_resolve_user(storage, name, out);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count)
+{
+ if (storage == NULL || count == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_count_level(storage, level, count);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_count_level(storage, level, count);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx)
+{
+ if (storage == NULL || fn == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_foreach_user(storage, fn, ctx);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_foreach_user(storage, fn, ctx);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out)
+{
+ if (storage == NULL || user == NULL || transport == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_session_begin(storage, user, transport, out);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_session_begin(storage, user, transport, out);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_session_end(hybbx_storage_t *storage,
+ uint64_t session_id)
+{
+ if (storage == NULL || session_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_session_end(storage, session_id);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_session_end(storage, session_id);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user)
+{
+ if (storage == NULL || user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_update_user(storage, user);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_update_user(storage, user);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
+
+hybbx_result_t hybbx_storage_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id)
+{
+ if (storage == NULL || user_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (storage->backend) {
+ case HYBBX_STORAGE_FLATFILE:
+ return hybbx_storage_flatfile_delete_user(storage, user_id);
+ case HYBBX_STORAGE_SQLITE:
+ return hybbx_storage_sql_delete_user(storage, user_id);
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+}
diff --git a/src/core/storage_flatfile.c b/src/core/storage_flatfile.c
new file mode 100644
index 0000000..4915da6
--- /dev/null
+++ b/src/core/storage_flatfile.c
@@ -0,0 +1,1507 @@
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/password.h"
+#include "hybbx/security.h"
+#include "hybbx/config.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+#include "hybbx/log.h"
+#include "storage_private.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <time.h>
+
+struct flatfile_state {
+ char users_dir[HYBBX_PATH_MAX];
+ char legacy_users_path[HYBBX_PATH_MAX];
+ char sessions_path[HYBBX_PATH_MAX];
+ char session_next_path[HYBBX_PATH_MAX];
+ char user_next_path[HYBBX_PATH_MAX];
+};
+
+typedef struct user_shard {
+ hybbx_user_record_t users[HYBBX_USERS_PER_FILE];
+ size_t count;
+} user_shard_t;
+
+static int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ len = strlen(path);
+ if (len >= sizeof(buf)) {
+ return -1;
+ }
+
+ memcpy(buf, path, len + 1);
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] == '/') {
+ buf[i] = '\0';
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static hybbx_result_t read_counter(const char *path, uint64_t *value)
+{
+ FILE *fp;
+ unsigned long long n = 0;
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ *value = 0;
+ return HYBBX_OK;
+ }
+
+ if (fscanf(fp, "%llu", &n) != 1) {
+ fclose(fp);
+ return HYBBX_ERR_IO;
+ }
+
+ fclose(fp);
+ *value = (uint64_t)n;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t write_counter(const char *path, uint64_t value)
+{
+ FILE *fp;
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "%llu\n", (unsigned long long)value);
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t bump_counter(const char *path, uint64_t *value)
+{
+ hybbx_result_t rc;
+
+ rc = read_counter(path, value);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (*value)++;
+ return write_counter(path, *value);
+}
+
+static hybbx_result_t append_line(const char *path, const char *line)
+{
+ FILE *fp;
+
+ fp = fopen(path, "a");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "%s\n", line);
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+#define HYBBX_USER_NICK_SUFFIX ".nick"
+
+static hybbx_result_t user_nickname_path(const struct flatfile_state *state,
+ const char *username,
+ char *out, size_t out_len)
+{
+ char name[HYBBX_USER_NAME_MAX + 8];
+ int n;
+
+ if (state == NULL || username == NULL || username[0] == '\0' ||
+ out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = snprintf(name, sizeof(name), "%s%s", username, HYBBX_USER_NICK_SUFFIX);
+ if (n < 0 || (size_t)n >= sizeof(name)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, state->users_dir, name);
+}
+
+static hybbx_result_t read_nickname_file(const struct flatfile_state *state,
+ const char *username,
+ char *nickname,
+ size_t nickname_len)
+{
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ char line[HYBBX_USER_NICKNAME_MAX];
+ char *nl;
+ hybbx_result_t rc;
+
+ if (nickname == NULL || nickname_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ nickname[0] = '\0';
+
+ rc = user_nickname_path(state, username, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ if (fgets(line, sizeof(line), fp) == NULL) {
+ fclose(fp);
+ return HYBBX_ERR_IO;
+ }
+
+ fclose(fp);
+
+ nl = strchr(line, '\n');
+ if (nl != NULL) {
+ *nl = '\0';
+ }
+ nl = strchr(line, '\r');
+ if (nl != NULL) {
+ *nl = '\0';
+ }
+
+ if (line[0] == '\0') {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ hybbx_strlcpy(nickname, line, nickname_len);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t write_nickname_file(const struct flatfile_state *state,
+ const char *username,
+ const char *nickname)
+{
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ hybbx_result_t rc;
+
+ if (state == NULL || username == NULL || username[0] == '\0' ||
+ nickname == NULL || nickname[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = user_nickname_path(state, username, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "%s\n", nickname);
+ if (fclose(fp) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void remove_nickname_file(const struct flatfile_state *state,
+ const char *username)
+{
+ char path[HYBBX_PATH_MAX];
+
+ if (state == NULL || username == NULL || username[0] == '\0') {
+ return;
+ }
+
+ if (user_nickname_path(state, username, path, sizeof(path)) == HYBBX_OK) {
+ (void)remove(path);
+ }
+}
+
+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 int file_exists(const char *path)
+{
+ struct stat st;
+
+ return path != NULL && stat(path, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static hybbx_result_t user_shard_path(const struct flatfile_state *state,
+ unsigned shard_index,
+ char *out, size_t out_len)
+{
+ char name[32];
+ int n;
+
+ if (state == NULL || out == NULL || out_len == 0 || shard_index == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (shard_index == 1) {
+ n = snprintf(name, sizeof(name), "users.ini");
+ } else {
+ n = snprintf(name, sizeof(name), "users%u.ini", shard_index);
+ }
+
+ if (n < 0 || (size_t)n >= sizeof(name)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, state->users_dir, name);
+}
+
+static unsigned last_user_shard_index(const struct flatfile_state *state)
+{
+ char path[HYBBX_PATH_MAX];
+ unsigned index;
+
+ for (index = 1; index < 10000u; index++) {
+ if (user_shard_path(state, index, path, sizeof(path)) != HYBBX_OK) {
+ break;
+ }
+ if (!file_exists(path)) {
+ break;
+ }
+ }
+
+ if (index == 1) {
+ return 0;
+ }
+
+ return index - 1;
+}
+
+static int parse_legacy_user_line(const char *line, hybbx_user_record_t *out)
+{
+ unsigned long long id = 0;
+ unsigned long long active = 0;
+ long long created = 0;
+ char username[HYBBX_USER_NAME_MAX];
+ char level_name[32];
+ char full_name[HYBBX_USER_FULL_NAME_MAX];
+ char country[HYBBX_USER_COUNTRY_MAX];
+ char location[HYBBX_USER_LOCATION_MAX];
+ char email[HYBBX_USER_EMAIL_MAX];
+ char password[HYBBX_USER_PASSWORD_MAX];
+ int fields;
+
+ if (line == NULL || out == NULL) {
+ return 0;
+ }
+
+ full_name[0] = '\0';
+ country[0] = '\0';
+ location[0] = '\0';
+ email[0] = '\0';
+ password[0] = '\0';
+
+ fields = sscanf(line,
+ "%llu|%63[^|]|%31[^|]|%llu|%lld|"
+ "%127[^|]|%63[^|]|%127[^|]|%127[^|]|%95[^|]",
+ &id, username, level_name, &active, &created,
+ full_name, country, location, email, password);
+ if (fields < 5) {
+ return 0;
+ }
+
+ memset(out, 0, sizeof(*out));
+ out->id = (uint64_t)id;
+ hybbx_strlcpy(out->username, username, sizeof(out->username));
+ out->level = hybbx_user_level_parse(level_name);
+ out->active = (int)active;
+ out->created_at = (time_t)created;
+
+ if (fields >= 9) {
+ hybbx_strlcpy(out->full_name, full_name, sizeof(out->full_name));
+ hybbx_strlcpy(out->country, country, sizeof(out->country));
+ hybbx_strlcpy(out->location, location, sizeof(out->location));
+ hybbx_strlcpy(out->email, email, sizeof(out->email));
+ }
+
+ if (fields >= 10) {
+ hybbx_strlcpy(out->password, password, sizeof(out->password));
+ }
+
+ return 1;
+}
+
+static int parse_user_section(const hybbx_config_t *cfg, const char *section,
+ hybbx_user_record_t *out)
+{
+ const char *value;
+ unsigned long long n;
+
+ if (cfg == NULL || section == NULL || out == NULL) {
+ return 0;
+ }
+
+ if (strncmp(section, HYBBX_USER_INI_SECTION_PREFIX,
+ strlen(HYBBX_USER_INI_SECTION_PREFIX)) != 0) {
+ return 0;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ value = hybbx_config_get(cfg, section, "id", NULL);
+ if (value == NULL || value[0] == '\0') {
+ return 0;
+ }
+ n = strtoull(value, NULL, 10);
+ if (n == 0) {
+ return 0;
+ }
+ out->id = (uint64_t)n;
+
+ value = hybbx_config_get(cfg, section, "username", NULL);
+ if (value == NULL || value[0] == '\0') {
+ return 0;
+ }
+ hybbx_strlcpy(out->username, value, sizeof(out->username));
+ hybbx_username_normalize(out->username);
+
+ value = hybbx_config_get(cfg, section, "nickname", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->nickname, value, sizeof(out->nickname));
+ }
+
+ value = hybbx_config_get(cfg, section, "level", "user");
+ out->level = hybbx_user_level_parse(value);
+ out->active = hybbx_config_get_bool(cfg, section, "active", 0);
+
+ value = hybbx_config_get(cfg, section, "created", NULL);
+ if (value != NULL && value[0] != '\0') {
+ out->created_at = (time_t)strtoll(value, NULL, 10);
+ }
+
+ value = hybbx_config_get(cfg, section, "fullname", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->full_name, value, sizeof(out->full_name));
+ }
+
+ value = hybbx_config_get(cfg, section, "country", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->country, value, sizeof(out->country));
+ }
+
+ value = hybbx_config_get(cfg, section, "location", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->location, value, sizeof(out->location));
+ }
+
+ value = hybbx_config_get(cfg, section, "email", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->email, value, sizeof(out->email));
+ }
+
+ value = hybbx_config_get(cfg, section, "password", NULL);
+ if (value != NULL) {
+ hybbx_strlcpy(out->password, value, sizeof(out->password));
+ }
+
+ value = hybbx_config_get(cfg, section, "last_login", NULL);
+ if (value != NULL && value[0] != '\0') {
+ out->last_login_at = (time_t)strtoll(value, NULL, 10);
+ }
+
+ return 1;
+}
+
+typedef struct section_list_ctx {
+ char names[HYBBX_USERS_PER_FILE][HYBBX_CONFIG_SECTION_MAX];
+ size_t count;
+} section_list_ctx_t;
+
+static void section_list_collect(const char *section, const char *key,
+ const char *value, void *ctx)
+{
+ section_list_ctx_t *list = (section_list_ctx_t *)ctx;
+ size_t i;
+
+ (void)key;
+ (void)value;
+
+ if (section == NULL || list == NULL) {
+ return;
+ }
+
+ if (strncmp(section, HYBBX_USER_INI_SECTION_PREFIX,
+ strlen(HYBBX_USER_INI_SECTION_PREFIX)) != 0) {
+ return;
+ }
+
+ for (i = 0; i < list->count; i++) {
+ if (strcmp(list->names[i], section) == 0) {
+ return;
+ }
+ }
+
+ if (list->count >= HYBBX_USERS_PER_FILE) {
+ return;
+ }
+
+ hybbx_strlcpy(list->names[list->count], section,
+ sizeof(list->names[list->count]));
+ list->count++;
+}
+
+static hybbx_result_t load_user_shard(const char *path, user_shard_t *shard)
+{
+ hybbx_config_t cfg;
+ section_list_ctx_t sections;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (path == NULL || shard == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(shard, 0, sizeof(*shard));
+ memset(&sections, 0, sizeof(sections));
+
+ rc = hybbx_config_load(&cfg, path);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_config_foreach(&cfg, section_list_collect, &sections);
+
+ for (i = 0; i < sections.count; i++) {
+ hybbx_user_record_t user;
+
+ if (!parse_user_section(&cfg, sections.names[i], &user)) {
+ continue;
+ }
+
+ if (shard->count >= HYBBX_USERS_PER_FILE) {
+ hybbx_config_free(&cfg);
+ return HYBBX_ERR_IO;
+ }
+
+ shard->users[shard->count++] = user;
+ }
+
+ hybbx_config_free(&cfg);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t save_user_shard(const struct flatfile_state *state,
+ unsigned shard_index,
+ const user_shard_t *shard)
+{
+ char path[HYBBX_PATH_MAX];
+ FILE *fp;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (state == NULL || shard == NULL || shard->count > HYBBX_USERS_PER_FILE) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = user_shard_path(state, shard_index, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ fprintf(fp, "; HyBBX user file (max %u users)\n",
+ (unsigned)HYBBX_USERS_PER_FILE);
+ fprintf(fp, "; shard %u\n\n", shard_index);
+
+ for (i = 0; i < shard->count; i++) {
+ const hybbx_user_record_t *user = &shard->users[i];
+
+ fprintf(fp, "[user.%llu]\n", (unsigned long long)user->id);
+ fprintf(fp, "id = %llu\n", (unsigned long long)user->id);
+ fprintf(fp, "username = %s\n", user->username);
+ fprintf(fp, "nickname = %s\n", user->nickname);
+ fprintf(fp, "level = %s\n", hybbx_user_level_name(user->level));
+ fprintf(fp, "active = %s\n", hybbx_bool_to_string(user->active));
+ fprintf(fp, "created = %lld\n", (long long)user->created_at);
+ fprintf(fp, "fullname = %s\n", user->full_name);
+ fprintf(fp, "country = %s\n", user->country);
+ fprintf(fp, "location = %s\n", user->location);
+ fprintf(fp, "email = %s\n", user->email);
+ fprintf(fp, "password = %s\n", user->password);
+ fprintf(fp, "last_login = %lld\n", (long long)user->last_login_at);
+
+ if (user->nickname[0] != '\0') {
+ (void)write_nickname_file(state, user->username, user->nickname);
+ }
+ }
+
+ if (fclose(fp) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t find_user_shard(const struct flatfile_state *state,
+ uint64_t user_id,
+ unsigned *shard_index,
+ size_t *slot_index)
+{
+ unsigned shard;
+ unsigned last;
+ char path[HYBBX_PATH_MAX];
+ user_shard_t loaded;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (state == NULL || user_id == 0 || shard_index == NULL ||
+ slot_index == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ last = last_user_shard_index(state);
+
+ for (shard = 1; shard <= last; shard++) {
+ if (user_shard_path(state, shard, path, sizeof(path)) != HYBBX_OK) {
+ continue;
+ }
+ if (!file_exists(path)) {
+ continue;
+ }
+
+ rc = load_user_shard(path, &loaded);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ for (i = 0; i < loaded.count; i++) {
+ if (loaded.users[i].id == user_id) {
+ *shard_index = shard;
+ *slot_index = i;
+ return HYBBX_OK;
+ }
+ }
+ }
+
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+static hybbx_result_t append_user_record(struct flatfile_state *state,
+ const hybbx_user_record_t *user)
+{
+ unsigned shard;
+ unsigned last;
+ char path[HYBBX_PATH_MAX];
+ user_shard_t loaded;
+ hybbx_result_t rc;
+
+ if (state == NULL || user == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ last = last_user_shard_index(state);
+ if (last == 0) {
+ shard = 1;
+ memset(&loaded, 0, sizeof(loaded));
+ } else {
+ rc = user_shard_path(state, last, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = load_user_shard(path, &loaded);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (loaded.count >= HYBBX_USERS_PER_FILE) {
+ shard = last + 1;
+ memset(&loaded, 0, sizeof(loaded));
+ } else {
+ shard = last;
+ }
+ }
+
+ if (loaded.count >= HYBBX_USERS_PER_FILE) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ loaded.users[loaded.count++] = *user;
+ return save_user_shard(state, shard, &loaded);
+}
+
+static hybbx_result_t ensure_default_sysop(hybbx_storage_t *storage)
+{
+ struct flatfile_state *state;
+ size_t sysop_count = 0;
+ hybbx_user_record_t sysop;
+ char plain_password[HYBBX_PASSWORD_MAX_LEN + 1];
+ uint64_t user_id;
+ time_t now = time(NULL);
+ hybbx_result_t rc;
+
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_flatfile_count_level(storage, HYBBX_LEVEL_SYSOP,
+ &sysop_count);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (sysop_count > 0) {
+ return HYBBX_OK;
+ }
+
+ rc = bump_counter(state->user_next_path, &user_id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ memset(&sysop, 0, sizeof(sysop));
+ sysop.id = user_id;
+ hybbx_strlcpy(sysop.username, "sysop", sizeof(sysop.username));
+ hybbx_strlcpy(sysop.nickname, HYBBX_DEFAULT_SYSOP_USERNAME,
+ sizeof(sysop.nickname));
+ sysop.level = HYBBX_LEVEL_SYSOP;
+ sysop.active = 1;
+ sysop.created_at = now;
+ hybbx_strlcpy(sysop.full_name, "System Operator", sizeof(sysop.full_name));
+ if (hybbx_password_generate_alnum(plain_password, sizeof(plain_password),
+ HYBBX_SYSOP_INIT_PASSWORD_MIN,
+ HYBBX_SYSOP_INIT_PASSWORD_MAX) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (hybbx_password_hash(plain_password,
+ sysop.password, sizeof(sysop.password)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = append_user_record(state, &sysop);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ hybbx_log_info("[storage] created default Sysop at %s/users/users.ini (login: %s / %s)",
+ storage->path, sysop.nickname, plain_password);
+ hybbx_security_log_write("sysop_created user=%s", sysop.nickname);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t foreach_user(struct flatfile_state *state,
+ hybbx_result_t (*fn)(const hybbx_user_record_t *user,
+ void *ctx),
+ void *ctx)
+{
+ unsigned shard;
+ unsigned last;
+ char path[HYBBX_PATH_MAX];
+ user_shard_t loaded;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (state == NULL || fn == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ last = last_user_shard_index(state);
+ for (shard = 1; shard <= last; shard++) {
+ rc = user_shard_path(state, shard, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = load_user_shard(path, &loaded);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ for (i = 0; i < loaded.count; i++) {
+ rc = fn(&loaded.users[i], ctx);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+typedef struct find_user_ctx {
+ const char *username;
+ hybbx_user_record_t *out;
+ int found;
+} find_user_ctx_t;
+
+static hybbx_result_t find_user_cb(const hybbx_user_record_t *user, void *ctx)
+{
+ find_user_ctx_t *fctx = (find_user_ctx_t *)ctx;
+
+ if (str_ieq(user->username, fctx->username)) {
+ *fctx->out = *user;
+ fctx->found = 1;
+ return HYBBX_ERR_BUSY;
+ }
+
+ return HYBBX_OK;
+}
+
+typedef struct find_nickname_ctx {
+ const char *nickname;
+ hybbx_user_record_t *out;
+ int found;
+} find_nickname_ctx_t;
+
+static hybbx_result_t find_nickname_cb(const hybbx_user_record_t *user, void *ctx)
+{
+ find_nickname_ctx_t *fctx = (find_nickname_ctx_t *)ctx;
+
+ if (user->nickname[0] != '\0' && str_ieq(user->nickname, fctx->nickname)) {
+ *fctx->out = *user;
+ fctx->found = 1;
+ return HYBBX_ERR_BUSY;
+ }
+
+ return HYBBX_OK;
+}
+
+typedef struct identity_taken_ctx {
+ const char *username;
+ const char *nickname;
+ int taken;
+} identity_taken_ctx_t;
+
+static hybbx_result_t identity_taken_cb(const hybbx_user_record_t *user,
+ void *ctx)
+{
+ identity_taken_ctx_t *ictx = (identity_taken_ctx_t *)ctx;
+
+ if (str_ieq(user->username, ictx->username) ||
+ str_ieq(user->username, ictx->nickname) ||
+ (user->nickname[0] != '\0' &&
+ (str_ieq(user->nickname, ictx->nickname) ||
+ str_ieq(user->nickname, ictx->username)))) {
+ ictx->taken = 1;
+ return HYBBX_ERR_BUSY;
+ }
+
+ return HYBBX_OK;
+}
+
+typedef struct count_level_ctx {
+ hybbx_user_level_t level;
+ size_t count;
+} count_level_ctx_t;
+
+static hybbx_result_t count_level_cb(const hybbx_user_record_t *user, void *ctx)
+{
+ count_level_ctx_t *cctx = (count_level_ctx_t *)ctx;
+
+ if (user->level == cctx->level) {
+ cctx->count++;
+ }
+
+ return HYBBX_OK;
+}
+
+typedef struct migrate_passwords_ctx {
+ hybbx_storage_t *storage;
+ hybbx_result_t last_error;
+} migrate_passwords_ctx_t;
+
+static hybbx_result_t migrate_passwords_cb(const hybbx_user_record_t *user,
+ void *ctx)
+{
+ migrate_passwords_ctx_t *mctx = (migrate_passwords_ctx_t *)ctx;
+ hybbx_user_record_t updated;
+ hybbx_result_t rc;
+
+ if (!hybbx_password_is_plain(user->password)) {
+ return HYBBX_OK;
+ }
+
+ updated = *user;
+ rc = hybbx_password_hash(user->password, updated.password,
+ sizeof(updated.password));
+ if (rc != HYBBX_OK) {
+ mctx->last_error = rc;
+ return rc;
+ }
+
+ rc = hybbx_storage_update_user(mctx->storage, &updated);
+ if (rc != HYBBX_OK) {
+ mctx->last_error = rc;
+ return rc;
+ }
+
+ hybbx_log_info("[storage] upgraded plain password to sha256 for user %s",
+ user->username);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t migrate_legacy_users_dat(hybbx_storage_t *storage)
+{
+ struct flatfile_state *state;
+ FILE *fp;
+ char line[HYBBX_CONFIG_LINE_MAX];
+ user_shard_t shard;
+ unsigned shard_index = 1;
+ hybbx_result_t rc;
+
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!file_exists(state->legacy_users_path)) {
+ return HYBBX_OK;
+ }
+
+ if (last_user_shard_index(state) > 0) {
+ return HYBBX_OK;
+ }
+
+ fp = fopen(state->legacy_users_path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ memset(&shard, 0, sizeof(shard));
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ hybbx_user_record_t user;
+ char *nl;
+
+ nl = strchr(line, '\n');
+ if (nl != NULL) {
+ *nl = '\0';
+ }
+
+ if (!parse_legacy_user_line(line, &user)) {
+ continue;
+ }
+
+ if (shard.count >= HYBBX_USERS_PER_FILE) {
+ rc = save_user_shard(state, shard_index, &shard);
+ if (rc != HYBBX_OK) {
+ fclose(fp);
+ return rc;
+ }
+ shard_index++;
+ memset(&shard, 0, sizeof(shard));
+ }
+
+ shard.users[shard.count++] = user;
+ }
+
+ fclose(fp);
+
+ if (shard.count > 0) {
+ rc = save_user_shard(state, shard_index, &shard);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ char backup[HYBBX_PATH_MAX];
+
+ if (hybbx_path_join(backup, sizeof(backup), storage->path,
+ "users.dat.migrated") == HYBBX_OK) {
+ (void)rename(state->legacy_users_path, backup);
+ }
+ }
+
+ hybbx_log_info("[storage] migrated legacy users.dat to INI user files");
+ return HYBBX_OK;
+}
+
+typedef struct migrate_identities_ctx {
+ hybbx_storage_t *storage;
+ struct flatfile_state *state;
+ hybbx_result_t last_error;
+} migrate_identities_ctx_t;
+
+static hybbx_result_t migrate_identities_cb(const hybbx_user_record_t *user,
+ void *ctx)
+{
+ migrate_identities_ctx_t *mctx = (migrate_identities_ctx_t *)ctx;
+ hybbx_user_record_t updated;
+ char stored_username[HYBBX_USER_NAME_MAX];
+ int changed = 0;
+ hybbx_result_t rc;
+
+ if (user == NULL || mctx == NULL) {
+ return HYBBX_OK;
+ }
+
+ updated = *user;
+ hybbx_strlcpy(stored_username, updated.username, sizeof(stored_username));
+
+ hybbx_username_normalize(updated.username);
+ if (!str_ieq(stored_username, updated.username)) {
+ changed = 1;
+ }
+
+ if (updated.nickname[0] == '\0') {
+ if (read_nickname_file(mctx->state, updated.username, updated.nickname,
+ sizeof(updated.nickname)) != HYBBX_OK) {
+ hybbx_nickname_infer(stored_username, updated.nickname,
+ sizeof(updated.nickname));
+ }
+ changed = 1;
+ }
+
+ if (!changed) {
+ (void)write_nickname_file(mctx->state, updated.username,
+ updated.nickname);
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_storage_flatfile_update_user(mctx->storage, &updated);
+ if (rc != HYBBX_OK) {
+ mctx->last_error = rc;
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t migrate_user_identities(hybbx_storage_t *storage)
+{
+ struct flatfile_state *state;
+ migrate_identities_ctx_t ctx;
+
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(&ctx, 0, sizeof(ctx));
+ ctx.storage = storage;
+ ctx.state = state;
+ ctx.last_error = HYBBX_OK;
+ return foreach_user(state, migrate_identities_cb, &ctx);
+}
+
+static hybbx_result_t migrate_plain_passwords(hybbx_storage_t *storage)
+{
+ struct flatfile_state *state;
+ migrate_passwords_ctx_t ctx;
+
+ if (storage == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.storage = storage;
+ ctx.last_error = HYBBX_OK;
+ return foreach_user(state, migrate_passwords_cb, &ctx);
+}
+
+hybbx_result_t hybbx_storage_flatfile_open(hybbx_storage_t *storage)
+{
+ struct flatfile_state *state;
+ hybbx_result_t rc;
+
+ if (storage == NULL || storage->path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (mkdir_p(storage->path) != 0) {
+ hybbx_log_warn("[storage] cannot create data path '%s'",
+ storage->path);
+ return HYBBX_ERR_IO;
+ }
+
+ state = calloc(1, sizeof(*state));
+ if (state == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = hybbx_path_join(state->users_dir, sizeof(state->users_dir),
+ storage->path, "users");
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ if (mkdir_p(state->users_dir) != 0) {
+ free(state);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_path_join(state->legacy_users_path, sizeof(state->legacy_users_path),
+ storage->path, "users.dat");
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ rc = hybbx_path_join(state->sessions_path, sizeof(state->sessions_path),
+ storage->path, "sessions.dat");
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ rc = hybbx_path_join(state->session_next_path, sizeof(state->session_next_path),
+ storage->path, "session.next");
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ rc = hybbx_path_join(state->user_next_path, sizeof(state->user_next_path),
+ storage->path, "user.next");
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ storage->backend_data = state;
+
+ rc = migrate_legacy_users_dat(storage);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_flatfile_close(storage);
+ return rc;
+ }
+
+ rc = ensure_default_sysop(storage);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_flatfile_close(storage);
+ return rc;
+ }
+
+ rc = migrate_user_identities(storage);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_flatfile_close(storage);
+ return rc;
+ }
+
+ rc = migrate_plain_passwords(storage);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_flatfile_close(storage);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_storage_flatfile_close(hybbx_storage_t *storage)
+{
+ if (storage == NULL) {
+ return;
+ }
+
+ free(storage->backend_data);
+ storage->backend_data = NULL;
+}
+
+hybbx_result_t hybbx_storage_flatfile_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out)
+{
+ struct flatfile_state *state;
+ find_user_ctx_t ctx;
+ hybbx_result_t rc;
+
+ if (storage == NULL || username == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ ctx.username = username;
+ ctx.out = out;
+ ctx.found = 0;
+
+ rc = foreach_user(state, find_user_cb, &ctx);
+ if (rc == HYBBX_ERR_BUSY) {
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+hybbx_result_t hybbx_storage_flatfile_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out)
+{
+ char normalized[HYBBX_USER_NAME_MAX];
+ struct flatfile_state *state;
+ find_nickname_ctx_t nctx;
+ hybbx_result_t rc;
+
+ if (storage == NULL || name == NULL || out == NULL || name[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(normalized, name, sizeof(normalized));
+ hybbx_username_normalize(normalized);
+
+ rc = hybbx_storage_flatfile_find_user(storage, normalized, out);
+ if (rc == HYBBX_OK) {
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_ERR_NOT_FOUND) {
+ return rc;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ nctx.nickname = name;
+ nctx.out = out;
+ nctx.found = 0;
+
+ rc = foreach_user(state, find_nickname_cb, &nctx);
+ if (rc == HYBBX_ERR_BUSY) {
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+hybbx_result_t hybbx_storage_flatfile_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count)
+{
+ struct flatfile_state *state;
+ count_level_ctx_t ctx;
+ hybbx_result_t rc;
+
+ if (storage == NULL || count == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ctx.level = level;
+ ctx.count = 0;
+
+ rc = foreach_user(state, count_level_cb, &ctx);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ *count = ctx.count;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_storage_flatfile_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx)
+{
+ struct flatfile_state *state;
+
+ if (storage == NULL || fn == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return foreach_user(state, fn, ctx);
+}
+
+hybbx_result_t hybbx_storage_flatfile_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out)
+{
+ struct flatfile_state *state;
+ hybbx_user_record_t existing;
+ uint64_t user_id;
+ time_t now = time(NULL);
+ hybbx_result_t rc;
+
+ if (storage == NULL || reg == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_registration_valid(reg, storage->guest_prefix)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_flatfile_find_user(storage, reg->username, &existing);
+ if (rc == HYBBX_OK) {
+ return HYBBX_ERR_BUSY;
+ }
+ if (rc != HYBBX_ERR_NOT_FOUND) {
+ return rc;
+ }
+
+ {
+ identity_taken_ctx_t taken;
+
+ memset(&taken, 0, sizeof(taken));
+ taken.username = reg->username;
+ taken.nickname = reg->nickname;
+
+ rc = foreach_user(state, identity_taken_cb, &taken);
+ if (rc == HYBBX_ERR_BUSY || taken.taken) {
+ return HYBBX_ERR_BUSY;
+ }
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ rc = bump_counter(state->user_next_path, &user_id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ memset(out, 0, sizeof(*out));
+ out->id = user_id;
+ hybbx_strlcpy(out->username, reg->username, sizeof(out->username));
+ hybbx_username_normalize(out->username);
+ hybbx_strlcpy(out->nickname, reg->nickname, sizeof(out->nickname));
+ out->level = HYBBX_LEVEL_USER;
+ out->active = 0;
+ out->created_at = now;
+ hybbx_strlcpy(out->full_name, reg->full_name, sizeof(out->full_name));
+ hybbx_strlcpy(out->country, reg->country, sizeof(out->country));
+ hybbx_strlcpy(out->location, reg->location, sizeof(out->location));
+ hybbx_strlcpy(out->email, reg->email, sizeof(out->email));
+
+ if (reg->password[0] != '\0') {
+ if (!hybbx_password_plain_valid(reg->password)) {
+ return HYBBX_ERR_INVALID;
+ }
+ rc = hybbx_password_hash(reg->password, out->password,
+ sizeof(out->password));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return append_user_record(state, out);
+}
+
+hybbx_result_t hybbx_storage_flatfile_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user)
+{
+ struct flatfile_state *state;
+ unsigned shard_index;
+ size_t slot_index;
+ char path[HYBBX_PATH_MAX];
+ user_shard_t shard;
+ hybbx_result_t rc;
+
+ if (storage == NULL || user == NULL || user->id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = find_user_shard(state, user->id, &shard_index, &slot_index);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = user_shard_path(state, shard_index, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = load_user_shard(path, &shard);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (slot_index >= shard.count) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ shard.users[slot_index] = *user;
+ hybbx_username_normalize(shard.users[slot_index].username);
+ return save_user_shard(state, shard_index, &shard);
+}
+
+hybbx_result_t hybbx_storage_flatfile_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id)
+{
+ struct flatfile_state *state;
+ unsigned shard_index;
+ size_t slot_index;
+ char path[HYBBX_PATH_MAX];
+ user_shard_t shard;
+ hybbx_result_t rc;
+
+ if (storage == NULL || user_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = find_user_shard(state, user_id, &shard_index, &slot_index);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = user_shard_path(state, shard_index, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = load_user_shard(path, &shard);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (slot_index >= shard.count) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ remove_nickname_file(state, shard.users[slot_index].username);
+
+ if (slot_index + 1 < shard.count) {
+ memmove(&shard.users[slot_index], &shard.users[slot_index + 1],
+ (shard.count - slot_index - 1) * sizeof(shard.users[0]));
+ }
+ shard.count--;
+
+ return save_user_shard(state, shard_index, &shard);
+}
+
+hybbx_result_t hybbx_storage_flatfile_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out)
+{
+ struct flatfile_state *state = storage->backend_data;
+ char line[384];
+ time_t now = time(NULL);
+ hybbx_result_t rc;
+
+ if (state == NULL || user == NULL || transport == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ rc = bump_counter(state->session_next_path, &out->session_id);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ out->user_id = user->id;
+ hybbx_strlcpy(out->username, user->username, sizeof(out->username));
+ hybbx_strlcpy(out->transport, transport, sizeof(out->transport));
+ out->connected_at = now;
+ out->active = 1;
+
+ snprintf(line, sizeof(line), "%llu|%llu|%s|%s|%lld|0|1",
+ (unsigned long long)out->session_id,
+ (unsigned long long)out->user_id,
+ out->username, out->transport, (long long)out->connected_at);
+
+ return append_line(state->sessions_path, line);
+}
+
+hybbx_result_t hybbx_storage_flatfile_session_end(hybbx_storage_t *storage,
+ uint64_t session_id)
+{
+ struct flatfile_state *state = storage->backend_data;
+ char line[384];
+ time_t now = time(NULL);
+
+ if (state == NULL || session_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ snprintf(line, sizeof(line), "%llu|0|_|_|0|%lld|0",
+ (unsigned long long)session_id, (long long)now);
+
+ return append_line(state->sessions_path, line);
+}
diff --git a/src/core/storage_private.h b/src/core/storage_private.h
new file mode 100644
index 0000000..579250c
--- /dev/null
+++ b/src/core/storage_private.h
@@ -0,0 +1,81 @@
+#ifndef HYBBX_STORAGE_PRIVATE_H
+#define HYBBX_STORAGE_PRIVATE_H
+
+#include "hybbx/auth.h"
+#include "hybbx/storage.h"
+
+#include <stddef.h>
+
+struct hybbx_storage {
+ hybbx_storage_backend_kind_t backend;
+ char *path;
+ char guest_prefix[HYBBX_AUTH_GUEST_PREFIX_MAX];
+ hybbx_storage_sql_config_t sql_cfg;
+ void *backend_data;
+};
+
+hybbx_result_t hybbx_storage_flatfile_open(hybbx_storage_t *storage);
+void hybbx_storage_flatfile_close(hybbx_storage_t *storage);
+hybbx_result_t hybbx_storage_flatfile_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out);
+hybbx_result_t hybbx_storage_flatfile_session_end(hybbx_storage_t *storage,
+ uint64_t session_id);
+hybbx_result_t hybbx_storage_flatfile_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_flatfile_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_flatfile_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count);
+hybbx_result_t hybbx_storage_flatfile_foreach_user(
+ hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx);
+hybbx_result_t hybbx_storage_flatfile_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_flatfile_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user);
+hybbx_result_t hybbx_storage_flatfile_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id);
+
+hybbx_result_t hybbx_storage_sql_open(hybbx_storage_t *storage);
+void hybbx_storage_sql_close(hybbx_storage_t *storage);
+hybbx_result_t hybbx_storage_sql_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_sql_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_sql_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out);
+hybbx_result_t hybbx_storage_sql_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count);
+hybbx_result_t hybbx_storage_sql_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx);
+hybbx_result_t hybbx_storage_sql_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user);
+hybbx_result_t hybbx_storage_sql_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id);
+hybbx_result_t hybbx_storage_sql_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out);
+hybbx_result_t hybbx_storage_sql_session_end(hybbx_storage_t *storage,
+ uint64_t session_id);
+void hybbx_storage_sql_backup_files(const hybbx_storage_t *storage);
+void hybbx_storage_sql_backup_tick(hybbx_storage_t *storage);
+
+#ifdef HYBBX_HAVE_SQLITE
+struct sqlite3;
+struct sqlite3 *hybbx_storage_sql_mail_db(hybbx_storage_t *storage);
+#endif
+
+#endif /* HYBBX_STORAGE_PRIVATE_H */
diff --git a/src/core/storage_sql.c b/src/core/storage_sql.c
new file mode 100644
index 0000000..4a5dc13
--- /dev/null
+++ b/src/core/storage_sql.c
@@ -0,0 +1,1209 @@
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/config.h"
+#include "hybbx/password.h"
+#include "hybbx/security.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "storage_private.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+#ifdef HYBBX_HAVE_SQLITE
+#include <sqlite3.h>
+#endif
+
+#define HYBBX_SQL_DEFAULT_USER_DB "users.db"
+#define HYBBX_SQL_DEFAULT_MAIL_DB "mail.db"
+
+#ifndef HYBBX_HAVE_SQLITE
+
+hybbx_result_t hybbx_storage_sql_open(hybbx_storage_t *storage)
+{
+ (void)storage;
+ hybbx_log_warn("[storage] SQLite backend requested but hybbx was built without "
+ "libsqlite3 — use backend=flatfile or rebuild with sqlite3");
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void hybbx_storage_sql_close(hybbx_storage_t *storage)
+{
+ (void)storage;
+}
+
+hybbx_result_t hybbx_storage_sql_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out)
+{
+ (void)storage;
+ (void)reg;
+ (void)out;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out)
+{
+ (void)storage;
+ (void)username;
+ (void)out;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out)
+{
+ (void)storage;
+ (void)name;
+ (void)out;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count)
+{
+ (void)storage;
+ (void)level;
+ (void)count;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx)
+{
+ (void)storage;
+ (void)fn;
+ (void)ctx;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user)
+{
+ (void)storage;
+ (void)user;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id)
+{
+ (void)storage;
+ (void)user_id;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out)
+{
+ (void)storage;
+ (void)user;
+ (void)transport;
+ (void)out;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t hybbx_storage_sql_session_end(hybbx_storage_t *storage,
+ uint64_t session_id)
+{
+ (void)storage;
+ (void)session_id;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void hybbx_storage_sql_backup_files(const hybbx_storage_t *storage)
+{
+ (void)storage;
+}
+
+void hybbx_storage_sql_backup_tick(hybbx_storage_t *storage)
+{
+ (void)storage;
+}
+
+#else /* HYBBX_HAVE_SQLITE */
+
+struct sql_state {
+ sqlite3 *users_db;
+ sqlite3 *mail_db;
+ unsigned backup_tick;
+};
+
+static int sql_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 int mkdir_p(const char *path)
+{
+ char buf[HYBBX_PATH_MAX];
+ size_t len;
+ size_t i;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ len = strlen(path);
+ if (len >= sizeof(buf)) {
+ return -1;
+ }
+
+ memcpy(buf, path, len + 1);
+
+ for (i = 1; i < len; i++) {
+ if (buf[i] == '/') {
+ buf[i] = '\0';
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+ buf[i] = '/';
+ }
+ }
+
+ if (mkdir(buf, 0755) != 0 && errno != EEXIST) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static hybbx_result_t sql_exec(sqlite3 *db, const char *sql)
+{
+ char *err = NULL;
+ int rc;
+
+ if (db == NULL || sql == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = sqlite3_exec(db, sql, NULL, NULL, &err);
+ if (rc != SQLITE_OK) {
+ hybbx_log_warn("[storage] sqlite: %s",
+ err != NULL ? err : sqlite3_errmsg(db));
+ sqlite3_free(err);
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t sql_meta_get(sqlite3 *db, const char *key, uint64_t *value)
+{
+ sqlite3_stmt *stmt;
+ int rc;
+
+ *value = 0;
+
+ rc = sqlite3_prepare_v2(db,
+ "SELECT value FROM meta WHERE key = ?1;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
+ rc = sqlite3_step(stmt);
+ if (rc == SQLITE_ROW) {
+ *value = (uint64_t)sqlite3_column_int64(stmt, 0);
+ }
+ sqlite3_finalize(stmt);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t sql_meta_set(sqlite3 *db, const char *key, uint64_t value)
+{
+ sqlite3_stmt *stmt;
+ int rc;
+
+ rc = sqlite3_prepare_v2(db,
+ "INSERT INTO meta(key,value) VALUES(?1,?2) "
+ "ON CONFLICT(key) DO UPDATE SET value=?2;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, key, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 2, (sqlite3_int64)value);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? HYBBX_OK : HYBBX_ERR_IO;
+}
+
+static hybbx_result_t sql_meta_bump(sqlite3 *db, const char *key, uint64_t *value)
+{
+ hybbx_result_t rc;
+
+ rc = sql_meta_get(db, key, value);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (*value)++;
+ return sql_meta_set(db, key, *value);
+}
+
+static hybbx_result_t sql_open_db(const char *path, sqlite3 **out_db)
+{
+ int rc;
+ int existed = (access(path, F_OK) == 0);
+
+ if (out_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out_db = NULL;
+ rc = sqlite3_open(path, out_db);
+ if (rc != SQLITE_OK) {
+ hybbx_log_warn("[storage] cannot open '%s': %s",
+ path, sqlite3_errmsg(*out_db));
+ if (*out_db != NULL) {
+ sqlite3_close(*out_db);
+ *out_db = NULL;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_busy_timeout(*out_db, 5000);
+ if (!existed) {
+ hybbx_log_info("[storage] created new database %s", path);
+ } else {
+ hybbx_log_info("[storage] opened existing database %s", path);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t sql_init_users_schema(sqlite3 *db)
+{
+ static const char *schema =
+ "CREATE TABLE IF NOT EXISTS users ("
+ " id INTEGER PRIMARY KEY,"
+ " username TEXT NOT NULL UNIQUE,"
+ " nickname TEXT,"
+ " level INTEGER NOT NULL,"
+ " active INTEGER NOT NULL,"
+ " created_at INTEGER NOT NULL,"
+ " full_name TEXT,"
+ " country TEXT,"
+ " location TEXT,"
+ " email TEXT,"
+ " password TEXT NOT NULL,"
+ " last_login_at INTEGER"
+ ");"
+ "CREATE INDEX IF NOT EXISTS idx_users_nickname ON users(nickname);"
+ "CREATE TABLE IF NOT EXISTS sessions ("
+ " session_id INTEGER PRIMARY KEY,"
+ " user_id INTEGER NOT NULL,"
+ " username TEXT,"
+ " transport TEXT,"
+ " remote TEXT,"
+ " connected_at INTEGER,"
+ " disconnected_at INTEGER,"
+ " active INTEGER"
+ ");"
+ "CREATE TABLE IF NOT EXISTS meta ("
+ " key TEXT PRIMARY KEY,"
+ " value INTEGER NOT NULL"
+ ");";
+
+ return sql_exec(db, schema);
+}
+
+static hybbx_result_t sql_init_mail_schema(sqlite3 *db)
+{
+ static const char *schema =
+ "CREATE TABLE IF NOT EXISTS messages ("
+ " id INTEGER PRIMARY KEY,"
+ " owner TEXT NOT NULL,"
+ " from_user TEXT NOT NULL,"
+ " subject TEXT,"
+ " body TEXT,"
+ " received_at INTEGER NOT NULL,"
+ " read_flag INTEGER NOT NULL DEFAULT 0,"
+ " deleted_at INTEGER,"
+ " folder INTEGER NOT NULL DEFAULT 0"
+ ");"
+ "CREATE INDEX IF NOT EXISTS idx_mail_owner ON messages(owner, folder, received_at DESC);"
+ "CREATE TABLE IF NOT EXISTS meta ("
+ " key TEXT PRIMARY KEY,"
+ " value INTEGER NOT NULL"
+ ");";
+
+ return sql_exec(db, schema);
+}
+
+static void sql_row_to_user(sqlite3_stmt *stmt, hybbx_user_record_t *user)
+{
+ memset(user, 0, sizeof(*user));
+ user->id = (uint64_t)sqlite3_column_int64(stmt, 0);
+ hybbx_strlcpy(user->username, (const char *)sqlite3_column_text(stmt, 1),
+ sizeof(user->username));
+ if (sqlite3_column_text(stmt, 2) != NULL) {
+ hybbx_strlcpy(user->nickname, (const char *)sqlite3_column_text(stmt, 2),
+ sizeof(user->nickname));
+ }
+ user->level = (hybbx_user_level_t)sqlite3_column_int(stmt, 3);
+ user->active = sqlite3_column_int(stmt, 4);
+ user->created_at = (time_t)sqlite3_column_int64(stmt, 5);
+ if (sqlite3_column_text(stmt, 6) != NULL) {
+ hybbx_strlcpy(user->full_name, (const char *)sqlite3_column_text(stmt, 6),
+ sizeof(user->full_name));
+ }
+ if (sqlite3_column_text(stmt, 7) != NULL) {
+ hybbx_strlcpy(user->country, (const char *)sqlite3_column_text(stmt, 7),
+ sizeof(user->country));
+ }
+ if (sqlite3_column_text(stmt, 8) != NULL) {
+ hybbx_strlcpy(user->location, (const char *)sqlite3_column_text(stmt, 8),
+ sizeof(user->location));
+ }
+ if (sqlite3_column_text(stmt, 9) != NULL) {
+ hybbx_strlcpy(user->email, (const char *)sqlite3_column_text(stmt, 9),
+ sizeof(user->email));
+ }
+ if (sqlite3_column_text(stmt, 10) != NULL) {
+ hybbx_strlcpy(user->password, (const char *)sqlite3_column_text(stmt, 10),
+ sizeof(user->password));
+ }
+ user->last_login_at = (time_t)sqlite3_column_int64(stmt, 11);
+}
+
+static hybbx_result_t sql_ensure_default_sysop(hybbx_storage_t *storage)
+{
+ struct sql_state *state = storage->backend_data;
+ size_t sysop_count = 0;
+ hybbx_user_record_t sysop;
+ char plain_password[HYBBX_PASSWORD_MAX_LEN + 1];
+ uint64_t user_id;
+ sqlite3_stmt *stmt;
+ int rc;
+ hybbx_result_t hres;
+
+ hres = hybbx_storage_sql_count_level(storage, HYBBX_LEVEL_SYSOP, &sysop_count);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+ if (sysop_count > 0) {
+ return HYBBX_OK;
+ }
+
+ hres = sql_meta_bump(state->users_db, "user_next", &user_id);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ memset(&sysop, 0, sizeof(sysop));
+ sysop.id = user_id;
+ hybbx_strlcpy(sysop.username, "sysop", sizeof(sysop.username));
+ hybbx_strlcpy(sysop.nickname, HYBBX_DEFAULT_SYSOP_USERNAME,
+ sizeof(sysop.nickname));
+ sysop.level = HYBBX_LEVEL_SYSOP;
+ sysop.active = 1;
+ sysop.created_at = time(NULL);
+ hybbx_strlcpy(sysop.full_name, "System Operator", sizeof(sysop.full_name));
+
+ if (hybbx_password_generate_alnum(plain_password, sizeof(plain_password),
+ HYBBX_SYSOP_INIT_PASSWORD_MIN,
+ HYBBX_SYSOP_INIT_PASSWORD_MAX) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (hybbx_password_hash(plain_password, sysop.password,
+ sizeof(sysop.password)) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "INSERT INTO users(id,username,nickname,level,active,"
+ "created_at,full_name,country,location,email,password,"
+ "last_login_at) VALUES(?1,?2,?3,?4,?5,?6,?7,'','','',?8,0);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)sysop.id);
+ sqlite3_bind_text(stmt, 2, sysop.username, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 3, sysop.nickname, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 4, (int)sysop.level);
+ sqlite3_bind_int(stmt, 5, sysop.active);
+ sqlite3_bind_int64(stmt, 6, (sqlite3_int64)sysop.created_at);
+ sqlite3_bind_text(stmt, 7, sysop.full_name, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 8, sysop.password, -1, SQLITE_STATIC);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_info("[storage] created default Sysop in %s (login: %s / %s)",
+ storage->sql_cfg.user_db, sysop.nickname, plain_password);
+ hybbx_security_log_write("sysop_created user=%s", sysop.nickname);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t sql_copy_file(const char *src, const char *dst)
+{
+ FILE *in_fp;
+ FILE *out_fp;
+ unsigned char buf[4096];
+ size_t n;
+
+ in_fp = fopen(src, "rb");
+ if (in_fp == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ out_fp = fopen(dst, "wb");
+ if (out_fp == NULL) {
+ fclose(in_fp);
+ return HYBBX_ERR_IO;
+ }
+
+ while ((n = fread(buf, 1, sizeof(buf), in_fp)) > 0) {
+ if (fwrite(buf, 1, n, out_fp) != n) {
+ fclose(in_fp);
+ fclose(out_fp);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ fclose(in_fp);
+ if (fclose(out_fp) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void sql_backup_one(sqlite3 *db, const char *src_path,
+ const char *backup_dir)
+{
+ char dst[HYBBX_PATH_MAX];
+ const char *base;
+ hybbx_result_t rc;
+
+ if (db == NULL || src_path == NULL || src_path[0] == '\0') {
+ return;
+ }
+
+ (void)sqlite3_wal_checkpoint_v2(db, NULL, SQLITE_CHECKPOINT_TRUNCATE, NULL, NULL);
+
+ base = strrchr(src_path, '/');
+ base = (base != NULL) ? base + 1 : src_path;
+
+ if (backup_dir != NULL && backup_dir[0] != '\0') {
+ char name[HYBBX_PATH_MAX];
+
+ if (mkdir_p(backup_dir) != 0) {
+ return;
+ }
+ if (strlen(base) + strlen(HYBBX_STORAGE_BACKUP_SUFFIX) + 1 >=
+ sizeof(name)) {
+ return;
+ }
+ snprintf(name, sizeof(name), "%s%s", base, HYBBX_STORAGE_BACKUP_SUFFIX);
+ if (hybbx_path_join(dst, sizeof(dst), backup_dir, name) != HYBBX_OK) {
+ return;
+ }
+ } else {
+ if (strlen(src_path) + strlen(HYBBX_STORAGE_BACKUP_SUFFIX) + 1 >=
+ sizeof(dst)) {
+ return;
+ }
+ snprintf(dst, sizeof(dst), "%s%s", src_path, HYBBX_STORAGE_BACKUP_SUFFIX);
+ }
+
+ rc = sql_copy_file(src_path, dst);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[storage] backup failed %s -> %s", src_path, dst);
+ }
+}
+
+hybbx_result_t hybbx_storage_sql_open(hybbx_storage_t *storage)
+{
+ struct sql_state *state;
+ hybbx_result_t rc;
+
+ if (storage == NULL || storage->path == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (storage->backend == HYBBX_STORAGE_MYSQL ||
+ storage->backend == HYBBX_STORAGE_MARIADB) {
+ hybbx_log_warn("[storage] MySQL/MariaDB backends are not supported — "
+ "use flatfile or sqlite");
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (mkdir_p(storage->path) != 0) {
+ hybbx_log_warn("[storage] cannot create data path '%s'",
+ storage->path);
+ return HYBBX_ERR_IO;
+ }
+
+ state = calloc(1, sizeof(*state));
+ if (state == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = sql_open_db(storage->sql_cfg.user_db, &state->users_db);
+ if (rc != HYBBX_OK) {
+ free(state);
+ return rc;
+ }
+
+ rc = sql_init_users_schema(state->users_db);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_sql_close(storage);
+ free(state);
+ return rc;
+ }
+
+ rc = sql_open_db(storage->sql_cfg.mail_db, &state->mail_db);
+ if (rc != HYBBX_OK) {
+ sqlite3_close(state->users_db);
+ free(state);
+ return rc;
+ }
+
+ rc = sql_init_mail_schema(state->mail_db);
+ if (rc != HYBBX_OK) {
+ sqlite3_close(state->users_db);
+ sqlite3_close(state->mail_db);
+ free(state);
+ return rc;
+ }
+
+ storage->backend_data = state;
+
+ rc = sql_ensure_default_sysop(storage);
+ if (rc != HYBBX_OK) {
+ hybbx_storage_sql_close(storage);
+ return rc;
+ }
+
+ hybbx_log_info("[storage] sqlite user_db=%s mail_db=%s backup_interval=%us",
+ storage->sql_cfg.user_db, storage->sql_cfg.mail_db,
+ storage->sql_cfg.backup_interval_sec);
+
+ return HYBBX_OK;
+}
+
+void hybbx_storage_sql_close(hybbx_storage_t *storage)
+{
+ struct sql_state *state;
+
+ if (storage == NULL) {
+ return;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return;
+ }
+
+ if (state->users_db != NULL) {
+ sqlite3_close(state->users_db);
+ }
+ if (state->mail_db != NULL) {
+ sqlite3_close(state->mail_db);
+ }
+
+ free(state);
+ storage->backend_data = NULL;
+}
+
+sqlite3 *hybbx_storage_sql_mail_db(hybbx_storage_t *storage)
+{
+ struct sql_state *state;
+
+ if (storage == NULL) {
+ return NULL;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return NULL;
+ }
+
+ return state->mail_db;
+}
+
+static hybbx_result_t sql_find_user_by_column(sqlite3 *db, const char *column,
+ const char *name,
+ hybbx_user_record_t *out)
+{
+ sqlite3_stmt *stmt;
+ int rc;
+ const char *sql_username =
+ "SELECT id,username,nickname,level,active,created_at,full_name,"
+ "country,location,email,password,last_login_at "
+ "FROM users WHERE lower(username)=lower(?1) LIMIT 1;";
+ const char *sql_nickname =
+ "SELECT id,username,nickname,level,active,created_at,full_name,"
+ "country,location,email,password,last_login_at "
+ "FROM users WHERE lower(nickname)=lower(?1) LIMIT 1;";
+ const char *sql = sql_str_ieq(column, "nickname") ? sql_nickname :
+ sql_username;
+
+ rc = sqlite3_prepare_v2(db, sql, -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_text(stmt, 1, name, -1, SQLITE_STATIC);
+ rc = sqlite3_step(stmt);
+ if (rc == SQLITE_ROW) {
+ sql_row_to_user(stmt, out);
+ sqlite3_finalize(stmt);
+ return HYBBX_OK;
+ }
+
+ sqlite3_finalize(stmt);
+ return HYBBX_ERR_NOT_FOUND;
+}
+
+hybbx_result_t hybbx_storage_sql_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out)
+{
+ struct sql_state *state;
+ char normalized[HYBBX_USER_NAME_MAX];
+
+ if (storage == NULL || username == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_strlcpy(normalized, username, sizeof(normalized));
+ hybbx_username_normalize(normalized);
+ return sql_find_user_by_column(state->users_db, "username", normalized, out);
+}
+
+hybbx_result_t hybbx_storage_sql_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out)
+{
+ hybbx_result_t rc;
+
+ if (storage == NULL || name == NULL || out == NULL || name[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_storage_sql_find_user(storage, name, out);
+ if (rc == HYBBX_OK) {
+ return HYBBX_OK;
+ }
+ if (rc != HYBBX_ERR_NOT_FOUND) {
+ return rc;
+ }
+
+ return sql_find_user_by_column(
+ ((struct sql_state *)storage->backend_data)->users_db,
+ "nickname", name, out);
+}
+
+typedef struct foreach_ctx {
+ hybbx_storage_user_fn fn;
+ void *user_ctx;
+} foreach_ctx_t;
+
+static int sql_foreach_cb(void *ctx, int n_cols, char **values, char **cols)
+{
+ hybbx_user_record_t user;
+ foreach_ctx_t *fctx = (foreach_ctx_t *)ctx;
+ hybbx_result_t rc;
+ int i;
+
+ (void)n_cols;
+ (void)cols;
+ memset(&user, 0, sizeof(user));
+
+ for (i = 0; values[i] != NULL; i++) {
+ switch (i) {
+ case 0: user.id = (uint64_t)strtoull(values[i], NULL, 10); break;
+ case 1: hybbx_strlcpy(user.username, values[i], sizeof(user.username)); break;
+ case 2: hybbx_strlcpy(user.nickname, values[i], sizeof(user.nickname)); break;
+ case 3: user.level = (hybbx_user_level_t)atoi(values[i]); break;
+ case 4: user.active = atoi(values[i]); break;
+ case 5: user.created_at = (time_t)strtoll(values[i], NULL, 10); break;
+ case 6: hybbx_strlcpy(user.full_name, values[i], sizeof(user.full_name)); break;
+ case 7: hybbx_strlcpy(user.country, values[i], sizeof(user.country)); break;
+ case 8: hybbx_strlcpy(user.location, values[i], sizeof(user.location)); break;
+ case 9: hybbx_strlcpy(user.email, values[i], sizeof(user.email)); break;
+ case 10: hybbx_strlcpy(user.password, values[i], sizeof(user.password)); break;
+ case 11: user.last_login_at = (time_t)strtoll(values[i], NULL, 10); break;
+ default: break;
+ }
+ }
+
+ rc = fctx->fn(&user, fctx->user_ctx);
+ return (rc == HYBBX_OK) ? 0 : 1;
+}
+
+hybbx_result_t hybbx_storage_sql_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx)
+{
+ struct sql_state *state;
+ foreach_ctx_t fctx;
+ char *err = NULL;
+ int rc;
+
+ if (storage == NULL || fn == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fctx.fn = fn;
+ fctx.user_ctx = ctx;
+
+ rc = sqlite3_exec(state->users_db,
+ "SELECT id,username,nickname,level,active,created_at,"
+ "full_name,country,location,email,password,last_login_at "
+ "FROM users ORDER BY id;",
+ sql_foreach_cb, &fctx, &err);
+ if (rc == SQLITE_ABORT) {
+ sqlite3_free(err);
+ return HYBBX_ERR_BUSY;
+ }
+ if (rc != SQLITE_OK) {
+ hybbx_log_warn("[storage] foreach: %s", err != NULL ? err : "error");
+ sqlite3_free(err);
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_storage_sql_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count)
+{
+ struct sql_state *state;
+ sqlite3_stmt *stmt;
+ int rc;
+
+ if (storage == NULL || count == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *count = 0;
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "SELECT COUNT(*) FROM users WHERE level=?1;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int(stmt, 1, (int)level);
+ rc = sqlite3_step(stmt);
+ if (rc == SQLITE_ROW) {
+ *count = (size_t)sqlite3_column_int64(stmt, 0);
+ }
+ sqlite3_finalize(stmt);
+ return HYBBX_OK;
+}
+
+typedef struct identity_taken_ctx {
+ const char *username;
+ const char *nickname;
+ int taken;
+} identity_taken_ctx_t;
+
+static hybbx_result_t identity_taken_cb(const hybbx_user_record_t *user, void *ctx)
+{
+ identity_taken_ctx_t *ictx = (identity_taken_ctx_t *)ctx;
+
+ if (sql_str_ieq(user->username, ictx->username) ||
+ sql_str_ieq(user->username, ictx->nickname) ||
+ (user->nickname[0] != '\0' &&
+ (sql_str_ieq(user->nickname, ictx->nickname) ||
+ sql_str_ieq(user->nickname, ictx->username)))) {
+ ictx->taken = 1;
+ return HYBBX_ERR_BUSY;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_storage_sql_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out)
+{
+ struct sql_state *state;
+ hybbx_user_record_t existing;
+ identity_taken_ctx_t taken;
+ uint64_t user_id;
+ sqlite3_stmt *stmt;
+ int rc;
+ hybbx_result_t hres;
+
+ if (storage == NULL || reg == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!hybbx_registration_valid(reg, storage->guest_prefix)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hres = hybbx_storage_sql_find_user(storage, reg->username, &existing);
+ if (hres == HYBBX_OK) {
+ return HYBBX_ERR_BUSY;
+ }
+ if (hres != HYBBX_ERR_NOT_FOUND) {
+ return hres;
+ }
+
+ memset(&taken, 0, sizeof(taken));
+ taken.username = reg->username;
+ taken.nickname = reg->nickname;
+ hres = hybbx_storage_sql_foreach_user(storage, identity_taken_cb, &taken);
+ if (hres == HYBBX_ERR_BUSY || taken.taken) {
+ return HYBBX_ERR_BUSY;
+ }
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ hres = sql_meta_bump(state->users_db, "user_next", &user_id);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ memset(out, 0, sizeof(*out));
+ out->id = user_id;
+ hybbx_strlcpy(out->username, reg->username, sizeof(out->username));
+ hybbx_username_normalize(out->username);
+ hybbx_strlcpy(out->nickname, reg->nickname, sizeof(out->nickname));
+ out->level = HYBBX_LEVEL_USER;
+ out->active = 0;
+ out->created_at = time(NULL);
+ hybbx_strlcpy(out->full_name, reg->full_name, sizeof(out->full_name));
+ hybbx_strlcpy(out->country, reg->country, sizeof(out->country));
+ hybbx_strlcpy(out->location, reg->location, sizeof(out->location));
+ hybbx_strlcpy(out->email, reg->email, sizeof(out->email));
+
+ if (reg->password[0] != '\0') {
+ if (!hybbx_password_plain_valid(reg->password)) {
+ return HYBBX_ERR_INVALID;
+ }
+ hres = hybbx_password_hash(reg->password, out->password,
+ sizeof(out->password));
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "INSERT INTO users(id,username,nickname,level,active,"
+ "created_at,full_name,country,location,email,password,"
+ "last_login_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8,?9,?10,?11,0);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)out->id);
+ sqlite3_bind_text(stmt, 2, out->username, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 3, out->nickname, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 4, (int)out->level);
+ sqlite3_bind_int(stmt, 5, out->active);
+ sqlite3_bind_int64(stmt, 6, (sqlite3_int64)out->created_at);
+ sqlite3_bind_text(stmt, 7, out->full_name, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 8, out->country, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 9, out->location, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 10, out->email, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 11, out->password, -1, SQLITE_STATIC);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? HYBBX_OK : HYBBX_ERR_IO;
+}
+
+hybbx_result_t hybbx_storage_sql_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user)
+{
+ struct sql_state *state;
+ sqlite3_stmt *stmt;
+ int rc;
+
+ if (storage == NULL || user == NULL || user->id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "UPDATE users SET username=?2,nickname=?3,level=?4,"
+ "active=?5,created_at=?6,full_name=?7,country=?8,"
+ "location=?9,email=?10,password=?11,last_login_at=?12 "
+ "WHERE id=?1;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)user->id);
+ sqlite3_bind_text(stmt, 2, user->username, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 3, user->nickname, -1, SQLITE_STATIC);
+ sqlite3_bind_int(stmt, 4, (int)user->level);
+ sqlite3_bind_int(stmt, 5, user->active);
+ sqlite3_bind_int64(stmt, 6, (sqlite3_int64)user->created_at);
+ sqlite3_bind_text(stmt, 7, user->full_name, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 8, user->country, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 9, user->location, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 10, user->email, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 11, user->password, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 12, (sqlite3_int64)user->last_login_at);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+ if (sqlite3_changes(state->users_db) == 0) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_storage_sql_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id)
+{
+ struct sql_state *state;
+ sqlite3_stmt *stmt;
+ int rc;
+
+ if (storage == NULL || user_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "DELETE FROM users WHERE id=?1;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)user_id);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+ if (sqlite3_changes(state->users_db) == 0) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_storage_sql_session_begin(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user,
+ const char *transport,
+ hybbx_session_record_t *out)
+{
+ struct sql_state *state;
+ sqlite3_stmt *stmt;
+ int rc;
+ hybbx_result_t hres;
+
+ if (storage == NULL || user == NULL || transport == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ hres = sql_meta_bump(state->users_db, "session_next", &out->session_id);
+ if (hres != HYBBX_OK) {
+ return hres;
+ }
+
+ out->user_id = user->id;
+ hybbx_strlcpy(out->username, user->username, sizeof(out->username));
+ hybbx_strlcpy(out->transport, transport, sizeof(out->transport));
+ out->connected_at = time(NULL);
+ out->active = 1;
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "INSERT INTO sessions(session_id,user_id,username,"
+ "transport,remote,connected_at,disconnected_at,active) "
+ "VALUES(?1,?2,?3,?4,'',?5,0,1);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)out->session_id);
+ sqlite3_bind_int64(stmt, 2, (sqlite3_int64)out->user_id);
+ sqlite3_bind_text(stmt, 3, out->username, -1, SQLITE_STATIC);
+ sqlite3_bind_text(stmt, 4, out->transport, -1, SQLITE_STATIC);
+ sqlite3_bind_int64(stmt, 5, (sqlite3_int64)out->connected_at);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ return (rc == SQLITE_DONE) ? HYBBX_OK : HYBBX_ERR_IO;
+}
+
+hybbx_result_t hybbx_storage_sql_session_end(hybbx_storage_t *storage,
+ uint64_t session_id)
+{
+ struct sql_state *state;
+ sqlite3_stmt *stmt;
+ int rc;
+ time_t now = time(NULL);
+
+ if (storage == NULL || session_id == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || state->users_db == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = sqlite3_prepare_v2(state->users_db,
+ "UPDATE sessions SET disconnected_at=?2, active=0 "
+ "WHERE session_id=?1;",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)session_id);
+ sqlite3_bind_int64(stmt, 2, (sqlite3_int64)now);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (sqlite3_changes(state->users_db) == 0) {
+ rc = sqlite3_prepare_v2(state->users_db,
+ "INSERT INTO sessions(session_id,user_id,username,"
+ "transport,remote,connected_at,disconnected_at,"
+ "active) VALUES(?1,0,'','','',0,?2,0);",
+ -1, &stmt, NULL);
+ if (rc != SQLITE_OK) {
+ return HYBBX_ERR_IO;
+ }
+ sqlite3_bind_int64(stmt, 1, (sqlite3_int64)session_id);
+ sqlite3_bind_int64(stmt, 2, (sqlite3_int64)now);
+ rc = sqlite3_step(stmt);
+ sqlite3_finalize(stmt);
+ if (rc != SQLITE_DONE) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_storage_sql_backup_files(const hybbx_storage_t *storage)
+{
+ struct sql_state *state;
+ const char *backup_dir;
+
+ if (storage == NULL || storage->backend != HYBBX_STORAGE_SQLITE) {
+ return;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL) {
+ return;
+ }
+
+ backup_dir = storage->sql_cfg.backup_path;
+ if (backup_dir != NULL && backup_dir[0] == '\0') {
+ backup_dir = NULL;
+ }
+
+ sql_backup_one(state->users_db, storage->sql_cfg.user_db, backup_dir);
+ sql_backup_one(state->mail_db, storage->sql_cfg.mail_db, backup_dir);
+}
+
+void hybbx_storage_sql_backup_tick(hybbx_storage_t *storage)
+{
+ struct sql_state *state;
+
+ if (storage == NULL || storage->backend != HYBBX_STORAGE_SQLITE) {
+ return;
+ }
+
+ state = storage->backend_data;
+ if (state == NULL || storage->sql_cfg.backup_interval_sec == 0) {
+ return;
+ }
+
+ state->backup_tick++;
+ if (state->backup_tick >= storage->sql_cfg.backup_interval_sec) {
+ state->backup_tick = 0;
+ hybbx_storage_sql_backup_files(storage);
+ }
+}
+
+#endif /* HYBBX_HAVE_SQLITE */
diff --git a/src/core/terminal.c b/src/core/terminal.c
new file mode 100644
index 0000000..b4929f4
--- /dev/null
+++ b/src/core/terminal.c
@@ -0,0 +1,98 @@
+#include "hybbx/terminal.h"
+#include "hybbx/session.h"
+#include "hybbx/traffic.h"
+#include "hybbx/limits.h"
+
+#include <string.h>
+
+static int term_is_csi_terminator(unsigned char ch)
+{
+ return ch >= 0x40 && ch <= 0x7E;
+}
+
+size_t hybbx_term_copy_plain(const char *src, char *dst, size_t dst_size)
+{
+ size_t di = 0;
+ size_t si = 0;
+
+ if (dst == NULL || dst_size == 0) {
+ return 0;
+ }
+
+ dst[0] = '\0';
+
+ if (src == NULL) {
+ return 0;
+ }
+
+ while (src[si] != '\0' && di + 1 < dst_size) {
+ unsigned char ch = (unsigned char)src[si];
+
+ if (ch == 0x1Bu) {
+ si++;
+ if (src[si] == '\0') {
+ break;
+ }
+
+ if (src[si] == '[') {
+ si++;
+ while (src[si] != '\0' && !term_is_csi_terminator((unsigned char)src[si])) {
+ si++;
+ }
+ if (src[si] != '\0') {
+ si++;
+ }
+ } else {
+ si++;
+ }
+ continue;
+ }
+
+ dst[di++] = (char)ch;
+ si++;
+ }
+
+ dst[di] = '\0';
+ return di;
+}
+
+hybbx_result_t hybbx_term_init_session(hybbx_session_t *session)
+{
+ const hybbx_traffic_config_t *traffic;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ traffic = hybbx_traffic_config_get();
+ if (traffic == NULL || !traffic->ansi) {
+ return HYBBX_OK;
+ }
+
+ return hybbx_session_write(session, HYBBX_TERM_SGR_LIGHTGRAY_ON_BLACK);
+}
+
+hybbx_result_t hybbx_term_clear_screen(hybbx_session_t *session)
+{
+ const hybbx_traffic_config_t *traffic;
+ int i;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ traffic = hybbx_traffic_config_get();
+ if (traffic != NULL && traffic->ansi) {
+ return hybbx_session_write(session, HYBBX_TERM_CLEAR_SCREEN);
+ }
+
+ for (i = 0; i < 24; i++) {
+ hybbx_result_t rc = hybbx_session_write(session, "\n");
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
diff --git a/src/core/texts.c b/src/core/texts.c
new file mode 100644
index 0000000..f983744
--- /dev/null
+++ b/src/core/texts.c
@@ -0,0 +1,470 @@
+#include "hybbx/texts.h"
+#include "hybbx/session.h"
+#include "hybbx/service.h"
+#include "hybbx/auth.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+#include "hybbx/hybbx.h"
+
+#include <stdio.h>
+#include <string.h>
+#include <time.h>
+
+void hybbx_texts_config_defaults(hybbx_texts_config_t *texts)
+{
+ if (texts == NULL) {
+ return;
+ }
+
+ hybbx_strlcpy(texts->path, HYBBX_DEFAULT_TEXTS_PATH, sizeof(texts->path));
+}
+
+hybbx_result_t hybbx_texts_resolve(const hybbx_texts_config_t *texts,
+ const char *filename,
+ char *out, size_t out_len)
+{
+ if (texts == NULL || filename == NULL || out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_path_join(out, out_len, texts->path, filename);
+}
+
+static size_t append_token_expanded(char *out, size_t out_len, size_t pos,
+ const char *value)
+{
+ size_t value_len;
+
+ if (out == NULL || out_len == 0 || value == NULL) {
+ return pos;
+ }
+
+ value_len = strlen(value);
+
+ if (pos + value_len >= out_len) {
+ return out_len - 1;
+ }
+
+ memcpy(out + pos, value, value_len);
+ return pos + value_len;
+}
+
+static void expand_text_line(char *out, size_t out_len,
+ const char *line,
+ const char *version,
+ const char *service_name,
+ const char *username,
+ const char *os_name,
+ const char *time_text,
+ const char *date_text)
+{
+ size_t pos = 0;
+ size_t i = 0;
+
+ if (out == NULL || out_len == 0 || line == NULL) {
+ return;
+ }
+
+ out[0] = '\0';
+
+ if (version == NULL) {
+ version = HYBBX_VERSION_STRING;
+ }
+ if (service_name == NULL || service_name[0] == '\0') {
+ service_name = HYBBX_DEFAULT_SERVICE_NAME;
+ }
+ if (username == NULL) {
+ username = "";
+ }
+ if (os_name == NULL) {
+ os_name = "";
+ }
+ if (time_text == NULL) {
+ time_text = "";
+ }
+ if (date_text == NULL) {
+ date_text = "";
+ }
+
+ while (line[i] != '\0' && pos + 1 < out_len) {
+ if (strncmp(line + i, HYBBX_BANNER_TOKEN_VERSION,
+ strlen(HYBBX_BANNER_TOKEN_VERSION)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, version);
+ i += strlen(HYBBX_BANNER_TOKEN_VERSION);
+ continue;
+ }
+
+ if (strncmp(line + i, HYBBX_BANNER_TOKEN_SERVICE,
+ strlen(HYBBX_BANNER_TOKEN_SERVICE)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, service_name);
+ i += strlen(HYBBX_BANNER_TOKEN_SERVICE);
+ continue;
+ }
+
+ if (strncmp(line + i, HYBBX_TEXT_TOKEN_USERNAME,
+ strlen(HYBBX_TEXT_TOKEN_USERNAME)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, username);
+ i += strlen(HYBBX_TEXT_TOKEN_USERNAME);
+ continue;
+ }
+
+ if (strncmp(line + i, HYBBX_TEXT_TOKEN_OS,
+ strlen(HYBBX_TEXT_TOKEN_OS)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, os_name);
+ i += strlen(HYBBX_TEXT_TOKEN_OS);
+ continue;
+ }
+
+ if (strncmp(line + i, HYBBX_TEXT_TOKEN_TIME,
+ strlen(HYBBX_TEXT_TOKEN_TIME)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, time_text);
+ i += strlen(HYBBX_TEXT_TOKEN_TIME);
+ continue;
+ }
+
+ if (strncmp(line + i, HYBBX_TEXT_TOKEN_DATE,
+ strlen(HYBBX_TEXT_TOKEN_DATE)) == 0) {
+ pos = append_token_expanded(out, out_len, pos, date_text);
+ i += strlen(HYBBX_TEXT_TOKEN_DATE);
+ continue;
+ }
+
+ out[pos++] = line[i++];
+ }
+
+ out[pos] = '\0';
+}
+
+static void format_text_tokens(char *time_text, size_t time_len,
+ char *date_text, size_t date_len,
+ const struct tm *tm)
+{
+ if (time_text != NULL && time_len > 0) {
+ time_text[0] = '\0';
+ if (tm != NULL) {
+ (void)hybbx_time_format_time(time_text, time_len, tm, NULL);
+ }
+ }
+
+ if (date_text != NULL && date_len > 0) {
+ date_text[0] = '\0';
+ if (tm != NULL) {
+ (void)hybbx_time_format_date(date_text, date_len, tm, NULL);
+ }
+ }
+}
+
+static void fill_os_name(char *os_name, size_t os_len)
+{
+ if (os_name == NULL || os_len == 0) {
+ return;
+ }
+
+ if (hybbx_platform_os_name(os_name, os_len) != HYBBX_OK) {
+ hybbx_strlcpy(os_name, "unknown", os_len);
+ }
+}
+
+static void expand_banner_line(char *out, size_t out_len,
+ const char *line,
+ const char *version,
+ const char *service_name,
+ const char *time_text,
+ const char *date_text)
+{
+ expand_text_line(out, out_len, line, version, service_name, NULL, NULL,
+ time_text, date_text);
+}
+
+typedef struct texts_line_emitter {
+ hybbx_session_t *session;
+ unsigned pending_blank;
+} texts_line_emitter_t;
+
+static void texts_emit_expanded_line(texts_line_emitter_t *emitter,
+ const char *expanded)
+{
+ unsigned i;
+
+ if (emitter == NULL || emitter->session == NULL || expanded == NULL) {
+ return;
+ }
+
+ if (expanded[0] == '\0') {
+ emitter->pending_blank++;
+ return;
+ }
+
+ for (i = 0; i < emitter->pending_blank; i++) {
+ hybbx_session_write(emitter->session, "\n");
+ }
+ emitter->pending_blank = 0;
+ hybbx_session_write_line(emitter->session, expanded);
+}
+
+static void texts_trim_trailing_cr(char *expanded)
+{
+ size_t n;
+
+ if (expanded == NULL) {
+ return;
+ }
+
+ n = strlen(expanded);
+ while (n > 0 && (expanded[n - 1] == '\n' || expanded[n - 1] == '\r')) {
+ expanded[--n] = '\0';
+ }
+}
+
+static hybbx_result_t send_banner_fallback(hybbx_session_t *session,
+ const char *version,
+ const char *service_name)
+{
+ char line[HYBBX_LINE_MAX];
+
+ if (version == NULL) {
+ version = HYBBX_VERSION_STRING;
+ }
+ if (service_name == NULL || service_name[0] == '\0') {
+ service_name = HYBBX_DEFAULT_SERVICE_NAME;
+ }
+
+ snprintf(line, sizeof(line), "HyBBX %s", version);
+ hybbx_session_write_line(session, line);
+ snprintf(line, sizeof(line), "Online at %s", service_name);
+ hybbx_session_write_line(session, line);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_texts_send_banner(const hybbx_texts_config_t *texts,
+ hybbx_session_t *session,
+ const char *version,
+ const char *service_name)
+{
+ char path[HYBBX_PATH_MAX];
+ char expanded[HYBBX_LINE_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ char time_text[32];
+ char date_text[32];
+ struct tm now_tm;
+ const struct tm *tm_ptr = NULL;
+ hybbx_result_t rc;
+
+ if (texts == NULL || session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_time_local_now(&now_tm) == HYBBX_OK) {
+ tm_ptr = &now_tm;
+ }
+ format_text_tokens(time_text, sizeof(time_text),
+ date_text, sizeof(date_text), tm_ptr);
+
+ rc = hybbx_texts_resolve(texts, HYBBX_TEXT_BANNER, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return send_banner_fallback(session, version, service_name);
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return send_banner_fallback(session, version, service_name);
+ }
+
+ {
+ texts_line_emitter_t emitter;
+
+ emitter.session = session;
+ emitter.pending_blank = 0;
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ expand_banner_line(expanded, sizeof(expanded), line, version,
+ service_name, time_text, date_text);
+ texts_trim_trailing_cr(expanded);
+ texts_emit_expanded_line(&emitter, expanded);
+ }
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_texts_send_motd(const hybbx_texts_config_t *texts,
+ hybbx_session_t *session)
+{
+ char path[HYBBX_PATH_MAX];
+ char expanded[HYBBX_LINE_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ char time_text[32];
+ char date_text[32];
+ struct tm now_tm;
+ const struct tm *tm_ptr = NULL;
+ const char *username;
+ hybbx_result_t rc;
+
+ if (texts == NULL || session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ username = hybbx_session_display_name(session);
+ if (username[0] == '\0') {
+ username = "visitor";
+ }
+
+ if (hybbx_time_local_now(&now_tm) == HYBBX_OK) {
+ tm_ptr = &now_tm;
+ }
+ format_text_tokens(time_text, sizeof(time_text),
+ date_text, sizeof(date_text), tm_ptr);
+
+ rc = hybbx_texts_resolve(texts, HYBBX_TEXT_MOTD, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ {
+ texts_line_emitter_t emitter;
+
+ emitter.session = session;
+ emitter.pending_blank = 0;
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ expand_text_line(expanded, sizeof(expanded), line, NULL, NULL,
+ username, NULL, time_text, date_text);
+ texts_trim_trailing_cr(expanded);
+ texts_emit_expanded_line(&emitter, expanded);
+ }
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_texts_send_file(const hybbx_texts_config_t *texts,
+ hybbx_session_t *session,
+ const char *filename)
+{
+ char path[HYBBX_PATH_MAX];
+ char expanded[HYBBX_LINE_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ char time_text[32];
+ char date_text[32];
+ char os_name[64];
+ struct tm now_tm;
+ const struct tm *tm_ptr = NULL;
+ hybbx_result_t rc;
+
+ if (texts == NULL || session == NULL || filename == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_time_local_now(&now_tm) == HYBBX_OK) {
+ tm_ptr = &now_tm;
+ }
+ format_text_tokens(time_text, sizeof(time_text),
+ date_text, sizeof(date_text), tm_ptr);
+ fill_os_name(os_name, sizeof(os_name));
+
+ rc = hybbx_texts_resolve(texts, filename, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return HYBBX_ERR_NOT_FOUND;
+ }
+
+ {
+ texts_line_emitter_t emitter;
+
+ emitter.session = session;
+ emitter.pending_blank = 0;
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ expand_text_line(expanded, sizeof(expanded), line,
+ HYBBX_VERSION_STRING, NULL, NULL, os_name,
+ time_text, date_text);
+ texts_trim_trailing_cr(expanded);
+ texts_emit_expanded_line(&emitter, expanded);
+ }
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_version_fallback(hybbx_session_t *session,
+ const char *os_name)
+{
+ char line[HYBBX_LINE_MAX];
+
+ snprintf(line, sizeof(line), "HyBBX %s", HYBBX_VERSION_STRING);
+ hybbx_session_write_line(session, line);
+ snprintf(line, sizeof(line), "Operating system: %s",
+ (os_name != NULL && os_name[0] != '\0') ? os_name : "unknown");
+ hybbx_session_write_line(session, line);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_texts_send_version(const hybbx_texts_config_t *texts,
+ hybbx_session_t *session)
+{
+ char path[HYBBX_PATH_MAX];
+ char expanded[HYBBX_LINE_MAX];
+ FILE *fp;
+ char line[HYBBX_LINE_MAX];
+ char time_text[32];
+ char date_text[32];
+ char os_name[64];
+ struct tm now_tm;
+ const struct tm *tm_ptr = NULL;
+ hybbx_result_t rc;
+
+ if (texts == NULL || session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fill_os_name(os_name, sizeof(os_name));
+
+ if (hybbx_time_local_now(&now_tm) == HYBBX_OK) {
+ tm_ptr = &now_tm;
+ }
+ format_text_tokens(time_text, sizeof(time_text),
+ date_text, sizeof(date_text), tm_ptr);
+
+ rc = hybbx_texts_resolve(texts, HYBBX_TEXT_VERSION, path, sizeof(path));
+ if (rc != HYBBX_OK) {
+ return send_version_fallback(session, os_name);
+ }
+
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return send_version_fallback(session, os_name);
+ }
+
+ {
+ texts_line_emitter_t emitter;
+
+ emitter.session = session;
+ emitter.pending_blank = 0;
+
+ while (fgets(line, sizeof(line), fp) != NULL) {
+ expand_text_line(expanded, sizeof(expanded), line,
+ HYBBX_VERSION_STRING, NULL, NULL, os_name,
+ time_text, date_text);
+ texts_trim_trailing_cr(expanded);
+ texts_emit_expanded_line(&emitter, expanded);
+ }
+ }
+
+ fclose(fp);
+ return HYBBX_OK;
+}
diff --git a/src/core/traffic.c b/src/core/traffic.c
new file mode 100644
index 0000000..b74d48f
--- /dev/null
+++ b/src/core/traffic.c
@@ -0,0 +1,294 @@
+#include "hybbx/traffic.h"
+#if !defined(HYBBX_CLIENT_BUILD)
+#include "hybbx/config.h"
+#endif
+#include "hybbx/session.h"
+#include "hybbx/terminal.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <string.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#include <sys/time.h>
+#endif
+
+static hybbx_traffic_config_t g_traffic_config;
+static int g_traffic_config_ready;
+
+void hybbx_traffic_config_defaults(hybbx_traffic_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return;
+ }
+
+ cfg->baud = HYBBX_BAUD2400;
+ cfg->line_width = HYBBX_LINE_WIDTH;
+ cfg->pace_output = 1;
+ cfg->ansi = 0;
+ cfg->input_echo = 0;
+}
+
+#if !defined(HYBBX_CLIENT_BUILD)
+static unsigned parse_uint_default(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ unsigned default_value,
+ unsigned min_value,
+ unsigned max_value)
+{
+ unsigned value;
+
+ value = hybbx_config_get_uint(config, section, key, default_value,
+ min_value, max_value);
+ return value;
+}
+#endif
+
+void hybbx_traffic_config_apply(const struct hybbx_config *config)
+{
+ hybbx_traffic_config_defaults(&g_traffic_config);
+
+#if !defined(HYBBX_CLIENT_BUILD)
+ if (config != NULL) {
+ g_traffic_config.baud = parse_uint_default(
+ config, "traffic", "baud", HYBBX_BAUD2400, 300u, 38400u);
+ g_traffic_config.line_width = parse_uint_default(
+ config, "traffic", "line_width", HYBBX_LINE_WIDTH, 20u,
+ HYBBX_LINE_WIDTH_MAX);
+ g_traffic_config.pace_output =
+ hybbx_config_get_bool(config, "traffic", "pace_output", 1);
+ g_traffic_config.ansi =
+ hybbx_config_get_bool(config, "traffic", "ansi", 0);
+ g_traffic_config.input_echo =
+ hybbx_config_get_bool(config, "traffic", "input_echo", 0);
+ }
+
+ hybbx_log_info("[traffic] baud=%u line_width=%u pace=%s ansi=%s echo=%s",
+ g_traffic_config.baud, g_traffic_config.line_width,
+ hybbx_bool_to_string(g_traffic_config.pace_output),
+ hybbx_bool_to_string(g_traffic_config.ansi),
+ hybbx_bool_to_string(g_traffic_config.input_echo));
+#else
+ (void)config;
+#endif
+
+ g_traffic_config_ready = 1;
+}
+
+const hybbx_traffic_config_t *hybbx_traffic_config_get(void)
+{
+ if (!g_traffic_config_ready) {
+ hybbx_traffic_config_defaults(&g_traffic_config);
+ g_traffic_config_ready = 1;
+ }
+
+ return &g_traffic_config;
+}
+
+unsigned hybbx_traffic_byte_delay_us(unsigned baud)
+{
+ if (baud == 0) {
+ return 0;
+ }
+
+ return 10000000u / baud;
+}
+
+static hybbx_result_t emit_raw(struct hybbx_session *session, char ch)
+{
+ const hybbx_traffic_config_t *cfg = hybbx_traffic_config_get();
+ char byte = ch;
+ hybbx_result_t rc;
+
+ if (session == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (session->transport != NULL && session->transport->write != NULL) {
+ rc = session->transport->write(session, &byte, 1);
+ } else {
+ if (fputc((unsigned char)byte, stdout) == EOF) {
+ return HYBBX_ERR_IO;
+ }
+ fflush(stdout);
+ rc = HYBBX_OK;
+ }
+
+ if (rc == HYBBX_OK && cfg->pace_output && cfg->baud > 0) {
+ unsigned delay = hybbx_traffic_byte_delay_us(cfg->baud);
+
+ if (delay > 0) {
+#if defined(_WIN32)
+ Sleep((DWORD)((delay + 999u) / 1000u));
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(delay / 1000000u);
+#if defined(__AMIGA__)
+ tv.tv_usec = (unsigned long)(delay % 1000000u);
+#else
+ tv.tv_usec = (suseconds_t)(delay % 1000000u);
+#endif
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+ }
+ }
+
+ return rc;
+}
+
+static hybbx_result_t emit_newline(struct hybbx_session *session,
+ unsigned *out_col)
+{
+ hybbx_result_t rc;
+
+ rc = emit_raw(session, '\r');
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = emit_raw(session, '\n');
+ if (rc == HYBBX_OK && out_col != NULL) {
+ *out_col = 0;
+ }
+
+ return rc;
+}
+
+static int traffic_plain_char(const char *src, size_t len, size_t *consumed,
+ char *out)
+{
+ unsigned char ch;
+
+ if (src == NULL || len == 0 || consumed == NULL || out == NULL) {
+ return 0;
+ }
+
+ ch = (unsigned char)src[0];
+
+ if (ch == '\r') {
+ *consumed = 1;
+ *out = '\n';
+ return 1;
+ }
+
+ if (ch == '\n') {
+ *consumed = 1;
+ *out = '\n';
+ return 1;
+ }
+
+ if (ch == '\t') {
+ *consumed = 1;
+ *out = ' ';
+ return 1;
+ }
+
+ if (ch < 0x20 || ch == 0x7f) {
+ *consumed = 1;
+ return 0;
+ }
+
+ if (ch == 0x1b && len >= 2 && src[1] == '[') {
+ size_t i = 2;
+
+ while (i < len && (src[i] < 0x40 || src[i] > 0x7e)) {
+ i++;
+ }
+ if (i < len) {
+ *consumed = i + 1;
+ } else {
+ *consumed = len;
+ }
+ return 0;
+ }
+
+ if (ch == 0x1b) {
+ *consumed = 1;
+ return 0;
+ }
+
+ *consumed = 1;
+ *out = (char)ch;
+ return 1;
+}
+
+hybbx_result_t hybbx_traffic_emit(struct hybbx_session *session,
+ unsigned *out_col,
+ const char *data, size_t len)
+{
+ const hybbx_traffic_config_t *cfg = hybbx_traffic_config_get();
+ size_t pos = 0;
+ unsigned col = out_col != NULL ? *out_col : 0;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len == 0) {
+ return HYBBX_OK;
+ }
+
+ if (cfg->ansi && strcmp(data, HYBBX_TERM_SGR_LIGHTGRAY_ON_BLACK) == 0 &&
+ len == strlen(HYBBX_TERM_SGR_LIGHTGRAY_ON_BLACK)) {
+ size_t i;
+
+ for (i = 0; i < len; i++) {
+ hybbx_result_t rc = emit_raw(session, data[i]);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+ }
+
+ while (pos < len) {
+ char ch;
+ size_t step = 0;
+ hybbx_result_t rc;
+
+ if (!traffic_plain_char(data + pos, len - pos, &step, &ch)) {
+ pos += step > 0 ? step : 1;
+ continue;
+ }
+
+ pos += step;
+
+ if (ch == '\n') {
+ rc = emit_newline(session, out_col);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ col = 0;
+ continue;
+ }
+
+ if (cfg->line_width > 0 && col >= cfg->line_width) {
+ rc = emit_newline(session, out_col);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ col = 0;
+ }
+
+ rc = emit_raw(session, ch);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ col++;
+ }
+
+ if (out_col != NULL) {
+ *out_col = col;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/src/core/util.c b/src/core/util.c
new file mode 100644
index 0000000..1c5e9e4
--- /dev/null
+++ b/src/core/util.c
@@ -0,0 +1,832 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+#include "hybbx/socket.h"
+#if !defined(HYBBX_CLIENT_BUILD)
+#include "hybbx/log.h"
+#endif
+
+#include <ctype.h>
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#if !defined(_WIN32) && !defined(__AMIGA__)
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <sys/socket.h>
+#endif
+
+#if !defined(_WIN32) && !defined(__AMIGA__)
+#include <sys/utsname.h>
+#endif
+
+#if !defined(HYBBX_CLIENT_BUILD)
+#include "hybbx/config.h"
+#endif
+
+static int bool_token_ieq(const char *value, const char *token)
+{
+ if (value == NULL || token == NULL) {
+ return 0;
+ }
+
+ while (*value != '\0' && *token != '\0') {
+ unsigned char cv = (unsigned char)tolower((unsigned char)*value);
+ unsigned char ct = (unsigned char)tolower((unsigned char)*token);
+
+ if (cv != ct) {
+ return 0;
+ }
+ value++;
+ token++;
+ }
+
+ return *value == '\0' && *token == '\0';
+}
+
+static int bool_match_any(const char *value, const char *const *tokens, size_t count)
+{
+ size_t i;
+
+ if (value == NULL || value[0] == '\0') {
+ return 0;
+ }
+
+ for (i = 0; i < count; i++) {
+ if (bool_token_ieq(value, tokens[i])) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static const char *const hybbx_bool_true_tokens[] = {
+ HYBBX_BOOL_YES, "true", "enable", "enabled", "on", "1"
+};
+
+static const char *const hybbx_bool_false_tokens[] = {
+ HYBBX_BOOL_NO, "false", "disable", "disabled", "off", "0"
+};
+
+int hybbx_bool_is_true(const char *value)
+{
+ return bool_match_any(value, hybbx_bool_true_tokens,
+ sizeof(hybbx_bool_true_tokens) /
+ sizeof(hybbx_bool_true_tokens[0]));
+}
+
+int hybbx_bool_is_false(const char *value)
+{
+ return bool_match_any(value, hybbx_bool_false_tokens,
+ sizeof(hybbx_bool_false_tokens) /
+ sizeof(hybbx_bool_false_tokens[0]));
+}
+
+int hybbx_parse_bool(const char *value, int default_value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ if (hybbx_bool_is_true(value)) {
+ return 1;
+ }
+
+ if (hybbx_bool_is_false(value)) {
+ return 0;
+ }
+
+ return default_value;
+}
+
+const char *hybbx_bool_to_string(int value)
+{
+ return value ? HYBBX_BOOL_YES : HYBBX_BOOL_NO;
+}
+
+const char *hybbx_result_name(hybbx_result_t rc)
+{
+ switch (rc) {
+ case HYBBX_OK:
+ return "ok";
+ case HYBBX_LOCAL_CMD:
+ return "local_cmd";
+ case HYBBX_SESSION_END:
+ return "session_end";
+ case HYBBX_ERR_INVALID:
+ return "invalid";
+ case HYBBX_ERR_NOMEM:
+ return "nomem";
+ case HYBBX_ERR_NOT_FOUND:
+ return "not_found";
+ case HYBBX_ERR_IO:
+ return "io";
+ case HYBBX_ERR_UNSUPPORTED:
+ return "unsupported";
+ case HYBBX_ERR_BUSY:
+ return "busy";
+ case HYBBX_ERR_DENIED:
+ return "denied";
+ default:
+ return "unknown";
+ }
+}
+
+size_t hybbx_strlcpy(char *dst, const char *src, size_t dst_size)
+{
+ size_t src_len;
+
+ if (dst_size == 0) {
+ return src != NULL ? strlen(src) : 0;
+ }
+
+ if (dst == NULL) {
+ return 0;
+ }
+
+ if (src == NULL) {
+ dst[0] = '\0';
+ return 0;
+ }
+
+ src_len = strlen(src);
+ if (src_len >= dst_size) {
+ memcpy(dst, src, dst_size - 1);
+ dst[dst_size - 1] = '\0';
+ return dst_size - 1;
+ }
+
+ memcpy(dst, src, src_len + 1);
+ return src_len;
+}
+
+hybbx_result_t hybbx_default_user_data_path(char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_strlcpy(out, HYBBX_DIR_DATA, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+static char g_install_root[HYBBX_PATH_MAX];
+
+void hybbx_install_root_set(const char *root)
+{
+ if (root == NULL || root[0] == '\0') {
+ g_install_root[0] = '\0';
+ return;
+ }
+
+ hybbx_strlcpy(g_install_root, root, sizeof(g_install_root));
+}
+
+const char *hybbx_install_root_get(void)
+{
+ return g_install_root[0] != '\0' ? g_install_root : NULL;
+}
+
+static int path_is_absolute(const char *path)
+{
+ if (path == NULL || path[0] == '\0') {
+ return 0;
+ }
+
+ if (path[0] == '/') {
+ return 1;
+ }
+
+#if defined(_WIN32) || defined(__CYGWIN__)
+ if (((path[0] >= 'A' && path[0] <= 'Z') ||
+ (path[0] >= 'a' && path[0] <= 'z')) &&
+ path[1] == ':') {
+ return 1;
+ }
+#endif
+
+ return 0;
+}
+
+hybbx_result_t hybbx_path_resolve(char *out, size_t out_len, const char *path)
+{
+ char expanded[HYBBX_PATH_MAX];
+ const char *root;
+ hybbx_result_t rc;
+
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_path_expand(expanded, sizeof(expanded), path);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (path_is_absolute(expanded)) {
+ if (hybbx_strlcpy(out, expanded, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ root = hybbx_install_root_get();
+ if (root != NULL && root[0] != '\0') {
+ return hybbx_path_join(out, out_len, root, expanded);
+ }
+
+ if (hybbx_strlcpy(out, expanded, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_path_dirname(const char *path, char *out, size_t out_len)
+{
+ char copy[HYBBX_PATH_MAX];
+ char *slash;
+
+ if (path == NULL || out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (hybbx_strlcpy(copy, path, sizeof(copy)) >= sizeof(copy)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ slash = strrchr(copy, '/');
+ if (slash == NULL) {
+ if (hybbx_strlcpy(out, ".", out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ if (slash == copy) {
+ if (hybbx_strlcpy(out, "/", out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ *slash = '\0';
+ if (hybbx_strlcpy(out, copy, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_platform_os_name(char *out, size_t out_len)
+{
+ const char *name;
+
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+#if defined(_WIN32)
+ name = "Windows";
+#elif defined(__AMIGA__)
+ name = "AmigaOS";
+#else
+ {
+ struct utsname u;
+
+ if (uname(&u) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ name = u.sysname;
+ if (name == NULL || name[0] == '\0') {
+ return HYBBX_ERR_IO;
+ }
+
+ if (strcmp(name, "Darwin") == 0) {
+ name = "MacOS";
+ }
+ }
+#endif
+
+ if (hybbx_strlcpy(out, name, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_path_expand(char *out, size_t out_len, const char *path)
+{
+ const char *home;
+
+ if (out == NULL || out_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (path == NULL || path[0] == '\0') {
+ return hybbx_default_user_data_path(out, out_len);
+ }
+
+ if (path[0] != '~') {
+ if (hybbx_strlcpy(out, path, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ if (path[1] != '\0' && path[1] != '/') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ home = getenv("HOME");
+ if (home == NULL || home[0] == '\0') {
+ if (path[1] == '\0') {
+ return hybbx_default_user_data_path(out, out_len);
+ }
+ if (hybbx_strlcpy(out, path + 2, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ if (path[1] == '\0') {
+ if (hybbx_strlcpy(out, home, out_len) >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+ return HYBBX_OK;
+ }
+
+ if (snprintf(out, out_len, "%s%s", home, path + 1) >= (int)out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+int hybbx_size_ok(size_t len)
+{
+ return len > 0 && len <= HYBBX_ALLOC_MAX;
+}
+
+static int path_component_valid(const char *name)
+{
+ if (name == NULL || name[0] == '\0') {
+ return 0;
+ }
+
+ if (strcmp(name, ".") == 0 || strcmp(name, "..") == 0) {
+ return 0;
+ }
+
+ if (strchr(name, '/') != NULL || strchr(name, '\\') != NULL) {
+ return 0;
+ }
+
+ return 1;
+}
+
+hybbx_result_t hybbx_path_join(char *out, size_t out_len,
+ const char *base, const char *name)
+{
+ size_t base_len;
+ int need_slash;
+ int written;
+
+ if (out == NULL || out_len == 0 || base == NULL || name == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!path_component_valid(name)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ base_len = strlen(base);
+ if (!hybbx_size_ok(base_len + 1) || !hybbx_size_ok(strlen(name) + 1)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ need_slash = base_len > 0 && base[base_len - 1] != '/';
+ written = snprintf(out, out_len, "%s%s%s", base, need_slash ? "/" : "", name);
+ if (written < 0 || (size_t)written >= out_len) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_time_format_t g_time_format;
+static int g_time_format_ready;
+
+void hybbx_time_format_defaults(hybbx_time_format_t *fmt)
+{
+ if (fmt == NULL) {
+ return;
+ }
+
+ fmt->clock_12h = 0;
+ fmt->seconds = 1;
+ fmt->date_format = HYBBX_DATE_ISO;
+}
+
+const char *hybbx_date_format_name(hybbx_date_format_t fmt)
+{
+ switch (fmt) {
+ case HYBBX_DATE_ISO_SHORT:
+ return "iso_short";
+ case HYBBX_DATE_US:
+ return "us";
+ case HYBBX_DATE_EU:
+ return "eu";
+ case HYBBX_DATE_ISO:
+ default:
+ return "iso";
+ }
+}
+
+#if !defined(HYBBX_CLIENT_BUILD)
+static hybbx_date_format_t parse_date_format(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_DATE_ISO;
+ }
+
+ if (bool_token_ieq(value, "iso") || bool_token_ieq(value, "yyyy_mm_dd") ||
+ bool_token_ieq(value, "yyyy/mm/dd")) {
+ return HYBBX_DATE_ISO;
+ }
+
+ if (bool_token_ieq(value, "iso_short") || bool_token_ieq(value, "yy_mm_dd") ||
+ bool_token_ieq(value, "yy/mm/dd")) {
+ return HYBBX_DATE_ISO_SHORT;
+ }
+
+ if (bool_token_ieq(value, "us") || bool_token_ieq(value, "mm_dd_yyyy") ||
+ bool_token_ieq(value, "mm/dd/yyyy")) {
+ return HYBBX_DATE_US;
+ }
+
+ if (bool_token_ieq(value, "eu") || bool_token_ieq(value, "dd_mm_yyyy") ||
+ bool_token_ieq(value, "dd/mm/yyyy")) {
+ return HYBBX_DATE_EU;
+ }
+
+ return HYBBX_DATE_ISO;
+}
+#endif
+
+hybbx_result_t hybbx_time_local_now(struct tm *out)
+{
+ time_t now;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ now = time(NULL);
+ if (now == (time_t)-1) {
+ return HYBBX_ERR_IO;
+ }
+
+#if defined(_WIN32)
+ if (localtime_s(out, &now) != 0) {
+ return HYBBX_ERR_IO;
+ }
+#else
+ if (localtime_r(&now, out) == NULL) {
+ return HYBBX_ERR_IO;
+ }
+#endif
+
+ return HYBBX_OK;
+}
+
+const hybbx_time_format_t *hybbx_time_format_get(void)
+{
+ if (!g_time_format_ready) {
+ hybbx_time_format_defaults(&g_time_format);
+ g_time_format_ready = 1;
+ }
+
+ return &g_time_format;
+}
+
+#if !defined(HYBBX_CLIENT_BUILD)
+void hybbx_time_config_apply(const struct hybbx_config *config)
+{
+ const char *clock;
+ int clock_12h;
+
+ hybbx_time_format_defaults(&g_time_format);
+
+ if (config != NULL) {
+ clock_12h = hybbx_config_get_bool(config, "time", "clock_12h", 0);
+ if (!clock_12h) {
+ clock_12h = hybbx_config_get_bool(config, "time", "am_pm", 0);
+ }
+
+ clock = hybbx_config_get(config, "time", "clock", NULL);
+ if (clock != NULL && clock[0] != '\0') {
+ if (bool_token_ieq(clock, "12h") || bool_token_ieq(clock, "12") ||
+ bool_token_ieq(clock, "am_pm") || bool_token_ieq(clock, "am/pm")) {
+ clock_12h = 1;
+ } else if (bool_token_ieq(clock, "24h") || bool_token_ieq(clock, "24")) {
+ clock_12h = 0;
+ }
+ }
+
+ g_time_format.clock_12h = clock_12h;
+ g_time_format.seconds =
+ hybbx_config_get_bool(config, "time", "seconds", 1);
+ g_time_format.date_format = parse_date_format(
+ hybbx_config_get(config, "time", "date", NULL));
+ }
+
+ g_time_format_ready = 1;
+
+ hybbx_log_info("[time] clock=%s seconds=%s date=%s",
+ g_time_format.clock_12h ? "12h" : "24h",
+ hybbx_bool_to_string(g_time_format.seconds),
+ hybbx_date_format_name(g_time_format.date_format));
+}
+#else
+void hybbx_time_config_apply(const struct hybbx_config *config)
+{
+ (void)config;
+ hybbx_time_format_defaults(&g_time_format);
+ g_time_format_ready = 1;
+}
+#endif
+
+hybbx_result_t hybbx_time_format_time(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt)
+{
+ int hour;
+ int minute;
+ int second;
+ const char *suffix;
+ int n;
+
+ if (out == NULL || out_len == 0 || tm == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (fmt == NULL) {
+ fmt = hybbx_time_format_get();
+ }
+
+ hour = tm->tm_hour;
+ minute = tm->tm_min;
+ second = tm->tm_sec;
+ suffix = "";
+
+ if (fmt->clock_12h) {
+ if (hour >= 12) {
+ suffix = "pm";
+ if (hour > 12) {
+ hour -= 12;
+ }
+ } else {
+ suffix = "am";
+ if (hour == 0) {
+ hour = 12;
+ }
+ }
+ }
+
+ if (fmt->clock_12h) {
+ if (fmt->seconds) {
+ n = snprintf(out, out_len, "%02d:%02d:%02d %s",
+ hour, minute, second, suffix);
+ } else {
+ n = snprintf(out, out_len, "%02d:%02d %s", hour, minute, suffix);
+ }
+ } else if (fmt->seconds) {
+ n = snprintf(out, out_len, "%02d:%02d:%02d", hour, minute, second);
+ } else {
+ n = snprintf(out, out_len, "%02d:%02d", hour, minute);
+ }
+
+ if (n < 0 || (size_t)n >= out_len) {
+ out[0] = '\0';
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_time_format_date(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt)
+{
+ int year;
+ int month;
+ int day;
+ int n;
+
+ if (out == NULL || out_len == 0 || tm == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (fmt == NULL) {
+ fmt = hybbx_time_format_get();
+ }
+
+ year = tm->tm_year + 1900;
+ month = tm->tm_mon + 1;
+ day = tm->tm_mday;
+
+ switch (fmt->date_format) {
+ case HYBBX_DATE_ISO_SHORT:
+ n = snprintf(out, out_len, "%02d/%02d/%02d",
+ year % 100, month, day);
+ break;
+ case HYBBX_DATE_US:
+ n = snprintf(out, out_len, "%02d/%02d/%04d", month, day, year);
+ break;
+ case HYBBX_DATE_EU:
+ n = snprintf(out, out_len, "%02d/%02d/%04d", day, month, year);
+ break;
+ case HYBBX_DATE_ISO:
+ default:
+ n = snprintf(out, out_len, "%04d/%02d/%02d", year, month, day);
+ break;
+ }
+
+ if (n < 0 || (size_t)n >= out_len) {
+ out[0] = '\0';
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_time_format_stamp(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt)
+{
+ int year;
+ int month;
+ int day;
+ int hour;
+ int minute;
+ int second;
+ const char *suffix;
+ int n;
+
+ if (out == NULL || out_len == 0 || tm == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (fmt == NULL) {
+ fmt = hybbx_time_format_get();
+ }
+
+ year = tm->tm_year + 1900;
+ month = tm->tm_mon + 1;
+ day = tm->tm_mday;
+ hour = tm->tm_hour;
+ minute = tm->tm_min;
+ second = tm->tm_sec;
+ suffix = "";
+
+ if (fmt->clock_12h) {
+ if (hour >= 12) {
+ suffix = "pm";
+ if (hour > 12) {
+ hour -= 12;
+ }
+ } else {
+ suffix = "am";
+ if (hour == 0) {
+ hour = 12;
+ }
+ }
+ }
+
+ if (fmt->clock_12h) {
+ if (fmt->seconds) {
+ n = snprintf(out, out_len, "%04d%02d%02d %02d:%02d:%02d %s",
+ year, month, day, hour, minute, second, suffix);
+ } else {
+ n = snprintf(out, out_len, "%04d%02d%02d %02d:%02d %s",
+ year, month, day, hour, minute, suffix);
+ }
+ } else if (fmt->seconds) {
+ n = snprintf(out, out_len, "%04d%02d%02d %02d:%02d:%02d",
+ year, month, day, hour, minute, second);
+ } else {
+ n = snprintf(out, out_len, "%04d%02d%02d %02d:%02d",
+ year, month, day, hour, minute);
+ }
+
+ if (n < 0 || (size_t)n >= out_len) {
+ out[0] = '\0';
+ return HYBBX_ERR_INVALID;
+ }
+
+ return HYBBX_OK;
+}
+
+void hybbx_socket_nosigpipe(int fd)
+{
+#ifdef SO_NOSIGPIPE
+ int on = 1;
+
+ if (fd >= 0) {
+ (void)setsockopt(fd, SOL_SOCKET, SO_NOSIGPIPE, &on, sizeof(on));
+ }
+#else
+ (void)fd;
+#endif
+}
+
+void hybbx_socket_log_bind_failure(const char *component, const char *addr,
+ unsigned port)
+{
+ int err = errno;
+
+ if (component == NULL || addr == NULL) {
+ return;
+ }
+
+#if !defined(HYBBX_CLIENT_BUILD)
+ hybbx_log_warn("[%s] failed to bind %s:%u (%s)", component, addr, port,
+ strerror(err));
+ if (err == EADDRINUSE) {
+ hybbx_log_warn("[%s] port %u already in use — another HyBBX instance may "
+ "already be running (ss -tlnp | grep %u)",
+ component, port, port);
+ }
+#else
+ fprintf(stderr, "[%s] failed to bind %s:%u (%s)\n", component, addr, port,
+ strerror(err));
+ if (err == EADDRINUSE) {
+ fprintf(stderr,
+ "[%s] port %u already in use — another HyBBX instance may "
+ "already be running (ss -tlnp | grep %u)\n",
+ component, port, port);
+ }
+#endif
+}
+
+hybbx_result_t hybbx_socket_peer_name(int fd, char *buf, size_t buf_len)
+{
+#if defined(_WIN32) || defined(__AMIGA__)
+ (void)fd;
+ (void)buf;
+ (void)buf_len;
+ return HYBBX_ERR_UNSUPPORTED;
+#else
+ struct sockaddr_storage addr;
+ socklen_t addr_len = sizeof(addr);
+ const void *ip;
+ char ip_buf[INET6_ADDRSTRLEN];
+
+ if (buf == NULL || buf_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ buf[0] = '\0';
+
+ if (fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (getpeername(fd, (struct sockaddr *)&addr, &addr_len) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (addr.ss_family == AF_INET) {
+ const struct sockaddr_in *in4 = (const struct sockaddr_in *)&addr;
+
+ ip = &in4->sin_addr;
+ } else if (addr.ss_family == AF_INET6) {
+ const struct sockaddr_in6 *in6 = (const struct sockaddr_in6 *)&addr;
+
+ ip = &in6->sin6_addr;
+ } else {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (inet_ntop(addr.ss_family, ip, ip_buf, sizeof(ip_buf)) == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_strlcpy(buf, ip_buf, buf_len);
+ return HYBBX_OK;
+#endif
+}
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com