summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorinfo@mode42.com <info@mode42.com>2026-08-08 03:54:55 +0000
committerinfo@mode42.com <info@mode42.com>2026-08-08 03:54:55 +0000
commit20cb29c2f8c5c87bc590896854a20b1473ceb358 (patch)
tree2857f41513a56ad41af97b57362639298aa7f033
#2
-rw-r--r--.cursor/rules/hybbx-c.mdc22
-rw-r--r--.cursor/rules/hybbx-project.mdc35
-rw-r--r--.cursor/rules/public-product-terminology.mdc31
-rw-r--r--.editorconfig23
-rw-r--r--.gitignore29
-rw-r--r--AGENTS.md65
-rw-r--r--CHANGELOG.md57
-rw-r--r--CMakeLists.txt272
-rw-r--r--CONTRIBUTING.md29
-rw-r--r--LICENSE.txt691
-rw-r--r--README.md70
-rw-r--r--cmake/Compiler.cmake97
-rw-r--r--cmake/Hardening.cmake44
-rw-r--r--cmake/HyBBXConfig.cmake.in11
-rw-r--r--cmake/Toolchain-AmigaOS.cmake39
-rw-r--r--cmake/Toolchain-Clang.cmake6
-rw-r--r--docs/ARDOP.md27
-rw-r--r--docs/BAYCOM.md57
-rw-r--r--docs/BUILD.md44
-rw-r--r--docs/CLIENTS.md28
-rw-r--r--docs/COMMANDS.md36
-rw-r--r--docs/CRDOP.md35
-rw-r--r--docs/DEVELOPMENT.md37
-rw-r--r--docs/DUAL-TNC-OPERATOR-GUIDE.md179
-rw-r--r--docs/ENTERTAIN.md25
-rw-r--r--docs/LICENSING.md26
-rw-r--r--docs/MAINS_PROXY.md37
-rw-r--r--docs/MANUAL.md63
-rw-r--r--docs/MASTER-GUIDE.md211
-rw-r--r--docs/PLATFORMS.md28
-rw-r--r--docs/README.md32
-rw-r--r--docs/SECURITY.md38
-rw-r--r--docs/TMODEM.md32
-rw-r--r--docs/TNCS.md56
-rw-r--r--docs/TOPOLOGY.md43
-rw-r--r--docs/WEBSOCKET.md27
-rw-r--r--include/hybbx/ardop.h50
-rw-r--r--include/hybbx/auth.h117
-rw-r--r--include/hybbx/ax25.h68
-rw-r--r--include/hybbx/bandwidth_policy.h35
-rw-r--r--include/hybbx/baycom.h82
-rw-r--r--include/hybbx/broadcast.h110
-rw-r--r--include/hybbx/chat.h82
-rw-r--r--include/hybbx/circuit.h119
-rw-r--r--include/hybbx/circuit_balance.h131
-rw-r--r--include/hybbx/circuit_bridge.h46
-rw-r--r--include/hybbx/circuit_tcp.h162
-rw-r--r--include/hybbx/command.h71
-rw-r--r--include/hybbx/commands_registry.h73
-rw-r--r--include/hybbx/conference.h69
-rw-r--r--include/hybbx/config.h126
-rw-r--r--include/hybbx/crdop.h44
-rw-r--r--include/hybbx/crypto.h101
-rw-r--r--include/hybbx/crypto_config.h69
-rw-r--r--include/hybbx/daemon_wrap.h36
-rw-r--r--include/hybbx/hybbx.h10
-rw-r--r--include/hybbx/instance.h61
-rw-r--r--include/hybbx/kiss.h61
-rw-r--r--include/hybbx/limits.h136
-rw-r--r--include/hybbx/link.h86
-rw-r--r--include/hybbx/log.h64
-rw-r--r--include/hybbx/mail.h129
-rw-r--r--include/hybbx/mains_proxy.h120
-rw-r--r--include/hybbx/max25.h72
-rw-r--r--include/hybbx/messages.h58
-rw-r--r--include/hybbx/monitor.h74
-rw-r--r--include/hybbx/networks.h63
-rw-r--r--include/hybbx/packet_radio.h57
-rw-r--r--include/hybbx/password.h53
-rw-r--r--include/hybbx/plugin.h75
-rw-r--r--include/hybbx/posix_time.h21
-rw-r--r--include/hybbx/privilege.h24
-rw-r--r--include/hybbx/proxychat.h40
-rw-r--r--include/hybbx/proxymail.h73
-rw-r--r--include/hybbx/registry.h28
-rw-r--r--include/hybbx/rf_tx_pace.h16
-rw-r--r--include/hybbx/security.h31
-rw-r--r--include/hybbx/security_ban.h80
-rw-r--r--include/hybbx/service.h174
-rw-r--r--include/hybbx/session.h226
-rw-r--r--include/hybbx/socket.h35
-rw-r--r--include/hybbx/ssh.h57
-rw-r--r--include/hybbx/storage.h178
-rw-r--r--include/hybbx/telnet.h40
-rw-r--r--include/hybbx/terminal.h39
-rw-r--r--include/hybbx/texts.h84
-rw-r--r--include/hybbx/tnc.h240
-rw-r--r--include/hybbx/tnc2c.h27
-rw-r--r--include/hybbx/traffic.h59
-rw-r--r--include/hybbx/types.h42
-rw-r--r--include/hybbx/util.h143
-rw-r--r--include/hybbx/websocket.h53
-rw-r--r--plugins/CMakeLists.txt44
-rw-r--r--plugins/ardop/CMakeLists.txt57
-rw-r--r--plugins/ardop/ardop.c49
-rw-r--r--plugins/ardop/ardop_config.c186
-rw-r--r--plugins/ardop/ardop_crc.c69
-rw-r--r--plugins/ardop/ardop_crc.h15
-rw-r--r--plugins/ardop/ardop_host.c650
-rw-r--r--plugins/ardop/ardop_host.h47
-rw-r--r--plugins/ardop/ardop_link.c446
-rw-r--r--plugins/ardop/ardop_link.h37
-rw-r--r--plugins/baycom/CMakeLists.txt36
-rw-r--r--plugins/baycom/baycom.c623
-rw-r--r--plugins/baycom/baycom_config.c395
-rw-r--r--plugins/baycom/baycom_kernel.c472
-rw-r--r--plugins/baycom/baycom_kernel.h26
-rw-r--r--plugins/baycom/baycom_kiss.c262
-rw-r--r--plugins/baycom/baycom_kiss.h26
-rw-r--r--plugins/baycom/baycom_modem.c94
-rw-r--r--plugins/baycom/baycom_modem.h31
-rw-r--r--plugins/crdop/CMakeLists.txt26
-rw-r--r--plugins/crdop/crdop.c51
-rw-r--r--plugins/crdop/crdop_config.c186
-rw-r--r--plugins/mains_proxy/CMakeLists.txt23
-rw-r--r--plugins/mains_proxy/mains_proxy.c137
-rw-r--r--plugins/packet_radio/CMakeLists.txt36
-rw-r--r--plugins/packet_radio/ax25.c250
-rw-r--r--plugins/packet_radio/kiss.c152
-rw-r--r--plugins/packet_radio/packet_radio.c1005
-rw-r--r--plugins/packet_radio/packet_radio_config.c786
-rw-r--r--plugins/packet_radio/serial_port.c705
-rw-r--r--plugins/packet_radio/serial_port.h52
-rw-r--r--plugins/packet_radio/sixpack.c146
-rw-r--r--plugins/packet_radio/sixpack.h40
-rw-r--r--plugins/packet_radio/tnc.c1080
-rw-r--r--plugins/packet_radio/tnc_host.c143
-rw-r--r--plugins/packet_radio/tnc_host.h51
-rw-r--r--plugins/packet_radio/tnc_profiles.c399
-rw-r--r--plugins/ssh/CMakeLists.txt35
-rw-r--r--plugins/ssh/ssh.c728
-rw-r--r--plugins/ssh/ssh_config.c132
-rw-r--r--plugins/ssh/ssh_keys.c146
-rw-r--r--plugins/telnet/CMakeLists.txt26
-rw-r--r--plugins/telnet/telnet.c516
-rw-r--r--plugins/telnet/telnet_config.c125
-rw-r--r--plugins/telnet/telnet_proto.c213
-rw-r--r--plugins/telnet/telnet_proto.h37
-rw-r--r--plugins/websocket/CMakeLists.txt37
-rw-r--r--plugins/websocket/websocket.c723
-rw-r--r--plugins/websocket/ws_config.c163
-rw-r--r--plugins/websocket/ws_proto.c565
-rw-r--r--plugins/websocket/ws_proto.h63
-rw-r--r--plugins/websocket/ws_sha1.c138
-rw-r--r--plugins/websocket/ws_sha1.h9
-rw-r--r--plugins/websocket/ws_tls.c400
-rw-r--r--plugins/websocket/ws_tls.h43
-rwxr-xr-xscripts/build-amiga-telnet.sh26
-rwxr-xr-xscripts/build-clients.sh19
-rwxr-xr-xscripts/dev-setup.sh19
-rwxr-xr-xscripts/hybbx.sh86
-rwxr-xr-xscripts/mock-ardopc.py137
-rwxr-xr-xscripts/test-ardop-plugin.sh89
-rwxr-xr-xscripts/test-crdop-plugin.sh97
-rw-r--r--share/THIRD_PARTY_NOTICES.txt22
-rw-r--r--share/areas.yaml66
-rw-r--r--share/cgi/hybbx-telnet.cgi.example18
-rw-r--r--share/commands.yaml306
-rw-r--r--share/data.gitkeep0
-rw-r--r--share/fail2ban/filter.d/hybbx-circuit.conf10
-rw-r--r--share/fail2ban/filter.d/hybbx-ssh.conf11
-rw-r--r--share/fail2ban/filter.d/hybbx-telnet.conf10
-rw-r--r--share/fail2ban/filter.d/hybbx-websocket.conf11
-rw-r--r--share/fail2ban/jail.d/hybbx.local.example45
-rw-r--r--share/hybbx-main.ini.example97
-rw-r--r--share/hybbx-mesh.ini.example93
-rw-r--r--share/hybbx-secondary.ini.example64
-rw-r--r--share/hybbx-standalone.ini.example125
-rw-r--r--share/hybbx.ini.example17
-rw-r--r--share/hybbxd-main.ini.example82
-rw-r--r--share/hybbxpd-proxy.ini.example61
-rw-r--r--share/hybbxsd-secondary.ini.example41
-rw-r--r--share/logs.gitkeep0
-rw-r--r--share/reverse-proxy/README.txt6
-rw-r--r--share/reverse-proxy/apache2.conf.example24
-rw-r--r--share/reverse-proxy/docroot/README.txt7
-rw-r--r--share/reverse-proxy/docroot/hybbx-websocket/hybbx-terminal.js95
-rw-r--r--share/reverse-proxy/docroot/hybbx-websocket/index.php89
-rw-r--r--share/reverse-proxy/lighttpd.conf.example26
-rw-r--r--share/reverse-proxy/nginx.conf.example28
-rw-r--r--src/CMakeLists.txt204
-rw-r--r--src/clients/CMakeLists.txt96
-rw-r--r--src/clients/circuit_client.c294
-rw-r--r--src/clients/client_display.c87
-rw-r--r--src/clients/client_display.h30
-rw-r--r--src/clients/client_line.c495
-rw-r--r--src/clients/client_line.h33
-rw-r--r--src/clients/hybbx_ssh.c495
-rw-r--r--src/clients/hybbx_telnet.c544
-rw-r--r--src/clients/hybbx_terminal.c433
-rw-r--r--src/clients/link_auth.c182
-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
-rw-r--r--src/instance_role_main.c7
-rw-r--r--src/instance_role_proxy.c7
-rw-r--r--src/instance_role_secondary.c7
-rw-r--r--src/main.c371
-rw-r--r--tests/CMakeLists.txt33
-rw-r--r--tests/test_config.c60
-rw-r--r--tests/test_instance.c170
-rw-r--r--tests/test_mains_proxy.c189
-rw-r--r--tests/test_mains_proxy_e2e.c360
-rw-r--r--tests/test_storage_smoke.c235
-rw-r--r--tests/test_util.c152
-rw-r--r--text/banner.txt4
-rw-r--r--text/motd.txt5
-rw-r--r--text/news.txt1
-rw-r--r--text/rules.txt50
-rw-r--r--text/version.txt2
-rw-r--r--third_party/monocypher/monocypher.c2956
-rw-r--r--third_party/monocypher/monocypher.h321
-rw-r--r--third_party/tinyaes/aes.c572
-rw-r--r--third_party/tinyaes/aes.h97
-rw-r--r--third_party/tinyaes/aes256gcm.c267
-rw-r--r--third_party/tinyaes/aes256gcm.h32
-rw-r--r--third_party/tinyaes/hybbx_aes_config.h13
-rw-r--r--third_party/tinysha256/README.md8
-rw-r--r--third_party/tinysha256/tinysha256.c210
-rw-r--r--third_party/tinysha256/tinysha256.h30
267 files changed, 60955 insertions, 0 deletions
diff --git a/.cursor/rules/hybbx-c.mdc b/.cursor/rules/hybbx-c.mdc
new file mode 100644
index 0000000..acd8b08
--- /dev/null
+++ b/.cursor/rules/hybbx-c.mdc
@@ -0,0 +1,22 @@
+---
+description: C/H coding patterns for HyBBX core and plugins
+globs: "**/*.{c,h}"
+alwaysApply: false
+---
+
+# HyBBX C conventions
+
+```c
+/* ✅ Public API in include/hybbx/ */
+hybbx_result_t hybbx_foo_do_thing(hybbx_foo_t *foo, const char *in);
+
+/* ✅ Booleans from INI / config strings */
+int enabled = hybbx_parse_bool(value, default_value);
+
+/* ✅ Safe copy */
+hybbx_strlcpy(dst, src, sizeof(dst));
+```
+
+- Return `HYBBX_ERR_*` from [include/hybbx/types.h](../../include/hybbx/types.h); `HYBBX_OK` on success.
+- Plugin wire code stays in `plugins/` — not `src/core/`.
+- Packet radio: TNC/AX.25 in `plugins/packet_radio/`; HBX client to `[circuit]`.
diff --git a/.cursor/rules/hybbx-project.mdc b/.cursor/rules/hybbx-project.mdc
new file mode 100644
index 0000000..df97cf5
--- /dev/null
+++ b/.cursor/rules/hybbx-project.mdc
@@ -0,0 +1,35 @@
+---
+description: HyBBX project conventions for all work in this repo
+alwaysApply: true
+---
+
+# HyBBX project rules
+
+Read [AGENTS.md](../../AGENTS.md) and [docs/DEVELOPMENT.md](../../docs/DEVELOPMENT.md).
+
+## Product
+
+Plugin-only session daemon; Main + Secondary HBX topology ([TOPOLOGY.md](../../docs/TOPOLOGY.md)). **v2.0.0**. Technical docs in `docs/`; root README is short overview.
+
+## Code
+
+- C99, `hybbx_` prefix, `hybbx_result_t` errors
+- Core = sessions + HBX/TCP only — no wire-protocol parsing in `src/core/`
+- Plugins in `plugins/`; booleans via `hybbx_parse_bool()`
+- Minimal scope; [limits.h](../../include/hybbx/limits.h)
+- **POSIX+ / portability:** shared code stays POSIX-friendly; keep `*BSD` (FreeBSD, NetBSD, OpenBSD, …) and AmigaOS 3.9+ compatible; platform code behind `_WIN32` / `__AMIGA__` guards or dedicated files
+
+## Docs
+
+- **Technical only** — operator/INI → MANUAL.md + `share/*.ini.example`; commands → COMMANDS.md + commands.yaml; build → BUILD.md
+- No planning, roadmap, feature-status, or release-note docs
+- **Current version only** — no prior-version references in docs, `text/`, or user-facing comments
+- **As-is software** — compact prose; no upgrade/migration/history sections
+- **Linux-based operator docs** — `docs/`, `text/`, README, share examples. `HTTPD_DOCROOT`, `systemctl`, `ss`. Platform targets only in `docs/PLATFORMS.md` — own sections per OS (Linux, AmigaOS 3.9+, *BSD, MacOS X+, Windows 10+, …); spell **MacOS**, not macOS
+- **POSIX+ / portability** — `*BSD` (FreeBSD, NetBSD, OpenBSD, …) and AmigaOS 3.9+ stay compatible; shared code easy to port; no Linux-only APIs in core without guards
+
+## Git
+
+- **Identity only:** `ngteq <info@un1t.me>` — no other author name or email in this repo (`.git/config`: `user.name`, `user.email`)
+- **Push only:** SSH `~/.ssh/id_ed25519_ngteq` → `git@github.com:ngteq/hyBBX.git`
+- No commit/push unless the user asks
diff --git a/.cursor/rules/public-product-terminology.mdc b/.cursor/rules/public-product-terminology.mdc
new file mode 100644
index 0000000..5a84b52
--- /dev/null
+++ b/.cursor/rules/public-product-terminology.mdc
@@ -0,0 +1,31 @@
+---
+description: Public product terminology — HyBBX BBX (not BBS); never AX25SRV in shipped docs
+alwaysApply: true
+---
+
+# Public product terminology (static rule)
+
+**Scope:** GitHub-facing and shipped `*.md` in this repo. Full spec: MASTER vault [AGENT-INDEX §0.16](https://github.com/ngteq/0-RESEARCHES/blob/main/AGENT-INDEX.md#016-public-product-terminology-static-rule) · `operations/2026-07-15-public-product-terminology-rule.md`.
+
+## HyBBX — use BBX, not BBS
+
+| Context | Term |
+|---------|------|
+| HyBBX product / UN1TME instance | **BBX** |
+| Forbidden in HyBBX public copy | **BBS** |
+
+## Other projects — BBS allowed
+
+| Context | Term |
+|---------|------|
+| MAX25-Stack, baycom-pr, generic packet apps | **BBS** OK |
+| Referring to HyBBX by name | **HyBBX** or **BBX** — not “HyBBX BBS” |
+
+## AX25SRV — never public
+
+| Scope | Rule |
+|-------|------|
+| Shipped docs (README, `docs/*.md`, GitHub) | **Never** `AX25SRV`, `ax25srv` in prose, titles, or filenames |
+| Replace with | “dual-TNC operator flow”, `DUAL-TNC-OPERATOR-GUIDE.md`, `hybbx.un1t.me`, “UN1TME” |
+
+**Relation:** document-standard · terms-exclusion
diff --git a/.editorconfig b/.editorconfig
new file mode 100644
index 0000000..ff5bb66
--- /dev/null
+++ b/.editorconfig
@@ -0,0 +1,23 @@
+# HyBBX — https://editorconfig.org
+root = true
+
+[*]
+charset = utf-8
+end_of_line = lf
+insert_final_newline = true
+trim_trailing_whitespace = true
+
+[*.{c,h}]
+indent_style = space
+indent_size = 4
+
+[*.{md,ini,sh,yml,yaml}]
+indent_style = space
+indent_size = 4
+
+[CMakeLists.txt]
+indent_style = space
+indent_size = 4
+
+[Makefile]
+indent_style = tab
diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..e8364e9
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,29 @@
+# Build artifacts
+build/
+build-*/
+bin/
+out/
+logs/
+data/
+local/*
+!local/README.md
+local/hybbx.ini
+share/keys/
+shared/keys/
+share/data-proxy/
+share/data-secondary/
+cmake-build-*/
+.cursor/cache/
+
+# IDE
+.idea/
+.vscode/
+*.swp
+compile_commands.json
+
+# OS
+.DS_Store
+Thumbs.db
+
+# vault/agent — never ship
+AGENT-INDEX.md
diff --git a/AGENTS.md b/AGENTS.md
new file mode 100644
index 0000000..e9b0ab8
--- /dev/null
+++ b/AGENTS.md
@@ -0,0 +1,65 @@
+# HyBBX — agent guide
+
+Humans: [CONTRIBUTING.md](CONTRIBUTING.md). **v2.0.0**.
+
+## Product
+
+Plugin-only session daemon. **Main** = users + telnet + HBX hub. **Secondary** = remote RF host (HBX client to Main). No modem DSP inside `hybbx`.
+
+**Text-first:** users work in plain-text terminals (`/` commands, mail, chat, line output). Core and operator docs assume text sessions on low-bandwidth links. **Extensible:** plugins and transports may add ANSI, menus, or client graphics (WebSocket/httpd) without moving wire or game logic into `src/core/`. **Portable:** POSIX+ C99 shared code — Linux, *BSD, AmigaOS 3.9+, MacOS, Windows ([PLATFORMS.md](docs/PLATFORMS.md)).
+
+| In tree | External |
+|---------|----------|
+| `src/core/`, `plugins/telnet` | TNC, KISS, sound-card apps |
+| `plugins/ssh`, `plugins/websocket` | libssh; httpd for browser UI |
+| Built-in `[security]` (network + abuse) | Optional external fail2ban filters in `share/fail2ban/` |
+| `plugins/packet_radio` | Serial/USB TNC — RF prep: **MainAX25-Stack (MAX25)** `hardware/tncs` |
+| `plugins/baycom` | BayCom modem path — prep: MAX25 `hardware/modems` (replaces standalone BayCom PR-Stack) |
+| `plugins/ardop`, `plugins/crdop` | ARDOPC; CRDOPC — soft-modem prep: MAX25 `hardware/soft-modems` (replaces standalone CRDOP tree) |
+| `plugins/entertain_*` (e.g. chess) | Entertain Area — games/apps on Main only |
+
+MAX25 owns TNC boot-wait, BayCom kernel lifecycle, and CRDOPC start; HyBBX plugins open serial/TCP **after** prep. Contract: MainAX25-Stack (MAX25) `docs/HYBBX.md`.
+
+## Rules
+
+1. Core = sessions + HBX/TCP only — no KISS/AX.25/telnet wire parsing in `src/core/`
+2. **Inter-node transport:** Secondary→Main, Main↔Main (`mains_proxy`), and proxy relay (`proxymail`/`proxychat`) MUST use HBX/Circuit (`hybbx_circuit_link_connect` + `LINK_AUTH`) — never raw TCP/AX.25 sockets between HyBBX processes. User sessions (telnet/SSH/WebSocket) are separate. Proxy carries user services only — no admin actions.
+3. Plugins: `hybbx_transport_plugin_t` in `plugins/`; **Entertain Area apps are plugins only** — see [ENTERTAIN.md](docs/ENTERTAIN.md)
+4. Booleans: `hybbx_parse_bool()` — `yes`/`no`
+5. Buffers: [limits.h](include/hybbx/limits.h)
+6. **Portability:** POSIX+ friendly — C99 and portable POSIX in shared code; keep `*BSD` (FreeBSD, NetBSD, OpenBSD, …) and **AmigaOS 3.9+** compatible; isolate platform code (`_WIN32`, `__AMIGA__`, …). New core changes should stay easy to port.
+7. **Logging:** `[log] level` = `debug` \| `stats` \| `info` \| `warn` (default). `hybbx_log_*` writes console + daily file when enabled. See [MANUAL.md](docs/MANUAL.md) `[log]`.
+8. **Docs — technical only.** Operator/INI → [MANUAL.md](docs/MANUAL.md) + `share/*.ini.example`. Commands → `share/areas.yaml` + [commands.yaml](share/commands.yaml) + [COMMANDS.md](docs/COMMANDS.md). Build → [BUILD.md](docs/BUILD.md). No planning, roadmap, or feature-status docs.
+9. **Commands:** user groups are Sysop, Admin, Mod, User, Guest only (no “Staff”). Help: two lines (`/<verb> …` + `Help: …`); headers use `/help <cmd> for more`. Output: one blank line before `/command` reply, none after (not chat/mail compose). `/broadcast` (alias `/announce`) = Sysop → all online users on **local Main** only.
+10. **Proxy network** (`mains_proxy`): user services (`proxymail`, `proxychat`) only — no Sysop/Admin/Mod actions across proxy links.
+11. **Version:** current `HYBBX_VERSION_STRING` only in code and brief doc headers. As-is software — compact text, no upgrade/history bloat.
+12. **Documentation — Linux-based.** Operator docs in `docs/`, `text/`, README, and share examples assume Linux (`HTTPD_DOCROOT`, `systemctl`, `ss`). Platform targets belong in [PLATFORMS.md](docs/PLATFORMS.md) only — each OS in its own section (Linux, AmigaOS 3.9+, *BSD [FreeBSD, NetBSD, OpenBSD, …], **MacOS X+**, **Windows 10+**; spell **MacOS**, not macOS). Do not mix MacOS into *BSD rubrics elsewhere.
+13. **Git** — no commit/push unless the user asks. **Only** `ngteq <info@un1t.me>` for author/committer (`.git/config`). Push **only** via `~/.ssh/id_ed25519_ngteq` → `git@github.com:ngteq/hyBBX.git`.
+
+## Doc map
+
+| File | Use |
+|------|-----|
+| [MANUAL.md](docs/MANUAL.md) | INI + operator |
+| [COMMANDS.md](docs/COMMANDS.md) | Command registry, help layout |
+| [commands.yaml](share/commands.yaml) | Command registry (help, aliases, access) |
+| [areas.yaml](share/areas.yaml) | Menu/index area layout |
+| [BUILD.md](docs/BUILD.md) | CMake |
+| [DEVELOPMENT.md](docs/DEVELOPMENT.md) | Code rules |
+| [TOPOLOGY.md](docs/TOPOLOGY.md) | Main, Secondary, mains-proxy; **HBX** (Hybrid Bridge eXchange v1) |
+| [SECURITY.md](docs/SECURITY.md) | Security + spam policy |
+| [WEBSOCKET.md](docs/WEBSOCKET.md) | WebSocket deploy |
+| [MAINS_PROXY.md](docs/MAINS_PROXY.md) | Proxy network INI |
+| [TNCS.md](docs/TNCS.md) | Packet radio TNC profiles |
+| [BAYCOM.md](docs/BAYCOM.md) | BayCom PR-Stack |
+| [ARDOP.md](docs/ARDOP.md) · [CRDOP.md](docs/CRDOP.md) | Modem plugins |
+| [CLIENTS.md](docs/CLIENTS.md) | CLI clients |
+| [PLATFORMS.md](docs/PLATFORMS.md) | Per-OS build notes (MacOS, Windows, …) |
+| [ENTERTAIN.md](docs/ENTERTAIN.md) | Entertain Area — plugin-only apps (chess, …) |
+
+## Build
+
+```bash
+./scripts/dev-setup.sh
+ctest --test-dir build # -DHYBBX_BUILD_TESTS=ON
+```
diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..d328409
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,57 @@
+# Changelog · HyBBX
+
+All notable changes to HyBBX are documented here.
+
+## [HyBBX 2.4.1]
+
+Version `2.4.1`. Patch release.
+
+### Menu / help display
+
+| Fix | Detail |
+|-----|--------|
+| General area wrap | `/menu` and `/index` split long verb lists across continuation lines (80-col wire format) — fixes truncated `/users /` and missing `/session` `/version` |
+
+### Docs — BayCom plugin
+
+| Change | Detail |
+|--------|--------|
+| Wording | BayCom = optional transport plugin (CMake ON; example INI `baycom=no`) — removed misleading “rejected” labels |
+
+## [HyBBX 2.4.0]
+
+Version `2.4.0`. Product label **HyBBX 2.4.0**.
+
+### RF / MAX25 integration matrix
+
+| Change | Detail |
+|--------|--------|
+| MAX25-minimal boundary | MAX25 owns hardware prep; HyBBX probes max25d and attaches KISS only |
+| `[max25] check=yes` | Local `packet_radio` **requires** reachable max25d TCP `:7325` |
+| `kiss_entry=none` | Default after MAX25 prep — HyBBX does not send `kiss on` |
+| BayCom plugin | Built by default; example INI templates set `[networks] baycom=no` |
+
+### Operator INI matrix
+
+| Key | v2.4.0 value |
+|-----|--------------|
+| `[max25] check` | `yes` |
+| `kiss_entry` | `none` |
+| `[networks] baycom` | `no` |
+
+### Carried from v2.0.0-p2
+
+| Item | Notes |
+|------|-------|
+| YAML menu/rights registry, `hybbxd`, screen/tmux | baseline |
+| TheFirmware TNC terminal recovery before KISS | baseline |
+| AX.25 broadcast timing / dual-TNC sequencing | baseline |
+| Circuit reconnect race fix | baseline |
+| Unified log levels | baseline |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Master guide | [docs/MASTER-GUIDE.md](docs/MASTER-GUIDE.md) |
+| TNC profiles | [docs/TNCS.md](docs/TNCS.md) |
diff --git a/CMakeLists.txt b/CMakeLists.txt
new file mode 100644
index 0000000..70a6c32
--- /dev/null
+++ b/CMakeLists.txt
@@ -0,0 +1,272 @@
+cmake_minimum_required(VERSION 3.16)
+
+# Default C compiler when CC / CMAKE_C_COMPILER are not set:
+# *BSD (FreeBSD, NetBSD, OpenBSD, …) → LLVM Clang (native toolchain on BSD)
+# other → GCC
+if(NOT DEFINED ENV{CC} AND NOT CMAKE_C_COMPILER)
+ if(CMAKE_HOST_SYSTEM_NAME MATCHES "^(FreeBSD|OpenBSD|NetBSD|DragonFly)$")
+ find_program(HYBBX_PREFERRED_CLANG NAMES clang)
+ if(HYBBX_PREFERRED_CLANG)
+ set(CMAKE_C_COMPILER "${HYBBX_PREFERRED_CLANG}"
+ CACHE FILEPATH "HyBBX C compiler (Clang on BSD)" FORCE)
+ else()
+ find_program(HYBBX_PREFERRED_GCC NAMES gcc)
+ if(HYBBX_PREFERRED_GCC)
+ set(CMAKE_C_COMPILER "${HYBBX_PREFERRED_GCC}"
+ CACHE FILEPATH "HyBBX C compiler (GCC fallback on BSD)" FORCE)
+ endif()
+ unset(HYBBX_PREFERRED_GCC)
+ endif()
+ unset(HYBBX_PREFERRED_CLANG)
+ else()
+ find_program(HYBBX_PREFERRED_GCC NAMES gcc)
+ if(HYBBX_PREFERRED_GCC)
+ set(CMAKE_C_COMPILER "${HYBBX_PREFERRED_GCC}"
+ CACHE FILEPATH "HyBBX primary C compiler (GCC)" FORCE)
+ endif()
+ unset(HYBBX_PREFERRED_GCC)
+ endif()
+endif()
+
+project(HyBBX
+ VERSION 2.8.0
+ DESCRIPTION "C99 transport service: centralized daemon + link/repeater edge"
+ LANGUAGES C
+)
+
+set(CMAKE_C_STANDARD 99)
+set(CMAKE_C_STANDARD_REQUIRED ON)
+set(CMAKE_C_EXTENSIONS OFF)
+
+# Export compile commands for IDE integration
+set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
+
+include(CMakeDependentOption)
+
+# ---------------------------------------------------------------------------
+# Options
+# ---------------------------------------------------------------------------
+option(HYBBX_BUILD_DAEMON "Build hybbx server daemon" ON)
+option(HYBBX_BUILD_CLIENTS "Build standalone CLI clients" ON)
+option(HYBBX_CLIENTS_ONLY "Clients only (no daemon, no plugins)" OFF)
+option(HYBBX_BUILD_PLUGINS "Build transport plugins" ON)
+option(HYBBX_PLUGIN_TELNET "Build TCP/IP telnet transport" ON)
+option(HYBBX_PLUGIN_PACKET_RADIO "Build packet radio transport" ON)
+option(HYBBX_PLUGIN_ARDOP "Build ARDOP host-client transport" ON)
+option(HYBBX_PLUGIN_CRDOP "Build CRDOP CB host-client transport" ON)
+option(HYBBX_PLUGIN_SSH "Build SSH transport (requires libssh)" ON)
+option(HYBBX_PLUGIN_BAYCOM "Build BayCom/based transport plugin (default ON)" ON)
+option(HYBBX_PLUGIN_MAINS_PROXY "Build Main-to-Main mesh proxy" ON)
+option(HYBBX_PLUGIN_WEBSOCKET "Build WebSocket forward-proxy transport" ON)
+option(HYBBX_BUILD_TESTS "Build unit tests" OFF)
+option(HYBBX_HARDENING "Enable compiler security hardening" ON)
+option(HYBBX_WARNINGS_AS_ERRORS "Treat warnings as errors" OFF)
+option(HYBBX_CRYPTO_OPENSSL "Optional OpenSSL crypto backends" OFF)
+option(HYBBX_CRYPTO_LIBSODIUM "Optional libsodium crypto backends" OFF)
+option(HYBBX_STORAGE_SQLITE "Optional SQLite storage backend" ON)
+
+cmake_dependent_option(HYBBX_BUILD_CLIENT_TELNET
+ "Build hybbx-telnet standalone client" ON "HYBBX_BUILD_CLIENTS" OFF)
+cmake_dependent_option(HYBBX_BUILD_CLIENT_TERMINAL
+ "Build hybbx-terminal standalone client" ON "HYBBX_BUILD_CLIENTS" OFF)
+cmake_dependent_option(HYBBX_BUILD_CLIENT_SSH
+ "Build hybbx-ssh standalone client (requires libssh)" ON "HYBBX_BUILD_CLIENTS" OFF)
+
+if(HYBBX_CLIENTS_ONLY)
+ set(HYBBX_BUILD_DAEMON OFF CACHE BOOL "Clients-only build" FORCE)
+ set(HYBBX_BUILD_PLUGINS OFF CACHE BOOL "Clients-only build" FORCE)
+ set(HYBBX_BUILD_CLIENTS ON CACHE BOOL "Clients-only build" FORCE)
+endif()
+
+set(HYBBX_HAVE_LIBSSH FALSE)
+if(HYBBX_PLUGIN_SSH OR HYBBX_BUILD_CLIENT_SSH)
+ find_package(PkgConfig QUIET)
+ if(PkgConfig_FOUND)
+ pkg_check_modules(LIBSSH QUIET libssh)
+ endif()
+ if(LIBSSH_FOUND)
+ set(HYBBX_HAVE_LIBSSH TRUE)
+ message(STATUS "HyBBX: libssh ${LIBSSH_VERSION} — SSH enabled")
+ else()
+ if(HYBBX_PLUGIN_SSH)
+ message(WARNING "HYBBX_PLUGIN_SSH=ON but libssh was not found — SSH plugin disabled")
+ set(HYBBX_PLUGIN_SSH OFF CACHE BOOL "libssh not found" FORCE)
+ endif()
+ if(HYBBX_BUILD_CLIENT_SSH)
+ message(WARNING "HYBBX_BUILD_CLIENT_SSH=ON but libssh was not found — hybbx-ssh disabled")
+ set(HYBBX_BUILD_CLIENT_SSH OFF CACHE BOOL "libssh not found" FORCE)
+ endif()
+ endif()
+endif()
+
+if(NOT HYBBX_BUILD_DAEMON)
+ set(HYBBX_BUILD_PLUGINS OFF CACHE BOOL "No daemon — skip plugins" FORCE)
+endif()
+
+set(HYBBX_HAVE_OPENSSL FALSE)
+if(HYBBX_CRYPTO_OPENSSL)
+ find_package(OpenSSL QUIET)
+ if(OpenSSL_FOUND)
+ set(HYBBX_HAVE_OPENSSL TRUE)
+ message(STATUS "HyBBX: OpenSSL crypto backends enabled")
+ else()
+ message(WARNING "HYBBX_CRYPTO_OPENSSL=ON but OpenSSL was not found")
+ endif()
+endif()
+
+set(HYBBX_HAVE_LIBSODIUM FALSE)
+if(HYBBX_CRYPTO_LIBSODIUM)
+ find_package(PkgConfig QUIET)
+ if(PkgConfig_FOUND)
+ pkg_check_modules(LIBSODIUM QUIET libsodium)
+ endif()
+ if(LIBSODIUM_FOUND)
+ set(HYBBX_HAVE_LIBSODIUM TRUE)
+ message(STATUS "HyBBX: libsodium crypto backends enabled")
+ else()
+ message(WARNING "HYBBX_CRYPTO_LIBSODIUM=ON but libsodium was not found")
+ endif()
+endif()
+
+set(HYBBX_HAVE_SQLITE FALSE)
+if(HYBBX_STORAGE_SQLITE AND HYBBX_BUILD_DAEMON)
+ find_package(PkgConfig QUIET)
+ if(PkgConfig_FOUND)
+ pkg_check_modules(SQLITE3 QUIET sqlite3)
+ endif()
+ if(SQLITE3_FOUND)
+ set(HYBBX_HAVE_SQLITE TRUE)
+ message(STATUS "HyBBX: sqlite3 ${SQLITE3_VERSION} — SQLite storage enabled")
+ if(SQLITE3_LIBRARY_DIRS)
+ link_directories(${SQLITE3_LIBRARY_DIRS})
+ endif()
+ else()
+ message(WARNING "HYBBX_STORAGE_SQLITE=ON but libsqlite3 was not found")
+ endif()
+endif()
+
+# ---------------------------------------------------------------------------
+# Platform detection (see docs/PLATFORMS.md)
+# ---------------------------------------------------------------------------
+if(CMAKE_SYSTEM_NAME STREQUAL "AmigaOS")
+ set(HYBBX_PLATFORM_AMIGAOS TRUE)
+ message(STATUS "HyBBX: targeting AmigaOS 3.9+ (cross-GCC)")
+elseif(WIN32)
+ set(HYBBX_PLATFORM_WINDOWS TRUE)
+ message(STATUS "HyBBX: targeting Windows 10+ (GCC/Clang via MinGW or LLVM-MinGW)")
+elseif(APPLE)
+ set(HYBBX_PLATFORM_MACOS TRUE)
+ message(STATUS "HyBBX: targeting MacOS X+ (Apple Clang / LLVM Clang)")
+elseif(CMAKE_SYSTEM_NAME MATCHES "Linux")
+ set(HYBBX_PLATFORM_LINUX TRUE)
+ message(STATUS "HyBBX: targeting Linux")
+ add_compile_definitions(_DEFAULT_SOURCE=1)
+elseif(CMAKE_SYSTEM_NAME MATCHES "FreeBSD|OpenBSD|NetBSD|DragonFly")
+ set(HYBBX_PLATFORM_BSD TRUE)
+ message(STATUS "HyBBX: targeting BSD (${CMAKE_SYSTEM_NAME})")
+else()
+ message(STATUS "HyBBX: targeting ${CMAKE_SYSTEM_NAME} (POSIX/GCC/Clang)")
+endif()
+
+if(NOT DEFINED HYBBX_PLATFORM_AMIGAOS)
+ set(HYBBX_PLATFORM_AMIGAOS FALSE)
+endif()
+
+if(HYBBX_PLATFORM_AMIGAOS)
+ set(HYBBX_PLUGIN_SSH OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_BUILD_CLIENT_SSH OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_BUILD_CLIENT_TERMINAL OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_PLUGIN_WEBSOCKET OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_PLUGIN_BAYCOM OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_PLUGIN_MAINS_PROXY OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_PLUGIN_ARDOP OFF CACHE BOOL "AmigaOS build" FORCE)
+ set(HYBBX_PLUGIN_CRDOP OFF CACHE BOOL "AmigaOS build" FORCE)
+ message(STATUS "HyBBX: AmigaOS — hybbx-telnet client (clib2); telnet + packet_radio plugins")
+endif()
+
+# ---------------------------------------------------------------------------
+# Install layout (self-contained — all usable files in ONE tree)
+#
+# Default (source tree):
+# cmake -B build && cmake --build build && cmake --install build
+# → ${CMAKE_SOURCE_DIR}/bin/
+#
+# Alternate (explicit prefix):
+# cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local
+# cmake --build build && sudo cmake --install build
+# → /usr/local/hybbx/
+#
+# cmake -B build -DCMAKE_INSTALL_PREFIX="$HOME"
+# → $HOME/hybbx/
+#
+# Layout under that root:
+# hybbxd, hybbx-start, hybbx.ini, text/, data/, logs/, clients…
+# ---------------------------------------------------------------------------
+
+# Default prefix = source directory → install into ./bin
+if(CMAKE_INSTALL_PREFIX_INITIALIZED_TO_DEFAULT)
+ set(CMAKE_INSTALL_PREFIX "${CMAKE_SOURCE_DIR}"
+ CACHE PATH "HyBBX install prefix (default: source dir → ./bin)" FORCE)
+endif()
+
+get_filename_component(_hybbx_src_abs "${CMAKE_SOURCE_DIR}" ABSOLUTE)
+get_filename_component(_hybbx_pfx_abs "${CMAKE_INSTALL_PREFIX}" ABSOLUTE)
+if(_hybbx_pfx_abs STREQUAL _hybbx_src_abs)
+ set(HYBBX_INSTALL_HOME "bin")
+else()
+ set(HYBBX_INSTALL_HOME "hybbx")
+endif()
+unset(_hybbx_src_abs)
+unset(_hybbx_pfx_abs)
+
+set(HYBBX_PLUGIN_INSTALL_DIR "${HYBBX_INSTALL_HOME}/lib/plugins")
+
+option(HYBBX_INSTALL_DEV
+ "Install headers, static libs, and CMake package files under the install tree"
+ OFF)
+
+message(STATUS "HyBBX install tree: ${CMAKE_INSTALL_PREFIX}/${HYBBX_INSTALL_HOME}"
+ " (HYBBX_INSTALL_DEV=${HYBBX_INSTALL_DEV})")
+
+include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Compiler.cmake)
+include(${CMAKE_CURRENT_SOURCE_DIR}/cmake/Hardening.cmake)
+
+# ---------------------------------------------------------------------------
+# Subdirectories
+# ---------------------------------------------------------------------------
+add_subdirectory(src)
+
+if(HYBBX_BUILD_PLUGINS)
+ add_subdirectory(plugins)
+endif()
+
+if(HYBBX_BUILD_TESTS)
+ enable_testing()
+ add_subdirectory(tests)
+endif()
+
+# ---------------------------------------------------------------------------
+# Package config (optional — HYBBX_INSTALL_DEV=ON)
+# ---------------------------------------------------------------------------
+if(HYBBX_INSTALL_DEV)
+ include(CMakePackageConfigHelpers)
+
+ configure_package_config_file(
+ "${CMAKE_CURRENT_SOURCE_DIR}/cmake/HyBBXConfig.cmake.in"
+ "${CMAKE_CURRENT_BINARY_DIR}/HyBBXConfig.cmake"
+ INSTALL_DESTINATION "${HYBBX_INSTALL_HOME}/lib/cmake/HyBBX"
+ )
+
+ write_basic_package_version_file(
+ "${CMAKE_CURRENT_BINARY_DIR}/HyBBXConfigVersion.cmake"
+ VERSION ${PROJECT_VERSION}
+ COMPATIBILITY SameMajorVersion
+ )
+
+ install(
+ FILES
+ "${CMAKE_CURRENT_BINARY_DIR}/HyBBXConfig.cmake"
+ "${CMAKE_CURRENT_BINARY_DIR}/HyBBXConfigVersion.cmake"
+ DESTINATION "${HYBBX_INSTALL_HOME}/lib/cmake/HyBBX"
+ )
+endif()
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
new file mode 100644
index 0000000..1f90ae7
--- /dev/null
+++ b/CONTRIBUTING.md
@@ -0,0 +1,29 @@
+# Contributing
+
+**v2.0.0** · standards: [AGENTS.md](AGENTS.md), [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md).
+
+```bash
+git clone <repository-url> && cd hyBBX
+./scripts/dev-setup.sh && ./scripts/hybbx.sh
+telnet 127.0.0.1 2323
+```
+
+## Git identity
+
+**Only** this identity — no alternatives:
+
+```
+user.name = ngteq
+user.email = info@un1t.me
+```
+
+Local `.git/config`. Push **only** via SSH `~/.ssh/id_ed25519_ngteq` to `git@github.com:ngteq/hyBBX.git`.
+
+## Pull requests
+
+- Small, focused diffs; Main/Secondary model unchanged unless discussed
+- Behaviour / INI change → [docs/MANUAL.md](docs/MANUAL.md) + `share/*.ini.example`
+- Entertain apps → plugin under `plugins/` only — [docs/ENTERTAIN.md](docs/ENTERTAIN.md)
+- Verify: `cmake --build build`; telnet session if session/core touched
+
+Issues: `.github/ISSUE_TEMPLATE/`.
diff --git a/LICENSE.txt b/LICENSE.txt
new file mode 100644
index 0000000..a3b8668
--- /dev/null
+++ b/LICENSE.txt
@@ -0,0 +1,691 @@
+Copyright (C) 2026 HyBBX contributors
+
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU General Public License as published by
+the Free Software Foundation, either version 3 of the License, or
+(at your option) any later version.
+
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License
+along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+------------------------------------------------------------------------
+
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see <https://www.gnu.org/licenses/>.
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ <program> Copyright (C) <year> <name of author>
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+<https://www.gnu.org/licenses/>.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+<https://www.gnu.org/licenses/why-not-lgpl.html>.
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..3401f04
--- /dev/null
+++ b/README.md
@@ -0,0 +1,70 @@
+# HyBBX · 2.8.0
+
+**hyBBX** = **hybrid Mailbox build System X**. Plannable **(hy)BBX** network system: closed / self-contained BBX core; Internet primarily for site interconnection and operator work; extensible via **plugins**. Public term: **BBX** (not BBS).
+
+C99 BBX daemon: text wire, `/` commands, mail, chat, conference. Main + Secondary topology over HBX; RF via external modems and MAX25 stack.
+
+## Feature matrix
+
+| Feature | Main | Secondary | Standalone |
+|---------|------|-----------|------------|
+| User login (telnet/SSH/WS) | yes | no | yes |
+| Mail / chat / conference | yes | via Main | yes |
+| HBX circuit hub | yes | client | optional |
+| `packet_radio` / ARDOP / CRDOP / **BayCom/based** | via plugin | yes | yes |
+| MAX25 attach (`[max25]`) | optional | typical | optional |
+| `mains_proxy` | opt-in build | opt-in | opt-in |
+
+## Transport matrix
+
+| Transport | Port (default) | Notes |
+|-----------|----------------|-------|
+| Telnet | 2323 | Plain text |
+| SSH | 3232 | Requires libssh build |
+| WebSocket | configurable | Menus / clients |
+| HBX | 7323 | Link auth + `link_password` |
+| MAX25 KISS | via max25d :7325 | v2.8.0 attach-only |
+| BayCom/based KISS | `/tmp/max25-bcpr/kiss-bc0` | via max25-bcpr · [docs/BAYCOM.md](docs/BAYCOM.md) |
+
+## MAX25 attach matrix
+
+| Rule | Value |
+|------|-------|
+| `kiss_entry` (local TNC) | `none` (MAX25 prep assumed) |
+| `[max25] check` | `yes` (default) — start fails if max25d down |
+
+## Build · test · run
+
+```bash
+cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build
+cmake -B build-test -DHYBBX_BUILD_TESTS=ON && cmake --build build-test && ctest --test-dir build-test
+./scripts/hybbx.sh
+```
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Operate | [MANUAL.md](docs/MANUAL.md) |
+| RF + MAX25 order | [TNCS.md](docs/TNCS.md) · [MASTER-GUIDE.md](docs/MASTER-GUIDE.md) |
+| Full index | [docs/README.md](docs/README.md) |
+
+---
+<div align="center">
+<sub>
+<strong>UN1TME/UN1T.me · BBX</strong>
+<br><br>
+<table>
+<tr><th>Protocol</th><th>Host</th><th>Port</th><th>Command / URL</th></tr>
+<tr><td align="center">Telnet</td><td align="center"><code>hybbx.un1t.me</code></td><td align="center">2323</td><td align="center"><code>telnet hybbx.un1t.me 2323</code></td></tr>
+<tr><td align="center">SSH</td><td align="center"><code>hybbx.un1t.me</code></td><td align="center">3232</td><td align="center"><code>ssh hybbx.un1t.me -p 3232</code></td></tr>
+<tr><td align="center">HTTPS</td><td align="center"><code>hybbx.un1t.me</code></td><td align="center">443</td><td align="center"><a href="https://hybbx.un1t.me/">https://hybbx.un1t.me/</a></td></tr>
+<tr><td align="center">HTTP</td><td align="center"><code>hybbx.un1t.me</code></td><td align="center">80</td><td align="center"><a href="http://hybbx.un1t.me/">http://hybbx.un1t.me/</a></td></tr>
+</table>
+<br>
+<strong>hybbx.un1t.me</strong> Telnet <strong>:2323</strong> · SSH <strong>:3232</strong> · HTTP(S) <strong>:80/:443</strong> · Primary RF <strong>27.245 MHz / CB25</strong>
+</sub>
+<br><br>
+<a href="https://github.com/user-attachments/assets/0429f9e3-61b0-4b21-a01a-dfb98eabce77"> <img width="80" height="60" alt="UN1TME/UN1T.me BBX — sysop menu" src="https://github.com/user-attachments/assets/0429f9e3-61b0-4b21-a01a-dfb98eabce77" />
+</a>
+</div>
diff --git a/cmake/Compiler.cmake b/cmake/Compiler.cmake
new file mode 100644
index 0000000..bd0b352
--- /dev/null
+++ b/cmake/Compiler.cmake
@@ -0,0 +1,97 @@
+# HyBBX toolchain detection and portable warning flags.
+#
+# Supported compilers: GCC (primary), LLVM Clang.
+# Supported OS families: Linux, *BSD (FreeBSD, NetBSD, OpenBSD, …), MacOS X+,
+# Windows 10+ (MinGW/Clang), AmigaOS 3.9+ (cross-GCC). POSIX+ friendly.
+
+include(CheckCCompilerFlag)
+
+set(HYBBX_COMPILER_GCC FALSE)
+set(HYBBX_COMPILER_CLANG FALSE)
+set(HYBBX_COMPILER_APPLECLANG FALSE)
+
+if(CMAKE_C_COMPILER_ID STREQUAL "GNU")
+ set(HYBBX_COMPILER_GCC TRUE)
+elseif(CMAKE_C_COMPILER_ID STREQUAL "Clang")
+ if(CMAKE_C_COMPILER_FRONTEND_VARIANT STREQUAL "MSVC")
+ message(FATAL_ERROR "HyBBX requires GCC or Clang.")
+ endif()
+ set(HYBBX_COMPILER_CLANG TRUE)
+elseif(CMAKE_C_COMPILER_ID STREQUAL "AppleClang")
+ set(HYBBX_COMPILER_CLANG TRUE)
+ set(HYBBX_COMPILER_APPLECLANG TRUE)
+elseif(CMAKE_C_COMPILER_ID STREQUAL "MSVC")
+ message(FATAL_ERROR "HyBBX requires GCC or Clang.")
+else()
+ message(WARNING
+ "HyBBX is developed with GCC (primary) and Clang. "
+ "Compiler '${CMAKE_C_COMPILER_ID}' is untested.")
+endif()
+
+function(hybbx_probe_c_flag flag out_var)
+ string(REGEX REPLACE "[^a-zA-Z0-9]+" "_" flag_id "${flag}")
+ set(cache_var "HYBBX_C_FLAG_${flag_id}")
+ if(NOT DEFINED ${cache_var})
+ check_c_compiler_flag("${flag}" ${cache_var})
+ endif()
+ if(${cache_var})
+ set(${out_var} TRUE PARENT_SCOPE)
+ else()
+ set(${out_var} FALSE PARENT_SCOPE)
+ endif()
+endfunction()
+
+function(hybbx_collect_supported_c_flags)
+ set(_candidates
+ -Wall
+ -Wextra
+ -Wpedantic
+ -Wformat=2
+ -Wstrict-prototypes
+ -Wmissing-prototypes
+ -Wwrite-strings
+ -Wshadow
+ -fno-strict-aliasing
+ )
+
+ if(NOT HYBBX_PLATFORM_AMIGAOS)
+ list(APPEND _candidates -fstack-protector-strong)
+ endif()
+
+ if(HYBBX_COMPILER_GCC)
+ list(APPEND _candidates
+ -Wduplicated-cond
+ -Wnull-dereference
+ -Wlogical-op
+ -Wformat-signedness
+ )
+ if(NOT CMAKE_C_COMPILER_VERSION VERSION_LESS 8.0 AND NOT HYBBX_PLATFORM_AMIGAOS)
+ list(APPEND _candidates -fstack-clash-protection)
+ endif()
+ endif()
+
+ if(HYBBX_COMPILER_CLANG AND NOT HYBBX_COMPILER_APPLECLANG)
+ list(APPEND _candidates -Wno-gnu-zero-variadic-macro-arguments)
+ endif()
+
+ set(_supported "")
+ foreach(flag IN LISTS _candidates)
+ hybbx_probe_c_flag("${flag}" _ok)
+ if(_ok)
+ list(APPEND _supported "${flag}")
+ endif()
+ endforeach()
+
+ set(HYBBX_SUPPORTED_C_FLAGS "${_supported}" CACHE INTERNAL
+ "C warning/security flags supported by ${CMAKE_C_COMPILER_ID}")
+endfunction()
+
+hybbx_collect_supported_c_flags()
+
+if(HYBBX_COMPILER_GCC)
+ message(STATUS "HyBBX compiler: GCC ${CMAKE_C_COMPILER_VERSION} (primary)")
+elseif(HYBBX_COMPILER_CLANG)
+ message(STATUS "HyBBX compiler: Clang ${CMAKE_C_COMPILER_VERSION}")
+else()
+ message(STATUS "HyBBX compiler: ${CMAKE_C_COMPILER_ID} ${CMAKE_C_COMPILER_VERSION}")
+endif()
diff --git a/cmake/Hardening.cmake b/cmake/Hardening.cmake
new file mode 100644
index 0000000..6bbc43f
--- /dev/null
+++ b/cmake/Hardening.cmake
@@ -0,0 +1,44 @@
+# HyBBX compiler and linker hardening (HYBBX_HARDENING=ON)
+
+function(hybbx_apply_hardening target)
+ if(NOT HYBBX_HARDENING)
+ return()
+ endif()
+
+ if(HYBBX_SUPPORTED_C_FLAGS)
+ target_compile_options(${target} PRIVATE ${HYBBX_SUPPORTED_C_FLAGS})
+ endif()
+
+ if(HYBBX_WARNINGS_AS_ERRORS)
+ target_compile_options(${target} PRIVATE -Werror)
+ endif()
+
+ if(CMAKE_BUILD_TYPE STREQUAL "Release" OR CMAKE_BUILD_TYPE STREQUAL "RelWithDebInfo")
+ if(NOT HYBBX_PLATFORM_AMIGAOS)
+ target_compile_definitions(${target} PRIVATE _FORTIFY_SOURCE=2)
+ endif()
+ endif()
+
+ if(NOT HYBBX_PLATFORM_AMIGAOS AND CMAKE_SYSTEM_NAME STREQUAL "Linux")
+ target_compile_options(${target} PRIVATE -fPIC)
+ if(HYBBX_COMPILER_GCC OR HYBBX_COMPILER_CLANG)
+ target_link_options(${target} PRIVATE
+ -Wl,-z,relro
+ -Wl,-z,now
+ )
+ endif()
+ endif()
+endfunction()
+
+function(hybbx_apply_hardening_executable target)
+ hybbx_apply_hardening(${target})
+
+ if(NOT HYBBX_HARDENING OR HYBBX_PLATFORM_AMIGAOS)
+ return()
+ endif()
+
+ if(CMAKE_SYSTEM_NAME STREQUAL "Linux" AND (HYBBX_COMPILER_GCC OR HYBBX_COMPILER_CLANG))
+ target_compile_options(${target} PRIVATE -fPIE)
+ target_link_options(${target} PRIVATE -pie)
+ endif()
+endfunction()
diff --git a/cmake/HyBBXConfig.cmake.in b/cmake/HyBBXConfig.cmake.in
new file mode 100644
index 0000000..6c0f9ef
--- /dev/null
+++ b/cmake/HyBBXConfig.cmake.in
@@ -0,0 +1,11 @@
+@PACKAGE_INIT@
+
+include(CMakeFindDependencyMacro)
+
+set(HYBBX_VERSION @PROJECT_VERSION@)
+
+if(NOT TARGET HyBBX::core)
+ include("${CMAKE_CURRENT_LIST_DIR}/HyBBXTargets.cmake")
+endif()
+
+check_required_components(HyBBX)
diff --git a/cmake/Toolchain-AmigaOS.cmake b/cmake/Toolchain-AmigaOS.cmake
new file mode 100644
index 0000000..ea51fe3
--- /dev/null
+++ b/cmake/Toolchain-AmigaOS.cmake
@@ -0,0 +1,39 @@
+# AmigaOS 3.9+ cross-compilation toolchain (bebbo-gcc / m68k-amigaos-gcc).
+#
+# Usage:
+# cmake -B build-amiga \
+# -DCMAKE_TOOLCHAIN_FILE=cmake/Toolchain-AmigaOS.cmake \
+# -DAMIGA_SDK_PATH=/opt/amiga
+#
+# AMIGA_SDK_PATH may also be set via environment variable or defaults to
+# /opt/amiga when that prefix exists. See docs/PLATFORMS.md.
+
+set(CMAKE_SYSTEM_NAME AmigaOS)
+set(CMAKE_SYSTEM_PROCESSOR m68k)
+
+if(NOT AMIGA_SDK_PATH)
+ if(DEFINED ENV{AMIGA_SDK_PATH} AND NOT "$ENV{AMIGA_SDK_PATH}" STREQUAL "")
+ set(AMIGA_SDK_PATH "$ENV{AMIGA_SDK_PATH}")
+ elseif(EXISTS "/opt/amiga/bin/m68k-amigaos-gcc")
+ set(AMIGA_SDK_PATH "/opt/amiga")
+ endif()
+endif()
+
+if(NOT AMIGA_SDK_PATH OR NOT EXISTS "${AMIGA_SDK_PATH}/bin/m68k-amigaos-gcc")
+ message(FATAL_ERROR
+ "Set AMIGA_SDK_PATH to your AmigaOS cross-compiler prefix, e.g.\n"
+ " cmake -DAMIGA_SDK_PATH=/opt/amiga -DCMAKE_TOOLCHAIN_FILE=...\n"
+ "or export AMIGA_SDK_PATH=/opt/amiga")
+endif()
+
+set(AMIGA_SDK_PATH "${AMIGA_SDK_PATH}" CACHE PATH
+ "AmigaOS cross-compiler prefix (bebbo-gcc)" FORCE)
+
+set(CMAKE_C_COMPILER "${AMIGA_SDK_PATH}/bin/m68k-amigaos-gcc")
+set(CMAKE_AR "${AMIGA_SDK_PATH}/bin/m68k-amigaos-ar")
+set(CMAKE_RANLIB "${AMIGA_SDK_PATH}/bin/m68k-amigaos-ranlib")
+
+set(CMAKE_FIND_ROOT_PATH "${AMIGA_SDK_PATH}")
+set(CMAKE_FIND_ROOT_PATH_MODE_PROGRAM NEVER)
+set(CMAKE_FIND_ROOT_PATH_MODE_LIBRARY ONLY)
+set(CMAKE_FIND_ROOT_PATH_MODE_INCLUDE ONLY)
diff --git a/cmake/Toolchain-Clang.cmake b/cmake/Toolchain-Clang.cmake
new file mode 100644
index 0000000..d263d2d
--- /dev/null
+++ b/cmake/Toolchain-Clang.cmake
@@ -0,0 +1,6 @@
+# Optional LLVM/Clang toolchain file.
+#
+# Usage:
+# cmake -B build-clang -DCMAKE_TOOLCHAIN_FILE=cmake/Toolchain-Clang.cmake
+
+set(CMAKE_C_COMPILER clang CACHE FILEPATH "C compiler")
diff --git a/docs/ARDOP.md b/docs/ARDOP.md
new file mode 100644
index 0000000..8196523
--- /dev/null
+++ b/docs/ARDOP.md
@@ -0,0 +1,27 @@
+# ARDOP plugin · HyBBX 2.8.0
+
+HyBBX transport plugin for ARDOP modem paths — external modem ownership.
+
+## Status matrix
+
+| Item | Value |
+|------|-------|
+| CMake | `HYBBX_PLUGIN_ARDOP=ON` (default) |
+| INI | `[networks] ardop=yes` when modem available |
+| RF prep | External ARDOP stack — not MAX25 CRDOP |
+| Host role | Secondary typical |
+
+## Config matrix
+
+| Item | Value |
+|------|-------|
+| Transport section | `[transport.ardopN]` per instance |
+| Circuit link | `circuit_host`, `link_id`, `link_password` |
+| Protocol | ARDOP session bridge to HBX |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Topology | [TOPOLOGY.md](TOPOLOGY.md) |
+| CRDOP (soft modem) | [CRDOP.md](CRDOP.md) |
diff --git a/docs/BAYCOM.md b/docs/BAYCOM.md
new file mode 100644
index 0000000..14ef442
--- /dev/null
+++ b/docs/BAYCOM.md
@@ -0,0 +1,57 @@
+# BayCom/based · HyBBX
+
+Public mark: **BayCom/based**. Never **Konverter** / **converter**.
+
+**Transport plugin** (`plugins/baycom/`) for PC-COM / SER12-class hardware via MAX25. SER12 L1 (HDLC + UART modem-control) lives in **MAX25 max25-bcpr** (device **`max25e0`**) — not inside HyBBX `packet_radio`.
+
+## Status: available and usable
+
+| Item | Value |
+|------|-------|
+| Build | `HYBBX_PLUGIN_BAYCOM=ON` (**default**) |
+| Enable | `[networks] baycom=yes` + `[transport.baycom1]` |
+| Standalone Main | `[instance] standalone=yes` — local RF with TNC + BayCom on one host |
+| KISS device | **`/tmp/max25-bcpr/kiss-bc0`** (max25d releases PTY after prep) |
+| Start order | **max25d first** → `[max25] check=yes` → hybbxd |
+| INI examples | `share/hybbx-standalone.ini.example` · `share/hybbx/baycom-ser12-host.ini.example` |
+
+`packet_radio` rejects `tnc=baycom` / `tnc=pccom` (not TNC profiles). Use the **baycom** transport for PC-COM.
+
+## Minimal INI
+
+```ini
+[instance]
+standalone = yes
+
+[networks]
+ax25 = yes
+baycom = yes
+
+[transport.baycom1]
+backend = kiss
+device = /tmp/max25-bcpr/kiss-bc0
+serial_baud = 1200
+frequency_mhz = 26.675
+
+[max25]
+check = yes
+host = 127.0.0.1
+port = 7325
+```
+
+## Operator notes
+
+| Rule | Detail |
+|------|--------|
+| RX before TX | Prove decode / Soft-DCD before live TX on PC-COM |
+| Circuit | BayCom link connects to internal HBX hub (`circuit_host` / `circuit_port`) |
+| Broadcast | AX.25 auto-beacon includes baycom link when circuit QoS qualifies |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| TNC serial (`packet_radio`) | [TNCS.md](TNCS.md) |
+| Manual | [MANUAL.md](MANUAL.md) |
+| MAX25 BayCom/based | MAX25-Stack [docs/BAYCOM.md](../MAX25-Stack/docs/BAYCOM.md) · device `max25e0` |
+| Co-host runbook | [MASTER-GUIDE.md](MASTER-GUIDE.md) |
diff --git a/docs/BUILD.md b/docs/BUILD.md
new file mode 100644
index 0000000..52d6592
--- /dev/null
+++ b/docs/BUILD.md
@@ -0,0 +1,44 @@
+# Build · HyBBX 2.8.0
+
+POSIX+ build reference. Platforms: [PLATFORMS.md](PLATFORMS.md).
+
+## Build matrix
+
+| Step | Command |
+|------|---------|
+| Release build | `cmake -B build -DCMAKE_BUILD_TYPE=Release && cmake --build build` |
+| Run | `./scripts/hybbx.sh` |
+| Test | `-DHYBBX_BUILD_TESTS=ON` → `ctest --test-dir build` |
+| Telnet smoke | `telnet 127.0.0.1 2323` |
+| SSH smoke | `ssh 127.0.0.1 -p 3232` |
+
+## CMake options matrix
+
+| Option | Default | Description |
+|--------|---------|-------------|
+| `HYBBX_BUILD_DAEMON` | ON | `hybbxd` + core |
+| `HYBBX_BUILD_CLIENTS` | ON | CLI clients |
+| `HYBBX_PLUGIN_PACKET_RADIO` | ON | Packet radio |
+| `HYBBX_PLUGIN_BAYCOM` | ON | BayCom plugin (v2.8.0 built; runtime off) |
+| `HYBBX_PLUGIN_MAINS_PROXY` | OFF | Main-to-Main mesh proxy |
+| `HYBBX_PLUGIN_ARDOP` | ON | ARDOP plugin |
+| `HYBBX_PLUGIN_CRDOP` | ON | CRDOP plugin |
+| `HYBBX_PLUGIN_SSH` | ON | SSH (libssh) |
+| `HYBBX_PLUGIN_WEBSOCKET` | ON | WebSocket proxy |
+| `HYBBX_BUILD_TESTS` | OFF | Unit tests |
+| `HYBBX_STORAGE_SQLITE` | ON | SQLite backend |
+
+## Config matrix
+
+| Item | Value |
+|------|-------|
+| Live INI | `./local/hybbx.ini` (gitignored) |
+| Templates | `share/hybbx-*.ini.example` |
+| Secrets | `./local/` only — never commit |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Platforms | [PLATFORMS.md](PLATFORMS.md) |
+| Developer | [DEVELOPMENT.md](DEVELOPMENT.md) |
diff --git a/docs/CLIENTS.md b/docs/CLIENTS.md
new file mode 100644
index 0000000..476afc6
--- /dev/null
+++ b/docs/CLIENTS.md
@@ -0,0 +1,28 @@
+# Clients · HyBBX 2.8.0
+
+User-facing connection clients for HyBBX sessions.
+
+## Client matrix
+
+| Client | Transport | Default port |
+|--------|-----------|--------------|
+| `hybbx-telnet` | TCP telnet | 2323 |
+| `hybbx-ssh` | SSH (libssh) | 3232 |
+| `hybbx-terminal` | HBX circuit | 7323 |
+| Web browser | WebSocket via reverse proxy | site-specific |
+
+## Session matrix
+
+| Item | Value |
+|------|-------|
+| Wire format | Plain text lines + `/` commands |
+| ANSI | `[traffic] ansi=yes` optional |
+| Guest login | `[service] auto_login=yes` |
+| Registered users | `/login` after connect |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| WebSocket setup | [WEBSOCKET.md](WEBSOCKET.md) |
+| Manual | [MANUAL.md](MANUAL.md) |
diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md
new file mode 100644
index 0000000..bb664e7
--- /dev/null
+++ b/docs/COMMANDS.md
@@ -0,0 +1,36 @@
+# Commands · HyBBX 2.8.0
+
+Slash-command reference — five access levels: Sysop → Admin → Mod → User → Guest.
+
+## Level matrix
+
+| Level | Typical commands |
+|-------|------------------|
+| Sysop | `/broadcast`, `/broadcast ax25`, `/shutdown` |
+| Admin | `/usercreate`, `/activate`, `/promote`, `/demote` |
+| Mod | moderation subset |
+| User | `/mail`, `/chat`, `/who` |
+| Guest | `/help`, `/menu` (filtered) |
+
+## RF command matrix
+
+| Command | Scope | Level |
+|---------|-------|-------|
+| `/broadcast <msg>` | Local online users | Sysop |
+| `/broadcast ax25` | Sequential RF beacon | Sysop |
+
+## Registry matrix
+
+| Item | Path |
+|------|------|
+| Command definitions | `share/commands.yaml` |
+| Area definitions | `share/areas.yaml` |
+| `/index` | Lists all commands (every account) |
+| `/who` (v2.8.0) | Interactive users only — not RF sessions |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Manual | [MANUAL.md](MANUAL.md) |
+| Security | [SECURITY.md](SECURITY.md) |
diff --git a/docs/CRDOP.md b/docs/CRDOP.md
new file mode 100644
index 0000000..0eb333e
--- /dev/null
+++ b/docs/CRDOP.md
@@ -0,0 +1,35 @@
+# CRDOP plugin · HyBBX 2.8.0
+
+HyBBX transport for MAX25-SoftModem (CRDOP) — acoustic AX.25 over soundcard.
+
+## Integration matrix
+
+| Layer | Owner |
+|-------|-------|
+| Soft-modem prep, TCP 8515/8516 | MAX25 `soft-crdop` |
+| HyBBX transport | `crdop` plugin |
+| INI merge | `share/hybbx/crdop-host.ini.example` |
+
+## Config matrix
+
+| Item | Value |
+|------|-------|
+| Enable | `[networks] crdop=yes` |
+| CMake | `HYBBX_PLUGIN_CRDOP=ON` (default) |
+| `modem_host` | MAX25 host (loopback or LAN) |
+| `modem_port` | `8515` (control) |
+
+## Start order matrix
+
+| # | Action |
+|---|--------|
+| 1 | `max25-ctl start --hardware soft-modems --device soft-crdop` |
+| 2 | Merge CRDOP host INI fragment |
+| 3 | Start HyBBX with `[networks] crdop=yes` |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| MAX25 CRDOP | MAX25-Stack `docs/CRDOP.md` |
+| Manual | [MANUAL.md](MANUAL.md) |
diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md
new file mode 100644
index 0000000..c7a8182
--- /dev/null
+++ b/docs/DEVELOPMENT.md
@@ -0,0 +1,37 @@
+# Development · HyBBX 2.8.0
+
+Contributor build, test, and code layout reference.
+
+## Workflow matrix
+
+| Step | Command |
+|------|---------|
+| Debug build | `cmake -B build -DCMAKE_BUILD_TYPE=Debug && cmake --build build` |
+| Tests | `-DHYBBX_BUILD_TESTS=ON && ctest --test-dir build` |
+| Clients only | `-DHYBBX_CLIENTS_ONLY=ON` |
+| Format / lint | project scripts in `scripts/` |
+
+## Layout matrix
+
+| Path | Content |
+|------|---------|
+| `src/` | Daemon core |
+| `plugins/` | Transport plugins |
+| `include/hybbx/` | Public headers |
+| `share/` | INI templates, YAML registries |
+| `tests/` | Unit tests (opt-in build) |
+
+## v2.8.0 dev notes matrix
+
+| Topic | Rule |
+|-------|------|
+| MAX25 boundary | No BayCom/PC-COM in `packet_radio` |
+| Local TNC tests | Mock max25d `:7325` recommended |
+| Live secrets | `./local/` only |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build | [BUILD.md](BUILD.md) |
+| Contributing | [../CONTRIBUTING.md](../CONTRIBUTING.md) |
diff --git a/docs/DUAL-TNC-OPERATOR-GUIDE.md b/docs/DUAL-TNC-OPERATOR-GUIDE.md
new file mode 100644
index 0000000..421a183
--- /dev/null
+++ b/docs/DUAL-TNC-OPERATOR-GUIDE.md
@@ -0,0 +1,179 @@
+<!-- AUTO-SYNC 2026-08-01 — vault: projects/integration/2026-07-13-master-operator-guide.md -->
+<!-- Re-sync: /home/akb/Code/0-RESEARCHES/tools/vault-sync-slave-docs.sh -->
+
+# Dual-TNC operator guide · HyBBX
+
+**Shipped guide** — canonical edits in research vault `projects/integration/2026-07-13-master-operator-guide.md`; run sync script after change.
+
+## Summary
+
+Linear operator sequence for a **standalone Main** host with **one or two local TNCs** (dual-TNC pattern): MAX25 prep first, HyBBX second, RF verification last. Applies to any dual-TNC reference station — adjust device paths and INI per site.
+
+---
+
+## 1. Scope
+
+| In scope | Out of scope |
+|----------|--------------|
+| Single-host standalone Main + local `packet_radio` | Remote Secondary + Main split (see HyBBX TOPOLOGY) |
+| TNC2C / PK-TNC2 class (TheFirmware) | BayCom-only or CRDOP-only sites (see MAX25 master) |
+| MAX25 prep → HyBBX → `/broadcast ax25` | mains_proxy mesh |
+
+Boundary SSoT: [2026-07-12-max25-hybbx-boundary-final.md](2026-07-12-max25-hybbx-boundary-final.md).
+
+---
+
+## 2. Responsibility split
+
+| Phase | MAX25 | HyBBX |
+|-------|-------|-------|
+| TNC cold boot (DTR+RTS at power-on) | `tnc2c-boot-wait.sh` / max25d | — |
+| Host MYCALL, TXDELAY, `kiss on` | max25d or boot-wait | — |
+| Serial port ownership in operation | max25d (optional) **or** HyBBX after prep | Opens `/dev/ttyS*` after prep |
+| max25d TCP probe | Listens `:7325` | `[max25] check=yes` |
+| KISS DATA / UI frames / PTT path | — | `packet_radio` plugin |
+| User sessions, `/broadcast ax25` | — | Main + HBX loopback |
+
+**One process per `/dev/tty*`** — never minicom + boot-wait + HyBBX concurrently.
+
+---
+
+## 3. Pre-flight checklist
+
+| # | Check | Pass criterion |
+|---|-------|----------------|
+| 1 | MAX25 scripts installed | `MAX25-Stack/stacks/tncs/tnc2c-boot-wait.sh` executable |
+| 2 | HyBBX built with `packet_radio` | `hybbxd` starts, plugin loads |
+| 3 | INI merged | Vault template: `share/` INI examples |
+| 4 | `[broadcast] ax25_mycall` set | Valid callsign for on-air source |
+| 5 | `kiss_entry=none` on each `[transport.packet_radioN]` | MAX25 owns KISS entry |
+| 6 | Unique `link_id` per transport | Matches circuit bridge registry |
+| 7 | Radio wired, antenna safe | Bench or low-power test first |
+| 8 | Radio squelch profile | **Primary:** SQ **on** (manual/ASQ) while local DCD/decode OK; open SQ only if needed; fringe/DX → Secondary or second Main — RFG/FMQ **off**; SSoT: [../../hardware/radio-equipment/2026-07-14-packet-radio-squelch-rfg-profiles.md](../../hardware/radio-equipment/2026-07-14-packet-radio-squelch-rfg-profiles.md) |
+
+Extended checklist: [2026-07-12-max25-tnc-prep-check.md](2026-07-12-max25-tnc-prep-check.md).
+
+---
+
+## 4. Linear start sequence
+
+### Step A — Stop conflicting owners
+
+```bash
+pkill hybbxd || true
+# ensure no minicom/screen on /dev/ttyS4 / ttyS5
+```
+
+### Step B — MAX25 TNC prep (per port)
+
+For each TNC (example ports — replace per site):
+
+```bash
+cd /path/to/MAX25-Stack/stacks/tncs
+
+# TNC unit A (cold boot or stuck host)
+./tnc2c-boot-wait.sh --device /dev/ttyS4 --recover-only
+# or full boot-wait with power cycle if DTR was low at cold start
+
+# TNC unit B (PK-TNC2)
+./pktnc2-boot-wait.sh --device /dev/ttyS5 --recover-only
+```
+
+**Optional:** start `max25d` instead of one-shot scripts — enables serial watch and M25/1:
+
+```bash
+max25-ctl start --hardware tncs --device tnc2c
+ss -ltn | grep 7325 # expect LISTEN on 7325
+```
+
+Note: one `max25d` instance = one RF backend in v1; dual-TNC sites often use **scripts per port** then HyBBX as serial owner. See vault prep check for dual-TNC pattern.
+
+### Step C — Verify MAX25 reachability
+
+```bash
+nc -zv 127.0.0.1 7325 # when max25d running
+# or: confirm boot-wait log shows TheFirmware banner + KISS ready
+```
+
+### Step D — Start HyBBX
+
+```bash
+hybbxd -c /path/to/hybbx.ini
+# or: hybbx-start
+```
+
+**Expected log sequence:**
+
+1. `max25d reachable` (if `[max25] check=yes`)
+2. `KISS attach (MAX25 prep assumed)` per `[transport.packet_radioN]`
+3. Circuit hub listening `:7323`
+
+### Step E — RF verification
+
+| Test | Command / action | Pass |
+|------|------------------|------|
+| Local user session | `hybbx-telnet -H 127.0.0.1` | Login OK |
+| Manual RF beacon | `/broadcast ax25` (Sysop) | Log `RF TX`; PTT at radio |
+| On-air decode | Remote monitor or second radio | AX.25 UI frame with `ax25_mycall` |
+| Dual link gap | Two transports configured | ~60 s between links (sequential) |
+
+RF debug timeline: [../hybbx/2026-07-12-site-rf-broadcast-investigation.md](../hybbx/2026-07-12-site-rf-broadcast-investigation.md).
+
+---
+
+## 5. INI pointers (dual local TNC)
+
+Site INI: merge `share/` examples per deployment.
+
+| Section | Keys to verify |
+|---------|----------------|
+| `[networks]` | `circuit=yes`, `ax25=yes` |
+| `[transport.circuit]` | `port=7323`, `max_links` ≥ transport count |
+| `[transport.packet_radio1]` | `device`, `tnc`, `link_id`, `link_password`, `kiss_entry=none`, `persist=255` |
+| `[transport.packet_radio2]` | same pattern — different `device`, `link_id` |
+| `[broadcast]` | `ax25_mycall`, `ax25_auto_message`, `ax25_dest` |
+| `[max25]` | `check=yes`, `host=127.0.0.1`, `port=7325` |
+
+HyBBX shipped templates: `hyBBX/share/hybbx-standalone.ini.example`.
+
+---
+
+## 6. Recovery without full host reboot
+
+| Symptom | Action |
+|---------|--------|
+| Echo-only on one TNC | Stop HyBBX → `--recover-only` on that port → restart HyBBX |
+| max25d `error-host` | Serial watch auto-repair; or manual `tnc2c-host-reset.sh --kiss` |
+| No PTT, log shows `RF TX` | Check FCS strip, `persist=255`, MYCALL prep — vault RF investigation |
+| Stuck after HyBBX crash | KISS return frame before re-prep; do not skip MAX25 step |
+
+Runbook: [operations/runbooks/tnc-recovery-without-power-cycle.md](../../operations/runbooks/tnc-recovery-without-power-cycle.md).
+
+---
+
+## 7. Reboot order (reference station)
+
+After host power cycle:
+
+1. **Physical:** TNC power with DTR high (boot-wait script running **before** or **during** TNC power-on)
+2. **MAX25:** boot-wait per TNC (mandatory for TNC2C cold boot)
+3. **HyBBX:** start immediately after successful prep (HyBBX re-asserts RTS/DTR)
+4. **Verify:** `/broadcast ax25` within 5 min of uptime
+
+Do **not** start HyBBX before TNC host mode is confirmed — HyBBX cannot recover Landolt cold-boot without DTR sequencing.
+
+---
+
+## Related
+
+| Topic | Path |
+|-------|------|
+| MAX25 ↔ HyBBX boundary | [2026-07-12-max25-hybbx-boundary-final.md](2026-07-12-max25-hybbx-boundary-final.md) |
+| TNC prep checklist | [2026-07-12-max25-tnc-prep-check.md](2026-07-12-max25-tnc-prep-check.md) |
+| HyBBX master guide | [../hybbx/2026-07-13-master-documentation.md](../hybbx/2026-07-13-master-documentation.md) |
+| MAX25 master guide | [../max25-stack/2026-07-13-master-documentation.md](../max25-stack/2026-07-13-master-documentation.md) |
+| RF investigation | [../hybbx/2026-07-12-site-rf-broadcast-investigation.md](../hybbx/2026-07-12-site-rf-broadcast-investigation.md) |
+| Production INI | `share/` INI examples |
+| TNC recovery runbook | [operations/runbooks/tnc-recovery-without-power-cycle.md](../../operations/runbooks/tnc-recovery-without-power-cycle.md) |
+| TNC2multi 1200/9600 (Amateurfunk) | [hardware/tnc2multi/2026-07-14-tnc2multi-modulation-1200-9600.md](../../hardware/tnc2multi/2026-07-14-tnc2multi-modulation-1200-9600.md) |
+| Packet Squelch/RFG (Primary/Secondary) | [hardware/radio-equipment/2026-07-14-packet-radio-squelch-rfg-profiles.md](../../hardware/radio-equipment/2026-07-14-packet-radio-squelch-rfg-profiles.md) |
diff --git a/docs/ENTERTAIN.md b/docs/ENTERTAIN.md
new file mode 100644
index 0000000..cbdec78
--- /dev/null
+++ b/docs/ENTERTAIN.md
@@ -0,0 +1,25 @@
+# Entertain Area · HyBBX 2.8.0
+
+Optional Main-only plugins for games and apps — not on Secondary RF hosts.
+
+## Plugin matrix
+
+| Plugin | Status | Notes |
+|--------|--------|-------|
+| Entertain Area framework | opt-in CMake | Main hosts only |
+| Chess / games | deferred | future or doc removal |
+
+## Config matrix
+
+| Item | Value |
+|------|-------|
+| Enable | `-DHYBBX_PLUGIN_ENTERTAIN=ON` (when available) |
+| Host role | Main only — Secondary runs RF transports |
+| User access | Level-gated via `[areas]` / `share/areas.yaml` |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Topology | [TOPOLOGY.md](TOPOLOGY.md) |
+| Commands | [COMMANDS.md](COMMANDS.md) |
diff --git a/docs/LICENSING.md b/docs/LICENSING.md
new file mode 100644
index 0000000..662505f
--- /dev/null
+++ b/docs/LICENSING.md
@@ -0,0 +1,26 @@
+# Licensing · HyBBX 2.8.0
+
+HyBBX and shipped components — license reference.
+
+## License matrix
+
+| Component | License |
+|-----------|---------|
+| HyBBX core + plugins | GPL-3.0 — [LICENSE.txt](../LICENSE.txt) |
+| Bundled crypto (tinysha256, tinyaes, monocypher) | respective upstream licenses |
+| Optional OpenSSL / libsodium | upstream licenses when enabled |
+
+## Distribution matrix
+
+| Item | Rule |
+|------|------|
+| Source offer | GPL-3.0 compliance required for derivatives |
+| Operator config | `./local/` — not part of distribution |
+| Third-party deps | Document in build output / package metadata |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build options | [BUILD.md](BUILD.md) |
+| Root README | [../README.md](../README.md) |
diff --git a/docs/MAINS_PROXY.md b/docs/MAINS_PROXY.md
new file mode 100644
index 0000000..7573cbe
--- /dev/null
+++ b/docs/MAINS_PROXY.md
@@ -0,0 +1,37 @@
+# mains_proxy · HyBBX 2.8.0
+
+Opt-in mesh linking Main or Secondary instances for `/proxymail` and `/proxychat`.
+
+## Feature matrix
+
+| Feature | Supported |
+|---------|-----------|
+| `/proxymail` cross-site | yes |
+| `/proxychat` cross-site | yes |
+| User account sync | no |
+| Sysop actions over proxy | no |
+| Raw Main-to-Main TCP bypass | no — HBX/Circuit only |
+
+## Build matrix
+
+| Item | Value |
+|------|-------|
+| CMake | `-DHYBBX_PLUGIN_MAINS_PROXY=ON` |
+| INI | `[networks] mains_proxy=yes` |
+| Template | `share/hybbx-mesh.ini.example` |
+| Auth | Same as Secondary — `link_id`, `link_password` |
+
+## Topology matrix
+
+| Peer A | Peer B | Transport |
+|--------|--------|-----------|
+| Main | Main | HBX circuit client |
+| Main | Secondary | HBX circuit client |
+| Secondary | Secondary | via Main hub (typical) |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Topology | [TOPOLOGY.md](TOPOLOGY.md) |
+| Manual | [MANUAL.md](MANUAL.md) |
diff --git a/docs/MANUAL.md b/docs/MANUAL.md
new file mode 100644
index 0000000..699e638
--- /dev/null
+++ b/docs/MANUAL.md
@@ -0,0 +1,63 @@
+# Operator manual · HyBBX 2.8.0
+
+Telnet, SSH, WebSocket operator reference. INI templates in `share/` — copy to `./local/hybbx.ini`.
+
+## Template matrix
+
+| Template | Use |
+|----------|-----|
+| `hybbx-standalone.ini.example` | Main + local TNC (one host) |
+| `hybbx-main.ini.example` | Main; RF on remote Secondary |
+| `hybbx-mesh.ini.example` | Main + `mains_proxy` mesh |
+| `hybbx-secondary.ini.example` | Secondary RF host |
+
+## Topology matrix
+
+| Role | `[networks]` typical | Hosts |
+|------|----------------------|-------|
+| **Main** | `circuit=yes`, `ax25=no` | Users, HBX hub |
+| **Secondary** | `circuit=no`, `ax25=yes` | TNC/RF; `circuit_host` → Main |
+| **Standalone Main** | `ax25=yes` on same box | Lab / single-host |
+
+## Core INI sections matrix
+
+| Section | Key keys |
+|---------|----------|
+| `[service]` | `name`, `max_online`, `prompt` |
+| `[storage]` | `backend=flatfile\|sqlite` |
+| `[networks]` | `ax25`, `baycom`, `crdop`, `circuit`, `ssh`, `websocket` |
+| `[transport.telnet]` | `bind`, `port=2323` |
+| `[transport.circuit]` | `port=7323`, `max_links` |
+| `[transport.packet_radioN]` | `device`, `tnc`, `protocol=kiss`, `kiss_entry=none` |
+| `[broadcast]` | `ax25_mycall`, `ax25_auto_message`, `ax25_dest` |
+| `[max25]` | `check=yes`, `host`, `port=7325` |
+| `[security]` | `bantime`, `maxretry`, `ban_backend` |
+
+## v2.8.0 RF keys matrix
+
+| Key | Value | Notes |
+|-----|-------|-------|
+| `kiss_entry` | `none` | MAX25 owns KISS entry |
+| `kiss_exit` | `none` | No `kiss off` on shutdown |
+| `[max25] check` | `yes` | Local TNC start fails if max25d down |
+
+## BayCom/based (via MAX25)
+
+Built by default. Enable `[networks] baycom=yes`; KISS device **`/tmp/max25-bcpr/kiss-bc0`**. Standalone template: `share/hybbx-standalone.ini.example`. Start max25d before HyBBX; prove RX before live TX. Full detail: [BAYCOM.md](BAYCOM.md).
+
+## Daemon matrix
+
+| Item | Value |
+|------|-------|
+| Binary | `hybbxd` |
+| Start | `hybbx-start` or `hybbxd -c hybbx.ini` |
+| Detached | `hybbx-start --screen` / `--tmux` |
+| First start | Sysop created with random password on console |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Topology | [TOPOLOGY.md](TOPOLOGY.md) |
+| Security | [SECURITY.md](SECURITY.md) |
+| Build | [BUILD.md](BUILD.md) |
diff --git a/docs/MASTER-GUIDE.md b/docs/MASTER-GUIDE.md
new file mode 100644
index 0000000..53895af
--- /dev/null
+++ b/docs/MASTER-GUIDE.md
@@ -0,0 +1,211 @@
+<!-- AUTO-SYNC 2026-08-01 — vault: projects/hybbx/2026-07-13-master-documentation.md -->
+<!-- Re-sync: /home/akb/Code/0-RESEARCHES/tools/vault-sync-slave-docs.sh -->
+
+# HyBBX — Master operator guide
+
+**Shipped guide** — canonical edits in research vault `projects/hybbx/2026-07-13-master-documentation.md`; run sync script after change.
+
+## Summary
+
+Single linear guide: what HyBBX is, how to install and configure it, attach RF via MAX25 prep, run clients and commands. Shipped product docs in `docs/` remain **frozen reference** — depth investigations live.
+
+---
+
+## 1. Role and architecture
+
+HyBBX (**hyBBX** — hybrid Mailbox build System X) is a **plannable (hy)BBX network system**: closed and self-contained BBX networking; the Internet is used primarily for interconnection and logical work; Internet and external services are fully extensible via **plugins** ([§0.17](../../AGENT-INDEX.md#017-hybbx-product-positioning-static-rule)).
+
+Technically: a **text-first BBX/mail/chat** daemon (`hybbxd`) with optional RF transports. Users connect via telnet (`:2323`), SSH (`:3232`), or WebSocket proxy. RF paths use transport plugins that bridge serial/KISS or kernel modems to **HBX** (Hybrid Bridge eXchange) on the circuit hub (`:7323`).
+
+```
+Users (telnet/SSH/WebSocket) ──► Main (storage, mail, HBX hub :7323)
+ ▲
+ │ HBX/TCP
+ Secondary (packet_radio / baycom / crdop)
+ │
+ TNC / modem (after MAX25 prep)
+```
+
+| Role | `[networks]` | Hosts |
+|------|--------------|-------|
+| **Main** | `circuit=yes`, `ax25=no` (typical) | Users, HBX hub |
+| **Secondary** | `circuit=no`, `ax25=yes` | RF near TNC; `circuit_host` → Main |
+| **Standalone Main** | `ax25=yes` on same box | Lab / single-host (e.g. dual local TNC) |
+
+**HBX/Circuit** — sole inter-node transport. Application core never sees raw KISS or on-air AX.25; only typed HBX frames on TCP.
+
+Frozen reference: `docs/TOPOLOGY.md`, `docs/MANUAL.md`.
+
+---
+
+## 2. Install and build
+
+| Step | Action |
+|------|--------|
+| Build | `hyBBX/scripts/build.sh` or CMake per `docs/BUILD.md` |
+| Binary | `hybbxd` — start via `hybbx-start` or `hybbxd -c hybbx.ini` |
+| Detached | `hybbx-start --screen` / `--tmux`; attach: `hybbxd --screen --attach` |
+| INI templates | `hyBBX/share/hybbx-standalone.ini.example`, `hybbx-main.ini.example`, `hybbx-secondary.ini.example`, `hybbx-mesh.ini.example` |
+
+First start creates Sysop in `users/users.ini` with a one-time random password on the console.
+
+---
+
+## 3. INI — operator essentials
+
+Full key tables: frozen `docs/MANUAL.md`. Vault INI copy (reference station): `share/` INI examples.
+
+### Core sections
+
+| Section | Purpose |
+|---------|---------|
+| `[service]` | Name, session limit, prompt |
+| `[storage]` | `flatfile` or `sqlite`; user shards under `users/` |
+| `[networks]` | Enable plugins: `ax25`, `baycom`, `crdop`, `circuit`, `ssh`, `websocket` |
+| `[transport.telnet]` | Bind, port `2323` |
+| `[transport.circuit]` | HBX hub port `7323`, `max_links` (default 8, max 16) |
+| `[transport.packet_radioN]` | TNC instance — device, `tnc=` profile, `protocol=kiss` |
+| `[broadcast]` | `ax25_mycall`, `ax25_auto_message`, `ax25_dest` |
+| `[max25]` | `check=yes` — probe max25d TCP `:7325` before local serial open |
+
+### RF on Main vs Secondary
+
+| Layout | TNC keys on Main `[transport.packet_radioN]` |
+|--------|-----------------------------------------------|
+| Remote Secondary | Bridge registry only (`link_id`, password) — no `device` |
+| Local TNC (standalone) | Full keys: `device`, `tnc`, `baud`, `serial_line`, `rts_dtr` |
+
+Bridge-registry rows without `device` are skipped at start (no serial open).
+
+---
+
+## 4. TNC attach (MAX25 contract) — v2.8.0
+
+**Order:** MAX25 prep **before** HyBBX for local serial TNC. One process per `/dev/tty*`. HyBBX **attach-only** on KISS (`kiss_entry=none` after max25d prep).
+
+| Layer | Owner | Action |
+|-------|-------|--------|
+| Boot-wait, DTR/RTS, MYCALL, `kiss on` | MAX25 | `max25-ctl start --hardware tncs` or `tnc2c-boot-wait.sh` |
+| max25d reachability | HyBBX `[max25] check=yes` | TCP `:7325` — **required** for local TNC (start fails if down) |
+| KISS attach | HyBBX `packet_radio` | `kiss_entry=none` (default with MAX25) |
+| AX.25 UI build, HBX bridge, broadcast | HyBBX | `broadcast.c`, `packet_radio.c`, `tnc.c` |
+| BayCom/based plugin | HyBBX | Built by default; `[networks] baycom=yes` when RF used — [operational status](../integration/2026-08-01-baycom-pccom-operational-status.md) |
+
+HyBBX builds outbound UI frames from `[broadcast] ax25_mycall` — it does **not** send host `MYCALL` to the TNC (MAX25 does that at prep).
+
+| INI key | Production value | Notes |
+|---------|------------------|-------|
+| `kiss_entry` | `none` | TNC already in KISS after MAX25 |
+| `kiss_exit` | `none` | Shutdown does not send `kiss off` |
+| `persist` | `255` on CB | CSMA — avoid random defer on busy channel |
+| `protocol` | `kiss` | Required for Secondary RF |
+| `[max25] check` | `yes` | Local serial edges only |
+
+TNC profiles in `packet_radio`: `tnc2c`, `tnc2`, `pk232`, … — see shipped `docs/TNCS.md`. BayCom/based transport: **built by default**, **available and usable** — enable `[networks] baycom=yes`; KISS **`/tmp/max25-bcpr/kiss-bc0`**; standalone/co-host verified 2026-07-31 / 2026-08-01.
+
+Release prep: [2026-07-14-v2.8.0-release-prep.md](2026-07-14-v2.8.0-release-prep.md).
+
+Recovery without power cycle: internal research note) · MAX25 `stacks/tncs/docs/TNC-RECOVERY.md`.
+
+---
+
+## 5. RF, broadcast, and HBX
+
+### Auto-beacon and manual broadcast
+
+| Command | Scope | Sysop |
+|---------|-------|-------|
+| `/broadcast <msg>` | Local logged-in users (telnet/SSH/WebSocket) | yes |
+| `/broadcast ax25` | Sequential RF beacon per link (`ax25_auto_message`; 60 s gap) | yes |
+
+RF path: Main → HBX → Secondary link → KISS → TNC → on-air.
+
+### Standalone Main production facts
+
+Dual local TNC + optional BayCom on one host — shipped: [BAYCOM.md](BAYCOM.md) · [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md). Vault depth: [co-host runbook](../integration/2026-07-31-standalone-cohost-runbook.md) · [BayCom operational status](../integration/2026-08-01-baycom-pccom-operational-status.md).
+
+| Issue class | Vault SSoT fix |
+|-------------|----------------|
+| No PTT despite `RF TX` log | FCS strip, MYCALL before KISS, UNPROTO path, `persist=255` |
+| Circuit queue dropped beacons | Bypass low-prio queue for AX.25 broadcast TX |
+| `ax25_dest=*` invalid | Map to `QST` |
+| TNC stuck after crash | KISS return frame `0xC0 0xFF 0xC0` before prep |
+
+### BayCom and CRDOP plugins
+
+| MAX25 hardware | HyBBX plugin | INI |
+|----------------|--------------|-----|
+| `hardware/modems` | `baycom` | `[networks] baycom=yes` — merge `share/hybbx/baycom-ser12-host.ini.example` |
+| **BayCom/based** (max25-bcpr via MAX25) | available · default build | Standalone Main or co-host (TNC + PC-COM); KISS `/tmp/max25-bcpr/kiss-bc0` — [plugin](2026-07-18-baycom-based-plugin-via-max25.md) · [operational status](../integration/2026-08-01-baycom-pccom-operational-status.md) |
+| `hardware/soft-modems` | `crdop` | `[networks] crdop=yes` — merge `share/hybbx/crdop-host.ini.example` |
+
+Frozen: `docs/BAYCOM.md`, `docs/CRDOP.md` · MAX25 contract: `docs/HYBBX.md`. Public mark for SER12 class: **BayCom/based** (never Konverter/converter).
+
+---
+
+## 6. Clients
+
+| Client | Transport | Default port |
+|--------|-----------|--------------|
+| `hybbx-telnet` | TCP | 2323 |
+| `hybbx-ssh` | SSH (libssh) | 3232 |
+| `hybbx-terminal` | HBX circuit | 7323 |
+| Web browser | WebSocket via reverse proxy | per site |
+
+Frozen: `docs/CLIENTS.md`, `docs/WEBSOCKET.md`.
+
+---
+
+## 7. Commands and access levels
+
+Five levels: Sysop → Admin → Mod → User → Guest. `/help`, `/menu` filtered by level; `/index` lists all commands for every account.
+
+| Sysop RF | `/broadcast`, `/broadcast ax25`, `/shutdown` |
+|----------|-----------------------------------------------|
+| Admin | `/usercreate`, `/activate`, `/promote`, `/demote` |
+
+Frozen: `docs/COMMANDS.md`, `share/commands.yaml`, `share/areas.yaml`.
+
+---
+
+## 8. Operator start order (with MAX25)
+
+Linear sequence for standalone Main + local TNC:
+
+1. **MAX25 prep** — boot-wait or `max25d` per TNC port (or verify `ss -ltn | grep 7325`)
+2. **HyBBX** — `hybbxd -c hybbx.ini` with `kiss_entry=none`, `[max25] check=yes`
+3. **Verify** — log: `max25d reachable` → `KISS attach (MAX25 prep assumed)` → `RF TX` on `/broadcast ax25`
+4. **RF check** — PTT and on-air decode at remote station
+
+Integration detail: [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md).
+
+---
+
+## 9. Future roadmap (deferred)
+
+Aligned with MAX25 [V2.0.0-SCOPE](../../10-PROJECTS/docs/V2.0.0-SCOPE.md) **Later** row — **no product commits** until operator orders a separate track.
+
+| Later | Status |
+|-------|--------|
+| **`/aichat`** — AI / assistant session command | **deferred** |
+| AI / assistant integration (backends, APIs) | **deferred** |
+| like-features | **deferred** |
+
+Full map: [2026-07-14-hybbx-future-roadmap.md](2026-07-14-hybbx-future-roadmap.md).
+
+**v2.8.0:** `/who` lists **interactive users only** (telnet/SSH/WebSocket) — plugin/RF connection sessions are not users.
+
+---
+
+## Related
+
+| Topic | Path |
+|-------|------|
+| RF investigation SSoT | [2026-07-12-site-rf-broadcast-investigation.md](2026-07-12-site-rf-broadcast-investigation.md) |
+| tnc.c recovery integration | [2026-07-13-tnc-recovery-integration.md](2026-07-13-tnc-recovery-integration.md) |
+| INI operator notes | [2026-07-12-ini-operator-notes.md](2026-07-12-ini-operator-notes.md) |
+| MAX25 boundary | [../integration/2026-07-12-max25-hybbx-boundary-final.md](../integration/2026-07-12-max25-hybbx-boundary-final.md) |
+| Dual-TNC operator flow | [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md) |
+| Frozen product docs | `docs/` (read-only) |
+| Production INI (vault) | `share/` INI examples |
+| TNC hardware SSoT | [hardware/tnc2c/CONFIRMED.md](../../hardware/tnc2c/CONFIRMED.md) |
diff --git a/docs/PLATFORMS.md b/docs/PLATFORMS.md
new file mode 100644
index 0000000..f74ff86
--- /dev/null
+++ b/docs/PLATFORMS.md
@@ -0,0 +1,28 @@
+# Platforms · HyBBX 2.8.0
+
+POSIX+ first — Linux, *BSD, AmigaOS 3.9+, macOS, Windows.
+
+## Platform matrix
+
+| Platform | Daemon | Telnet client | packet_radio | SSH |
+|----------|--------|---------------|--------------|-----|
+| Linux | yes | yes | yes | yes (libssh) |
+| FreeBSD / NetBSD / OpenBSD | yes | yes | yes | yes |
+| AmigaOS 3.9+ | partial | yes | yes | no |
+| macOS | yes | yes | yes | yes |
+| Windows | yes | yes | limited | yes |
+
+## AmigaOS matrix
+
+| Item | Value |
+|------|-------|
+| Cross-build | `./scripts/build-amiga-telnet.sh` |
+| Daemon plugins | telnet + packet_radio only |
+| Full feature set | Linux / *BSD recommended |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build | [BUILD.md](BUILD.md) |
+| Manual | [MANUAL.md](MANUAL.md) |
diff --git a/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..bff6b9c
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,32 @@
+# Documentation index · HyBBX 2.8.0
+
+One-line role: compact doc map — start at MASTER-GUIDE for linear operator flow.
+
+## Doc index matrix
+
+| Role | Document |
+|------|----------|
+| Master operator guide | [MASTER-GUIDE.md](MASTER-GUIDE.md) |
+| Dual-TNC operator flow | [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md) |
+| Full manual | [MANUAL.md](MANUAL.md) |
+| Topology | [TOPOLOGY.md](TOPOLOGY.md) |
+| TNC profiles | [TNCS.md](TNCS.md) |
+| Clients | [CLIENTS.md](CLIENTS.md) |
+| Commands | [COMMANDS.md](COMMANDS.md) |
+| BayCom transport plugin | [BAYCOM.md](BAYCOM.md) |
+| CRDOP | [CRDOP.md](CRDOP.md) |
+| ARDOP | [ARDOP.md](ARDOP.md) |
+| Build | [BUILD.md](BUILD.md) |
+| Platforms | [PLATFORMS.md](PLATFORMS.md) |
+| Security | [SECURITY.md](SECURITY.md) |
+| WebSocket | [WEBSOCKET.md](WEBSOCKET.md) |
+| Developer | [DEVELOPMENT.md](DEVELOPMENT.md) |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Build | [BUILD.md](BUILD.md) |
+| RF attach | [TNCS.md](TNCS.md) |
+
+- [TMODEM.md](TMODEM.md) — T-Modem-c1224 attach via MAX25
diff --git a/docs/SECURITY.md b/docs/SECURITY.md
new file mode 100644
index 0000000..ed0b3f0
--- /dev/null
+++ b/docs/SECURITY.md
@@ -0,0 +1,38 @@
+# Security · HyBBX 2.8.0
+
+Built-in `[security]` — network protection and abuse in one subsystem.
+
+## Layer matrix
+
+| Layer | What | Ban? |
+|-------|------|------|
+| **Soft limits** | RF pacing, message size, traffic shaping | **No** |
+| **Abuse** | Brute-force, connection flood, excessive spam | **Yes** — short IP/CALLID cool-down |
+
+## Soft limit matrix
+
+| Area | Keys | Effect |
+|------|------|--------|
+| `[traffic]` | `baud`, `pace_output`, `line_width` | Output pacing |
+| `[chat]` | `message_max` | Truncate oversized lines |
+| `[mail]` | `max_messages`, `body_max` | Mailbox caps |
+| AX.25 broadcast | fixed | Min 900 s auto cycle; 60 s between links |
+
+## Ban trigger matrix
+
+| Target | Event | Default threshold |
+|--------|-------|-------------------|
+| **IP** | `login_fail` | 5 / 10 min |
+| **IP** | `link_auth_fail` | 5 / 10 min |
+| **IP** | `rate_limit` | 30 / 60 s |
+| **CALLID** | `link_auth_fail` | same as login |
+| **CALLID** | `ban_callid=` config | immediate, permanent |
+
+CALLID = AX.25 callsign or HBX `link_id`. Optional `iptables`/`nftables` via `ban_backend`.
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Manual `[security]` | [MANUAL.md](MANUAL.md) |
+| Topology link auth | [TOPOLOGY.md](TOPOLOGY.md) |
diff --git a/docs/TMODEM.md b/docs/TMODEM.md
new file mode 100644
index 0000000..71aa0ed
--- /dev/null
+++ b/docs/TMODEM.md
@@ -0,0 +1,32 @@
+# T-Modem-c1224 — HyBBX attach
+
+**AS-IS** · prototype. Public term: **BBX** (not BBS). Never **AX25SRV** in public copy.
+
+## Role
+
+HyBBX does **not** run T-Modem L1. Attach to a running **max25d** that has device id **`tmodem`** enabled (USB KISS half-TNC from T-Modem-c1224 Pico firmware).
+
+| Layer | Owns |
+|-------|------|
+| T-Modem-c1224 FW | HDLC + bitbang + chip AFSK + PTT |
+| MAX25-Stack max25d | Device `tmodem` · `kiss-raw-serial` on `/dev/ttyACM*` |
+| HyBBX | Network/BBX attach to max25d only |
+
+Not BayCom/based. Not SER12.
+
+## Example (default off)
+
+```ini
+[networks]
+tmodem = no
+```
+
+When max25d already serves `tmodem`, prefer the existing **max25** / host attach path used for other MAX25 devices — enable only after max25d RX proof (RX before TX).
+
+## Related
+
+| Doc | Path |
+|-----|------|
+| MAX25 DEV | MAX25-Stack `docs/DEV/TMODEM-C1224.md` |
+| BayCom/based (different) | [BAYCOM.md](BAYCOM.md) |
+| TNCs | [TNCS.md](TNCS.md) |
diff --git a/docs/TNCS.md b/docs/TNCS.md
new file mode 100644
index 0000000..c2b156b
--- /dev/null
+++ b/docs/TNCS.md
@@ -0,0 +1,56 @@
+# TNC profiles · HyBBX 2.8.0
+
+Single `packet_radio` transport — up to 8 instances per process. MAX25 owns RF prep; HyBBX attach-only in v2.8.0.
+
+## packet_radio / MAX25 matrix
+
+| Rule | Value |
+|------|-------|
+| `kiss_entry` | `none` (default) — MAX25 sends `kiss on` |
+| `[max25] check` | `yes` — probe max25d `:7325` before serial open |
+| Serial ownership | One process per `/dev/tty*` |
+
+## Host protocol matrix
+
+| Protocol | INI `protocol=` | Use |
+|----------|-----------------|-----|
+| KISS | `kiss` (default) | Secondary RF — recommended after MAX25 prep |
+| TNC2 host converse | `hostmode`, `host`, `tnc2` | Interactive host session |
+| 6PACK | `sixpack` | DF6BU 6PACK over serial |
+
+## KISS entry/exit matrix
+
+| INI key | Values | v2.8.0 default |
+|---------|--------|----------------|
+| `kiss_entry` | `none`, `kiss_on`, `esc_at_k`, `auto` | `none` |
+| `kiss_exit` | `none`, `kiss_off`, `kiss_frame`, `auto` | `none` |
+
+## TNC profile matrix (packet_radio)
+
+| Profile | Aliases | Serial | KISS attach | RTS/DTR |
+|---------|---------|--------|-------------|---------|
+| `tnc2c` | `tnc2-c` | 7E1 | `none` | boot-wait |
+| `tnc2` | `pktnc2`, `pk-tnc2`, `tapr`, `thefirmware` | 8N1 | `none` | boot-wait |
+| `pk232` | `pk-232`, `aea` | 8N1 | `none` | off |
+| `mfj1278` | `mfj-1278` | 7E1 | `none` | off |
+| `kantronics` | `kpc`, `kpc3` | 8N1 | `none` | off |
+| `generic` | `tnc` | 8N1 | `none` | off |
+
+BayCom hardware: optional [`baycom`](BAYCOM.md) transport plugin — not a `packet_radio` TNC profile.
+
+## AX.25 source matrix
+
+| Item | Owner |
+|------|-------|
+| Host `MYCALL` on TNC | MAX25 (prep) |
+| Outbound UI source | HyBBX `[broadcast] ax25_mycall` |
+| KISS DATA FCS | Stripped by HyBBX — TNC adds CRC on air |
+| `persist` on CB | `255` recommended — CSMA defer avoidance |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Manual | [MANUAL.md](MANUAL.md) |
+| BayCom plugin | [BAYCOM.md](BAYCOM.md) |
+| Dual-TNC | [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md) |
diff --git a/docs/TOPOLOGY.md b/docs/TOPOLOGY.md
new file mode 100644
index 0000000..0336c9d
--- /dev/null
+++ b/docs/TOPOLOGY.md
@@ -0,0 +1,43 @@
+# Topology · HyBBX 2.8.0
+
+Main, Secondary, and mains-proxy mesh layout.
+
+## Role matrix
+
+| Role | Process | Typical `[networks]` | Hosts |
+|------|---------|----------------------|-------|
+| **Main** | `hybbxd` | `circuit=yes` | Users, storage, HBX hub `:7323` |
+| **Secondary** | `hybbxd` (remote) | `circuit=no`, `ax25=yes` | RF; HBX client to Main |
+| **Standalone Main** | `hybbxd` | `ax25=yes` on same box | Lab / dual local TNC |
+
+## RF attachment matrix
+
+| Layout | TNC host | Main `[transport.packet_radioN]` |
+|--------|----------|----------------------------------|
+| Remote Secondary | Secondary (`device`, `tnc`, `circuit_host`) | Bridge registry only (`link_id`, password) |
+| Local TNC on Main | Same box as users | Full TNC keys (`device`, `tnc`, …) |
+
+## HBX/Circuit matrix
+
+| Item | Value |
+|------|-------|
+| Protocol | HBX v1 — magic `HBX` |
+| Default port | `7323` |
+| Max links | 16 (default `max_links=8`) |
+| Auth | Per-link `link_password` |
+| Scope | Sole inter-node transport — core never sees raw KISS |
+
+## Broadcast matrix
+
+| Command | Scope | Level |
+|---------|-------|-------|
+| `/broadcast <msg>` | Local logged-in users | Sysop |
+| `/broadcast ax25` | Sequential RF beacon (60 s link gap) | Sysop |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Manual | [MANUAL.md](MANUAL.md) |
+| Mesh proxy | [MAINS_PROXY.md](MAINS_PROXY.md) |
+| Dual-TNC | [DUAL-TNC-OPERATOR-GUIDE.md](DUAL-TNC-OPERATOR-GUIDE.md) |
diff --git a/docs/WEBSOCKET.md b/docs/WEBSOCKET.md
new file mode 100644
index 0000000..7a19b03
--- /dev/null
+++ b/docs/WEBSOCKET.md
@@ -0,0 +1,27 @@
+# WebSocket · HyBBX 2.8.0
+
+WebSocket forward-proxy for browser and rich clients — core stays text-first.
+
+## Transport matrix
+
+| Item | Value |
+|------|-------|
+| Plugin | `HYBBX_PLUGIN_WEBSOCKET=ON` (default) |
+| Default port | `4591` (plain); wss with OpenSSL |
+| INI | `[networks] websocket=yes`, `[transport.websocket]` |
+| Reverse proxy | nginx/caddy TLS termination typical |
+
+## Security matrix
+
+| Item | Value |
+|------|-------|
+| User auth | Same as telnet — session login |
+| TLS | Optional `-DHYBBX_CRYPTO_OPENSSL=ON` for wss |
+| Link auth | Separate from HBX circuit auth |
+
+## Related
+
+| Goal | Doc |
+|------|-----|
+| Clients | [CLIENTS.md](CLIENTS.md) |
+| Security | [SECURITY.md](SECURITY.md) |
diff --git a/include/hybbx/ardop.h b/include/hybbx/ardop.h
new file mode 100644
index 0000000..b0869b0
--- /dev/null
+++ b/include/hybbx/ardop.h
@@ -0,0 +1,50 @@
+#ifndef HYBBX_ARDOP_H
+#define HYBBX_ARDOP_H
+
+/**
+ * ARDOP link adapter — HyBBX Host-Client subset (external ARDOP modem).
+ *
+ * HyBBX is never a sound-modem service. Operator runs ARDOPC or ardopcf as a
+ * separate process; this plugin speaks the TNC Host Interface over TCP.
+ * For CRDOP, use the `crdop` plugin and external CRDOPC instead.
+ */
+
+#include "hybbx/types.h"
+#include "hybbx/crdop.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default ARDOPC TCP control port (data = port + 1). */
+#define HYBBX_ARDOP_DEFAULT_PORT 8515u
+
+/** Max payload per ARDOP host data frame (spec timing guidance). */
+#define HYBBX_ARDOP_DATA_MAX 2000u
+
+typedef struct hybbx_ardop_config {
+ char *ardop_host;
+ unsigned ardop_port;
+ char *mycall;
+ char *arq_bandwidth;
+ char *peer_call;
+ int listen;
+ char *circuit_host;
+ unsigned circuit_port;
+ char *link_id;
+ char *link_password;
+ char *link_role;
+ double frequency_mhz;
+ /** Experimental CRDOP preview: cb | amateur (see docs/CRDOP.md). */
+ hybbx_crdop_radio_profile_t radio_profile;
+} hybbx_ardop_config_t;
+
+hybbx_result_t hybbx_ardop_config_parse(const char *config,
+ hybbx_ardop_config_t *out);
+void hybbx_ardop_config_free(hybbx_ardop_config_t *config);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_ARDOP_H */
diff --git a/include/hybbx/auth.h b/include/hybbx/auth.h
new file mode 100644
index 0000000..10c27ce
--- /dev/null
+++ b/include/hybbx/auth.h
@@ -0,0 +1,117 @@
+#ifndef HYBBX_AUTH_H
+#define HYBBX_AUTH_H
+
+#include "hybbx/password.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_AUTH_GUEST_PREFIX_MAX 32
+
+/** Default guest name prefix: Guest1, Guest2, … */
+#define HYBBX_AUTH_DEFAULT_GUEST_PREFIX "Guest"
+
+/** Highest guest number issued (Guest1 … Guest25); max simultaneous guests. */
+#define HYBBX_GUEST_NUMBER_MAX 25u
+
+/**
+ * Synthetic user IDs for ephemeral guest sessions (not stored in user files).
+ * Slot @p n is 1 … @ref HYBBX_GUEST_NUMBER_MAX.
+ */
+#define HYBBX_GUEST_USER_ID(n) (0xffffffffffffff00ULL + (uint64_t)(n))
+
+#define HYBBX_USERNAME_MIN_LEN 4u
+#define HYBBX_USERNAME_MAX_LEN 12u
+#define HYBBX_USERNAME_MAX_DIGITS 4u
+
+/** Default Sysop account (auto-created when no Sysop exists in the user database). */
+#define HYBBX_DEFAULT_SYSOP_USERNAME "Sysop"
+#define HYBBX_SYSOP_INIT_PASSWORD_MIN 10u
+#define HYBBX_SYSOP_INIT_PASSWORD_MAX 14u
+
+/** Plain-text password length policy for new passwords (/changeme, future set-password). */
+#define HYBBX_PASSWORD_MIN_LEN 8u
+#define HYBBX_PASSWORD_MAX_LEN 24u
+
+/**
+ * User privilege levels (highest to lowest).
+ * Sysop: exactly one account on the system.
+ */
+typedef enum hybbx_user_level {
+ HYBBX_LEVEL_SYSOP = 1,
+ HYBBX_LEVEL_ADMIN = 2,
+ HYBBX_LEVEL_MOD = 3,
+ HYBBX_LEVEL_USER = 4,
+ HYBBX_LEVEL_GUEST = 5
+} hybbx_user_level_t;
+
+typedef struct hybbx_auth_config {
+ int auto_login;
+ char guest_prefix[HYBBX_AUTH_GUEST_PREFIX_MAX];
+} hybbx_auth_config_t;
+
+void hybbx_auth_config_defaults(hybbx_auth_config_t *auth);
+
+const char *hybbx_user_level_name(hybbx_user_level_t level);
+hybbx_user_level_t hybbx_user_level_parse(const char *name);
+int hybbx_user_level_is_guest(hybbx_user_level_t level);
+
+/** Non-zero when @p level is Sysop or Admin. */
+int hybbx_user_level_is_sysop_or_admin(hybbx_user_level_t level);
+
+/** Non-zero when @p level is the protected Sysop account tier. */
+int hybbx_user_level_is_sysop(hybbx_user_level_t level);
+
+/** Non-zero when @p password meets the plain-text password policy (8–24 characters). */
+int hybbx_password_plain_valid(const char *password);
+
+/**
+ * Return non-zero when @p username is valid for registration:
+ * 4–12 characters, letters (a–z), digits (max 4), and at most one `_` or one `-`
+ * (not both); case-insensitive.
+ */
+int hybbx_username_valid(const char *username, const char *guest_prefix);
+
+/** Fold @p username to lowercase in place (for case-insensitive lookup). */
+void hybbx_username_normalize(char *username);
+
+/** Presentation form for output (e.g. Sysop instead of stored sysop). */
+const char *hybbx_username_display(const char *username,
+ hybbx_user_level_t level);
+
+struct hybbx_user_record;
+
+/** User-facing display name (nickname when set, else username). */
+const char *hybbx_user_display_name(const struct hybbx_user_record *user);
+
+/**
+ * Derive a default nickname from a legacy stored username when none is set.
+ */
+void hybbx_nickname_infer(const char *stored_username,
+ char *nickname,
+ size_t nickname_len);
+
+/**
+ * Parse @p username as @c <guest_prefix><1-25> (case-insensitive prefix).
+ * Writes slot to @p slot_out. Returns 1 on match, else 0.
+ */
+int hybbx_guest_slot_from_username(const char *guest_prefix,
+ const char *username,
+ unsigned *slot_out);
+
+struct hybbx_user_record;
+
+/** Fill @p out with an ephemeral guest record for @p slot (1 … 25). */
+void hybbx_guest_fill_record(const char *guest_prefix,
+ unsigned slot,
+ struct hybbx_user_record *out);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_AUTH_H */
diff --git a/include/hybbx/ax25.h b/include/hybbx/ax25.h
new file mode 100644
index 0000000..753de15
--- /dev/null
+++ b/include/hybbx/ax25.h
@@ -0,0 +1,68 @@
+#ifndef HYBBX_AX25_H
+#define HYBBX_AX25_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_AX25_CALL_MAX 6
+#define HYBBX_AX25_SSID_MAX 15
+#define HYBBX_AX25_ADDR_ENCODED 7
+#define HYBBX_AX25_MAX_DIGI 8
+#define HYBBX_AX25_FRAME_MAX 330
+#define HYBBX_AX25_PID_NOL3 0xF0u
+#define HYBBX_AX25_CTRL_UI 0x03u
+
+typedef struct hybbx_ax25_address {
+ char call[HYBBX_AX25_CALL_MAX + 1];
+ unsigned ssid;
+} hybbx_ax25_address_t;
+
+typedef struct hybbx_ax25_path {
+ hybbx_ax25_address_t dest;
+ hybbx_ax25_address_t source;
+ hybbx_ax25_address_t digi[HYBBX_AX25_MAX_DIGI];
+ unsigned digi_count;
+} hybbx_ax25_path_t;
+
+/**
+ * Parse "CALL" or "CALL-SSID" into @p out.
+ */
+hybbx_result_t hybbx_ax25_address_parse(const char *text,
+ hybbx_ax25_address_t *out);
+
+/**
+ * Encode one AX.25 address field (7 bytes).
+ * @p last non-zero on the final address field before control byte.
+ */
+void hybbx_ax25_encode_address(uint8_t *out, const hybbx_ax25_address_t *addr,
+ int last);
+
+/** CRC-CCITT (0x1021), init 0xFFFF — AX.25 FCS. */
+uint16_t hybbx_ax25_crc(const uint8_t *data, size_t len);
+
+/**
+ * Build an AX.25 UI frame (PID 0xF0) into @p out.
+ * @return frame length or 0 on error.
+ */
+size_t hybbx_ax25_build_ui(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap);
+
+/**
+ * Parse a UI frame. Returns payload length or 0 if not a UI frame / invalid FCS.
+ */
+size_t hybbx_ax25_parse_ui(const uint8_t *frame, size_t frame_len,
+ hybbx_ax25_path_t *path,
+ uint8_t *payload, size_t payload_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_AX25_H */
diff --git a/include/hybbx/bandwidth_policy.h b/include/hybbx/bandwidth_policy.h
new file mode 100644
index 0000000..6c12296
--- /dev/null
+++ b/include/hybbx/bandwidth_policy.h
@@ -0,0 +1,35 @@
+#ifndef HYBBX_BANDWIDTH_POLICY_H
+#define HYBBX_BANDWIDTH_POLICY_H
+
+/**
+ * Per-user bandwidth limits when a low-bandwidth circuit link is under pressure.
+ * Circuit (secondary) sessions are never targeted. Among users, AX.25 sessions
+ * are sacrificed before full-duplex TCP/telnet; within each class, newest first.
+ */
+
+#include "hybbx/circuit_balance.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_BANDWIDTH_DISCONNECT_MSG "You are disconnected by bandwidth limits."
+
+struct hybbx_service;
+
+/** Logged-in telnet and packet_radio users subject to limits (not circuit links). */
+unsigned hybbx_bandwidth_policy_user_count(struct hybbx_service *service);
+
+/**
+ * Apply pause / break / cancel / resume to online users (AX.25 before TCP, then LIFO).
+ * Circuit (secondary) sessions are never targeted. Returns users affected.
+ */
+unsigned hybbx_bandwidth_policy_apply(struct hybbx_service *service,
+ hybbx_circuit_balance_action_t action);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_BANDWIDTH_POLICY_H */
diff --git a/include/hybbx/baycom.h b/include/hybbx/baycom.h
new file mode 100644
index 0000000..a351e55
--- /dev/null
+++ b/include/hybbx/baycom.h
@@ -0,0 +1,82 @@
+#ifndef HYBBX_BAYCOM_H
+#define HYBBX_BAYCOM_H
+
+/**
+ * BayCom PR-Stack link adapter — Linux kernel HDLC modem (SER12 / PAR96 / EPP)
+ * or serial KISS firmware path.
+ *
+ * Native PR-Stack L2 uses Thomas Sailer kernel drivers (baycom_ser_fdx,
+ * baycom_ser_hdx, baycom_par, baycom_epp) and hdlcdrv channel access.
+ * See docs/BAYCOM.md.
+ */
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default Linux baycom_ser_fdx netdev (SER12 fullduplex). */
+#define HYBBX_BAYCOM_DEFAULT_INTERFACE "bcsf0"
+
+/** Default SER12 UART I/O base (COM1). */
+#define HYBBX_BAYCOM_DEFAULT_IOBASE 0x3f8u
+
+/** Default SER12 IRQ (COM1). */
+#define HYBBX_BAYCOM_DEFAULT_IRQ 4u
+
+/** Default SER12 on-air baud (1200 AFSK). */
+#define HYBBX_BAYCOM_DEFAULT_RADIO_BAUD 1200u
+
+typedef enum hybbx_baycom_backend {
+ HYBBX_BAYCOM_BACKEND_KERNEL = 1,
+ HYBBX_BAYCOM_BACKEND_KISS = 2,
+} hybbx_baycom_backend_t;
+
+typedef enum hybbx_baycom_modem_mode {
+ HYBBX_BAYCOM_MODE_SER12 = 1,
+ HYBBX_BAYCOM_MODE_SER12_SOFTDCD,
+ HYBBX_BAYCOM_MODE_SER12_INV,
+ HYBBX_BAYCOM_MODE_SER12HDX,
+ HYBBX_BAYCOM_MODE_PAR96,
+ HYBBX_BAYCOM_MODE_PAR96_SOFTDCD,
+ HYBBX_BAYCOM_MODE_EPP,
+} hybbx_baycom_modem_mode_t;
+
+typedef struct hybbx_baycom_config {
+ hybbx_baycom_backend_t backend;
+ hybbx_baycom_modem_mode_t mode;
+ char *interface;
+ char *kernel_module;
+ unsigned iobase;
+ unsigned irq;
+ unsigned radio_baud;
+ int kernel_autoload;
+ unsigned txdelay;
+ unsigned txtail;
+ unsigned slot;
+ unsigned persist;
+ int full_duplex;
+ char *device;
+ unsigned serial_baud;
+ char *mycall;
+ char *circuit_host;
+ unsigned circuit_port;
+ char *link_id;
+ char *link_password;
+ char *link_role;
+ char *frequency_mhz;
+} hybbx_baycom_config_t;
+
+hybbx_result_t hybbx_baycom_config_parse(const char *config,
+ hybbx_baycom_config_t *out);
+void hybbx_baycom_config_free(hybbx_baycom_config_t *config);
+
+const char *hybbx_baycom_modem_mode_name(hybbx_baycom_modem_mode_t mode);
+const char *hybbx_baycom_backend_name(hybbx_baycom_backend_t backend);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_BAYCOM_H */
diff --git a/include/hybbx/broadcast.h b/include/hybbx/broadcast.h
new file mode 100644
index 0000000..a4002b7
--- /dev/null
+++ b/include/hybbx/broadcast.h
@@ -0,0 +1,110 @@
+#ifndef HYBBX_BROADCAST_H
+#define HYBBX_BROADCAST_H
+
+/**
+ * HyBBX broadcast — Main instance only.
+ *
+ * User command `/broadcast` (alias `/announce`): instant `+++` message to every
+ * online local user session on this Main (telnet/SSH/WebSocket).
+ * Format: `+++ <from>: <message>` (manual Sysop/grant announce).
+ * Automatic system notices use `***` instead.
+ *
+ * `/broadcast ax25`: manual instant RF beacon — ax25_auto_message to each
+ * qualifying packet-radio link sequentially (no custom RF text).
+ *
+ * INI `[broadcast]` ax25_auto: periodic AX.25 QST UI beacon over HBX to
+ * Secondary extenders (low-bandwidth + half-duplex links). Separate from
+ * `/broadcast`; not a user command. One sequential cycle per interval (min
+ * 900 s); 180 s own-channel idle before each link TX; 180 s between links;
+ * per-link min 900 s. Busy channel defers until idle.
+ */
+
+#include "hybbx/ax25.h"
+#include "hybbx/config.h"
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_AX25_FREQUENCY_MAX 40u
+/** Local Main announce message cap. */
+#define HYBBX_BROADCAST_MESSAGE_MAX 240u
+
+#define HYBBX_BROADCAST_AUTO_MESSAGE_DEFAULT "Broadcast: @service@ online"
+
+typedef struct hybbx_ax25_frequency_table {
+ unsigned count;
+ double mhz[HYBBX_AX25_FREQUENCY_MAX];
+ char labels[HYBBX_AX25_FREQUENCY_MAX][32];
+} hybbx_ax25_frequency_table_t;
+
+typedef struct hybbx_broadcast_config {
+ int enabled;
+ int ax25_enabled;
+ int ax25_auto;
+ unsigned ax25_auto_interval_sec;
+ /** Ignored (legacy INI); links always send sequentially with link gap. */
+ unsigned ax25_auto_stagger_sec;
+ char ax25_mycall[HYBBX_AX25_CALL_MAX + 1];
+ char ax25_dest[HYBBX_AX25_CALL_MAX + 1];
+ char ax25_auto_message[HYBBX_BROADCAST_AX25_MESSAGE_MAX + 1];
+ hybbx_ax25_frequency_table_t frequencies;
+} hybbx_broadcast_config_t;
+
+struct hybbx_service;
+struct hybbx_session;
+
+void hybbx_ax25_frequency_table_clear(hybbx_ax25_frequency_table_t *table);
+
+/** Load `[ax25]` frequency1 … frequencyN (MHz) and optional labels. */
+void hybbx_ax25_frequency_apply(hybbx_ax25_frequency_table_t *table,
+ const hybbx_config_t *config);
+
+/** Compare two MHz values (tolerance for INI rounding). */
+int hybbx_ax25_frequency_match(double a_mhz, double b_mhz);
+
+void hybbx_broadcast_config_defaults(hybbx_broadcast_config_t *cfg);
+
+/** Load `[broadcast]` + `[ax25]` from INI. */
+void hybbx_broadcast_config_apply(hybbx_broadcast_config_t *cfg,
+ const hybbx_config_t *config);
+
+const hybbx_broadcast_config_t *hybbx_service_get_broadcast(
+ const struct hybbx_service *service);
+
+/**
+ * Send @p message to every online local user on this Main (Sysop `/broadcast`).
+ */
+hybbx_result_t hybbx_broadcast_announce(struct hybbx_service *service,
+ struct hybbx_session *from,
+ const char *message);
+
+/**
+ * Manual AX.25 beacon (Sysop `/broadcast ax25`): INI ax25_auto_message to each
+ * qualifying link immediately, one after another. No custom RF text.
+ */
+hybbx_result_t hybbx_broadcast_ax25_manual(struct hybbx_service *service);
+
+/**
+ * AX.25 auto-beacon over HBX (INI ax25_auto only).
+ * Link must be low-bandwidth and half-duplex (QoS).
+ */
+hybbx_result_t hybbx_broadcast_ax25(struct hybbx_service *service,
+ double frequency_mhz,
+ const char *message);
+
+/**
+ * Periodic AX.25 auto-beacon (call once per second from Main service loop).
+ */
+void hybbx_broadcast_ax25_tick(struct hybbx_service *service);
+
+/** Abort an in-flight sequential AX.25 cycle (hub stop / shutdown). */
+void hybbx_broadcast_ax25_seq_cancel(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_BROADCAST_H */
diff --git a/include/hybbx/chat.h b/include/hybbx/chat.h
new file mode 100644
index 0000000..4f03cb4
--- /dev/null
+++ b/include/hybbx/chat.h
@@ -0,0 +1,82 @@
+#ifndef HYBBX_CHAT_H
+#define HYBBX_CHAT_H
+
+#include "hybbx/config.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+/** Hard limit on chat channels (also the default configured count). */
+#define HYBBX_CHAT_CHANNEL_MAX 10u
+
+#define HYBBX_CHAT_CHANNEL_NAME_MAX 32
+
+#define HYBBX_DEFAULT_CHAT_CHANNELS HYBBX_CHAT_CHANNEL_MAX
+
+/** Maximum characters per chat message line (fits 80-column wrap). */
+#define HYBBX_CHAT_MESSAGE_MAX 72u
+
+typedef struct hybbx_chat_config {
+ /** Active channels (1 … @ref HYBBX_CHAT_CHANNEL_MAX). */
+ unsigned channel_count;
+ /** Maximum characters per chat message line. */
+ unsigned message_max;
+ /**
+ * Channel names (index 0 = channel 1). Default: Channel1 … Channel10.
+ * Override with [chat] channel1 … channel10 in INI.
+ */
+ char names[HYBBX_CHAT_CHANNEL_MAX][HYBBX_CHAT_CHANNEL_NAME_MAX];
+} hybbx_chat_config_t;
+
+void hybbx_chat_config_defaults(hybbx_chat_config_t *chat);
+
+/** Load `[chat]` settings from an INI file. */
+void hybbx_chat_config_apply(hybbx_chat_config_t *chat,
+ const hybbx_config_t *config);
+
+const hybbx_chat_config_t *hybbx_service_get_chat(const struct hybbx_service *service);
+
+/** Return channel name for @p channel_index (1-based), or NULL when invalid. */
+const char *hybbx_chat_channel_name(const hybbx_chat_config_t *chat,
+ unsigned channel_index);
+
+/**
+ * Resolve @p spec as channel number (1-based) or name (case-insensitive).
+ * @p out_index receives 1-based channel index on success.
+ */
+hybbx_result_t hybbx_chat_resolve_channel(const hybbx_chat_config_t *chat,
+ const char *spec,
+ unsigned *out_index);
+
+/** List configured channels on @p session. */
+void hybbx_chat_list_channels(struct hybbx_session *session,
+ const hybbx_chat_config_t *chat);
+
+/** Broadcast a chat line within the sender's channel.
+ * Sender sees "ME: …"; others see "<username>: …".
+ */
+hybbx_result_t hybbx_chat_post(struct hybbx_service *service,
+ struct hybbx_session *from,
+ const char *message);
+
+/** List display names in the requester's current chat channel only. */
+void hybbx_chat_show_channel(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+/**
+ * List all users in public chat channels as nick@ChannelN, sorted by channel,
+ * wrapped at 80 columns (conference sessions excluded).
+ */
+void hybbx_chat_show_all(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CHAT_H */
diff --git a/include/hybbx/circuit.h b/include/hybbx/circuit.h
new file mode 100644
index 0000000..c732602
--- /dev/null
+++ b/include/hybbx/circuit.h
@@ -0,0 +1,119 @@
+#ifndef HYBBX_CIRCUIT_H
+#define HYBBX_CIRCUIT_H
+
+/**
+ * HBX — Hybrid Bridge eXchange (v1).
+ *
+ * HyBBX internal framed protocol on the circuit TCP hub. Multiplexes
+ * link-layer payloads (AX.25 and future stacks) and application streams
+ * (terminal, proxymail, proxychat) between HyBBX processes. Header magic:
+ * `H` `B` `X`. Transport is internal TCP/IPv4+IPv6 only — the application
+ * core never sees KISS, AX.25 on-air framing, or serial.
+ *
+ * Short form in docs: **HBX** or **HBX/Circuit** — Circuit = TCP hub
+ * (:7323), HBX = framing on top.
+ */
+
+#include "hybbx/ax25.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_CIRCUIT_MAGIC_0 'H'
+#define HYBBX_CIRCUIT_MAGIC_1 'B'
+#define HYBBX_CIRCUIT_MAGIC_2 'X'
+#define HYBBX_CIRCUIT_VERSION 0x01u
+
+#define HYBBX_CIRCUIT_HEADER_SIZE 12u
+#define HYBBX_CIRCUIT_MAX_PAYLOAD 4096u
+#define HYBBX_CIRCUIT_MAX_FRAME (HYBBX_CIRCUIT_HEADER_SIZE + HYBBX_CIRCUIT_MAX_PAYLOAD)
+
+/** Default internal circuit TCP port (loopback). */
+#define HYBBX_CIRCUIT_DEFAULT_PORT 7323u
+
+/**
+ * Protocol identifiers — extensible for future link stacks.
+ * Values 0x01..0x7F: link-layer payloads; 0x80..0xFF: application streams.
+ */
+typedef enum hybbx_circuit_proto {
+ HYBBX_CIRCUIT_PROTO_AX25 = 0x01, /**< Raw AX.25 frame incl. FCS */
+ HYBBX_CIRCUIT_PROTO_AX25_UI = 0x02, /**< Masked UI: path + payload */
+ HYBBX_CIRCUIT_PROTO_LINK_AUTH = 0x03, /**< Edge link password auth (no ping) */
+ HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK = 0x04,/**< Central auth response */
+ HYBBX_CIRCUIT_PROTO_FLOW_CTRL = 0x05, /**< Load-balance pause/break/cancel */
+ HYBBX_CIRCUIT_PROTO_TERMINAL = 0x10, /**< BBX / terminal byte stream */
+ HYBBX_CIRCUIT_PROTO_PROXY_MAIL = 0x80, /**< Inter-Main proxymail (service only) */
+ HYBBX_CIRCUIT_PROTO_PROXY_CHAT = 0x81, /**< Inter-Main proxychat (service only) */
+ HYBBX_CIRCUIT_PROTO_RESERVED_APRS = 0x20,
+ HYBBX_CIRCUIT_PROTO_RESERVED_NETROM = 0x21
+} hybbx_circuit_proto_t;
+
+typedef enum hybbx_circuit_flags {
+ HYBBX_CIRCUIT_FLAG_NONE = 0x0000,
+ HYBBX_CIRCUIT_FLAG_RX = 0x0001, /**< Frame originated on RF RX */
+ HYBBX_CIRCUIT_FLAG_TX = 0x0002, /**< Frame destined for RF TX */
+ HYBBX_CIRCUIT_FLAG_PATH = 0x0004, /**< AX.25_UI payload carries path */
+ HYBBX_CIRCUIT_FLAG_G3RUH_FSK = 0x0008 /**< G3RUH 9600 FSK on-air (vs AFSK) */
+} hybbx_circuit_flags_t;
+
+typedef void (*hybbx_circuit_frame_cb)(hybbx_circuit_proto_t proto,
+ uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata);
+
+typedef struct hybbx_circuit_decoder {
+ uint8_t buf[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t len;
+ size_t need;
+ int have_header;
+ hybbx_circuit_proto_t proto;
+ uint16_t flags;
+} hybbx_circuit_decoder_t;
+
+void hybbx_circuit_decoder_init(hybbx_circuit_decoder_t *dec);
+
+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 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);
+
+size_t hybbx_circuit_encode_ax25(const uint8_t *frame, size_t frame_len,
+ uint16_t flags,
+ uint8_t *out, size_t out_cap);
+
+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);
+
+size_t hybbx_circuit_encode_terminal(const char *data, size_t len,
+ uint8_t *out, size_t out_cap);
+
+/** Encode LINK_AUTH or LINK_AUTH_ACK line-oriented payload. */
+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);
+
+/**
+ * Unpack AX.25_UI masked payload into path + UI bytes.
+ * @return UI payload length or 0 on error.
+ */
+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);
+
+const char *hybbx_circuit_proto_name(hybbx_circuit_proto_t proto);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CIRCUIT_H */
diff --git a/include/hybbx/circuit_balance.h b/include/hybbx/circuit_balance.h
new file mode 100644
index 0000000..80edbb0
--- /dev/null
+++ b/include/hybbx/circuit_balance.h
@@ -0,0 +1,131 @@
+#ifndef HYBBX_CIRCUIT_BALANCE_H
+#define HYBBX_CIRCUIT_BALANCE_H
+
+/**
+ * HBX circuit auto load-balancing — pace and stabilize mixed link speeds,
+ * duplex modes, and sync/async downlink traffic toward edge adapters.
+ *
+ * Escalation (low-bandwidth links): pause → break → cancel connection.
+ * A few seconds of lag on slow links is normal; the balancer auto-stabilizes.
+ */
+
+#include "hybbx/circuit.h"
+#include "hybbx/link.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+#include <time.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_CIRCUIT_BALANCE_QUEUE_MAX 64u
+#define HYBBX_CIRCUIT_BALANCE_DEFAULT_BAUD 115200u
+#define HYBBX_CIRCUIT_BALANCE_LOW_BAUD_THRESHOLD 9600u
+
+typedef enum hybbx_circuit_bandwidth {
+ HYBBX_CIRCUIT_BW_HIGH = 0,
+ HYBBX_CIRCUIT_BW_LOW = 1
+} hybbx_circuit_bandwidth_t;
+
+typedef enum hybbx_circuit_duplex {
+ HYBBX_CIRCUIT_DUPLEX_UNSET = 0,
+ HYBBX_CIRCUIT_DUPLEX_HALF = 1,
+ HYBBX_CIRCUIT_DUPLEX_FULL = 2
+} hybbx_circuit_duplex_t;
+
+typedef enum hybbx_circuit_balance_action {
+ HYBBX_CIRCUIT_BAL_NONE = 0,
+ HYBBX_CIRCUIT_BAL_PAUSE = 1,
+ HYBBX_CIRCUIT_BAL_BREAK = 2,
+ HYBBX_CIRCUIT_BAL_CANCEL = 3,
+ HYBBX_CIRCUIT_BAL_RESUME = 4
+} hybbx_circuit_balance_action_t;
+
+typedef enum hybbx_circuit_balance_tick_result {
+ HYBBX_CIRCUIT_BAL_TICK_OK = 0,
+ HYBBX_CIRCUIT_BAL_TICK_CANCEL_LINK = 1
+} hybbx_circuit_balance_tick_result_t;
+
+typedef struct hybbx_circuit_balance_config {
+ int enabled;
+ unsigned lag_sec;
+ size_t queue_pause;
+ size_t queue_break;
+ size_t queue_cancel;
+} hybbx_circuit_balance_config_t;
+
+typedef struct hybbx_circuit_link_profile {
+ hybbx_circuit_bandwidth_t bandwidth;
+ hybbx_circuit_duplex_t duplex;
+ unsigned baud;
+ double frequency_mhz;
+} hybbx_circuit_link_profile_t;
+
+typedef struct hybbx_circuit_link_qos {
+ unsigned baud;
+ int duplex;
+ const char *bandwidth;
+ const char *frequency_mhz;
+} hybbx_circuit_link_qos_t;
+
+typedef hybbx_result_t (*hybbx_circuit_balance_send_fn)(void *ctx,
+ const uint8_t *frame,
+ size_t len);
+
+typedef void (*hybbx_circuit_balance_flow_fn)(void *ctx,
+ hybbx_circuit_balance_action_t action,
+ const char *reason);
+
+typedef struct hybbx_circuit_balance hybbx_circuit_balance_t;
+
+void hybbx_circuit_balance_config_defaults(hybbx_circuit_balance_config_t *cfg);
+
+void hybbx_circuit_link_profile_from_auth(const hybbx_link_auth_t *auth,
+ hybbx_circuit_link_profile_t *out);
+
+const char *hybbx_circuit_balance_action_name(hybbx_circuit_balance_action_t action);
+
+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);
+
+size_t hybbx_circuit_flow_ctrl_format(hybbx_circuit_balance_action_t action,
+ const char *reason,
+ char *out, size_t out_cap);
+
+hybbx_circuit_balance_t *hybbx_circuit_balance_create(
+ const hybbx_circuit_balance_config_t *cfg);
+
+void hybbx_circuit_balance_destroy(hybbx_circuit_balance_t *bal);
+
+void hybbx_circuit_balance_set_profile(hybbx_circuit_balance_t *bal,
+ const hybbx_circuit_link_profile_t *profile);
+
+hybbx_circuit_balance_action_t hybbx_circuit_balance_action(
+ const hybbx_circuit_balance_t *bal);
+
+size_t hybbx_circuit_balance_queued_bytes(const hybbx_circuit_balance_t *bal);
+
+/** De-escalate from cancel after user sacrifice — keep secondary link up. */
+void hybbx_circuit_balance_spared_cancel(hybbx_circuit_balance_t *bal);
+
+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);
+
+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);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CIRCUIT_BALANCE_H */
diff --git a/include/hybbx/circuit_bridge.h b/include/hybbx/circuit_bridge.h
new file mode 100644
index 0000000..1eb24b4
--- /dev/null
+++ b/include/hybbx/circuit_bridge.h
@@ -0,0 +1,46 @@
+#ifndef HYBBX_CIRCUIT_BRIDGE_H
+#define HYBBX_CIRCUIT_BRIDGE_H
+
+/**
+ * Main-side bridge registry for remote Secondaries — one entry per
+ * [transport.packet_radioN], [transport.ardopN], or [transport.crdopN] section
+ * (link metadata; RF runs on the Secondary host).
+ * Secondaries (edge extenders/repeaters, not telnet users or local Main transports)
+ * authenticate with matching link_id + link_password over the HBX/TCP circuit hub.
+ */
+
+#include "hybbx/config.h"
+#include "hybbx/link.h"
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_circuit_bridge_entry {
+ char link_id[HYBBX_LINK_ID_MAX];
+ char link_password[128];
+ char link_role[HYBBX_LINK_ROLE_MAX];
+ double frequency_mhz;
+} hybbx_circuit_bridge_entry_t;
+
+typedef struct hybbx_circuit_bridge_registry {
+ hybbx_circuit_bridge_entry_t entries[HYBBX_CIRCUIT_MAX_LINKS];
+ unsigned count;
+} hybbx_circuit_bridge_registry_t;
+
+void hybbx_circuit_bridge_clear(hybbx_circuit_bridge_registry_t *reg);
+
+/** Load transport.packet_radioN, transport.ardopN, and transport.crdopN sections. */
+hybbx_result_t hybbx_circuit_bridge_load(hybbx_circuit_bridge_registry_t *reg,
+ const hybbx_config_t *config);
+
+const hybbx_circuit_bridge_entry_t *hybbx_circuit_bridge_find(
+ const hybbx_circuit_bridge_registry_t *reg, const char *link_id);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CIRCUIT_BRIDGE_H */
diff --git a/include/hybbx/circuit_tcp.h b/include/hybbx/circuit_tcp.h
new file mode 100644
index 0000000..900c379
--- /dev/null
+++ b/include/hybbx/circuit_tcp.h
@@ -0,0 +1,162 @@
+#ifndef HYBBX_CIRCUIT_TCP_H
+#define HYBBX_CIRCUIT_TCP_H
+
+/**
+ * HBX/Circuit TCP hub on Main (Circuit = TCP listener :7323; HBX = framed
+ * payloads on that stream). Remote Secondary processes connect via LINK_AUTH.
+ * Secondaries are separate edge machines (extenders/repeaters), not telnet users
+ * or local [transport.*] adapters on Main.
+ * Hub: [circuit] bind/port. Client: hybbx_circuit_link_* + LINK_AUTH.
+ * All inter-node paths (Secondary, mains_proxy mesh, future relays) use this
+ * client pattern only — never a direct socket to a remote HyBBX Main.
+ * Formal name: HBX — Hybrid Bridge eXchange (v1); see docs/TOPOLOGY.md.
+ */
+
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/circuit_bridge.h"
+#include "hybbx/service.h"
+#include "hybbx/types.h"
+#include "hybbx/limits.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_circuit_hub hybbx_circuit_hub_t;
+
+typedef struct hybbx_circuit_config {
+ char bind4[64];
+ char bind6[64];
+ unsigned port;
+ int ipv4;
+ int ipv6;
+ char link_password[128];
+ unsigned link_stale_days;
+ int link_auth;
+ char data_path[HYBBX_PATH_MAX];
+ char config_path[HYBBX_PATH_MAX];
+ unsigned max_links;
+ hybbx_circuit_bridge_registry_t bridge;
+ hybbx_circuit_balance_config_t balance;
+} hybbx_circuit_config_t;
+
+/** Internal transport plugin used for sessions bridged over the circuit hub. */
+extern const hybbx_transport_plugin_t hybbx_plugin_circuit;
+
+void hybbx_circuit_config_defaults(hybbx_circuit_config_t *cfg);
+
+hybbx_circuit_hub_t *hybbx_circuit_hub_create(hybbx_service_t *service);
+void hybbx_circuit_hub_destroy(hybbx_circuit_hub_t *hub);
+
+hybbx_result_t hybbx_circuit_hub_start(hybbx_circuit_hub_t *hub,
+ const hybbx_circuit_config_t *cfg);
+
+void hybbx_circuit_hub_stop(hybbx_circuit_hub_t *hub);
+
+int hybbx_circuit_hub_running(const hybbx_circuit_hub_t *hub);
+
+unsigned hybbx_circuit_hub_port(const hybbx_circuit_hub_t *hub);
+
+/**
+ * Send a pre-encoded HBX frame to the attached link adapter.
+ */
+hybbx_result_t hybbx_circuit_hub_send_raw(hybbx_circuit_hub_t *hub,
+ const uint8_t *frame, size_t len);
+
+hybbx_result_t hybbx_circuit_hub_send_hbx(hybbx_circuit_hub_t *hub,
+ const uint8_t *frame, size_t len);
+
+unsigned hybbx_circuit_hub_active_link_count(const hybbx_circuit_hub_t *hub);
+
+/**
+ * Send HBX to all active links. @p frequency_mhz 0 = any MHz.
+ * @p require_broadcast_qos filters to low-bandwidth + half-duplex links.
+ * Returns OK when at least one link accepted the frame.
+ */
+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);
+
+/**
+ * Send HBX to one circuit slot (sequential AX.25 broadcast).
+ * @p slot_index must be an active broadcast-QoS link when @p require_broadcast_qos.
+ */
+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);
+
+double hybbx_circuit_hub_link_frequency_mhz(const hybbx_circuit_hub_t *hub);
+
+/** Non-zero when link QoS is low-bandwidth and half-duplex (AX.25 broadcast allowed). */
+int hybbx_circuit_hub_link_broadcast_qos(const hybbx_circuit_hub_t *hub);
+
+typedef struct hybbx_circuit_broadcast_link {
+ double frequency_mhz;
+ unsigned slot_index;
+ char link_id[HYBBX_LINK_ID_MAX];
+} hybbx_circuit_broadcast_link_t;
+
+/**
+ * Active broadcast-QoS links sorted by MHz (then link_id).
+ * Returns count written to @p out (may be 0).
+ */
+unsigned hybbx_circuit_hub_broadcast_links(const hybbx_circuit_hub_t *hub,
+ hybbx_circuit_broadcast_link_t *out,
+ unsigned out_max);
+
+void hybbx_circuit_hub_prune_links(hybbx_circuit_hub_t *hub);
+
+/** Record RF activity on a link adapter (RX or TX). */
+void hybbx_circuit_hub_note_rf_activity(hybbx_circuit_hub_t *hub,
+ const char *link_id);
+
+/** Non-zero when @p slot_index has had no RF activity for @p min_idle_sec. */
+int hybbx_circuit_hub_link_band_idle(const hybbx_circuit_hub_t *hub,
+ unsigned slot_index,
+ unsigned min_idle_sec);
+
+/**
+ * Earliest time a broadcast may TX on @p slot_index (now if already idle).
+ * Returns (time_t)-1 when the slot is unavailable.
+ */
+time_t hybbx_circuit_hub_link_band_ready_at(const hybbx_circuit_hub_t *hub,
+ unsigned slot_index,
+ unsigned min_idle_sec);
+
+/** Link adapter: connect to the internal circuit hub (TCP client). */
+hybbx_result_t hybbx_circuit_link_connect(const char *host, unsigned port,
+ int *out_fd);
+
+hybbx_result_t hybbx_circuit_link_write(int fd, const uint8_t *frame,
+ size_t len);
+
+hybbx_result_t hybbx_circuit_link_read(int fd, uint8_t *buf, size_t buf_len,
+ size_t *read_len);
+
+/**
+ * Send LINK_AUTH HBX frame after TCP connect (link/repeater edge daemon
+ * toward the centralized daemon). Password only — no heartbeat/ping.
+ */
+hybbx_result_t hybbx_circuit_link_authenticate(int fd,
+ const char *password,
+ const char *role,
+ const char *id);
+
+/**
+ * LINK_AUTH with optional QoS (baud, duplex, bandwidth) for auto load-balancing.
+ */
+hybbx_result_t hybbx_circuit_link_authenticate_ex(int fd,
+ const char *password,
+ const char *role,
+ const char *id,
+ const hybbx_circuit_link_qos_t *qos);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CIRCUIT_TCP_H */
diff --git a/include/hybbx/command.h b/include/hybbx/command.h
new file mode 100644
index 0000000..5d8ba5c
--- /dev/null
+++ b/include/hybbx/command.h
@@ -0,0 +1,71 @@
+#ifndef HYBBX_COMMAND_H
+#define HYBBX_COMMAND_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+/**
+ * Input line routing scope.
+ *
+ * HyBBX accepts only lines starting with '/'. Valid forms:
+ * / or /command /cmd /commands → /help
+ * /<verb> or / <verb> → command
+ * /command <verb> or /cmd <verb> → same as / <verb>
+ *
+ * - COMMENT: starts with ';' or '#' — ignored (like empty line)
+ * - LOCAL: anything else — not HyBBX; silently ignored
+ */
+typedef enum hybbx_command_scope {
+ HYBBX_CMD_SCOPE_HYBBX = 1,
+ HYBBX_CMD_SCOPE_LOCAL = 2,
+ HYBBX_CMD_SCOPE_COMMENT = 3
+} hybbx_command_scope_t;
+
+typedef struct hybbx_parsed_command {
+ hybbx_command_scope_t scope;
+ char *line;
+ char *verb;
+ char **argv;
+ size_t argc;
+} hybbx_parsed_command_t;
+
+/**
+ * Classify a single input line.
+ * Returns HYBBX_CMD_SCOPE_HYBBX only when the first non-whitespace
+ * character is '/'.
+ */
+hybbx_command_scope_t hybbx_command_classify(const char *line);
+
+/** Non-zero when @p line is a HyBBX system command (starts with '/'). */
+int hybbx_command_is_hybbx(const char *line);
+
+/** Non-zero when @p line is a local comment (';' or '#' after whitespace). */
+int hybbx_command_is_comment(const char *line);
+
+/**
+ * Parse a trimmed command line into verb and arguments.
+ * For HyBBX commands the leading '/' is stripped from the verb.
+ */
+hybbx_result_t hybbx_command_parse(const char *line,
+ hybbx_parsed_command_t *out);
+
+void hybbx_command_free(hybbx_parsed_command_t *cmd);
+
+/**
+ * Dispatch a parsed HyBBX command (scope must be HYBBX_CMD_SCOPE_HYBBX).
+ */
+hybbx_result_t hybbx_command_dispatch(struct hybbx_service *service,
+ struct hybbx_session *session,
+ const hybbx_parsed_command_t *cmd);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_COMMAND_H */
diff --git a/include/hybbx/commands_registry.h b/include/hybbx/commands_registry.h
new file mode 100644
index 0000000..22b1143
--- /dev/null
+++ b/include/hybbx/commands_registry.h
@@ -0,0 +1,73 @@
+#ifndef HYBBX_COMMANDS_REGISTRY_H
+#define HYBBX_COMMANDS_REGISTRY_H
+
+#include "hybbx/auth.h"
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_session;
+
+typedef struct hybbx_command_def {
+ char verb[HYBBX_CMD_VERB_MAX];
+ char group[16];
+ hybbx_user_level_t min_level;
+ hybbx_user_level_t max_level; /* 0 = no upper cap */
+ int only_level; /* HYBBX_LEVEL_* when set; else 0 */
+ char line1[HYBBX_COMMANDS_HELP_LINE_MAX];
+ char line2[HYBBX_COMMANDS_HELP_LINE_MAX];
+} hybbx_command_def_t;
+
+/** Load areas.yaml then commands.yaml from install root. Call once at service startup. */
+hybbx_result_t hybbx_commands_registry_init(void);
+
+void hybbx_commands_registry_shutdown(void);
+
+const hybbx_command_def_t *hybbx_commands_registry_find(const char *verb);
+
+/** Map alias or topic name to canonical verb; returns @p topic if unknown. */
+const char *hybbx_commands_registry_canonical(const char *topic);
+
+int hybbx_commands_registry_verb_allowed(hybbx_user_level_t level,
+ const char *verb);
+
+int hybbx_commands_registry_help_allowed(hybbx_user_level_t level,
+ const char *verb);
+
+int hybbx_commands_registry_may_userchange(hybbx_user_level_t actor,
+ hybbx_user_level_t target);
+
+int hybbx_commands_registry_may_userdelete(hybbx_user_level_t actor,
+ hybbx_user_level_t 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);
+
+int hybbx_commands_registry_may_demote(hybbx_user_level_t actor,
+ hybbx_user_level_t target);
+
+int hybbx_commands_registry_may_delete(hybbx_user_level_t actor,
+ hybbx_user_level_t target);
+
+void hybbx_commands_registry_show_menu(struct hybbx_session *session);
+void hybbx_commands_registry_show_index(struct hybbx_session *session);
+void hybbx_commands_registry_show_aliases(struct hybbx_session *session);
+void hybbx_commands_registry_show_help(struct hybbx_session *session,
+ const char *canonical);
+
+/**
+ * List line1 for every registry command with min Admin or Sysop
+ * (the admin set a Sysop may run while staying invisible via /monitor).
+ */
+void hybbx_commands_registry_show_sysop_cmds(struct hybbx_session *session);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_COMMANDS_REGISTRY_H */
diff --git a/include/hybbx/conference.h b/include/hybbx/conference.h
new file mode 100644
index 0000000..53baa27
--- /dev/null
+++ b/include/hybbx/conference.h
@@ -0,0 +1,69 @@
+#ifndef HYBBX_CONFERENCE_H
+#define HYBBX_CONFERENCE_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+#define HYBBX_CONFERENCE_TOPIC_MAX 32
+
+/** Invite rate limit: max invites per target user within the window. */
+#define HYBBX_CONFERENCE_INVITE_MAX_PER_TARGET 2u
+
+/** Invite rate limit window (seconds). */
+#define HYBBX_CONFERENCE_INVITE_WINDOW_SEC 1800u
+
+/**
+ * Send a conference invite: @p topic to @p partner (login or nickname).
+ * Partner must accept with y/n or yes/no. Max two invites per target per 30 min.
+ */
+hybbx_result_t hybbx_conference_start(struct hybbx_service *service,
+ struct hybbx_session *initiator,
+ const char *topic,
+ const char *partner);
+
+/**
+ * Monitor/sysop meet: invite @p partner with timeout, or @p force join immediately.
+ * Topic defaults to "monitor". Status lines go to initiator.
+ */
+hybbx_result_t hybbx_conference_monitor_meet(struct hybbx_service *service,
+ struct hybbx_session *initiator,
+ const char *partner,
+ int force);
+
+/** Expire timed-out invites (monitor path). Safe to call every session tick. */
+void hybbx_conference_invite_tick(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+/** Non-zero when @p session has a pending conference invite to answer. */
+int hybbx_conference_invite_pending(const struct hybbx_session *session);
+
+/**
+ * Handle invite reply line (y/n, yes/no). Call for local input while invite pending.
+ * Returns HYBBX_OK when consumed.
+ */
+hybbx_result_t hybbx_conference_reply_invite(struct hybbx_service *service,
+ struct hybbx_session *session,
+ const char *line);
+
+/** Clear pending invites involving @p session (disconnect). */
+void hybbx_conference_session_closed(struct hybbx_session *session);
+
+/** Deliver a line within an active conference (sender must be in conference area). */
+hybbx_result_t hybbx_conference_post(struct hybbx_service *service,
+ struct hybbx_session *from,
+ const char *message);
+
+/** Tear down conference state when leaving the conference area. */
+void hybbx_conference_area_leaving(struct hybbx_session *session);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CONFERENCE_H */
diff --git a/include/hybbx/config.h b/include/hybbx/config.h
new file mode 100644
index 0000000..c2083ab
--- /dev/null
+++ b/include/hybbx/config.h
@@ -0,0 +1,126 @@
+#ifndef HYBBX_CONFIG_H
+#define HYBBX_CONFIG_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_config_entry {
+ char *section;
+ char *key;
+ char *value;
+} hybbx_config_entry_t;
+
+typedef struct hybbx_config {
+ hybbx_config_entry_t *entries;
+ size_t count;
+} hybbx_config_t;
+
+/** Load configuration from an INI file. */
+hybbx_result_t hybbx_config_load(hybbx_config_t *config, const char *path);
+
+/** Release all memory held by @p config. */
+void hybbx_config_free(hybbx_config_t *config);
+
+/**
+ * Return the value for @p key in @p section, or @p default_value if missing.
+ * Both @p section and @p key are case-sensitive.
+ */
+const char *hybbx_config_get(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ const char *default_value);
+
+/** Parse HyBBX boolean tokens (yes/no and aliases; see hybbx_parse_bool). */
+int hybbx_config_get_bool(const hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ int default_value);
+
+/**
+ * Parse an unsigned decimal integer in [@p min_value, @p max_value].
+ * Returns @p default_value when missing or invalid.
+ */
+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);
+
+/**
+ * Build a semicolon-separated key=value string for all keys in @p section.
+ * Caller must free the returned string.
+ */
+char *hybbx_config_format_section(const hybbx_config_t *config,
+ const char *section);
+
+/**
+ * Format all @c transport.&lt;plugin&gt; and @c transport.&lt;plugin&gt;N
+ * sections (in file order) into one start string. Multiple sections are
+ * joined with @ref HYBBX_PACKET_RADIO_INSTANCE_SEP (packet_radio only).
+ * Caller must free the returned string.
+ */
+char *hybbx_config_format_transport_sections(const hybbx_config_t *config,
+ const char *plugin_name);
+
+/**
+ * Like @ref hybbx_config_format_transport_sections for @c packet_radio, but
+ * prepends a `[max25]` key chunk when that INI section exists.
+ */
+char *hybbx_config_format_packet_radio_start(const hybbx_config_t *config);
+
+/** Like packet_radio start — prepends `[max25]` keys for baycom transport. */
+char *hybbx_config_format_baycom_start(const hybbx_config_t *config);
+
+/**
+ * Prepend @c [max25] keys as a packet_radio start prefix (before instance sep).
+ * Caller must free the returned string.
+ */
+char *hybbx_config_prepend_packet_radio_max25(const hybbx_config_t *config,
+ const char *body);
+
+/**
+ * Resolve INI section for a transport plugin: @c transport.&lt;plugin&gt; first,
+ * else the first @c transport.&lt;plugin&gt;&lt;digits&gt; section with keys
+ * (e.g. @c transport.packet_radio1). Writes the chosen name to @p out_section.
+ * Returns 1 when a numbered (or legacy) section with keys was found, else 0.
+ */
+int hybbx_config_resolve_transport_section(const hybbx_config_t *config,
+ const char *plugin_name,
+ char *out_section,
+ size_t out_size);
+
+typedef void (*hybbx_config_iter_fn)(const char *section, const char *key,
+ const char *value, void *ctx);
+
+typedef void (*hybbx_config_section_iter_fn)(const char *section, void *ctx);
+
+/** Invoke @p fn for every entry in @p config (in file order). */
+void hybbx_config_foreach(const hybbx_config_t *config,
+ hybbx_config_iter_fn fn, void *ctx);
+
+/** Invoke @p fn once per unique section name (first occurrence order). */
+void hybbx_config_foreach_section(const hybbx_config_t *config,
+ hybbx_config_section_iter_fn fn, void *ctx);
+
+/** Set or replace a key in @p config (in-memory only until saved). */
+hybbx_result_t hybbx_config_set(hybbx_config_t *config,
+ const char *section,
+ const char *key,
+ const char *value);
+
+/** Remove all keys in @p section from @p config. */
+void hybbx_config_remove_section(hybbx_config_t *config, const char *section);
+
+/** Write @p config to @p path (INI format). */
+hybbx_result_t hybbx_config_save(const hybbx_config_t *config,
+ const char *path);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CONFIG_H */
diff --git a/include/hybbx/crdop.h b/include/hybbx/crdop.h
new file mode 100644
index 0000000..be98200
--- /dev/null
+++ b/include/hybbx/crdop.h
@@ -0,0 +1,44 @@
+#ifndef HYBBX_CRDOP_H
+#define HYBBX_CRDOP_H
+
+/**
+ * CRDOP — CB Radio Digital Open Protocol (Level 2, experimental).
+ *
+ * HyBBX `crdop` plugin: CB host-client bridge to external CRDOPC.
+ * ARDOP uses the `ardop` plugin with ARDOPC/ardopcf.
+ * Modem DSP stays outside HyBBX. Profile helpers shared with `ardop`.
+ */
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_crdop_radio_profile {
+ HYBBX_CRDOP_PROFILE_AMATEUR = 0,
+ HYBBX_CRDOP_PROFILE_CB = 1
+} hybbx_crdop_radio_profile_t;
+
+/** Parse radio_profile INI value (cb, amateur). */
+hybbx_crdop_radio_profile_t hybbx_crdop_profile_parse(const char *value);
+
+const char *hybbx_crdop_profile_name(hybbx_crdop_radio_profile_t profile);
+
+/** Default ARQ bandwidth string for profile when INI omits arq_bandwidth. */
+const char *hybbx_crdop_default_arq_bandwidth(hybbx_crdop_radio_profile_t profile);
+
+/** Non-zero when bandwidth string is above CB-safe ceiling (1000MAX). */
+int hybbx_crdop_bandwidth_exceeds_cb(const char *arq_bandwidth);
+
+struct hybbx_ardop_config;
+
+/** Parse `[transport.crdop]` config (CB defaults). */
+hybbx_result_t hybbx_crdop_config_parse(const char *config,
+ struct hybbx_ardop_config *out);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CRDOP_H */
diff --git a/include/hybbx/crypto.h b/include/hybbx/crypto.h
new file mode 100644
index 0000000..5411e5e
--- /dev/null
+++ b/include/hybbx/crypto.h
@@ -0,0 +1,101 @@
+#ifndef HYBBX_CRYPTO_H
+#define HYBBX_CRYPTO_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * HyBBX reversible cryptography.
+ *
+ * Defaults use bundled in-tree sources (tinyaes, monocypher). Backends are
+ * selectable system-wide via `[crypto]` in hybbx.ini when optional OpenSSL or
+ * libsodium support is enabled at build time (see hybbx/crypto_config.h).
+ *
+ * Three modern AEAD schemes:
+ * 1. AES-256-GCM (NIST, TLS, IPsec)
+ * 2. XChaCha20-Poly1305 (extended-nonce ChaCha, Monocypher)
+ * 3. X25519 + XChaCha20-Poly1305 sealed messages (ECIES-style, Monocypher)
+ *
+ * Password hashing (one-way) remains in hybbx/password.h (SHA-256 / MD5).
+ */
+
+typedef enum hybbx_crypto_alg {
+ HYBBX_CRYPTO_AES_256_GCM = 1,
+ HYBBX_CRYPTO_XCHACHA20_POLY1305 = 2,
+ HYBBX_CRYPTO_X25519_AEAD = 3
+} hybbx_crypto_alg_t;
+
+#define HYBBX_CRYPTO_KEY_SIZE 32
+#define HYBBX_CRYPTO_TAG_SIZE 16
+#define HYBBX_CRYPTO_AES_GCM_NONCE 12
+#define HYBBX_CRYPTO_XCHACHA_NONCE 24
+#define HYBBX_CRYPTO_X25519_PUBLIC_KEY 32
+#define HYBBX_CRYPTO_X25519_SECRET_KEY 32
+/** Sealed blob overhead: ephemeral_pk(32) + nonce(24) + tag(16). */
+#define HYBBX_CRYPTO_X25519_SEAL_OVERHEAD 72
+
+/** Fill @p buf with system random bytes. */
+hybbx_result_t hybbx_crypto_random(uint8_t *buf, size_t len);
+
+/** Human-readable cipher name. */
+const char *hybbx_crypto_alg_name(hybbx_crypto_alg_t alg);
+
+/** Required nonce length for @p alg (0 for X25519 sealed mode). */
+size_t hybbx_crypto_nonce_size(hybbx_crypto_alg_t alg);
+
+/**
+ * Symmetric AEAD encrypt (algorithms 1 and 2).
+ * @p ciphertext length equals @p plaintext_len; authentication tag is separate.
+ */
+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]);
+
+/** Symmetric AEAD decrypt; returns HYBBX_ERR_DENIED when the tag is 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]);
+
+/** Generate an X25519 key pair (Montgomery curve, RFC 7748). */
+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]);
+
+/**
+ * Asymmetric seal for @p recipient_pk.
+ * Output: ephemeral_pk || nonce || ciphertext || tag.
+ */
+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);
+
+/** Open a sealed message with @p recipient_sk. */
+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);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CRYPTO_H */
diff --git a/include/hybbx/crypto_config.h b/include/hybbx/crypto_config.h
new file mode 100644
index 0000000..ec1f61d
--- /dev/null
+++ b/include/hybbx/crypto_config.h
@@ -0,0 +1,69 @@
+#ifndef HYBBX_CRYPTO_CONFIG_H
+#define HYBBX_CRYPTO_CONFIG_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_config;
+
+/** Password hashing backend for new hashes and verification. */
+typedef enum hybbx_password_hash_backend {
+ HYBBX_PASSWORD_HASH_TINYSHA256 = 1,
+ HYBBX_PASSWORD_HASH_OPENSSL = 2
+} hybbx_password_hash_backend_t;
+
+/** AES-256-GCM implementation backend. */
+typedef enum hybbx_aes_gcm_backend {
+ HYBBX_AES_GCM_TINYAES = 1,
+ HYBBX_AES_GCM_OPENSSL = 2
+} hybbx_aes_gcm_backend_t;
+
+/** XChaCha20-Poly1305 implementation backend. */
+typedef enum hybbx_chacha_backend {
+ HYBBX_CHACHA_MONOCYPHER = 1,
+ HYBBX_CHACHA_LIBSODIUM = 2
+} hybbx_chacha_backend_t;
+
+/** X25519 key agreement backend (sealed-box mode). */
+typedef enum hybbx_x25519_backend {
+ HYBBX_X25519_MONOCYPHER = 1,
+ HYBBX_X25519_LIBSODIUM = 2
+} hybbx_x25519_backend_t;
+
+/** OS random bytes source. */
+typedef enum hybbx_random_backend {
+ HYBBX_RANDOM_SYSTEM = 1,
+ HYBBX_RANDOM_OPENSSL = 2
+} hybbx_random_backend_t;
+
+typedef struct hybbx_crypto_config {
+ hybbx_password_hash_backend_t password_hash;
+ hybbx_aes_gcm_backend_t aes_gcm;
+ hybbx_chacha_backend_t chacha;
+ hybbx_x25519_backend_t x25519;
+ hybbx_random_backend_t random;
+} hybbx_crypto_config_t;
+
+/** Bundled defaults: tinysha256, tinyaes, monocypher, system random. */
+void hybbx_crypto_config_defaults(hybbx_crypto_config_t *cfg);
+
+/** Load `[crypto]` from @p config (missing keys keep defaults). */
+void hybbx_crypto_config_apply(const struct hybbx_config *config);
+
+/** Active system-wide crypto settings (defaults until apply is called). */
+const hybbx_crypto_config_t *hybbx_crypto_config_get(void);
+
+const char *hybbx_password_hash_backend_name(hybbx_password_hash_backend_t b);
+const char *hybbx_aes_gcm_backend_name(hybbx_aes_gcm_backend_t b);
+const char *hybbx_chacha_backend_name(hybbx_chacha_backend_t b);
+const char *hybbx_x25519_backend_name(hybbx_x25519_backend_t b);
+const char *hybbx_random_backend_name(hybbx_random_backend_t b);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CRYPTO_CONFIG_H */
diff --git a/include/hybbx/daemon_wrap.h b/include/hybbx/daemon_wrap.h
new file mode 100644
index 0000000..0144e5c
--- /dev/null
+++ b/include/hybbx/daemon_wrap.h
@@ -0,0 +1,36 @@
+#ifndef HYBBX_DAEMON_WRAP_H
+#define HYBBX_DAEMON_WRAP_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_DAEMON_DEFAULT_SESSION "hybbxd"
+#define HYBBX_DAEMON_WRAP_ENV "HYBBXD_WRAPPED"
+
+typedef struct hybbx_daemon_launch_opts {
+ int foreground;
+ int attach;
+ int use_screen;
+ int use_tmux;
+ char session[32];
+} hybbx_daemon_launch_opts_t;
+
+void hybbx_daemon_launch_opts_defaults(hybbx_daemon_launch_opts_t *opts);
+
+/**
+ * Handle --screen / --tmux / --attach before the service loop.
+ * Spawns or attaches via GNU screen or tmux when requested.
+ * Returns HYBBX_OK to continue a foreground run; otherwise exits the process.
+ */
+hybbx_result_t hybbx_daemon_apply_launch_opts(const hybbx_daemon_launch_opts_t *opts,
+ const char *binary_path,
+ int argc, char **argv);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_DAEMON_WRAP_H */
diff --git a/include/hybbx/hybbx.h b/include/hybbx/hybbx.h
new file mode 100644
index 0000000..e086c2a
--- /dev/null
+++ b/include/hybbx/hybbx.h
@@ -0,0 +1,10 @@
+#ifndef HYBBX_H
+#define HYBBX_H
+
+#define HYBBX_VERSION_MAJOR 2
+#define HYBBX_VERSION_MINOR 8
+#define HYBBX_VERSION_PATCH 0
+
+#define HYBBX_VERSION_STRING "2.8.0"
+
+#endif /* HYBBX_H */
diff --git a/include/hybbx/instance.h b/include/hybbx/instance.h
new file mode 100644
index 0000000..9ed07d3
--- /dev/null
+++ b/include/hybbx/instance.h
@@ -0,0 +1,61 @@
+#ifndef HYBBX_INSTANCE_H
+#define HYBBX_INSTANCE_H
+
+#include "hybbx/networks.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Hardcoded network layout instance (compile-time per binary).
+ *
+ * hybbxd — Main (local BBX; WebSocket user path; hub + mains_proxy)
+ * hybbxsd — Secondary (other-Main peers via mains_proxy; no user BBX)
+ * hybbxpd — Proxy (hybrid bridge; no WebSocket; no in-process AX.25)
+ *
+ * SSoT: vault projects/hybbx/2026-07-16-network-layout-instances.md
+ */
+typedef enum hybbx_instance_role {
+ HYBBX_INSTANCE_MAIN = 1,
+ HYBBX_INSTANCE_SECONDARY = 2,
+ HYBBX_INSTANCE_PROXY = 3
+} hybbx_instance_role_t;
+
+hybbx_instance_role_t hybbx_instance_role(void);
+
+/**
+ * Standalone Main (hybbxd + local RF on one host).
+ * Set from INI `[instance] standalone=yes` or auto when Main INI enables local RF.
+ */
+void hybbx_instance_set_standalone(int enabled);
+int hybbx_instance_standalone(void);
+
+/** "hybbxd" / "hybbxsd" / "hybbxpd" */
+const char *hybbx_instance_binary_name(void);
+
+/** Short role label: "Main" / "Secondary" / "Proxy". */
+const char *hybbx_instance_role_name(void);
+
+/** Non-zero when this instance runs local user BBX (login/mail/chat). */
+int hybbx_instance_offers_user_bbx(void);
+
+/**
+ * Apply hard layout rules to @p networks (force/clear flags).
+ * Logs warnings when INI requested a forbidden combination.
+ * Returns HYBBX_OK, or HYBBX_ERR_INVALID when the role cannot start safely.
+ */
+hybbx_result_t hybbx_networks_enforce_instance(hybbx_networks_config_t *networks);
+
+/**
+ * Non-zero when @p plugin_name may be registered/started for this instance.
+ * Complements [networks] wanted checks.
+ */
+int hybbx_instance_plugin_allowed(const char *plugin_name);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_INSTANCE_H */
diff --git a/include/hybbx/kiss.h b/include/hybbx/kiss.h
new file mode 100644
index 0000000..9cd378d
--- /dev/null
+++ b/include/hybbx/kiss.h
@@ -0,0 +1,61 @@
+#ifndef HYBBX_KISS_H
+#define HYBBX_KISS_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_KISS_FEND 0xC0u
+#define HYBBX_KISS_FESC 0xDBu
+#define HYBBX_KISS_TFEND 0xDCu
+#define HYBBX_KISS_TFESC 0xDDu
+
+typedef enum hybbx_kiss_command {
+ HYBBX_KISS_CMD_DATA = 0x00,
+ HYBBX_KISS_CMD_TXDELAY = 0x01,
+ HYBBX_KISS_CMD_PERSIST = 0x02,
+ HYBBX_KISS_CMD_SLOTTIME = 0x03,
+ HYBBX_KISS_CMD_TXTAIL = 0x04,
+ HYBBX_KISS_CMD_FULLDUPLEX = 0x05,
+ HYBBX_KISS_CMD_SETHARDWARE = 0x06
+} hybbx_kiss_command_t;
+
+#define HYBBX_KISS_MAX_FRAME 2048
+
+typedef void (*hybbx_kiss_frame_cb)(uint8_t port, const uint8_t *frame,
+ size_t len, void *userdata);
+
+typedef struct hybbx_kiss_decoder {
+ uint8_t buf[HYBBX_KISS_MAX_FRAME];
+ size_t len;
+ int in_frame;
+ int escape;
+} hybbx_kiss_decoder_t;
+
+void hybbx_kiss_decoder_init(hybbx_kiss_decoder_t *dec);
+
+/**
+ * Feed raw serial bytes; complete KISS frames invoke @p cb.
+ */
+void hybbx_kiss_decoder_feed(hybbx_kiss_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_kiss_frame_cb cb, void *userdata);
+
+/**
+ * Encode a KISS frame into @p out (must hold at least @p out_cap bytes).
+ * Returns encoded length or 0 on error.
+ */
+size_t hybbx_kiss_encode(uint8_t port, hybbx_kiss_command_t cmd,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_KISS_H */
diff --git a/include/hybbx/limits.h b/include/hybbx/limits.h
new file mode 100644
index 0000000..081af1f
--- /dev/null
+++ b/include/hybbx/limits.h
@@ -0,0 +1,136 @@
+#ifndef HYBBX_LIMITS_H
+#define HYBBX_LIMITS_H
+
+#include "hybbx/traffic.h"
+
+/** INI parser line buffer size. */
+#define HYBBX_CONFIG_LINE_MAX 512
+
+/** Maximum path length for files and directories. */
+#define HYBBX_PATH_MAX 512
+
+/** Relative paths under the HyBBX install root (see @ref HYBBX_ENV_ROOT). */
+#define HYBBX_DIR_DATA "data"
+#define HYBBX_DIR_TEXT "text"
+#define HYBBX_DIR_LOGS "logs"
+#define HYBBX_FILE_CONFIG "hybbx.ini"
+#define HYBBX_FILE_COMMANDS "commands.yaml"
+#define HYBBX_FILE_AREAS "areas.yaml"
+#define HYBBX_DAEMON_BINARY "hybbxd"
+
+/** Command registry (share/commands.yaml + share/areas.yaml). */
+#define HYBBX_COMMANDS_MAX 64u
+#define HYBBX_AREAS_MAX 12u
+#define HYBBX_AREAS_SUB_MAX 4u
+#define HYBBX_COMMANDS_VERBS_PER_GROUP 16u
+#define HYBBX_COMMANDS_ALIASES_MAX 48u
+#define HYBBX_COMMANDS_ALIAS_PER 8u
+#define HYBBX_COMMANDS_ALIAS_LINES 8u
+#define HYBBX_MENU_LEVELS_MAX 5u
+#define HYBBX_MENU_AREAS_PER_LEVEL 12u
+#define HYBBX_RIGHTS_TARGET_RULES_MAX 16u
+#define HYBBX_RIGHTS_PROMOTE_RULES_MAX 8u
+#define HYBBX_RIGHTS_DEMOTE_RULES_MAX 4u
+#define HYBBX_COMMANDS_HELP_LINE_MAX 96u
+#define HYBBX_COMMANDS_HEADER_MAX 128u
+
+/** Environment variable: absolute path to the HyBBX install directory. */
+#define HYBBX_ENV_ROOT "HYBBX_ROOT"
+
+#define HYBBX_CONFIG_SECTION_MAX 128
+#define HYBBX_CONFIG_KEY_MAX 128
+#define HYBBX_CONFIG_VALUE_MAX 256
+
+/** Maximum tokens per HyBBX command line (verb + args). */
+#define HYBBX_CMD_TOKEN_MAX 16
+
+#define HYBBX_CMD_VERB_MAX 32
+
+/** Maximum registered transport plugins. */
+#define HYBBX_MAX_PLUGINS 16
+
+/** Maximum length of the global session prompt (empty = no visible prompt). */
+#define HYBBX_PROMPT_MAX 32
+
+/** Maximum single allocation for duplicated strings (bytes). */
+#define HYBBX_ALLOC_MAX (256u * 1024u)
+
+/** Default maximum simultaneous online sessions (guests included). */
+#define HYBBX_DEFAULT_MAX_ONLINE 35u
+
+/** Maximum concurrent HBX circuit links (secondaries) on Main. */
+#define HYBBX_CIRCUIT_MAX_LINKS 16u
+
+/** Default [circuit] max_links when not set in INI. */
+#define HYBBX_CIRCUIT_DEFAULT_MAX_LINKS 8u
+
+/** Maximum concurrent TNC devices in one packet_radio transport. */
+#define HYBBX_PACKET_RADIO_MAX_INSTANCES 8u
+
+/** Separates multiple INI sections in one packet_radio start config. */
+#define HYBBX_PACKET_RADIO_INSTANCE_SEP '\x1e'
+
+/** Maximum concurrent BayCom modems in one baycom transport. */
+#define HYBBX_BAYCOM_MAX_INSTANCES 4u
+
+/** Separates multiple INI sections in one baycom start config. */
+#define HYBBX_BAYCOM_INSTANCE_SEP '\x1e'
+
+/** Separates multiple INI sections in one mains_proxy start config. */
+#define HYBBX_MAINS_PROXY_INSTANCE_SEP '\x1e'
+
+/** Default guest session lifetime before auto-disconnect (minutes). */
+#define HYBBX_DEFAULT_GUEST_TIMEOUT_MINUTES 30u
+
+/** Command history depth for interactive line editors. */
+#define HYBBX_HISTORY_MAX 25u
+
+/** AX.25 auto-beacon minimum interval (seconds); INI may only increase this. */
+#define HYBBX_BROADCAST_AX25_INTERVAL_MIN_SEC 900u
+
+/** Per-link minimum between any AX.25 broadcast TX (manual or auto). */
+#define HYBBX_BROADCAST_AX25_LINK_MIN_SEC 900u
+
+/** RF channel must be idle this long before an auto AX.25 beacon may TX. */
+#define HYBBX_BROADCAST_AX25_BAND_IDLE_SEC 180u
+
+/** Minimum pause between successive link TX in one broadcast (seconds). */
+#define HYBBX_BROADCAST_AX25_LINK_GAP_SEC 180u
+
+/** Retry delay when a sequential broadcast step is deferred (band busy, etc.). */
+#define HYBBX_BROADCAST_AX25_DEFER_RETRY_SEC 30u
+
+/** Default max25d TCP listen port (MAX25 max25d.ini.example). */
+#define HYBBX_MAX25_DEFAULT_PORT 7325u
+
+/** Default TCP connect timeout for max25d reachability probe (ms). */
+#define HYBBX_MAX25_PROBE_TIMEOUT_MS 3000u
+
+/** Wait up to this long for max25d when [max25] check=yes (ms). */
+#define HYBBX_MAX25_PROBE_WAIT_MS 120000u
+
+/** AX.25 on-air payload cap (1200 baud; keep UI frames short). */
+#define HYBBX_BROADCAST_AX25_MESSAGE_MAX 48u
+
+/** SQLite fallback copy suffix (see `[storage] backup_path`). */
+#define HYBBX_STORAGE_BACKUP_SUFFIX ".flb"
+
+/** Default seconds between SQLite DB fallback copies. */
+#define HYBBX_STORAGE_BACKUP_INTERVAL_DEFAULT_SEC 300u
+
+/** [security] defaults — short cool-down bans (seconds). */
+#define HYBBX_SECURITY_DEFAULT_MAXRETRY 5u
+#define HYBBX_SECURITY_DEFAULT_FINDTIME_SEC 600u
+#define HYBBX_SECURITY_DEFAULT_BANTIME_SEC 600u
+/** Abuse (excessive spam/flood) — higher bar than login failures; no ban for normal use. */
+#define HYBBX_SECURITY_DEFAULT_ABUSE_MAXRETRY 30u
+#define HYBBX_SECURITY_DEFAULT_ABUSE_FINDTIME_SEC 600u
+#define HYBBX_SECURITY_DEFAULT_RATE_LIMIT 30u
+#define HYBBX_SECURITY_DEFAULT_RATE_WINDOW_SEC 60u
+#define HYBBX_SECURITY_BAN_MAX 256u
+#define HYBBX_SECURITY_TRACK_MAX 512u
+
+/** AX.25 CALL-SSID or HBX link_id (same ban table). */
+#define HYBBX_CALLID_MAX 64u
+
+#endif /* HYBBX_LIMITS_H */
diff --git a/include/hybbx/link.h b/include/hybbx/link.h
new file mode 100644
index 0000000..3bd60d1
--- /dev/null
+++ b/include/hybbx/link.h
@@ -0,0 +1,86 @@
+#ifndef HYBBX_LINK_H
+#define HYBBX_LINK_H
+
+/**
+ * HyBBX link/repeater edge daemon registry.
+ *
+ * Roles: gateway, digipeater, repeater, link — edge daemons toward centralized
+ * daemon [circuit]. Password auth only (LINK_AUTH); stale prune link_stale_days.
+ * INI: [circuit] link_* ; data/links/<id>.ini ; [link.<id>] in hybbx.ini.
+ */
+
+#include "hybbx/types.h"
+#include "hybbx/limits.h"
+
+#include <stddef.h>
+#include <time.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_LINK_STALE_DAYS 10u
+#define HYBBX_LINK_ID_MAX 64
+#define HYBBX_LINK_ROLE_MAX 32
+#define HYBBX_LINK_CODE_MAX 16
+#define HYBBX_LINK_AUTH_PAYLOAD_MAX 512
+
+typedef struct hybbx_link_auth {
+ char password[128];
+ char role[HYBBX_LINK_ROLE_MAX];
+ char id[HYBBX_LINK_ID_MAX];
+ unsigned baud;
+ int duplex;
+ char bandwidth[16];
+ char frequency_mhz[16];
+} hybbx_link_auth_t;
+
+typedef struct hybbx_link_registry {
+ char dir[HYBBX_PATH_MAX];
+ char config_path[HYBBX_PATH_MAX];
+ unsigned stale_days;
+} hybbx_link_registry_t;
+
+void hybbx_link_auth_clear(hybbx_link_auth_t *auth);
+
+/**
+ * Build line-oriented LINK_AUTH payload (password=, role=, id=, optional
+ * baud=, duplex=, bandwidth= for circuit load-balancing).
+ * @return payload length or 0 on error.
+ */
+size_t hybbx_link_auth_format(const hybbx_link_auth_t *auth,
+ char *out, size_t out_cap);
+
+/** Parse LINK_AUTH payload into @p auth. Returns HYBBX_OK on success. */
+hybbx_result_t hybbx_link_auth_parse(const char *payload, size_t len,
+ hybbx_link_auth_t *auth);
+
+void hybbx_link_registry_init(hybbx_link_registry_t *reg,
+ const char *data_dir,
+ const char *config_path,
+ unsigned stale_days);
+
+/**
+ * Record successful password authentication for @p id.
+ * Creates or updates @c data/links/<id>.ini and @c [link.<id>] in hybbx.ini.
+ */
+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);
+
+/**
+ * Remove link entries with @c last_seen older than @p stale_days.
+ * Also drops matching @c [link.*] sections from hybbx.ini when @p config_path set.
+ */
+hybbx_result_t hybbx_link_registry_prune(hybbx_link_registry_t *reg);
+
+/** Generate a short link code (uppercase alphanumeric). */
+void hybbx_link_generate_code(char *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_LINK_H */
diff --git a/include/hybbx/log.h b/include/hybbx/log.h
new file mode 100644
index 0000000..aa70b5b
--- /dev/null
+++ b/include/hybbx/log.h
@@ -0,0 +1,64 @@
+#ifndef HYBBX_LOG_H
+#define HYBBX_LOG_H
+
+#include "hybbx/types.h"
+#include "hybbx/limits.h"
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * File + console log severity (least to most severe for filtering):
+ * debug → stats → info → warn (default threshold).
+ * Messages at or above the configured [log] level are emitted.
+ */
+typedef enum hybbx_log_level {
+ HYBBX_LOG_DEBUG = 0,
+ HYBBX_LOG_STATS = 1,
+ HYBBX_LOG_INFO = 2,
+ HYBBX_LOG_WARN = 3
+} hybbx_log_level_t;
+
+typedef struct hybbx_log_config {
+ int enabled;
+ char dir[HYBBX_PATH_MAX];
+ hybbx_log_level_t level;
+} hybbx_log_config_t;
+
+void hybbx_log_config_defaults(hybbx_log_config_t *cfg);
+void hybbx_log_config_apply(const struct hybbx_config *config);
+const hybbx_log_config_t *hybbx_log_config_get(void);
+
+/** Non-zero when file logging is active. */
+int hybbx_log_enabled(void);
+
+/**
+ * Copy absolute path of the current hybbx log file into @p out.
+ * Returns HYBBX_OK when logging is enabled and a path is available.
+ */
+hybbx_result_t hybbx_log_current_path(char *out, size_t out_len);
+
+const char *hybbx_log_level_name(hybbx_log_level_t level);
+hybbx_log_level_t hybbx_log_parse_level(const char *value);
+
+/** Non-zero when @p level would be written (console and file). */
+int hybbx_log_level_visible(hybbx_log_level_t level);
+
+void hybbx_log_write(hybbx_log_level_t level, const char *fmt, ...)
+ __attribute__((format(printf, 2, 3)));
+
+void hybbx_log_shutdown(void);
+
+#define hybbx_log_debug(...) hybbx_log_write(HYBBX_LOG_DEBUG, __VA_ARGS__)
+#define hybbx_log_stats(...) hybbx_log_write(HYBBX_LOG_STATS, __VA_ARGS__)
+#define hybbx_log_info(...) hybbx_log_write(HYBBX_LOG_INFO, __VA_ARGS__)
+#define hybbx_log_warn(...) hybbx_log_write(HYBBX_LOG_WARN, __VA_ARGS__)
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_LOG_H */
diff --git a/include/hybbx/mail.h b/include/hybbx/mail.h
new file mode 100644
index 0000000..f96e1b6
--- /dev/null
+++ b/include/hybbx/mail.h
@@ -0,0 +1,129 @@
+#ifndef HYBBX_MAIL_H
+#define HYBBX_MAIL_H
+
+#include "hybbx/config.h"
+#include "hybbx/storage.h"
+#include "hybbx/types.h"
+
+#include <time.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+/** Maximum stored messages per user inbox. */
+#define HYBBX_MAIL_MAX_MESSAGES 50u
+
+/** Subject line limit (80-column profile). */
+#define HYBBX_MAIL_SUBJECT_MAX 72u
+
+/** Total message body size (all lines). */
+#define HYBBX_MAIL_BODY_MAX 2048u
+
+/** Default days before recycled mail is permanently removed. */
+#define HYBBX_MAIL_DEFAULT_RECYCLE_DAYS 10u
+
+typedef struct hybbx_mail_config {
+ int enabled;
+ unsigned max_messages;
+ unsigned subject_max;
+ unsigned body_max;
+ unsigned recycle_days;
+ /** Root mail directory (typically <storage>/mail). */
+ char root[512];
+} hybbx_mail_config_t;
+
+typedef struct hybbx_mail_entry {
+ uint64_t id;
+ char from[64];
+ char subject[HYBBX_MAIL_SUBJECT_MAX];
+ time_t received_at;
+ int read;
+} hybbx_mail_entry_t;
+
+void hybbx_mail_config_defaults(hybbx_mail_config_t *mail);
+
+/** Load `[mail]` and set @p mail->root under @p storage_path. */
+void hybbx_mail_config_apply(hybbx_mail_config_t *mail,
+ const hybbx_config_t *config,
+ const char *storage_path);
+
+const hybbx_mail_config_t *hybbx_service_get_mail(const struct hybbx_service *service);
+
+/** List inbox on @p session (registered users only). */
+void hybbx_mail_list_inbox(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+/**
+ * List inbox rows @p from..@p to (1-based, newest first).
+ * @p to = 0 means through the last message.
+ */
+void hybbx_mail_list_inbox_range(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned from, unsigned to);
+
+/**
+ * After login: if mail arrived since @p since_login, announce the count.
+ * No output when mail is disabled, the user is a guest, or the count is zero.
+ * @p since_login 0 means first login (all inbox messages count as new).
+ */
+void hybbx_mail_announce_since_last_login(struct hybbx_service *service,
+ struct hybbx_session *session,
+ time_t since_login);
+
+/**
+ * Parse @p spec as @c from-to (e.g. @c 1-15 , @c 5-20 ) or a single index.
+ * Returns 0 on invalid input.
+ */
+int hybbx_mail_parse_list_range(const char *spec,
+ unsigned *from, unsigned *to);
+
+/**
+ * Read message by 1-based list index (newest first).
+ * Marks the message as read.
+ */
+hybbx_result_t hybbx_mail_read(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned list_index);
+
+/** Move inbox message(s) to recycle (1-based index or range). */
+hybbx_result_t hybbx_mail_delete(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned list_index);
+
+hybbx_result_t hybbx_mail_delete_range(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned from, unsigned to);
+
+/** Permanently remove all messages in the user's recycle bin. */
+hybbx_result_t hybbx_mail_recycle_empty(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+/**
+ * Deliver a message from @p from_user to @p to_user.
+ * @p to_user must exist and be an active registered account.
+ */
+hybbx_result_t hybbx_mail_deliver(struct hybbx_service *service,
+ const char *from_user,
+ const char *to_user,
+ const char *subject,
+ const char *body);
+
+/**
+ * Mail active Sysop and Admin when a guest self-registers.
+ * Body lists every field from `/register` plus assigned account metadata.
+ * No-op when mail is disabled. Individual delivery failures are ignored.
+ */
+hybbx_result_t hybbx_mail_notify_staff_registration(
+ struct hybbx_service *service,
+ const hybbx_user_registration_t *reg,
+ const hybbx_user_record_t *user);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_MAIL_H */
diff --git a/include/hybbx/mains_proxy.h b/include/hybbx/mains_proxy.h
new file mode 100644
index 0000000..7d91c87
--- /dev/null
+++ b/include/hybbx/mains_proxy.h
@@ -0,0 +1,120 @@
+#ifndef HYBBX_MAINS_PROXY_H
+#define HYBBX_MAINS_PROXY_H
+
+/**
+ * Main-to-Main mesh proxy — link HyBBX instances for user services only.
+ *
+ * SECURITY: inter-node mesh links MUST attach via HBX/Circuit only
+ * (hybbx_circuit_link_connect + LINK_AUTH). Never open a raw TCP socket
+ * to a remote Main or peer process — same rule as Secondary edge links.
+ *
+ * Only proxymail and proxychat service payloads cross the mesh. No user
+ * accounts, rights, or other Main data are transferred.
+ */
+
+#include "hybbx/circuit.h"
+#include "hybbx/config.h"
+#include "hybbx/link.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_MAINS_PROXY_PEER_ID_MAX 64
+#define HYBBX_MAINS_PROXY_HOST_MAX 256
+#define HYBBX_MAINS_PROXY_MAX_PEERS 8u
+
+typedef enum hybbx_mains_proxy_wire {
+ /** IP path carried on HBX circuit frames (not a direct Main socket). */
+ HYBBX_MAINS_PROXY_WIRE_CIRCUIT = 0,
+ HYBBX_MAINS_PROXY_WIRE_AX25,
+} hybbx_mains_proxy_wire_t;
+
+typedef enum hybbx_mains_proxy_duplex {
+ HYBBX_MAINS_PROXY_DUPLEX_FULL = 0,
+ HYBBX_MAINS_PROXY_DUPLEX_HALF,
+} hybbx_mains_proxy_duplex_t;
+
+typedef struct hybbx_mains_proxy_peer_config {
+ char peer_id[HYBBX_MAINS_PROXY_PEER_ID_MAX];
+ char circuit_host[HYBBX_MAINS_PROXY_HOST_MAX];
+ unsigned circuit_port;
+ char link_id[HYBBX_LINK_ID_MAX];
+ char link_password[128];
+ /** @deprecated Ignored for mesh connect — use circuit_host. */
+ char host[HYBBX_MAINS_PROXY_HOST_MAX];
+ /** @deprecated Ignored for mesh connect — use circuit_port. */
+ unsigned port;
+ hybbx_mains_proxy_wire_t wire;
+ hybbx_mains_proxy_duplex_t duplex;
+ int use_secondary;
+ int enabled;
+} hybbx_mains_proxy_peer_config_t;
+
+typedef struct hybbx_mains_proxy_mesh {
+ unsigned peer_count;
+ hybbx_mains_proxy_peer_config_t peers[HYBBX_MAINS_PROXY_MAX_PEERS];
+ int running;
+} hybbx_mains_proxy_mesh_t;
+
+struct hybbx_service;
+
+hybbx_mains_proxy_wire_t hybbx_mains_proxy_wire_parse(const char *value);
+const char *hybbx_mains_proxy_wire_name(hybbx_mains_proxy_wire_t wire);
+
+hybbx_mains_proxy_duplex_t hybbx_mains_proxy_duplex_parse(const char *value);
+const char *hybbx_mains_proxy_duplex_name(hybbx_mains_proxy_duplex_t duplex);
+
+void hybbx_mains_proxy_peer_defaults(hybbx_mains_proxy_peer_config_t *peer);
+
+/** Parse one `transport.mains_proxy` / `transport.mains_proxyN` section. */
+hybbx_result_t hybbx_mains_proxy_peer_parse(const char *config,
+ hybbx_mains_proxy_peer_config_t *out);
+
+void hybbx_mains_proxy_mesh_init(hybbx_mains_proxy_mesh_t *mesh);
+
+/**
+ * Register peers and start mesh. Existing mesh state is replaced when called
+ * again while running.
+ */
+hybbx_result_t hybbx_mains_proxy_mesh_start(struct hybbx_service *service,
+ hybbx_mains_proxy_mesh_t *mesh);
+
+void hybbx_mains_proxy_mesh_stop(hybbx_mains_proxy_mesh_t *mesh);
+
+/** Periodic mesh I/O. Call from service main loop when running. */
+void hybbx_mains_proxy_mesh_tick(struct hybbx_service *service,
+ hybbx_mains_proxy_mesh_t *mesh);
+
+/** Non-zero when the mesh relay is active with at least one live peer link. */
+int hybbx_mains_proxy_mesh_active(void);
+
+/**
+ * Deliver proxymail to a configured peer (HBX PROXY_MAIL frame).
+ * @p from_address and @p to_address use @c user@service form.
+ */
+hybbx_result_t hybbx_mains_proxy_send_mail(struct hybbx_service *service,
+ const char *from_address,
+ const char *to_address,
+ const char *subject,
+ const char *body);
+
+/** Relay a proxychat line to all connected peers (HBX PROXY_CHAT frame). */
+hybbx_result_t hybbx_mains_proxy_send_chat(struct hybbx_service *service,
+ const char *from_address,
+ const char *line);
+
+/** Handle PROXY_MAIL / PROXY_CHAT received on the circuit hub (inbound link). */
+void hybbx_mains_proxy_inbound_frame(struct hybbx_service *service,
+ hybbx_circuit_proto_t proto,
+ const uint8_t *payload, size_t len);
+
+/** Service-loop tick (implemented by plugin when built). */
+void hybbx_mains_proxy_plugin_tick(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_MAINS_PROXY_H */
diff --git a/include/hybbx/max25.h b/include/hybbx/max25.h
new file mode 100644
index 0000000..5bae5c3
--- /dev/null
+++ b/include/hybbx/max25.h
@@ -0,0 +1,72 @@
+#ifndef HYBBX_MAX25_H
+#define HYBBX_MAX25_H
+
+#include "hybbx/config.h"
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default max25d TCP listen address. */
+#define HYBBX_MAX25_DEFAULT_HOST "127.0.0.1"
+
+typedef struct hybbx_max25_config {
+ int check;
+ char host[HYBBX_CONFIG_VALUE_MAX];
+ unsigned port;
+ unsigned timeout_ms;
+} hybbx_max25_config_t;
+
+/** Snapshot from max25d M25/1 STATUS (read-only; HyBBX never overrides). */
+typedef struct hybbx_max25_status {
+ int handshake_ok;
+ char error[16];
+ char voice[16];
+ char stack[32];
+ char serial[64];
+} hybbx_max25_status_t;
+
+void hybbx_max25_config_defaults(hybbx_max25_config_t *cfg);
+
+/** Load `[max25]` from INI. `check` defaults to `yes`. Reporting is MAX25-only. */
+void hybbx_max25_config_apply(hybbx_max25_config_t *cfg,
+ const hybbx_config_t *config);
+
+/** Parse a semicolon key=value chunk (`max25_check=…` keys). */
+void hybbx_max25_config_parse_kv(const char *max25_kv,
+ hybbx_max25_config_t *cfg);
+
+/**
+ * When @p config starts with a max25 prefix (before
+ * @ref HYBBX_PACKET_RADIO_INSTANCE_SEP), parse it and return the remainder.
+ * When no prefix is present, @p cfg->check is cleared.
+ */
+const char *hybbx_max25_config_skip_prefix(const char *config,
+ hybbx_max25_config_t *cfg);
+
+void hybbx_max25_status_clear(hybbx_max25_status_t *status);
+
+/**
+ * TCP connect + M25/1 connect handshake (`OK` + `STATUS …`).
+ * Returns @ref HYBBX_OK when max25d responds; @p status_out receives
+ * `error=` / `voice=` / `stack=` / `serial=` as reported by MAX25 (valid or invalid).
+ * Data-quality rules (3×20s passes, CALLID, min % good) live in max25d only —
+ * HyBBX accepts STATUS without override; only TCP/handshake failure is fatal.
+ */
+hybbx_result_t hybbx_max25_probe(const hybbx_max25_config_t *cfg,
+ hybbx_max25_status_t *status_out);
+
+/**
+ * When @p cfg->check is set, block until max25d responds or
+ * @ref HYBBX_MAX25_PROBE_WAIT_MS elapses.
+ * Returns @ref HYBBX_OK when max25d is reachable; @ref HYBBX_ERR_IO on timeout.
+ */
+hybbx_result_t hybbx_max25_wait_ready(const hybbx_max25_config_t *cfg);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_MAX25_H */
diff --git a/include/hybbx/messages.h b/include/hybbx/messages.h
new file mode 100644
index 0000000..d1c7345
--- /dev/null
+++ b/include/hybbx/messages.h
@@ -0,0 +1,58 @@
+#ifndef HYBBX_MESSAGES_H
+#define HYBBX_MESSAGES_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_session;
+
+/** Automatic system notices (login, mail, timeouts, conference status, …). */
+#define HYBBX_MSG_PREFIX_SYSTEM "*** "
+
+/** Sysop / granted manual system-wide notices (`/broadcast`, `/announce`). */
+#define HYBBX_MSG_PREFIX_SYSOP "+++ "
+
+/** Private directed messages (system or Sysop+granted); v1 half-stub. */
+#define HYBBX_MSG_PREFIX_PRIVATE "### "
+
+/**
+ * Format a system line: `*** <body>`
+ */
+hybbx_result_t hybbx_msg_format_system(char *out, size_t out_len,
+ const char *body);
+
+/**
+ * Format a Sysop/granted system-wide line: `+++ <from>: <body>`
+ * @p from_label may be NULL → `+++ <body>`
+ */
+hybbx_result_t hybbx_msg_format_sysop(char *out, size_t out_len,
+ const char *from_label,
+ const char *body);
+
+/**
+ * Format a private line: `### <from>: <body>` or `### <body>` when @p from_label is NULL.
+ */
+hybbx_result_t hybbx_msg_format_private(char *out, size_t out_len,
+ const char *from_label,
+ const char *body);
+
+hybbx_result_t hybbx_msg_send_system(struct hybbx_session *session,
+ const char *body);
+
+hybbx_result_t hybbx_msg_send_sysop(struct hybbx_session *session,
+ const char *from_label,
+ const char *body);
+
+/** Half-stub: directed private message to one session. */
+hybbx_result_t hybbx_msg_send_private(struct hybbx_session *session,
+ const char *from_label,
+ const char *body);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_MESSAGES_H */
diff --git a/include/hybbx/monitor.h b/include/hybbx/monitor.h
new file mode 100644
index 0000000..a4d8db5
--- /dev/null
+++ b/include/hybbx/monitor.h
@@ -0,0 +1,74 @@
+#ifndef HYBBX_MONITOR_H
+#define HYBBX_MONITOR_H
+
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_config;
+struct hybbx_service;
+struct hybbx_session;
+
+#define HYBBX_MONITOR_ALLOW_MAX 16u
+#define HYBBX_MONITOR_ALLOW_NAME_MAX 64u
+
+typedef struct hybbx_monitor_config {
+ int enabled;
+ int follow_hybbx;
+ int follow_security;
+ /** When set, Sysop sessions are always hidden from /who and /online. */
+ int invisible_sysop;
+ unsigned invite_timeout_sec;
+ char allow[HYBBX_MONITOR_ALLOW_MAX][HYBBX_MONITOR_ALLOW_NAME_MAX];
+ unsigned allow_count;
+} hybbx_monitor_config_t;
+
+void hybbx_monitor_config_defaults(hybbx_monitor_config_t *cfg);
+void hybbx_monitor_config_apply(const struct hybbx_config *config);
+const hybbx_monitor_config_t *hybbx_monitor_config_get(void);
+
+/** Non-zero when [monitor] enabled=yes. */
+int hybbx_monitor_enabled(void);
+
+/** Non-zero when [monitor] invisible-sysop=yes (Sysop always /who-hidden). */
+int hybbx_monitor_invisible_sysop(void);
+
+/** Non-zero when @p username is listed in [monitor] allow=. */
+int hybbx_monitor_user_allowed(const char *username);
+
+/**
+ * Non-zero when @p session may use /monitor (Sysop/registry OR allow list).
+ * Caller still runs normal command access for registry min-level.
+ */
+int hybbx_monitor_session_may_use(const struct hybbx_session *session);
+
+/** Enable/disable monitor mode on @p session. */
+hybbx_result_t hybbx_monitor_set_active(struct hybbx_session *session, int on);
+
+int hybbx_monitor_is_active(const struct hybbx_session *session);
+
+/**
+ * Push a live line to every session with monitor mode on.
+ * Prefixes with "[monitor] " for clarity.
+ */
+void hybbx_monitor_broadcast(struct hybbx_service *service, const char *line);
+
+/** Format Username@plugin event and push live (always when any monitor on). */
+void hybbx_monitor_event(struct hybbx_service *service,
+ const char *username,
+ const char *plugin,
+ const char *detail);
+
+/** Poll log file tails (call from service loop). */
+void hybbx_monitor_tick(struct hybbx_service *service);
+
+void hybbx_monitor_shutdown(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_MONITOR_H */
diff --git a/include/hybbx/networks.h b/include/hybbx/networks.h
new file mode 100644
index 0000000..ab8e954
--- /dev/null
+++ b/include/hybbx/networks.h
@@ -0,0 +1,63 @@
+#ifndef HYBBX_NETWORKS_H
+#define HYBBX_NETWORKS_H
+
+#include "hybbx/config.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Connection / link adapter switches from INI `[networks]`.
+ *
+ * Static transport depends on instance role (see hybbx/instance.h):
+ * Main — websocket (always on when built)
+ * Secondary — mains_proxy
+ * Proxy — none (optional TCP transports)
+ */
+typedef struct hybbx_networks_config {
+ /** Telnet (`transport.telnet`) — not used on Main (WebSocket only). */
+ int telnet;
+ /** AX.25 / packet radio — always forced off (MAX25-Stack owns RF). */
+ int ax25;
+ /** BayCom — always forced off (MAX25-Stack owns RF). */
+ int baycom;
+ /** ARDOP — always forced off (MAX25-Stack owns RF / soft-modem). */
+ int ardop;
+ /** CRDOP — always forced off (MAX25-Stack owns RF / soft-modem). */
+ int crdop;
+ /** SSH transport (`transport.ssh`). */
+ int ssh;
+ /** WebSocket forward-proxy (`transport.websocket`) — Main static path. */
+ int websocket;
+ /** Internal HBX circuit hub / attach. */
+ int circuit;
+ /** Main-to-Main mesh (`transport.mains_proxy`) — Secondary primary. */
+ int mains_proxy;
+} hybbx_networks_config_t;
+
+void hybbx_networks_config_defaults(hybbx_networks_config_t *networks);
+
+/** Load `[networks]` from an INI file. */
+void hybbx_networks_config_apply(hybbx_networks_config_t *networks,
+ const hybbx_config_t *config);
+
+/**
+ * Non-zero when @p plugin_name is the role's static core transport.
+ * Ignores `[transport.*] enabled` and is started when built.
+ */
+int hybbx_networks_is_static_transport(const char *plugin_name);
+
+/**
+ * Non-zero when transport @p plugin_name should be started for this config.
+ * Respects instance plugin allow-list and `[networks]` toggles.
+ */
+int hybbx_networks_transport_wanted(const char *plugin_name,
+ const hybbx_networks_config_t *networks);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_NETWORKS_H */
diff --git a/include/hybbx/packet_radio.h b/include/hybbx/packet_radio.h
new file mode 100644
index 0000000..51f79a0
--- /dev/null
+++ b/include/hybbx/packet_radio.h
@@ -0,0 +1,57 @@
+#ifndef HYBBX_PACKET_RADIO_H
+#define HYBBX_PACKET_RADIO_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default line speed for packet radio (2400 baud). */
+#define HYBBX_PACKET_RADIO_DEFAULT_BAUD 2400
+
+/** Default USB AX.25 TNC/modem device (Linux). */
+#define HYBBX_PACKET_RADIO_DEFAULT_DEVICE_USB "/dev/ttyUSB0"
+
+#if defined(_WIN32)
+#define HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL "COM1"
+#elif defined(__AMIGA__)
+#define HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL "serial.device"
+#else
+#define HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL "/dev/ttyS0"
+#endif
+
+typedef enum hybbx_packet_radio_device_type {
+ HYBBX_PACKET_RADIO_DEVICE_USB = 1,
+ HYBBX_PACKET_RADIO_DEVICE_SERIAL = 2
+} hybbx_packet_radio_device_type_t;
+
+struct hybbx_packet_radio_config;
+
+/**
+ * Parse a semicolon-separated key=value transport config string.
+ * Missing keys receive HyBBX defaults (TNC2C, USB, KISS @ 2400 baud).
+ * Full config type: include hybbx/tnc.h.
+ */
+hybbx_result_t hybbx_packet_radio_config_parse(const char *config,
+ struct hybbx_packet_radio_config *out);
+
+void hybbx_packet_radio_config_free(struct hybbx_packet_radio_config *config);
+
+/**
+ * Non-zero when @p config starts a local TNC (device/tnc/protocol/circuit_host).
+ * Main bridge-registry rows (link_id only) return zero.
+ */
+int hybbx_packet_radio_section_is_local_edge(const char *config);
+
+/**
+ * Return non-zero when @p baud is valid for packet radio configuration.
+ * HyBBX accepts any positive host baud rate; TNC2C documents 300..38400.
+ */
+int hybbx_packet_radio_baud_valid(unsigned int baud);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PACKET_RADIO_H */
diff --git a/include/hybbx/password.h b/include/hybbx/password.h
new file mode 100644
index 0000000..cc1b38e
--- /dev/null
+++ b/include/hybbx/password.h
@@ -0,0 +1,53 @@
+#ifndef HYBBX_PASSWORD_H
+#define HYBBX_PASSWORD_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Stored password prefix for SHA-256 (default, tinysha256). */
+#define HYBBX_PASSWORD_SHA256_PREFIX "{sha256}"
+/** Stored password prefix for legacy MD5 (verify-only fallback). */
+#define HYBBX_PASSWORD_MD5_PREFIX "{md5}"
+
+/** Max stored password field: prefix + 64 hex digits + NUL. */
+#define HYBBX_PASSWORD_STORED_MAX 96
+
+/** Non-zero when @p stored is a hashed password (sha256 or md5 prefix). */
+int hybbx_password_is_hashed(const char *stored);
+
+/** Non-zero when @p stored is plain text and should be upgraded on load. */
+int hybbx_password_is_plain(const char *stored);
+
+/**
+ * Hash @p plain into @p out as {sha256}<hex> using the configured backend
+ * (default: bundled tinysha256; see [crypto] password_hash in hybbx.ini).
+ */
+hybbx_result_t hybbx_password_hash_sha256(const char *plain,
+ char *out,
+ size_t out_size);
+
+/** Hash @p plain with the configured default algorithm (SHA-256). */
+hybbx_result_t hybbx_password_hash(const char *plain, char *out, size_t out_size);
+
+/** Return non-zero when @p provided matches @p stored (plain, sha256, or md5). */
+int hybbx_password_match(const char *stored, const char *provided);
+
+/**
+ * Fill @p out with a random password of length @p min_len … @p max_len using
+ * characters a-z and 0-9 only.
+ */
+hybbx_result_t hybbx_password_generate_alnum(char *out,
+ size_t out_size,
+ size_t min_len,
+ size_t max_len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PASSWORD_H */
diff --git a/include/hybbx/plugin.h b/include/hybbx/plugin.h
new file mode 100644
index 0000000..ea72a65
--- /dev/null
+++ b/include/hybbx/plugin.h
@@ -0,0 +1,75 @@
+#ifndef HYBBX_PLUGIN_H
+#define HYBBX_PLUGIN_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+/**
+ * Transport plugin interface (link adapters / host-client bridges).
+ *
+ * HyBBX is plugin-only: session core + plugins. Modems, TNCs, sound-card
+ * software, ARDOPC, and CRDOPC are external — never embedded in HyBBX.
+ *
+ * Core uses TCP/IPv4+IPv6 and HBX internally. Plugins terminate the wire
+ * to external services (telnet TCP, serial TNC, ARDOP host TCP, …) and
+ * expose a byte stream to hybbx_session via the write callback.
+ */
+typedef struct hybbx_transport_plugin {
+ const char *name;
+ hybbx_transport_kind_t kind;
+ unsigned int version;
+
+ /** Called once when the plugin is loaded. */
+ hybbx_result_t (*init)(struct hybbx_service *service);
+
+ /** Called once when the plugin is unloaded. */
+ void (*shutdown)(void);
+
+ /**
+ * Start listening / accepting connections on the given bind address.
+ * @p config is a semicolon-separated key=value string built from the
+ * transport's INI section (e.g. "bind=0.0.0.0;port=2323").
+ */
+ hybbx_result_t (*start)(const char *config);
+
+ /** Stop accepting new connections; existing sessions may continue. */
+ hybbx_result_t (*stop)(void);
+
+ /**
+ * Send raw bytes to the connected client for this session.
+ * NULL → core writes to stdout (development fallback).
+ */
+ hybbx_result_t (*write)(struct hybbx_session *session,
+ const char *data, size_t len);
+
+} hybbx_transport_plugin_t;
+
+/**
+ * Session callbacks invoked by a transport plugin when a client connects.
+ */
+typedef struct hybbx_session_ops {
+ hybbx_result_t (*on_connect)(struct hybbx_session *session, void *userdata);
+ hybbx_result_t (*on_data)(struct hybbx_session *session,
+ const uint8_t *data, size_t len,
+ void *userdata);
+ void (*on_disconnect)(struct hybbx_session *session, void *userdata);
+} hybbx_session_ops_t;
+
+/** Opaque session handle passed between core and transport plugins. */
+typedef struct hybbx_session {
+ const hybbx_transport_plugin_t *transport;
+ void *transport_data;
+ void *core_data;
+} hybbx_session_t;
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PLUGIN_H */
diff --git a/include/hybbx/posix_time.h b/include/hybbx/posix_time.h
new file mode 100644
index 0000000..6790120
--- /dev/null
+++ b/include/hybbx/posix_time.h
@@ -0,0 +1,21 @@
+#ifndef HYBBX_POSIX_TIME_H
+#define HYBBX_POSIX_TIME_H
+
+/*
+ * clock_gettime(CLOCK_MONOTONIC) and usleep() on glibc/Linux (GCC 15+ C99).
+ * Include before <time.h> / <unistd.h> in .c files, or rely on CMake
+ * _DEFAULT_SOURCE on Linux builds.
+ */
+#if defined(__linux__) || defined(__GLIBC__)
+#ifndef _DEFAULT_SOURCE
+#define _DEFAULT_SOURCE 1
+#endif
+#endif
+
+#include <time.h>
+
+#if !defined(_WIN32)
+#include <unistd.h>
+#endif
+
+#endif /* HYBBX_POSIX_TIME_H */
diff --git a/include/hybbx/privilege.h b/include/hybbx/privilege.h
new file mode 100644
index 0000000..df646c8
--- /dev/null
+++ b/include/hybbx/privilege.h
@@ -0,0 +1,24 @@
+#ifndef HYBBX_PRIVILEGE_H
+#define HYBBX_PRIVILEGE_H
+
+#include "hybbx/config.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Apply [service] user=/group=/uid=/gid= from @p config.
+ *
+ * Linux: hybbxd must not keep running as root. When euid==0, @c user is
+ * required and privileges drop immediately via setuid/setgid.
+ * Non-root starts are unchanged (optional @c user must match effective uid).
+ */
+hybbx_result_t hybbx_privilege_apply_from_config(const hybbx_config_t *config);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PRIVILEGE_H */
diff --git a/include/hybbx/proxychat.h b/include/hybbx/proxychat.h
new file mode 100644
index 0000000..74f71f2
--- /dev/null
+++ b/include/hybbx/proxychat.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_PROXYCHAT_H
+#define HYBBX_PROXYCHAT_H
+
+/**
+ * Inter-Main chat via mains_proxy.
+ *
+ * Local `/chat` stays on this Main. `/proxychat` is the mains-proxy sub-area
+ * under `/chat` (or `/chat proxychat`). Only chat lines cross the mesh — no
+ * user accounts or rights.
+ */
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+struct hybbx_session;
+
+/** Show proxychat area banner. */
+void hybbx_proxychat_show_banner(struct hybbx_session *session);
+
+/**
+ * Post a line in proxychat (relay when mesh is active).
+ */
+hybbx_result_t hybbx_proxychat_post(struct hybbx_service *service,
+ struct hybbx_session *session,
+ const char *line);
+
+/** Deliver a line received from a remote Main. */
+void hybbx_proxychat_receive(struct hybbx_service *service,
+ const char *from_address,
+ const char *line);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PROXYCHAT_H */
diff --git a/include/hybbx/proxymail.h b/include/hybbx/proxymail.h
new file mode 100644
index 0000000..123b663
--- /dev/null
+++ b/include/hybbx/proxymail.h
@@ -0,0 +1,73 @@
+#ifndef HYBBX_PROXYMAIL_H
+#define HYBBX_PROXYMAIL_H
+
+/**
+ * Inter-Main mail via mains_proxy — separate from local `/mail`.
+ *
+ * Local mail uses flat-file or SQL under `[mail]`; proxymail uses flat files
+ * under `data/proxymail/` on each node. Only service payloads cross the mesh.
+ */
+
+#include "hybbx/mail.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** `username@remote-main-service` address string. */
+#define HYBBX_PROXYMAIL_ADDRESS_MAX 128u
+#define HYBBX_PROXYMAIL_SERVICE_NAME_MAX 64u
+
+struct hybbx_service;
+struct hybbx_session;
+
+/**
+ * Parse @p address as @c user@remote-service .
+ * @return 0 when invalid.
+ */
+int hybbx_proxymail_parse_address(const char *address,
+ char *user, size_t user_len,
+ char *remote_service, size_t remote_len);
+
+/** List proxymail inbox (stub). */
+void hybbx_proxymail_list_inbox(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+void hybbx_proxymail_list_inbox_range(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned from, unsigned to);
+
+hybbx_result_t hybbx_proxymail_read(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned list_index);
+
+hybbx_result_t hybbx_proxymail_delete_range(struct hybbx_service *service,
+ struct hybbx_session *session,
+ unsigned from, unsigned to);
+
+hybbx_result_t hybbx_proxymail_recycle_empty(struct hybbx_service *service,
+ struct hybbx_session *session);
+
+/**
+ * Queue outbound proxymail for mesh delivery.
+ * @p to_address must be @c user@remote-main-service .
+ */
+hybbx_result_t hybbx_proxymail_deliver(struct hybbx_service *service,
+ const char *from_user,
+ const char *to_address,
+ const char *subject,
+ const char *body);
+
+/** Store proxymail received from a remote Main. */
+void hybbx_proxymail_receive(struct hybbx_service *service,
+ const char *from_address,
+ const char *to_address,
+ const char *subject,
+ const char *body);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_PROXYMAIL_H */
diff --git a/include/hybbx/registry.h b/include/hybbx/registry.h
new file mode 100644
index 0000000..51577ee
--- /dev/null
+++ b/include/hybbx/registry.h
@@ -0,0 +1,28 @@
+#ifndef HYBBX_REGISTRY_H
+#define HYBBX_REGISTRY_H
+
+#include "hybbx/plugin.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Register a transport plugin (called at plugin init time). */
+hybbx_result_t hybbx_registry_register(const hybbx_transport_plugin_t *plugin);
+
+/** Unregister a transport plugin by name. */
+hybbx_result_t hybbx_registry_unregister(const char *name);
+
+/** Look up a registered plugin by name. Returns NULL if not found. */
+const hybbx_transport_plugin_t *hybbx_registry_find(const char *name);
+
+/** Iterate over all registered plugins. */
+typedef void (*hybbx_registry_iter_fn)(const hybbx_transport_plugin_t *plugin,
+ void *userdata);
+void hybbx_registry_foreach(hybbx_registry_iter_fn fn, void *userdata);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_REGISTRY_H */
diff --git a/include/hybbx/rf_tx_pace.h b/include/hybbx/rf_tx_pace.h
new file mode 100644
index 0000000..f987490
--- /dev/null
+++ b/include/hybbx/rf_tx_pace.h
@@ -0,0 +1,16 @@
+#ifndef HYBBX_RF_TX_PACE_H
+#define HYBBX_RF_TX_PACE_H
+
+/** Minimum idle time between RF frame sends (HyBBX packet radio). */
+#define HYBBX_RF_TX_MIN_GAP_MS 1500u
+
+/** Maximum continuous RF send burst (wall clock); new burst after MIN_GAP idle. */
+#define HYBBX_RF_TX_MAX_BURST_MS 15000u
+
+/** Min gap + max burst (TNC / direct serial). */
+void hybbx_rf_tx_pace(void);
+
+/** Max burst only — use when max25-bcpr enforces min gap on KISS (baycom plugin). */
+void hybbx_rf_tx_burst_guard(void);
+
+#endif
diff --git a/include/hybbx/security.h b/include/hybbx/security.h
new file mode 100644
index 0000000..f4a9476
--- /dev/null
+++ b/include/hybbx/security.h
@@ -0,0 +1,31 @@
+#ifndef HYBBX_SECURITY_H
+#define HYBBX_SECURITY_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_config;
+
+/** Log file name under the configured logs directory. */
+#define HYBBX_SECURITY_LOG_FILE "security.log"
+
+void hybbx_security_log_config_apply(const struct hybbx_config *config);
+void hybbx_security_log_shutdown(void);
+
+/** Append one line to security.log (timestamp added). Always enabled when the log directory is available. */
+void hybbx_security_log_write(const char *fmt, ...)
+ __attribute__((format(printf, 1, 2)));
+
+/** Absolute path to security.log when the security log is ready. */
+hybbx_result_t hybbx_security_log_current_path(char *out, size_t out_len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SECURITY_H */
diff --git a/include/hybbx/security_ban.h b/include/hybbx/security_ban.h
new file mode 100644
index 0000000..9e63757
--- /dev/null
+++ b/include/hybbx/security_ban.h
@@ -0,0 +1,80 @@
+#ifndef HYBBX_SECURITY_BAN_H
+#define HYBBX_SECURITY_BAN_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_config;
+
+typedef enum hybbx_ban_backend {
+ HYBBX_BAN_BACKEND_INTERNAL = 0,
+ HYBBX_BAN_BACKEND_LOG,
+ HYBBX_BAN_BACKEND_IPTABLES,
+ HYBBX_BAN_BACKEND_NFTABLES,
+ HYBBX_BAN_BACKEND_HOSTS
+} hybbx_ban_backend_t;
+
+void hybbx_security_ban_config_apply(const struct hybbx_config *config);
+void hybbx_security_ban_shutdown(void);
+
+/** Expire bans and prune stale failure/rate records (service loop). */
+void hybbx_security_ban_tick(void);
+
+/** Non-zero when @p ip is currently banned. */
+int hybbx_security_ban_is_banned(const char *ip);
+
+/**
+ * Normalize @p in into @p out (uppercase CALL/CALL-SSID or link_id).
+ * Returns non-zero when valid.
+ */
+int hybbx_security_callid_normalize(const char *in, char *out, size_t out_cap);
+
+/** Non-zero when @p callid is banned (AX.25 callsign or HBX link_id). */
+int hybbx_security_ban_callid_is_banned(const char *callid);
+
+/**
+ * Accept gate for HBX link_id / AX.25 source before circuit or RF uplink.
+ * Returns non-zero when allowed.
+ */
+int hybbx_security_ban_callid_accept(const char *callid);
+
+/**
+ * Accept gate: rate-limit check + ban check.
+ * Returns non-zero when the connection may proceed.
+ */
+int hybbx_security_ban_accept(const char *ip);
+
+/**
+ * Same as @ref hybbx_security_ban_accept using the peer address of @p fd.
+ * Allows the connection when the peer address cannot be resolved.
+ */
+int hybbx_security_ban_accept_fd(int fd);
+
+/** Record a failed login for @p transport (telnet, ssh, websocket, …). */
+void hybbx_security_ban_login_fail(const char *ip, const char *transport);
+
+/** Record a failed HBX circuit link authentication (IP). */
+void hybbx_security_ban_link_auth_fail(const char *ip);
+
+/** Record a failed HBX circuit link authentication (link_id / CALLID). */
+void hybbx_security_ban_link_auth_fail_callid(const char *callid);
+
+/**
+ * Record excessive abuse (chat/mail flood, register spam, …).
+ * Uses @c abuse_maxretry / @c abuse_findtime — not login @c maxretry.
+ * Normal traffic limits ([chat], [mail], [traffic]) never call this.
+ */
+void hybbx_security_ban_abuse_report(const char *ip, const char *category);
+
+/** Same as @ref hybbx_security_ban_abuse_report for @p callid. */
+void hybbx_security_ban_callid_abuse_report(const char *callid,
+ const char *category);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SECURITY_BAN_H */
diff --git a/include/hybbx/service.h b/include/hybbx/service.h
new file mode 100644
index 0000000..4730b8b
--- /dev/null
+++ b/include/hybbx/service.h
@@ -0,0 +1,174 @@
+#ifndef HYBBX_SERVICE_H
+#define HYBBX_SERVICE_H
+
+/**
+ * Centralized HyBBX daemon API (`hybbx` binary).
+ *
+ * Loads INI ([service], [storage], [auth], [transport.*], [circuit]), starts
+ * transport plugins and the HBX circuit hub. Edge link/repeater daemons attach
+ * to [circuit]; see share/hybbx.ini.example and docs/TOPOLOGY.md.
+ */
+
+#include "hybbx/config.h"
+#include "hybbx/plugin.h"
+#include "hybbx/auth.h"
+#include "hybbx/storage.h"
+#include "hybbx/texts.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default global service name (`[service] name` in INI). */
+#define HYBBX_DEFAULT_SERVICE_NAME "hyBBX"
+
+typedef struct hybbx_service {
+ const char *name;
+ void *userdata;
+} hybbx_service_t;
+
+/** Create the main HyBBX service instance. */
+hybbx_service_t *hybbx_service_create(const char *name);
+
+/** Destroy the service and shut down all active transports. */
+void hybbx_service_destroy(hybbx_service_t *service);
+
+/**
+ * Apply settings from an INI configuration (service name, enabled transports).
+ */
+hybbx_result_t hybbx_service_apply_config(hybbx_service_t *service,
+ const hybbx_config_t *config,
+ const char *config_path);
+
+/**
+ * Load a transport plugin by name (e.g. "telnet", "packet_radio").
+ * Built-in plugins are linked statically; dynamic loading comes later.
+ */
+hybbx_result_t hybbx_service_load_transport(hybbx_service_t *service,
+ const char *plugin_name);
+
+/** Start a loaded transport with transport-specific configuration. */
+hybbx_result_t hybbx_service_start_transport(hybbx_service_t *service,
+ const char *plugin_name,
+ const char *config);
+
+/** Stop a running transport. */
+hybbx_result_t hybbx_service_stop_transport(hybbx_service_t *service,
+ const char *plugin_name);
+
+/** Run the main event loop until hybbx_service_stop() is called. */
+hybbx_result_t hybbx_service_run(hybbx_service_t *service);
+
+/** Request the main loop to exit. */
+void hybbx_service_stop(hybbx_service_t *service);
+
+typedef enum hybbx_shutdown_mode {
+ HYBBX_SHUTDOWN_NONE = 0,
+ HYBBX_SHUTDOWN_STOP = 1,
+ HYBBX_SHUTDOWN_RESTART = 2
+} hybbx_shutdown_mode_t;
+
+/** Store the daemon binary path used for /restart (typically argv[0]). */
+void hybbx_service_set_launch_binary(hybbx_service_t *service, const char *argv0);
+
+/** Sysop /shutdown or /restart — stops the main loop; restart re-execs after exit. */
+void hybbx_service_request_shutdown(hybbx_service_t *service, int restart);
+
+/** Shutdown mode requested while the service is still running. */
+hybbx_shutdown_mode_t hybbx_service_shutdown_mode(const hybbx_service_t *service);
+
+/**
+ * Re-exec the daemon when @ref hybbx_service_shutdown_mode is
+ * @ref HYBBX_SHUTDOWN_RESTART. Call before @ref hybbx_service_destroy.
+ * Does not return on success.
+ */
+void hybbx_service_restart_exec(const hybbx_service_t *service);
+
+const char *hybbx_service_config_path(const hybbx_service_t *service);
+
+hybbx_storage_t *hybbx_service_get_storage(hybbx_service_t *service);
+const hybbx_auth_config_t *hybbx_service_get_auth(hybbx_service_t *service);
+const hybbx_texts_config_t *hybbx_service_get_texts(hybbx_service_t *service);
+
+/**
+ * Global input prompt shown to every connected user.
+ * Empty string when unset (default): no visible prompt before input.
+ */
+const char *hybbx_service_get_prompt(const hybbx_service_t *service);
+
+/** Global service name from configuration (default @ref HYBBX_DEFAULT_SERVICE_NAME). */
+const char *hybbx_service_get_name(const hybbx_service_t *service);
+
+/** Configured maximum simultaneous online sessions (default @ref HYBBX_DEFAULT_MAX_ONLINE). */
+unsigned hybbx_service_max_online(const hybbx_service_t *service);
+
+/**
+ * Non-zero when `[service] login_announce=yes`:
+ * broadcast "*** User login: user@plugin" and list /who as user@plugin.
+ */
+int hybbx_service_login_announce(const hybbx_service_t *service);
+
+/** Currently connected sessions (all transports). */
+unsigned hybbx_service_active_nodes(const hybbx_service_t *service);
+
+/** Guest auto-disconnect timeout in seconds (from INI minutes setting). */
+unsigned hybbx_service_guest_timeout_seconds(const hybbx_service_t *service);
+
+struct hybbx_user_record;
+
+/**
+ * Assign the lowest free ephemeral guest slot (Guest1 … Guest25).
+ * Guests are not written to user files.
+ */
+hybbx_result_t hybbx_service_guest_assign(hybbx_service_t *service,
+ const char *guest_prefix,
+ struct hybbx_user_record *out,
+ unsigned *slot_out);
+
+/** Release a guest slot when the session ends or leaves guest mode. */
+void hybbx_service_guest_release(hybbx_service_t *service, unsigned slot);
+
+/**
+ * Reserve one node slot for a new connection.
+ * @return HYBBX_ERR_BUSY when @ref hybbx_service_max_online is reached.
+ */
+hybbx_result_t hybbx_service_acquire_node(hybbx_service_t *service);
+
+/** Release a node slot (pair with @ref hybbx_service_acquire_node). */
+void hybbx_service_release_node(hybbx_service_t *service);
+
+struct hybbx_session;
+
+/** Track an active connection for chat broadcast and similar features. */
+hybbx_result_t hybbx_service_attach_session(hybbx_service_t *service,
+ struct hybbx_session *session);
+
+/** Remove a session from the active connection list. */
+void hybbx_service_detach_session(hybbx_service_t *service,
+ struct hybbx_session *session);
+
+typedef void (*hybbx_service_session_visit_fn)(struct hybbx_session *session,
+ void *userdata);
+
+/** Invoke @p fn for each attached session (snapshot; lock not held in @p fn). */
+void hybbx_service_visit_sessions(hybbx_service_t *service,
+ hybbx_service_session_visit_fn fn,
+ void *userdata);
+
+/**
+ * Return a logged-in registered session for @p user_id on another connection,
+ * or NULL when the account is not online elsewhere.
+ */
+struct hybbx_session *hybbx_service_find_registered_session(
+ hybbx_service_t *service,
+ uint64_t user_id,
+ struct hybbx_session *exclude);
+
+struct hybbx_circuit_hub;
+struct hybbx_circuit_hub *hybbx_service_circuit_hub(hybbx_service_t *service);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SERVICE_H */
diff --git a/include/hybbx/session.h b/include/hybbx/session.h
new file mode 100644
index 0000000..79f5343
--- /dev/null
+++ b/include/hybbx/session.h
@@ -0,0 +1,226 @@
+#ifndef HYBBX_SESSION_H
+#define HYBBX_SESSION_H
+
+#include "hybbx/plugin.h"
+#include "hybbx/storage.h"
+#include "hybbx/mail.h"
+#include "hybbx/proxymail.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+
+typedef enum hybbx_session_area {
+ HYBBX_AREA_MAIN = 1,
+ HYBBX_AREA_MAIL = 2,
+ HYBBX_AREA_CHAT = 3,
+ HYBBX_AREA_CONFERENCE = 4,
+ /** Inter-Main mail sub-area under mail (`/proxymail`, `/mail proxymail`). */
+ HYBBX_AREA_PROXYMAIL = 5,
+ /** Inter-Main chat sub-area under chat (`/proxychat`, `/chat proxychat`). */
+ HYBBX_AREA_PROXYCHAT = 6
+} hybbx_session_area_t;
+
+/** Maximum nested area depth (main + sub-areas). */
+#define HYBBX_AREA_STACK_MAX 8u
+
+/**
+ * Open a new connection session.
+ * When auto-login is enabled (default), assigns the next free Guest1 … Guest25
+ * slot in memory (not written to user files).
+ * When auto-login is disabled, the session stays at a login prompt for
+ * registered `/login` and `/register` only (no guest slots).
+ */
+hybbx_result_t hybbx_session_open(struct hybbx_service *service,
+ const hybbx_transport_plugin_t *transport,
+ void *transport_data,
+ hybbx_session_t **out);
+
+void hybbx_session_close(hybbx_session_t *session);
+
+hybbx_result_t hybbx_session_handle_input(hybbx_session_t *session,
+ const uint8_t *data, size_t len);
+
+const char *hybbx_session_username(const hybbx_session_t *session);
+/** User-facing name (nickname) for the logged-in account. */
+const char *hybbx_session_display_name(const hybbx_session_t *session);
+/**
+ * Format `username@plugin` (transport name) into @p out.
+ * Uses display name and session transport / record.
+ */
+hybbx_result_t hybbx_session_format_user_at_plugin(const hybbx_session_t *session,
+ char *out, size_t out_len);
+uint64_t hybbx_session_id(const hybbx_session_t *session);
+const hybbx_session_record_t *hybbx_session_record(const hybbx_session_t *session);
+/** Store the client peer address on the session record (for security.log). */
+hybbx_result_t hybbx_session_set_remote(hybbx_session_t *session,
+ const char *remote);
+hybbx_user_level_t hybbx_session_user_level(const hybbx_session_t *session);
+int hybbx_session_is_guest(const hybbx_session_t *session);
+/** Non-zero for telnet, SSH, or WebSocket — not plugin/link transports. */
+int hybbx_session_is_interactive_user(const hybbx_session_t *session);
+
+/**
+ * Non-zero when this Sysop is hidden from /who and /online:
+ * [monitor] invisible-sysop=yes, or /monitor on.
+ * Non-Sysop grant users are never hidden.
+ */
+int hybbx_session_hidden_from_who(const hybbx_session_t *session);
+
+int hybbx_session_monitor_active(const hybbx_session_t *session);
+void hybbx_session_set_monitor_active(hybbx_session_t *session, int on);
+/** Non-zero when the session has completed login (guest or registered). */
+int hybbx_session_logged_in(const hybbx_session_t *session);
+/** Non-zero when auto_login is off: banner + prompt, registered login only. */
+int hybbx_session_login_prompt(const hybbx_session_t *session);
+
+/** Write raw text to the connected client (no line ending, no prompt). */
+hybbx_result_t hybbx_session_write(hybbx_session_t *session, const char *text);
+
+/** Write a line and append CRLF. */
+hybbx_result_t hybbx_session_write_line(hybbx_session_t *session,
+ const char *text);
+
+/** One blank line before `/command` output (not chat/mail compose). */
+hybbx_result_t hybbx_session_command_gap(hybbx_session_t *session);
+
+/**
+ * Show the global input prompt when configured.
+ * Default (empty prompt): no output — users type on a blank line.
+ */
+hybbx_result_t hybbx_session_show_prompt(hybbx_session_t *session);
+
+/** Clear screen and discard the current input line; show prompt when set. */
+hybbx_result_t hybbx_session_clear_terminal(hybbx_session_t *session);
+
+/** Non-zero when typed characters are echoed back to the client. */
+int hybbx_session_input_echo(const hybbx_session_t *session);
+
+/** Enable or disable per-session input echo. */
+hybbx_result_t hybbx_session_set_input_echo(hybbx_session_t *session, int enabled);
+
+/** Current session area (main, mail, chat, …). */
+hybbx_session_area_t hybbx_session_area(const hybbx_session_t *session);
+
+/** Area name: main, mail, chat. */
+const char *hybbx_session_area_name(hybbx_session_area_t area);
+
+/** Parse area name (main, mail, chat). */
+hybbx_session_area_t hybbx_session_area_parse(const char *name);
+
+/** Go up one area level (alias: /back). Clears state for the area being left. */
+hybbx_result_t hybbx_session_leave_area(hybbx_session_t *session);
+
+/** Return to main from any depth (alias: /menu). Clears all sub-area state. */
+hybbx_result_t hybbx_session_go_main(hybbx_session_t *session);
+
+/** Enter an area (pushes onto the area stack). */
+hybbx_result_t hybbx_session_enter_area(hybbx_session_t *session,
+ hybbx_session_area_t area);
+
+/** Switch the session to a registered (non-guest) account after login. */
+hybbx_result_t hybbx_session_switch_user(hybbx_session_t *session,
+ const hybbx_user_record_t *user);
+
+/**
+ * Periodic session maintenance (guest time limit, etc.).
+ * @return HYBBX_SESSION_END when the session should be closed.
+ */
+hybbx_result_t hybbx_session_tick(hybbx_session_t *session);
+
+/** Join a chat channel (enters chat area). @p channel_index is 1-based. */
+hybbx_result_t hybbx_session_join_chat_channel(hybbx_session_t *session,
+ unsigned channel_index);
+
+/** Current chat channel (1-based), or 0 when not in chat. */
+unsigned hybbx_session_chat_channel(const hybbx_session_t *session);
+
+struct hybbx_service *hybbx_session_service(hybbx_session_t *session);
+
+/** Join a private conference (conference area). Internal — use hybbx_conference_start. */
+hybbx_result_t hybbx_session_join_conference(hybbx_session_t *session,
+ const char *topic,
+ const char *partner_username);
+
+void hybbx_session_clear_conference(hybbx_session_t *session);
+
+int hybbx_session_conference_may_invite(hybbx_session_t *session,
+ const char *target);
+void hybbx_session_conference_invite_sent(hybbx_session_t *session,
+ const char *target);
+void hybbx_session_set_conference_invite(hybbx_session_t *session,
+ const char *from_username,
+ const char *topic);
+/** Optional deadline (0 = none). Used by monitor-initiated invites. */
+void hybbx_session_set_conference_invite_deadline(hybbx_session_t *session,
+ time_t deadline);
+time_t hybbx_session_conference_invite_deadline(const hybbx_session_t *session);
+void hybbx_session_clear_conference_invite(hybbx_session_t *session);
+const char *hybbx_session_conference_invite_from(const hybbx_session_t *session);
+const char *hybbx_session_conference_invite_topic(const hybbx_session_t *session);
+const char *hybbx_session_conference_partner(const hybbx_session_t *session);
+
+/** Enter local mail area (does not enter proxymail). */
+hybbx_result_t hybbx_session_enter_mail(hybbx_session_t *session);
+
+/** Enter proxymail sub-area (pushes mail when needed). */
+hybbx_result_t hybbx_session_enter_proxymail(hybbx_session_t *session);
+
+/** Enter local chat area without joining a channel. */
+hybbx_result_t hybbx_session_enter_chat(hybbx_session_t *session);
+
+/** Enter proxychat sub-area (pushes chat when needed). */
+hybbx_result_t hybbx_session_enter_proxychat(hybbx_session_t *session);
+
+/** Non-zero when composing an outbound mail message. */
+int hybbx_session_mail_composing(const hybbx_session_t *session);
+
+/** Start mail compose to @p to_user with @p subject; enters mail area. */
+hybbx_result_t hybbx_session_mail_compose_start(hybbx_session_t *session,
+ const char *to_user,
+ const char *subject);
+
+/** Cancel an in-progress mail compose. */
+void hybbx_session_mail_compose_cancel(hybbx_session_t *session);
+
+/** Body accumulated so far during compose (NUL-terminated). */
+const char *hybbx_session_mail_compose_body(const hybbx_session_t *session);
+
+const char *hybbx_session_mail_compose_to(const hybbx_session_t *session);
+const char *hybbx_session_mail_compose_subject(const hybbx_session_t *session);
+
+/** Non-zero when composing proxymail (`user@remote-main` recipient). */
+int hybbx_session_proxymail_composing(const hybbx_session_t *session);
+
+hybbx_result_t hybbx_session_proxymail_compose_start(hybbx_session_t *session,
+ const char *to_address,
+ const char *subject);
+
+void hybbx_session_proxymail_compose_cancel(hybbx_session_t *session);
+
+const char *hybbx_session_proxymail_compose_body(const hybbx_session_t *session);
+const char *hybbx_session_proxymail_compose_to(const hybbx_session_t *session);
+const char *hybbx_session_proxymail_compose_subject(
+ const hybbx_session_t *session);
+
+/** Wall-clock time when this session connected (for bandwidth policy ordering). */
+time_t hybbx_session_connected_at(const hybbx_session_t *session);
+
+/** Non-zero when the session is frozen by circuit bandwidth limits. */
+int hybbx_session_bandwidth_paused(const hybbx_session_t *session);
+
+void hybbx_session_set_bandwidth_paused(hybbx_session_t *session, int paused);
+
+/**
+ * End the session after sending @ref HYBBX_BANDWIDTH_DISCONNECT_MSG.
+ * Newest-first under low-bandwidth link pressure.
+ */
+void hybbx_session_disconnect_bandwidth(hybbx_session_t *session);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SESSION_H */
diff --git a/include/hybbx/socket.h b/include/hybbx/socket.h
new file mode 100644
index 0000000..1baa639
--- /dev/null
+++ b/include/hybbx/socket.h
@@ -0,0 +1,35 @@
+#ifndef HYBBX_SOCKET_H
+#define HYBBX_SOCKET_H
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include <sys/socket.h>
+
+#ifndef MSG_NOSIGNAL
+#define MSG_NOSIGNAL 0
+#endif
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Suppress SIGPIPE on send() (SO_NOSIGPIPE on BSD/MacOS; noop on Linux). */
+void hybbx_socket_nosigpipe(int fd);
+
+/**
+ * Format the peer IP address of a connected socket into @p buf.
+ * IPv4 dotted-quad or IPv6 colon form (no brackets).
+ */
+hybbx_result_t hybbx_socket_peer_name(int fd, char *buf, size_t buf_len);
+
+/** Log bind(2) failure; hints when @c EADDRINUSE (HyBBX already running). */
+void hybbx_socket_log_bind_failure(const char *component, const char *addr,
+ unsigned port);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SOCKET_H */
diff --git a/include/hybbx/ssh.h b/include/hybbx/ssh.h
new file mode 100644
index 0000000..2555198
--- /dev/null
+++ b/include/hybbx/ssh.h
@@ -0,0 +1,57 @@
+#ifndef HYBBX_SSH_H
+#define HYBBX_SSH_H
+
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default HyBBX SSH TCP port (non-privileged; parallels telnet 2323). */
+#define HYBBX_SSH_DEFAULT_PORT 3232u
+
+#define HYBBX_SSH_BIND_V4_MAX 64
+#define HYBBX_SSH_BIND_V6_MAX 64
+#define HYBBX_SSH_HOSTKEY_DIR_MAX HYBBX_PATH_MAX
+
+#define HYBBX_SSH_DEFAULT_BIND_V4 "0.0.0.0"
+#define HYBBX_SSH_DEFAULT_BIND_V6 "::"
+#define HYBBX_SSH_DEFAULT_HOSTKEY_DIR "keys"
+
+#define HYBBX_SSH_HOSTKEY_ED25519 "ssh_host_ed25519_key"
+
+/** Regenerate Ed25519 host keys when older than this (days). */
+#define HYBBX_SSH_HOSTKEY_VALID_DAYS 1825u
+
+typedef struct hybbx_ssh_config {
+ char bind_v4[HYBBX_SSH_BIND_V4_MAX];
+ char bind_v6[HYBBX_SSH_BIND_V6_MAX];
+ char hostkey_dir[HYBBX_SSH_HOSTKEY_DIR_MAX];
+ unsigned int port;
+ int ipv4;
+ int ipv6;
+} hybbx_ssh_config_t;
+
+void hybbx_ssh_config_defaults(hybbx_ssh_config_t *config);
+
+/**
+ * Parse a semicolon-separated key=value transport config string
+ * (from transport.ssh INI section).
+ */
+hybbx_result_t hybbx_ssh_config_parse(const char *config,
+ hybbx_ssh_config_t *out);
+
+/**
+ * Ensure Ed25519 host keys exist under @p keys_dir (resolved path).
+ * Writes the private key path into @p hostkey_path.
+ */
+hybbx_result_t hybbx_ssh_keys_ensure(const char *keys_dir,
+ char *hostkey_path,
+ size_t hostkey_path_len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SSH_H */
diff --git a/include/hybbx/storage.h b/include/hybbx/storage.h
new file mode 100644
index 0000000..f4b4802
--- /dev/null
+++ b/include/hybbx/storage.h
@@ -0,0 +1,178 @@
+#ifndef HYBBX_STORAGE_H
+#define HYBBX_STORAGE_H
+
+#include "hybbx/auth.h"
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+#include <time.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_storage_backend_kind {
+ HYBBX_STORAGE_FLATFILE = 1,
+ HYBBX_STORAGE_SQLITE = 2,
+ HYBBX_STORAGE_MYSQL = 3,
+ HYBBX_STORAGE_MARIADB = 4
+} hybbx_storage_backend_kind_t;
+
+#define HYBBX_USER_NAME_MAX 64
+#define HYBBX_USER_NICKNAME_MAX 64
+#define HYBBX_USER_FULL_NAME_MAX 128
+#define HYBBX_USER_COUNTRY_MAX 64
+#define HYBBX_USER_LOCATION_MAX 128
+#define HYBBX_USER_EMAIL_MAX 128
+#define HYBBX_USER_PASSWORD_MAX HYBBX_PASSWORD_STORED_MAX
+#define HYBBX_TRANSPORT_NAME_MAX 32
+/** Peer address string (IPv4 or bracketed IPv6). */
+#define HYBBX_REMOTE_ADDR_MAX 64
+
+/** Maximum registered user records per INI user-file shard. */
+#define HYBBX_USERS_PER_FILE 50u
+
+/** INI section prefix for one user: [user.<id>]. */
+#define HYBBX_USER_INI_SECTION_PREFIX "user."
+
+typedef struct hybbx_user_registration {
+ char username[HYBBX_USER_NAME_MAX];
+ char nickname[HYBBX_USER_NICKNAME_MAX];
+ 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];
+} hybbx_user_registration_t;
+
+/** Validate profile fields only (full name, country, location, email). */
+int hybbx_user_profile_valid(const hybbx_user_registration_t *reg);
+
+/** Validate registration profile fields (username + full name, country, location, email). */
+int hybbx_registration_valid(const hybbx_user_registration_t *reg,
+ const char *guest_prefix);
+
+typedef struct hybbx_user_record {
+ uint64_t id;
+ char username[HYBBX_USER_NAME_MAX];
+ char nickname[HYBBX_USER_NICKNAME_MAX];
+ hybbx_user_level_t level;
+ int active;
+ time_t created_at;
+ 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];
+ time_t last_login_at;
+} hybbx_user_record_t;
+
+typedef struct hybbx_session_record {
+ uint64_t session_id;
+ uint64_t user_id;
+ char username[HYBBX_USER_NAME_MAX];
+ char transport[HYBBX_TRANSPORT_NAME_MAX];
+ char remote[HYBBX_REMOTE_ADDR_MAX];
+ time_t connected_at;
+ time_t disconnected_at;
+ int active;
+} hybbx_session_record_t;
+
+typedef struct hybbx_storage hybbx_storage_t;
+
+/** Paths and backup settings for SQLite (`[storage]`). */
+typedef struct hybbx_storage_sql_config {
+ char user_db[HYBBX_PATH_MAX];
+ char mail_db[HYBBX_PATH_MAX];
+ unsigned backup_interval_sec;
+ char backup_path[HYBBX_PATH_MAX];
+} hybbx_storage_sql_config_t;
+
+typedef struct hybbx_storage_options {
+ hybbx_storage_backend_kind_t backend;
+ const char *path;
+ const char *guest_prefix;
+ const hybbx_storage_sql_config_t *sql_cfg;
+} hybbx_storage_options_t;
+
+struct hybbx_config;
+
+void hybbx_storage_sql_config_defaults(hybbx_storage_sql_config_t *cfg);
+
+/** Resolve `user_db` / `mail_db` under @p storage_path from INI `[storage]`. */
+void hybbx_storage_sql_config_apply(hybbx_storage_sql_config_t *cfg,
+ const struct hybbx_config *config,
+ const char *storage_path);
+
+const hybbx_storage_sql_config_t *hybbx_storage_sql_config(
+ const hybbx_storage_t *storage);
+
+/** Copy SQLite DB files to fallback paths (no-op when not SQLite). */
+void hybbx_storage_backup_tick(hybbx_storage_t *storage);
+
+hybbx_storage_t *hybbx_storage_open(const hybbx_storage_options_t *options);
+void hybbx_storage_close(hybbx_storage_t *storage);
+
+hybbx_storage_backend_kind_t hybbx_storage_backend(const hybbx_storage_t *storage);
+
+/** Root data directory path, or NULL when unset. */
+const char *hybbx_storage_root_path(const hybbx_storage_t *storage);
+
+/**
+ * Register a new user account (level user, inactive until Sysop/Admin activates).
+ */
+hybbx_result_t hybbx_storage_register_user(hybbx_storage_t *storage,
+ const hybbx_user_registration_t *reg,
+ hybbx_user_record_t *out);
+
+/** Look up a user by login name (case-insensitive; stored lowercase). */
+hybbx_result_t hybbx_storage_find_user(hybbx_storage_t *storage,
+ const char *username,
+ hybbx_user_record_t *out);
+
+/** Resolve by login name or nickname (case-insensitive). */
+hybbx_result_t hybbx_storage_resolve_user(hybbx_storage_t *storage,
+ const char *name,
+ hybbx_user_record_t *out);
+
+/** Count existing accounts at a given level (e.g. enforce one Sysop). */
+hybbx_result_t hybbx_storage_count_level(hybbx_storage_t *storage,
+ hybbx_user_level_t level,
+ size_t *count);
+
+/**
+ * Iterate all user records. @p fn returns HYBBX_OK to continue; any other
+ * result stops iteration and is returned from this function.
+ */
+typedef hybbx_result_t (*hybbx_storage_user_fn)(const hybbx_user_record_t *user,
+ void *ctx);
+
+hybbx_result_t hybbx_storage_foreach_user(hybbx_storage_t *storage,
+ hybbx_storage_user_fn fn,
+ void *ctx);
+
+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);
+
+hybbx_result_t hybbx_storage_session_end(hybbx_storage_t *storage,
+ uint64_t session_id);
+
+/**
+ * Replace an existing user record (matched by @p user->id).
+ */
+hybbx_result_t hybbx_storage_update_user(hybbx_storage_t *storage,
+ const hybbx_user_record_t *user);
+
+/** Remove a user record from storage (matched by @p user_id). */
+hybbx_result_t hybbx_storage_delete_user(hybbx_storage_t *storage,
+ uint64_t user_id);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_STORAGE_H */
diff --git a/include/hybbx/telnet.h b/include/hybbx/telnet.h
new file mode 100644
index 0000000..956f1f5
--- /dev/null
+++ b/include/hybbx/telnet.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_TELNET_H
+#define HYBBX_TELNET_H
+
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default HyBBX telnet TCP port (BBX / terminal convention). */
+#define HYBBX_TELNET_DEFAULT_PORT 2323u
+
+#define HYBBX_TELNET_BIND_V4_MAX 64
+#define HYBBX_TELNET_BIND_V6_MAX 64
+
+#define HYBBX_TELNET_DEFAULT_BIND_V4 "0.0.0.0"
+#define HYBBX_TELNET_DEFAULT_BIND_V6 "::"
+
+typedef struct hybbx_telnet_config {
+ char bind_v4[HYBBX_TELNET_BIND_V4_MAX];
+ char bind_v6[HYBBX_TELNET_BIND_V6_MAX];
+ unsigned int port;
+ int ipv4;
+ int ipv6;
+} hybbx_telnet_config_t;
+
+void hybbx_telnet_config_defaults(hybbx_telnet_config_t *config);
+
+/**
+ * Parse a semicolon-separated key=value transport config string
+ * (from transport.telnet INI section).
+ */
+hybbx_result_t hybbx_telnet_config_parse(const char *config,
+ hybbx_telnet_config_t *out);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TELNET_H */
diff --git a/include/hybbx/terminal.h b/include/hybbx/terminal.h
new file mode 100644
index 0000000..fec0871
--- /dev/null
+++ b/include/hybbx/terminal.h
@@ -0,0 +1,39 @@
+#ifndef HYBBX_TERMINAL_H
+#define HYBBX_TERMINAL_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_session;
+
+/**
+ * HyBBX uses a single terminal palette: light gray text on a black background.
+ * ANSI SGR: foreground 37, background 40.
+ */
+#define HYBBX_TERM_SGR_LIGHTGRAY_ON_BLACK "\033[37;40m"
+
+/** Clear screen and move cursor home (requires `traffic.ansi = yes`). */
+#define HYBBX_TERM_CLEAR_SCREEN "\033[2J\033[H"
+
+/** Apply the HyBBX terminal colors on a session (sent once at connect). */
+hybbx_result_t hybbx_term_init_session(struct hybbx_session *session);
+
+/** Clear the client screen (ANSI or plain newlines). */
+hybbx_result_t hybbx_term_clear_screen(struct hybbx_session *session);
+
+/**
+ * Copy @p src to @p dst without ANSI escape sequences (colors, cursor, etc.).
+ * @return length of written string excluding the terminator.
+ */
+size_t hybbx_term_copy_plain(const char *src, char *dst, size_t dst_size);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TERMINAL_H */
diff --git a/include/hybbx/texts.h b/include/hybbx/texts.h
new file mode 100644
index 0000000..ef1b9e3
--- /dev/null
+++ b/include/hybbx/texts.h
@@ -0,0 +1,84 @@
+#ifndef HYBBX_TEXTS_H
+#define HYBBX_TEXTS_H
+
+#include "hybbx/types.h"
+#include "hybbx/limits.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_session;
+
+#define HYBBX_DEFAULT_TEXTS_PATH HYBBX_DIR_TEXT
+
+#define HYBBX_TEXT_BANNER "banner.txt"
+#define HYBBX_TEXT_MOTD "motd.txt"
+#define HYBBX_TEXT_NEWS "news.txt"
+#define HYBBX_TEXT_RULES "rules.txt"
+#define HYBBX_TEXT_VERSION "version.txt"
+
+/** Substituted in banner.txt / version.txt — HyBBX version number (e.g. 2.4.0). */
+#define HYBBX_BANNER_TOKEN_VERSION "@version@"
+/** Substituted in banner.txt line 2. */
+#define HYBBX_BANNER_TOKEN_SERVICE "@service@"
+/** Substituted in motd.txt (current session nickname). */
+#define HYBBX_TEXT_TOKEN_USERNAME "@username@"
+/** Substituted in version.txt — host operating system name (e.g. Linux). */
+#define HYBBX_TEXT_TOKEN_OS "@os@"
+/** Substituted in text files — local time per `[time]` (default HH:MM:SS). */
+#define HYBBX_TEXT_TOKEN_TIME "%time%"
+/** Substituted in text files — local date per `[time] date=` (default YYYY/MM/DD). */
+#define HYBBX_TEXT_TOKEN_DATE "%date%"
+
+#define HYBBX_TEXTS_PATH_MAX 512
+
+typedef struct hybbx_texts_config {
+ char path[HYBBX_TEXTS_PATH_MAX];
+} hybbx_texts_config_t;
+
+void hybbx_texts_config_defaults(hybbx_texts_config_t *texts);
+
+/**
+ * Build full path to a text file under the configured texts directory.
+ * Returns HYBBX_OK on success.
+ */
+hybbx_result_t hybbx_texts_resolve(const hybbx_texts_config_t *texts,
+ const char *filename,
+ char *out, size_t out_len);
+
+/**
+ * Send a text file line-by-line to the session (connection output).
+ * Expands @version@, @service@, @username@, @os@, %time%, and %date%.
+ * Missing files are skipped silently.
+ */
+hybbx_result_t hybbx_texts_send_file(const hybbx_texts_config_t *texts,
+ struct hybbx_session *session,
+ const char *filename);
+
+/**
+ * Send banner.txt with @version@ and @service@ tokens expanded.
+ */
+hybbx_result_t hybbx_texts_send_banner(const hybbx_texts_config_t *texts,
+ struct hybbx_session *session,
+ const char *version,
+ const char *service_name);
+
+/**
+ * Send motd.txt with @username@ expanded from @p session.
+ */
+hybbx_result_t hybbx_texts_send_motd(const hybbx_texts_config_t *texts,
+ struct hybbx_session *session);
+
+/**
+ * Send version.txt with @version@ and @os@ expanded from this host.
+ * Falls back to the built-in two-line version block if the file is missing.
+ */
+hybbx_result_t hybbx_texts_send_version(const hybbx_texts_config_t *texts,
+ struct hybbx_session *session);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TEXTS_H */
diff --git a/include/hybbx/tnc.h b/include/hybbx/tnc.h
new file mode 100644
index 0000000..9655aa8
--- /dev/null
+++ b/include/hybbx/tnc.h
@@ -0,0 +1,240 @@
+#ifndef HYBBX_TNC_H
+#define HYBBX_TNC_H
+
+/**
+ * Unified TNC / AX.25 link driver for HyBBX packet radio.
+ *
+ * Supported hardware profiles (see docs/TNCS.md):
+ * - Landolt TNC2C, classic TNC-2 / PK-TNC2 (TheFirmware)
+ * - AEA PK-232, MFJ-1278, Kantronics KPC
+ * - Generic TNC2-class controllers
+ *
+ * Not in packet_radio: BayCom/based SER12 / PC-COM TCM3105 — use MAX25
+ * bcpr (`max25e0`) then optional HyBBX baycom transport or [max25] attach.
+ * UN1TME = TNCs only.
+ *
+ * Host protocols: KISS, TNC2 host mode, 6PACK (DF6BU).
+ */
+
+#include "hybbx/ax25.h"
+#include "hybbx/packet_radio.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_TNC2C_DEFAULT_HOST_BAUD HYBBX_PACKET_RADIO_DEFAULT_BAUD
+#define HYBBX_TNC2C_DEFAULT_RADIO_BAUD 1200u
+#define HYBBX_AFSK_RADIO_BAUD 1200u
+#define HYBBX_G3RUH_RADIO_BAUD 9600u
+#define HYBBX_TNC2C_CLOCK_4_9_MHZ 4.9f
+#define HYBBX_TNC2C_CLOCK_9_8_MHZ 9.8f
+#define HYBBX_TNC2C_CLOCK_10_MHZ 10.0f
+#define HYBBX_TNC2C_HOST_BAUD_MIN 300u
+#define HYBBX_TNC2C_HOST_BAUD_MAX 38400u
+#define HYBBX_TNC2C_DATA_BITS 7u
+
+typedef enum hybbx_tnc_serial_parity {
+ HYBBX_TNC_SERIAL_PARITY_UNSET = 0,
+ HYBBX_TNC_SERIAL_PARITY_NONE = 1,
+ HYBBX_TNC_SERIAL_PARITY_EVEN = 2,
+ HYBBX_TNC_SERIAL_PARITY_ODD = 3
+} hybbx_tnc_serial_parity_t;
+
+#define HYBBX_PACKET_RADIO_VIA_MAX 8
+
+typedef enum hybbx_tnc2c_modem {
+ HYBBX_TNC2C_MODEM_TCM3105 = 1,
+ HYBBX_TNC2C_MODEM_9600 = 2, /**< G3RUH-compatible 9600 baud FSK */
+ HYBBX_TNC2C_MODEM_19200 = 3
+} hybbx_tnc2c_modem_t;
+
+/**
+ * Over-the-air modulation standard.
+ * AFSK (Bell 202 / TCM3105 @ 1200) is the classic CB packet default.
+ * G3RUH FSK @ 9600 baud for higher throughput (requires compatible modem/TNC).
+ */
+typedef enum hybbx_packet_radio_modulation {
+ HYBBX_PACKET_RADIO_MOD_UNSET = 0,
+ HYBBX_PACKET_RADIO_MOD_AFSK = 1,
+ HYBBX_PACKET_RADIO_MOD_G3RUH_FSK = 2
+} hybbx_packet_radio_modulation_t;
+
+#define HYBBX_PACKET_RADIO_MOD_DEFAULT HYBBX_PACKET_RADIO_MOD_AFSK
+
+typedef enum hybbx_packet_radio_protocol {
+ HYBBX_PACKET_RADIO_PROTO_KISS = 1,
+ HYBBX_PACKET_RADIO_PROTO_HOSTMODE = 2,
+ HYBBX_PACKET_RADIO_PROTO_SIXPACK = 3
+} hybbx_packet_radio_protocol_t;
+
+typedef enum hybbx_packet_radio_tnc {
+ HYBBX_PACKET_RADIO_TNC_TNC2C = 1,
+ HYBBX_PACKET_RADIO_TNC_BAYCOM = 2,
+ HYBBX_PACKET_RADIO_TNC_PCCOM = 3,
+ HYBBX_PACKET_RADIO_TNC_GENERIC = 4,
+ HYBBX_PACKET_RADIO_TNC_TNC2 = 5,
+ HYBBX_PACKET_RADIO_TNC_PK232 = 6,
+ HYBBX_PACKET_RADIO_TNC_MFJ1278 = 7,
+ HYBBX_PACKET_RADIO_TNC_KANTRONICS = 8
+} hybbx_packet_radio_tnc_t;
+
+/** How HyBBX enters KISS mode on the host serial link. */
+typedef enum hybbx_kiss_entry {
+ HYBBX_KISS_ENTRY_UNSET = 0,
+ HYBBX_KISS_ENTRY_NONE = 1,
+ HYBBX_KISS_ENTRY_KISS_ON = 2,
+ HYBBX_KISS_ENTRY_ESC_AT_K = 3,
+ HYBBX_KISS_ENTRY_AUTO = 4
+} hybbx_kiss_entry_t;
+
+/** How HyBBX leaves KISS mode on shutdown. */
+typedef enum hybbx_kiss_exit {
+ HYBBX_KISS_EXIT_UNSET = 0,
+ HYBBX_KISS_EXIT_NONE = 1,
+ HYBBX_KISS_EXIT_KISS_OFF = 2,
+ HYBBX_KISS_EXIT_KISS_FRAME = 3,
+ HYBBX_KISS_EXIT_AUTO = 4
+} hybbx_kiss_exit_t;
+
+/**
+ * RF service band — drives safe duplex defaults.
+ * CB is half-duplex only (shared channel). Amateur may use full-duplex.
+ */
+typedef enum hybbx_packet_radio_band {
+ HYBBX_PACKET_RADIO_BAND_UNSET = 0,
+ HYBBX_PACKET_RADIO_BAND_CB = 1,
+ HYBBX_PACKET_RADIO_BAND_AMATEUR = 2
+} hybbx_packet_radio_band_t;
+
+/**
+ * Radio link duplex mode (distinct from host serial full-duplex).
+ * half = one direction at a time on the RF channel (CB / classic packet).
+ * full = TNC may key TX while receiving (amateur split / full-dup setups).
+ */
+typedef enum hybbx_packet_radio_duplex {
+ HYBBX_PACKET_RADIO_DUPLEX_UNSET = 0,
+ HYBBX_PACKET_RADIO_DUPLEX_HALF = 1,
+ HYBBX_PACKET_RADIO_DUPLEX_FULL = 2
+} hybbx_packet_radio_duplex_t;
+
+#define HYBBX_PACKET_RADIO_DUPLEX_DEFAULT HYBBX_PACKET_RADIO_DUPLEX_HALF
+
+typedef struct hybbx_tnc_params {
+ float clock_mhz;
+ hybbx_tnc2c_modem_t modem;
+ hybbx_packet_radio_modulation_t modulation;
+ unsigned int radio_baud;
+ unsigned int kiss_port;
+ unsigned int txdelay;
+ unsigned int persist;
+ unsigned int slot;
+ unsigned int txtail;
+ hybbx_packet_radio_band_t band;
+ hybbx_packet_radio_duplex_t duplex;
+ int fullduplex;
+ hybbx_kiss_entry_t kiss_entry;
+ hybbx_kiss_exit_t kiss_exit;
+ int host_connect_on_start;
+ unsigned int data_bits;
+ hybbx_tnc_serial_parity_t serial_parity;
+ unsigned int stop_bits;
+ int assert_modem_lines;
+} hybbx_tnc_params_t;
+
+typedef struct hybbx_packet_radio_config {
+ hybbx_packet_radio_device_type_t device_type;
+ char *device;
+ unsigned int baud;
+ hybbx_packet_radio_tnc_t tnc;
+ hybbx_packet_radio_protocol_t protocol;
+ hybbx_tnc_params_t params;
+ char *mycall;
+ char *dest_call;
+ char *via[HYBBX_PACKET_RADIO_VIA_MAX];
+ unsigned via_count;
+ char *circuit_host;
+ unsigned circuit_port;
+ char *link_id;
+ char *link_password;
+ char *link_role;
+ char *frequency_mhz;
+} hybbx_packet_radio_config_t;
+
+typedef struct hybbx_tnc hybbx_tnc_t;
+
+typedef void (*hybbx_tnc_ui_cb)(const uint8_t *payload, size_t len,
+ const hybbx_ax25_path_t *path,
+ void *userdata);
+
+/** Raw AX.25 frame from KISS/6PACK (before UI parsing). */
+typedef void (*hybbx_tnc_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+hybbx_result_t hybbx_tnc_open(hybbx_tnc_t **out,
+ const hybbx_packet_radio_config_t *config,
+ hybbx_tnc_frame_cb frame_cb,
+ hybbx_tnc_ui_cb ui_cb,
+ void *rx_userdata);
+
+void hybbx_tnc_close(hybbx_tnc_t *tnc);
+
+hybbx_result_t hybbx_tnc_poll(hybbx_tnc_t *tnc);
+
+/** POSIX serial fd for poll/select; returns -1 when unavailable. */
+int hybbx_tnc_serial_fd(const hybbx_tnc_t *tnc);
+
+/** Send a raw AX.25 frame (already built) to the TNC. */
+hybbx_result_t hybbx_tnc_send_frame(hybbx_tnc_t *tnc,
+ const uint8_t *frame, size_t len);
+
+/** Build and send an AX.25 UI frame from text payload. */
+hybbx_result_t hybbx_tnc_send_ui(hybbx_tnc_t *tnc,
+ const uint8_t *payload, size_t len);
+
+/** Send bytes during host-mode connected converse. */
+hybbx_result_t hybbx_tnc_send_converse(hybbx_tnc_t *tnc,
+ const char *data, size_t len);
+
+int hybbx_tnc_host_connected(const hybbx_tnc_t *tnc);
+
+hybbx_packet_radio_duplex_t hybbx_tnc_duplex(const hybbx_tnc_t *tnc);
+hybbx_packet_radio_band_t hybbx_tnc_band(const hybbx_tnc_t *tnc);
+int hybbx_tnc_duplex_is_full(const hybbx_tnc_t *tnc);
+
+const char *hybbx_packet_radio_duplex_name(hybbx_packet_radio_duplex_t duplex);
+const char *hybbx_packet_radio_band_name(hybbx_packet_radio_band_t band);
+
+const char *hybbx_packet_radio_modulation_name(
+ hybbx_packet_radio_modulation_t modulation);
+
+int hybbx_packet_radio_modulation_is_g3ruh(
+ hybbx_packet_radio_modulation_t modulation);
+
+/** Apply g3ruh_fsk / modulation keys to modem, radio_baud, and timing. */
+hybbx_result_t hybbx_tnc_finalize_modulation(hybbx_packet_radio_config_t *cfg);
+
+/** Apply band/duplex rules and sync @p fullduplex for the TNC firmware. */
+hybbx_result_t hybbx_tnc_finalize_radio_duplex(hybbx_packet_radio_config_t *cfg);
+
+/** Apply profile-specific defaults (TNC2C, generic, …). */
+hybbx_result_t hybbx_tnc_profile_apply_defaults(hybbx_packet_radio_config_t *cfg);
+
+/** Apply host serial line defaults (TNC2C: 7E1 + RTS/DTR). */
+hybbx_result_t hybbx_tnc_finalize_serial_line(hybbx_packet_radio_config_t *cfg);
+
+/** Apply kiss_entry / kiss_exit defaults from TNC profile when unset. */
+hybbx_result_t hybbx_tnc_finalize_kiss_entry(hybbx_packet_radio_config_t *cfg);
+
+/** Apply CSMA defaults (Persist/SlotTime) from band when unset in INI. */
+hybbx_result_t hybbx_tnc_finalize_csma(hybbx_packet_radio_config_t *cfg);
+
+const char *hybbx_packet_radio_tnc_name(hybbx_packet_radio_tnc_t tnc);
+const char *hybbx_kiss_entry_name(hybbx_kiss_entry_t entry);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TNC_H */
diff --git a/include/hybbx/tnc2c.h b/include/hybbx/tnc2c.h
new file mode 100644
index 0000000..42f5277
--- /dev/null
+++ b/include/hybbx/tnc2c.h
@@ -0,0 +1,27 @@
+#ifndef HYBBX_TNC2C_H
+#define HYBBX_TNC2C_H
+
+/**
+ * Backward-compatible header for the Landolt TNC2C driver.
+ * New code should include hybbx/tnc.h.
+ */
+
+#include "hybbx/tnc.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef hybbx_tnc_t hybbx_tnc2c_t;
+typedef hybbx_tnc_params_t hybbx_tnc2c_params_t;
+
+/* hybbx_tnc2c_open removed — use hybbx_tnc_open() with RX callback. */
+#define hybbx_tnc2c_close hybbx_tnc_close
+#define hybbx_tnc2c_poll hybbx_tnc_poll
+#define hybbx_tnc2c_send_frame hybbx_tnc_send_frame
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TNC2C_H */
diff --git a/include/hybbx/traffic.h b/include/hybbx/traffic.h
new file mode 100644
index 0000000..8d06cf3
--- /dev/null
+++ b/include/hybbx/traffic.h
@@ -0,0 +1,59 @@
+#ifndef HYBBX_TRAFFIC_H
+#define HYBBX_TRAFFIC_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_config;
+struct hybbx_session;
+
+/** Host link speed HyBBX is optimized for (8N1, ~1 byte per 10 bit times). */
+#define HYBBX_BAUD2400 2400u
+
+/** Default terminal width (columns). */
+#define HYBBX_LINE_WIDTH 80u
+
+/** Maximum configurable `line_width` in INI. */
+#define HYBBX_LINE_WIDTH_MAX 132u
+
+/** Maximum user input line (commands / chat). */
+#define HYBBX_LINE_MAX 80u
+
+/** Typical AX.25 UI payload ceiling used for outbound chunk sizing. */
+#define HYBBX_AX25_PAYLOAD_MAX 256u
+
+typedef struct hybbx_traffic_config {
+ unsigned baud;
+ unsigned line_width;
+ int pace_output;
+ int ansi;
+ int input_echo;
+} hybbx_traffic_config_t;
+
+void hybbx_traffic_config_defaults(hybbx_traffic_config_t *cfg);
+void hybbx_traffic_config_apply(const struct hybbx_config *config);
+const hybbx_traffic_config_t *hybbx_traffic_config_get(void);
+
+/**
+ * Microseconds to wait after one 8N1 byte at @p baud (0 when pacing is off).
+ */
+unsigned hybbx_traffic_byte_delay_us(unsigned baud);
+
+/**
+ * Emit @p data to @p session with plain-ASCII policy, column wrap, and pacing.
+ * @p out_col is updated (wrap position on the current output line).
+ */
+hybbx_result_t hybbx_traffic_emit(struct hybbx_session *session,
+ unsigned *out_col,
+ const char *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TRAFFIC_H */
diff --git a/include/hybbx/types.h b/include/hybbx/types.h
new file mode 100644
index 0000000..775a711
--- /dev/null
+++ b/include/hybbx/types.h
@@ -0,0 +1,42 @@
+#ifndef HYBBX_TYPES_H
+#define HYBBX_TYPES_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_result {
+ HYBBX_OK = 0,
+ /** Input is a local/mailbox command (not a HyBBX '/' command). */
+ HYBBX_LOCAL_CMD = 1,
+ /** Session should be closed (e.g. /exit). */
+ HYBBX_SESSION_END = 2,
+ HYBBX_ERR_INVALID = -1,
+ HYBBX_ERR_NOMEM = -2,
+ HYBBX_ERR_NOT_FOUND = -3,
+ HYBBX_ERR_IO = -4,
+ HYBBX_ERR_UNSUPPORTED = -5,
+ HYBBX_ERR_BUSY = -6,
+ HYBBX_ERR_DENIED = -7
+} hybbx_result_t;
+
+typedef enum hybbx_transport_kind {
+ HYBBX_TRANSPORT_TELNET = 1,
+ HYBBX_TRANSPORT_PACKET_RADIO = 2,
+ HYBBX_TRANSPORT_CIRCUIT = 3,
+ HYBBX_TRANSPORT_ARDOP = 4,
+ HYBBX_TRANSPORT_CRDOP = 5,
+ HYBBX_TRANSPORT_SSH = 6,
+ HYBBX_TRANSPORT_WEBSOCKET = 7,
+ HYBBX_TRANSPORT_BAYCOM = 8,
+ HYBBX_TRANSPORT_MAINS_PROXY = 9,
+} hybbx_transport_kind_t;
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TYPES_H */
diff --git a/include/hybbx/util.h b/include/hybbx/util.h
new file mode 100644
index 0000000..de439b4
--- /dev/null
+++ b/include/hybbx/util.h
@@ -0,0 +1,143 @@
+#ifndef HYBBX_UTIL_H
+#define HYBBX_UTIL_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <time.h>
+
+struct tm;
+
+struct hybbx_config;
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/**
+ * Safe string copy (always NUL-terminates when @p dst_size > 0).
+ * Returns bytes copied excluding the terminator.
+ */
+size_t hybbx_strlcpy(char *dst, const char *src, size_t dst_size);
+
+/**
+ * Join @p base and @p name into @p out with a single '/'.
+ * Rejects empty components and path traversal ("..").
+ */
+hybbx_result_t hybbx_path_join(char *out, size_t out_len,
+ const char *base, const char *name);
+
+/** Default relative storage path (@ref HYBBX_DIR_DATA). */
+hybbx_result_t hybbx_default_user_data_path(char *out, size_t out_len);
+
+/**
+ * Expand a config path: `~` → $HOME, `~/foo` → $HOME/foo.
+ * Empty or NULL @p path → @ref HYBBX_DIR_DATA. Other paths copied unchanged.
+ */
+hybbx_result_t hybbx_path_expand(char *out, size_t out_len, const char *path);
+
+/** Set the HyBBX install root used by @ref hybbx_path_resolve. */
+void hybbx_install_root_set(const char *root);
+
+/** Install root set by @ref hybbx_install_root_set, or NULL when unset. */
+const char *hybbx_install_root_get(void);
+
+/**
+ * Resolve a config path: @ref hybbx_path_expand, then prefix relative paths
+ * with the install root when set.
+ */
+hybbx_result_t hybbx_path_resolve(char *out, size_t out_len, const char *path);
+
+/** Parent directory of @p path (POSIX `/` rules). */
+hybbx_result_t hybbx_path_dirname(const char *path, char *out, size_t out_len);
+
+/**
+ * Host operating system name for display (e.g. Linux, FreeBSD, MacOS, Windows).
+ * Does not include OS version or kernel release.
+ */
+hybbx_result_t hybbx_platform_os_name(char *out, size_t out_len);
+
+/** Return non-zero when @p len is safe for HyBBX allocations. */
+int hybbx_size_ok(size_t len);
+
+/**
+ * HyBBX boolean configuration standard.
+ * Canonical written form: @c yes / @c no (see @ref HYBBX_BOOL_YES / @ref HYBBX_BOOL_NO).
+ * Accepted true: yes, true, enable, enabled, on, 1
+ * Accepted false: no, false, disable, disabled, off, 0
+ * Matching is case-insensitive.
+ */
+#define HYBBX_BOOL_YES "yes"
+#define HYBBX_BOOL_NO "no"
+
+/** Return non-zero when @p value is a recognized true token. */
+int hybbx_bool_is_true(const char *value);
+
+/** Return non-zero when @p value is a recognized false token. */
+int hybbx_bool_is_false(const char *value);
+
+/**
+ * Parse a boolean string. Returns 1 or 0 when recognized; otherwise
+ * returns @p default_value (also used when @p value is NULL or empty).
+ */
+int hybbx_parse_bool(const char *value, int default_value);
+
+/** Canonical @c yes / @c no string for a boolean value. */
+const char *hybbx_bool_to_string(int value);
+
+/** Short name for @p rc (logging / tests). */
+const char *hybbx_result_name(hybbx_result_t rc);
+
+/**
+ * HyBBX system-local date/time formatting for logs and text/ tokens.
+ * Default: 24-hour clock with seconds, ISO date YYYY/MM/DD.
+ */
+typedef enum hybbx_date_format {
+ HYBBX_DATE_ISO = 0,
+ HYBBX_DATE_ISO_SHORT = 1,
+ HYBBX_DATE_US = 2,
+ HYBBX_DATE_EU = 3,
+} hybbx_date_format_t;
+
+typedef struct hybbx_time_format {
+ int clock_12h;
+ int seconds;
+ hybbx_date_format_t date_format;
+} hybbx_time_format_t;
+
+void hybbx_time_format_defaults(hybbx_time_format_t *fmt);
+const hybbx_time_format_t *hybbx_time_format_get(void);
+void hybbx_time_config_apply(const struct hybbx_config *config);
+
+/** Local wall-clock time for text tokens and stamps. */
+hybbx_result_t hybbx_time_local_now(struct tm *out);
+
+const char *hybbx_date_format_name(hybbx_date_format_t fmt);
+
+/**
+ * Format @p tm as clock time (default @c HH:MM:SS, 12h when configured).
+ */
+hybbx_result_t hybbx_time_format_time(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt);
+
+/**
+ * Format @p tm as calendar date (default @c YYYY/MM/DD).
+ */
+hybbx_result_t hybbx_time_format_date(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt);
+
+/**
+ * Format @p tm as @c yyyymmdd HH:MM per @p fmt (compact log stamp).
+ * @return HYBBX_OK on success.
+ */
+hybbx_result_t hybbx_time_format_stamp(char *out, size_t out_len,
+ const struct tm *tm,
+ const hybbx_time_format_t *fmt);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_UTIL_H */
diff --git a/include/hybbx/websocket.h b/include/hybbx/websocket.h
new file mode 100644
index 0000000..d14e870
--- /dev/null
+++ b/include/hybbx/websocket.h
@@ -0,0 +1,53 @@
+#ifndef HYBBX_WEBSOCKET_H
+#define HYBBX_WEBSOCKET_H
+
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Default WebSocket listen port (behind TLS reverse proxy). */
+#define HYBBX_WEBSOCKET_DEFAULT_PORT 4591u
+
+/** Default simultaneous WebSocket client connections. */
+#define HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS 10u
+
+#define HYBBX_WEBSOCKET_BIND_V4_MAX 64
+#define HYBBX_WEBSOCKET_BIND_V6_MAX 64
+#define HYBBX_WEBSOCKET_PATH_MAX 128
+
+#define HYBBX_WEBSOCKET_DEFAULT_BIND_V4 "127.0.0.1"
+#define HYBBX_WEBSOCKET_DEFAULT_BIND_V6 "::1"
+#define HYBBX_WEBSOCKET_DEFAULT_PATH "/hybbx"
+#define HYBBX_WEBSOCKET_DEFAULT_CERT_DIR "keys"
+
+/** Filenames inside @c cert_dir (auto-generated on first start when TLS). */
+#define HYBBX_WS_TLS_CERT_FILENAME "hybbx_ws.crt"
+#define HYBBX_WS_TLS_KEY_FILENAME "hybbx_ws.key"
+
+/** Self-signed WebSocket TLS certificate validity (days). */
+#define HYBBX_WS_TLS_CERT_VALID_DAYS 1825u
+
+typedef struct hybbx_websocket_config {
+ char bind_v4[HYBBX_WEBSOCKET_BIND_V4_MAX];
+ char bind_v6[HYBBX_WEBSOCKET_BIND_V6_MAX];
+ char path[HYBBX_WEBSOCKET_PATH_MAX];
+ char cert_dir[HYBBX_PATH_MAX];
+ unsigned int port;
+ unsigned int max_connections;
+ int ipv4;
+ int ipv6;
+} hybbx_websocket_config_t;
+
+void hybbx_websocket_config_defaults(hybbx_websocket_config_t *config);
+
+hybbx_result_t hybbx_websocket_config_parse(const char *config,
+ hybbx_websocket_config_t *out);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_WEBSOCKET_H */
diff --git a/plugins/CMakeLists.txt b/plugins/CMakeLists.txt
new file mode 100644
index 0000000..9736302
--- /dev/null
+++ b/plugins/CMakeLists.txt
@@ -0,0 +1,44 @@
+# Link a transport plugin into all HyBBX instance binaries (hybbxd/hybbxsd/hybbxpd).
+function(hybbx_link_plugin_instances plugin_target define_name)
+ foreach(_bin IN ITEMS hybbxd hybbxsd hybbxpd)
+ if(TARGET ${_bin})
+ target_link_libraries(${_bin} PRIVATE ${plugin_target})
+ if(NOT "${define_name}" STREQUAL "")
+ target_compile_definitions(${_bin} PRIVATE ${define_name})
+ endif()
+ endif()
+ endforeach()
+endfunction()
+
+if(HYBBX_PLUGIN_TELNET)
+ add_subdirectory(telnet)
+endif()
+
+if(HYBBX_PLUGIN_PACKET_RADIO)
+ add_subdirectory(packet_radio)
+endif()
+
+if(HYBBX_PLUGIN_ARDOP OR HYBBX_PLUGIN_CRDOP)
+ add_subdirectory(ardop)
+endif()
+
+if(HYBBX_PLUGIN_CRDOP)
+ add_subdirectory(crdop)
+endif()
+
+if(HYBBX_PLUGIN_BAYCOM)
+ add_subdirectory(baycom)
+endif()
+
+if(HYBBX_PLUGIN_MAINS_PROXY)
+ add_subdirectory(mains_proxy)
+ target_compile_definitions(hybbx_core PRIVATE HYBBX_HAVE_PLUGIN_MAINS_PROXY)
+endif()
+
+if(HYBBX_PLUGIN_SSH AND HYBBX_HAVE_LIBSSH)
+ add_subdirectory(ssh)
+endif()
+
+if(HYBBX_PLUGIN_WEBSOCKET)
+ add_subdirectory(websocket)
+endif()
diff --git a/plugins/ardop/CMakeLists.txt b/plugins/ardop/CMakeLists.txt
new file mode 100644
index 0000000..c39ab91
--- /dev/null
+++ b/plugins/ardop/CMakeLists.txt
@@ -0,0 +1,57 @@
+add_library(hybbx_ardop_common STATIC
+ ardop_link.c
+ ardop_host.c
+ ardop_crc.c
+ ardop_config.c
+)
+
+target_include_directories(hybbx_ardop_common
+ PUBLIC
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_ardop_common
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_ardop_common PUBLIC hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_ardop_common PUBLIC amiga)
+endif()
+hybbx_apply_hardening(hybbx_ardop_common)
+
+if(HYBBX_PLUGIN_ARDOP)
+ add_library(hybbx_plugin_ardop STATIC
+ ardop.c
+ )
+
+ target_include_directories(hybbx_plugin_ardop
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+
+ target_compile_definitions(hybbx_plugin_ardop
+ PRIVATE HYBBX_PLUGIN_BUILD
+ )
+
+ target_link_libraries(hybbx_plugin_ardop PRIVATE hybbx_ardop_common)
+ hybbx_link_plugin_instances(hybbx_plugin_ardop HYBBX_HAVE_PLUGIN_ARDOP)
+ hybbx_apply_hardening(hybbx_plugin_ardop)
+
+ if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_plugin_ardop
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ )
+ endif()
+endif()
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_ardop_common
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/ardop/ardop.c b/plugins/ardop/ardop.c
new file mode 100644
index 0000000..e07a158
--- /dev/null
+++ b/plugins/ardop/ardop.c
@@ -0,0 +1,49 @@
+/*
+ * ardop — ARDOP host-client link adapter (external ARDOPC / ardopcf).
+ * INI: [transport.ardop] or [transport.ardopN].
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/ardop.h"
+#include "ardop_link.h"
+
+static const ardop_link_plugin_t g_ardop_link = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .default_link_id = "ardop-link",
+ .modem_hint = "ARDOPC or ardopcf",
+ .parse_config = (ardop_link_config_fn)hybbx_ardop_config_parse,
+ .config_free = (void (*)(void *))hybbx_ardop_config_free,
+ .config_size = sizeof(hybbx_ardop_config_t),
+};
+
+static hybbx_result_t ardop_init(hybbx_service_t *service)
+{
+ return ardop_link_init(service, &g_ardop_link);
+}
+
+static void ardop_shutdown(void)
+{
+ ardop_link_shutdown(&g_ardop_link);
+}
+
+static hybbx_result_t ardop_start(const char *config)
+{
+ return ardop_link_start(&g_ardop_link, config);
+}
+
+static hybbx_result_t ardop_stop(void)
+{
+ return ardop_link_stop(&g_ardop_link);
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_ardop = {
+ .name = "ardop",
+ .kind = HYBBX_TRANSPORT_ARDOP,
+ .version = 1,
+ .init = ardop_init,
+ .shutdown = ardop_shutdown,
+ .start = ardop_start,
+ .stop = ardop_stop,
+ .write = NULL,
+};
diff --git a/plugins/ardop/ardop_config.c b/plugins/ardop/ardop_config.c
new file mode 100644
index 0000000..71b40dc
--- /dev/null
+++ b/plugins/ardop/ardop_config.c
@@ -0,0 +1,186 @@
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *ardop_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_ardop_config_free(hybbx_ardop_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->ardop_host);
+ free(config->mycall);
+ free(config->arq_bandwidth);
+ free(config->peer_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ memset(config, 0, sizeof(*config));
+}
+
+hybbx_result_t hybbx_ardop_config_parse(const char *config,
+ hybbx_ardop_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ardop_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_kv(config, "ardop_host", scratch, sizeof(scratch));
+ out->ardop_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->ardop_port = (unsigned)strtoul(
+ find_kv(config, "ardop_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "8515", NULL, 10);
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ out->mycall = ardop_strdup(value != NULL ? value : "NOCALL");
+
+ value = find_kv(config, "radio_profile", scratch, sizeof(scratch));
+ out->radio_profile = hybbx_crdop_profile_parse(value);
+
+ value = find_kv(config, "arq_bandwidth", scratch, sizeof(scratch));
+ out->arq_bandwidth = ardop_strdup(
+ value != NULL ? value :
+ hybbx_crdop_default_arq_bandwidth(out->radio_profile));
+
+ value = find_kv(config, "peer_call", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->peer_call = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "listen", scratch, sizeof(scratch));
+ out->listen = value == NULL || str_ieq(value, "yes") || str_ieq(value, "1") ||
+ str_ieq(value, "true");
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = ardop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->circuit_port = (unsigned)strtoul(
+ find_kv(config, "circuit_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "7323", NULL, 10);
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = ardop_strdup(value != NULL ? value : "ardop-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = ardop_strdup(value);
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = ardop_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = strtod(value, NULL);
+ }
+
+ if (out->radio_profile == HYBBX_CRDOP_PROFILE_CB &&
+ hybbx_crdop_bandwidth_exceeds_cb(out->arq_bandwidth)) {
+ hybbx_log_warn("[ardop] warning: arq_bandwidth=%s high for CB profile "
+ "(CRDOP Level 2 experimental)",
+ out->arq_bandwidth);
+ }
+
+ if (out->ardop_host == NULL || out->mycall == NULL ||
+ out->arq_bandwidth == NULL || out->circuit_host == NULL ||
+ out->link_id == NULL || out->link_role == NULL) {
+ hybbx_ardop_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_crc.c b/plugins/ardop/ardop_crc.c
new file mode 100644
index 0000000..4cf205a
--- /dev/null
+++ b/plugins/ardop/ardop_crc.c
@@ -0,0 +1,69 @@
+#include "ardop_crc.h"
+
+/* Algorithm from ARDOPC (KN6KB / G8BPQ) — CRC-16-CCITT variant, poly 0x8810. */
+
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length)
+{
+ int int_register = 0xffff;
+ int i;
+ int j;
+ int bit;
+ const int int_poly = 0x8810;
+
+ if (data == NULL || length == 0) {
+ return 0xffffu;
+ }
+
+ for (j = 0; j < (int)length; j++) {
+ int mask = 0x80;
+
+ for (i = 0; i < 8; i++) {
+ bit = data[j] & mask;
+ mask >>= 1;
+
+ if (int_register & 0x8000) {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ int_register = int_register ^ int_poly;
+ } else {
+ if (bit) {
+ int_register = 0xffff & (1 + (int_register << 1));
+ } else {
+ int_register = 0xffff & (int_register << 1);
+ }
+ }
+ }
+ }
+
+ return (unsigned int)int_register;
+}
+
+int ardop_crc16_valid(const unsigned char *data, unsigned short length)
+{
+ unsigned int crc;
+
+ if (data == NULL || length < 3) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)(length - 2));
+ return data[length - 2] == (unsigned char)(crc >> 8) &&
+ data[length - 1] == (unsigned char)(crc & 0xff);
+}
+
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap)
+{
+ unsigned int crc;
+
+ if (data == NULL || length + 2 > cap) {
+ return 0;
+ }
+
+ crc = ardop_crc16(data, (unsigned short)length);
+ data[length] = (unsigned char)(crc >> 8);
+ data[length + 1] = (unsigned char)(crc & 0xff);
+ return length + 2;
+}
diff --git a/plugins/ardop/ardop_crc.h b/plugins/ardop/ardop_crc.h
new file mode 100644
index 0000000..69c2f07
--- /dev/null
+++ b/plugins/ardop/ardop_crc.h
@@ -0,0 +1,15 @@
+#ifndef ARDOP_CRC_H
+#define ARDOP_CRC_H
+
+#include <stddef.h>
+
+/** ARDOP host/TNC CRC-16 (poly 0x8810, init 0xFFFF) per ARDOPC reference. */
+unsigned int ardop_crc16(const unsigned char *data, unsigned short length);
+
+/** Non-zero when @p length includes trailing 2-byte CRC that validates. */
+int ardop_crc16_valid(const unsigned char *data, unsigned short length);
+
+/** Append CRC16 (MSB, LSB) after @p length bytes; returns new length. */
+size_t ardop_crc16_append(unsigned char *data, size_t length, size_t cap);
+
+#endif /* ARDOP_CRC_H */
diff --git a/plugins/ardop/ardop_host.c b/plugins/ardop/ardop_host.c
new file mode 100644
index 0000000..aeac403
--- /dev/null
+++ b/plugins/ardop/ardop_host.c
@@ -0,0 +1,650 @@
+#include "ardop_host.h"
+#include "ardop_crc.h"
+
+#include "hybbx/socket.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define ARDOP_HOST_RX_MAX 8192u
+#define ARDOP_HOST_CMD_MAX 256u
+#define ARDOP_HOST_FRAME_MAX (HYBBX_ARDOP_DATA_MAX + 64u)
+
+struct ardop_host {
+ hybbx_ardop_config_t cfg;
+ ardop_host_data_fn on_data;
+ ardop_host_event_fn on_event;
+ void *userdata;
+ int ctrl_fd;
+ int data_fd;
+ int tnc_ready;
+ int link_up;
+ char peer_call[16];
+ unsigned char ctrl_rx[ARDOP_HOST_RX_MAX];
+ size_t ctrl_len;
+ unsigned char data_rx[ARDOP_HOST_RX_MAX];
+ size_t data_len;
+ int init_sent;
+ const char *log_tag;
+};
+
+static const char *host_log_tag(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL && host->log_tag[0] != '\0') {
+ return host->log_tag;
+ }
+ return "ardop";
+}
+
+static const char *host_modem_name(const ardop_host_t *host)
+{
+ if (host != NULL && host->log_tag != NULL &&
+ strcmp(host->log_tag, "crdop") == 0) {
+ return "CRDOPC";
+ }
+ return "ARDOPC";
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static void ardop_host_fire_event(ardop_host_t *host, const char *event,
+ const char *detail)
+{
+ if (host != NULL && host->on_event != NULL) {
+ host->on_event(event, detail, host->userdata);
+ }
+}
+
+static int ardop_host_set_socket_opts(int fd)
+{
+ int on = 1;
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int ardop_host_tcp_connect(const char *host, unsigned port)
+{
+ struct sockaddr_in addr;
+ int fd;
+ int rc;
+
+ if (host == NULL || host[0] == '\0' || port == 0) {
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (ardop_host_set_socket_opts(fd) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, host, &addr.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr, sizeof(addr));
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static hybbx_result_t ardop_host_write_all(int fd, const unsigned char *buf,
+ size_t len)
+{
+ size_t off = 0;
+
+ if (fd < 0 || buf == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (off < len) {
+ ssize_t n = send(fd, buf + off, len - off, MSG_NOSIGNAL);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ardop_host_send_cmd_frame(ardop_host_t *host,
+ const char *cmd)
+{
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 4];
+ size_t len;
+
+ if (host == NULL || cmd == NULL || host->ctrl_fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(cmd);
+ if (len + 3 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(frame, cmd, len);
+ frame[len++] = '\r';
+ len = ardop_crc16_append(frame, len, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->ctrl_fd, frame, len);
+}
+
+static hybbx_result_t ardop_host_send_rdy(ardop_host_t *host)
+{
+ return ardop_host_send_cmd_frame(host, "RDY");
+}
+
+static void ardop_host_strip_prefix(char *line)
+{
+ if (line == NULL) {
+ return;
+ }
+
+ if (strncmp(line, "c:", 2) == 0 || strncmp(line, "C:", 2) == 0) {
+ memmove(line, line + 2, strlen(line + 2) + 1);
+ }
+}
+
+static void ardop_host_handle_ctrl_line(ardop_host_t *host, char *line)
+{
+ char *sp;
+
+ if (host == NULL || line == NULL) {
+ return;
+ }
+
+ ardop_host_strip_prefix(line);
+
+ if (str_ieq(line, "RDY")) {
+ host->tnc_ready = 1;
+ return;
+ }
+
+ if (strncmp(line, "VERSION", 7) == 0) {
+ hybbx_log_debug("[%s] modem %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "CONNECTED", 9) == 0) {
+ char *tok;
+
+ sp = line + 9;
+ while (*sp == ' ') {
+ sp++;
+ }
+ hybbx_strlcpy(host->peer_call, sp, sizeof(host->peer_call));
+ tok = strchr(host->peer_call, ' ');
+ if (tok != NULL) {
+ *tok = '\0';
+ }
+ host->link_up = 1;
+ ardop_host_fire_event(host, "connected", host->peer_call);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (str_ieq(line, "DISCONNECTED")) {
+ host->link_up = 0;
+ host->peer_call[0] = '\0';
+ ardop_host_fire_event(host, "disconnected", NULL);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "FAULT", 5) == 0 ||
+ strncmp(line, "CRCFAULT", 8) == 0) {
+ hybbx_log_warn("[%s] TNC fault: %s", host_log_tag(host), line);
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ if (strncmp(line, "BUSY", 4) == 0 ||
+ strncmp(line, "NEWSTATE", 8) == 0 ||
+ strncmp(line, "BUFFER", 6) == 0 ||
+ strncmp(line, "STATUS", 6) == 0 ||
+ strncmp(line, "PENDING", 7) == 0 ||
+ strncmp(line, "TARGET", 6) == 0) {
+ (void)ardop_host_send_rdy(host);
+ return;
+ }
+
+ (void)ardop_host_send_rdy(host);
+}
+
+static void ardop_host_process_ctrl_rx(ardop_host_t *host)
+{
+ unsigned char *cr;
+ unsigned char frame[ARDOP_HOST_CMD_MAX + 8];
+ size_t msg_len;
+ char line[ARDOP_HOST_CMD_MAX];
+
+ if (host == NULL) {
+ return;
+ }
+
+ for (;;) {
+ cr = memchr(host->ctrl_rx, '\r', host->ctrl_len);
+ if (cr == NULL) {
+ return;
+ }
+
+ msg_len = (size_t)(cr - host->ctrl_rx) + 1u + 2u;
+ if (host->ctrl_len < msg_len) {
+ return;
+ }
+
+ if (msg_len > sizeof(frame)) {
+ host->ctrl_len = 0;
+ return;
+ }
+
+ memcpy(frame, host->ctrl_rx, msg_len);
+ if (!ardop_crc16_valid(frame, (unsigned short)msg_len)) {
+ hybbx_log_warn("[%s] control CRC fault", host_log_tag(host));
+ host->ctrl_len = 0;
+ return;
+ }
+
+ frame[msg_len - 3] = '\0';
+ hybbx_strlcpy(line, (const char *)frame, sizeof(line));
+ ardop_host_handle_ctrl_line(host, line);
+
+ memmove(host->ctrl_rx, host->ctrl_rx + msg_len, host->ctrl_len - msg_len);
+ host->ctrl_len -= msg_len;
+ }
+}
+
+static void ardop_host_process_data_rx(ardop_host_t *host)
+{
+ size_t off = 0;
+
+ if (host == NULL) {
+ return;
+ }
+
+ while (host->data_len >= off + 6u) {
+ const unsigned char *buf = host->data_rx + off;
+ size_t remain = host->data_len - off;
+ size_t payload_len;
+ size_t frame_len;
+ const uint8_t *payload;
+ const char *tag;
+
+ if (buf[0] != 'd' && buf[0] != 'D') {
+ off++;
+ continue;
+ }
+ if (buf[1] != ':') {
+ off++;
+ continue;
+ }
+
+ payload_len = ((size_t)buf[2] << 8) | (size_t)buf[3];
+ if (payload_len < 3 || payload_len > HYBBX_ARDOP_DATA_MAX + 8u) {
+ off++;
+ continue;
+ }
+
+ frame_len = 2u + 2u + payload_len + 2u;
+ if (remain < frame_len) {
+ break;
+ }
+
+ if (!ardop_crc16_valid(buf, (unsigned short)frame_len)) {
+ off++;
+ continue;
+ }
+
+ tag = (const char *)(buf + 4);
+ payload = buf + 7;
+ payload_len -= 3u;
+
+ if (strncmp(tag, "ARQ", 3) == 0 && host->on_data != NULL &&
+ payload_len > 0) {
+ host->on_data(payload, payload_len, host->userdata);
+ }
+
+ (void)ardop_host_send_cmd_frame(host, "RDY");
+ off += frame_len;
+ }
+
+ if (off > 0) {
+ memmove(host->data_rx, host->data_rx + off, host->data_len - off);
+ host->data_len -= off;
+ }
+}
+
+static hybbx_result_t ardop_host_send_init(ardop_host_t *host)
+{
+ char cmd[96];
+ hybbx_result_t rc;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "INITIALIZE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "MYCALL %s",
+ host->cfg.mycall != NULL ? host->cfg.mycall : "NOCALL");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host, "PROTOCOLMODE ARQ");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ snprintf(cmd, sizeof(cmd), "ARQBW %s",
+ host->cfg.arq_bandwidth != NULL ? host->cfg.arq_bandwidth :
+ "500MAX");
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = ardop_host_send_cmd_frame(host,
+ host->cfg.listen ? "LISTEN TRUE" :
+ "LISTEN FALSE");
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (host->cfg.peer_call != NULL && host->cfg.peer_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "ARQCALL %s 5", host->cfg.peer_call);
+ rc = ardop_host_send_cmd_frame(host, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ host->init_sent = 1;
+ hybbx_log_info("[%s] host init sent (external %s at %s:%u)",
+ host_log_tag(host), host_modem_name(host),
+ host->cfg.ardop_host != NULL ? host->cfg.ardop_host : "?",
+ host->cfg.ardop_port);
+ return HYBBX_OK;
+}
+
+static void ardop_host_read_fd(int fd, unsigned char *buf, size_t *len,
+ size_t cap)
+{
+ ssize_t n;
+
+ if (fd < 0 || buf == NULL || len == NULL || *len >= cap) {
+ return;
+ }
+
+ n = recv(fd, buf + *len, cap - *len, 0);
+ if (n > 0) {
+ *len += (size_t)n;
+ } else if (n == 0) {
+ close(fd);
+ }
+}
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag)
+{
+ ardop_host_t *host;
+
+ if (cfg == NULL) {
+ return NULL;
+ }
+
+ host = calloc(1, sizeof(*host));
+ if (host == NULL) {
+ return NULL;
+ }
+
+ host->cfg = *cfg;
+ host->on_data = on_data;
+ host->on_event = on_event;
+ host->userdata = userdata;
+ host->ctrl_fd = -1;
+ host->data_fd = -1;
+ host->log_tag = log_tag;
+ return host;
+}
+
+void ardop_host_destroy(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ ardop_host_disconnect(host);
+ free(host);
+}
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host)
+{
+ const char *h;
+ unsigned port;
+
+ if (host == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ ardop_host_disconnect(host);
+
+ h = host->cfg.ardop_host;
+ if (h == NULL || h[0] == '\0') {
+ h = "127.0.0.1";
+ }
+ port = host->cfg.ardop_port;
+ if (port == 0) {
+ port = HYBBX_ARDOP_DEFAULT_PORT;
+ }
+
+ host->ctrl_fd = ardop_host_tcp_connect(h, port);
+ if (host->ctrl_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s control %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port);
+ return HYBBX_ERR_IO;
+ }
+
+ host->data_fd = ardop_host_tcp_connect(h, port + 1u);
+ if (host->data_fd < 0) {
+ hybbx_log_warn("[%s] cannot connect to %s data %s:%u",
+ host_log_tag(host), host_modem_name(host), h, port + 1u);
+ ardop_host_disconnect(host);
+ return HYBBX_ERR_IO;
+ }
+
+ host->tnc_ready = 0;
+ host->init_sent = 0;
+ hybbx_log_info("[%s] connected to external %s %s:%u (data %u)",
+ host_log_tag(host), host_modem_name(host), h, port, port + 1u);
+
+ (void)ardop_host_send_init(host);
+ {
+ unsigned spin;
+
+ for (spin = 0; spin < 8u; spin++) {
+ ardop_host_poll(host);
+ if (host->tnc_ready) {
+ break;
+ }
+ }
+ }
+ return HYBBX_OK;
+}
+
+void ardop_host_disconnect(ardop_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ if (host->ctrl_fd >= 0) {
+ close(host->ctrl_fd);
+ host->ctrl_fd = -1;
+ }
+ if (host->data_fd >= 0) {
+ close(host->data_fd);
+ host->data_fd = -1;
+ }
+
+ host->ctrl_len = 0;
+ host->data_len = 0;
+ host->tnc_ready = 0;
+ host->link_up = 0;
+ host->init_sent = 0;
+ host->peer_call[0] = '\0';
+}
+
+void ardop_host_poll(ardop_host_t *host)
+{
+ struct pollfd pfds[2];
+ int count = 0;
+ int pr;
+
+ if (host == NULL || host->ctrl_fd < 0) {
+ return;
+ }
+
+ pfds[0].fd = host->ctrl_fd;
+ pfds[0].events = POLLIN;
+ count = 1;
+
+ if (host->data_fd >= 0) {
+ pfds[1].fd = host->data_fd;
+ pfds[1].events = POLLIN;
+ count = 2;
+ }
+
+ pr = poll(pfds, (nfds_t)count, 0);
+ if (pr <= 0) {
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+ return;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->ctrl_fd, host->ctrl_rx, &host->ctrl_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_ctrl_rx(host);
+ }
+
+ if (count > 1 && (pfds[1].revents & POLLIN) != 0) {
+ ardop_host_read_fd(host->data_fd, host->data_rx, &host->data_len,
+ ARDOP_HOST_RX_MAX);
+ ardop_host_process_data_rx(host);
+ }
+
+ if (host->tnc_ready && !host->init_sent) {
+ (void)ardop_host_send_init(host);
+ }
+}
+
+int ardop_host_link_up(const ardop_host_t *host)
+{
+ return host != NULL && host->link_up;
+}
+
+int ardop_host_modem_connected(const ardop_host_t *host)
+{
+ return host != NULL && host->ctrl_fd >= 0;
+}
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ unsigned char frame[ARDOP_HOST_FRAME_MAX];
+ size_t frame_len;
+
+ if (host == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!host->link_up || host->data_fd < 0) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ if (len > HYBBX_ARDOP_DATA_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len + 8 > sizeof(frame)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 'D';
+ frame[1] = ':';
+ frame[2] = (unsigned char)((len >> 8) & 0xff);
+ frame[3] = (unsigned char)(len & 0xff);
+ memcpy(frame + 4, data, len);
+
+ frame_len = 4u + len;
+ frame_len = ardop_crc16_append(frame, frame_len, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return ardop_host_write_all(host->data_fd, frame, frame_len);
+}
diff --git a/plugins/ardop/ardop_host.h b/plugins/ardop/ardop_host.h
new file mode 100644
index 0000000..4aee53d
--- /dev/null
+++ b/plugins/ardop/ardop_host.h
@@ -0,0 +1,47 @@
+#ifndef ARDOP_HOST_H
+#define ARDOP_HOST_H
+
+/**
+ * Minimal ARDOP TNC Host Interface client (TCP control + data ports).
+ * Implements the subset HyBBX needs — see docs/MANUAL.md § ARDOP.
+ */
+
+#include "hybbx/types.h"
+#include "hybbx/ardop.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct ardop_host ardop_host_t;
+
+typedef void (*ardop_host_data_fn)(const uint8_t *data, size_t len, void *ctx);
+typedef void (*ardop_host_event_fn)(const char *event, const char *detail,
+ void *ctx);
+
+ardop_host_t *ardop_host_create(const hybbx_ardop_config_t *cfg,
+ ardop_host_data_fn on_data,
+ ardop_host_event_fn on_event,
+ void *userdata,
+ const char *log_tag);
+void ardop_host_destroy(ardop_host_t *host);
+
+hybbx_result_t ardop_host_connect(ardop_host_t *host);
+void ardop_host_disconnect(ardop_host_t *host);
+
+/** Drive I/O and state machine; call periodically from plugin poll thread. */
+void ardop_host_poll(ardop_host_t *host);
+
+int ardop_host_link_up(const ardop_host_t *host);
+
+/** Control TCP session to external ARDOPC is open. */
+int ardop_host_modem_connected(const ardop_host_t *host);
+
+hybbx_result_t ardop_host_send_data(ardop_host_t *host,
+ const uint8_t *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_HOST_H */
diff --git a/plugins/ardop/ardop_link.c b/plugins/ardop/ardop_link.c
new file mode 100644
index 0000000..e0500b9
--- /dev/null
+++ b/plugins/ardop/ardop_link.c
@@ -0,0 +1,446 @@
+#include "ardop_link.h"
+#include "ardop_host.h"
+
+#include "hybbx/ardop.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/crdop.h"
+#include "hybbx/log.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#endif
+
+static const ardop_link_plugin_t *g_plugin;
+static hybbx_ardop_config_t g_active_config;
+static ardop_host_t *g_host;
+static int g_circuit_fd = -1;
+static hybbx_circuit_decoder_t g_circuit_dec;
+static pthread_t g_poll_thread;
+static volatile int g_running;
+static int g_poll_thread_started;
+static volatile int g_circuit_flow_paused;
+static volatile int g_circuit_flow_cancel;
+static unsigned g_modem_reconnect_backoff_ms;
+
+static const char *link_tag(void)
+{
+ return g_plugin != NULL && g_plugin->name != NULL ? g_plugin->name : "ardop";
+}
+
+static int circuit_uplink_allowed(void)
+{
+ return !g_circuit_flow_paused && !g_circuit_flow_cancel;
+}
+
+static hybbx_result_t circuit_uplink_terminal(const uint8_t *payload, size_t len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ if (g_circuit_fd < 0 || len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!circuit_uplink_allowed()) {
+ return HYBBX_OK;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(g_circuit_fd, hbx, hbx_len);
+}
+
+static void on_modem_data(const uint8_t *data, size_t len, void *userdata)
+{
+ (void)userdata;
+
+ if (len == 0 || g_circuit_fd < 0 || !circuit_uplink_allowed()) {
+ return;
+ }
+
+ (void)circuit_uplink_terminal(data, len);
+}
+
+static void on_modem_event(const char *event, const char *detail, void *userdata)
+{
+ (void)userdata;
+
+ if (event == NULL) {
+ return;
+ }
+
+ if (strcmp(event, "connected") == 0) {
+ if (detail != NULL && detail[0] != '\0') {
+ hybbx_log_stats("[%s] RF link up (%s)", link_tag(), detail);
+ } else {
+ hybbx_log_stats("[%s] RF link up", link_tag());
+ }
+ g_modem_reconnect_backoff_ms = 1000u;
+ return;
+ }
+
+ if (strcmp(event, "disconnected") == 0) {
+ hybbx_log_stats("[%s] RF link down", link_tag());
+ }
+}
+
+static void on_circuit_flow_ctrl(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+ (void)userdata;
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ g_circuit_flow_paused = 1;
+ hybbx_log_stats("[%s] circuit flow pause (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ g_circuit_flow_paused = 0;
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ hybbx_log_stats("[%s] circuit flow break (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ g_circuit_flow_cancel = 1;
+ hybbx_log_warn("[%s] circuit flow cancel (%s)", link_tag(), reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ g_circuit_flow_paused = 0;
+ hybbx_log_stats("[%s] circuit flow resume (%s)", link_tag(), reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ (void)flags;
+ (void)userdata;
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ on_circuit_flow_ctrl(proto, flags, payload, len, userdata);
+ return;
+ }
+
+ if (g_host == NULL || len == 0 || g_circuit_flow_paused) {
+ return;
+ }
+
+ if (proto != HYBBX_CIRCUIT_PROTO_TERMINAL) {
+ return;
+ }
+
+ if (!ardop_host_link_up(g_host)) {
+ return;
+ }
+
+ (void)ardop_host_send_data(g_host, payload, len);
+}
+
+static void link_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t link_connect_circuit(void);
+
+static void link_maybe_reconnect_modem(void)
+{
+ static unsigned ticks;
+
+ if (g_host == NULL) {
+ return;
+ }
+
+ if (ardop_host_modem_connected(g_host)) {
+ ticks = 0;
+ return;
+ }
+
+ ticks++;
+ if (ticks < (g_modem_reconnect_backoff_ms / 20u)) {
+ return;
+ }
+
+ ticks = 0;
+ if (ardop_host_connect(g_host) != HYBBX_OK) {
+ if (g_modem_reconnect_backoff_ms < 30000u) {
+ g_modem_reconnect_backoff_ms += 1000u;
+ }
+ }
+}
+
+static void *link_poll_thread(void *arg)
+{
+ uint8_t buf[512];
+
+ (void)arg;
+
+ while (g_running) {
+ if (g_host != NULL) {
+ ardop_host_poll(g_host);
+ link_maybe_reconnect_modem();
+ }
+
+ if (g_circuit_fd >= 0) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(g_circuit_fd, buf,
+ sizeof(buf), &read_len);
+ if (rc == HYBBX_OK && read_len > 0) {
+ hybbx_circuit_decoder_feed(&g_circuit_dec, buf, read_len,
+ on_circuit_downlink, NULL);
+ }
+ } else {
+ static unsigned circuit_ticks;
+
+ circuit_ticks++;
+ if (circuit_ticks >= 50u) {
+ circuit_ticks = 0;
+ (void)link_connect_circuit();
+ }
+ }
+
+ if (g_circuit_flow_cancel) {
+ g_circuit_flow_cancel = 0;
+ g_circuit_flow_paused = 0;
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+ (void)link_connect_circuit();
+ }
+
+ link_poll_sleep_ms(20);
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t link_connect_circuit(void)
+{
+ const char *host = g_active_config.circuit_host;
+ unsigned port = g_active_config.circuit_port;
+ const char *link_id = g_active_config.link_id;
+ unsigned attempt;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = g_plugin != NULL && g_plugin->default_link_id != NULL ?
+ g_plugin->default_link_id : "ardop-link";
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &g_circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&g_circuit_dec);
+ if (g_active_config.link_password != NULL &&
+ g_active_config.link_password[0] != '\0') {
+ const char *link_role = g_active_config.link_role;
+ hybbx_circuit_link_qos_t qos;
+ char freq_buf[16];
+ const char *freq_ptr = NULL;
+
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ memset(&qos, 0, sizeof(qos));
+ qos.bandwidth = "low";
+ qos.duplex = 1;
+ if (g_active_config.frequency_mhz > 0.0) {
+ snprintf(freq_buf, sizeof(freq_buf), "%.3f",
+ g_active_config.frequency_mhz);
+ freq_ptr = freq_buf;
+ }
+ qos.frequency_mhz = freq_ptr;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ g_circuit_fd, g_active_config.link_password,
+ link_role, link_id, &qos);
+ if (rc != HYBBX_OK) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ link_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[%s] linked to internal circuit %s:%u (HBX)",
+ link_tag(), host, port);
+ return HYBBX_OK;
+ }
+ link_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[%s] could not connect to internal circuit %s:%u",
+ link_tag(), host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void link_shutdown(void)
+{
+ g_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ if (g_circuit_fd >= 0) {
+ close(g_circuit_fd);
+ g_circuit_fd = -1;
+ }
+
+ if (g_host != NULL) {
+ ardop_host_destroy(g_host);
+ g_host = NULL;
+ }
+
+ if (g_plugin != NULL && g_plugin->config_free != NULL) {
+ g_plugin->config_free(&g_active_config);
+ }
+}
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin)
+{
+ (void)service;
+ g_plugin = plugin;
+ g_host = NULL;
+ g_circuit_fd = -1;
+ g_running = 0;
+ g_circuit_flow_paused = 0;
+ g_circuit_flow_cancel = 0;
+ g_modem_reconnect_backoff_ms = 1000u;
+ return HYBBX_OK;
+}
+
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ link_shutdown();
+}
+
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config)
+{
+ hybbx_result_t rc;
+ const char *modem_hint;
+
+ if (plugin == NULL || plugin->parse_config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ g_plugin = plugin;
+ link_shutdown();
+ memset(&g_active_config, 0, sizeof(g_active_config));
+ g_poll_thread = (pthread_t)0;
+
+ rc = plugin->parse_config(config, &g_active_config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] invalid configuration", link_tag());
+ return rc;
+ }
+
+ hybbx_log_info("[%s] external modem %s:%u mycall=%s arq=%s profile=%s circuit=%s:%u "
+ "link_id=%s",
+ link_tag(),
+ g_active_config.ardop_host,
+ g_active_config.ardop_port,
+ g_active_config.mycall,
+ g_active_config.arq_bandwidth,
+ hybbx_crdop_profile_name(g_active_config.radio_profile),
+ g_active_config.circuit_host,
+ g_active_config.circuit_port,
+ g_active_config.link_id != NULL ? g_active_config.link_id : "?");
+
+ if (g_active_config.radio_profile == HYBBX_CRDOP_PROFILE_CB) {
+ hybbx_log_info("[%s] CB half-duplex profile (experimental Level 2)",
+ link_tag());
+ }
+ if (g_active_config.frequency_mhz > 0.0) {
+ hybbx_log_info("[%s] frequency_mhz=%.3f", link_tag(),
+ g_active_config.frequency_mhz);
+ }
+
+ modem_hint = plugin->modem_hint != NULL ? plugin->modem_hint : "ARDOPC";
+ hybbx_log_info("[%s] operator must run external %s separately (e.g. ardopc TCPIP %u)",
+ link_tag(), modem_hint, g_active_config.ardop_port);
+
+ g_host = ardop_host_create(&g_active_config, on_modem_data, on_modem_event,
+ NULL, link_tag());
+ if (g_host == NULL) {
+ link_shutdown();
+ return HYBBX_ERR_NOMEM;
+ }
+
+ rc = ardop_host_connect(g_host);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] external modem not reachable yet — will retry in poll loop",
+ link_tag());
+ }
+
+ rc = link_connect_circuit();
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[%s] circuit hub not reachable — HBX uplink deferred",
+ link_tag());
+ }
+
+ g_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, link_poll_thread, NULL) != 0) {
+ hybbx_log_warn("[%s] poll thread failed", link_tag());
+ link_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin)
+{
+ (void)plugin;
+ hybbx_log_info("[%s] stop", link_tag());
+ link_shutdown();
+ return HYBBX_OK;
+}
diff --git a/plugins/ardop/ardop_link.h b/plugins/ardop/ardop_link.h
new file mode 100644
index 0000000..0f10dba
--- /dev/null
+++ b/plugins/ardop/ardop_link.h
@@ -0,0 +1,37 @@
+#ifndef ARDOP_LINK_H
+#define ARDOP_LINK_H
+
+#include "hybbx/plugin.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+struct hybbx_service;
+
+typedef hybbx_result_t (*ardop_link_config_fn)(const char *config,
+ void *config_out);
+
+typedef struct ardop_link_plugin {
+ const char *name;
+ hybbx_transport_kind_t kind;
+ const char *default_link_id;
+ const char *modem_hint;
+ ardop_link_config_fn parse_config;
+ void (*config_free)(void *config_out);
+ size_t config_size;
+} ardop_link_plugin_t;
+
+hybbx_result_t ardop_link_init(struct hybbx_service *service,
+ const ardop_link_plugin_t *plugin);
+void ardop_link_shutdown(const ardop_link_plugin_t *plugin);
+hybbx_result_t ardop_link_start(const ardop_link_plugin_t *plugin,
+ const char *config);
+hybbx_result_t ardop_link_stop(const ardop_link_plugin_t *plugin);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* ARDOP_LINK_H */
diff --git a/plugins/baycom/CMakeLists.txt b/plugins/baycom/CMakeLists.txt
new file mode 100644
index 0000000..8a2dc2d
--- /dev/null
+++ b/plugins/baycom/CMakeLists.txt
@@ -0,0 +1,36 @@
+add_library(hybbx_plugin_baycom STATIC
+ baycom.c
+ baycom_config.c
+ baycom_modem.c
+ baycom_kernel.c
+ baycom_kiss.c
+ ../packet_radio/ax25.c
+ ../packet_radio/kiss.c
+ ../packet_radio/serial_port.c
+)
+
+target_include_directories(hybbx_plugin_baycom
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_baycom
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_plugin_baycom PRIVATE hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_plugin_baycom PRIVATE amiga)
+endif()
+hybbx_link_plugin_instances(hybbx_plugin_baycom HYBBX_HAVE_PLUGIN_BAYCOM)
+hybbx_apply_hardening(hybbx_plugin_baycom)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_baycom
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/baycom/baycom.c b/plugins/baycom/baycom.c
new file mode 100644
index 0000000..e2fb5c9
--- /dev/null
+++ b/plugins/baycom/baycom.c
@@ -0,0 +1,623 @@
+/*
+ * baycom — BayCom PR-Stack link adapter (Linux kernel SER12/PAR96/EPP or KISS serial).
+ * INI: [transport.baycom] or [transport.baycomN]. See docs/BAYCOM.md.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/baycom.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/limits.h"
+#include "hybbx/max25.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "hybbx/ax25.h"
+
+#include "baycom_modem.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <sys/select.h>
+#endif
+
+typedef struct baycom_instance {
+ hybbx_baycom_config_t config;
+ baycom_modem_t *modem;
+ int circuit_fd;
+ hybbx_circuit_decoder_t circuit_dec;
+ volatile int flow_paused;
+ volatile int flow_cancel;
+ unsigned circuit_reconnect_polls;
+ unsigned index;
+} baycom_instance_t;
+
+static hybbx_service_t *g_service;
+
+static baycom_instance_t g_instances[HYBBX_BAYCOM_MAX_INSTANCES];
+static unsigned g_instance_count;
+static pthread_t g_poll_thread;
+static volatile int g_running;
+static int g_poll_thread_started;
+
+static void baycom_poll_sleep_ms(unsigned ms);
+static hybbx_result_t instance_connect_circuit(baycom_instance_t *inst);
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata);
+
+static int instance_circuit_uplink_allowed(const baycom_instance_t *inst)
+{
+ return inst != NULL && !inst->flow_paused && !inst->flow_cancel;
+}
+
+static hybbx_result_t instance_circuit_uplink(baycom_instance_t *inst,
+ const uint8_t *frame,
+ size_t frame_len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ if (inst == NULL || inst->circuit_fd < 0 || frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return HYBBX_OK;
+ }
+
+ hbx_len = hybbx_circuit_encode_ax25(frame, frame_len, HYBBX_CIRCUIT_FLAG_RX,
+ hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+}
+
+static void instance_format_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (addr == NULL || addr->call[0] == '\0') {
+ return;
+ }
+
+ if (addr->ssid > 0u) {
+ unsigned ssid = addr->ssid;
+
+ if (ssid > HYBBX_AX25_SSID_MAX) {
+ ssid = HYBBX_AX25_SSID_MAX;
+ }
+ snprintf(out, out_len, "%s-%u", addr->call, ssid);
+ } else {
+ hybbx_strlcpy(out, addr->call, out_len);
+ }
+}
+
+static void instance_note_rf_activity(const baycom_instance_t *inst)
+{
+ hybbx_circuit_hub_t *hub;
+ const char *link_id;
+
+ if (inst == NULL || g_service == NULL) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(g_service);
+ link_id = inst->config.link_id;
+ if (hub != NULL && link_id != NULL && link_id[0] != '\0') {
+ hybbx_circuit_hub_note_rf_activity(hub, link_id);
+ }
+}
+
+static void instance_log_rf_tx(const baycom_instance_t *inst,
+ hybbx_result_t rc,
+ const hybbx_ax25_path_t *path,
+ size_t payload_len)
+{
+ char src[HYBBX_AX25_CALL_MAX + 8];
+ char dst[HYBBX_AX25_CALL_MAX + 8];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_format_call(path != NULL ? &path->source : NULL, src, sizeof(src));
+ instance_format_call(path != NULL ? &path->dest : NULL, dst, sizeof(dst));
+
+ if (rc == HYBBX_OK) {
+ hybbx_log_stats("[baycom%u] RF TX %s>%s (%zu bytes)",
+ inst->index + 1, src, dst, payload_len);
+ } else {
+ hybbx_log_warn("[baycom%u] RF TX failed %s>%s (%zu bytes, rc=%d)",
+ inst->index + 1, src, dst, payload_len, (int)rc);
+ }
+}
+
+static void instance_circuit_disconnect(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ inst->circuit_reconnect_polls = 0;
+}
+
+static void instance_circuit_reconnect(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_circuit_disconnect(inst);
+ baycom_poll_sleep_ms(250);
+ if (instance_connect_circuit(inst) == HYBBX_OK) {
+ hybbx_log_info("[baycom%u] circuit reconnected", inst->index + 1);
+ }
+}
+
+static void instance_drain_circuit(baycom_instance_t *inst)
+{
+ uint8_t buf[512];
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return;
+ }
+
+ for (;;) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(inst->circuit_fd, buf,
+ sizeof(buf), &read_len);
+
+ if (rc == HYBBX_ERR_IO) {
+ hybbx_log_warn("[baycom%u] circuit disconnected — reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ return;
+ }
+ if (rc != HYBBX_OK || read_len == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&inst->circuit_dec, buf, read_len,
+ on_circuit_downlink, inst);
+ }
+}
+
+static void on_modem_ax25_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ baycom_instance_t *inst = (baycom_instance_t *)userdata;
+
+ if (len == 0 || inst == NULL) {
+ return;
+ }
+
+ (void)instance_circuit_uplink(inst, frame, len);
+}
+
+static void baycom_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t instance_connect_circuit(baycom_instance_t *inst)
+{
+ const char *host;
+ unsigned port;
+ unsigned attempt;
+
+ if (inst == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = inst->config.circuit_host;
+ port = inst->config.circuit_port;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &inst->circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ if (inst->config.link_password != NULL &&
+ inst->config.link_password[0] != '\0') {
+ const char *link_id = inst->config.link_id;
+ const char *link_role = inst->config.link_role;
+ hybbx_circuit_link_qos_t qos;
+
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "baycom-link";
+ }
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ memset(&qos, 0, sizeof(qos));
+ qos.baud = inst->config.radio_baud;
+ qos.duplex = inst->config.full_duplex ? 2 : 1;
+ qos.bandwidth = "low";
+ qos.frequency_mhz = inst->config.frequency_mhz;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ inst->circuit_fd, inst->config.link_password,
+ link_role, link_id, &qos);
+ if (rc != HYBBX_OK) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ baycom_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[baycom%u] linked to internal circuit %s:%u (HBX)",
+ inst->index + 1, host, port);
+ return HYBBX_OK;
+ }
+ baycom_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[baycom%u] could not connect to internal circuit %s:%u",
+ inst->index + 1, host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void instance_on_circuit_flow_ctrl(baycom_instance_t *inst,
+ const uint8_t *payload, size_t len)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ inst->flow_paused = 1;
+ hybbx_log_stats("[baycom%u] circuit flow pause (%s)", inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ inst->flow_paused = 0;
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ hybbx_log_stats("[baycom%u] circuit flow break (%s)", inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ inst->flow_cancel = 1;
+ hybbx_log_warn("[baycom%u] circuit flow cancel (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ inst->flow_paused = 0;
+ hybbx_log_stats("[baycom%u] circuit flow resume (%s)", inst->index + 1, reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ baycom_instance_t *inst = (baycom_instance_t *)userdata;
+
+ (void)flags;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ instance_on_circuit_flow_ctrl(inst, payload, len);
+ return;
+ }
+
+ if (inst->modem == NULL || len == 0 || inst->flow_paused) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_AX25) {
+ hybbx_result_t rc = baycom_modem_send_frame(inst->modem, payload, len);
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path, ui, sizeof(ui));
+
+ if (rc == HYBBX_OK) {
+ instance_note_rf_activity(inst);
+ if (ui_len > 0) {
+ instance_log_rf_tx(inst, rc, &path, ui_len);
+ }
+ } else {
+ hybbx_log_warn("[baycom%u] RF TX frame failed (rc=%d, %zu bytes)",
+ inst->index + 1, (int)rc, len);
+ }
+ }
+}
+
+static void instance_shutdown(baycom_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->modem != NULL) {
+ baycom_modem_close(inst->modem);
+ inst->modem = NULL;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+
+ hybbx_baycom_config_free(&inst->config);
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+}
+
+static void *baycom_poll_thread(void *arg)
+{
+ unsigned i;
+
+ (void)arg;
+
+ while (g_running) {
+ for (i = 0; i < g_instance_count; i++) {
+ baycom_instance_t *inst = &g_instances[i];
+
+ if (inst->modem != NULL) {
+ (void)baycom_modem_poll(inst->modem);
+ }
+
+ instance_drain_circuit(inst);
+
+ if (inst->circuit_fd < 0) {
+ inst->circuit_reconnect_polls++;
+ if (inst->circuit_reconnect_polls >= 50u) {
+ inst->circuit_reconnect_polls = 0;
+ instance_circuit_reconnect(inst);
+ }
+ } else {
+ inst->circuit_reconnect_polls = 0;
+ }
+
+ if (inst->flow_cancel) {
+ inst->flow_cancel = 0;
+ inst->flow_paused = 0;
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ (void)instance_connect_circuit(inst);
+ }
+ }
+
+ baycom_poll_sleep_ms(20);
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t instance_start(baycom_instance_t *inst,
+ const char *config,
+ unsigned index)
+{
+ hybbx_result_t rc;
+
+ if (inst == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+ inst->index = index;
+
+ rc = hybbx_baycom_config_parse(config, &inst->config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom%u] invalid configuration", index + 1);
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ {
+ char msg[512];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ "[baycom%u] backend=%s mode=%s",
+ index + 1,
+ hybbx_baycom_backend_name(inst->config.backend),
+ hybbx_baycom_modem_mode_name(inst->config.mode));
+
+ if (inst->config.backend == HYBBX_BAYCOM_BACKEND_KERNEL) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " interface=%s module=%s iobase=0x%x irq=%u radio_baud=%u",
+ inst->config.interface != NULL ? inst->config.interface : "?",
+ inst->config.kernel_module != NULL ? inst->config.kernel_module
+ : "?",
+ inst->config.iobase, inst->config.irq, inst->config.radio_baud);
+ } else {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " device=%s serial_baud=%u",
+ inst->config.device != NULL ? inst->config.device : "?",
+ inst->config.serial_baud);
+ }
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " circuit=%s:%u (HBX over internal TCP)",
+ inst->config.circuit_host, inst->config.circuit_port);
+ hybbx_log_info("%s", msg);
+ }
+
+ if (inst->config.frequency_mhz != NULL &&
+ inst->config.frequency_mhz[0] != '\0') {
+ hybbx_log_info("[baycom%u] frequency_mhz=%s", index + 1,
+ inst->config.frequency_mhz);
+ }
+
+ rc = instance_connect_circuit(inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ rc = baycom_modem_open(&inst->modem, &inst->config, on_modem_ax25_frame,
+ inst);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom%u] modem open failed (%s)", index + 1,
+ hybbx_result_name(rc));
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t baycom_init(hybbx_service_t *service)
+{
+ g_service = service;
+ g_instance_count = 0;
+ g_running = 0;
+ return HYBBX_OK;
+}
+
+static void baycom_shutdown(void)
+{
+ unsigned i;
+
+ g_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ for (i = 0; i < g_instance_count; i++) {
+ instance_shutdown(&g_instances[i]);
+ }
+ g_instance_count = 0;
+}
+
+static hybbx_result_t baycom_start(const char *config)
+{
+ hybbx_max25_config_t max25;
+ hybbx_result_t rc = HYBBX_OK;
+ unsigned started = 0;
+ const char *cursor;
+ const char *cursor_after_max25;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ baycom_shutdown();
+ g_poll_thread = (pthread_t)0;
+
+ cursor_after_max25 = hybbx_max25_config_skip_prefix(config, &max25);
+ if (max25.check && cursor_after_max25 != config) {
+ rc = hybbx_max25_wait_ready(&max25);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] local RF requires max25d — continuing without BayCom");
+ return HYBBX_OK;
+ }
+ }
+
+ cursor = cursor_after_max25;
+ while (*cursor != '\0' && g_instance_count < HYBBX_BAYCOM_MAX_INSTANCES) {
+ char scratch[4096];
+ const char *sep = strchr(cursor, HYBBX_BAYCOM_INSTANCE_SEP);
+ size_t chunk_len;
+
+ if (sep != NULL) {
+ chunk_len = (size_t)(sep - cursor);
+ } else {
+ chunk_len = strlen(cursor);
+ }
+
+ if (chunk_len >= sizeof(scratch)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, cursor, chunk_len);
+ scratch[chunk_len] = '\0';
+
+ rc = instance_start(&g_instances[g_instance_count], scratch,
+ g_instance_count);
+ if (rc == HYBBX_OK) {
+ g_instance_count++;
+ started++;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ if (started == 0) {
+ /* Soft-fail: keep HyBBX up (TNCs/telnet/…) if BayCom/based open fails. */
+ hybbx_log_warn("[baycom] no instances started (%s) — continuing without BayCom",
+ rc != HYBBX_OK ? hybbx_result_name(rc) : "invalid");
+ return HYBBX_OK;
+ }
+
+ g_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, baycom_poll_thread, NULL) != 0) {
+ baycom_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t baycom_stop(void)
+{
+ baycom_shutdown();
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_baycom = {
+ .name = "baycom",
+ .kind = HYBBX_TRANSPORT_BAYCOM,
+ .version = 1,
+ .init = baycom_init,
+ .shutdown = baycom_shutdown,
+ .start = baycom_start,
+ .stop = baycom_stop,
+ .write = NULL,
+};
diff --git a/plugins/baycom/baycom_config.c b/plugins/baycom/baycom_config.c
new file mode 100644
index 0000000..4e740f1
--- /dev/null
+++ b/plugins/baycom/baycom_config.c
@@ -0,0 +1,395 @@
+#include "hybbx/baycom.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static char *baycom_normalize_kiss_path(const char *device)
+{
+ static const struct {
+ const char *from;
+ const char *to;
+ } map[] = {
+ {"/tmp/bcpr/", "/tmp/max25-bcpr/"},
+ {"/tmp/bcpr", "/tmp/max25-bcpr"},
+ {"/var/run/bcpr/", "/tmp/max25-bcpr/"},
+ {"/var/run/bcpr", "/tmp/max25-bcpr"},
+ };
+ size_t i;
+ size_t flen;
+ char *norm;
+
+ if (device == NULL || device[0] == '\0') {
+ return NULL;
+ }
+
+ for (i = 0; i < sizeof(map) / sizeof(map[0]); i++) {
+ flen = strlen(map[i].from);
+ if (strncmp(device, map[i].from, flen) != 0) {
+ continue;
+ }
+ if (device[flen] != '\0' && device[flen] != '/') {
+ continue;
+ }
+ norm = malloc(strlen(map[i].to) + strlen(device + flen) + 1);
+ if (norm == NULL) {
+ return baycom_strdup(device);
+ }
+ snprintf(norm, strlen(map[i].to) + strlen(device + flen) + 1, "%s%s",
+ map[i].to, device + flen);
+ return norm;
+ }
+
+ return baycom_strdup(device);
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+const char *hybbx_baycom_backend_name(hybbx_baycom_backend_t backend)
+{
+ switch (backend) {
+ case HYBBX_BAYCOM_BACKEND_KISS:
+ return "kiss";
+ case HYBBX_BAYCOM_BACKEND_KERNEL:
+ default:
+ return "kernel";
+ }
+}
+
+const char *hybbx_baycom_modem_mode_name(hybbx_baycom_modem_mode_t mode)
+{
+ switch (mode) {
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ return "ser12*";
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ return "ser12+";
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ return "ser12hdx";
+ case HYBBX_BAYCOM_MODE_PAR96:
+ return "par96";
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ return "par96*";
+ case HYBBX_BAYCOM_MODE_EPP:
+ return "epp";
+ case HYBBX_BAYCOM_MODE_SER12:
+ default:
+ return "ser12";
+ }
+}
+
+static hybbx_baycom_backend_t parse_backend(const char *value)
+{
+ if (value == NULL || str_ieq(value, "kernel") || str_ieq(value, "hdlc") ||
+ str_ieq(value, "prstack") || str_ieq(value, "l2")) {
+ return HYBBX_BAYCOM_BACKEND_KERNEL;
+ }
+
+ if (str_ieq(value, "kiss") || str_ieq(value, "serial")) {
+ return HYBBX_BAYCOM_BACKEND_KISS;
+ }
+
+ return HYBBX_BAYCOM_BACKEND_KERNEL;
+}
+
+static hybbx_baycom_modem_mode_t parse_mode(const char *value)
+{
+ if (value == NULL || str_ieq(value, "ser12")) {
+ return HYBBX_BAYCOM_MODE_SER12;
+ }
+ if (str_ieq(value, "ser12*") || str_ieq(value, "ser12_softdcd")) {
+ return HYBBX_BAYCOM_MODE_SER12_SOFTDCD;
+ }
+ if (str_ieq(value, "ser12+") || str_ieq(value, "ser12_inv")) {
+ return HYBBX_BAYCOM_MODE_SER12_INV;
+ }
+ if (str_ieq(value, "ser12hdx") || str_ieq(value, "ser12_hdx") ||
+ str_ieq(value, "hdx")) {
+ return HYBBX_BAYCOM_MODE_SER12HDX;
+ }
+ if (str_ieq(value, "par96")) {
+ return HYBBX_BAYCOM_MODE_PAR96;
+ }
+ if (str_ieq(value, "par96*") || str_ieq(value, "par96_softdcd")) {
+ return HYBBX_BAYCOM_MODE_PAR96_SOFTDCD;
+ }
+ if (str_ieq(value, "epp") || str_ieq(value, "par97") ||
+ str_ieq(value, "baycom_epp")) {
+ return HYBBX_BAYCOM_MODE_EPP;
+ }
+
+ return HYBBX_BAYCOM_MODE_SER12_SOFTDCD;
+}
+
+void hybbx_baycom_config_free(hybbx_baycom_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->interface);
+ free(config->kernel_module);
+ free(config->device);
+ free(config->mycall);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ free(config->frequency_mhz);
+ memset(config, 0, sizeof(*config));
+}
+
+static void apply_mode_defaults(hybbx_baycom_config_t *out)
+{
+ if (out->interface == NULL) {
+ switch (out->mode) {
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ out->interface = baycom_strdup("bcsh0");
+ break;
+ case HYBBX_BAYCOM_MODE_PAR96:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ out->interface = baycom_strdup("bcp0");
+ break;
+ case HYBBX_BAYCOM_MODE_EPP:
+ out->interface = baycom_strdup("bce0");
+ break;
+ case HYBBX_BAYCOM_MODE_SER12:
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ default:
+ out->interface = baycom_strdup(HYBBX_BAYCOM_DEFAULT_INTERFACE);
+ break;
+ }
+ }
+
+ if (out->kernel_module == NULL) {
+ switch (out->mode) {
+ case HYBBX_BAYCOM_MODE_SER12HDX:
+ out->kernel_module = baycom_strdup("baycom_ser_hdx");
+ break;
+ case HYBBX_BAYCOM_MODE_PAR96:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ out->kernel_module = baycom_strdup("baycom_par");
+ break;
+ case HYBBX_BAYCOM_MODE_EPP:
+ out->kernel_module = baycom_strdup("baycom_epp");
+ break;
+ default:
+ out->kernel_module = baycom_strdup("baycom_ser_fdx");
+ break;
+ }
+ }
+
+ if (out->device == NULL) {
+ if (out->backend == HYBBX_BAYCOM_BACKEND_KISS) {
+ out->device = baycom_strdup("/tmp/max25-bcpr/kiss-bc0");
+ } else {
+ out->device = baycom_strdup("/dev/ttyS0");
+ }
+ }
+}
+
+hybbx_result_t hybbx_baycom_config_parse(const char *config,
+ hybbx_baycom_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_baycom_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_kv(config, "backend", scratch, sizeof(scratch));
+ out->backend = parse_backend(value);
+
+ value = find_kv(config, "mode", scratch, sizeof(scratch));
+ out->mode = parse_mode(value);
+
+ value = find_kv(config, "interface", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->interface = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "kernel_module", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->kernel_module = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "iobase", scratch, sizeof(scratch));
+ out->iobase = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_IOBASE;
+
+ value = find_kv(config, "irq", scratch, sizeof(scratch));
+ out->irq = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_IRQ;
+
+ value = find_kv(config, "radio_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "baud", scratch, sizeof(scratch));
+ }
+ out->radio_baud = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_BAYCOM_DEFAULT_RADIO_BAUD;
+
+ value = find_kv(config, "kernel_autoload", scratch, sizeof(scratch));
+ out->kernel_autoload = value != NULL ? hybbx_parse_bool(value, 0) : 0;
+
+ value = find_kv(config, "txdelay", scratch, sizeof(scratch));
+ out->txdelay = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 30u;
+
+ value = find_kv(config, "txtail", scratch, sizeof(scratch));
+ out->txtail = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 1u;
+
+ value = find_kv(config, "slot", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "slottime", scratch, sizeof(scratch));
+ }
+ out->slot = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 10u;
+
+ value = find_kv(config, "persist", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "ppersist", scratch, sizeof(scratch));
+ }
+ out->persist = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 128u;
+
+ value = find_kv(config, "full_duplex", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->full_duplex = hybbx_parse_bool(value, 0);
+ } else {
+ value = find_kv(config, "duplex", scratch, sizeof(scratch));
+ out->full_duplex = value != NULL && str_ieq(value, "full");
+ }
+
+ value = find_kv(config, "device", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->device = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "serial_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "host_baud", scratch, sizeof(scratch));
+ }
+ out->serial_baud = value != NULL ? (unsigned)strtoul(value, NULL, 0) : 1200u;
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->mycall = baycom_strdup(value);
+ }
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = baycom_strdup(value != NULL ? value : "127.0.0.1");
+
+ value = find_kv(config, "circuit_port", scratch, sizeof(scratch));
+ out->circuit_port = value != NULL ? (unsigned)strtoul(value, NULL, 0)
+ : HYBBX_CIRCUIT_DEFAULT_PORT;
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = baycom_strdup(value != NULL ? value : "baycom-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ out->link_password = baycom_strdup(value != NULL ? value : "");
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = baycom_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->frequency_mhz = baycom_strdup(value);
+ }
+
+ apply_mode_defaults(out);
+
+ if (out->backend == HYBBX_BAYCOM_BACKEND_KISS && out->device != NULL) {
+ char *norm = baycom_normalize_kiss_path(out->device);
+
+ if (norm != NULL) {
+ free(out->device);
+ out->device = norm;
+ }
+ }
+
+ if (out->circuit_host == NULL || out->link_id == NULL) {
+ hybbx_baycom_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/baycom/baycom_kernel.c b/plugins/baycom/baycom_kernel.c
new file mode 100644
index 0000000..0babc08
--- /dev/null
+++ b/plugins/baycom/baycom_kernel.c
@@ -0,0 +1,472 @@
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "baycom_kernel.h"
+
+#include "hybbx/ax25.h"
+#include "hybbx/baycom.h"
+#include "hybbx/log.h"
+#include "hybbx/rf_tx_pace.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(__linux__)
+
+#include <arpa/inet.h>
+#include <fcntl.h>
+#include <net/if.h>
+#include <sys/ioctl.h>
+#include <sys/socket.h>
+#include <linux/if_packet.h>
+#include <linux/if_ether.h>
+#include <linux/hdlcdrv.h>
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+struct baycom_kernel_modem {
+ hybbx_baycom_config_t cfg;
+ baycom_modem_frame_cb frame_cb;
+ void *frame_userdata;
+ int ctl_fd;
+ int pkt_fd;
+ int ifindex;
+};
+
+static void baycom_kernel_mode_string(const hybbx_baycom_config_t *cfg,
+ char *out, size_t out_cap)
+{
+ unsigned rate;
+ char suffix = '\0';
+
+ if (out == NULL || out_cap == 0 || cfg == NULL) {
+ return;
+ }
+
+ switch (cfg->mode) {
+ case HYBBX_BAYCOM_MODE_SER12_SOFTDCD:
+ case HYBBX_BAYCOM_MODE_PAR96_SOFTDCD:
+ suffix = '*';
+ break;
+ case HYBBX_BAYCOM_MODE_SER12_INV:
+ suffix = '+';
+ break;
+ default:
+ break;
+ }
+
+ if (cfg->mode == HYBBX_BAYCOM_MODE_PAR96 ||
+ cfg->mode == HYBBX_BAYCOM_MODE_PAR96_SOFTDCD) {
+ snprintf(out, out_cap, "par96%c", suffix);
+ return;
+ }
+
+ if (cfg->mode == HYBBX_BAYCOM_MODE_EPP) {
+ snprintf(out, out_cap, "epp");
+ return;
+ }
+
+ rate = cfg->radio_baud / 100u;
+ if (rate == 0) {
+ rate = 12;
+ }
+ snprintf(out, out_cap, "ser%u%c", rate, suffix);
+}
+
+static int baycom_kernel_hdlc_ioctl(baycom_kernel_modem_t *modem,
+ struct hdlcdrv_ioctl *hi)
+{
+ struct ifreq ifr;
+
+ if (modem == NULL || hi == NULL || modem->ctl_fd < 0 ||
+ modem->cfg.interface == NULL) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ ifr.ifr_data = (void *)hi;
+
+ if (ioctl(modem->ctl_fd, SIOCDEVPRIVATE, &ifr) < 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+static int baycom_kernel_interface_flags(baycom_kernel_modem_t *modem,
+ unsigned short *flags_out)
+{
+ struct ifreq ifr;
+
+ if (modem == NULL || flags_out == NULL || modem->ctl_fd < 0) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ if (ioctl(modem->ctl_fd, SIOCGIFFLAGS, &ifr) < 0) {
+ return -1;
+ }
+
+ *flags_out = ifr.ifr_flags;
+ return 0;
+}
+
+static int baycom_kernel_interface_up(baycom_kernel_modem_t *modem, int up)
+{
+ struct ifreq ifr;
+ unsigned short flags = 0;
+
+ if (baycom_kernel_interface_flags(modem, &flags) < 0) {
+ return -1;
+ }
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ ifr.ifr_flags = flags;
+ if (up) {
+ ifr.ifr_flags |= IFF_UP;
+ } else {
+ ifr.ifr_flags &= (unsigned short)~IFF_UP;
+ }
+
+ return ioctl(modem->ctl_fd, SIOCSIFFLAGS, &ifr);
+}
+
+static int baycom_kernel_set_modem_params(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETMODEMPAR;
+ hi.data.mp.iobase = (int)modem->cfg.iobase;
+ hi.data.mp.irq = (int)modem->cfg.irq;
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_set_mode(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+ char modestr[32];
+
+ baycom_kernel_mode_string(&modem->cfg, modestr, sizeof(modestr));
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETMODE;
+ strncpy(hi.data.modename, modestr, sizeof(hi.data.modename) - 1);
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_set_channel_params(baycom_kernel_modem_t *modem)
+{
+ struct hdlcdrv_ioctl hi;
+
+ memset(&hi, 0, sizeof(hi));
+ hi.cmd = HDLCDRVCTL_SETCHANNELPAR;
+ hi.data.cp.tx_delay = (int)modem->cfg.txdelay;
+ hi.data.cp.tx_tail = (int)modem->cfg.txtail;
+ hi.data.cp.slottime = (int)modem->cfg.slot;
+ hi.data.cp.ppersist = (int)modem->cfg.persist;
+ hi.data.cp.fulldup = modem->cfg.full_duplex ? 1 : 0;
+
+ return baycom_kernel_hdlc_ioctl(modem, &hi);
+}
+
+static int baycom_kernel_modprobe(const hybbx_baycom_config_t *cfg)
+{
+ char cmd[384];
+ char modestr[32];
+ int rc;
+
+ if (cfg == NULL || cfg->kernel_module == NULL) {
+ return -1;
+ }
+
+ baycom_kernel_mode_string(cfg, modestr, sizeof(modestr));
+ snprintf(cmd, sizeof(cmd),
+ "modprobe %s mode=%s iobase=0x%x irq=%u baud=%u 2>/dev/null",
+ cfg->kernel_module, modestr, cfg->iobase, cfg->irq,
+ cfg->radio_baud);
+
+ rc = system(cmd);
+ if (rc != 0) {
+ hybbx_log_warn("[baycom] modprobe failed (%s)", cfg->kernel_module);
+ return -1;
+ }
+
+ return 0;
+}
+
+static int baycom_kernel_ifindex(baycom_kernel_modem_t *modem)
+{
+ struct ifreq ifr;
+
+ memset(&ifr, 0, sizeof(ifr));
+ strncpy(ifr.ifr_name, modem->cfg.interface, IFNAMSIZ - 1);
+ if (ioctl(modem->ctl_fd, SIOCGIFINDEX, &ifr) < 0) {
+ return -1;
+ }
+
+ modem->ifindex = ifr.ifr_ifindex;
+ return 0;
+}
+
+static int baycom_kernel_open_packet(baycom_kernel_modem_t *modem)
+{
+ struct sockaddr_ll bind_addr;
+
+ modem->pkt_fd = socket(AF_PACKET, SOCK_RAW, htons(ETH_P_AX25));
+ if (modem->pkt_fd < 0) {
+ return -1;
+ }
+
+ if (baycom_kernel_ifindex(modem) < 0) {
+ return -1;
+ }
+
+ memset(&bind_addr, 0, sizeof(bind_addr));
+ bind_addr.sll_family = AF_PACKET;
+ bind_addr.sll_protocol = htons(ETH_P_AX25);
+ bind_addr.sll_ifindex = modem->ifindex;
+
+ if (bind(modem->pkt_fd, (struct sockaddr *)&bind_addr,
+ sizeof(bind_addr)) < 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_kernel_modem_t *km;
+ unsigned short flags = 0;
+
+ if (out == NULL || cfg == NULL || cfg->interface == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ km = calloc(1, sizeof(*km));
+ if (km == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ /* Own string copies only — avoid double-free with caller config. */
+ memset(&km->cfg, 0, sizeof(km->cfg));
+ km->cfg.backend = cfg->backend;
+ km->cfg.mode = cfg->mode;
+ km->cfg.iobase = cfg->iobase;
+ km->cfg.irq = cfg->irq;
+ km->cfg.radio_baud = cfg->radio_baud;
+ km->cfg.kernel_autoload = cfg->kernel_autoload;
+ km->cfg.txdelay = cfg->txdelay;
+ km->cfg.txtail = cfg->txtail;
+ km->cfg.slot = cfg->slot;
+ km->cfg.persist = cfg->persist;
+ km->cfg.full_duplex = cfg->full_duplex;
+ km->cfg.serial_baud = cfg->serial_baud;
+ km->cfg.circuit_port = cfg->circuit_port;
+ km->cfg.interface = baycom_strdup(cfg->interface);
+ km->cfg.kernel_module = baycom_strdup(cfg->kernel_module);
+ km->cfg.device = baycom_strdup(cfg->device);
+ km->cfg.mycall = baycom_strdup(cfg->mycall);
+ km->cfg.circuit_host = baycom_strdup(cfg->circuit_host);
+ km->cfg.link_id = baycom_strdup(cfg->link_id);
+ km->cfg.link_password = baycom_strdup(cfg->link_password);
+ km->cfg.link_role = baycom_strdup(cfg->link_role);
+ km->cfg.frequency_mhz = baycom_strdup(cfg->frequency_mhz);
+ km->frame_cb = cb;
+ km->frame_userdata = userdata;
+ km->ctl_fd = -1;
+ km->pkt_fd = -1;
+
+ if (km->cfg.interface == NULL) {
+ free(km);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (cfg->kernel_autoload) {
+ if (baycom_kernel_modprobe(cfg) < 0) {
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ km->ctl_fd = socket(AF_INET, SOCK_DGRAM, 0);
+ if (km->ctl_fd < 0) {
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+ return HYBBX_ERR_IO;
+ }
+
+ if (baycom_kernel_interface_flags(km, &flags) < 0) {
+ hybbx_log_warn("[baycom] interface %s not found (load %s first)",
+ km->cfg.interface,
+ km->cfg.kernel_module != NULL ? km->cfg.kernel_module
+ : "baycom module");
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ if (!(flags & IFF_UP)) {
+ if (baycom_kernel_set_modem_params(km) < 0) {
+ hybbx_log_warn("[baycom] SETMODEMPAR failed (need CAP_SYS_RAWIO, iface down)");
+ }
+ if (baycom_kernel_set_mode(km) < 0) {
+ hybbx_log_warn("[baycom] SETMODE failed (need CAP_NET_ADMIN, iface down)");
+ }
+ if (baycom_kernel_interface_up(km, 1) < 0) {
+ hybbx_log_warn("[baycom] failed to bring %s up", km->cfg.interface);
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (baycom_kernel_set_channel_params(km) < 0) {
+ hybbx_log_warn("[baycom] channel params ioctl failed");
+ }
+
+ if (baycom_kernel_open_packet(km) < 0) {
+ baycom_kernel_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ *out = km;
+ return HYBBX_OK;
+}
+
+void baycom_kernel_close(baycom_kernel_modem_t *km)
+{
+ if (km == NULL) {
+ return;
+ }
+
+ if (km->pkt_fd >= 0) {
+ close(km->pkt_fd);
+ }
+ if (km->ctl_fd >= 0) {
+ close(km->ctl_fd);
+ }
+
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+}
+
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *km)
+{
+ uint8_t buf[HYBBX_AX25_FRAME_MAX + 64];
+ ssize_t n;
+
+ if (km == NULL || km->pkt_fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ for (;;) {
+ n = recv(km->pkt_fd, buf, sizeof(buf), MSG_DONTWAIT);
+ if (n < 0) {
+ if (errno == EAGAIN
+#if EAGAIN != EWOULDBLOCK
+ || errno == EWOULDBLOCK
+#endif
+ ) {
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_OK;
+ }
+
+ if (km->frame_cb != NULL && (size_t)n > 0) {
+ km->frame_cb(buf, (size_t)n, km->frame_userdata);
+ }
+ }
+}
+
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *km,
+ const uint8_t *frame, size_t len)
+{
+ struct sockaddr_ll dest;
+ ssize_t n;
+
+ if (km == NULL || frame == NULL || len == 0 || km->pkt_fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_pace();
+
+ memset(&dest, 0, sizeof(dest));
+ dest.sll_family = AF_PACKET;
+ dest.sll_protocol = htons(ETH_P_AX25);
+ dest.sll_ifindex = km->ifindex;
+ dest.sll_halen = 0;
+
+ n = sendto(km->pkt_fd, frame, len, 0, (struct sockaddr *)&dest,
+ sizeof(dest));
+ if (n < 0 || (size_t)n != len) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+#else /* !__linux__ */
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ (void)out;
+ (void)cfg;
+ (void)cb;
+ (void)userdata;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void baycom_kernel_close(baycom_kernel_modem_t *modem)
+{
+ (void)modem;
+}
+
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *modem)
+{
+ (void)modem;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *modem,
+ const uint8_t *frame, size_t len)
+{
+ (void)modem;
+ (void)frame;
+ (void)len;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+#endif /* __linux__ */
diff --git a/plugins/baycom/baycom_kernel.h b/plugins/baycom/baycom_kernel.h
new file mode 100644
index 0000000..b35e3b9
--- /dev/null
+++ b/plugins/baycom/baycom_kernel.h
@@ -0,0 +1,26 @@
+#ifndef BAYCOM_KERNEL_H
+#define BAYCOM_KERNEL_H
+
+#include "baycom_modem.h"
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct baycom_kernel_modem baycom_kernel_modem_t;
+
+hybbx_result_t baycom_kernel_open(baycom_kernel_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_kernel_close(baycom_kernel_modem_t *modem);
+hybbx_result_t baycom_kernel_poll(baycom_kernel_modem_t *modem);
+hybbx_result_t baycom_kernel_send_frame(baycom_kernel_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_KERNEL_H */
diff --git a/plugins/baycom/baycom_kiss.c b/plugins/baycom/baycom_kiss.c
new file mode 100644
index 0000000..c66b220
--- /dev/null
+++ b/plugins/baycom/baycom_kiss.c
@@ -0,0 +1,262 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "baycom_kiss.h"
+
+#include "hybbx/kiss.h"
+#include "hybbx/rf_tx_pace.h"
+#include "serial_port.h"
+#include "hybbx/log.h"
+#include "hybbx/limits.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+static hybbx_result_t kiss_wait_device(const char *path)
+{
+ unsigned waited_ms = 0;
+ const unsigned step_ms = 500u;
+ const unsigned max_wait_ms = 30000u;
+
+ if (path == NULL || path[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (access(path, F_OK) == 0) {
+ return HYBBX_OK;
+ }
+
+ while (access(path, F_OK) != 0) {
+ if (waited_ms >= max_wait_ms) {
+ hybbx_log_warn("[baycom] kiss device not ready: %s", path);
+ return HYBBX_ERR_IO;
+ }
+ if (waited_ms == 0u) {
+ hybbx_log_info("[baycom] waiting for kiss device %s (up to %u s)",
+ path, max_wait_ms / 1000u);
+ }
+ usleep((useconds_t)step_ms * 1000u);
+ waited_ms += step_ms;
+ }
+
+ return HYBBX_OK;
+}
+
+struct baycom_kiss_modem {
+ hybbx_baycom_config_t cfg;
+ hybbx_serial_port_t *serial;
+ hybbx_kiss_decoder_t kiss_dec;
+ baycom_modem_frame_cb frame_cb;
+ void *frame_userdata;
+};
+
+static char *baycom_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static void on_kiss_frame(uint8_t port, const uint8_t *frame, size_t len,
+ void *userdata)
+{
+ baycom_kiss_modem_t *km = (baycom_kiss_modem_t *)userdata;
+
+ (void)port;
+
+ if (km == NULL || km->frame_cb == NULL || len == 0) {
+ return;
+ }
+
+ km->frame_cb(frame, len, km->frame_userdata);
+}
+
+static hybbx_result_t kiss_send_param(baycom_kiss_modem_t *km,
+ hybbx_kiss_command_t cmd, uint8_t value)
+{
+ uint8_t out[16];
+ size_t out_len;
+
+ out_len = hybbx_kiss_encode(0, cmd, &value, 1, out, sizeof(out));
+ if (out_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_serial_write(km->serial, out, out_len);
+}
+
+static hybbx_result_t kiss_apply_channel_params(baycom_kiss_modem_t *km)
+{
+ hybbx_result_t rc;
+
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_TXDELAY, (uint8_t)km->cfg.txdelay);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_PERSIST, (uint8_t)km->cfg.persist);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_SLOTTIME, (uint8_t)km->cfg.slot);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ rc = kiss_send_param(km, HYBBX_KISS_CMD_TXTAIL, (uint8_t)km->cfg.txtail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ return kiss_send_param(km, HYBBX_KISS_CMD_FULLDUPLEX,
+ km->cfg.full_duplex ? 1u : 0u);
+}
+
+hybbx_result_t baycom_kiss_open(baycom_kiss_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_kiss_modem_t *km;
+ hybbx_serial_params_t sparams;
+ hybbx_result_t rc;
+
+ if (out == NULL || cfg == NULL || cfg->device == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ km = calloc(1, sizeof(*km));
+ if (km == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ /* Own string copies only — never shallow-copy pointers from caller config
+ * (hybbx_baycom_config_free on close would double-free inst->config). */
+ memset(&km->cfg, 0, sizeof(km->cfg));
+ km->cfg.backend = cfg->backend;
+ km->cfg.mode = cfg->mode;
+ km->cfg.iobase = cfg->iobase;
+ km->cfg.irq = cfg->irq;
+ km->cfg.radio_baud = cfg->radio_baud;
+ km->cfg.kernel_autoload = cfg->kernel_autoload;
+ km->cfg.txdelay = cfg->txdelay;
+ km->cfg.txtail = cfg->txtail;
+ km->cfg.slot = cfg->slot;
+ km->cfg.persist = cfg->persist;
+ km->cfg.full_duplex = cfg->full_duplex;
+ km->cfg.serial_baud = cfg->serial_baud;
+ km->cfg.circuit_port = cfg->circuit_port;
+ km->cfg.device = baycom_strdup(cfg->device);
+ km->cfg.mycall = baycom_strdup(cfg->mycall);
+ km->cfg.circuit_host = baycom_strdup(cfg->circuit_host);
+ km->cfg.link_id = baycom_strdup(cfg->link_id);
+ km->cfg.link_password = baycom_strdup(cfg->link_password);
+ km->cfg.link_role = baycom_strdup(cfg->link_role);
+ km->cfg.frequency_mhz = baycom_strdup(cfg->frequency_mhz);
+ km->frame_cb = cb;
+ km->frame_userdata = userdata;
+ hybbx_kiss_decoder_init(&km->kiss_dec);
+
+ hybbx_serial_params_default(&sparams, cfg->serial_baud);
+ sparams.data_bits = 8;
+ sparams.parity = HYBBX_SERIAL_PARITY_NONE;
+ sparams.stop_bits = 1;
+ sparams.assert_modem_lines = 0;
+
+ rc = kiss_wait_device(km->cfg.device);
+ if (rc != HYBBX_OK) {
+ baycom_kiss_close(km);
+ return rc;
+ }
+
+ if (access(km->cfg.device, R_OK | W_OK) != 0) {
+ hybbx_log_warn("[baycom] kiss device not readable/writable: %s (%s; user in dialout?)",
+ km->cfg.device, strerror(errno));
+ baycom_kiss_close(km);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_serial_open(&km->serial, km->cfg.device, &sparams);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] kiss serial open failed: %s (see [serial] log)",
+ km->cfg.device);
+ baycom_kiss_close(km);
+ return rc;
+ }
+
+ rc = kiss_apply_channel_params(km);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[baycom] kiss channel params failed");
+ }
+
+ *out = km;
+ return HYBBX_OK;
+}
+
+void baycom_kiss_close(baycom_kiss_modem_t *km)
+{
+ if (km == NULL) {
+ return;
+ }
+
+ if (km->serial != NULL) {
+ hybbx_serial_close(km->serial);
+ }
+
+ hybbx_baycom_config_free(&km->cfg);
+ free(km);
+}
+
+hybbx_result_t baycom_kiss_poll(baycom_kiss_modem_t *km)
+{
+ uint8_t buf[256];
+ size_t read_len = 0;
+ hybbx_result_t rc;
+
+ if (km == NULL || km->serial == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = hybbx_serial_read(km->serial, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (read_len > 0) {
+ hybbx_kiss_decoder_feed(&km->kiss_dec, buf, read_len, on_kiss_frame, km);
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t baycom_kiss_send_frame(baycom_kiss_modem_t *km,
+ const uint8_t *frame, size_t len)
+{
+ uint8_t out[HYBBX_KISS_MAX_FRAME + 8];
+ size_t out_len;
+
+ if (km == NULL || km->serial == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_burst_guard();
+
+ out_len = hybbx_kiss_encode(0, HYBBX_KISS_CMD_DATA, frame, len,
+ out, sizeof(out));
+ if (out_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_serial_write(km->serial, out, out_len);
+}
diff --git a/plugins/baycom/baycom_kiss.h b/plugins/baycom/baycom_kiss.h
new file mode 100644
index 0000000..49c7b36
--- /dev/null
+++ b/plugins/baycom/baycom_kiss.h
@@ -0,0 +1,26 @@
+#ifndef BAYCOM_KISS_H
+#define BAYCOM_KISS_H
+
+#include "baycom_modem.h"
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct baycom_kiss_modem baycom_kiss_modem_t;
+
+hybbx_result_t baycom_kiss_open(baycom_kiss_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_kiss_close(baycom_kiss_modem_t *modem);
+hybbx_result_t baycom_kiss_poll(baycom_kiss_modem_t *modem);
+hybbx_result_t baycom_kiss_send_frame(baycom_kiss_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_KISS_H */
diff --git a/plugins/baycom/baycom_modem.c b/plugins/baycom/baycom_modem.c
new file mode 100644
index 0000000..e4e332f
--- /dev/null
+++ b/plugins/baycom/baycom_modem.c
@@ -0,0 +1,94 @@
+#include "baycom_modem.h"
+#include "baycom_kernel.h"
+#include "baycom_kiss.h"
+
+#include <stdlib.h>
+
+struct baycom_modem {
+ hybbx_baycom_backend_t backend;
+ baycom_kernel_modem_t *kernel;
+ baycom_kiss_modem_t *kiss;
+};
+
+hybbx_result_t baycom_modem_open(baycom_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata)
+{
+ baycom_modem_t *modem;
+ hybbx_result_t rc;
+
+ if (out == NULL || cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *out = NULL;
+ modem = calloc(1, sizeof(*modem));
+ if (modem == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ modem->backend = cfg->backend;
+
+ if (cfg->backend == HYBBX_BAYCOM_BACKEND_KISS) {
+ rc = baycom_kiss_open(&modem->kiss, cfg, cb, userdata);
+ } else {
+ rc = baycom_kernel_open(&modem->kernel, cfg, cb, userdata);
+ }
+
+ if (rc != HYBBX_OK) {
+ baycom_modem_close(modem);
+ return rc;
+ }
+
+ *out = modem;
+ return HYBBX_OK;
+}
+
+void baycom_modem_close(baycom_modem_t *modem)
+{
+ if (modem == NULL) {
+ return;
+ }
+
+ if (modem->kernel != NULL) {
+ baycom_kernel_close(modem->kernel);
+ }
+ if (modem->kiss != NULL) {
+ baycom_kiss_close(modem->kiss);
+ }
+
+ free(modem);
+}
+
+hybbx_result_t baycom_modem_poll(baycom_modem_t *modem)
+{
+ if (modem == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (modem->kiss != NULL) {
+ return baycom_kiss_poll(modem->kiss);
+ }
+ if (modem->kernel != NULL) {
+ return baycom_kernel_poll(modem->kernel);
+ }
+
+ return HYBBX_ERR_IO;
+}
+
+hybbx_result_t baycom_modem_send_frame(baycom_modem_t *modem,
+ const uint8_t *frame, size_t len)
+{
+ if (modem == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (modem->kiss != NULL) {
+ return baycom_kiss_send_frame(modem->kiss, frame, len);
+ }
+ if (modem->kernel != NULL) {
+ return baycom_kernel_send_frame(modem->kernel, frame, len);
+ }
+
+ return HYBBX_ERR_IO;
+}
diff --git a/plugins/baycom/baycom_modem.h b/plugins/baycom/baycom_modem.h
new file mode 100644
index 0000000..2365636
--- /dev/null
+++ b/plugins/baycom/baycom_modem.h
@@ -0,0 +1,31 @@
+#ifndef BAYCOM_MODEM_H
+#define BAYCOM_MODEM_H
+
+#include "hybbx/baycom.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef void (*baycom_modem_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+typedef struct baycom_modem baycom_modem_t;
+
+hybbx_result_t baycom_modem_open(baycom_modem_t **out,
+ const hybbx_baycom_config_t *cfg,
+ baycom_modem_frame_cb cb, void *userdata);
+void baycom_modem_close(baycom_modem_t *modem);
+hybbx_result_t baycom_modem_poll(baycom_modem_t *modem);
+hybbx_result_t baycom_modem_send_frame(baycom_modem_t *modem,
+ const uint8_t *frame, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* BAYCOM_MODEM_H */
diff --git a/plugins/crdop/CMakeLists.txt b/plugins/crdop/CMakeLists.txt
new file mode 100644
index 0000000..03c4f1d
--- /dev/null
+++ b/plugins/crdop/CMakeLists.txt
@@ -0,0 +1,26 @@
+add_library(hybbx_plugin_crdop STATIC
+ crdop.c
+ crdop_config.c
+)
+
+target_include_directories(hybbx_plugin_crdop
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_SOURCE_DIR}/plugins/ardop
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_crdop
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_crdop PRIVATE hybbx_ardop_common)
+hybbx_link_plugin_instances(hybbx_plugin_crdop HYBBX_HAVE_PLUGIN_CRDOP)
+hybbx_apply_hardening(hybbx_plugin_crdop)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_crdop
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/crdop/crdop.c b/plugins/crdop/crdop.c
new file mode 100644
index 0000000..d2c6f3d
--- /dev/null
+++ b/plugins/crdop/crdop.c
@@ -0,0 +1,51 @@
+/*
+ * crdop — CB Radio Digital Open Protocol host-client (external ARDOPC/ardopcf).
+ * INI: [transport.crdop] or [transport.crdopN]. No embedded modem in HyBBX.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+
+#include "../ardop/ardop_link.h"
+
+static const ardop_link_plugin_t g_crdop_link = {
+ .name = "crdop",
+ .kind = HYBBX_TRANSPORT_CRDOP,
+ .default_link_id = "crdop-link",
+ .modem_hint = "CRDOPC (ARDOP-compatible host TCP)",
+ .parse_config = (ardop_link_config_fn)hybbx_crdop_config_parse,
+ .config_free = (void (*)(void *))hybbx_ardop_config_free,
+ .config_size = sizeof(hybbx_ardop_config_t),
+};
+
+static hybbx_result_t crdop_init(hybbx_service_t *service)
+{
+ return ardop_link_init(service, &g_crdop_link);
+}
+
+static void crdop_shutdown(void)
+{
+ ardop_link_shutdown(&g_crdop_link);
+}
+
+static hybbx_result_t crdop_start(const char *config)
+{
+ return ardop_link_start(&g_crdop_link, config);
+}
+
+static hybbx_result_t crdop_stop(void)
+{
+ return ardop_link_stop(&g_crdop_link);
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_crdop = {
+ .name = "crdop",
+ .kind = HYBBX_TRANSPORT_CRDOP,
+ .version = 1,
+ .init = crdop_init,
+ .shutdown = crdop_shutdown,
+ .start = crdop_start,
+ .stop = crdop_stop,
+ .write = NULL,
+};
diff --git a/plugins/crdop/crdop_config.c b/plugins/crdop/crdop_config.c
new file mode 100644
index 0000000..e1393bd
--- /dev/null
+++ b/plugins/crdop/crdop_config.c
@@ -0,0 +1,186 @@
+#include "hybbx/ardop.h"
+#include "hybbx/crdop.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static char *crdop_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+static const char *find_host(const char *config, char *scratch, size_t len)
+{
+ const char *value;
+
+ value = find_kv(config, "modem_host", scratch, len);
+ if (value != NULL && value[0] != '\0') {
+ return value;
+ }
+ return find_kv(config, "ardop_host", scratch, len);
+}
+
+static const char *find_port_str(const char *config, char *scratch, size_t len)
+{
+ const char *value;
+
+ value = find_kv(config, "modem_port", scratch, len);
+ if (value != NULL && value[0] != '\0') {
+ return value;
+ }
+ return find_kv(config, "ardop_port", scratch, len);
+}
+
+hybbx_result_t hybbx_crdop_config_parse(const char *config,
+ hybbx_ardop_config_t *out)
+{
+ char scratch[128];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ardop_config_free(out);
+
+ if (config == NULL) {
+ config = "";
+ }
+
+ value = find_host(config, scratch, sizeof(scratch));
+ out->ardop_host = crdop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->ardop_port = (unsigned)strtoul(
+ find_port_str(config, scratch, sizeof(scratch)) != NULL ?
+ scratch : "8515", NULL, 10);
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ out->mycall = crdop_strdup(value != NULL ? value : "NOCALL");
+
+ out->radio_profile = HYBBX_CRDOP_PROFILE_CB;
+
+ value = find_kv(config, "arq_bandwidth", scratch, sizeof(scratch));
+ out->arq_bandwidth = crdop_strdup(
+ value != NULL ? value :
+ hybbx_crdop_default_arq_bandwidth(HYBBX_CRDOP_PROFILE_CB));
+
+ value = find_kv(config, "peer_call", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->peer_call = crdop_strdup(value);
+ }
+
+ value = find_kv(config, "listen", scratch, sizeof(scratch));
+ out->listen = value == NULL || str_ieq(value, "yes") || str_ieq(value, "1") ||
+ str_ieq(value, "true");
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ out->circuit_host = crdop_strdup(value != NULL ? value : "127.0.0.1");
+
+ out->circuit_port = (unsigned)strtoul(
+ find_kv(config, "circuit_port", scratch, sizeof(scratch)) != NULL ?
+ scratch : "7323", NULL, 10);
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ out->link_id = crdop_strdup(value != NULL ? value : "crdop-link");
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = crdop_strdup(value);
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ out->link_role = crdop_strdup(value != NULL ? value : "link");
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = strtod(value, NULL);
+ }
+
+ if (hybbx_crdop_bandwidth_exceeds_cb(out->arq_bandwidth)) {
+ hybbx_log_warn("[crdop] warning: arq_bandwidth=%s high for CB profile",
+ out->arq_bandwidth);
+ }
+
+ if (out->ardop_host == NULL || out->mycall == NULL ||
+ out->arq_bandwidth == NULL || out->circuit_host == NULL ||
+ out->link_id == NULL || out->link_role == NULL) {
+ hybbx_ardop_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/mains_proxy/CMakeLists.txt b/plugins/mains_proxy/CMakeLists.txt
new file mode 100644
index 0000000..0d2922e
--- /dev/null
+++ b/plugins/mains_proxy/CMakeLists.txt
@@ -0,0 +1,23 @@
+add_library(hybbx_plugin_mains_proxy STATIC
+ mains_proxy.c
+)
+
+target_include_directories(hybbx_plugin_mains_proxy
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_mains_proxy
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_mains_proxy PRIVATE hybbx_core)
+hybbx_link_plugin_instances(hybbx_plugin_mains_proxy HYBBX_HAVE_PLUGIN_MAINS_PROXY)
+
+hybbx_apply_hardening(hybbx_plugin_mains_proxy)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_mains_proxy
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/mains_proxy/mains_proxy.c b/plugins/mains_proxy/mains_proxy.c
new file mode 100644
index 0000000..8b6edff
--- /dev/null
+++ b/plugins/mains_proxy/mains_proxy.c
@@ -0,0 +1,137 @@
+/*
+ * mains_proxy — Main-to-Main mesh proxy.
+ *
+ * Peers attach via HBX/Circuit only (circuit_host, circuit_port, link_id).
+ * Service linking only — proxymail and proxychat payloads; no user data.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/mains_proxy.h"
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+typedef struct mains_proxy_plugin_state {
+ hybbx_service_t *service;
+ hybbx_mains_proxy_mesh_t mesh;
+ int loaded;
+} mains_proxy_plugin_state_t;
+
+static mains_proxy_plugin_state_t g_state;
+
+static hybbx_result_t mains_proxy_parse_instances(const char *config,
+ hybbx_mains_proxy_mesh_t *mesh)
+{
+ const char *cursor;
+ const char *end;
+ char section_buf[HYBBX_CONFIG_LINE_MAX];
+ unsigned count = 0;
+
+ if (mesh == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_mains_proxy_mesh_init(mesh);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ cursor = config;
+ while (*cursor != '\0' && count < HYBBX_MAINS_PROXY_MAX_PEERS) {
+ size_t len;
+
+ end = strchr(cursor, HYBBX_MAINS_PROXY_INSTANCE_SEP);
+ if (end == NULL) {
+ len = strlen(cursor);
+ } else {
+ len = (size_t)(end - cursor);
+ }
+
+ if (len >= sizeof(section_buf)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(section_buf, cursor, len);
+ section_buf[len] = '\0';
+
+ if (hybbx_mains_proxy_peer_parse(section_buf, &mesh->peers[count]) !=
+ HYBBX_OK) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (mesh->peers[count].enabled) {
+ count++;
+ }
+
+ if (end == NULL) {
+ break;
+ }
+ cursor = end + 1;
+ }
+
+ mesh->peer_count = count;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t mains_proxy_init(hybbx_service_t *service)
+{
+ memset(&g_state, 0, sizeof(g_state));
+ g_state.service = service;
+ hybbx_mains_proxy_mesh_init(&g_state.mesh);
+ g_state.loaded = 1;
+ return HYBBX_OK;
+}
+
+static void mains_proxy_shutdown(void)
+{
+ hybbx_mains_proxy_mesh_stop(&g_state.mesh);
+ memset(&g_state, 0, sizeof(g_state));
+}
+
+static hybbx_result_t mains_proxy_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (!g_state.loaded) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = mains_proxy_parse_instances(config, &g_state.mesh);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[mains_proxy] invalid peer configuration");
+ return rc;
+ }
+
+ return hybbx_mains_proxy_mesh_start(g_state.service, &g_state.mesh);
+}
+
+static hybbx_result_t mains_proxy_stop(void)
+{
+ hybbx_mains_proxy_mesh_stop(&g_state.mesh);
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_mains_proxy = {
+ .name = "mains_proxy",
+ .kind = HYBBX_TRANSPORT_MAINS_PROXY,
+ .version = 1,
+ .init = mains_proxy_init,
+ .shutdown = mains_proxy_shutdown,
+ .start = mains_proxy_start,
+ .stop = mains_proxy_stop,
+ .write = NULL,
+};
+
+void hybbx_mains_proxy_plugin_tick(void)
+{
+ if (!g_state.loaded || !g_state.mesh.running) {
+ return;
+ }
+
+ hybbx_mains_proxy_mesh_tick(g_state.service, &g_state.mesh);
+} \ No newline at end of file
diff --git a/plugins/packet_radio/CMakeLists.txt b/plugins/packet_radio/CMakeLists.txt
new file mode 100644
index 0000000..1dc07a5
--- /dev/null
+++ b/plugins/packet_radio/CMakeLists.txt
@@ -0,0 +1,36 @@
+add_library(hybbx_plugin_packet_radio STATIC
+ packet_radio.c
+ packet_radio_config.c
+ ax25.c
+ kiss.c
+ serial_port.c
+ sixpack.c
+ tnc.c
+ tnc_host.c
+ tnc_profiles.c
+)
+
+target_include_directories(hybbx_plugin_packet_radio
+ PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+)
+
+target_compile_definitions(hybbx_plugin_packet_radio
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(Threads REQUIRED)
+target_link_libraries(hybbx_plugin_packet_radio PRIVATE hybbx_core Threads::Threads)
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_link_libraries(hybbx_plugin_packet_radio PRIVATE amiga)
+endif()
+hybbx_link_plugin_instances(hybbx_plugin_packet_radio HYBBX_HAVE_PLUGIN_PACKET_RADIO)
+hybbx_apply_hardening(hybbx_plugin_packet_radio)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_packet_radio
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/packet_radio/ax25.c b/plugins/packet_radio/ax25.c
new file mode 100644
index 0000000..5cbcfd8
--- /dev/null
+++ b/plugins/packet_radio/ax25.c
@@ -0,0 +1,250 @@
+#include "hybbx/ax25.h"
+
+#include <ctype.h>
+#include <stdlib.h>
+#include <string.h>
+
+static void address_upper(char *call)
+{
+ size_t i;
+
+ for (i = 0; call[i] != '\0'; i++) {
+ call[i] = (char)toupper((unsigned char)call[i]);
+ }
+}
+
+hybbx_result_t hybbx_ax25_address_parse(const char *text,
+ hybbx_ax25_address_t *out)
+{
+ const char *dash;
+ size_t call_len;
+ char *end = NULL;
+ unsigned long ssid;
+
+ if (text == NULL || out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+ dash = strchr(text, '-');
+ if (dash != NULL) {
+ call_len = (size_t)(dash - text);
+ ssid = strtoul(dash + 1, &end, 10);
+ if (end == dash + 1 || ssid > HYBBX_AX25_SSID_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+ out->ssid = (unsigned)ssid;
+ } else {
+ call_len = strlen(text);
+ }
+
+ if (call_len == 0 || call_len > HYBBX_AX25_CALL_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(out->call, text, call_len);
+ out->call[call_len] = '\0';
+ address_upper(out->call);
+ return HYBBX_OK;
+}
+
+void hybbx_ax25_encode_address(uint8_t *out, const hybbx_ax25_address_t *addr,
+ int last)
+{
+ size_t i;
+ char padded[HYBBX_AX25_CALL_MAX];
+
+ memset(padded, ' ', sizeof(padded));
+ for (i = 0; i < HYBBX_AX25_CALL_MAX && addr->call[i] != '\0'; i++) {
+ padded[i] = addr->call[i];
+ }
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ out[i] = (uint8_t)((unsigned char)padded[i] << 1);
+ }
+
+ out[6] = (uint8_t)(((addr->ssid & 0x0Fu) << 1) | 0x60u);
+ if (last) {
+ out[6] |= 0x01u;
+ }
+}
+
+uint16_t hybbx_ax25_crc(const uint8_t *data, size_t len)
+{
+ uint16_t crc = 0xFFFFu;
+ size_t i;
+ int bit;
+
+ for (i = 0; i < len; i++) {
+ crc ^= (uint16_t)data[i] << 8;
+ for (bit = 0; bit < 8; bit++) {
+ if (crc & 0x8000u) {
+ crc = (uint16_t)((crc << 1) ^ 0x1021u);
+ } else {
+ crc = (uint16_t)(crc << 1);
+ }
+ }
+ }
+
+ return crc;
+}
+
+static size_t append_addresses(const hybbx_ax25_path_t *path,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ unsigned i;
+
+ if (out_cap < (size_t)(2 + path->digi_count) * HYBBX_AX25_ADDR_ENCODED) {
+ return 0;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->dest, 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+
+ for (i = 0; i < path->digi_count; i++) {
+ hybbx_ax25_encode_address(out + pos, &path->digi[i], 0);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ }
+
+ hybbx_ax25_encode_address(out + pos, &path->source, 1);
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ return pos;
+}
+
+size_t hybbx_ax25_build_ui(const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos;
+ uint16_t crc;
+
+ if (path == NULL || out == NULL ||
+ (payload_len > 0 && payload == NULL)) {
+ return 0;
+ }
+
+ if (payload_len + 32 > out_cap || payload_len > HYBBX_AX25_FRAME_MAX) {
+ return 0;
+ }
+
+ pos = append_addresses(path, out, out_cap);
+ if (pos + 4 + payload_len > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_AX25_CTRL_UI;
+ out[pos++] = HYBBX_AX25_PID_NOL3;
+
+ if (payload_len > 0) {
+ memcpy(out + pos, payload, payload_len);
+ pos += payload_len;
+ }
+
+ crc = hybbx_ax25_crc(out, pos);
+ out[pos++] = (uint8_t)(crc & 0xFFu);
+ out[pos++] = (uint8_t)((crc >> 8) & 0xFFu);
+ return pos;
+}
+
+static void decode_address(const uint8_t *in, hybbx_ax25_address_t *out)
+{
+ size_t i;
+
+ for (i = 0; i < HYBBX_AX25_CALL_MAX; i++) {
+ char ch = (char)((in[i] >> 1) & 0x7Fu);
+ out->call[i] = ch == ' ' ? '\0' : ch;
+ }
+ out->call[HYBBX_AX25_CALL_MAX] = '\0';
+
+ for (i = HYBBX_AX25_CALL_MAX; i > 0; i--) {
+ if (out->call[i - 1] != '\0') {
+ break;
+ }
+ }
+ out->call[i] = '\0';
+ out->ssid = (unsigned)((in[6] >> 1) & 0x0Fu);
+}
+
+size_t hybbx_ax25_parse_ui(const uint8_t *frame, size_t frame_len,
+ hybbx_ax25_path_t *path,
+ uint8_t *payload, size_t payload_cap)
+{
+ size_t pos = 0;
+ unsigned digi = 0;
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (frame == NULL || frame_len < 16) {
+ return 0;
+ }
+
+ if (path != NULL) {
+ memset(path, 0, sizeof(*path));
+ }
+
+ while (pos + HYBBX_AX25_ADDR_ENCODED <= frame_len) {
+ int last = frame[pos + 6] & 0x01;
+ hybbx_ax25_address_t tmp;
+
+ decode_address(frame + pos, &tmp);
+ if (path != NULL) {
+ if (digi == 0) {
+ path->dest = tmp;
+ } else if (!last) {
+ if (digi - 1 < HYBBX_AX25_MAX_DIGI) {
+ path->digi[digi - 1] = tmp;
+ path->digi_count = digi;
+ }
+ } else {
+ path->source = tmp;
+ }
+ }
+
+ pos += HYBBX_AX25_ADDR_ENCODED;
+ digi++;
+
+ if (last) {
+ break;
+ }
+
+ if (digi > HYBBX_AX25_MAX_DIGI + 1) {
+ return 0;
+ }
+ }
+
+ if (pos + 4 > frame_len) {
+ return 0;
+ }
+
+ if (frame[pos] != HYBBX_AX25_CTRL_UI) {
+ return 0;
+ }
+
+ pos += 2; /* control + PID */
+
+ if (frame_len < pos + 2) {
+ return 0;
+ }
+
+ crc_rx = (uint16_t)frame[frame_len - 2] |
+ ((uint16_t)frame[frame_len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, frame_len - 2);
+ if (crc_rx != crc_calc) {
+ return 0;
+ }
+
+ {
+ size_t payload_len = frame_len - pos - 2;
+
+ if (payload_len > payload_cap) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload != NULL) {
+ memcpy(payload, frame + pos, payload_len);
+ }
+
+ return payload_len;
+ }
+}
diff --git a/plugins/packet_radio/kiss.c b/plugins/packet_radio/kiss.c
new file mode 100644
index 0000000..a075442
--- /dev/null
+++ b/plugins/packet_radio/kiss.c
@@ -0,0 +1,152 @@
+#include "hybbx/kiss.h"
+
+#include <string.h>
+
+void hybbx_kiss_decoder_init(hybbx_kiss_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void deliver_frame(hybbx_kiss_decoder_t *dec,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ uint8_t port;
+ const uint8_t *payload;
+ size_t payload_len;
+
+ if (dec->len < 1) {
+ return;
+ }
+
+ port = (uint8_t)((dec->buf[0] >> 4) & 0x0Fu);
+ payload = dec->buf + 1;
+ payload_len = dec->len - 1;
+
+ if (cb != NULL) {
+ cb(port, payload, payload_len, userdata);
+ }
+}
+
+void hybbx_kiss_decoder_feed(hybbx_kiss_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_kiss_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t byte = data[i];
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (dec->in_frame && dec->len > 0) {
+ deliver_frame(dec, cb, userdata);
+ }
+ dec->in_frame = 1;
+ dec->escape = 0;
+ dec->len = 0;
+ continue;
+ }
+
+ if (!dec->in_frame) {
+ continue;
+ }
+
+ if (dec->escape) {
+ if (byte == HYBBX_KISS_TFEND) {
+ byte = HYBBX_KISS_FEND;
+ } else if (byte == HYBBX_KISS_TFESC) {
+ byte = HYBBX_KISS_FESC;
+ }
+ dec->escape = 0;
+ } else if (byte == HYBBX_KISS_FESC) {
+ dec->escape = 1;
+ continue;
+ }
+
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ dec->len = 0;
+ dec->in_frame = 0;
+ continue;
+ }
+
+ dec->buf[dec->len++] = byte;
+ }
+}
+
+static size_t kiss_write_byte(uint8_t byte, uint8_t *out, size_t out_cap, size_t pos)
+{
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ if (byte == HYBBX_KISS_FEND) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFEND;
+ return pos;
+ }
+
+ if (byte == HYBBX_KISS_FESC) {
+ if (pos + 2 > out_cap) {
+ return 0;
+ }
+ out[pos++] = HYBBX_KISS_FESC;
+ out[pos++] = HYBBX_KISS_TFESC;
+ return pos;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_kiss_encode(uint8_t port, hybbx_kiss_command_t cmd,
+ const uint8_t *payload, size_t payload_len,
+ uint8_t *out, size_t out_cap)
+{
+ size_t pos = 0;
+ uint8_t cmd_byte;
+ size_t i;
+
+ if (out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ if (payload_len > 0 && payload == NULL) {
+ return 0;
+ }
+
+ if (payload_len + 4 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+
+ cmd_byte = (uint8_t)(((port & 0x0Fu) << 4) | ((uint8_t)cmd & 0x0Fu));
+ pos = kiss_write_byte(cmd_byte, out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+
+ for (i = 0; i < payload_len; i++) {
+ pos = kiss_write_byte(payload[i], out, out_cap, pos);
+ if (pos == 0) {
+ return 0;
+ }
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = HYBBX_KISS_FEND;
+ return pos;
+}
diff --git a/plugins/packet_radio/packet_radio.c b/plugins/packet_radio/packet_radio.c
new file mode 100644
index 0000000..e8952ae
--- /dev/null
+++ b/plugins/packet_radio/packet_radio.c
@@ -0,0 +1,1005 @@
+/*
+ * packet_radio — link/repeater edge adapter: serial TNC, KISS/AX.25 → HBX client.
+ * INI: [transport.packet_radio] or [transport.packet_radioN]. See share/hybbx.ini.example.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/broadcast.h"
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit_balance.h"
+#include "hybbx/traffic.h"
+#include "hybbx/limits.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/ax25.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+#include "hybbx/max25.h"
+
+#include <pthread.h>
+#include <stdio.h>
+#include <string.h>
+#include <stdlib.h>
+#include <unistd.h>
+
+#if defined(_WIN32)
+#include <windows.h>
+#else
+#include <errno.h>
+#include <poll.h>
+#include <sys/select.h>
+#endif
+
+#define PACKET_RADIO_POLL_MS 20u
+
+static hybbx_service_t *g_service;
+
+typedef struct packet_radio_instance {
+ hybbx_packet_radio_config_t config;
+ hybbx_tnc_t *tnc;
+ int circuit_fd;
+ hybbx_circuit_decoder_t circuit_dec;
+ volatile int flow_paused;
+ volatile int flow_cancel;
+ unsigned index;
+ unsigned circuit_reconnect_polls;
+} packet_radio_instance_t;
+
+static packet_radio_instance_t g_instances[HYBBX_PACKET_RADIO_MAX_INSTANCES];
+static unsigned g_instance_count;
+static pthread_t g_poll_thread;
+static volatile int g_radio_running;
+static int g_poll_thread_started;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_packet_radio;
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst);
+
+static unsigned packet_radio_count_local_edges(const char *config)
+{
+ char scratch[4096];
+ const char *cursor = config;
+ unsigned count = 0;
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ return count;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+
+ if (hybbx_packet_radio_section_is_local_edge(scratch)) {
+ count++;
+ }
+
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ return count;
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst);
+static void packet_radio_poll_sleep_ms(unsigned ms);
+
+static const char *device_type_name(hybbx_packet_radio_device_type_t type)
+{
+ return type == HYBBX_PACKET_RADIO_DEVICE_SERIAL ? "serial" : "usb";
+}
+
+static const char *protocol_name(hybbx_packet_radio_protocol_t proto)
+{
+ switch (proto) {
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ return "hostmode";
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ return "6pack";
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ default:
+ return "kiss";
+ }
+}
+
+static const char *tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+static int instance_circuit_uplink_allowed(const packet_radio_instance_t *inst)
+{
+ return inst != NULL && !inst->flow_paused && !inst->flow_cancel;
+}
+
+static int packet_radio_source_banned(const hybbx_ax25_path_t *path)
+{
+ char raw[HYBBX_CALLID_MAX];
+ char norm[HYBBX_CALLID_MAX];
+
+ if (path == NULL || path->source.call[0] == '\0') {
+ return 0;
+ }
+
+ if (path->source.ssid > 0u) {
+ snprintf(raw, sizeof(raw), "%s-%u", path->source.call,
+ path->source.ssid);
+ } else {
+ hybbx_strlcpy(raw, path->source.call, sizeof(raw));
+ }
+
+ if (!hybbx_security_callid_normalize(raw, norm, sizeof(norm))) {
+ return 0;
+ }
+
+ return hybbx_security_ban_callid_is_banned(norm);
+}
+
+static int packet_radio_frame_source_banned(const uint8_t *frame, size_t len)
+{
+ hybbx_ax25_path_t path;
+ uint8_t scratch[HYBBX_AX25_FRAME_MAX];
+
+ if (frame == NULL || len == 0) {
+ return 0;
+ }
+
+ if (hybbx_ax25_parse_ui(frame, len, &path, scratch, sizeof(scratch)) == 0) {
+ return 0;
+ }
+
+ return packet_radio_source_banned(&path);
+}
+
+static hybbx_result_t instance_circuit_uplink(packet_radio_instance_t *inst,
+ const uint8_t *frame,
+ size_t frame_len)
+{
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+ uint16_t flags = HYBBX_CIRCUIT_FLAG_RX;
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return HYBBX_OK;
+ }
+
+ if (hybbx_packet_radio_modulation_is_g3ruh(inst->config.params.modulation)) {
+ flags |= HYBBX_CIRCUIT_FLAG_G3RUH_FSK;
+ }
+
+ hbx_len = hybbx_circuit_encode_ax25(frame, frame_len, flags,
+ hbx, sizeof(hbx));
+ if (hbx_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+}
+
+static void on_tnc_ax25_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (len == 0 || inst == NULL) {
+ return;
+ }
+
+ if (packet_radio_frame_source_banned(frame, len)) {
+ return;
+ }
+
+ instance_note_rf_activity(inst);
+ (void)instance_circuit_uplink(inst, frame, len);
+}
+
+static void on_tnc_host_ui(const uint8_t *payload, size_t len,
+ const hybbx_ax25_path_t *path, void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+ uint8_t hbx[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t hbx_len;
+
+ (void)path;
+
+ if (inst == NULL || inst->circuit_fd < 0 || len == 0) {
+ return;
+ }
+
+ if (path != NULL && packet_radio_source_banned(path)) {
+ return;
+ }
+
+ if (!instance_circuit_uplink_allowed(inst)) {
+ return;
+ }
+
+ hbx_len = hybbx_circuit_encode(HYBBX_CIRCUIT_PROTO_TERMINAL,
+ HYBBX_CIRCUIT_FLAG_RX,
+ payload, len, hbx, sizeof(hbx));
+ if (hbx_len > 0) {
+ (void)hybbx_circuit_link_write(inst->circuit_fd, hbx, hbx_len);
+ }
+}
+
+static void instance_format_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (out == NULL || out_len == 0) {
+ return;
+ }
+
+ out[0] = '\0';
+ if (addr == NULL || addr->call[0] == '\0') {
+ return;
+ }
+
+ if (addr->ssid > 0u) {
+ unsigned ssid = addr->ssid;
+
+ if (ssid > HYBBX_AX25_SSID_MAX) {
+ ssid = HYBBX_AX25_SSID_MAX;
+ }
+ snprintf(out, out_len, "%s-%u", addr->call, ssid);
+ } else {
+ hybbx_strlcpy(out, addr->call, out_len);
+ }
+}
+
+static void instance_log_rf_tx(packet_radio_instance_t *inst,
+ hybbx_result_t rc,
+ const hybbx_ax25_path_t *path,
+ size_t payload_len)
+{
+ char src[HYBBX_AX25_CALL_MAX + 8];
+ char dst[HYBBX_AX25_CALL_MAX + 8];
+
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_format_call(path != NULL ? &path->source : NULL, src, sizeof(src));
+ instance_format_call(path != NULL ? &path->dest : NULL, dst, sizeof(dst));
+
+ if (rc == HYBBX_OK) {
+ hybbx_log_stats("[packet_radio%u] RF TX %s>%s (%zu bytes)",
+ inst->index + 1, src, dst, payload_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX failed %s>%s (%zu bytes, rc=%d)",
+ inst->index + 1, src, dst, payload_len, (int)rc);
+ }
+}
+
+static void instance_note_rf_activity(packet_radio_instance_t *inst)
+{
+ hybbx_circuit_hub_t *hub;
+ const char *link_id;
+
+ if (inst == NULL || g_service == NULL) {
+ return;
+ }
+
+ hub = hybbx_service_circuit_hub(g_service);
+ link_id = inst->config.link_id;
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+
+ if (hub != NULL) {
+ hybbx_circuit_hub_note_rf_activity(hub, link_id);
+ }
+}
+
+static hybbx_result_t instance_tx_ax25_ui(packet_radio_instance_t *inst,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+ hybbx_result_t rc;
+
+ if (inst == NULL || inst->tnc == NULL || path == NULL ||
+ payload == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(path, payload, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_tnc_send_frame(inst->tnc, frame, frame_len);
+ if (rc == HYBBX_OK) {
+ instance_note_rf_activity(inst);
+ }
+ instance_log_rf_tx(inst, rc, path, len);
+ return rc;
+}
+
+static hybbx_result_t instance_send_bytes(packet_radio_instance_t *inst,
+ const uint8_t *data, size_t len)
+{
+ if (inst == NULL || inst->tnc == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ if (hybbx_tnc_host_connected(inst->tnc)) {
+ return hybbx_tnc_send_converse(inst->tnc, (const char *)data, len);
+ }
+ return HYBBX_ERR_BUSY;
+ }
+
+ return hybbx_tnc_send_ui(inst->tnc, data, len);
+}
+
+static void instance_circuit_disconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ inst->circuit_reconnect_polls = 0;
+}
+
+static void instance_circuit_reconnect(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ instance_circuit_disconnect(inst);
+ packet_radio_poll_sleep_ms(250);
+ if (instance_connect_circuit(inst) == HYBBX_OK) {
+ hybbx_log_info("[packet_radio%u] circuit reconnected", inst->index + 1);
+ }
+}
+
+static void instance_on_circuit_flow_ctrl(packet_radio_instance_t *inst,
+ hybbx_circuit_proto_t proto,
+ uint16_t flags,
+ const uint8_t *payload, size_t len)
+{
+ hybbx_circuit_balance_action_t action;
+ char reason[64];
+
+ (void)proto;
+ (void)flags;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (hybbx_circuit_flow_ctrl_parse((const char *)payload, len, &action,
+ reason, sizeof(reason)) != HYBBX_OK) {
+ return;
+ }
+
+ switch (action) {
+ case HYBBX_CIRCUIT_BAL_PAUSE:
+ inst->flow_paused = 1;
+ hybbx_log_stats("[packet_radio%u] circuit flow pause (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_BREAK:
+ inst->flow_paused = 0;
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ hybbx_log_stats("[packet_radio%u] circuit flow break (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_CANCEL:
+ inst->flow_cancel = 1;
+ hybbx_log_warn("[packet_radio%u] circuit flow cancel (%s)",
+ inst->index + 1, reason);
+ break;
+ case HYBBX_CIRCUIT_BAL_RESUME:
+ inst->flow_paused = 0;
+ hybbx_log_stats("[packet_radio%u] circuit flow resume (%s)",
+ inst->index + 1, reason);
+ break;
+ default:
+ break;
+ }
+}
+
+static void on_circuit_downlink(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ packet_radio_instance_t *inst = (packet_radio_instance_t *)userdata;
+
+ if (inst == NULL) {
+ return;
+ }
+
+ if (proto == HYBBX_CIRCUIT_PROTO_FLOW_CTRL) {
+ instance_on_circuit_flow_ctrl(inst, proto, flags, payload, len);
+ return;
+ }
+
+ if (inst->tnc == NULL || len == 0) {
+ return;
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ (void)instance_send_bytes(inst, payload, len);
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25: {
+ hybbx_result_t rc = hybbx_tnc_send_frame(inst->tnc, payload, len);
+ if (rc == HYBBX_OK) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(payload, len, &path,
+ ui, sizeof(ui));
+
+ instance_note_rf_activity(inst);
+ if (ui_len > 0) {
+ instance_log_rf_tx(inst, rc, &path, ui_len);
+ }
+ } else {
+ hybbx_log_warn("[packet_radio%u] RF TX frame failed (rc=%d, %zu bytes)",
+ inst->index + 1, (int)rc, len);
+ }
+ break;
+ }
+ case HYBBX_CIRCUIT_PROTO_AX25_UI: {
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path,
+ ui, sizeof(ui));
+ if (ui_len > 0) {
+ (void)instance_tx_ax25_ui(inst, &path, ui, ui_len);
+ } else {
+ hybbx_log_warn("[packet_radio%u] AX25_UI unpack failed (%zu bytes)",
+ inst->index + 1, len);
+ }
+ break;
+ }
+ default:
+ break;
+ }
+}
+
+static void packet_radio_poll_sleep_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)(ms / 1000u);
+ tv.tv_usec = (suseconds_t)((ms % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+#endif
+}
+
+static hybbx_result_t instance_connect_circuit(packet_radio_instance_t *inst)
+{
+ const char *host;
+ unsigned port;
+ unsigned attempt;
+
+ if (inst == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ host = inst->config.circuit_host;
+ port = inst->config.circuit_port;
+
+ if (host == NULL || host[0] == '\0') {
+ host = "127.0.0.1";
+ }
+ if (port == 0) {
+ port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+
+ if (inst->config.link_password == NULL ||
+ inst->config.link_password[0] == '\0') {
+ hybbx_log_warn("[packet_radio%u] missing link_password for HBX LINK_AUTH; "
+ "set link_password in [transport.packet_radioN] "
+ "(hub logs link_auth_fail reason=timeout_no_link_auth)",
+ inst->index + 1);
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (attempt = 0; attempt < 50; attempt++) {
+ hybbx_result_t rc = hybbx_circuit_link_connect(host, port,
+ &inst->circuit_fd);
+ if (rc == HYBBX_OK) {
+ hybbx_circuit_decoder_init(&inst->circuit_dec);
+ {
+ const char *link_id = inst->config.link_id;
+ const char *link_role = inst->config.link_role;
+
+ if (link_id == NULL || link_id[0] == '\0') {
+ link_id = "packet-radio";
+ }
+ if (link_role == NULL || link_role[0] == '\0') {
+ link_role = "link";
+ }
+
+ {
+ hybbx_circuit_link_qos_t qos;
+ int duplex = (int)inst->config.params.duplex;
+
+ memset(&qos, 0, sizeof(qos));
+ qos.baud = inst->config.params.radio_baud;
+ if (duplex == HYBBX_PACKET_RADIO_DUPLEX_HALF) {
+ qos.duplex = 1;
+ } else if (duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ qos.duplex = 2;
+ }
+ qos.bandwidth = "low";
+ qos.frequency_mhz = inst->config.frequency_mhz;
+
+ rc = hybbx_circuit_link_authenticate_ex(
+ inst->circuit_fd, inst->config.link_password,
+ link_role, link_id, &qos);
+ }
+ if (rc != HYBBX_OK) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ packet_radio_poll_sleep_ms(100);
+ continue;
+ }
+ }
+ hybbx_log_info("[packet_radio%u] linked to internal circuit %s:%u (HBX)",
+ inst->index + 1, host, port);
+ return HYBBX_OK;
+ }
+ packet_radio_poll_sleep_ms(100);
+ }
+
+ hybbx_log_warn("[packet_radio%u] could not connect to internal circuit %s:%u",
+ inst->index + 1, host, port);
+ return HYBBX_ERR_IO;
+}
+
+static void instance_shutdown(packet_radio_instance_t *inst)
+{
+ if (inst == NULL) {
+ return;
+ }
+
+ if (inst->circuit_fd >= 0) {
+ close(inst->circuit_fd);
+ inst->circuit_fd = -1;
+ }
+
+ if (inst->tnc != NULL) {
+ hybbx_tnc_close(inst->tnc);
+ inst->tnc = NULL;
+ }
+
+ hybbx_packet_radio_config_free(&inst->config);
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+}
+
+typedef enum {
+ PR_POLL_CIRCUIT = 0,
+ PR_POLL_SERIAL = 1
+} packet_radio_poll_kind_t;
+
+typedef struct {
+ unsigned instance_index;
+ packet_radio_poll_kind_t kind;
+} packet_radio_poll_map_t;
+
+static void instance_drain_circuit(packet_radio_instance_t *inst)
+{
+ uint8_t buf[512];
+
+ if (inst == NULL || inst->circuit_fd < 0) {
+ return;
+ }
+
+ for (;;) {
+ size_t read_len = 0;
+ hybbx_result_t rc = hybbx_circuit_link_read(inst->circuit_fd, buf,
+ sizeof(buf), &read_len);
+
+ if (rc == HYBBX_ERR_IO) {
+ hybbx_log_warn("[packet_radio%u] circuit disconnected — reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ return;
+ }
+ if (rc != HYBBX_OK || read_len == 0) {
+ break;
+ }
+
+ hybbx_circuit_decoder_feed(&inst->circuit_dec, buf, read_len,
+ on_circuit_downlink, inst);
+ }
+}
+
+#if !defined(_WIN32)
+static void packet_radio_poll_instances(void)
+{
+ struct pollfd pfds[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ packet_radio_poll_map_t map[HYBBX_PACKET_RADIO_MAX_INSTANCES * 2u];
+ nfds_t nfds = 0;
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+ int circuit_fd = inst->circuit_fd;
+ int serial_fd = inst->tnc != NULL ? hybbx_tnc_serial_fd(inst->tnc) : -1;
+
+ if (circuit_fd >= 0) {
+ pfds[nfds].fd = circuit_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_CIRCUIT;
+ nfds++;
+ }
+
+ if (serial_fd >= 0) {
+ pfds[nfds].fd = serial_fd;
+ pfds[nfds].events = POLLIN;
+ pfds[nfds].revents = 0;
+ map[nfds].instance_index = i;
+ map[nfds].kind = PR_POLL_SERIAL;
+ nfds++;
+ }
+ }
+
+ if (nfds > 0) {
+ int pr = poll(pfds, nfds, (int)PACKET_RADIO_POLL_MS);
+
+ if (pr < 0) {
+ if (errno != EINTR) {
+ hybbx_log_warn("[packet_radio] poll failed");
+ }
+ } else if (pr > 0) {
+ nfds_t j;
+
+ for (j = 0; j < nfds; j++) {
+ short rev = pfds[j].revents;
+
+ if (rev == 0) {
+ continue;
+ }
+
+ if ((rev & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ hybbx_log_warn("[packet_radio%u] circuit hangup — "
+ "reconnecting",
+ inst->index + 1);
+ instance_circuit_reconnect(inst);
+ }
+ continue;
+ }
+
+ if ((rev & POLLIN) == 0) {
+ continue;
+ }
+
+ if (map[j].kind == PR_POLL_CIRCUIT) {
+ instance_drain_circuit(&g_instances[map[j].instance_index]);
+ } else {
+ packet_radio_instance_t *inst =
+ &g_instances[map[j].instance_index];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+ }
+ }
+ }
+ } else {
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+ }
+}
+#endif
+
+static void packet_radio_service_maintenance(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->circuit_fd < 0) {
+ inst->circuit_reconnect_polls++;
+ if (inst->circuit_reconnect_polls >= 50u) {
+ inst->circuit_reconnect_polls = 0;
+ (void)instance_connect_circuit(inst);
+ }
+ }
+
+ if (inst->flow_cancel) {
+ inst->flow_cancel = 0;
+ inst->flow_paused = 0;
+ instance_circuit_reconnect(inst);
+ }
+ }
+}
+
+#if defined(_WIN32)
+static void packet_radio_poll_win32(void)
+{
+ unsigned i;
+
+ for (i = 0; i < g_instance_count; i++) {
+ packet_radio_instance_t *inst = &g_instances[i];
+
+ if (inst->tnc != NULL) {
+ (void)hybbx_tnc_poll(inst->tnc);
+ }
+
+ if (inst->circuit_fd >= 0) {
+ instance_drain_circuit(inst);
+ }
+ }
+}
+#endif
+
+static void *packet_radio_poll_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_radio_running) {
+#if !defined(_WIN32)
+ packet_radio_poll_instances();
+#else
+ packet_radio_poll_win32();
+ packet_radio_poll_sleep_ms(PACKET_RADIO_POLL_MS);
+#endif
+ packet_radio_service_maintenance();
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t instance_start(packet_radio_instance_t *inst,
+ const char *config,
+ unsigned index)
+{
+ hybbx_result_t rc;
+ hybbx_tnc_frame_cb frame_cb = on_tnc_ax25_frame;
+ hybbx_tnc_ui_cb ui_cb = NULL;
+
+ if (inst == NULL || config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(inst, 0, sizeof(*inst));
+ inst->circuit_fd = -1;
+ inst->index = index;
+
+ rc = hybbx_packet_radio_config_parse(config, &inst->config);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[packet_radio%u] invalid configuration", index + 1);
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (g_service != NULL) {
+ const hybbx_broadcast_config_t *bc =
+ hybbx_service_get_broadcast(g_service);
+
+ if (bc != NULL && bc->ax25_mycall[0] != '\0') {
+ size_t n = strlen(bc->ax25_mycall) + 1;
+ char *mc = malloc(n);
+
+ if (mc != NULL) {
+ memcpy(mc, bc->ax25_mycall, n);
+ free(inst->config.mycall);
+ inst->config.mycall = mc;
+ hybbx_log_info("[packet_radio%u] ax25 source=%s (from [broadcast] ax25_mycall)",
+ index + 1, inst->config.mycall);
+ }
+ }
+ }
+
+ hybbx_log_info("[packet_radio%u] tnc=%s protocol=%s device_type=%s device=%s "
+ "band=%s duplex=%s modulation=%s circuit=%s:%u "
+ "(HBX over internal TCP)",
+ index + 1,
+ tnc_name(inst->config.tnc),
+ protocol_name(inst->config.protocol),
+ device_type_name(inst->config.device_type),
+ inst->config.device,
+ hybbx_packet_radio_band_name(inst->config.params.band),
+ hybbx_packet_radio_duplex_name(inst->config.params.duplex),
+ hybbx_packet_radio_modulation_name(inst->config.params.modulation),
+ inst->config.circuit_host,
+ inst->config.circuit_port);
+ if (inst->config.frequency_mhz != NULL &&
+ inst->config.frequency_mhz[0] != '\0') {
+ hybbx_log_info("[packet_radio%u] frequency_mhz=%s", index + 1,
+ inst->config.frequency_mhz);
+ }
+
+ rc = instance_connect_circuit(inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ if (inst->config.protocol == HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ frame_cb = NULL;
+ ui_cb = on_tnc_host_ui;
+ }
+
+ rc = hybbx_tnc_open(&inst->tnc, &inst->config, frame_cb, ui_cb, inst);
+ if (rc != HYBBX_OK) {
+ instance_shutdown(inst);
+ return rc;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_init(hybbx_service_t *service)
+{
+ g_service = service;
+ g_instance_count = 0;
+ g_radio_running = 0;
+ return HYBBX_OK;
+}
+
+static void packet_radio_shutdown(void)
+{
+ unsigned i;
+
+ g_radio_running = 0;
+ if (g_poll_thread_started) {
+ pthread_join(g_poll_thread, NULL);
+ g_poll_thread_started = 0;
+ }
+
+ for (i = 0; i < g_instance_count; i++) {
+ instance_shutdown(&g_instances[i]);
+ }
+ g_instance_count = 0;
+}
+
+static hybbx_result_t packet_radio_start(const char *config)
+{
+ char scratch[4096];
+ const char *cursor;
+ hybbx_max25_config_t max25;
+ hybbx_max25_status_t max25_status;
+ hybbx_result_t rc = HYBBX_OK;
+ unsigned started = 0;
+ unsigned section_num = 0;
+ unsigned local_edges;
+
+ (void)g_service;
+
+ if (config == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ packet_radio_shutdown();
+ g_poll_thread = (pthread_t)0;
+
+ cursor = hybbx_max25_config_skip_prefix(config, &max25);
+ local_edges = packet_radio_count_local_edges(cursor);
+ if (local_edges > 0u) {
+ rc = hybbx_max25_wait_ready(&max25);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (*cursor != '\0' && g_instance_count < HYBBX_PACKET_RADIO_MAX_INSTANCES) {
+ const char *sep = strchr(cursor, HYBBX_PACKET_RADIO_INSTANCE_SEP);
+ size_t len = sep != NULL ? (size_t)(sep - cursor) : strlen(cursor);
+
+ if (len == 0) {
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ if (len >= sizeof(scratch)) {
+ packet_radio_shutdown();
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, cursor, len);
+ scratch[len] = '\0';
+ section_num++;
+
+ if (!hybbx_packet_radio_section_is_local_edge(scratch)) {
+ hybbx_log_info("[packet_radio%u] bridge registry only — no local TNC",
+ section_num);
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ continue;
+ }
+
+ rc = instance_start(&g_instances[g_instance_count], scratch,
+ g_instance_count);
+ if (rc != HYBBX_OK) {
+ packet_radio_shutdown();
+ return rc;
+ }
+
+ g_instance_count++;
+ started++;
+ cursor = sep != NULL ? sep + 1 : cursor + len;
+ if (sep == NULL) {
+ break;
+ }
+ }
+
+ if (started == 0) {
+ return HYBBX_OK;
+ }
+
+ g_radio_running = 1;
+ if (pthread_create(&g_poll_thread, NULL, packet_radio_poll_thread,
+ NULL) != 0) {
+ hybbx_log_warn("[packet_radio] poll thread failed");
+ packet_radio_shutdown();
+ return HYBBX_ERR_IO;
+ }
+ g_poll_thread_started = 1;
+
+ hybbx_log_info("[packet_radio] %u TNC instance(s) active", started);
+ return HYBBX_OK;
+}
+
+static hybbx_result_t packet_radio_stop(void)
+{
+ hybbx_log_info("[packet_radio] stop");
+ packet_radio_shutdown();
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_packet_radio = {
+ .name = "packet_radio",
+ .kind = HYBBX_TRANSPORT_PACKET_RADIO,
+ .version = 1,
+ .init = packet_radio_init,
+ .shutdown = packet_radio_shutdown,
+ .start = packet_radio_start,
+ .stop = packet_radio_stop,
+ .write = NULL,
+};
diff --git a/plugins/packet_radio/packet_radio_config.c b/plugins/packet_radio/packet_radio_config.c
new file mode 100644
index 0000000..f8e2d65
--- /dev/null
+++ b/plugins/packet_radio/packet_radio_config.c
@@ -0,0 +1,786 @@
+#include "hybbx/packet_radio.h"
+#include "hybbx/tnc.h"
+#include "hybbx/circuit.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static char *hybbx_strdup(const char *s)
+{
+ size_t len;
+ char *copy;
+
+ if (s == NULL) {
+ return NULL;
+ }
+
+ len = strlen(s) + 1;
+ copy = malloc(len);
+ if (copy != NULL) {
+ memcpy(copy, s, len);
+ }
+ return copy;
+}
+
+static int str_ieq(const char *a, const char *b)
+{
+ if (a == NULL || b == NULL) {
+ return 0;
+ }
+
+ while (*a != '\0' && *b != '\0') {
+ char ca = (char)(*a >= 'A' && *a <= 'Z' ? *a + 32 : *a);
+ char cb = (char)(*b >= 'A' && *b <= 'Z' ? *b + 32 : *b);
+
+ if (ca != cb) {
+ return 0;
+ }
+ a++;
+ b++;
+ }
+
+ return *a == '\0' && *b == '\0';
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+int hybbx_packet_radio_section_is_local_edge(const char *config)
+{
+ char scratch[64];
+
+ if (config == NULL) {
+ return 0;
+ }
+
+ if (find_kv(config, "circuit_host", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "device", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "tnc", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+ if (find_kv(config, "protocol", scratch, sizeof(scratch)) != NULL) {
+ return 1;
+ }
+
+ return 0;
+}
+
+static hybbx_packet_radio_device_type_t parse_device_type(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "usb") || str_ieq(value, "ax25") ||
+ str_ieq(value, "ax.25")) {
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+ }
+
+ if (str_ieq(value, "serial") || str_ieq(value, "com") ||
+ str_ieq(value, "rs232") || str_ieq(value, "v24")) {
+ return HYBBX_PACKET_RADIO_DEVICE_SERIAL;
+ }
+
+ return HYBBX_PACKET_RADIO_DEVICE_USB;
+}
+
+static const char *default_device_for_type(hybbx_packet_radio_device_type_t type)
+{
+ if (type == HYBBX_PACKET_RADIO_DEVICE_SERIAL) {
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_SERIAL;
+ }
+ return HYBBX_PACKET_RADIO_DEFAULT_DEVICE_USB;
+}
+
+static hybbx_packet_radio_tnc_t parse_tnc(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tnc2c") || str_ieq(value, "tnc2-c")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+ }
+
+ if (str_ieq(value, "tnc2") || str_ieq(value, "tnc-2") ||
+ str_ieq(value, "pktnc2") || str_ieq(value, "pk-tnc2") ||
+ str_ieq(value, "tapr") || str_ieq(value, "thefirmware") ||
+ str_ieq(value, "tf")) {
+ return HYBBX_PACKET_RADIO_TNC_TNC2;
+ }
+
+ if (str_ieq(value, "pk232") || str_ieq(value, "pk-232") ||
+ str_ieq(value, "aea") || str_ieq(value, "pk87") ||
+ str_ieq(value, "pk-87")) {
+ return HYBBX_PACKET_RADIO_TNC_PK232;
+ }
+
+ if (str_ieq(value, "mfj1278") || str_ieq(value, "mfj-1278") ||
+ str_ieq(value, "mfj1278b") || str_ieq(value, "mfj")) {
+ return HYBBX_PACKET_RADIO_TNC_MFJ1278;
+ }
+
+ if (str_ieq(value, "kantronics") || str_ieq(value, "kpc") ||
+ str_ieq(value, "kpc3") || str_ieq(value, "kpc9612")) {
+ return HYBBX_PACKET_RADIO_TNC_KANTRONICS;
+ }
+
+ if (str_ieq(value, "baycom") || str_ieq(value, "bay-com")) {
+ return HYBBX_PACKET_RADIO_TNC_BAYCOM;
+ }
+
+ if (str_ieq(value, "pccom") || str_ieq(value, "pc-com") ||
+ str_ieq(value, "albrecht")) {
+ return HYBBX_PACKET_RADIO_TNC_PCCOM;
+ }
+
+ if (str_ieq(value, "generic") || str_ieq(value, "tnc")) {
+ return HYBBX_PACKET_RADIO_TNC_GENERIC;
+ }
+
+ return HYBBX_PACKET_RADIO_TNC_TNC2C;
+}
+
+static hybbx_packet_radio_protocol_t parse_protocol(const char *value)
+{
+ if (value == NULL || str_ieq(value, "kiss")) {
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ if (str_ieq(value, "hostmode") || str_ieq(value, "host") ||
+ str_ieq(value, "tnc2")) {
+ return HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+
+ if (str_ieq(value, "6pack") || str_ieq(value, "sixpack")) {
+ return HYBBX_PACKET_RADIO_PROTO_SIXPACK;
+ }
+
+ return HYBBX_PACKET_RADIO_PROTO_KISS;
+}
+
+static hybbx_tnc2c_modem_t parse_modem(const char *value)
+{
+ if (value == NULL || str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ if (str_ieq(value, "9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600")) {
+ return HYBBX_TNC2C_MODEM_9600;
+ }
+
+ if (str_ieq(value, "19200")) {
+ return HYBBX_TNC2C_MODEM_19200;
+ }
+
+ return HYBBX_TNC2C_MODEM_TCM3105;
+}
+
+static float parse_clock_mhz(const char *value)
+{
+ if (value == NULL) {
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+
+ if (str_ieq(value, "9.8") || str_ieq(value, "9,8")) {
+ return HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+
+ if (str_ieq(value, "10") || str_ieq(value, "10.0")) {
+ return HYBBX_TNC2C_CLOCK_10_MHZ;
+ }
+
+ return HYBBX_TNC2C_CLOCK_4_9_MHZ;
+}
+
+static unsigned int parse_uint(const char *value, unsigned int default_value)
+{
+ char *end = NULL;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return default_value;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || n == 0) {
+ return default_value;
+ }
+
+ return (unsigned int)n;
+}
+
+static hybbx_packet_radio_band_t parse_radio_band(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ if (str_ieq(value, "cb") || str_ieq(value, "11m") ||
+ str_ieq(value, "27mhz") || str_ieq(value, "citizen")) {
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ }
+
+ if (str_ieq(value, "amateur") || str_ieq(value, "ham") ||
+ str_ieq(value, "amateurfunk") || str_ieq(value, "afu")) {
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+}
+
+static hybbx_packet_radio_duplex_t parse_radio_duplex(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ }
+
+ if (str_ieq(value, "full") || str_ieq(value, "full-duplex") ||
+ str_ieq(value, "fullduplex") || str_ieq(value, "fulldup")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_FULL;
+ }
+
+ if (str_ieq(value, "half") || str_ieq(value, "half-duplex") ||
+ str_ieq(value, "duplex") || str_ieq(value, "simplex")) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+}
+
+static hybbx_packet_radio_modulation_t parse_modulation(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+ }
+
+ if (str_ieq(value, "g3ruh") || str_ieq(value, "g3ruh_fsk") ||
+ str_ieq(value, "fsk9600") || str_ieq(value, "9600fsk")) {
+ return HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+ }
+
+ if (str_ieq(value, "afsk") || str_ieq(value, "bell202") ||
+ str_ieq(value, "tcm3105") || str_ieq(value, "1200")) {
+ return HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ return HYBBX_PACKET_RADIO_MOD_UNSET;
+}
+
+static hybbx_tnc_serial_parity_t parse_serial_parity(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+ }
+
+ if (str_ieq(value, "even") || str_ieq(value, "e")) {
+ return HYBBX_TNC_SERIAL_PARITY_EVEN;
+ }
+
+ if (str_ieq(value, "odd") || str_ieq(value, "o")) {
+ return HYBBX_TNC_SERIAL_PARITY_ODD;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "n") || str_ieq(value, "off")) {
+ return HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+
+ return HYBBX_TNC_SERIAL_PARITY_UNSET;
+}
+
+static void apply_serial_line(hybbx_packet_radio_config_t *out, const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return;
+ }
+
+ if (str_ieq(value, "7e1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "8n1")) {
+ out->params.data_bits = 8;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ out->params.stop_bits = 1;
+ return;
+ }
+
+ if (str_ieq(value, "7o1")) {
+ out->params.data_bits = 7;
+ out->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_ODD;
+ out->params.stop_bits = 1;
+ }
+}
+
+static hybbx_kiss_entry_t parse_kiss_entry(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_ENTRY_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_ENTRY_NONE;
+ }
+
+ if (str_ieq(value, "kiss_on") || str_ieq(value, "kiss-on") ||
+ str_ieq(value, "command") || str_ieq(value, "on") ||
+ str_ieq(value, "yes")) {
+ return HYBBX_KISS_ENTRY_KISS_ON;
+ }
+
+ if (str_ieq(value, "esc_at_k") || str_ieq(value, "esc@k") ||
+ str_ieq(value, "esc") || str_ieq(value, "@k")) {
+ return HYBBX_KISS_ENTRY_ESC_AT_K;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_ENTRY_AUTO;
+ }
+
+ return HYBBX_KISS_ENTRY_UNSET;
+}
+
+static hybbx_kiss_exit_t parse_kiss_exit(const char *value)
+{
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_KISS_EXIT_UNSET;
+ }
+
+ if (str_ieq(value, "none") || str_ieq(value, "skip") ||
+ str_ieq(value, "off") || str_ieq(value, "no")) {
+ return HYBBX_KISS_EXIT_NONE;
+ }
+
+ if (str_ieq(value, "kiss_off") || str_ieq(value, "kiss-off") ||
+ str_ieq(value, "command")) {
+ return HYBBX_KISS_EXIT_KISS_OFF;
+ }
+
+ if (str_ieq(value, "kiss_frame") || str_ieq(value, "frame") ||
+ str_ieq(value, "fend")) {
+ return HYBBX_KISS_EXIT_KISS_FRAME;
+ }
+
+ if (str_ieq(value, "auto")) {
+ return HYBBX_KISS_EXIT_AUTO;
+ }
+
+ return HYBBX_KISS_EXIT_UNSET;
+}
+
+static unsigned int default_radio_baud_for_modem(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return 9600;
+ case HYBBX_TNC2C_MODEM_19200:
+ return 19200;
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+}
+
+static hybbx_result_t parse_via_list(hybbx_packet_radio_config_t *out,
+ const char *value)
+{
+ char scratch[256];
+ char *cursor;
+ char *token;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ if (strlen(value) >= sizeof(scratch)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memcpy(scratch, value, strlen(value) + 1);
+ cursor = scratch;
+
+ while ((token = strtok(cursor, ",")) != NULL && out->via_count < HYBBX_PACKET_RADIO_VIA_MAX) {
+ char *item = hybbx_strdup(token);
+ if (item == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ out->via[out->via_count++] = item;
+ cursor = NULL;
+ }
+
+ return HYBBX_OK;
+}
+
+int hybbx_packet_radio_baud_valid(unsigned int baud)
+{
+ return baud > 0;
+}
+
+hybbx_result_t hybbx_packet_radio_config_parse(const char *config,
+ hybbx_packet_radio_config_t *out)
+{
+ char scratch[256];
+ const char *value;
+ unsigned long baud;
+ hybbx_result_t rc;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ memset(out, 0, sizeof(*out));
+
+ out->tnc = HYBBX_PACKET_RADIO_TNC_TNC2C;
+ out->protocol = 0; /* profile may choose */
+ out->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ out->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ out->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ out->params.kiss_port = 0;
+ out->params.txdelay = 50;
+ out->params.persist = 0;
+ out->params.slot = 10;
+ out->params.txtail = 0;
+ out->params.band = HYBBX_PACKET_RADIO_BAND_UNSET;
+ out->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_UNSET;
+ out->params.modulation = HYBBX_PACKET_RADIO_MOD_UNSET;
+ out->params.kiss_entry = HYBBX_KISS_ENTRY_UNSET;
+ out->params.kiss_exit = HYBBX_KISS_EXIT_UNSET;
+ out->params.host_connect_on_start = 0;
+ out->params.assert_modem_lines = -1;
+
+ value = find_kv(config, "device_type", scratch, sizeof(scratch));
+ out->device_type = parse_device_type(value);
+
+ value = find_kv(config, "device", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->device = hybbx_strdup(default_device_for_type(out->device_type));
+ } else {
+ out->device = hybbx_strdup(value);
+ }
+
+ if (out->device == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ value = find_kv(config, "baud", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ out->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ } else {
+ baud = strtoul(value, NULL, 10);
+ if (!hybbx_packet_radio_baud_valid((unsigned int)baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->baud = (unsigned int)baud;
+ }
+
+ value = find_kv(config, "tnc", scratch, sizeof(scratch));
+ out->tnc = parse_tnc(value);
+
+ if (out->tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM ||
+ out->tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ hybbx_log_warn("[packet_radio] tnc=%s is not a packet_radio profile — "
+ "use tnc=tnc2c (or generic) with kiss_entry=none after "
+ "max25d prep; BayCom/based SER12 = MAX25 max25e0 (bcpr), "
+ "optional [networks] baycom=yes on non-UN1TME sites only",
+ hybbx_packet_radio_tnc_name(out->tnc));
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+
+ value = find_kv(config, "protocol", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->protocol = parse_protocol(value);
+ }
+
+ value = find_kv(config, "clock_mhz", scratch, sizeof(scratch));
+ out->params.clock_mhz = parse_clock_mhz(value);
+
+ value = find_kv(config, "modem", scratch, sizeof(scratch));
+ out->params.modem = parse_modem(value);
+
+ value = find_kv(config, "radio_baud", scratch, sizeof(scratch));
+ if (value == NULL) {
+ out->params.radio_baud = default_radio_baud_for_modem(out->params.modem);
+ } else {
+ unsigned int radio_baud = parse_uint(value, 0);
+ if (!hybbx_packet_radio_baud_valid(radio_baud)) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_INVALID;
+ }
+ out->params.radio_baud = radio_baud;
+ }
+
+ value = find_kv(config, "kiss_port", scratch, sizeof(scratch));
+ out->params.kiss_port = parse_uint(value, 0);
+
+ value = find_kv(config, "txdelay", scratch, sizeof(scratch));
+ out->params.txdelay = parse_uint(value, out->params.txdelay);
+
+ value = find_kv(config, "persist", scratch, sizeof(scratch));
+ out->params.persist = parse_uint(value, out->params.persist);
+
+ value = find_kv(config, "slot", scratch, sizeof(scratch));
+ out->params.slot = parse_uint(value, out->params.slot);
+
+ value = find_kv(config, "txtail", scratch, sizeof(scratch));
+ out->params.txtail = parse_uint(value, out->params.txtail);
+
+ value = find_kv(config, "radio_band", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.band = parse_radio_band(value);
+ }
+
+ value = find_kv(config, "radio_duplex", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "duplex", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.duplex = parse_radio_duplex(value);
+ }
+
+ value = find_kv(config, "fullduplex", scratch, sizeof(scratch));
+ if (value != NULL &&
+ out->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ out->params.duplex = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_DUPLEX_FULL :
+ HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ value = find_kv(config, "g3ruh_fsk", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->params.modulation = hybbx_parse_bool(value, 0) ?
+ HYBBX_PACKET_RADIO_MOD_G3RUH_FSK :
+ HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ value = find_kv(config, "modulation", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_packet_radio_modulation_t mod = parse_modulation(value);
+ if (mod != HYBBX_PACKET_RADIO_MOD_UNSET) {
+ out->params.modulation = mod;
+ }
+ }
+
+ value = find_kv(config, "kiss_entry", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_entry_t kiss_entry = parse_kiss_entry(value);
+ if (kiss_entry != HYBBX_KISS_ENTRY_UNSET) {
+ out->params.kiss_entry = kiss_entry;
+ }
+ }
+
+ value = find_kv(config, "kiss_exit", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_kiss_exit_t kiss_exit = parse_kiss_exit(value);
+ if (kiss_exit != HYBBX_KISS_EXIT_UNSET) {
+ out->params.kiss_exit = kiss_exit;
+ }
+ }
+
+ value = find_kv(config, "host_connect", scratch, sizeof(scratch));
+ out->params.host_connect_on_start = hybbx_parse_bool(value,
+ out->params.host_connect_on_start);
+
+ value = find_kv(config, "serial_line", scratch, sizeof(scratch));
+ if (value != NULL) {
+ apply_serial_line(out, value);
+ }
+
+ value = find_kv(config, "data_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int data_bits = parse_uint(value, 0);
+ if (data_bits == 7 || data_bits == 8) {
+ out->params.data_bits = data_bits;
+ }
+ }
+
+ value = find_kv(config, "parity", scratch, sizeof(scratch));
+ if (value != NULL) {
+ hybbx_tnc_serial_parity_t parity = parse_serial_parity(value);
+ if (parity != HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ out->params.serial_parity = parity;
+ }
+ }
+
+ value = find_kv(config, "stop_bits", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ unsigned int stop_bits = parse_uint(value, 0);
+ if (stop_bits == 1 || stop_bits == 2) {
+ out->params.stop_bits = stop_bits;
+ }
+ }
+
+ value = find_kv(config, "assert_modem_lines", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "rts_dtr", scratch, sizeof(scratch));
+ }
+ if (value != NULL) {
+ out->params.assert_modem_lines = hybbx_parse_bool(value, 0) ? 1 : 0;
+ }
+
+ value = find_kv(config, "mycall", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->mycall = hybbx_strdup(value);
+ if (out->mycall == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "dest", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "dest_call", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->dest_call = hybbx_strdup(value);
+ if (out->dest_call == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "via", scratch, sizeof(scratch));
+ rc = parse_via_list(out, value);
+ if (rc != HYBBX_OK) {
+ hybbx_packet_radio_config_free(out);
+ return rc;
+ }
+
+ value = find_kv(config, "circuit_host", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_host = hybbx_strdup(value);
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "circuit_port", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->circuit_port = parse_uint(value, HYBBX_CIRCUIT_DEFAULT_PORT);
+ }
+
+ if (out->circuit_port == 0) {
+ out->circuit_port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ }
+ if (out->circuit_host == NULL) {
+ out->circuit_host = hybbx_strdup("127.0.0.1");
+ if (out->circuit_host == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_id", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_id = hybbx_strdup(value);
+ if (out->link_id == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_password", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_password = hybbx_strdup(value);
+ if (out->link_password == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "link_role", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ out->link_role = hybbx_strdup(value);
+ if (out->link_role == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ value = find_kv(config, "frequency_mhz", scratch, sizeof(scratch));
+ if (value == NULL || value[0] == '\0') {
+ value = find_kv(config, "frequency", scratch, sizeof(scratch));
+ }
+ if (value != NULL && value[0] != '\0') {
+ out->frequency_mhz = hybbx_strdup(value);
+ if (out->frequency_mhz == NULL) {
+ hybbx_packet_radio_config_free(out);
+ return HYBBX_ERR_NOMEM;
+ }
+ }
+
+ (void)hybbx_tnc_profile_apply_defaults(out);
+ return HYBBX_OK;
+}
+
+void hybbx_packet_radio_config_free(hybbx_packet_radio_config_t *config)
+{
+ unsigned i;
+
+ if (config == NULL) {
+ return;
+ }
+
+ free(config->device);
+ free(config->mycall);
+ free(config->dest_call);
+ free(config->circuit_host);
+ free(config->link_id);
+ free(config->link_password);
+ free(config->link_role);
+ free(config->frequency_mhz);
+ for (i = 0; i < config->via_count; i++) {
+ free(config->via[i]);
+ config->via[i] = NULL;
+ }
+
+ memset(config, 0, sizeof(*config));
+}
diff --git a/plugins/packet_radio/serial_port.c b/plugins/packet_radio/serial_port.c
new file mode 100644
index 0000000..70dd92c
--- /dev/null
+++ b/plugins/packet_radio/serial_port.c
@@ -0,0 +1,705 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "serial_port.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+#include <stdio.h>
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud)
+{
+ if (params == NULL) {
+ return;
+ }
+
+ memset(params, 0, sizeof(*params));
+ params->baud = baud;
+ params->data_bits = 8;
+ params->parity = HYBBX_SERIAL_PARITY_NONE;
+ params->stop_bits = 1;
+ params->assert_modem_lines = 0;
+}
+
+#if defined(_WIN32)
+
+#define WIN32_LEAN_AND_MEAN
+#include <windows.h>
+
+struct hybbx_serial_port {
+ HANDLE handle;
+ unsigned int baud;
+};
+
+static int win32_normalize_device(const char *device, char *out, size_t out_len)
+{
+ const char *name;
+ unsigned com_num = 0;
+
+ if (device == NULL || out_len == 0) {
+ return 0;
+ }
+
+ if (strncmp(device, "\\\\.\\", 4) == 0) {
+ return hybbx_strlcpy(out, device, out_len) < out_len;
+ }
+
+ name = device;
+ if (name[0] == '/' && strncmp(name, "/dev/ttyS", 9) == 0 &&
+ name[9] >= '0' && name[9] <= '9' && name[10] == '\0') {
+ com_num = (unsigned)(name[9] - '0') + 1u;
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+ }
+
+ if ((name[0] == 'C' || name[0] == 'c') &&
+ (name[1] == 'O' || name[1] == 'o') &&
+ (name[2] == 'M' || name[2] == 'm')) {
+ name += 3;
+ }
+
+ if (*name == '\0') {
+ return 0;
+ }
+
+ while (*name >= '0' && *name <= '9') {
+ com_num = com_num * 10u + (unsigned)(*name - '0');
+ name++;
+ }
+ if (*name != '\0' || com_num == 0) {
+ return 0;
+ }
+
+ return (size_t)snprintf(out, out_len, "\\\\.\\COM%u", com_num) < out_len;
+}
+
+static hybbx_result_t win32_configure(HANDLE handle,
+ const hybbx_serial_params_t *params)
+{
+ DCB dcb;
+ COMMTIMEOUTS timeouts;
+
+ memset(&dcb, 0, sizeof(dcb));
+ dcb.DCBlength = sizeof(dcb);
+ if (!GetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ dcb.BaudRate = (DWORD)params->baud;
+ dcb.ByteSize = (BYTE)(params->data_bits == 7 ? 7 : 8);
+ switch (params->parity) {
+ case HYBBX_SERIAL_PARITY_EVEN:
+ dcb.Parity = EVENPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_ODD:
+ dcb.Parity = ODDPARITY;
+ dcb.fParity = TRUE;
+ break;
+ case HYBBX_SERIAL_PARITY_NONE:
+ default:
+ dcb.Parity = NOPARITY;
+ dcb.fParity = FALSE;
+ break;
+ }
+ dcb.StopBits = params->stop_bits == 2 ? TWOSTOPBITS : ONESTOPBIT;
+ dcb.fBinary = TRUE;
+ dcb.fOutxCtsFlow = FALSE;
+ dcb.fOutxDsrFlow = FALSE;
+ dcb.fDtrControl = params->assert_modem_lines ?
+ DTR_CONTROL_ENABLE : DTR_CONTROL_DISABLE;
+ dcb.fRtsControl = params->assert_modem_lines ?
+ RTS_CONTROL_ENABLE : RTS_CONTROL_DISABLE;
+ dcb.fOutX = FALSE;
+ dcb.fInX = FALSE;
+ dcb.fNull = FALSE;
+
+ if (!SetCommState(handle, &dcb)) {
+ return HYBBX_ERR_IO;
+ }
+
+ memset(&timeouts, 0, sizeof(timeouts));
+ timeouts.ReadIntervalTimeout = MAXDWORD;
+ timeouts.ReadTotalTimeoutConstant = 0;
+ timeouts.ReadTotalTimeoutMultiplier = 0;
+ timeouts.WriteTotalTimeoutConstant = 0;
+ timeouts.WriteTotalTimeoutMultiplier = 0;
+ if (!SetCommTimeouts(handle, &timeouts)) {
+ return HYBBX_ERR_IO;
+ }
+
+ PurgeComm(handle, PURGE_RXCLEAR | PURGE_TXCLEAR);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ char path[64];
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!win32_normalize_device(device, path, sizeof(path))) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->handle = CreateFileA(path, GENERIC_READ | GENERIC_WRITE, 0, NULL,
+ OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL);
+ if (port->handle == INVALID_HANDLE_VALUE) {
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = win32_configure(port->handle, params);
+ if (rc != HYBBX_OK) {
+ CloseHandle(port->handle);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->handle != INVALID_HANDLE_VALUE) {
+ CloseHandle(port->handle);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (written < len) {
+ DWORD chunk = 0;
+
+ if (!WriteFile(port->handle, data + written,
+ (DWORD)(len - written), &chunk, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+ if (chunk == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ DWORD n = 0;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (!ReadFile(port->handle, buf, (DWORD)buf_len, &n, NULL)) {
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#elif defined(__AMIGA__)
+
+#include <proto/exec.h>
+#include <devices/serial.h>
+
+struct hybbx_serial_port {
+ struct IOExtSer *io;
+ struct MsgPort *port;
+ char device_name[32];
+ unsigned long unit;
+ unsigned int baud;
+};
+
+static int amiga_parse_device(const char *device, char *name, size_t name_len,
+ unsigned long *unit)
+{
+ const char *colon;
+ char *end;
+
+ if (unit == NULL || name == NULL || name_len == 0) {
+ return 0;
+ }
+
+ *unit = 0;
+ if (device == NULL || device[0] == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+
+ colon = strchr(device, ':');
+ if (colon != NULL) {
+ size_t prefix = (size_t)(colon - device);
+
+ if (prefix == 0 || prefix >= name_len) {
+ return 0;
+ }
+ memcpy(name, device, prefix);
+ name[prefix] = '\0';
+ *unit = strtoul(colon + 1, &end, 10);
+ return end != colon + 1 && *end == '\0';
+ }
+
+ if (device[0] >= '0' && device[0] <= '9') {
+ *unit = strtoul(device, &end, 10);
+ if (end != device && *end == '\0') {
+ hybbx_strlcpy(name, SERIALNAME, name_len);
+ return 1;
+ }
+ }
+
+ hybbx_strlcpy(name, device, name_len);
+ return 1;
+}
+
+static hybbx_result_t amiga_configure(struct IOExtSer *io,
+ const hybbx_serial_params_t *params)
+{
+ io->IOSer.io_Command = SDCMD_SETPARAMS;
+ io->io_Baud = (ULONG)params->baud;
+ io->io_ReadLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_WriteLen = (UBYTE)(params->data_bits == 7 ? 7 : 8);
+ io->io_StopBits = (UBYTE)(params->stop_bits == 2 ? 2 : 1);
+ io->io_SerFlags = (UBYTE)(io->io_SerFlags & (UBYTE)~SERF_PARTY_ON);
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN ||
+ params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ io->io_SerFlags |= SERF_PARTY_ON;
+ }
+ io->io_SerFlags |= SERF_XDISABLED;
+
+ if (DoIO((struct IORequest *)io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ if (!amiga_parse_device(device, port->device_name, sizeof(port->device_name),
+ &port->unit)) {
+ free(port);
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->port = CreatePort(NULL, 0);
+ if (port->port == NULL) {
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io = (struct IOExtSer *)CreateExtIO(port->port,
+ (LONG)sizeof(*port->io));
+ if (port->io == NULL) {
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->io->io_SerFlags = SERF_SHARED | SERF_XDISABLED;
+ if (OpenDevice(port->device_name, port->unit,
+ (struct IORequest *)port->io, 0) != 0) {
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = amiga_configure(port->io, params);
+ if (rc != HYBBX_OK) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ DeletePort(port->port);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->io != NULL) {
+ CloseDevice((struct IORequest *)port->io);
+ DeleteExtIO((struct IORequest *)port->io);
+ }
+ if (port->port != NULL) {
+ DeletePort(port->port);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ if (port == NULL || data == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_WRITE;
+ port->io->IOSer.io_Data = (APTR)data;
+ port->io->IOSer.io_Length = (ULONG)len;
+ port->io->IOSer.io_Flags = 0;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ if (port == NULL || buf == NULL || read_len == NULL || buf_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port->io->IOSer.io_Command = CMD_READ;
+ port->io->IOSer.io_Data = (APTR)buf;
+ port->io->IOSer.io_Length = (ULONG)buf_len;
+ port->io->IOSer.io_Flags = IOF_QUICK;
+
+ if (DoIO((struct IORequest *)port->io) != 0) {
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+
+ *read_len = (size_t)port->io->IOSer.io_Actual;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ (void)port;
+ return -1;
+}
+
+#else /* POSIX */
+
+#include <errno.h>
+#include <fcntl.h>
+#include <stdio.h>
+#include <sys/ioctl.h>
+#include <termios.h>
+#include <unistd.h>
+
+struct hybbx_serial_port {
+ int fd;
+ unsigned int baud;
+ int assert_modem_lines;
+};
+
+#ifndef cfmakeraw
+static void hybbx_cfmakeraw(struct termios *tty)
+{
+ tty->c_iflag &= ~(IGNBRK | BRKINT | PARMRK | ISTRIP | INLCR |
+ IGNCR | ICRNL | IXON);
+ tty->c_oflag &= ~OPOST;
+ tty->c_lflag &= ~(ECHO | ECHONL | ICANON | ISIG | IEXTEN);
+ tty->c_cflag &= ~(CSIZE | PARENB);
+ tty->c_cflag |= CS8;
+}
+#define cfmakeraw hybbx_cfmakeraw
+#endif
+
+static hybbx_result_t set_baud_rate(struct termios *tty, unsigned int baud)
+{
+#if defined(__linux__) || defined(__GLIBC__)
+ if (cfsetspeed(tty, (speed_t)baud) == 0) {
+ return HYBBX_OK;
+ }
+#endif
+
+ {
+ speed_t speed = B0;
+ switch (baud) {
+ case 50: speed = B50; break;
+ case 75: speed = B75; break;
+ case 110: speed = B110; break;
+ case 134: speed = B134; break;
+ case 150: speed = B150; break;
+ case 200: speed = B200; break;
+ case 300: speed = B300; break;
+ case 600: speed = B600; break;
+ case 1200: speed = B1200; break;
+ case 1800: speed = B1800; break;
+ case 2400: speed = B2400; break;
+ case 4800: speed = B4800; break;
+ case 9600: speed = B9600; break;
+#ifdef B19200
+ case 19200: speed = B19200; break;
+#endif
+#ifdef B38400
+ case 38400: speed = B38400; break;
+#endif
+#ifdef B57600
+ case 57600: speed = B57600; break;
+#endif
+#ifdef B115200
+ case 115200: speed = B115200; break;
+#endif
+ default:
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ cfsetispeed(tty, speed);
+ cfsetospeed(tty, speed);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t posix_assert_modem_lines(int fd, int on)
+{
+#ifdef TIOCMGET
+ int flags;
+
+ /* BayCom KISS PTY (max25-bcpr): assert_modem_lines=0 — no UART modem bits.
+ * TIOCMGET on /dev/pts/* often fails; skip when not asserting. */
+ if (!on) {
+ return HYBBX_OK;
+ }
+
+ if (ioctl(fd, TIOCMGET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+ flags |= TIOCM_RTS | TIOCM_DTR;
+ if (ioctl(fd, TIOCMSET, &flags) != 0) {
+ return HYBBX_ERR_IO;
+ }
+#else
+ (void)fd;
+ (void)on;
+#endif
+ return HYBBX_OK;
+}
+
+static hybbx_result_t configure_port(int fd, const hybbx_serial_params_t *params)
+{
+ struct termios tty;
+ tcflag_t databits;
+
+ if (tcgetattr(fd, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ cfmakeraw(&tty);
+ tty.c_cflag |= (CLOCAL | CREAD);
+ tty.c_cflag &= ~(CSIZE | PARENB | PARODD | CSTOPB);
+ if (params->data_bits == 7) {
+ databits = CS7;
+ } else {
+ databits = CS8;
+ }
+ tty.c_cflag |= databits;
+ if (params->parity == HYBBX_SERIAL_PARITY_EVEN) {
+ tty.c_cflag |= PARENB;
+ } else if (params->parity == HYBBX_SERIAL_PARITY_ODD) {
+ tty.c_cflag |= PARENB | PARODD;
+ }
+ if (params->stop_bits == 2) {
+ tty.c_cflag |= CSTOPB;
+ }
+ tty.c_cc[VMIN] = 0;
+ tty.c_cc[VTIME] = 1;
+
+ if (set_baud_rate(&tty, params->baud) != HYBBX_OK) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tcsetattr(fd, TCSANOW, &tty) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ tcflush(fd, TCIOFLUSH);
+ return posix_assert_modem_lines(fd, params->assert_modem_lines);
+}
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params)
+{
+ hybbx_serial_port_t *port;
+ hybbx_result_t rc;
+
+ if (out == NULL || device == NULL || params == NULL || params->baud == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ port = calloc(1, sizeof(*port));
+ if (port == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ port->fd = open(device, O_RDWR | O_NOCTTY | O_NONBLOCK);
+ if (port->fd < 0) {
+ hybbx_log_warn("[serial] open %s failed: %s", device, strerror(errno));
+ free(port);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = configure_port(port->fd, params);
+ if (rc != HYBBX_OK) {
+ close(port->fd);
+ free(port);
+ return rc;
+ }
+
+ port->baud = params->baud;
+ port->assert_modem_lines = params->assert_modem_lines;
+ *out = port;
+ return HYBBX_OK;
+}
+
+void hybbx_serial_close(hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return;
+ }
+
+ if (port->fd >= 0) {
+ close(port->fd);
+ }
+ free(port);
+}
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len)
+{
+ size_t written = 0;
+
+ if (port == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (port->assert_modem_lines) {
+ hybbx_result_t rc = posix_assert_modem_lines(port->fd, 1);
+
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ while (written < len) {
+ ssize_t n = write(port->fd, data + written, len - written);
+
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ written += (size_t)n;
+ }
+
+ if (tcdrain(port->fd) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ ssize_t n;
+
+ if (port == NULL || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = read(port->fd, buf, buf_len);
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+int hybbx_serial_fd(const hybbx_serial_port_t *port)
+{
+ if (port == NULL) {
+ return -1;
+ }
+
+ return port->fd;
+}
+
+#endif
diff --git a/plugins/packet_radio/serial_port.h b/plugins/packet_radio/serial_port.h
new file mode 100644
index 0000000..549d3c5
--- /dev/null
+++ b/plugins/packet_radio/serial_port.h
@@ -0,0 +1,52 @@
+#ifndef HYBBX_SERIAL_PORT_H
+#define HYBBX_SERIAL_PORT_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_serial_port hybbx_serial_port_t;
+
+typedef enum hybbx_serial_parity {
+ HYBBX_SERIAL_PARITY_NONE = 0,
+ HYBBX_SERIAL_PARITY_EVEN = 1,
+ HYBBX_SERIAL_PARITY_ODD = 2
+} hybbx_serial_parity_t;
+
+typedef struct hybbx_serial_params {
+ unsigned int baud;
+ unsigned int data_bits;
+ hybbx_serial_parity_t parity;
+ unsigned int stop_bits;
+ int assert_modem_lines;
+} hybbx_serial_params_t;
+
+void hybbx_serial_params_default(hybbx_serial_params_t *params,
+ unsigned int baud);
+
+hybbx_result_t hybbx_serial_open(hybbx_serial_port_t **out,
+ const char *device,
+ const hybbx_serial_params_t *params);
+
+void hybbx_serial_close(hybbx_serial_port_t *port);
+
+hybbx_result_t hybbx_serial_write(hybbx_serial_port_t *port,
+ const uint8_t *data, size_t len);
+
+hybbx_result_t hybbx_serial_read(hybbx_serial_port_t *port,
+ uint8_t *buf, size_t buf_len,
+ size_t *read_len);
+
+/** POSIX serial fd for poll/select; returns -1 when unavailable. */
+int hybbx_serial_fd(const hybbx_serial_port_t *port);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SERIAL_PORT_H */
diff --git a/plugins/packet_radio/sixpack.c b/plugins/packet_radio/sixpack.c
new file mode 100644
index 0000000..28c0510
--- /dev/null
+++ b/plugins/packet_radio/sixpack.c
@@ -0,0 +1,146 @@
+#include "sixpack.h"
+
+#include <string.h>
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec)
+{
+ if (dec == NULL) {
+ return;
+ }
+
+ memset(dec, 0, sizeof(*dec));
+}
+
+static void sixpack_reset(hybbx_sixpack_decoder_t *dec)
+{
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ dec->in_frame = 0;
+}
+
+static void sixpack_deliver(hybbx_sixpack_decoder_t *dec,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ if (cb != NULL && dec->len > 0) {
+ cb(dec->buf, dec->len, userdata);
+ }
+
+ sixpack_reset(dec);
+}
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata)
+{
+ size_t i;
+
+ if (dec == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t raw = data[i];
+ uint8_t byte = raw;
+
+ if (!dec->in_frame) {
+ if (raw == HYBBX_SIXPACK_LEAD) {
+ dec->in_frame = 1;
+ dec->len = 0;
+ dec->count = 0;
+ dec->checksum = 0;
+ }
+ continue;
+ }
+
+ if (dec->count == 0) {
+ dec->count = raw;
+ dec->checksum = raw;
+ continue;
+ }
+
+ dec->count--;
+ dec->checksum = (uint8_t)(dec->checksum + raw);
+
+ if (raw < 0x30u) {
+ byte = (uint8_t)(raw + 0x40u);
+ }
+
+ if (dec->count > 0) {
+ if (dec->len >= HYBBX_KISS_MAX_FRAME) {
+ sixpack_reset(dec);
+ continue;
+ }
+ dec->buf[dec->len++] = byte;
+ continue;
+ }
+
+ if (dec->checksum == 0) {
+ sixpack_deliver(dec, cb, userdata);
+ } else {
+ sixpack_reset(dec);
+ }
+ }
+}
+
+static size_t sixpack_write_encoded(uint8_t byte, uint8_t *out, size_t out_cap,
+ size_t pos)
+{
+ if (byte < 0x30u) {
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+ out[pos++] = (uint8_t)(byte + 0x40u);
+ return pos;
+ }
+
+ if (pos + 1 > out_cap) {
+ return 0;
+ }
+
+ out[pos++] = byte;
+ return pos;
+}
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap)
+{
+ uint8_t scratch[HYBBX_KISS_MAX_FRAME + 4];
+ size_t enc_len = 0;
+ size_t i;
+ uint8_t count;
+ uint8_t sum;
+ size_t pos = 0;
+
+ if (frame == NULL || out == NULL || frame_len == 0 ||
+ frame_len > HYBBX_KISS_MAX_FRAME) {
+ return 0;
+ }
+
+ for (i = 0; i < frame_len; i++) {
+ enc_len = sixpack_write_encoded(frame[i], scratch, sizeof(scratch),
+ enc_len);
+ if (enc_len == 0) {
+ return 0;
+ }
+ }
+
+ if (enc_len + 4 > out_cap || enc_len + 1 > 255) {
+ return 0;
+ }
+
+ count = (uint8_t)(enc_len + 1);
+ sum = count;
+ for (i = 0; i < enc_len; i++) {
+ sum = (uint8_t)(sum + scratch[i]);
+ }
+
+ out[pos++] = HYBBX_SIXPACK_LEAD;
+ out[pos++] = count;
+ for (i = 0; i < enc_len; i++) {
+ out[pos++] = scratch[i];
+ }
+ out[pos++] = (uint8_t)(0x100u - (unsigned)sum);
+
+ return pos;
+}
diff --git a/plugins/packet_radio/sixpack.h b/plugins/packet_radio/sixpack.h
new file mode 100644
index 0000000..0d60e87
--- /dev/null
+++ b/plugins/packet_radio/sixpack.h
@@ -0,0 +1,40 @@
+#ifndef HYBBX_SIXPACK_H
+#define HYBBX_SIXPACK_H
+
+#include "hybbx/kiss.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_SIXPACK_LEAD 0x53u /* 'S' */
+
+typedef void (*hybbx_sixpack_frame_cb)(const uint8_t *frame, size_t len,
+ void *userdata);
+
+typedef struct hybbx_sixpack_decoder {
+ uint8_t buf[HYBBX_KISS_MAX_FRAME];
+ size_t len;
+ int in_frame;
+ uint8_t count;
+ uint8_t checksum;
+} hybbx_sixpack_decoder_t;
+
+void hybbx_sixpack_decoder_init(hybbx_sixpack_decoder_t *dec);
+
+void hybbx_sixpack_decoder_feed(hybbx_sixpack_decoder_t *dec,
+ const uint8_t *data, size_t len,
+ hybbx_sixpack_frame_cb cb, void *userdata);
+
+size_t hybbx_sixpack_encode(const uint8_t *frame, size_t frame_len,
+ uint8_t *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_SIXPACK_H */
diff --git a/plugins/packet_radio/tnc.c b/plugins/packet_radio/tnc.c
new file mode 100644
index 0000000..a42480d
--- /dev/null
+++ b/plugins/packet_radio/tnc.c
@@ -0,0 +1,1080 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/tnc.h"
+#include "hybbx/kiss.h"
+#include "hybbx/rf_tx_pace.h"
+#include "hybbx/traffic.h"
+#include "serial_port.h"
+#include "sixpack.h"
+#include "tnc_host.h"
+
+#include "hybbx/log.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+
+#if !defined(_WIN32)
+#include <sys/select.h>
+#endif
+
+struct hybbx_tnc {
+ hybbx_packet_radio_config_t config;
+ hybbx_serial_port_t *serial;
+ hybbx_kiss_decoder_t kiss_dec;
+ hybbx_sixpack_decoder_t sixpack_dec;
+ hybbx_tnc_host_t host;
+ hybbx_ax25_path_t tx_path;
+ int kiss_active;
+ hybbx_kiss_entry_t kiss_entry_used;
+ int host_ready;
+ hybbx_tnc_frame_cb frame_cb;
+ hybbx_tnc_ui_cb ui_cb;
+ void *rx_userdata;
+ unsigned long long last_rx_ms;
+};
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static void config_copy(hybbx_packet_radio_config_t *dst,
+ const hybbx_packet_radio_config_t *src)
+{
+ unsigned i;
+
+ memset(dst, 0, sizeof(*dst));
+ dst->device_type = src->device_type;
+ dst->device = src->device;
+ dst->baud = src->baud;
+ dst->tnc = src->tnc;
+ dst->protocol = src->protocol;
+ dst->params = src->params;
+ dst->mycall = src->mycall;
+ dst->dest_call = src->dest_call;
+ dst->via_count = src->via_count;
+ for (i = 0; i < src->via_count; i++) {
+ dst->via[i] = src->via[i];
+ }
+}
+
+static unsigned long long tnc_monotonic_ms(void)
+{
+#if defined(CLOCK_MONOTONIC)
+ struct timespec ts;
+
+ if (clock_gettime(CLOCK_MONOTONIC, &ts) == 0) {
+ return (unsigned long long)ts.tv_sec * 1000ull +
+ (unsigned long long)ts.tv_nsec / 1000000ull;
+ }
+#endif
+ return (unsigned long long)time(NULL) * 1000ull;
+}
+
+static void tnc_note_rx(hybbx_tnc_t *tnc)
+{
+ if (tnc != NULL) {
+ tnc->last_rx_ms = tnc_monotonic_ms();
+ }
+}
+
+static hybbx_result_t tnc_wait_half_duplex_clear(hybbx_tnc_t *tnc)
+{
+ unsigned guard_ms;
+ unsigned long long now;
+ unsigned long long elapsed;
+
+ if (tnc == NULL || hybbx_tnc_duplex_is_full(tnc)) {
+ return HYBBX_OK;
+ }
+
+ guard_ms = tnc->config.params.slot * 10u;
+ if (guard_ms < 50u) {
+ guard_ms = 50u;
+ }
+
+ now = tnc_monotonic_ms();
+ if (tnc->last_rx_ms == 0 || now <= tnc->last_rx_ms) {
+ return HYBBX_OK;
+ }
+
+ elapsed = now - tnc->last_rx_ms;
+ if (elapsed >= guard_ms) {
+ return HYBBX_OK;
+ }
+
+#if defined(_WIN32)
+ Sleep((DWORD)(guard_ms - elapsed));
+#else
+ {
+ struct timeval tv;
+
+ tv.tv_sec = (time_t)((guard_ms - elapsed) / 1000u);
+ tv.tv_usec = (suseconds_t)(((guard_ms - elapsed) % 1000u) * 1000u);
+ (void)select(0, NULL, NULL, NULL, &tv);
+ }
+#endif
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_raw(hybbx_tnc_t *tnc, const uint8_t *data, size_t len)
+{
+ return hybbx_serial_write(tnc->serial, data, len);
+}
+
+static hybbx_result_t send_cmd(hybbx_tnc_t *tnc, const char *cmd)
+{
+ char line[128];
+ size_t len;
+
+ len = hybbx_tnc_host_format_cmd(cmd, line, sizeof(line));
+ if (len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return send_raw(tnc, (const uint8_t *)line, len);
+}
+
+static hybbx_result_t drain_serial(hybbx_tnc_t *tnc, unsigned rounds)
+{
+ uint8_t buf[128];
+ size_t read_len;
+ unsigned i;
+
+ for (i = 0; i < rounds; i++) {
+ if (hybbx_serial_read(tnc->serial, buf, sizeof(buf),
+ &read_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (read_len == 0) {
+ break;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_kiss_param(hybbx_tnc_t *tnc,
+ hybbx_kiss_command_t cmd,
+ uint8_t value)
+{
+ uint8_t frame[16];
+ size_t len;
+
+ len = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port, cmd,
+ &value, 1, frame, sizeof(frame));
+ if (len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return send_raw(tnc, frame, len);
+}
+
+static hybbx_result_t tnc_apply_kiss_params(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXDELAY,
+ (uint8_t)tnc->config.params.txdelay);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_SLOTTIME,
+ (uint8_t)tnc->config.params.slot);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ rc = send_kiss_param(tnc, HYBBX_KISS_CMD_TXTAIL,
+ (uint8_t)tnc->config.params.txtail);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_kiss_param(tnc, HYBBX_KISS_CMD_FULLDUPLEX,
+ (uint8_t)(tnc->config.params.fullduplex ? 1 : 0));
+}
+
+static hybbx_result_t tnc_send_esc_at_k(hybbx_tnc_t *tnc)
+{
+ const uint8_t seq[] = { 0x1b, '@', 'K' };
+
+ return send_raw(tnc, seq, sizeof(seq));
+}
+
+static hybbx_result_t tnc_send_kiss_return_frame(hybbx_tnc_t *tnc)
+{
+ const uint8_t frame[] = { HYBBX_KISS_FEND, 0xFFu, HYBBX_KISS_FEND };
+
+ return send_raw(tnc, frame, sizeof(frame));
+}
+
+static void tnc_serial_pause_ms(unsigned ms)
+{
+#if defined(_WIN32)
+ Sleep(ms);
+#else
+ struct timespec ts;
+
+ ts.tv_sec = (time_t)(ms / 1000u);
+ ts.tv_nsec = (long)((ms % 1000u) * 1000000u);
+ (void)nanosleep(&ts, NULL);
+#endif
+}
+
+static hybbx_result_t tnc_send_jhost0(hybbx_tnc_t *tnc)
+{
+ static const uint8_t nuls[300] = { 0 };
+ static const uint8_t jhost0[] = {
+ 0x00, 0x01, 0x06, 'J', 'H', 'O', 'S', 'T', ' ', '0', '\r'
+ };
+ hybbx_result_t rc;
+
+ rc = send_raw(tnc, nuls, sizeof(nuls));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ tnc_serial_pause_ms(150);
+ return send_raw(tnc, jhost0, sizeof(jhost0));
+}
+
+static hybbx_result_t tnc_send_restart_escape(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+
+ for (i = 0; i < 3u; i++) {
+ if (send_raw(tnc, (const uint8_t *)"\x03", 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ tnc_serial_pause_ms(80);
+ }
+ tnc_serial_pause_ms(300);
+ return send_cmd(tnc, "RESTART");
+}
+
+/** Leave KISS/host/transparency → terminal (TheFirmware ladder). */
+static hybbx_result_t tnc_recover_terminal_mode(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc->kiss_active = 0;
+ (void)tnc_send_kiss_return_frame(tnc);
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 8);
+
+ (void)tnc_send_jhost0(tnc);
+ tnc_serial_pause_ms(800);
+ drain_serial(tnc, 8);
+
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(400);
+ drain_serial(tnc, 6);
+ (void)send_cmd(tnc, "INFO");
+ drain_serial(tnc, 12);
+
+ (void)tnc_send_restart_escape(tnc);
+ tnc_serial_pause_ms(1200);
+ drain_serial(tnc, 8);
+ (void)send_cmd(tnc, "kiss off");
+ tnc_serial_pause_ms(300);
+ drain_serial(tnc, 4);
+
+ return HYBBX_OK;
+}
+
+/** Leave KISS (crash/kill) so host commands (MYCALL, kiss on) work again. */
+static hybbx_result_t tnc_kiss_wakeup_host(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return tnc_recover_terminal_mode(tnc);
+}
+
+static hybbx_result_t tnc_enter_kiss_by_entry(hybbx_tnc_t *tnc,
+ hybbx_kiss_entry_t entry)
+{
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ if (tnc_send_esc_at_k(tnc) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ if (send_cmd(tnc, "kiss on") != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+
+ case HYBBX_KISS_ENTRY_AUTO:
+ if (send_cmd(tnc, "kiss on") == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_KISS_ON;
+ return HYBBX_OK;
+ }
+ if (tnc_send_esc_at_k(tnc) == HYBBX_OK) {
+ drain_serial(tnc, 8);
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_ESC_AT_K;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return HYBBX_ERR_INVALID;
+ }
+}
+
+static void tnc_exit_kiss_mode(hybbx_tnc_t *tnc)
+{
+ hybbx_kiss_exit_t exit_mode;
+
+ if (tnc == NULL || !tnc->kiss_active ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return;
+ }
+
+ exit_mode = tnc->config.params.kiss_exit;
+ if (exit_mode == HYBBX_KISS_EXIT_UNSET) {
+ exit_mode = HYBBX_KISS_EXIT_AUTO;
+ }
+ if (exit_mode == HYBBX_KISS_EXIT_AUTO) {
+ if (tnc->kiss_entry_used == HYBBX_KISS_ENTRY_ESC_AT_K) {
+ exit_mode = HYBBX_KISS_EXIT_KISS_FRAME;
+ } else {
+ exit_mode = HYBBX_KISS_EXIT_KISS_OFF;
+ }
+ }
+
+ switch (exit_mode) {
+ case HYBBX_KISS_EXIT_KISS_FRAME:
+ (void)tnc_send_kiss_return_frame(tnc);
+ break;
+ case HYBBX_KISS_EXIT_KISS_OFF:
+ (void)send_cmd(tnc, "kiss off");
+ break;
+ case HYBBX_KISS_EXIT_NONE:
+ case HYBBX_KISS_EXIT_UNSET:
+ default:
+ break;
+ }
+}
+
+static hybbx_result_t tnc_enter_kiss_mode(hybbx_tnc_t *tnc)
+{
+ return tnc_enter_kiss_by_entry(tnc, tnc->config.params.kiss_entry);
+}
+
+static void tnc_deliver_ui(hybbx_tnc_t *tnc, const uint8_t *frame, size_t len)
+{
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len == 0) {
+ return;
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_kiss_frame(uint8_t port, const uint8_t *frame, size_t len,
+ void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+ uint8_t payload[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t payload_len;
+
+ (void)port;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb == NULL) {
+ return;
+ }
+
+ payload_len = hybbx_ax25_parse_ui(frame, len, &path, payload,
+ sizeof(payload));
+ if (payload_len > 0) {
+ tnc->ui_cb(payload, payload_len, &path, tnc->rx_userdata);
+ }
+}
+
+static void on_sixpack_frame(const uint8_t *frame, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->frame_cb != NULL) {
+ tnc->frame_cb(frame, len, tnc->rx_userdata);
+ }
+
+ if (tnc->ui_cb != NULL) {
+ tnc_deliver_ui(tnc, frame, len);
+ }
+}
+
+static void on_host_text(const uint8_t *data, size_t len, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (tnc->ui_cb != NULL) {
+ tnc->ui_cb(data, len, NULL, tnc->rx_userdata);
+ }
+
+ tnc_note_rx(tnc);
+}
+
+static void on_host_event(hybbx_tnc_host_state_t state, void *userdata)
+{
+ hybbx_tnc_t *tnc = (hybbx_tnc_t *)userdata;
+
+ if (state == HYBBX_TNC_HOST_CMD) {
+ tnc->host_ready = 1;
+ }
+
+ hybbx_log_debug("[tnc] host state -> %d", (int)state);
+}
+
+static hybbx_result_t tnc_build_tx_path(hybbx_tnc_t *tnc)
+{
+ unsigned i;
+ hybbx_result_t rc;
+
+ memset(&tnc->tx_path, 0, sizeof(tnc->tx_path));
+
+ if (tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.dest_call,
+ &tnc->tx_path.dest);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("QST", &tnc->tx_path.dest);
+ }
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ rc = hybbx_ax25_address_parse(tnc->config.mycall,
+ &tnc->tx_path.source);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else {
+ (void)hybbx_ax25_address_parse("HYBBX", &tnc->tx_path.source);
+ }
+
+ for (i = 0; i < tnc->config.via_count; i++) {
+ rc = hybbx_ax25_address_parse(tnc->config.via[i],
+ &tnc->tx_path.digi[i]);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ tnc->tx_path.digi_count = tnc->config.via_count;
+
+ return HYBBX_OK;
+}
+
+static const char *serial_parity_letter(hybbx_tnc_serial_parity_t parity)
+{
+ switch (parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ return "E";
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ return "O";
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ return "N";
+ }
+}
+
+static void tnc_serial_params_from_config(const hybbx_packet_radio_config_t *config,
+ hybbx_serial_params_t *out)
+{
+ hybbx_serial_params_default(out, config->baud);
+ out->data_bits = config->params.data_bits;
+ out->stop_bits = config->params.stop_bits;
+ out->assert_modem_lines = config->params.assert_modem_lines;
+
+ switch (config->params.serial_parity) {
+ case HYBBX_TNC_SERIAL_PARITY_EVEN:
+ out->parity = HYBBX_SERIAL_PARITY_EVEN;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_ODD:
+ out->parity = HYBBX_SERIAL_PARITY_ODD;
+ break;
+ case HYBBX_TNC_SERIAL_PARITY_NONE:
+ default:
+ out->parity = HYBBX_SERIAL_PARITY_NONE;
+ break;
+ }
+}
+
+static const char *tnc_profile_name(hybbx_packet_radio_tnc_t tnc)
+{
+ return hybbx_packet_radio_tnc_name(tnc);
+}
+
+static const char *modem_name(hybbx_tnc2c_modem_t modem)
+{
+ switch (modem) {
+ case HYBBX_TNC2C_MODEM_9600:
+ return "9600";
+ case HYBBX_TNC2C_MODEM_19200:
+ return "19200";
+ case HYBBX_TNC2C_MODEM_TCM3105:
+ default:
+ return "tcm3105";
+ }
+}
+
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc);
+
+static hybbx_result_t tnc_profile_init(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+ char cmd[96];
+
+ rc = tnc_build_tx_path(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ if (tnc->config.params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ tnc->kiss_active = 1;
+ tnc->kiss_entry_used = HYBBX_KISS_ENTRY_NONE;
+ hybbx_log_info("[tnc] KISS attach (MAX25 prep assumed)");
+ return HYBBX_OK;
+ }
+
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ (void)tnc_recover_terminal_mode(tnc);
+ hybbx_log_info("[tnc] TheFirmware terminal recovery before KISS entry");
+ }
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ tnc->kiss_active = 0;
+ return HYBBX_OK;
+ }
+
+ /* Host mode setup (firmware TNC profiles only; not BayCom/based SER12 / PC-COM modem). */
+ drain_serial(tnc, 4);
+ (void)send_cmd(tnc, "");
+
+ if (tnc->config.mycall != NULL && tnc->config.mycall[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "MYCALL %s", tnc->config.mycall);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ snprintf(cmd, sizeof(cmd), "TXDELAY %u", tnc->config.params.txdelay);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "Persist %u", tnc->config.params.persist);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "SlotTime %u", tnc->config.params.slot);
+ (void)send_cmd(tnc, cmd);
+ snprintf(cmd, sizeof(cmd), "TXTAIL %u", tnc->config.params.txtail);
+ (void)send_cmd(tnc, cmd);
+
+ if (tnc->config.params.fullduplex) {
+ (void)send_cmd(tnc, "FULLDUP ON");
+ } else {
+ (void)send_cmd(tnc, "FULLDUP OFF");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_PCCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ (void)send_cmd(tnc, "MAXFRAME 4");
+ }
+
+ if (tnc->config.tnc == HYBBX_PACKET_RADIO_TNC_BAYCOM) {
+ (void)send_cmd(tnc, "PACLEN 256");
+ }
+
+ if (tnc->config.params.host_connect_on_start &&
+ tnc->config.dest_call != NULL && tnc->config.dest_call[0] != '\0') {
+ snprintf(cmd, sizeof(cmd), "C %s", tnc->config.dest_call);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ drain_serial(tnc, 6);
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_open(hybbx_tnc_t **out,
+ const hybbx_packet_radio_config_t *config,
+ hybbx_tnc_frame_cb frame_cb,
+ hybbx_tnc_ui_cb ui_cb,
+ void *rx_userdata)
+{
+ hybbx_tnc_t *tnc;
+ hybbx_result_t rc;
+
+ if (out == NULL || config == NULL || config->device == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (frame_cb == NULL && ui_cb == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc = calloc(1, sizeof(*tnc));
+ if (tnc == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+
+ config_copy(&tnc->config, config);
+ tnc->frame_cb = frame_cb;
+ tnc->ui_cb = ui_cb;
+ tnc->rx_userdata = rx_userdata;
+
+ hybbx_kiss_decoder_init(&tnc->kiss_dec);
+ hybbx_sixpack_decoder_init(&tnc->sixpack_dec);
+ hybbx_tnc_host_init(&tnc->host, on_host_text, on_host_event, tnc);
+
+ hybbx_serial_params_t serial_params;
+
+ tnc_serial_params_from_config(config, &serial_params);
+
+ rc = hybbx_serial_open(&tnc->serial, config->device, &serial_params);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] cannot open %s at %u baud (%u%c%u) — "
+ "device not available or permission denied",
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits);
+ free(tnc);
+ return rc;
+ }
+
+ hybbx_log_info("[tnc] profile=%s protocol=%u device=%s host=%u %u%c%u rts_dtr=%s "
+ "kiss_entry=%s radio=%u modem=%s modulation=%s band=%s duplex=%s "
+ "txdelay=%u persist=%u",
+ tnc_profile_name(config->tnc), (unsigned)config->protocol,
+ config->device, config->baud, serial_params.data_bits,
+ *serial_parity_letter(config->params.serial_parity),
+ serial_params.stop_bits,
+ serial_params.assert_modem_lines ? "on" : "off",
+ hybbx_kiss_entry_name(config->params.kiss_entry),
+ config->params.radio_baud,
+ modem_name(config->params.modem),
+ hybbx_packet_radio_modulation_name(config->params.modulation),
+ hybbx_packet_radio_band_name(config->params.band),
+ hybbx_packet_radio_duplex_name(config->params.duplex),
+ config->params.txdelay,
+ config->params.persist);
+
+ if (config->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ serial_params.data_bits == 8 &&
+ config->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_NONE) {
+ hybbx_log_warn("[tnc] tnc2c %s: 8N1 host line — use serial_line=7E1 if KISS/UNPROTO TX fails",
+ config->device);
+ }
+
+ rc = tnc_profile_init(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_tnc_close(tnc);
+ return rc;
+ }
+
+ if (config->protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ hybbx_log_info("[tnc] KISS active (port %u)", config->params.kiss_port);
+ if (tnc_profile_thefirmware(config->tnc)) {
+ hybbx_log_info("[tnc] UI transmit=TheFirmware UNPROTO (KISS for RX)");
+ }
+ } else if (config->protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ hybbx_log_info("[tnc] 6PACK active");
+ } else {
+ hybbx_log_info("[tnc] TNC2 host mode active");
+ }
+
+ *out = tnc;
+ return HYBBX_OK;
+}
+
+void hybbx_tnc_close(hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return;
+ }
+
+ if (tnc->serial != NULL) {
+ tnc_exit_kiss_mode(tnc);
+ hybbx_serial_close(tnc->serial);
+ }
+
+ free(tnc);
+}
+
+int hybbx_tnc_serial_fd(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL || tnc->serial == NULL) {
+ return -1;
+ }
+
+ return hybbx_serial_fd(tnc->serial);
+}
+
+static hybbx_result_t tnc_ensure_kiss_tx(hybbx_tnc_t *tnc)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL ||
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_OK;
+ }
+
+ if (!tnc->kiss_active) {
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS TX blocked — host mode (re-entry failed)");
+ return rc;
+ }
+ hybbx_log_info("[tnc] KISS re-entered before RF TX");
+ return tnc_apply_kiss_params(tnc);
+ }
+
+ return HYBBX_OK;
+}
+
+/** KISS DATA: host sends AX.25 body only; TheFirmware adds FCS on air. */
+static size_t tnc_kiss_ax25_len(const uint8_t *frame, size_t len)
+{
+ uint16_t crc_rx;
+ uint16_t crc_calc;
+
+ if (len < 16) {
+ return len;
+ }
+
+ crc_rx = (uint16_t)frame[len - 2] | ((uint16_t)frame[len - 1] << 8);
+ crc_calc = hybbx_ax25_crc(frame, len - 2);
+ if (crc_rx == crc_calc) {
+ return len - 2;
+ }
+
+ return len;
+}
+
+static int tnc_profile_thefirmware(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return 1;
+ default:
+ return 0;
+ }
+}
+
+static void tnc_format_ax25_call(const hybbx_ax25_address_t *addr,
+ char *out, size_t out_len)
+{
+ if (addr == NULL || out == NULL || out_len == 0) {
+ return;
+ }
+
+ snprintf(out, out_len, "%s", addr->call);
+ if (addr->ssid > 0) {
+ size_t n = strlen(out);
+
+ if (n + 4 < out_len) {
+ snprintf(out + n, out_len - n, "-%u", addr->ssid);
+ }
+ }
+}
+
+/**
+ * TheFirmware (TNC-2 class) often ignores KISS DATA on hybrid host/KISS builds
+ * while UNPROTO from cmd: mode keys PTT reliably. Exit KISS, send, re-enter.
+ */
+static hybbx_result_t tnc_thefirmware_unproto_send(hybbx_tnc_t *tnc,
+ const hybbx_ax25_path_t *path,
+ const uint8_t *payload,
+ size_t payload_len)
+{
+ char cmd[320];
+ char dest[HYBBX_AX25_CALL_MAX + 8];
+ hybbx_result_t rc;
+
+ if (tnc == NULL || path == NULL || payload == NULL || payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (payload_len + 64 >= sizeof(cmd)) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ tnc_format_ax25_call(&path->dest, dest, sizeof(dest));
+
+ rc = tnc_kiss_wakeup_host(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ (void)send_cmd(tnc, "MON OFF");
+
+ snprintf(cmd, sizeof(cmd), "UNPROTO %s 0 %.*s",
+ dest, (int)payload_len, (const char *)payload);
+ rc = send_cmd(tnc, cmd);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] UNPROTO %s host send failed", dest);
+ return rc;
+ }
+
+ drain_serial(tnc, 24);
+ tnc_serial_pause_ms(500);
+
+ hybbx_log_info("[tnc] TheFirmware UNPROTO %s (%zu bytes)", dest, payload_len);
+
+ rc = tnc_enter_kiss_mode(tnc);
+ if (rc != HYBBX_OK) {
+ hybbx_log_warn("[tnc] KISS re-entry after UNPROTO failed");
+ return rc;
+ }
+
+ return tnc_apply_kiss_params(tnc);
+}
+
+hybbx_result_t hybbx_tnc_send_frame(hybbx_tnc_t *tnc,
+ const uint8_t *frame, size_t len)
+{
+ uint8_t buf[HYBBX_KISS_MAX_FRAME + 8];
+ size_t encoded;
+ hybbx_result_t rc;
+
+ if (tnc == NULL || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_rf_tx_pace();
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_SIXPACK) {
+ encoded = hybbx_sixpack_encode(frame, len, buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+ return send_raw(tnc, buf, encoded);
+ }
+
+ if (!tnc->kiss_active &&
+ tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_KISS) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS &&
+ tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_ax25_path_t path;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ size_t ui_len = hybbx_ax25_parse_ui(frame, len, &path, ui, sizeof(ui));
+
+ if (ui_len > 0) {
+ return tnc_thefirmware_unproto_send(tnc, &path, ui, ui_len);
+ }
+ }
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ rc = tnc_ensure_kiss_tx(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ const uint8_t *tx_frame = frame;
+ size_t tx_len = len;
+
+ if (tnc->config.protocol == HYBBX_PACKET_RADIO_PROTO_KISS) {
+ tx_len = tnc_kiss_ax25_len(frame, len);
+ tx_frame = frame;
+ (void)send_kiss_param(tnc, HYBBX_KISS_CMD_PERSIST,
+ (uint8_t)tnc->config.params.persist);
+ if (tnc_profile_thefirmware(tnc->config.tnc)) {
+ hybbx_log_warn("[tnc] UI parse failed (%zu bytes) — KISS DATA fallback",
+ len);
+ }
+ }
+
+ encoded = hybbx_kiss_encode((uint8_t)tnc->config.params.kiss_port,
+ HYBBX_KISS_CMD_DATA, tx_frame, tx_len,
+ buf, sizeof(buf));
+ if (encoded == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_log_debug("[tnc] KISS DATA ax25=%zu kiss=%zu%s",
+ tx_len, encoded,
+ tx_len + 2 == len ? " (fcs stripped)" : "");
+
+ return send_raw(tnc, buf, encoded);
+ }
+}
+
+hybbx_result_t hybbx_tnc_send_ui(hybbx_tnc_t *tnc,
+ const uint8_t *payload, size_t len)
+{
+ uint8_t frame[HYBBX_AX25_FRAME_MAX];
+ size_t frame_len;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_ax25_build_ui(&tnc->tx_path, payload, len,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return hybbx_tnc_send_frame(tnc, frame, frame_len);
+}
+
+hybbx_result_t hybbx_tnc_send_converse(hybbx_tnc_t *tnc,
+ const char *data, size_t len)
+{
+ hybbx_result_t rc;
+
+ if (tnc == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (tnc->config.protocol != HYBBX_PACKET_RADIO_PROTO_HOSTMODE) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ rc = tnc_wait_half_duplex_clear(tnc);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ return send_raw(tnc, (const uint8_t *)data, len);
+}
+
+hybbx_packet_radio_duplex_t hybbx_tnc_duplex(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ return tnc->config.params.duplex;
+}
+
+hybbx_packet_radio_band_t hybbx_tnc_band(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return HYBBX_PACKET_RADIO_BAND_UNSET;
+ }
+
+ return tnc->config.params.band;
+}
+
+int hybbx_tnc_duplex_is_full(const hybbx_tnc_t *tnc)
+{
+ return tnc != NULL &&
+ tnc->config.params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL;
+}
+
+int hybbx_tnc_host_connected(const hybbx_tnc_t *tnc)
+{
+ if (tnc == NULL) {
+ return 0;
+ }
+
+ return tnc->host.state == HYBBX_TNC_HOST_CONNECTED;
+}
+
+hybbx_result_t hybbx_tnc_poll(hybbx_tnc_t *tnc)
+{
+ uint8_t buf[256];
+ size_t read_len;
+ hybbx_result_t rc;
+
+ if (tnc == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = hybbx_serial_read(tnc->serial, buf, sizeof(buf), &read_len);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (read_len == 0) {
+ return HYBBX_OK;
+ }
+
+ switch (tnc->config.protocol) {
+ case HYBBX_PACKET_RADIO_PROTO_KISS:
+ hybbx_kiss_decoder_feed(&tnc->kiss_dec, buf, read_len,
+ on_kiss_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_SIXPACK:
+ hybbx_sixpack_decoder_feed(&tnc->sixpack_dec, buf, read_len,
+ on_sixpack_frame, tnc);
+ break;
+ case HYBBX_PACKET_RADIO_PROTO_HOSTMODE:
+ hybbx_tnc_host_feed(&tnc->host, buf, read_len);
+ break;
+ default:
+ break;
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/packet_radio/tnc_host.c b/plugins/packet_radio/tnc_host.c
new file mode 100644
index 0000000..8700e61
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.c
@@ -0,0 +1,143 @@
+#include "tnc_host.h"
+
+#include <ctype.h>
+#include <string.h>
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ memset(host, 0, sizeof(*host));
+ host->state = HYBBX_TNC_HOST_BOOT;
+ host->text_cb = text_cb;
+ host->event_cb = event_cb;
+ host->userdata = userdata;
+}
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host)
+{
+ if (host == NULL) {
+ return;
+ }
+
+ host->line_len = 0;
+ host->line[0] = '\0';
+ host->state = HYBBX_TNC_HOST_BOOT;
+}
+
+static int host_line_ieq(const char *line, const char *prefix)
+{
+ size_t i;
+ size_t plen = strlen(prefix);
+
+ for (i = 0; i < plen; i++) {
+ char a = (char)tolower((unsigned char)line[i]);
+ char b = (char)tolower((unsigned char)prefix[i]);
+
+ if (a != b) {
+ return 0;
+ }
+ }
+
+ return 1;
+}
+
+static void host_emit_event(hybbx_tnc_host_t *host, hybbx_tnc_host_state_t state)
+{
+ if (host->state == state) {
+ return;
+ }
+
+ host->state = state;
+ if (host->event_cb != NULL) {
+ host->event_cb(state, host->userdata);
+ }
+}
+
+static void host_process_line(hybbx_tnc_host_t *host)
+{
+ const char *line = host->line;
+
+ if (host_line_ieq(line, "cmd:") || host_line_ieq(line, "cmd: ")) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** connected") ||
+ host_line_ieq(line, "*** connected ") ||
+ strstr(line, "CONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CONNECTED);
+ return;
+ }
+
+ if (strstr(line, "***") != NULL &&
+ (host_line_ieq(line, "*** disconnected") ||
+ strstr(line, "DISCONNECTED") != NULL)) {
+ host_emit_event(host, HYBBX_TNC_HOST_CMD);
+ return;
+ }
+
+ if (host->state == HYBBX_TNC_HOST_CONNECTED && host->text_cb != NULL &&
+ line[0] != '\0') {
+ host->text_cb((const uint8_t *)line, strlen(line), host->userdata);
+ host->text_cb((const uint8_t *)"\r\n", 2, host->userdata);
+ }
+}
+
+static void host_push_byte(hybbx_tnc_host_t *host, uint8_t byte)
+{
+ if (byte == '\r' || byte == '\n') {
+ if (host->line_len > 0) {
+ host->line[host->line_len] = '\0';
+ host_process_line(host);
+ host->line_len = 0;
+ }
+ return;
+ }
+
+ if (host->line_len + 1 >= sizeof(host->line)) {
+ host->line_len = 0;
+ return;
+ }
+
+ host->line[host->line_len++] = (char)byte;
+}
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len)
+{
+ size_t i;
+
+ if (host == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ host_push_byte(host, data[i]);
+ }
+}
+
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap)
+{
+ size_t len;
+
+ if (cmd == NULL || out == NULL || out_cap < 3) {
+ return 0;
+ }
+
+ len = strlen(cmd);
+ if (len + 2 >= out_cap) {
+ return 0;
+ }
+
+ memcpy(out, cmd, len);
+ out[len++] = '\r';
+ out[len] = '\0';
+ return len;
+}
diff --git a/plugins/packet_radio/tnc_host.h b/plugins/packet_radio/tnc_host.h
new file mode 100644
index 0000000..af55505
--- /dev/null
+++ b/plugins/packet_radio/tnc_host.h
@@ -0,0 +1,51 @@
+#ifndef HYBBX_TNC_HOST_H
+#define HYBBX_TNC_HOST_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef enum hybbx_tnc_host_state {
+ HYBBX_TNC_HOST_BOOT = 0,
+ HYBBX_TNC_HOST_CMD = 1,
+ HYBBX_TNC_HOST_CONNECTED = 2
+} hybbx_tnc_host_state_t;
+
+typedef void (*hybbx_tnc_host_text_cb)(const uint8_t *data, size_t len,
+ void *userdata);
+
+typedef void (*hybbx_tnc_host_event_cb)(hybbx_tnc_host_state_t state,
+ void *userdata);
+
+typedef struct hybbx_tnc_host {
+ hybbx_tnc_host_state_t state;
+ char line[256];
+ size_t line_len;
+ hybbx_tnc_host_text_cb text_cb;
+ hybbx_tnc_host_event_cb event_cb;
+ void *userdata;
+} hybbx_tnc_host_t;
+
+void hybbx_tnc_host_init(hybbx_tnc_host_t *host,
+ hybbx_tnc_host_text_cb text_cb,
+ hybbx_tnc_host_event_cb event_cb,
+ void *userdata);
+
+void hybbx_tnc_host_reset(hybbx_tnc_host_t *host);
+
+void hybbx_tnc_host_feed(hybbx_tnc_host_t *host,
+ const uint8_t *data, size_t len);
+
+/** Build a TNC2 command line (adds CR). Returns length or 0. */
+size_t hybbx_tnc_host_format_cmd(const char *cmd, char *out, size_t out_cap);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TNC_HOST_H */
diff --git a/plugins/packet_radio/tnc_profiles.c b/plugins/packet_radio/tnc_profiles.c
new file mode 100644
index 0000000..05a2397
--- /dev/null
+++ b/plugins/packet_radio/tnc_profiles.c
@@ -0,0 +1,399 @@
+#include "hybbx/tnc.h"
+#include "hybbx/log.h"
+
+#include <stdio.h>
+
+const char *hybbx_packet_radio_duplex_name(hybbx_packet_radio_duplex_t duplex)
+{
+ switch (duplex) {
+ case HYBBX_PACKET_RADIO_DUPLEX_FULL:
+ return "full";
+ case HYBBX_PACKET_RADIO_DUPLEX_HALF:
+ return "half";
+ case HYBBX_PACKET_RADIO_DUPLEX_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_band_name(hybbx_packet_radio_band_t band)
+{
+ switch (band) {
+ case HYBBX_PACKET_RADIO_BAND_CB:
+ return "cb";
+ case HYBBX_PACKET_RADIO_BAND_AMATEUR:
+ return "amateur";
+ case HYBBX_PACKET_RADIO_BAND_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+const char *hybbx_packet_radio_modulation_name(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ switch (modulation) {
+ case HYBBX_PACKET_RADIO_MOD_G3RUH_FSK:
+ return "g3ruh_fsk";
+ case HYBBX_PACKET_RADIO_MOD_AFSK:
+ return "afsk";
+ case HYBBX_PACKET_RADIO_MOD_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+int hybbx_packet_radio_modulation_is_g3ruh(
+ hybbx_packet_radio_modulation_t modulation)
+{
+ return modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_modulation(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_UNSET) {
+ cfg->params.modulation = HYBBX_PACKET_RADIO_MOD_AFSK;
+ }
+
+ if (cfg->params.modulation == HYBBX_PACKET_RADIO_MOD_G3RUH_FSK) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_9600;
+ cfg->params.radio_baud = HYBBX_G3RUH_RADIO_BAUD;
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C &&
+ cfg->params.clock_mhz <= HYBBX_TNC2C_CLOCK_4_9_MHZ) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_9_8_MHZ;
+ }
+ cfg->params.txdelay = 15;
+ cfg->params.persist = 128;
+ cfg->params.slot = 3;
+ return HYBBX_OK;
+ }
+
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_AFSK_RADIO_BAUD;
+ }
+ if (cfg->params.modem == 0) {
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_packet_radio_band_t default_band_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return HYBBX_PACKET_RADIO_BAND_CB;
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ default:
+ return HYBBX_PACKET_RADIO_BAND_AMATEUR;
+ }
+}
+
+const char *hybbx_packet_radio_tnc_name(hybbx_packet_radio_tnc_t tnc)
+{
+ switch (tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ return "baycom";
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ return "pccom";
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ return "generic";
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ return "tnc2";
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ return "pk232";
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ return "mfj1278";
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ return "kantronics";
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ return "tnc2c";
+ }
+}
+
+const char *hybbx_kiss_entry_name(hybbx_kiss_entry_t entry)
+{
+ switch (entry) {
+ case HYBBX_KISS_ENTRY_NONE:
+ return "none";
+ case HYBBX_KISS_ENTRY_KISS_ON:
+ return "kiss_on";
+ case HYBBX_KISS_ENTRY_ESC_AT_K:
+ return "esc_at_k";
+ case HYBBX_KISS_ENTRY_AUTO:
+ return "auto";
+ case HYBBX_KISS_ENTRY_UNSET:
+ default:
+ return "unset";
+ }
+}
+
+static hybbx_kiss_entry_t default_kiss_entry_for_tnc(hybbx_packet_radio_tnc_t tnc)
+{
+ (void)tnc;
+ return HYBBX_KISS_ENTRY_NONE;
+}
+
+hybbx_result_t hybbx_tnc_finalize_kiss_entry(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_UNSET) {
+ cfg->params.kiss_entry = default_kiss_entry_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.kiss_exit == HYBBX_KISS_EXIT_UNSET) {
+ if (cfg->params.kiss_entry == HYBBX_KISS_ENTRY_NONE) {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_NONE;
+ } else {
+ cfg->params.kiss_exit = HYBBX_KISS_EXIT_AUTO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_radio_duplex(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = default_band_for_tnc(cfg->tnc);
+ }
+
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) {
+ hybbx_log_warn("[tnc] radio_band=cb allows half-duplex only; "
+ "ignoring radio_duplex=full");
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+
+ cfg->params.fullduplex =
+ (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_FULL) ? 1 : 0;
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_csma(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.persist == 0) {
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB) {
+ cfg->params.persist = 255;
+ } else {
+ cfg->params.persist = 63;
+ }
+ } else if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_CB &&
+ cfg->params.persist < 255) {
+ hybbx_log_warn("[tnc] CB band persist=%u overridden to 255 for beacon TX",
+ cfg->params.persist);
+ cfg->params.persist = 255;
+ }
+
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 50;
+ }
+
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_finalize_serial_line(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (cfg->params.data_bits == 0) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.data_bits = HYBBX_TNC2C_DATA_BITS;
+ } else {
+ cfg->params.data_bits = 8;
+ }
+ }
+
+ if (cfg->params.serial_parity == HYBBX_TNC_SERIAL_PARITY_UNSET) {
+ if (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C ||
+ cfg->tnc == HYBBX_PACKET_RADIO_TNC_MFJ1278) {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_EVEN;
+ } else {
+ cfg->params.serial_parity = HYBBX_TNC_SERIAL_PARITY_NONE;
+ }
+ }
+
+ if (cfg->params.stop_bits == 0) {
+ cfg->params.stop_bits = 1;
+ }
+
+ if (cfg->params.assert_modem_lines < 0) {
+ cfg->params.assert_modem_lines =
+ (cfg->tnc == HYBBX_PACKET_RADIO_TNC_TNC2C) ? 1 : 0;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_tnc_profile_apply_defaults(hybbx_packet_radio_config_t *cfg)
+{
+ if (cfg == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ switch (cfg->tnc) {
+ case HYBBX_PACKET_RADIO_TNC_BAYCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 30;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 63;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PCCOM:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->params.band == HYBBX_PACKET_RADIO_BAND_UNSET) {
+ cfg->params.band = HYBBX_PACKET_RADIO_BAND_CB;
+ }
+ cfg->params.radio_baud = 1200;
+ cfg->params.modem = HYBBX_TNC2C_MODEM_TCM3105;
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_HOSTMODE;
+ }
+ if (cfg->params.duplex == HYBBX_PACKET_RADIO_DUPLEX_UNSET) {
+ cfg->params.duplex = HYBBX_PACKET_RADIO_DUPLEX_HALF;
+ }
+ if (cfg->params.txdelay == 0) {
+ cfg->params.txdelay = 25;
+ }
+ if (cfg->params.persist == 0) {
+ cfg->params.persist = 128;
+ }
+ if (cfg->params.slot == 0) {
+ cfg->params.slot = 10;
+ }
+ cfg->params.host_connect_on_start = 1;
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_GENERIC:
+ if (cfg->baud == 0) {
+ cfg->baud = HYBBX_PACKET_RADIO_DEFAULT_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_PK232:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_MFJ1278:
+ if (cfg->baud == 0) {
+ cfg->baud = 4800;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_KANTRONICS:
+ if (cfg->baud == 0) {
+ cfg->baud = 9600;
+ }
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+
+ case HYBBX_PACKET_RADIO_TNC_TNC2C:
+ default:
+ if (cfg->params.radio_baud == 0) {
+ cfg->params.radio_baud = HYBBX_TNC2C_DEFAULT_RADIO_BAUD;
+ }
+ if (cfg->params.clock_mhz == 0.0f) {
+ cfg->params.clock_mhz = HYBBX_TNC2C_CLOCK_4_9_MHZ;
+ }
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+ break;
+ }
+
+ if (cfg->protocol == 0) {
+ cfg->protocol = HYBBX_PACKET_RADIO_PROTO_KISS;
+ }
+
+ (void)hybbx_tnc_finalize_modulation(cfg);
+ (void)hybbx_tnc_finalize_radio_duplex(cfg);
+ (void)hybbx_tnc_finalize_csma(cfg);
+ (void)hybbx_tnc_finalize_serial_line(cfg);
+ return hybbx_tnc_finalize_kiss_entry(cfg);
+}
diff --git a/plugins/ssh/CMakeLists.txt b/plugins/ssh/CMakeLists.txt
new file mode 100644
index 0000000..9c656b8
--- /dev/null
+++ b/plugins/ssh/CMakeLists.txt
@@ -0,0 +1,35 @@
+add_library(hybbx_plugin_ssh STATIC
+ ssh.c
+ ssh_config.c
+ ssh_keys.c
+)
+
+find_package(PkgConfig REQUIRED)
+pkg_check_modules(LIBSSH REQUIRED IMPORTED_TARGET libssh)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_ssh
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_ssh
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_ssh
+ PRIVATE
+ hybbx_core
+ Threads::Threads
+ PkgConfig::LIBSSH
+)
+
+hybbx_link_plugin_instances(hybbx_plugin_ssh HYBBX_HAVE_PLUGIN_SSH)
+hybbx_apply_hardening(hybbx_plugin_ssh)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_ssh
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/ssh/ssh.c b/plugins/ssh/ssh.c
new file mode 100644
index 0000000..442653d
--- /dev/null
+++ b/plugins/ssh/ssh.c
@@ -0,0 +1,728 @@
+/*
+ * ssh — libssh transport plugin (port 3232). INI: [transport.ssh].
+ *
+ * Links dynamically against libssh (LGPL-2.1+). See share/THIRD_PARTY_NOTICES.txt.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/ssh.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <libssh/callbacks.h>
+#include <libssh/libssh.h>
+#include <libssh/server.h>
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define SSH_CLIENT_POLL_MS 30000
+#define SSH_READ_BUF 512
+
+typedef struct ssh_client {
+ int fd;
+ ssh_session session;
+ ssh_channel channel;
+ ssh_event event;
+ hybbx_session_t *hbx_session;
+ int ssh_authenticated;
+ int auth_attempts;
+ hybbx_result_t last_rc;
+ volatile int shell_ready;
+ struct ssh_server_callbacks_struct server_cb;
+ struct ssh_channel_callbacks_struct channel_cb;
+} ssh_client_t;
+
+typedef struct ssh_client_ctx {
+ ssh_client_t *client;
+} ssh_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_ssh;
+
+static hybbx_service_t *g_service;
+static hybbx_ssh_config_t g_config;
+static char g_hostkey_path[HYBBX_PATH_MAX];
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_ssh_running = 0;
+
+static hybbx_result_t ssh_plugin_stop(void);
+
+static int set_socket_options(int fd, int family)
+{
+ int on = 1;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ hybbx_socket_nosigpipe(fd);
+
+#ifdef IPV6_V6ONLY
+ if (family == AF_INET6) {
+ if (setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+ }
+#else
+ (void)family;
+#endif
+
+ return 0;
+}
+
+static int create_listen_socket(int family, const char *bind_addr, unsigned port)
+{
+ int fd;
+ int rc;
+
+ fd = socket(family, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (set_socket_options(fd, family) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (family == AF_INET6) {
+ struct sockaddr_in6 addr6;
+
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET6, bind_addr, &addr6.sin6_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ struct sockaddr_in addr4;
+
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, bind_addr, &addr4.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+/*
+ * SSH wire authentication only — accepts any username/password so the client
+ * can open a shell channel. HyBBX login (guest auto-login or /login prompt)
+ * follows [auth] in hybbx.ini, same as telnet.
+ */
+static int ssh_auth_password(ssh_session session, const char *user,
+ const char *password, void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+ (void)user;
+ (void)password;
+
+ if (client == NULL) {
+ return SSH_AUTH_DENIED;
+ }
+
+ client->auth_attempts++;
+ client->ssh_authenticated = 1;
+ return SSH_AUTH_SUCCESS;
+}
+
+static hybbx_result_t ssh_plugin_stop(void);
+
+static int ssh_channel_pty_request(ssh_session session, ssh_channel channel,
+ const char *term, int cols, int rows,
+ int py, int px, void *userdata)
+{
+ (void)session;
+ (void)channel;
+ (void)term;
+ (void)cols;
+ (void)rows;
+ (void)py;
+ (void)px;
+ (void)userdata;
+ return SSH_OK;
+}
+
+static int ssh_channel_shell_request(ssh_session session, ssh_channel channel,
+ void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+ (void)channel;
+
+ if (client == NULL) {
+ return SSH_ERROR;
+ }
+
+ client->shell_ready = 1;
+ return SSH_OK;
+}
+
+static ssh_channel ssh_channel_open(ssh_session session, void *userdata)
+{
+ ssh_client_t *client = (ssh_client_t *)userdata;
+
+ (void)session;
+
+ if (client == NULL) {
+ return NULL;
+ }
+
+ client->channel = ssh_channel_new(session);
+ if (client->channel == NULL) {
+ return NULL;
+ }
+
+ memset(&client->channel_cb, 0, sizeof(client->channel_cb));
+ client->channel_cb.userdata = client;
+ client->channel_cb.channel_pty_request_function = ssh_channel_pty_request;
+ client->channel_cb.channel_shell_request_function = ssh_channel_shell_request;
+ ssh_callbacks_init(&client->channel_cb);
+ ssh_set_channel_callbacks(client->channel, &client->channel_cb);
+
+ return client->channel;
+}
+
+static hybbx_result_t ssh_plugin_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ ssh_client_t *client;
+ size_t i;
+ int rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (ssh_client_t *)session->transport_data;
+ if (client == NULL || client->channel == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char out[2];
+ size_t out_len = 1;
+
+ out[0] = data[i];
+ if (data[i] == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ out[0] = '\r';
+ out[1] = '\n';
+ out_len = 2;
+ }
+
+ rc = ssh_channel_write(client->channel, out, out_len);
+ if (rc < 0) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void ssh_send_busy(ssh_channel channel)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (channel == NULL) {
+ return;
+ }
+
+ (void)ssh_channel_write(channel, msg, sizeof(msg) - 1u);
+}
+
+static int ssh_wait_auth_and_shell(ssh_client_t *client)
+{
+ int ticks = 0;
+
+ while (g_ssh_running) {
+ if (client->ssh_authenticated && client->channel != NULL &&
+ client->shell_ready) {
+ return 0;
+ }
+
+ if (client->auth_attempts >= 5 || ticks >= 300) {
+ return -1;
+ }
+
+ if (ssh_event_dopoll(client->event, 100) == SSH_ERROR) {
+ return -1;
+ }
+ ticks++;
+ }
+
+ return -1;
+}
+
+static void ssh_on_user_data(const uint8_t *data, size_t len, void *ctx)
+{
+ ssh_client_ctx_t *cctx = (ssh_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (cctx == NULL || cctx->client == NULL ||
+ cctx->client->hbx_session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(cctx->client->hbx_session, data, len);
+ if (rc != HYBBX_OK) {
+ cctx->client->last_rc = rc;
+ }
+}
+
+static int ssh_plugin_service_request(ssh_session session, const char *service,
+ void *userdata)
+{
+ (void)session;
+ (void)userdata;
+
+ if (service == NULL) {
+ return SSH_ERROR;
+ }
+
+ if (strcmp(service, "ssh-userauth") == 0 ||
+ strcmp(service, "ssh-connection") == 0) {
+ return SSH_OK;
+ }
+
+ return SSH_ERROR;
+}
+
+static void *ssh_client_thread(void *arg)
+{
+ ssh_client_t *client = (ssh_client_t *)arg;
+ ssh_bind sshbind = NULL;
+ ssh_client_ctx_t cctx;
+ uint8_t buf[SSH_READ_BUF];
+ hybbx_result_t rc;
+ char remote[64];
+
+ if (client == NULL || g_service == NULL) {
+ free(client);
+ return NULL;
+ }
+
+ remote[0] = '\0';
+ (void)hybbx_socket_peer_name(client->fd, remote, sizeof(remote));
+
+ sshbind = ssh_bind_new();
+ if (sshbind == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_bind_options_set(sshbind, SSH_BIND_OPTIONS_HOSTKEY,
+ g_hostkey_path) != SSH_OK) {
+ hybbx_log_warn("[ssh] host key load failed: %s", g_hostkey_path);
+ goto cleanup;
+ }
+
+ client->session = ssh_new();
+ if (client->session == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_bind_accept_fd(sshbind, client->session, client->fd) != SSH_OK) {
+ goto cleanup;
+ }
+
+ memset(&client->server_cb, 0, sizeof(client->server_cb));
+ client->server_cb.userdata = client;
+ client->server_cb.auth_password_function = ssh_auth_password;
+ client->server_cb.service_request_function = ssh_plugin_service_request;
+ client->server_cb.channel_open_request_session_function = ssh_channel_open;
+ ssh_callbacks_init(&client->server_cb);
+ ssh_set_server_callbacks(client->session, &client->server_cb);
+ ssh_set_auth_methods(client->session, SSH_AUTH_METHOD_PASSWORD);
+
+ if (ssh_server_init_kex(client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ if (ssh_handle_key_exchange(client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ client->event = ssh_event_new();
+ if (client->event == NULL) {
+ goto cleanup;
+ }
+
+ if (ssh_event_add_session(client->event, client->session) != SSH_OK) {
+ goto cleanup;
+ }
+
+ if (ssh_wait_auth_and_shell(client) != 0) {
+ goto cleanup;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_ssh, client,
+ &client->hbx_session);
+ if (rc != HYBBX_OK || client->hbx_session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ ssh_send_busy(client->channel);
+ }
+ goto cleanup;
+ }
+
+ if (remote[0] != '\0') {
+ (void)hybbx_session_set_remote(client->hbx_session, remote);
+ }
+
+ /*
+ * Telnet clients echo locally when the server sends WONT ECHO; SSH PTY
+ * clients do not. Mirror telnet UX by enabling HyBBX input echo unless
+ * [traffic] input_echo=yes already turned it on.
+ */
+ {
+ const hybbx_traffic_config_t *traffic = hybbx_traffic_config_get();
+
+ if (traffic == NULL || !traffic->input_echo) {
+ (void)hybbx_session_set_input_echo(client->hbx_session, 1);
+ }
+ }
+
+ cctx.client = client;
+ client->last_rc = HYBBX_OK;
+
+ while (g_ssh_running && ssh_channel_is_open(client->channel)) {
+ int n;
+ int ev;
+
+ ev = ssh_event_dopoll(client->event, SSH_CLIENT_POLL_MS);
+ if (ev == SSH_ERROR) {
+ break;
+ }
+
+ if (ev == 0) {
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ }
+
+ while ((n = ssh_channel_read(client->channel, buf, sizeof(buf), 0)) > 0) {
+ ssh_on_user_data((const uint8_t *)buf, (size_t)n, &cctx);
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ }
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ if (n < 0 && !ssh_channel_is_open(client->channel)) {
+ break;
+ }
+
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ }
+
+cleanup:
+ if (client->hbx_session != NULL) {
+ hybbx_session_close(client->hbx_session);
+ client->hbx_session = NULL;
+ }
+
+ if (client->channel != NULL) {
+ if (ssh_channel_is_open(client->channel)) {
+ ssh_channel_send_eof(client->channel);
+ ssh_channel_close(client->channel);
+ }
+ ssh_channel_free(client->channel);
+ client->channel = NULL;
+ }
+
+ if (client->event != NULL) {
+ ssh_event_free(client->event);
+ client->event = NULL;
+ }
+
+ if (client->session != NULL) {
+ ssh_disconnect(client->session);
+ ssh_free(client->session);
+ client->session = NULL;
+ }
+
+ if (sshbind != NULL) {
+ ssh_bind_free(sshbind);
+ sshbind = NULL;
+ }
+
+ if (client->fd >= 0) {
+ client->fd = -1;
+ }
+
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ ssh_client_t *client;
+ pthread_t thread;
+ pthread_attr_t attr;
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(client_fd);
+ return;
+ }
+
+ client->fd = client_fd;
+
+ pthread_attr_init(&attr);
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
+
+ if (pthread_create(&thread, &attr, ssh_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *ssh_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_ssh_running) {
+ struct pollfd fds[2];
+ nfds_t nfds = 0;
+ int i;
+ int ready;
+
+ memset(fds, 0, sizeof(fds));
+
+ if (g_listen_v4 >= 0) {
+ fds[nfds].fd = g_listen_v4;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (g_listen_v6 >= 0) {
+ fds[nfds].fd = g_listen_v6;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (nfds == 0) {
+ break;
+ }
+
+ ready = poll(fds, nfds, 500);
+ if (ready < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (ready == 0) {
+ continue;
+ }
+
+ for (i = 0; i < (int)nfds; i++) {
+ if (fds[i].revents & POLLIN) {
+ int client_fd = accept(fds[i].fd, NULL, NULL);
+
+ if (client_fd >= 0) {
+ if (!hybbx_security_ban_accept_fd(client_fd)) {
+ close(client_fd);
+ continue;
+ }
+ accept_client(client_fd);
+ }
+ }
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t ssh_plugin_init(hybbx_service_t *service)
+{
+ int rc;
+
+ g_service = service;
+
+ rc = ssh_init();
+ if (rc < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void ssh_plugin_shutdown(void)
+{
+ ssh_plugin_stop();
+ ssh_finalize();
+}
+
+static hybbx_result_t ssh_plugin_start(const char *config)
+{
+ char keys_resolved[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (g_ssh_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_ssh_config_parse(config, &g_config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (hybbx_path_resolve(keys_resolved, sizeof(keys_resolved),
+ g_config.hostkey_dir) != HYBBX_OK) {
+ hybbx_strlcpy(keys_resolved, g_config.hostkey_dir,
+ sizeof(keys_resolved));
+ }
+
+ rc = hybbx_ssh_keys_ensure(keys_resolved, g_hostkey_path,
+ sizeof(g_hostkey_path));
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ g_listen_v4 = -1;
+ g_listen_v6 = -1;
+
+ if (g_config.ipv4) {
+ g_listen_v4 = create_listen_socket(AF_INET, g_config.bind_v4,
+ g_config.port);
+ if (g_listen_v4 < 0) {
+ hybbx_socket_log_bind_failure("ssh", g_config.bind_v4,
+ g_config.port);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (g_config.ipv6) {
+ g_listen_v6 = create_listen_socket(AF_INET6, g_config.bind_v6,
+ g_config.port);
+ if (g_listen_v6 < 0) {
+ hybbx_log_warn("[ssh] IPv6 bind [%s]:%u skipped (%s)",
+ g_config.bind_v6, g_config.port, strerror(errno));
+ }
+ }
+
+ if (g_listen_v4 < 0 && g_listen_v6 < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ g_ssh_running = 1;
+
+ if (pthread_create(&g_accept_thread, NULL, ssh_accept_thread, NULL) != 0) {
+ g_ssh_running = 0;
+ if (g_listen_v4 >= 0) {
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+ if (g_listen_v6 >= 0) {
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ char msg[256];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, "[ssh] listening");
+ if (g_listen_v4 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv4 %s:%u",
+ g_config.bind_v4, g_config.port);
+ }
+ if (g_listen_v6 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv6 [%s]:%u",
+ g_config.bind_v6, g_config.port);
+ }
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " (libssh)");
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ssh_plugin_stop(void)
+{
+ if (!g_ssh_running) {
+ return HYBBX_OK;
+ }
+
+ g_ssh_running = 0;
+
+ if (g_listen_v4 >= 0) {
+ shutdown(g_listen_v4, SHUT_RDWR);
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+
+ if (g_listen_v6 >= 0) {
+ shutdown(g_listen_v6, SHUT_RDWR);
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+
+ pthread_join(g_accept_thread, NULL);
+ hybbx_log_info("[ssh] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_ssh = {
+ .name = "ssh",
+ .kind = HYBBX_TRANSPORT_SSH,
+ .version = 1,
+ .init = ssh_plugin_init,
+ .shutdown = ssh_plugin_shutdown,
+ .start = ssh_plugin_start,
+ .stop = ssh_plugin_stop,
+ .write = ssh_plugin_write,
+};
diff --git a/plugins/ssh/ssh_config.c b/plugins/ssh/ssh_config.c
new file mode 100644
index 0000000..77fb7db
--- /dev/null
+++ b/plugins/ssh/ssh_config.c
@@ -0,0 +1,132 @@
+#include "hybbx/ssh.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned int parse_port(const char *value)
+{
+ char *end;
+ unsigned long port;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_SSH_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_SSH_DEFAULT_PORT;
+ }
+
+ return (unsigned int)port;
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (scratch != NULL && scratch_len > 0) {
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ return value;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_ssh_config_defaults(hybbx_ssh_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_SSH_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_SSH_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ hybbx_strlcpy(config->hostkey_dir, HYBBX_SSH_DEFAULT_HOSTKEY_DIR,
+ sizeof(config->hostkey_dir));
+ config->port = HYBBX_SSH_DEFAULT_PORT;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_ssh_config_parse(const char *config,
+ hybbx_ssh_config_t *out)
+{
+ char scratch[HYBBX_SSH_BIND_V6_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_ssh_config_defaults(out);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ value = find_kv(config, "port", scratch, sizeof(scratch));
+ out->port = parse_port(value);
+
+ value = find_kv(config, "bind", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ if (strchr(value, ':') != NULL) {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ } else {
+ hybbx_strlcpy(out->bind_v4, value, sizeof(out->bind_v4));
+ }
+ }
+
+ value = find_kv(config, "bind6", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ }
+
+ value = find_kv(config, "hostkey_dir", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->hostkey_dir, value, sizeof(out->hostkey_dir));
+ }
+
+ value = find_kv(config, "ipv4", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv4 = hybbx_parse_bool(value, 1);
+ }
+
+ value = find_kv(config, "ipv6", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv6 = hybbx_parse_bool(value, 1);
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/ssh/ssh_keys.c b/plugins/ssh/ssh_keys.c
new file mode 100644
index 0000000..656b9d4
--- /dev/null
+++ b/plugins/ssh/ssh_keys.c
@@ -0,0 +1,146 @@
+#include "hybbx/ssh.h"
+#include "hybbx/util.h"
+#include "hybbx/log.h"
+
+#include <libssh/libssh.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <time.h>
+#include <unistd.h>
+
+static hybbx_result_t mkdir_keys_dir(const char *keys_dir)
+{
+ char parent[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (keys_dir == NULL || keys_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (stat(keys_dir, &st) == 0) {
+ return S_ISDIR(st.st_mode) ? HYBBX_OK : HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_dirname(keys_dir, parent, sizeof(parent)) == HYBBX_OK &&
+ parent[0] != '\0' && strcmp(parent, keys_dir) != 0 &&
+ stat(parent, &st) != 0) {
+ if (mkdir_keys_dir(parent) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (mkdir(keys_dir, 0700) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t generate_ed25519_keypair(const char *priv_path,
+ const char *pub_path)
+{
+ ssh_key key = NULL;
+ int rc;
+
+ rc = ssh_pki_generate(SSH_KEYTYPE_ED25519, 0, &key);
+ if (rc != SSH_OK || key == NULL) {
+ hybbx_log_warn("[ssh] Ed25519 generate failed (rc=%d)", rc);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = ssh_pki_export_privkey_file(key, NULL, NULL, NULL, priv_path);
+ if (rc != SSH_OK) {
+ hybbx_log_warn("[ssh] export private key failed: %s",
+ ssh_get_error(key));
+ ssh_key_free(key);
+ return HYBBX_ERR_IO;
+ }
+
+ rc = ssh_pki_export_pubkey_file(key, pub_path);
+ ssh_key_free(key);
+ if (rc != SSH_OK) {
+ hybbx_log_warn("[ssh] export public key failed");
+ return HYBBX_ERR_IO;
+ }
+
+ (void)chmod(priv_path, 0600);
+ (void)chmod(pub_path, 0644);
+ return HYBBX_OK;
+}
+
+static int hostkey_needs_rotation(const char *priv_path)
+{
+ struct stat st;
+ time_t now;
+ time_t age;
+
+ if (stat(priv_path, &st) != 0) {
+ return 0;
+ }
+
+ now = time(NULL);
+ if (now <= st.st_mtime) {
+ return 0;
+ }
+
+ age = now - st.st_mtime;
+ return age > (time_t)HYBBX_SSH_HOSTKEY_VALID_DAYS * 86400L;
+}
+
+hybbx_result_t hybbx_ssh_keys_ensure(const char *keys_dir,
+ char *hostkey_path,
+ size_t hostkey_path_len)
+{
+ char resolved_dir[HYBBX_PATH_MAX];
+ char priv_path[HYBBX_PATH_MAX];
+ char pub_path[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (keys_dir == NULL || hostkey_path == NULL || hostkey_path_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (ssh_init() < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_resolve(resolved_dir, sizeof(resolved_dir),
+ keys_dir) != HYBBX_OK) {
+ hybbx_strlcpy(resolved_dir, keys_dir, sizeof(resolved_dir));
+ }
+
+ if (mkdir_keys_dir(resolved_dir) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(priv_path, sizeof(priv_path), resolved_dir,
+ HYBBX_SSH_HOSTKEY_ED25519) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_join(pub_path, sizeof(pub_path), resolved_dir,
+ HYBBX_SSH_HOSTKEY_ED25519 ".pub") != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (stat(priv_path, &st) == 0 && hostkey_needs_rotation(priv_path)) {
+ hybbx_log_warn("[ssh] host key older than %u days — rotating %s",
+ HYBBX_SSH_HOSTKEY_VALID_DAYS, priv_path);
+ (void)unlink(priv_path);
+ (void)unlink(pub_path);
+ }
+
+ if (stat(priv_path, &st) != 0) {
+ if (generate_ed25519_keypair(priv_path, pub_path) != HYBBX_OK) {
+ hybbx_log_warn("[ssh] failed to generate host key in %s",
+ resolved_dir);
+ return HYBBX_ERR_IO;
+ }
+ hybbx_log_info("[ssh] generated host key %s (valid %u days)", priv_path,
+ HYBBX_SSH_HOSTKEY_VALID_DAYS);
+ }
+
+ hybbx_strlcpy(hostkey_path, priv_path, hostkey_path_len);
+ return HYBBX_OK;
+}
diff --git a/plugins/telnet/CMakeLists.txt b/plugins/telnet/CMakeLists.txt
new file mode 100644
index 0000000..144ba1d
--- /dev/null
+++ b/plugins/telnet/CMakeLists.txt
@@ -0,0 +1,26 @@
+add_library(hybbx_plugin_telnet STATIC
+ telnet.c
+ telnet_config.c
+ telnet_proto.c
+)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_telnet
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_telnet
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+target_link_libraries(hybbx_plugin_telnet PRIVATE hybbx_core Threads::Threads)
+hybbx_link_plugin_instances(hybbx_plugin_telnet HYBBX_HAVE_PLUGIN_TELNET)
+hybbx_apply_hardening(hybbx_plugin_telnet)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_telnet
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/telnet/telnet.c b/plugins/telnet/telnet.c
new file mode 100644
index 0000000..a857b7d
--- /dev/null
+++ b/plugins/telnet/telnet.c
@@ -0,0 +1,516 @@
+/*
+ * telnet — TCP transport plugin (port 2323). INI: [transport.telnet].
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/telnet.h"
+#include "telnet_proto.h"
+#include "hybbx/log.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+typedef struct telnet_client {
+ int fd;
+ hybbx_telnet_parser_t parser;
+} telnet_client_t;
+
+typedef struct telnet_client_ctx {
+ hybbx_session_t *session;
+ hybbx_result_t last_rc;
+} telnet_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_telnet;
+
+static hybbx_service_t *g_service;
+static hybbx_telnet_config_t g_config;
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_telnet_running = 0;
+
+static hybbx_result_t telnet_stop(void);
+
+static int set_socket_options(int fd, int family)
+{
+ int on = 1;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ hybbx_socket_nosigpipe(fd);
+
+#ifdef IPV6_V6ONLY
+ if (family == AF_INET6) {
+ if (setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+ }
+#else
+ (void)family;
+#endif
+
+ return 0;
+}
+
+static int create_listen_socket(int family, const char *bind_addr, unsigned port)
+{
+ int fd;
+ int rc;
+
+ fd = socket(family, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (set_socket_options(fd, family) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (family == AF_INET6) {
+ struct sockaddr_in6 addr6;
+
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET6, bind_addr, &addr6.sin6_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ struct sockaddr_in addr4;
+
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, bind_addr, &addr4.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static hybbx_result_t telnet_send_byte(int fd, char ch)
+{
+ ssize_t sent;
+ const char *p = &ch;
+
+ sent = send(fd, p, 1, MSG_NOSIGNAL);
+ if (sent < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ telnet_client_t *client;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (telnet_client_t *)session->transport_data;
+ if (client == NULL || client->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char ch = data[i];
+
+ if (ch == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ rc = telnet_send_byte(client->fd, '\r');
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ {
+ ssize_t sent = send(client->fd, &ch, 1, MSG_NOSIGNAL);
+
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void telnet_on_user_data(void *ctx, const uint8_t *data, size_t len)
+{
+ telnet_client_ctx_t *tctx = (telnet_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (tctx == NULL || tctx->session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(tctx->session, data, len);
+ if (rc != HYBBX_OK) {
+ tctx->last_rc = rc;
+ }
+}
+
+#define TELNET_CLIENT_POLL_MS 30000
+
+static void telnet_send_busy(int fd)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (fd < 0) {
+ return;
+ }
+
+ (void)send(fd, msg, sizeof(msg) - 1u, 0);
+}
+
+static void *telnet_client_thread(void *arg)
+{
+ telnet_client_t *client = (telnet_client_t *)arg;
+ hybbx_session_t *session = NULL;
+ telnet_client_ctx_t tctx;
+ uint8_t buf[256];
+ ssize_t n;
+ hybbx_result_t rc;
+
+ if (client == NULL || g_service == NULL) {
+ free(client);
+ return NULL;
+ }
+
+ (void)set_socket_options(client->fd, 0);
+
+ rc = hybbx_telnet_send_greeting(client->fd);
+ if (rc != HYBBX_OK) {
+ close(client->fd);
+ free(client);
+ return NULL;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_telnet, client, &session);
+ if (rc != HYBBX_OK || session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ telnet_send_busy(client->fd);
+ }
+ close(client->fd);
+ free(client);
+ return NULL;
+ }
+
+ {
+ char remote[64];
+
+ if (hybbx_socket_peer_name(client->fd, remote, sizeof(remote)) == HYBBX_OK) {
+ (void)hybbx_session_set_remote(session, remote);
+ }
+ }
+
+ hybbx_telnet_parser_init(&client->parser);
+ tctx.session = session;
+ tctx.last_rc = HYBBX_OK;
+
+ while (g_telnet_running) {
+ struct pollfd pfd;
+ int pr;
+
+ pfd.fd = client->fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+
+ pr = poll(&pfd, 1, TELNET_CLIENT_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ rc = hybbx_session_tick(session);
+ if (rc == HYBBX_SESSION_END) {
+ tctx.last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ continue;
+ }
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ n = recv(client->fd, buf, sizeof(buf), 0);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ rc = hybbx_telnet_parser_feed(&client->parser, client->fd, buf, (size_t)n,
+ telnet_on_user_data, &tctx);
+ if (rc != HYBBX_OK) {
+ break;
+ }
+
+ if (tctx.last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ }
+
+ hybbx_session_close(session);
+ shutdown(client->fd, SHUT_RDWR);
+ close(client->fd);
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ telnet_client_t *client;
+ pthread_t thread;
+ pthread_attr_t attr;
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(client_fd);
+ return;
+ }
+
+ client->fd = client_fd;
+
+ pthread_attr_init(&attr);
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
+
+ if (pthread_create(&thread, &attr, telnet_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *telnet_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_telnet_running) {
+ struct pollfd fds[2];
+ nfds_t nfds = 0;
+ int i;
+ int ready;
+
+ memset(fds, 0, sizeof(fds));
+
+ if (g_listen_v4 >= 0) {
+ fds[nfds].fd = g_listen_v4;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (g_listen_v6 >= 0) {
+ fds[nfds].fd = g_listen_v6;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (nfds == 0) {
+ break;
+ }
+
+ ready = poll(fds, nfds, 500);
+ if (ready < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (ready == 0) {
+ continue;
+ }
+
+ for (i = 0; i < (int)nfds; i++) {
+ if (fds[i].revents & POLLIN) {
+ int client_fd = accept(fds[i].fd, NULL, NULL);
+
+ if (client_fd >= 0) {
+ if (!hybbx_security_ban_accept_fd(client_fd)) {
+ close(client_fd);
+ continue;
+ }
+ accept_client(client_fd);
+ }
+ }
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t telnet_init(hybbx_service_t *service)
+{
+ g_service = service;
+ return HYBBX_OK;
+}
+
+static void telnet_shutdown(void)
+{
+ telnet_stop();
+}
+
+static hybbx_result_t telnet_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (g_telnet_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_telnet_config_parse(config, &g_config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ g_listen_v4 = -1;
+ g_listen_v6 = -1;
+
+ if (g_config.ipv4) {
+ g_listen_v4 = create_listen_socket(AF_INET, g_config.bind_v4, g_config.port);
+ if (g_listen_v4 < 0) {
+ hybbx_socket_log_bind_failure("telnet", g_config.bind_v4,
+ g_config.port);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (g_config.ipv6) {
+ g_listen_v6 = create_listen_socket(AF_INET6, g_config.bind_v6, g_config.port);
+ if (g_listen_v6 < 0) {
+ hybbx_log_warn("[telnet] IPv6 bind [%s]:%u skipped (%s)",
+ g_config.bind_v6, g_config.port, strerror(errno));
+ }
+ }
+
+ if (g_listen_v4 < 0 && g_listen_v6 < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ g_telnet_running = 1;
+
+ if (pthread_create(&g_accept_thread, NULL, telnet_accept_thread, NULL) != 0) {
+ g_telnet_running = 0;
+ if (g_listen_v4 >= 0) {
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+ if (g_listen_v6 >= 0) {
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ char msg[256];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, "[telnet] listening");
+ if (g_listen_v4 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv4 %s:%u",
+ g_config.bind_v4, g_config.port);
+ }
+ if (g_listen_v6 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv6 [%s]:%u",
+ g_config.bind_v6, g_config.port);
+ }
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_stop(void)
+{
+ if (!g_telnet_running) {
+ return HYBBX_OK;
+ }
+
+ g_telnet_running = 0;
+
+ if (g_listen_v4 >= 0) {
+ shutdown(g_listen_v4, SHUT_RDWR);
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+
+ if (g_listen_v6 >= 0) {
+ shutdown(g_listen_v6, SHUT_RDWR);
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+
+ pthread_join(g_accept_thread, NULL);
+ hybbx_log_info("[telnet] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_telnet = {
+ .name = "telnet",
+ .kind = HYBBX_TRANSPORT_TELNET,
+ .version = 1,
+ .init = telnet_init,
+ .shutdown = telnet_shutdown,
+ .start = telnet_start,
+ .stop = telnet_stop,
+ .write = telnet_write,
+};
diff --git a/plugins/telnet/telnet_config.c b/plugins/telnet/telnet_config.c
new file mode 100644
index 0000000..d154f88
--- /dev/null
+++ b/plugins/telnet/telnet_config.c
@@ -0,0 +1,125 @@
+#include "hybbx/telnet.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned int parse_port(const char *value)
+{
+ char *end;
+ unsigned long port;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_TELNET_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_TELNET_DEFAULT_PORT;
+ }
+
+ return (unsigned int)port;
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (scratch != NULL && scratch_len > 0) {
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ return value;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_telnet_config_defaults(hybbx_telnet_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_TELNET_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_TELNET_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ config->port = HYBBX_TELNET_DEFAULT_PORT;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_telnet_config_parse(const char *config,
+ hybbx_telnet_config_t *out)
+{
+ char scratch[HYBBX_TELNET_BIND_V6_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_telnet_config_defaults(out);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ value = find_kv(config, "port", scratch, sizeof(scratch));
+ out->port = parse_port(value);
+
+ value = find_kv(config, "bind", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ if (strchr(value, ':') != NULL) {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ } else {
+ hybbx_strlcpy(out->bind_v4, value, sizeof(out->bind_v4));
+ }
+ }
+
+ value = find_kv(config, "bind6", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ }
+
+ value = find_kv(config, "ipv4", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv4 = hybbx_parse_bool(value, 1);
+ }
+
+ value = find_kv(config, "ipv6", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv6 = hybbx_parse_bool(value, 1);
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/telnet/telnet_proto.c b/plugins/telnet/telnet_proto.c
new file mode 100644
index 0000000..523c68c
--- /dev/null
+++ b/plugins/telnet/telnet_proto.c
@@ -0,0 +1,213 @@
+#include "telnet_proto.h"
+
+#include "hybbx/socket.h"
+
+#include <errno.h>
+#include <string.h>
+#include <unistd.h>
+
+#define TELNET_IAC 255
+#define TELNET_DONT 254
+#define TELNET_DO 253
+#define TELNET_WONT 252
+#define TELNET_WILL 251
+#define TELNET_SB 250
+#define TELNET_GA 249
+#define TELNET_EL 248
+#define TELNET_EC 247
+#define TELNET_AYT 246
+#define TELNET_AO 245
+#define TELNET_IP 244
+#define TELNET_BRK 243
+#define TELNET_DM 242
+#define TELNET_NOP 241
+#define TELNET_SE 240
+
+#define TELNET_OPT_ECHO 1
+#define TELNET_OPT_SGA 3
+#define TELNET_OPT_TTYPE 24
+#define TELNET_OPT_NAWS 31
+#define TELNET_OPT_LINEMODE 34
+#define TELNET_OPT_CHARSET 42
+
+enum telnet_parse_state {
+ TS_DATA = 0,
+ TS_IAC,
+ TS_CMD,
+ TS_SUBNEG,
+ TS_SUBNEG_IAC
+};
+
+static hybbx_result_t telnet_send_raw(int fd, const uint8_t *data, size_t len)
+{
+ size_t sent_total = 0;
+
+ while (sent_total < len) {
+ ssize_t sent;
+
+ sent = send(fd, data + sent_total, len - sent_total, MSG_NOSIGNAL);
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ sent_total += (size_t)sent;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t telnet_send_option(int fd, uint8_t cmd, uint8_t option)
+{
+ uint8_t buf[3];
+
+ buf[0] = TELNET_IAC;
+ buf[1] = cmd;
+ buf[2] = option;
+ return telnet_send_raw(fd, buf, sizeof(buf));
+}
+
+static hybbx_result_t telnet_reply_do(int fd, uint8_t option)
+{
+ switch (option) {
+ case TELNET_OPT_ECHO:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ case TELNET_OPT_SGA:
+ return telnet_send_option(fd, TELNET_WILL, option);
+ case TELNET_OPT_TTYPE:
+ case TELNET_OPT_NAWS:
+ case TELNET_OPT_LINEMODE:
+ case TELNET_OPT_CHARSET:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ default:
+ return telnet_send_option(fd, TELNET_WONT, option);
+ }
+}
+
+static hybbx_result_t telnet_reply_will(int fd, uint8_t option)
+{
+ switch (option) {
+ case TELNET_OPT_SGA:
+ return telnet_send_option(fd, TELNET_DO, option);
+ case TELNET_OPT_ECHO:
+ return telnet_send_option(fd, TELNET_DONT, option);
+ default:
+ return telnet_send_option(fd, TELNET_DONT, option);
+ }
+}
+
+static void emit_byte(hybbx_telnet_data_cb on_data, void *ctx, uint8_t byte)
+{
+ if (on_data != NULL) {
+ on_data(ctx, &byte, 1);
+ }
+}
+
+void hybbx_telnet_parser_init(hybbx_telnet_parser_t *parser)
+{
+ if (parser == NULL) {
+ return;
+ }
+
+ parser->state = TS_DATA;
+ parser->command = 0;
+}
+
+hybbx_result_t hybbx_telnet_send_greeting(int fd)
+{
+ static const uint8_t greeting[] = {
+ TELNET_IAC, TELNET_WONT, TELNET_OPT_ECHO,
+ TELNET_IAC, TELNET_WILL, TELNET_OPT_SGA,
+ TELNET_IAC, TELNET_DONT, TELNET_OPT_LINEMODE,
+ };
+
+ if (fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ return telnet_send_raw(fd, greeting, sizeof(greeting));
+}
+
+hybbx_result_t hybbx_telnet_parser_feed(hybbx_telnet_parser_t *parser, int fd,
+ const uint8_t *data, size_t len,
+ hybbx_telnet_data_cb on_data,
+ void *ctx)
+{
+ size_t i;
+ hybbx_result_t rc;
+
+ if (parser == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ uint8_t byte = data[i];
+
+ switch (parser->state) {
+ case TS_DATA:
+ if (byte == TELNET_IAC) {
+ parser->state = TS_IAC;
+ } else {
+ emit_byte(on_data, ctx, byte);
+ }
+ break;
+
+ case TS_IAC:
+ if (byte == TELNET_IAC) {
+ emit_byte(on_data, ctx, TELNET_IAC);
+ parser->state = TS_DATA;
+ } else if (byte == TELNET_WILL || byte == TELNET_WONT ||
+ byte == TELNET_DO || byte == TELNET_DONT) {
+ parser->command = (int)byte;
+ parser->state = TS_CMD;
+ } else if (byte == TELNET_SB) {
+ parser->state = TS_SUBNEG;
+ } else {
+ parser->state = TS_DATA;
+ }
+ break;
+
+ case TS_CMD:
+ if (fd >= 0) {
+ if (parser->command == TELNET_DO) {
+ rc = telnet_reply_do(fd, byte);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ } else if (parser->command == TELNET_WILL) {
+ rc = telnet_reply_will(fd, byte);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ }
+ parser->state = TS_DATA;
+ break;
+
+ case TS_SUBNEG:
+ if (byte == TELNET_IAC) {
+ parser->state = TS_SUBNEG_IAC;
+ }
+ break;
+
+ case TS_SUBNEG_IAC:
+ if (byte == TELNET_SE) {
+ parser->state = TS_DATA;
+ } else {
+ parser->state = TS_SUBNEG;
+ }
+ break;
+
+ default:
+ parser->state = TS_DATA;
+ break;
+ }
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/telnet/telnet_proto.h b/plugins/telnet/telnet_proto.h
new file mode 100644
index 0000000..aa29122
--- /dev/null
+++ b/plugins/telnet/telnet_proto.h
@@ -0,0 +1,37 @@
+#ifndef HYBBX_TELNET_PROTO_H
+#define HYBBX_TELNET_PROTO_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_telnet_parser {
+ int state;
+ int command;
+} hybbx_telnet_parser_t;
+
+typedef void (*hybbx_telnet_data_cb)(void *ctx, const uint8_t *data, size_t len);
+
+void hybbx_telnet_parser_init(hybbx_telnet_parser_t *parser);
+
+/** Send initial option negotiation (echo, suppress go ahead). */
+hybbx_result_t hybbx_telnet_send_greeting(int fd);
+
+/**
+ * Strip telnet protocol bytes and invoke @p on_data for user payload.
+ */
+hybbx_result_t hybbx_telnet_parser_feed(hybbx_telnet_parser_t *parser, int fd,
+ const uint8_t *data, size_t len,
+ hybbx_telnet_data_cb on_data,
+ void *ctx);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_TELNET_PROTO_H */
diff --git a/plugins/websocket/CMakeLists.txt b/plugins/websocket/CMakeLists.txt
new file mode 100644
index 0000000..a63f83d
--- /dev/null
+++ b/plugins/websocket/CMakeLists.txt
@@ -0,0 +1,37 @@
+add_library(hybbx_plugin_websocket STATIC
+ websocket.c
+ ws_config.c
+ ws_proto.c
+ ws_sha1.c
+ ws_tls.c
+)
+
+find_package(Threads REQUIRED)
+
+target_include_directories(hybbx_plugin_websocket
+ PRIVATE ${CMAKE_SOURCE_DIR}/include
+)
+
+target_compile_definitions(hybbx_plugin_websocket
+ PRIVATE HYBBX_PLUGIN_BUILD
+)
+
+find_package(OpenSSL QUIET)
+if(OpenSSL_FOUND)
+ target_compile_definitions(hybbx_plugin_websocket PRIVATE HYBBX_WS_HAVE_TLS)
+ target_link_libraries(hybbx_plugin_websocket PRIVATE OpenSSL::SSL OpenSSL::Crypto)
+ message(STATUS "WebSocket plugin: OpenSSL found — wss + self-signed certs")
+else()
+ message(STATUS "WebSocket plugin: OpenSSL not found — plain ws only")
+endif()
+
+target_link_libraries(hybbx_plugin_websocket PRIVATE hybbx_core Threads::Threads)
+hybbx_link_plugin_instances(hybbx_plugin_websocket HYBBX_HAVE_PLUGIN_WEBSOCKET)
+hybbx_apply_hardening(hybbx_plugin_websocket)
+
+if(HYBBX_INSTALL_DEV)
+install(TARGETS hybbx_plugin_websocket
+ ARCHIVE DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+ LIBRARY DESTINATION ${HYBBX_PLUGIN_INSTALL_DIR}
+)
+endif()
diff --git a/plugins/websocket/websocket.c b/plugins/websocket/websocket.c
new file mode 100644
index 0000000..4ff2230
--- /dev/null
+++ b/plugins/websocket/websocket.c
@@ -0,0 +1,723 @@
+/*
+ * websocket — RFC6455 forward-proxy transport. INI: [transport.websocket].
+ *
+ * Raw byte stream to the session core (no HyBBX auth at the wire layer).
+ * Intended behind a TLS reverse proxy; see docs/WEBSOCKET.md.
+ */
+#include "hybbx/plugin.h"
+#include "hybbx/service.h"
+#include "hybbx/session.h"
+#include "hybbx/socket.h"
+#include "hybbx/security_ban.h"
+#include "hybbx/websocket.h"
+#include "hybbx/util.h"
+#include "ws_proto.h"
+#include "ws_tls.h"
+#include "hybbx/log.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <pthread.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <time.h>
+#include <unistd.h>
+
+#define WS_CLIENT_POLL_MS 5000
+#define WS_CLIENT_PING_IDLE_SEC 20
+
+typedef struct ws_client {
+ hybbx_ws_connection_t ws;
+ hybbx_session_t *hbx_session;
+ hybbx_result_t last_rc;
+ int slot_held;
+ time_t last_io_at;
+ uint8_t tx_buf[HYBBX_WS_FRAME_PAYLOAD_MAX];
+ size_t tx_len;
+} ws_client_t;
+
+typedef struct ws_client_ctx {
+ ws_client_t *client;
+} ws_client_ctx_t;
+
+extern const hybbx_transport_plugin_t hybbx_plugin_websocket;
+
+static hybbx_service_t *g_service;
+static hybbx_websocket_config_t g_config;
+static pthread_t g_accept_thread;
+static int g_listen_v4 = -1;
+static int g_listen_v6 = -1;
+static volatile int g_ws_running = 0;
+static unsigned g_active_clients;
+static pthread_mutex_t g_client_lock = PTHREAD_MUTEX_INITIALIZER;
+
+static hybbx_result_t ws_plugin_stop(void);
+
+static int set_socket_options(int fd, int family)
+{
+ int on = 1;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ hybbx_socket_nosigpipe(fd);
+
+#ifdef IPV6_V6ONLY
+ if (family == AF_INET6) {
+ if (setsockopt(fd, IPPROTO_IPV6, IPV6_V6ONLY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+ }
+#else
+ (void)family;
+#endif
+
+ return 0;
+}
+
+static int create_listen_socket(int family, const char *bind_addr, unsigned port)
+{
+ int fd;
+ int rc;
+
+ fd = socket(family, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+
+ if (set_socket_options(fd, family) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (family == AF_INET6) {
+ struct sockaddr_in6 addr6;
+
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET6, bind_addr, &addr6.sin6_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ struct sockaddr_in addr4;
+
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+
+ if (inet_pton(AF_INET, bind_addr, &addr4.sin_addr) != 1) {
+ close(fd);
+ return -1;
+ }
+
+ rc = bind(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return -1;
+ }
+
+ if (listen(fd, 16) != 0) {
+ close(fd);
+ return -1;
+ }
+
+ return fd;
+}
+
+static void log_bind_failure(const char *bind_addr, unsigned port)
+{
+ int err = errno;
+
+ hybbx_socket_log_bind_failure("websocket", bind_addr, port);
+ if (err == EACCES && port < 1024u) {
+ hybbx_log_warn("[websocket] port %u is privileged — run as root, grant "
+ "bind permission, or set port >= 1024 in hybbx.ini",
+ port);
+ }
+}
+
+static size_t utf8_char_span(const uint8_t *buf, size_t len, size_t pos)
+{
+ uint8_t b;
+ size_t n;
+ size_t i;
+
+ if (pos >= len) {
+ return 0;
+ }
+
+ b = buf[pos];
+ if (b < 0x80u) {
+ return 1;
+ }
+ if ((b & 0xE0u) == 0xC0u) {
+ n = 2;
+ } else if ((b & 0xF0u) == 0xE0u) {
+ n = 3;
+ } else if ((b & 0xF8u) == 0xF0u) {
+ n = 4;
+ } else {
+ return 1;
+ }
+
+ if (pos + n > len) {
+ return 0;
+ }
+
+ for (i = 1; i < n; i++) {
+ if ((buf[pos + i] & 0xC0u) != 0x80u) {
+ return 1;
+ }
+ }
+
+ return n;
+}
+
+static size_t utf8_complete_prefix(const uint8_t *buf, size_t len)
+{
+ size_t pos = 0;
+
+ while (pos < len) {
+ size_t span = utf8_char_span(buf, len, pos);
+
+ if (span == 0) {
+ break;
+ }
+ pos += span;
+ }
+
+ return pos;
+}
+
+static hybbx_result_t ws_tx_flush(ws_client_t *client, int force_tail)
+{
+ size_t n;
+ hybbx_result_t rc;
+
+ if (client == NULL || client->tx_len == 0) {
+ return HYBBX_OK;
+ }
+
+ n = utf8_complete_prefix(client->tx_buf, client->tx_len);
+ if (n == 0 && force_tail) {
+ client->tx_buf[0] = '?';
+ n = 1;
+ }
+ if (n == 0) {
+ return HYBBX_OK;
+ }
+
+ rc = hybbx_ws_write_text(&client->ws, (const char *)client->tx_buf, n);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ client->last_io_at = time(NULL);
+
+ if (n < client->tx_len) {
+ memmove(client->tx_buf, client->tx_buf + n, client->tx_len - n);
+ }
+ client->tx_len -= n;
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_plugin_write(hybbx_session_t *session,
+ const char *data, size_t len)
+{
+ ws_client_t *client;
+ size_t i;
+ hybbx_result_t rc;
+
+ if (session == NULL || data == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ client = (ws_client_t *)session->transport_data;
+ if (client == NULL || !client->ws.established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ for (i = 0; i < len; i++) {
+ char ch = data[i];
+
+ if (ch == '\n' && (i == 0 || data[i - 1] != '\r')) {
+ if (client->tx_len + 2 > sizeof(client->tx_buf)) {
+ rc = ws_tx_flush(client, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+ client->tx_buf[client->tx_len++] = '\r';
+ }
+
+ if (client->tx_len >= sizeof(client->tx_buf)) {
+ rc = ws_tx_flush(client, 1);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ client->tx_buf[client->tx_len++] = (uint8_t)ch;
+
+ rc = ws_tx_flush(client, 0);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+ }
+
+ return HYBBX_OK;
+}
+
+static void ws_send_busy(ws_client_t *client)
+{
+ static const char msg[] = "All nodes in use. Try later.\r\n";
+
+ if (client == NULL) {
+ return;
+ }
+
+ (void)hybbx_ws_write_text(&client->ws, msg, sizeof(msg) - 1u);
+}
+
+static void ws_send_limit(ws_client_t *client)
+{
+ static const char msg[] =
+ "All WebSocket connections in use. Try later.\r\n";
+
+ if (client == NULL) {
+ return;
+ }
+
+ (void)hybbx_ws_write_text(&client->ws, msg, sizeof(msg) - 1u);
+}
+
+static int ws_client_acquire_slot(ws_client_t *client)
+{
+ if (client == NULL) {
+ return 0;
+ }
+
+ pthread_mutex_lock(&g_client_lock);
+ if (g_active_clients >= g_config.max_connections) {
+ pthread_mutex_unlock(&g_client_lock);
+ return 0;
+ }
+
+ g_active_clients++;
+ client->slot_held = 1;
+ pthread_mutex_unlock(&g_client_lock);
+ return 1;
+}
+
+static void ws_client_release_slot(ws_client_t *client)
+{
+ if (client == NULL || !client->slot_held) {
+ return;
+ }
+
+ pthread_mutex_lock(&g_client_lock);
+ if (g_active_clients > 0) {
+ g_active_clients--;
+ }
+ client->slot_held = 0;
+ pthread_mutex_unlock(&g_client_lock);
+}
+
+static void ws_on_user_data(void *ctx, const uint8_t *data, size_t len)
+{
+ ws_client_ctx_t *wctx = (ws_client_ctx_t *)ctx;
+ hybbx_result_t rc;
+
+ if (wctx == NULL || wctx->client == NULL ||
+ wctx->client->hbx_session == NULL || data == NULL || len == 0) {
+ return;
+ }
+
+ rc = hybbx_session_handle_input(wctx->client->hbx_session, data, len);
+ if (rc != HYBBX_OK) {
+ wctx->client->last_rc = rc;
+ }
+}
+
+static void *ws_client_thread(void *arg)
+{
+ ws_client_t *client = (ws_client_t *)arg;
+ ws_client_ctx_t wctx;
+ hybbx_result_t rc;
+ char remote[64];
+
+ if (client == NULL || g_service == NULL) {
+ free(client);
+ return NULL;
+ }
+
+ remote[0] = '\0';
+ (void)hybbx_socket_peer_name(client->ws.fd, remote, sizeof(remote));
+
+ if (hybbx_ws_tls_server_enabled()) {
+ void *tls = hybbx_ws_tls_accept(client->ws.fd);
+
+ if (tls == NULL) {
+ goto cleanup;
+ }
+ hybbx_ws_connection_set_tls(&client->ws, tls);
+ }
+
+ rc = hybbx_ws_server_handshake(&client->ws, g_config.path);
+ if (rc != HYBBX_OK) {
+ goto cleanup;
+ }
+
+ if (!ws_client_acquire_slot(client)) {
+ ws_send_limit(client);
+ goto cleanup;
+ }
+
+ rc = hybbx_session_open(g_service, &hybbx_plugin_websocket, client,
+ &client->hbx_session);
+ if (rc != HYBBX_OK || client->hbx_session == NULL) {
+ if (rc == HYBBX_ERR_BUSY) {
+ ws_send_busy(client);
+ }
+ goto cleanup;
+ }
+
+ if (remote[0] != '\0') {
+ (void)hybbx_session_set_remote(client->hbx_session, remote);
+ }
+
+ wctx.client = client;
+ client->last_rc = HYBBX_OK;
+ client->last_io_at = time(NULL);
+
+ while (g_ws_running && client->ws.established) {
+ struct pollfd pfd;
+ int pr;
+
+ pfd.fd = client->ws.fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+
+ pr = poll(&pfd, 1, WS_CLIENT_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (pr == 0) {
+ time_t now = time(NULL);
+
+ if (now != (time_t)-1 &&
+ (client->last_io_at == 0 ||
+ now - client->last_io_at >= WS_CLIENT_PING_IDLE_SEC)) {
+ rc = hybbx_ws_ping(&client->ws);
+ if (rc != HYBBX_OK) {
+ break;
+ }
+ client->last_io_at = now;
+ }
+
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ continue;
+ }
+ if ((pfd.revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ continue;
+ }
+
+ rc = hybbx_ws_read_frames(&client->ws, ws_on_user_data, &wctx);
+ if (rc != HYBBX_OK) {
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+ break;
+ }
+ client->last_io_at = time(NULL);
+
+ if (client->last_rc == HYBBX_SESSION_END) {
+ break;
+ }
+
+ rc = hybbx_session_tick(client->hbx_session);
+ if (rc == HYBBX_SESSION_END) {
+ client->last_rc = HYBBX_SESSION_END;
+ break;
+ }
+ }
+
+cleanup:
+ if (client->hbx_session != NULL) {
+ hybbx_session_close(client->hbx_session);
+ client->hbx_session = NULL;
+ }
+
+ ws_client_release_slot(client);
+
+ (void)ws_tx_flush(client, 1);
+
+ if (client->ws.established) {
+ (void)hybbx_ws_close(&client->ws);
+ }
+
+ hybbx_ws_connection_cleanup(&client->ws);
+
+ if (client->ws.fd >= 0) {
+ shutdown(client->ws.fd, SHUT_RDWR);
+ close(client->ws.fd);
+ client->ws.fd = -1;
+ }
+
+ free(client);
+ return NULL;
+}
+
+static void accept_client(int client_fd)
+{
+ ws_client_t *client;
+ pthread_t thread;
+ pthread_attr_t attr;
+
+ client = calloc(1, sizeof(*client));
+ if (client == NULL) {
+ close(client_fd);
+ return;
+ }
+
+ hybbx_ws_connection_init(&client->ws, client_fd);
+
+ pthread_attr_init(&attr);
+ pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED);
+
+ if (pthread_create(&thread, &attr, ws_client_thread, client) != 0) {
+ close(client_fd);
+ free(client);
+ }
+
+ pthread_attr_destroy(&attr);
+}
+
+static void *ws_accept_thread(void *arg)
+{
+ (void)arg;
+
+ while (g_ws_running) {
+ struct pollfd fds[2];
+ nfds_t nfds = 0;
+ int i;
+ int ready;
+
+ memset(fds, 0, sizeof(fds));
+
+ if (g_listen_v4 >= 0) {
+ fds[nfds].fd = g_listen_v4;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (g_listen_v6 >= 0) {
+ fds[nfds].fd = g_listen_v6;
+ fds[nfds].events = POLLIN;
+ nfds++;
+ }
+
+ if (nfds == 0) {
+ break;
+ }
+
+ ready = poll(fds, nfds, 500);
+ if (ready < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (ready == 0) {
+ continue;
+ }
+
+ for (i = 0; i < (int)nfds; i++) {
+ if (fds[i].revents & POLLIN) {
+ int client_fd = accept(fds[i].fd, NULL, NULL);
+
+ if (client_fd >= 0) {
+ if (!hybbx_security_ban_accept_fd(client_fd)) {
+ close(client_fd);
+ continue;
+ }
+ accept_client(client_fd);
+ }
+ }
+ }
+ }
+
+ return NULL;
+}
+
+static hybbx_result_t ws_plugin_init(hybbx_service_t *service)
+{
+ g_service = service;
+ return HYBBX_OK;
+}
+
+static void ws_plugin_shutdown(void)
+{
+ ws_plugin_stop();
+}
+
+static hybbx_result_t ws_plugin_start(const char *config)
+{
+ hybbx_result_t rc;
+
+ if (g_ws_running) {
+ return HYBBX_ERR_BUSY;
+ }
+
+ rc = hybbx_websocket_config_parse(config, &g_config);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ g_listen_v4 = -1;
+ g_listen_v6 = -1;
+
+ if (g_config.ipv4) {
+ g_listen_v4 = create_listen_socket(AF_INET, g_config.bind_v4,
+ g_config.port);
+ if (g_listen_v4 < 0) {
+ log_bind_failure(g_config.bind_v4, g_config.port);
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (g_config.ipv6) {
+ g_listen_v6 = create_listen_socket(AF_INET6, g_config.bind_v6,
+ g_config.port);
+ if (g_listen_v6 < 0) {
+ hybbx_log_warn("[websocket] IPv6 bind [%s]:%u skipped (%s)",
+ g_config.bind_v6, g_config.port, strerror(errno));
+ }
+ }
+
+ if (g_listen_v4 < 0 && g_listen_v6 < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_ws_tls_compiled()) {
+ char cert_dir[HYBBX_PATH_MAX];
+ hybbx_result_t tls_rc;
+
+ if (hybbx_path_resolve(cert_dir, sizeof(cert_dir),
+ g_config.cert_dir) != HYBBX_OK) {
+ hybbx_strlcpy(cert_dir, g_config.cert_dir, sizeof(cert_dir));
+ }
+
+ tls_rc = hybbx_ws_tls_ensure_certs(cert_dir);
+
+ if (tls_rc == HYBBX_OK) {
+ tls_rc = hybbx_ws_tls_server_start(cert_dir);
+ }
+ if (tls_rc != HYBBX_OK) {
+ hybbx_log_warn("[websocket] TLS init failed (%d), plain ws only",
+ (int)tls_rc);
+ }
+ }
+
+ g_ws_running = 1;
+ g_active_clients = 0;
+
+ if (pthread_create(&g_accept_thread, NULL, ws_accept_thread, NULL) != 0) {
+ g_ws_running = 0;
+ if (g_listen_v4 >= 0) {
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+ if (g_listen_v6 >= 0) {
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+ return HYBBX_ERR_IO;
+ }
+
+ {
+ char msg[384];
+ size_t pos = 0;
+
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, "[websocket] listening");
+ if (g_listen_v4 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv4 %s:%u",
+ g_config.bind_v4, g_config.port);
+ }
+ if (g_listen_v6 >= 0) {
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos, " IPv6 [%s]:%u",
+ g_config.bind_v6, g_config.port);
+ }
+ pos += (size_t)snprintf(msg + pos, sizeof(msg) - pos,
+ " path=%s max_connections=%u (%s, forward-proxy)",
+ g_config.path, g_config.max_connections,
+ hybbx_ws_tls_server_enabled() ? "wss" : "ws");
+ hybbx_log_info("%s", msg);
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_plugin_stop(void)
+{
+ if (!g_ws_running) {
+ return HYBBX_OK;
+ }
+
+ g_ws_running = 0;
+
+ if (g_listen_v4 >= 0) {
+ shutdown(g_listen_v4, SHUT_RDWR);
+ close(g_listen_v4);
+ g_listen_v4 = -1;
+ }
+
+ if (g_listen_v6 >= 0) {
+ shutdown(g_listen_v6, SHUT_RDWR);
+ close(g_listen_v6);
+ g_listen_v6 = -1;
+ }
+
+ pthread_join(g_accept_thread, NULL);
+ hybbx_ws_tls_server_stop();
+ hybbx_log_info("[websocket] stop");
+ return HYBBX_OK;
+}
+
+const hybbx_transport_plugin_t hybbx_plugin_websocket = {
+ .name = "websocket",
+ .kind = HYBBX_TRANSPORT_WEBSOCKET,
+ .version = 1,
+ .init = ws_plugin_init,
+ .shutdown = ws_plugin_shutdown,
+ .start = ws_plugin_start,
+ .stop = ws_plugin_stop,
+ .write = ws_plugin_write,
+};
diff --git a/plugins/websocket/ws_config.c b/plugins/websocket/ws_config.c
new file mode 100644
index 0000000..baf88b2
--- /dev/null
+++ b/plugins/websocket/ws_config.c
@@ -0,0 +1,163 @@
+#include "hybbx/websocket.h"
+#include "hybbx/util.h"
+
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned int parse_port(const char *value)
+{
+ char *end;
+ unsigned long port;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_WEBSOCKET_DEFAULT_PORT;
+ }
+
+ port = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || port == 0 || port > 65535u) {
+ return HYBBX_WEBSOCKET_DEFAULT_PORT;
+ }
+
+ return (unsigned int)port;
+}
+
+static unsigned int parse_max_connections(const char *value)
+{
+ char *end;
+ unsigned long n;
+
+ if (value == NULL || value[0] == '\0') {
+ return HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ }
+
+ n = strtoul(value, &end, 10);
+ if (end == value || *end != '\0' || n == 0 || n > 65535u) {
+ return HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ }
+
+ return (unsigned int)n;
+}
+
+static const char *find_kv(const char *config, const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = config;
+ size_t key_len = strlen(key);
+
+ if (config == NULL || key == NULL) {
+ return NULL;
+ }
+
+ while (*cursor != '\0') {
+ const char *sep = strchr(cursor, ';');
+ const char *end = sep != NULL ? sep : cursor + strlen(cursor);
+ const char *eq = strchr(cursor, '=');
+
+ if (eq != NULL && eq < end && (size_t)(eq - cursor) == key_len &&
+ strncmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(end - value);
+
+ if (scratch != NULL && scratch_len > 0) {
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ return value;
+ }
+
+ if (sep == NULL) {
+ break;
+ }
+ cursor = sep + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_websocket_config_defaults(hybbx_websocket_config_t *config)
+{
+ if (config == NULL) {
+ return;
+ }
+
+ memset(config, 0, sizeof(*config));
+ hybbx_strlcpy(config->bind_v4, HYBBX_WEBSOCKET_DEFAULT_BIND_V4,
+ sizeof(config->bind_v4));
+ hybbx_strlcpy(config->bind_v6, HYBBX_WEBSOCKET_DEFAULT_BIND_V6,
+ sizeof(config->bind_v6));
+ hybbx_strlcpy(config->path, HYBBX_WEBSOCKET_DEFAULT_PATH,
+ sizeof(config->path));
+ hybbx_strlcpy(config->cert_dir, HYBBX_WEBSOCKET_DEFAULT_CERT_DIR,
+ sizeof(config->cert_dir));
+ config->port = HYBBX_WEBSOCKET_DEFAULT_PORT;
+ config->max_connections = HYBBX_WEBSOCKET_DEFAULT_MAX_CONNECTIONS;
+ config->ipv4 = 1;
+ config->ipv6 = 1;
+}
+
+hybbx_result_t hybbx_websocket_config_parse(const char *config,
+ hybbx_websocket_config_t *out)
+{
+ char scratch[HYBBX_WEBSOCKET_PATH_MAX];
+ const char *value;
+
+ if (out == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_websocket_config_defaults(out);
+
+ if (config == NULL || config[0] == '\0') {
+ return HYBBX_OK;
+ }
+
+ value = find_kv(config, "port", scratch, sizeof(scratch));
+ out->port = parse_port(value);
+
+ value = find_kv(config, "max_connections", scratch, sizeof(scratch));
+ if (value == NULL) {
+ value = find_kv(config, "max_online", scratch, sizeof(scratch));
+ }
+ out->max_connections = parse_max_connections(value);
+
+ value = find_kv(config, "bind", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ if (strchr(value, ':') != NULL) {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ } else {
+ hybbx_strlcpy(out->bind_v4, value, sizeof(out->bind_v4));
+ }
+ }
+
+ value = find_kv(config, "bind6", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->bind_v6, value, sizeof(out->bind_v6));
+ }
+
+ value = find_kv(config, "path", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->path, value, sizeof(out->path));
+ }
+
+ value = find_kv(config, "cert_dir", scratch, sizeof(scratch));
+ if (value != NULL && value[0] != '\0') {
+ hybbx_strlcpy(out->cert_dir, value, sizeof(out->cert_dir));
+ }
+
+ value = find_kv(config, "ipv4", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv4 = hybbx_parse_bool(value, 1);
+ }
+
+ value = find_kv(config, "ipv6", scratch, sizeof(scratch));
+ if (value != NULL) {
+ out->ipv6 = hybbx_parse_bool(value, 1);
+ }
+
+ return HYBBX_OK;
+}
diff --git a/plugins/websocket/ws_proto.c b/plugins/websocket/ws_proto.c
new file mode 100644
index 0000000..b0e63b5
--- /dev/null
+++ b/plugins/websocket/ws_proto.c
@@ -0,0 +1,565 @@
+#include "ws_proto.h"
+#include "ws_sha1.h"
+#include "ws_tls.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <sys/socket.h>
+#include <unistd.h>
+
+#define WS_GUID "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"
+
+static int header_has_token(const char *value, const char *token)
+{
+ const char *p;
+
+ if (value == NULL || token == NULL) {
+ return 0;
+ }
+
+ for (p = value; *p != '\0'; p++) {
+ if (strncasecmp(p, token, strlen(token)) == 0) {
+ return 1;
+ }
+ }
+
+ return 0;
+}
+
+static int header_value_equals(const char *value, const char *expected)
+{
+ size_t expected_len;
+
+ if (value == NULL || expected == NULL) {
+ return 0;
+ }
+
+ expected_len = strlen(expected);
+ if (strncasecmp(value, expected, expected_len) != 0) {
+ return 0;
+ }
+
+ {
+ char c = value[expected_len];
+
+ return c == '\0' || c == '\r' || c == ' ' || c == '\t' || c == ',';
+ }
+}
+
+static ssize_t recv_some(int fd, void *buf, size_t len)
+{
+ ssize_t n;
+
+ n = recv(fd, buf, len, 0);
+ if (n < 0 && errno == EINTR) {
+ return 0;
+ }
+ return n;
+}
+
+static ssize_t ws_recv_some(hybbx_ws_connection_t *ws, void *buf, size_t len)
+{
+ if (ws == NULL) {
+ return -1;
+ }
+
+ if (ws->tls != NULL) {
+ return hybbx_ws_tls_recv(ws->tls, buf, len);
+ }
+
+ return recv_some(ws->fd, buf, len);
+}
+
+static ssize_t send_all(int fd, const void *buf, size_t len)
+{
+ const uint8_t *p = (const uint8_t *)buf;
+ size_t sent = 0;
+
+ while (sent < len) {
+ ssize_t n = send(fd, p + sent, len - sent, MSG_NOSIGNAL);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return -1;
+ }
+ if (n == 0) {
+ return -1;
+ }
+ sent += (size_t)n;
+ }
+
+ return (ssize_t)sent;
+}
+
+static ssize_t ws_send_all(hybbx_ws_connection_t *ws, const void *buf,
+ size_t len)
+{
+ if (ws == NULL) {
+ return -1;
+ }
+
+ if (ws->tls != NULL) {
+ return hybbx_ws_tls_send(ws->tls, buf, len);
+ }
+
+ return send_all(ws->fd, buf, len);
+}
+
+static int base64_encode(const uint8_t *in, size_t in_len, char *out,
+ size_t out_len)
+{
+ static const char tbl[] =
+ "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
+ size_t i;
+ size_t o = 0;
+
+ if (out_len < ((in_len + 2) / 3) * 4 + 1) {
+ return -1;
+ }
+
+ for (i = 0; i < in_len; i += 3) {
+ uint32_t v = (uint32_t)in[i] << 16;
+
+ if (i + 1 < in_len) {
+ v |= (uint32_t)in[i + 1] << 8;
+ }
+ if (i + 2 < in_len) {
+ v |= (uint32_t)in[i + 2];
+ }
+
+ out[o++] = tbl[(v >> 18) & 63u];
+ out[o++] = tbl[(v >> 12) & 63u];
+ out[o++] = (i + 1 < in_len) ? tbl[(v >> 6) & 63u] : '=';
+ out[o++] = (i + 2 < in_len) ? tbl[v & 63u] : '=';
+ }
+
+ out[o] = '\0';
+ return 0;
+}
+
+static const char *header_value(const char *headers, const char *name)
+{
+ size_t name_len = strlen(name);
+ const char *p = headers;
+
+ while (p != NULL && *p != '\0') {
+ const char *line_end = strstr(p, "\r\n");
+
+ if (line_end == NULL) {
+ break;
+ }
+
+ if ((size_t)(line_end - p) > name_len + 2 &&
+ strncasecmp(p, name, name_len) == 0 && p[name_len] == ':') {
+ const char *value = p + name_len + 1;
+
+ while (*value == ' ' || *value == '\t') {
+ value++;
+ }
+ return value;
+ }
+
+ p = line_end + 2;
+ }
+
+ return NULL;
+}
+
+static int path_matches(const char *request_line, const char *path)
+{
+ const char *sp1;
+ const char *sp2;
+ size_t path_len;
+
+ if (path == NULL || path[0] == '\0') {
+ return 1;
+ }
+
+ sp1 = strchr(request_line, ' ');
+ if (sp1 == NULL) {
+ return 0;
+ }
+ sp1++;
+ sp2 = strchr(sp1, ' ');
+ if (sp2 == NULL) {
+ return 0;
+ }
+
+ path_len = strlen(path);
+ if ((size_t)(sp2 - sp1) < path_len) {
+ return 0;
+ }
+ if (strncmp(sp1, path, path_len) != 0) {
+ return 0;
+ }
+ if (sp1[path_len] != ' ' && sp1[path_len] != '?' &&
+ sp1[path_len] != '\0') {
+ return 0;
+ }
+
+ return 1;
+}
+
+void hybbx_ws_connection_init(hybbx_ws_connection_t *ws, int fd)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ memset(ws, 0, sizeof(*ws));
+ ws->fd = fd;
+}
+
+void hybbx_ws_connection_set_tls(hybbx_ws_connection_t *ws, void *tls)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ ws->tls = tls;
+}
+
+void hybbx_ws_connection_cleanup(hybbx_ws_connection_t *ws)
+{
+ if (ws == NULL) {
+ return;
+ }
+
+ if (ws->tls != NULL) {
+ hybbx_ws_tls_shutdown(ws->tls);
+ hybbx_ws_tls_free(ws->tls);
+ ws->tls = NULL;
+ }
+}
+
+hybbx_result_t hybbx_ws_server_handshake(hybbx_ws_connection_t *ws,
+ const char *path)
+{
+ char buf[HYBBX_WS_HANDSHAKE_MAX];
+ size_t total = 0;
+ const char *key_hdr;
+ char key[128];
+ char accept_src[256];
+ uint8_t digest[20];
+ char accept_b64[64];
+ char response[512];
+ const char *upgrade;
+ const char *connection;
+
+ if (ws == NULL || ws->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (total + 1 < sizeof(buf)) {
+ ssize_t n = ws_recv_some(ws, buf + total, sizeof(buf) - 1 - total);
+
+ if (n < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ total += (size_t)n;
+ buf[total] = '\0';
+
+ if (strstr(buf, "\r\n\r\n") != NULL) {
+ break;
+ }
+ }
+
+ if (strncmp(buf, "GET ", 4) != 0) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ {
+ const char *eol = strstr(buf, "\r\n");
+ char first_line[256];
+ size_t line_len;
+
+ if (eol == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+ line_len = (size_t)(eol - buf);
+ if (line_len >= sizeof(first_line)) {
+ return HYBBX_ERR_DENIED;
+ }
+ memcpy(first_line, buf, line_len);
+ first_line[line_len] = '\0';
+ if (!path_matches(first_line, path)) {
+ return HYBBX_ERR_DENIED;
+ }
+ }
+
+ upgrade = header_value(buf, "Upgrade");
+ connection = header_value(buf, "Connection");
+ if (upgrade == NULL || connection == NULL ||
+ !header_value_equals(upgrade, "websocket") ||
+ !header_has_token(connection, "upgrade")) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ key_hdr = header_value(buf, "Sec-WebSocket-Key");
+ if (key_hdr == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+
+ {
+ const char *key_end = strstr(key_hdr, "\r\n");
+ size_t key_len;
+
+ if (key_end == NULL) {
+ return HYBBX_ERR_DENIED;
+ }
+ key_len = (size_t)(key_end - key_hdr);
+ while (key_len > 0 &&
+ (key_hdr[key_len - 1] == ' ' || key_hdr[key_len - 1] == '\t')) {
+ key_len--;
+ }
+ if (key_len >= sizeof(key)) {
+ return HYBBX_ERR_DENIED;
+ }
+ memcpy(key, key_hdr, key_len);
+ key[key_len] = '\0';
+ }
+
+ snprintf(accept_src, sizeof(accept_src), "%s%s", key, WS_GUID);
+ hybbx_ws_sha1((const uint8_t *)accept_src, strlen(accept_src), digest);
+ if (base64_encode(digest, sizeof(digest), accept_b64,
+ sizeof(accept_b64)) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ snprintf(response, sizeof(response),
+ "HTTP/1.1 101 Switching Protocols\r\n"
+ "Upgrade: websocket\r\n"
+ "Connection: Upgrade\r\n"
+ "Sec-WebSocket-Accept: %s\r\n"
+ "\r\n",
+ accept_b64);
+
+ if (ws_send_all(ws, response, strlen(response)) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ ws->established = 1;
+ ws->rx_len = 0;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t ws_handle_frame(hybbx_ws_connection_t *ws,
+ const uint8_t *frame, size_t frame_len,
+ hybbx_ws_data_cb on_data, void *ctx)
+{
+ uint8_t opcode;
+ uint64_t payload_len;
+ size_t header_len;
+ uint8_t mask[4];
+ uint8_t payload[HYBBX_WS_FRAME_PAYLOAD_MAX];
+ size_t i;
+
+ if (frame_len < 2) {
+ return HYBBX_ERR_IO;
+ }
+
+ opcode = frame[0] & 0x0Fu;
+ if ((frame[0] & 0x80) == 0) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if ((frame[1] & 0x80) == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ payload_len = frame[1] & 0x7Fu;
+ header_len = 2;
+
+ if (payload_len == 126) {
+ if (frame_len < 4) {
+ return HYBBX_ERR_IO;
+ }
+ payload_len = ((uint64_t)frame[2] << 8) | frame[3];
+ header_len = 4;
+ } else if (payload_len == 127) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (payload_len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (frame_len < header_len + 4 + payload_len) {
+ return HYBBX_ERR_IO;
+ }
+
+ memcpy(mask, frame + header_len, 4);
+ header_len += 4;
+
+ for (i = 0; i < payload_len; i++) {
+ payload[i] = frame[header_len + i] ^ mask[i % 4];
+ }
+
+ switch (opcode) {
+ case 0x1:
+ case 0x2:
+ if (on_data != NULL && payload_len > 0) {
+ on_data(ctx, payload, (size_t)payload_len);
+ }
+ return HYBBX_OK;
+ case 0x8:
+ return HYBBX_ERR_IO;
+ case 0x9:
+ {
+ uint8_t pong[2];
+
+ pong[0] = 0x8A;
+ pong[1] = 0x00;
+ (void)ws_send_all(ws, pong, 2);
+ }
+ return HYBBX_OK;
+ case 0xA:
+ return HYBBX_OK;
+ default:
+ return HYBBX_OK;
+ }
+}
+
+hybbx_result_t hybbx_ws_read_frames(hybbx_ws_connection_t *ws,
+ hybbx_ws_data_cb on_data,
+ void *ctx)
+{
+ uint8_t tmp[256];
+ ssize_t n;
+
+ if (ws == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = ws_recv_some(ws, tmp, sizeof(tmp));
+ if (n < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (ws->rx_len + (size_t)n > sizeof(ws->rx_buf)) {
+ ws->rx_len = 0;
+ return HYBBX_ERR_IO;
+ }
+
+ memcpy(ws->rx_buf + ws->rx_len, tmp, (size_t)n);
+ ws->rx_len += (size_t)n;
+
+ while (ws->rx_len >= 2) {
+ uint64_t payload_len = ws->rx_buf[1] & 0x7Fu;
+ size_t header_len = 2;
+ size_t frame_len;
+ hybbx_result_t rc;
+
+ if (payload_len == 126) {
+ if (ws->rx_len < 4) {
+ return HYBBX_OK;
+ }
+ payload_len = ((uint64_t)ws->rx_buf[2] << 8) | ws->rx_buf[3];
+ header_len = 4;
+ } else if (payload_len == 127) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ if (payload_len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_UNSUPPORTED;
+ }
+
+ frame_len = header_len + 4 + (size_t)payload_len;
+ if (ws->rx_len < frame_len) {
+ return HYBBX_OK;
+ }
+
+ rc = ws_handle_frame(ws, ws->rx_buf, frame_len, on_data, ctx);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ if (ws->rx_len > frame_len) {
+ memmove(ws->rx_buf, ws->rx_buf + frame_len,
+ ws->rx_len - frame_len);
+ }
+ ws->rx_len -= frame_len;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_write_text(hybbx_ws_connection_t *ws,
+ const char *data, size_t len)
+{
+ uint8_t hdr[10];
+ size_t hdr_len = 2;
+
+ if (ws == NULL || data == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (len > HYBBX_WS_FRAME_PAYLOAD_MAX) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hdr[0] = 0x81;
+ if (len < 126) {
+ hdr[1] = (uint8_t)len;
+ hdr_len = 2;
+ } else {
+ hdr[1] = 126;
+ hdr[2] = (uint8_t)((len >> 8) & 0xFFu);
+ hdr[3] = (uint8_t)(len & 0xFFu);
+ hdr_len = 4;
+ }
+
+ if (ws_send_all(ws, hdr, hdr_len) < 0) {
+ return HYBBX_ERR_IO;
+ }
+ if (len > 0 && ws_send_all(ws, data, len) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_ping(hybbx_ws_connection_t *ws)
+{
+ uint8_t frame[2];
+
+ if (ws == NULL || !ws->established) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 0x89;
+ frame[1] = 0x00;
+
+ if (ws_send_all(ws, frame, sizeof(frame)) < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_close(hybbx_ws_connection_t *ws)
+{
+ uint8_t frame[2];
+
+ if (ws == NULL || ws->fd < 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame[0] = 0x88;
+ frame[1] = 0x00;
+ (void)ws_send_all(ws, frame, 2);
+ ws->established = 0;
+ return HYBBX_OK;
+}
diff --git a/plugins/websocket/ws_proto.h b/plugins/websocket/ws_proto.h
new file mode 100644
index 0000000..5a1cb23
--- /dev/null
+++ b/plugins/websocket/ws_proto.h
@@ -0,0 +1,63 @@
+#ifndef HYBBX_WS_PROTO_H
+#define HYBBX_WS_PROTO_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define HYBBX_WS_HANDSHAKE_MAX 4096u
+#define HYBBX_WS_FRAME_PAYLOAD_MAX 4096u
+
+typedef void (*hybbx_ws_data_cb)(void *ctx, const uint8_t *data, size_t len);
+
+typedef struct hybbx_ws_connection {
+ int fd;
+ void *tls;
+ int established;
+ uint8_t rx_buf[HYBBX_WS_FRAME_PAYLOAD_MAX + 16];
+ size_t rx_len;
+} hybbx_ws_connection_t;
+
+void hybbx_ws_connection_init(hybbx_ws_connection_t *ws, int fd);
+
+/** Attach TLS session (opaque SSL*) after accept; plain ws when NULL. */
+void hybbx_ws_connection_set_tls(hybbx_ws_connection_t *ws, void *tls);
+
+/** Release TLS resources; does not close @c fd. */
+void hybbx_ws_connection_cleanup(hybbx_ws_connection_t *ws);
+
+/**
+ * Perform server-side HTTP Upgrade handshake.
+ * @p path must match the request URI path (e.g. /hybbx) when non-empty.
+ */
+hybbx_result_t hybbx_ws_server_handshake(hybbx_ws_connection_t *ws,
+ const char *path);
+
+/**
+ * Read WebSocket frames; invoke @p on_data for each complete text/binary
+ * payload from the client. Returns HYBBX_ERR_IO on disconnect.
+ */
+hybbx_result_t hybbx_ws_read_frames(hybbx_ws_connection_t *ws,
+ hybbx_ws_data_cb on_data,
+ void *ctx);
+
+/** Send one server text frame (unmasked). */
+hybbx_result_t hybbx_ws_write_text(hybbx_ws_connection_t *ws,
+ const char *data, size_t len);
+
+/** Send one server ping frame (unmasked, empty payload). */
+hybbx_result_t hybbx_ws_ping(hybbx_ws_connection_t *ws);
+
+/** Send WebSocket close frame. */
+hybbx_result_t hybbx_ws_close(hybbx_ws_connection_t *ws);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_WS_PROTO_H */
diff --git a/plugins/websocket/ws_sha1.c b/plugins/websocket/ws_sha1.c
new file mode 100644
index 0000000..53ad4c0
--- /dev/null
+++ b/plugins/websocket/ws_sha1.c
@@ -0,0 +1,138 @@
+/*
+ * Minimal SHA-1 for WebSocket handshake (RFC 6455). Public-domain style.
+ */
+#include "ws_sha1.h"
+
+#include <stdint.h>
+#include <string.h>
+#include <string.h>
+
+typedef struct ws_sha1_ctx {
+ uint32_t state[5];
+ uint64_t count;
+ uint8_t buffer[64];
+} ws_sha1_ctx_t;
+
+static uint32_t rol(uint32_t v, unsigned bits)
+{
+ return (v << bits) | (v >> (32u - bits));
+}
+
+static void ws_sha1_transform(ws_sha1_ctx_t *ctx, const uint8_t block[64])
+{
+ uint32_t w[80];
+ uint32_t a, b, c, d, e, f, k, t;
+ unsigned i;
+
+ for (i = 0; i < 16; i++) {
+ w[i] = ((uint32_t)block[i * 4] << 24) |
+ ((uint32_t)block[i * 4 + 1] << 16) |
+ ((uint32_t)block[i * 4 + 2] << 8) |
+ (uint32_t)block[i * 4 + 3];
+ }
+ for (i = 16; i < 80; i++) {
+ w[i] = rol(w[i - 3] ^ w[i - 8] ^ w[i - 14] ^ w[i - 16], 1);
+ }
+
+ a = ctx->state[0];
+ b = ctx->state[1];
+ c = ctx->state[2];
+ d = ctx->state[3];
+ e = ctx->state[4];
+
+ for (i = 0; i < 80; i++) {
+ if (i < 20) {
+ f = (b & c) | ((~b) & d);
+ k = 0x5A827999u;
+ } else if (i < 40) {
+ f = b ^ c ^ d;
+ k = 0x6ED9EBA1u;
+ } else if (i < 60) {
+ f = (b & c) | (b & d) | (c & d);
+ k = 0x8F1BBCDCu;
+ } else {
+ f = b ^ c ^ d;
+ k = 0xCA62C1D6u;
+ }
+ t = rol(a, 5) + f + e + k + w[i];
+ e = d;
+ d = c;
+ c = rol(b, 30);
+ b = a;
+ a = t;
+ }
+
+ ctx->state[0] += a;
+ ctx->state[1] += b;
+ ctx->state[2] += c;
+ ctx->state[3] += d;
+ ctx->state[4] += e;
+}
+
+static void ws_sha1_init(ws_sha1_ctx_t *ctx)
+{
+ ctx->state[0] = 0x67452301u;
+ ctx->state[1] = 0xEFCDAB89u;
+ ctx->state[2] = 0x98BADCFEu;
+ ctx->state[3] = 0x10325476u;
+ ctx->state[4] = 0xC3D2E1F0u;
+ ctx->count = 0;
+}
+
+static void ws_sha1_update(ws_sha1_ctx_t *ctx, const uint8_t *data, size_t len)
+{
+ size_t i = 0;
+ size_t idx = (size_t)((ctx->count >> 3) & 63u);
+
+ ctx->count += (uint64_t)len * 8u;
+
+ if (idx + len > 63) {
+ memcpy(ctx->buffer + idx, data, 64 - idx);
+ ws_sha1_transform(ctx, ctx->buffer);
+ for (i = 64 - idx; i + 63 < len; i += 64) {
+ ws_sha1_transform(ctx, data + i);
+ }
+ idx = 0;
+ } else {
+ i = 0;
+ }
+
+ memcpy(ctx->buffer + idx, data + i, len - i);
+}
+
+static void ws_sha1_final(ws_sha1_ctx_t *ctx, uint8_t out[20])
+{
+ uint8_t final[64];
+ size_t idx = (size_t)((ctx->count >> 3) & 63u);
+ unsigned i;
+
+ memcpy(final, ctx->buffer, idx);
+ final[idx++] = 0x80;
+ if (idx > 56) {
+ memset(final + idx, 0, 64 - idx);
+ ws_sha1_transform(ctx, final);
+ idx = 0;
+ }
+ memset(final + idx, 0, 56 - idx);
+
+ for (i = 0; i < 8; i++) {
+ final[56 + i] = (uint8_t)(ctx->count >> (56 - i * 8));
+ }
+ ws_sha1_transform(ctx, final);
+
+ for (i = 0; i < 5; i++) {
+ out[i * 4] = (uint8_t)(ctx->state[i] >> 24);
+ out[i * 4 + 1] = (uint8_t)(ctx->state[i] >> 16);
+ out[i * 4 + 2] = (uint8_t)(ctx->state[i] >> 8);
+ out[i * 4 + 3] = (uint8_t)(ctx->state[i]);
+ }
+}
+
+void hybbx_ws_sha1(const uint8_t *data, size_t len, uint8_t out[20])
+{
+ ws_sha1_ctx_t ctx;
+
+ ws_sha1_init(&ctx);
+ ws_sha1_update(&ctx, data, len);
+ ws_sha1_final(&ctx, out);
+}
diff --git a/plugins/websocket/ws_sha1.h b/plugins/websocket/ws_sha1.h
new file mode 100644
index 0000000..b685cf7
--- /dev/null
+++ b/plugins/websocket/ws_sha1.h
@@ -0,0 +1,9 @@
+#ifndef HYBBX_WS_SHA1_H
+#define HYBBX_WS_SHA1_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+void hybbx_ws_sha1(const uint8_t *data, size_t len, uint8_t out[20]);
+
+#endif /* HYBBX_WS_SHA1_H */
diff --git a/plugins/websocket/ws_tls.c b/plugins/websocket/ws_tls.c
new file mode 100644
index 0000000..679ce59
--- /dev/null
+++ b/plugins/websocket/ws_tls.c
@@ -0,0 +1,400 @@
+#include "ws_tls.h"
+
+#include "hybbx/limits.h"
+#include "hybbx/util.h"
+#include "hybbx/websocket.h"
+#include "hybbx/log.h"
+
+#include <errno.h>
+#include <stdio.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+#ifdef HYBBX_WS_HAVE_TLS
+
+#include <openssl/err.h>
+#include <openssl/evp.h>
+#include <openssl/pem.h>
+#include <openssl/ssl.h>
+#include <openssl/x509.h>
+
+static SSL_CTX *g_ssl_ctx;
+static int g_tls_enabled;
+
+static hybbx_result_t mkdir_cert_dir(const char *cert_dir)
+{
+ char parent[HYBBX_PATH_MAX];
+ struct stat st;
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (stat(cert_dir, &st) == 0) {
+ return S_ISDIR(st.st_mode) ? HYBBX_OK : HYBBX_ERR_IO;
+ }
+
+ if (hybbx_path_dirname(cert_dir, parent, sizeof(parent)) == HYBBX_OK &&
+ parent[0] != '\0' && strcmp(parent, cert_dir) != 0 &&
+ stat(parent, &st) != 0) {
+ if (mkdir_cert_dir(parent) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ if (mkdir(cert_dir, 0700) != 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void build_cert_path(const char *cert_dir, const char *name,
+ char *out, size_t out_len)
+{
+ if (cert_dir == NULL || name == NULL || out == NULL || out_len == 0) {
+ if (out != NULL && out_len > 0) {
+ out[0] = '\0';
+ }
+ return;
+ }
+
+ snprintf(out, out_len, "%s/%s", cert_dir, name);
+}
+
+static int cert_files_exist(const char *cert_path, const char *key_path)
+{
+ struct stat st;
+
+ return stat(cert_path, &st) == 0 && S_ISREG(st.st_mode) &&
+ stat(key_path, &st) == 0 && S_ISREG(st.st_mode);
+}
+
+static hybbx_result_t generate_self_signed(const char *cert_path,
+ const char *key_path)
+{
+ EVP_PKEY *pkey = NULL;
+ EVP_PKEY_CTX *pctx = NULL;
+ X509 *cert = NULL;
+ X509_NAME *name;
+ FILE *fp;
+ int rc = HYBBX_ERR_IO;
+
+ pctx = EVP_PKEY_CTX_new_id(EVP_PKEY_RSA, NULL);
+ if (pctx == NULL ||
+ EVP_PKEY_keygen_init(pctx) <= 0 ||
+ EVP_PKEY_CTX_set_rsa_keygen_bits(pctx, 2048) <= 0 ||
+ EVP_PKEY_keygen(pctx, &pkey) <= 0 || pkey == NULL) {
+ goto done;
+ }
+
+ cert = X509_new();
+ if (cert == NULL) {
+ goto done;
+ }
+
+ if (X509_set_version(cert, 2) != 1 ||
+ ASN1_INTEGER_set(X509_get_serialNumber(cert), 1) != 1 ||
+ X509_gmtime_adj(X509_get_notBefore(cert), 0) == NULL ||
+ X509_gmtime_adj(X509_get_notAfter(cert),
+ (long)HYBBX_WS_TLS_CERT_VALID_DAYS * 24L * 3600L) ==
+ NULL ||
+ X509_set_pubkey(cert, pkey) != 1) {
+ goto done;
+ }
+
+ name = X509_get_subject_name(cert);
+ if (name == NULL ||
+ X509_NAME_add_entry_by_txt(name, "CN", MBSTRING_ASC,
+ (const unsigned char *)"hybbx", -1, -1,
+ 0) != 1 ||
+ X509_set_issuer_name(cert, name) != 1 ||
+ X509_sign(cert, pkey, EVP_sha256()) <= 0) {
+ goto done;
+ }
+
+ fp = fopen(key_path, "w");
+ if (fp == NULL ||
+ PEM_write_PrivateKey(fp, pkey, NULL, NULL, 0, NULL, NULL) != 1) {
+ if (fp != NULL) {
+ fclose(fp);
+ unlink(key_path);
+ }
+ goto done;
+ }
+ fclose(fp);
+ (void)chmod(key_path, 0600);
+
+ fp = fopen(cert_path, "w");
+ if (fp == NULL || PEM_write_X509(fp, cert) != 1) {
+ if (fp != NULL) {
+ fclose(fp);
+ unlink(cert_path);
+ }
+ goto done;
+ }
+ fclose(fp);
+ (void)chmod(cert_path, 0644);
+
+ rc = HYBBX_OK;
+
+done:
+ if (cert != NULL) {
+ X509_free(cert);
+ }
+ if (pkey != NULL) {
+ EVP_PKEY_free(pkey);
+ }
+ if (pctx != NULL) {
+ EVP_PKEY_CTX_free(pctx);
+ }
+
+ return rc;
+}
+
+int hybbx_ws_tls_compiled(void)
+{
+ return 1;
+}
+
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir)
+{
+ char cert_path[HYBBX_PATH_MAX];
+ char key_path[HYBBX_PATH_MAX];
+ hybbx_result_t rc;
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ rc = mkdir_cert_dir(cert_dir);
+ if (rc != HYBBX_OK) {
+ return rc;
+ }
+
+ build_cert_path(cert_dir, HYBBX_WS_TLS_CERT_FILENAME, cert_path,
+ sizeof(cert_path));
+ build_cert_path(cert_dir, HYBBX_WS_TLS_KEY_FILENAME, key_path,
+ sizeof(key_path));
+
+ if (cert_files_exist(cert_path, key_path)) {
+ return HYBBX_OK;
+ }
+
+ rc = generate_self_signed(cert_path, key_path);
+ if (rc == HYBBX_OK) {
+ hybbx_log_info("[websocket] created self-signed TLS certificate in %s",
+ cert_dir);
+ }
+
+ return rc;
+}
+
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir)
+{
+ char cert_path[HYBBX_PATH_MAX];
+ char key_path[HYBBX_PATH_MAX];
+
+ if (g_tls_enabled) {
+ return HYBBX_OK;
+ }
+
+ if (cert_dir == NULL || cert_dir[0] == '\0') {
+ return HYBBX_ERR_INVALID;
+ }
+
+ build_cert_path(cert_dir, HYBBX_WS_TLS_CERT_FILENAME, cert_path,
+ sizeof(cert_path));
+ build_cert_path(cert_dir, HYBBX_WS_TLS_KEY_FILENAME, key_path,
+ sizeof(key_path));
+
+ if (!cert_files_exist(cert_path, key_path)) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (OPENSSL_init_ssl(OPENSSL_INIT_LOAD_SSL_STRINGS |
+ OPENSSL_INIT_LOAD_CRYPTO_STRINGS,
+ NULL) != 1) {
+ return HYBBX_ERR_IO;
+ }
+
+ g_ssl_ctx = SSL_CTX_new(TLS_server_method());
+ if (g_ssl_ctx == NULL) {
+ return HYBBX_ERR_IO;
+ }
+
+ SSL_CTX_set_min_proto_version(g_ssl_ctx, TLS1_2_VERSION);
+
+ if (SSL_CTX_use_certificate_file(g_ssl_ctx, cert_path, SSL_FILETYPE_PEM) !=
+ 1 ||
+ SSL_CTX_use_PrivateKey_file(g_ssl_ctx, key_path, SSL_FILETYPE_PEM) !=
+ 1 ||
+ SSL_CTX_check_private_key(g_ssl_ctx) != 1) {
+ SSL_CTX_free(g_ssl_ctx);
+ g_ssl_ctx = NULL;
+ return HYBBX_ERR_IO;
+ }
+
+ g_tls_enabled = 1;
+ return HYBBX_OK;
+}
+
+void hybbx_ws_tls_server_stop(void)
+{
+ if (g_ssl_ctx != NULL) {
+ SSL_CTX_free(g_ssl_ctx);
+ g_ssl_ctx = NULL;
+ }
+ g_tls_enabled = 0;
+}
+
+int hybbx_ws_tls_server_enabled(void)
+{
+ return g_tls_enabled;
+}
+
+void *hybbx_ws_tls_accept(int fd)
+{
+ SSL *ssl;
+
+ if (!g_tls_enabled || g_ssl_ctx == NULL || fd < 0) {
+ return NULL;
+ }
+
+ ssl = SSL_new(g_ssl_ctx);
+ if (ssl == NULL) {
+ return NULL;
+ }
+
+ if (SSL_set_fd(ssl, fd) != 1 || SSL_accept(ssl) <= 0) {
+ SSL_free(ssl);
+ return NULL;
+ }
+
+ return ssl;
+}
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len)
+{
+ SSL *ssl = (SSL *)tls;
+ int n;
+
+ if (ssl == NULL || buf == NULL || len == 0) {
+ return -1;
+ }
+
+ do {
+ n = SSL_read(ssl, buf, (int)len);
+ } while (n < 0 && SSL_get_error(ssl, n) == SSL_ERROR_SYSCALL &&
+ errno == EINTR);
+
+ return (ssize_t)n;
+}
+
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len)
+{
+ const uint8_t *p = (const uint8_t *)buf;
+ SSL *ssl = (SSL *)tls;
+ size_t sent = 0;
+
+ if (ssl == NULL || buf == NULL) {
+ return -1;
+ }
+
+ while (sent < len) {
+ int n;
+
+ do {
+ n = SSL_write(ssl, p + sent, (int)(len - sent));
+ } while (n < 0 && SSL_get_error(ssl, n) == SSL_ERROR_SYSCALL &&
+ errno == EINTR);
+
+ if (n <= 0) {
+ return -1;
+ }
+ sent += (size_t)n;
+ }
+
+ return (ssize_t)sent;
+}
+
+void hybbx_ws_tls_shutdown(void *tls)
+{
+ SSL *ssl = (SSL *)tls;
+
+ if (ssl != NULL) {
+ (void)SSL_shutdown(ssl);
+ }
+}
+
+void hybbx_ws_tls_free(void *tls)
+{
+ SSL *ssl = (SSL *)tls;
+
+ if (ssl != NULL) {
+ SSL_free(ssl);
+ }
+}
+
+#else /* !HYBBX_WS_HAVE_TLS */
+
+int hybbx_ws_tls_compiled(void)
+{
+ return 0;
+}
+
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir)
+{
+ (void)cert_dir;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir)
+{
+ (void)cert_dir;
+ return HYBBX_ERR_UNSUPPORTED;
+}
+
+void hybbx_ws_tls_server_stop(void)
+{
+}
+
+int hybbx_ws_tls_server_enabled(void)
+{
+ return 0;
+}
+
+void *hybbx_ws_tls_accept(int fd)
+{
+ (void)fd;
+ return NULL;
+}
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len)
+{
+ (void)tls;
+ (void)buf;
+ (void)len;
+ return -1;
+}
+
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len)
+{
+ (void)tls;
+ (void)buf;
+ (void)len;
+ return -1;
+}
+
+void hybbx_ws_tls_shutdown(void *tls)
+{
+ (void)tls;
+}
+
+void hybbx_ws_tls_free(void *tls)
+{
+ (void)tls;
+}
+
+#endif /* HYBBX_WS_HAVE_TLS */
diff --git a/plugins/websocket/ws_tls.h b/plugins/websocket/ws_tls.h
new file mode 100644
index 0000000..2615fab
--- /dev/null
+++ b/plugins/websocket/ws_tls.h
@@ -0,0 +1,43 @@
+#ifndef HYBBX_WS_TLS_H
+#define HYBBX_WS_TLS_H
+
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <sys/types.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+/** Return 1 when this build links OpenSSL for WebSocket TLS. */
+int hybbx_ws_tls_compiled(void);
+
+/**
+ * Create @p cert_dir if needed and generate a self-signed certificate on first
+ * start when files are missing. No-op when OpenSSL is not compiled in.
+ */
+hybbx_result_t hybbx_ws_tls_ensure_certs(const char *cert_dir);
+
+/** Load server TLS context after @p cert_dir is ready. */
+hybbx_result_t hybbx_ws_tls_server_start(const char *cert_dir);
+
+void hybbx_ws_tls_server_stop(void);
+
+/** Return 1 when the server accepts TLS (OpenSSL + certs loaded). */
+int hybbx_ws_tls_server_enabled(void);
+
+/** Perform TLS server handshake on @p fd; returns opaque SSL* or NULL. */
+void *hybbx_ws_tls_accept(int fd);
+
+ssize_t hybbx_ws_tls_recv(void *tls, void *buf, size_t len);
+ssize_t hybbx_ws_tls_send(void *tls, const void *buf, size_t len);
+
+void hybbx_ws_tls_shutdown(void *tls);
+void hybbx_ws_tls_free(void *tls);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_WS_TLS_H */
diff --git a/scripts/build-amiga-telnet.sh b/scripts/build-amiga-telnet.sh
new file mode 100755
index 0000000..b423923
--- /dev/null
+++ b/scripts/build-amiga-telnet.sh
@@ -0,0 +1,26 @@
+#!/usr/bin/env sh
+# Cross-build hybbx-telnet for AmigaOS 3.9+ (bebbo-gcc / clib2).
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+BUILD="${1:-$ROOT/build-amiga}"
+SDK="${AMIGA_SDK_PATH:-/opt/amiga}"
+
+shift 2>/dev/null || true
+
+if [ ! -x "$SDK/bin/m68k-amigaos-gcc" ]; then
+ echo "Amiga SDK not found at $SDK (set AMIGA_SDK_PATH)" >&2
+ exit 1
+fi
+
+cmake -B "$BUILD" \
+ -DCMAKE_TOOLCHAIN_FILE="$ROOT/cmake/Toolchain-AmigaOS.cmake" \
+ -DAMIGA_SDK_PATH="$SDK" \
+ -DHYBBX_CLIENTS_ONLY=ON \
+ -DCMAKE_BUILD_TYPE=Release \
+ "$@"
+
+cmake --build "$BUILD" --target hybbx-telnet
+
+echo "AmigaOS client:"
+echo " $BUILD/src/clients/hybbx-telnet"
diff --git a/scripts/build-clients.sh b/scripts/build-clients.sh
new file mode 100755
index 0000000..3e909f9
--- /dev/null
+++ b/scripts/build-clients.sh
@@ -0,0 +1,19 @@
+#!/usr/bin/env sh
+# Build hybbx-telnet, hybbx-terminal, and hybbx-ssh (when libssh is available)
+# without the hybbx server or plugins.
+set -eu
+
+ROOT="$(CDPATH= cd -- "$(dirname "$0")/.." && pwd)"
+BUILD="${1:-$ROOT/build-clients}"
+
+shift 2>/dev/null || true
+
+cmake -B "$BUILD" -DHYBBX_CLIENTS_ONLY=ON "$@"
+cmake --build "$BUILD"
+
+echo "Clients:"
+echo " $BUILD/src/clients/hybbx-telnet"
+echo " $BUILD/src/clients/hybbx-terminal"
+if [ -x "$BUILD/src/clients/hybbx-ssh" ]; then
+ echo " $BUILD/src/clients/hybbx-ssh"
+fi
diff --git a/scripts/dev-setup.sh b/scripts/dev-setup.sh
new file mode 100755
index 0000000..69929d3
--- /dev/null
+++ b/scripts/dev-setup.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+# One-shot dev environment: configure, build, IDE compile_commands symlink.
+set -e
+
+root="$(cd "$(dirname "$0")/.." && pwd)"
+
+cmake -B "$root/build" \
+ -DCMAKE_BUILD_TYPE=Release \
+ -DCMAKE_EXPORT_COMPILE_COMMANDS=ON
+
+cmake --build "$root/build"
+
+if [ -f "$root/build/compile_commands.json" ]; then
+ ln -sf build/compile_commands.json "$root/compile_commands.json"
+ echo "Linked compile_commands.json -> build/compile_commands.json"
+fi
+
+echo "Ready. Install (default → ./bin): cmake --install $root/build"
+echo "Start: $root/bin/hybbx-start or $root/scripts/hybbx.sh"
diff --git a/scripts/hybbx.sh b/scripts/hybbx.sh
new file mode 100755
index 0000000..9652654
--- /dev/null
+++ b/scripts/hybbx.sh
@@ -0,0 +1,86 @@
+#!/bin/sh
+# Start hybbxd only. Build and install with CMake (see README).
+#
+# Default install (source tree): ./bin/hybbx-start
+# Prefix install: /usr/local/hybbx/hybbx-start (or $HOME/hybbx/…)
+# Dev (no install): ./scripts/hybbx.sh
+#
+# Detached: hybbx-start --screen | --tmux [--attach]
+set -e
+
+self="${0#./}"
+case "$self" in
+ /*) script_path="$self" ;;
+ *) script_path="$(pwd)/$self" ;;
+esac
+script_dir="$(cd "$(dirname "$script_path")" && pwd)"
+script_name="$(basename "$script_path")"
+
+# Installed layout: <tree>/hybbx-start + <tree>/hybbxd (./bin or <prefix>/hybbx)
+if [ "$script_name" = "hybbx-start" ] && [ -x "$script_dir/hybbxd" ]; then
+ prefix="$script_dir"
+ config="${HYBBX_CONFIG:-$prefix/hybbx.ini}"
+ binary="$script_dir/hybbxd"
+ export HYBBX_ROOT="${HYBBX_ROOT:-$prefix}"
+ cd "$prefix"
+ if [ ! -f "$config" ]; then
+ echo "Config not found: $config" >&2
+ exit 1
+ fi
+ if [ "$#" -eq 0 ]; then
+ exec "$binary" -c "$config"
+ fi
+ exec "$binary" -c "$config" "$@"
+fi
+
+# Explicit or known install roots
+root="$(cd "$script_dir/.." && pwd)"
+if [ -n "$HYBBX_ROOT" ]; then
+ prefix="$HYBBX_ROOT"
+elif [ -x "$root/bin/hybbxd" ]; then
+ prefix="$root/bin"
+elif [ -x "/usr/local/hybbx/hybbxd" ]; then
+ prefix="/usr/local/hybbx"
+elif [ -n "$HOME" ] && [ -x "$HOME/hybbx/hybbxd" ]; then
+ prefix="$HOME/hybbx"
+else
+ prefix=""
+fi
+
+if [ -n "$prefix" ]; then
+ config="${HYBBX_CONFIG:-$prefix/hybbx.ini}"
+ binary="$prefix/hybbxd"
+ if [ -x "$binary" ] && [ -f "$config" ]; then
+ export HYBBX_ROOT="${HYBBX_ROOT:-$prefix}"
+ cd "$prefix"
+ if [ "$#" -eq 0 ]; then
+ exec "$binary" -c "$config"
+ fi
+ exec "$binary" -c "$config" "$@"
+ fi
+fi
+
+# Development tree (repo checkout, no cmake --install yet)
+config="${HYBBX_CONFIG:-$root/local/hybbx.ini}"
+build="$root/build/src/hybbxd"
+
+if [ -f "$config" ] && [ -x "$build" ]; then
+ export HYBBX_ROOT="${HYBBX_ROOT:-$root}"
+ cd "$root"
+ if [ "$#" -eq 0 ]; then
+ exec "$build" -c "$config"
+ fi
+ exec "$build" -c "$config" "$@"
+fi
+
+echo "HyBBX not found." >&2
+echo "Build and install (default → ./bin):" >&2
+echo " cmake -B build -DCMAKE_BUILD_TYPE=Release" >&2
+echo " cmake --build build" >&2
+echo " cmake --install build" >&2
+echo " ./bin/hybbx-start" >&2
+echo "System prefix:" >&2
+echo " cmake -B build -DCMAKE_INSTALL_PREFIX=/usr/local" >&2
+echo " cmake --build build && sudo cmake --install build" >&2
+echo " /usr/local/hybbx/hybbx-start" >&2
+exit 1
diff --git a/scripts/mock-ardopc.py b/scripts/mock-ardopc.py
new file mode 100755
index 0000000..0a2e110
--- /dev/null
+++ b/scripts/mock-ardopc.py
@@ -0,0 +1,137 @@
+#!/usr/bin/env python3
+"""
+Minimal ARDOPC TCP mock for HyBBX plugin host-TCP local tests.
+Control port N, data port N+1. CRC-16 poly 0x8810 (ARDOP host family).
+"""
+from __future__ import annotations
+
+import socket
+import struct
+import sys
+import threading
+import time
+
+INT_POLY = 0x8810
+
+
+def crc16(data: bytes) -> int:
+ reg = 0xFFFF
+ for byte in data:
+ mask = 0x80
+ for _ in range(8):
+ bit = byte & mask
+ mask >>= 1
+ if reg & 0x8000:
+ if bit:
+ reg = (1 + (reg << 1)) & 0xFFFF
+ else:
+ reg = (reg << 1) & 0xFFFF
+ reg ^= INT_POLY
+ else:
+ if bit:
+ reg = (1 + (reg << 1)) & 0xFFFF
+ else:
+ reg = (reg << 1) & 0xFFFF
+ return reg
+
+
+def frame_cmd(text: str) -> bytes:
+ body = (text + "\r").encode("ascii")
+ c = crc16(body)
+ return body + struct.pack(">H", c)
+
+
+def parse_frames(buf: bytearray) -> list[bytes]:
+ out: list[bytes] = []
+ while True:
+ if b"\r" not in buf:
+ break
+ cr = buf.index(0x0D)
+ need = cr + 1 + 2
+ if len(buf) < need:
+ break
+ frame = bytes(buf[:need])
+ del buf[:need]
+ if crc16(frame[:-2]) == struct.unpack(">H", frame[-2:])[0]:
+ out.append(frame[:-2])
+ return out
+
+
+def serve_ctrl(port: int, log: list[str]) -> None:
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind(("127.0.0.1", port))
+ srv.listen(1)
+ srv.settimeout(8.0)
+ try:
+ conn, _ = srv.accept()
+ except socket.timeout:
+ srv.close()
+ return
+ conn.settimeout(2.0)
+ conn.sendall(frame_cmd("VERSION crdopc_1.0.0"))
+ buf = bytearray()
+ try:
+ while True:
+ try:
+ chunk = conn.recv(4096)
+ except TimeoutError:
+ break
+ if not chunk:
+ break
+ buf.extend(chunk)
+ for frame in parse_frames(buf):
+ cmd = frame.decode("ascii", errors="replace").strip()
+ log.append(f"ctrl<= {cmd}")
+ if cmd.startswith("INITIALIZE"):
+ conn.sendall(frame_cmd("RDY"))
+ elif cmd == "RDY":
+ pass
+ else:
+ conn.sendall(frame_cmd("RDY"))
+ finally:
+ conn.close()
+ srv.close()
+
+
+def serve_data(port: int, log: list[str]) -> None:
+ srv = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
+ srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ srv.bind(("127.0.0.1", port))
+ srv.listen(1)
+ srv.settimeout(8.0)
+ try:
+ conn, _ = srv.accept()
+ except socket.timeout:
+ srv.close()
+ return
+ conn.settimeout(1.0)
+ try:
+ while True:
+ try:
+ chunk = conn.recv(4096)
+ except socket.timeout:
+ continue
+ if not chunk:
+ break
+ log.append(f"data<= {len(chunk)} bytes")
+ finally:
+ conn.close()
+ srv.close()
+
+
+def main() -> int:
+ port = int(sys.argv[1]) if len(sys.argv) > 1 else 18515
+ log: list[str] = []
+ t1 = threading.Thread(target=serve_ctrl, args=(port, log), daemon=True)
+ t2 = threading.Thread(target=serve_data, args=(port + 1, log), daemon=True)
+ t1.start()
+ t2.start()
+ time.sleep(6)
+ for line in log:
+ print(line)
+ return 0 if any("INITIALIZE" in x for x in log) else 1
+
+
+if __name__ == "__main__":
+ sys.exit(main())
diff --git a/scripts/test-ardop-plugin.sh b/scripts/test-ardop-plugin.sh
new file mode 100755
index 0000000..67900f1
--- /dev/null
+++ b/scripts/test-ardop-plugin.sh
@@ -0,0 +1,89 @@
+#!/usr/bin/env bash
+# Local test: mock ARDOPC + HyBBX ardop plugin (no Main circuit required).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${ROOT}/build"
+HYBBX="${BUILD}/src/hybbxd"
+MOCK_PORT=18515
+TMPDIR="${ROOT}/local/test-ardop-$$"
+mkdir -p "${TMPDIR}"
+
+cleanup() {
+ kill "${MOCK_PID:-}" 2>/dev/null || true
+ kill "${CIRCUIT_PID:-}" 2>/dev/null || true
+ kill "${HYBBX_PID:-}" 2>/dev/null || true
+ rm -rf "${TMPDIR}"
+}
+trap cleanup EXIT
+
+if [[ ! -x "${HYBBX}" ]]; then
+ echo "Build hybbx first: cmake --build build" >&2
+ exit 1
+fi
+
+cat > "${TMPDIR}/hybbx.ini" <<EOF
+[service]
+name = test-ardop
+
+[storage]
+path = ${TMPDIR}/data
+
+[auth]
+auto_login = no
+
+[networks]
+ax25 = no
+ardop = yes
+circuit = no
+
+[transport.telnet]
+enabled = no
+
+[transport.ardop]
+enabled = yes
+ardop_host = 127.0.0.1
+ardop_port = ${MOCK_PORT}
+mycall = TEST-0
+radio_profile = cb
+arq_bandwidth = 500MAX
+listen = yes
+circuit_host = 127.0.0.1
+circuit_port = 17323
+link_id = test-ardop
+EOF
+
+python3 "${ROOT}/scripts/mock-ardopc.py" "${MOCK_PORT}" &
+MOCK_PID=$!
+sleep 0.3
+
+# Minimal TCP acceptor so circuit connect does not block startup (no HBX handshake).
+nc -l 127.0.0.1 17323 >/dev/null 2>&1 &
+CIRCUIT_PID=$!
+sleep 0.2
+
+LOG="${TMPDIR}/hybbx.log"
+stdbuf -oL timeout 5 "${HYBBX}" -c "${TMPDIR}/hybbx.ini" >"${LOG}" 2>&1 &
+HYBBX_PID=$!
+sleep 3
+kill "${HYBBX_PID}" 2>/dev/null || true
+wait "${HYBBX_PID}" 2>/dev/null || true
+kill "${CIRCUIT_PID}" 2>/dev/null || true
+
+grep -q "connected to external ARDOPC" "${LOG}" || {
+ echo "FAIL: no ARDOPC connect log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q "host init sent" "${LOG}" || {
+ echo "FAIL: no host init log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q "CB half-duplex profile" "${LOG}" || {
+ echo "FAIL: no CB profile log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+
+echo "OK: ardop plugin local test passed"
+cat "${LOG}" | grep '\[ardop\]'
diff --git a/scripts/test-crdop-plugin.sh b/scripts/test-crdop-plugin.sh
new file mode 100755
index 0000000..aec10e1
--- /dev/null
+++ b/scripts/test-crdop-plugin.sh
@@ -0,0 +1,97 @@
+#!/usr/bin/env bash
+# Local test: mock CRDOPC + HyBBX crdop plugin (CB defaults, no Main circuit required).
+set -euo pipefail
+ROOT="$(cd "$(dirname "$0")/.." && pwd)"
+BUILD="${ROOT}/build"
+HYBBX="${BUILD}/src/hybbxd"
+MOCK_PORT=18517
+TMPDIR="${ROOT}/local/test-crdop-$$"
+mkdir -p "${TMPDIR}"
+
+cleanup() {
+ kill "${MOCK_PID:-}" 2>/dev/null || true
+ kill "${CIRCUIT_PID:-}" 2>/dev/null || true
+ kill "${HYBBX_PID:-}" 2>/dev/null || true
+ rm -rf "${TMPDIR}"
+}
+trap cleanup EXIT
+
+if [[ ! -x "${HYBBX}" ]]; then
+ echo "Build hybbx first: cmake --build build" >&2
+ exit 1
+fi
+
+cat > "${TMPDIR}/hybbx.ini" <<EOF
+[service]
+name = test-crdop
+
+[storage]
+path = ${TMPDIR}/data
+
+[auth]
+auto_login = no
+
+[networks]
+ax25 = no
+ardop = no
+crdop = yes
+circuit = no
+
+[transport.telnet]
+enabled = no
+
+[transport.crdop]
+enabled = yes
+modem_host = 127.0.0.1
+modem_port = ${MOCK_PORT}
+mycall = CB-0
+listen = yes
+circuit_host = 127.0.0.1
+circuit_port = 17325
+link_id = test-crdop
+EOF
+
+python3 "${ROOT}/scripts/mock-ardopc.py" "${MOCK_PORT}" &
+MOCK_PID=$!
+sleep 0.3
+
+nc -l 127.0.0.1 17325 >/dev/null 2>&1 &
+CIRCUIT_PID=$!
+sleep 0.2
+
+LOG="${TMPDIR}/hybbx.log"
+stdbuf -oL timeout 5 "${HYBBX}" -c "${TMPDIR}/hybbx.ini" >"${LOG}" 2>&1 &
+HYBBX_PID=$!
+sleep 3
+kill "${HYBBX_PID}" 2>/dev/null || true
+wait "${HYBBX_PID}" 2>/dev/null || true
+kill "${CIRCUIT_PID}" 2>/dev/null || true
+
+grep -q '\[crdop\] connected to external CRDOPC' "${LOG}" || {
+ echo "FAIL: no CRDOP CRDOPC connect log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q '\[crdop\] modem VERSION' "${LOG}" || {
+ echo "FAIL: no CRDOP VERSION log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q '\[crdop\] host init sent' "${LOG}" || {
+ echo "FAIL: no CRDOP host init log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q '\[crdop\] CB half-duplex profile' "${LOG}" || {
+ echo "FAIL: no CRDOP CB profile log" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+grep -q 'profile=cb' "${LOG}" || {
+ echo "FAIL: crdop profile not cb" >&2
+ cat "${LOG}" >&2
+ exit 1
+}
+
+echo "OK: crdop plugin local test passed"
+grep '\[crdop\]' "${LOG}"
diff --git a/share/THIRD_PARTY_NOTICES.txt b/share/THIRD_PARTY_NOTICES.txt
new file mode 100644
index 0000000..cf2c7c9
--- /dev/null
+++ b/share/THIRD_PARTY_NOTICES.txt
@@ -0,0 +1,22 @@
+HyBBX third-party notices
+=========================
+
+libssh
+------
+
+The SSH transport plugin (`plugins/ssh/`) uses libssh for the SSHv2 server
+protocol.
+
+ Project: https://www.libssh.org/
+ License: GNU Lesser General Public License v2.1 or later (LGPL-2.1+)
+
+HyBBX links against the shared libssh library (`libssh.so`) at build and
+runtime. HyBBX source code does not incorporate libssh source.
+
+Under the LGPL you may replace the libssh shared library with a compatible
+version without rebuilding HyBBX. Corresponding source code for the libssh
+version you use is available from the libssh project and your OS distributor.
+
+ SPDX-License-Identifier: LGPL-2.1-or-later
+
+See also LICENSE.txt for HyBBX (GPL-3.0).
diff --git a/share/areas.yaml b/share/areas.yaml
new file mode 100644
index 0000000..8a10d54
--- /dev/null
+++ b/share/areas.yaml
@@ -0,0 +1,66 @@
+# HyBBX menu layout — areas, per-level menu, and full index.
+# Command help, aliases, access, and account rights: commands.yaml (loaded after).
+
+meta:
+ menu_header: "HyBBX commands /help <cmd> for more"
+ index_header: "HyBBX command-index /help <cmd> for more"
+ alias_header: "HyBBX aliases /help <cmd> for more"
+
+user_groups:
+ - Sysop
+ - Admin
+ - Mod
+ - User
+ - Guest
+
+areas:
+ - label: General
+ commands: [help, menu, index, alias, news, banner, motd, rules, who, users, session, version]
+ - label: Screen
+ commands: [clear, echo]
+ - label: Areas
+ commands: [leave, main, chat, conference, mail, proxymail, proxychat, exit]
+ - label: Account
+ commands: [login, changeme, register, deleteme]
+ - label: Admin
+ commands: [usercreate, activate, changeuser, promote, demote, delete]
+ - label: Sysop
+ commands: [deleteuser, shutdown, restart, broadcast, monitor]
+
+menu:
+ Guest:
+ - General
+ - Screen
+ - Account
+ - Areas
+ User:
+ - General
+ - Screen
+ - Areas
+ - Account
+ Mod:
+ - General
+ - Screen
+ - Areas
+ - Account
+ Admin:
+ - General
+ - Screen
+ - Areas
+ - Account
+ - Admin
+ Sysop:
+ - General
+ - Screen
+ - Areas
+ - Account
+ - Admin
+ - Sysop
+
+index:
+ - General
+ - Screen
+ - Areas
+ - Account
+ - Admin
+ - Sysop
diff --git a/share/cgi/hybbx-telnet.cgi.example b/share/cgi/hybbx-telnet.cgi.example
new file mode 100644
index 0000000..ba96ec4
--- /dev/null
+++ b/share/cgi/hybbx-telnet.cgi.example
@@ -0,0 +1,18 @@
+#!/bin/sh
+# Example CGI bridge: hybbx-telnet to local HyBBX (telnet transport).
+# Prefer WebSocket + reverse proxy when available — see docs/WEBSOCKET.md.
+#
+# Install: copy to cgi-bin, chmod +x, set HYBBX_TELNET and paths below.
+
+HYBBX_TELNET="${HYBBX_TELNET:-/usr/local/hybbx/hybbx-telnet}"
+HOST="${HYBBX_HOST:-127.0.0.1}"
+PORT="${HYBBX_PORT:-2323}"
+
+printf 'Content-Type: text/plain; charset=utf-8\r\n\r\n'
+
+if [ ! -x "$HYBBX_TELNET" ]; then
+ printf 'hybbx-telnet not found\n'
+ exit 1
+fi
+
+exec "$HYBBX_TELNET" -H "$HOST" -p "$PORT" --pace no
diff --git a/share/commands.yaml b/share/commands.yaml
new file mode 100644
index 0000000..0f97200
--- /dev/null
+++ b/share/commands.yaml
@@ -0,0 +1,306 @@
+# HyBBX command registry — help text, aliases, access levels, account rights.
+# Menu layout and user groups: areas.yaml (loaded first).
+#
+# Help format (2 lines, max 80 cols):
+# /<verb> placeholders
+# Help: summary. More: subcommands or aliases
+# No square brackets in session help/menu text.
+
+rights:
+ userchange:
+ - actor: Sysop
+ targets: [Admin, Mod, User]
+ - actor: Admin
+ targets: [Mod, User]
+ userdelete:
+ - actor: Sysop
+ targets: [Admin, Mod, User, Guest]
+ delete:
+ - actor: Sysop
+ targets: [Admin, Mod, User]
+ - actor: Admin
+ targets: [Mod, User]
+ promote:
+ - actor: Sysop
+ to: Admin
+ from: [User, Mod]
+ - actor: Sysop
+ to: Mod
+ from: [User]
+ - actor: Admin
+ to: Mod
+ from: [User]
+ demote:
+ - actor: Sysop
+ from: [Admin, Mod]
+ - actor: Admin
+ from: [Mod]
+
+aliases:
+ help:
+ - "?"
+ - command
+ - cmd
+ - commands
+ banner:
+ - loginmsg
+ rules:
+ - legal
+ who:
+ - online
+ version:
+ - ver
+ session:
+ - info
+ leave:
+ - back
+ clear:
+ - cls
+ - reset
+ exit:
+ - logout
+ - bye
+ - quit
+ delete:
+ - del
+ conference:
+ - meeting
+ usercreate:
+ - createuser
+ changeuser:
+ - userchange
+ deleteuser:
+ - userdelete
+ broadcast:
+ - announce
+ monitor:
+ - mon
+ proxymail:
+ - /mail proxymail
+ proxychat:
+ - /chat proxychat
+
+commands:
+ help:
+ group: general
+ min: Guest
+ line1: "/help cmd"
+ line2: "Help: Full command-index or /help <cmd>. More: /menu /alias."
+
+ menu:
+ group: general
+ min: Guest
+ line1: "/menu"
+ line2: "Help: Full command-index for this Main. Same as /index."
+
+ index:
+ group: general
+ min: Guest
+ line1: "/index"
+ line2: "Help: Full command-index for this Main. Same as /menu."
+
+ alias:
+ group: general
+ min: Guest
+ line1: "/alias"
+ line2: "Help: Alternate names for commands. More: see /alias output."
+
+ news:
+ group: general
+ min: Guest
+ line1: "/news"
+ line2: "Help: Show system news from news.txt."
+
+ banner:
+ group: general
+ min: Guest
+ line1: "/banner"
+ line2: "Help: Show login banner from banner.txt. More: /loginmsg"
+
+ motd:
+ group: general
+ min: Guest
+ line1: "/motd"
+ line2: "Help: Show message of the day."
+
+ rules:
+ group: general
+ min: Guest
+ line1: "/rules"
+ line2: "Help: Show terms of use from rules.txt. More: /legal"
+
+ who:
+ group: general
+ min: Guest
+ line1: "/who"
+ line2: "Help: Online interactive users (telnet/SSH/Web). More: /online"
+
+ users:
+ group: general
+ min: User
+ line1: "/users"
+ line2: "Help: Registered users by level (Sysop Admin Mod User Guest)."
+
+ session:
+ group: general
+ min: Guest
+ line1: "/session"
+ line2: "Help: Username, session id, transport. More: /info"
+
+ version:
+ group: general
+ min: Guest
+ line1: "/version"
+ line2: "Help: Show text/version.txt (@version@ @os@). More: /ver"
+
+ clear:
+ group: screen
+ min: Guest
+ line1: "/clear"
+ line2: "Help: Clear screen and discard input line. More: /cls /reset"
+
+ echo:
+ group: screen
+ min: Guest
+ line1: "/echo yes|no"
+ line2: "Help: Show or set typed-character echo."
+
+ leave:
+ group: areas
+ min: User
+ line1: "/leave"
+ line2: "Help: Up one area level. More: /back"
+
+ main:
+ group: areas
+ min: User
+ line1: "/main"
+ line2: "Help: Return to main prompt."
+
+ chat:
+ group: areas
+ min: User
+ line1: "/chat n|name|show|showall|proxychat"
+ line2: "Help: Join or list chat channels. More: /chat proxychat"
+
+ conference:
+ group: areas
+ min: User
+ line1: "/conference topic user"
+ line2: "Help: Private two-user channel. More: /meeting"
+
+ mail:
+ group: areas
+ min: User
+ line1: "/mail list|read|send|delete|recycle|proxymail ..."
+ line2: "Help: Personal mailbox on this Main. More: list read send delete"
+
+ proxymail:
+ group: areas
+ min: User
+ line1: "/proxymail"
+ line2: "Help: mail to user@other-main. More: list read send delete recycle."
+
+ proxychat:
+ group: areas
+ min: User
+ line1: "/proxychat"
+ line2: "Help: Chat with users on other mains. More: /chat proxychat"
+
+ exit:
+ group: areas
+ min: Guest
+ line1: "/exit"
+ line2: "Help: Close connection. More: /quit /logout /bye"
+
+ login:
+ group: account
+ min: Guest
+ line1: "/login username password"
+ line2: "Help: Log in with a registered account."
+
+ register:
+ group: account
+ min: Guest
+ only: Guest
+ line1: "/register user name country location email password"
+ line2: "Help: Self-registration. Guest only. Sysop and Admin notified."
+
+ changeme:
+ group: account
+ min: User
+ line1: "/changeme old new name country location email"
+ line2: "Help: Update own profile and password."
+
+ deleteme:
+ group: account
+ min: User
+ max: Admin
+ line1: "/deleteme yes|no"
+ line2: "Help: Permanently delete own account."
+
+ usercreate:
+ group: admin
+ min: Admin
+ line1: "/usercreate user name country location email"
+ line2: "Help: Create user account. Inactive until /activate. More: /createuser"
+
+ activate:
+ group: admin
+ min: Admin
+ line1: "/activate username"
+ line2: "Help: Set username activated."
+
+ changeuser:
+ group: admin
+ min: Admin
+ line1: "/changeuser user name country location email password"
+ line2: "Help: Overwrite user profile and password. More: /userchange"
+
+ promote:
+ group: admin
+ min: Admin
+ line1: "/promote username admin|mod"
+ line2: "Help: Raise user to Admin or Mod."
+
+ demote:
+ group: admin
+ min: Admin
+ line1: "/demote username"
+ line2: "Help: Set user to User level."
+
+ delete:
+ group: admin
+ min: Admin
+ line1: "/delete username"
+ line2: "Help: Permanently remove account. More: /del"
+
+ deleteuser:
+ group: sysop
+ min: Sysop
+ line1: "/deleteuser username"
+ line2: "Help: Delete any non-Sysop account. More: /userdelete"
+
+ shutdown:
+ group: sysop
+ min: Sysop
+ line1: "/shutdown"
+ line2: "Help: Stop the HyBBX daemon."
+
+ restart:
+ group: sysop
+ min: Sysop
+ line1: "/restart"
+ line2: "Help: Restart the HyBBX daemon."
+
+ broadcast:
+ group: sysop
+ min: Sysop
+ line1: "/broadcast message | ax25"
+ line2: "Help: Local +++ announce or sequential RF beacon. More: /announce"
+
+ monitor:
+ group: sysop
+ min: Sysop
+ line1: "/monitor on|off|list|cmds|meet user"
+ line2: "Help: Live log monitor and sysop admin menu. More: /mon"
diff --git a/share/data.gitkeep b/share/data.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/share/data.gitkeep
diff --git a/share/fail2ban/filter.d/hybbx-circuit.conf b/share/fail2ban/filter.d/hybbx-circuit.conf
new file mode 100644
index 0000000..901e8a2
--- /dev/null
+++ b/share/fail2ban/filter.d/hybbx-circuit.conf
@@ -0,0 +1,10 @@
+# HyBBX HBX circuit link authentication failures (security.log).
+# Matches failed LINK_AUTH on [circuit] port (default 7323).
+
+[INCLUDES]
+before = common.conf
+
+[Definition]
+_daemon = hybbx
+failregex = ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} link_auth_fail ip=<HOST> .*transport=circuit
+ignoreregex =
diff --git a/share/fail2ban/filter.d/hybbx-ssh.conf b/share/fail2ban/filter.d/hybbx-ssh.conf
new file mode 100644
index 0000000..407be52
--- /dev/null
+++ b/share/fail2ban/filter.d/hybbx-ssh.conf
@@ -0,0 +1,11 @@
+# HyBBX SSH transport login brute-force (security.log).
+# Enable when [transport.ssh] is running and logs login_fail
+# with transport=ssh to security.log.
+
+[INCLUDES]
+before = common.conf
+
+[Definition]
+_daemon = hybbx
+failregex = ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} login_fail ip=<HOST> user=.* transport=ssh
+ignoreregex =
diff --git a/share/fail2ban/filter.d/hybbx-telnet.conf b/share/fail2ban/filter.d/hybbx-telnet.conf
new file mode 100644
index 0000000..f6dfa53
--- /dev/null
+++ b/share/fail2ban/filter.d/hybbx-telnet.conf
@@ -0,0 +1,10 @@
+# HyBBX telnet login brute-force (security.log).
+# Install: copy to /etc/fail2ban/filter.d/ and enable hybbx-telnet in jail.d.
+
+[INCLUDES]
+before = common.conf
+
+[Definition]
+_daemon = hybbx
+failregex = ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} login_fail ip=<HOST> user=.* transport=telnet
+ignoreregex =
diff --git a/share/fail2ban/filter.d/hybbx-websocket.conf b/share/fail2ban/filter.d/hybbx-websocket.conf
new file mode 100644
index 0000000..2166ee7
--- /dev/null
+++ b/share/fail2ban/filter.d/hybbx-websocket.conf
@@ -0,0 +1,11 @@
+# HyBBX WebSocket transport login brute-force (security.log).
+# Enable when [transport.websocket] is running and logs login_fail
+# with transport=websocket to security.log.
+
+[INCLUDES]
+before = common.conf
+
+[Definition]
+_daemon = hybbx
+failregex = ^\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2} login_fail ip=<HOST> user=.* transport=websocket
+ignoreregex =
diff --git a/share/fail2ban/jail.d/hybbx.local.example b/share/fail2ban/jail.d/hybbx.local.example
new file mode 100644
index 0000000..3d91296
--- /dev/null
+++ b/share/fail2ban/jail.d/hybbx.local.example
@@ -0,0 +1,45 @@
+# Example fail2ban jails for HyBBX security.log
+#
+# HyBBX v2.0.0+ includes built-in [security] bans — these filters are optional
+# for site-wide iptables/nftables integration alongside the daemon.
+#
+# 1. Copy filter.d/*.conf to /etc/fail2ban/filter.d/
+# 2. Copy this file to /etc/fail2ban/jail.d/hybbx.local
+# 3. Set logpath to your HyBBX logs directory ([log] dir in hybbx.ini)
+# 4. sudo fail2ban-client reload
+
+[hybbx-telnet]
+enabled = true
+filter = hybbx-telnet
+port = 2323
+logpath = /usr/local/hybbx/logs/security.log
+maxretry = 5
+findtime = 600
+bantime = 600
+
+[hybbx-circuit]
+enabled = true
+filter = hybbx-circuit
+port = 7323
+logpath = /usr/local/hybbx/logs/security.log
+maxretry = 5
+findtime = 600
+bantime = 600
+
+[hybbx-ssh]
+enabled = false
+filter = hybbx-ssh
+port = 3232
+logpath = /usr/local/hybbx/logs/security.log
+maxretry = 5
+findtime = 600
+bantime = 600
+
+[hybbx-websocket]
+enabled = false
+filter = hybbx-websocket
+port = 4591
+logpath = /usr/local/hybbx/logs/security.log
+maxretry = 5
+findtime = 600
+bantime = 600
diff --git a/share/hybbx-main.ini.example b/share/hybbx-main.ini.example
new file mode 100644
index 0000000..8573620
--- /dev/null
+++ b/share/hybbx-main.ini.example
@@ -0,0 +1,97 @@
+; SUPERSEDED — use share/hybbxd-main.ini.example (hybbxd Main instance)
+; HyBBX v2.8.0 — Main (users + HBX hub; RF on remote Secondary)
+; Pair with: share/hybbx-secondary.ini.example on the remote RF host
+; Copy: cp share/hybbx-main.ini.example hybbx.ini
+
+[service]
+name = hyBBX
+login_announce = no
+; login_announce = yes — "*** User login: user@plugin" (not Guest; not invisible Sysop);
+; /who and /online list as user@plugin
+
+[storage]
+backend = flatfile
+path = data
+
+[auth]
+auto_login = yes
+
+[traffic]
+baud = 2400
+line_width = 80
+pace_output = yes
+ansi = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+
+[monitor]
+enabled = yes
+follow_hybbx = yes
+follow_security = yes
+invisible-sysop = no
+invite_timeout_sec = 20
+; allow = Alice,Bob
+; invisible-sysop = yes — Sysop always hidden from /who (even without /monitor on)
+
+[security]
+enabled = yes
+telnet = yes
+ssh = yes
+websocket = yes
+circuit = yes
+
+[mail]
+enabled = yes
+path = mail
+
+[networks]
+ax25 = no
+baycom = no
+tmodem = no
+ssh = yes
+websocket = yes
+circuit = yes
+mains_proxy = no
+
+[transport.telnet]
+bind = 0.0.0.0
+port = 2323
+
+[transport.ssh]
+enabled = yes
+bind = 0.0.0.0
+port = 3232
+hostkey_dir = keys
+
+[circuit]
+bind = 127.0.0.1
+port = 7323
+link_auth = yes
+link_password = changeme
+max_links = 8
+balance = yes
+
+[broadcast]
+enabled = yes
+ax25 = yes
+ax25_auto = yes
+ax25_auto_interval = 300
+ax25_auto_stagger = 0
+ax25_auto_message = Broadcast: @service@ online
+ax25_mycall = HYBBX
+ax25_dest = QST
+
+[ax25]
+frequency1 = 27.205
+frequency1_label = remote-A
+
+; Bridge registry — link_id/password/MHz must match Secondary [transport.packet_radioN]
+[transport.packet_radio1]
+link_id = secondary-1
+link_password = changeme
+link_role = link
+frequency_mhz = 27.205
diff --git a/share/hybbx-mesh.ini.example b/share/hybbx-mesh.ini.example
new file mode 100644
index 0000000..6225048
--- /dev/null
+++ b/share/hybbx-mesh.ini.example
@@ -0,0 +1,93 @@
+; SUPERSEDED — use share/hybbxd-main.ini.example + hybbxsd-secondary / hybbxpd-proxy
+; HyBBX v2.8.0 — Main + proxy mesh (mains_proxy) + remote Secondary RF
+; Build: -DHYBBX_PLUGIN_MAINS_PROXY=ON · Pair Secondary: hybbx-secondary.ini.example
+; Reciprocal peer INI required on remote Main. Docs: docs/MAINS_PROXY.md
+
+[service]
+name = main-west
+login_announce = no
+; login_announce = yes — "*** User login: user@plugin" (not Guest; not invisible Sysop);
+; /who and /online list as user@plugin
+
+[storage]
+backend = flatfile
+path = data
+
+[auth]
+auto_login = yes
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+
+[monitor]
+enabled = yes
+follow_hybbx = yes
+follow_security = yes
+invisible-sysop = no
+invite_timeout_sec = 20
+; allow = Alice,Bob
+; invisible-sysop = yes — Sysop always hidden from /who (even without /monitor on)
+
+[security]
+enabled = yes
+telnet = yes
+ssh = yes
+circuit = yes
+
+[mail]
+enabled = yes
+path = mail
+
+[networks]
+ax25 = no
+baycom = no
+tmodem = no
+ssh = yes
+circuit = yes
+mains_proxy = yes
+
+[transport.telnet]
+bind = 0.0.0.0
+port = 2323
+
+[circuit]
+bind = 127.0.0.1
+port = 7323
+link_auth = yes
+link_password = changeme
+max_links = 8
+balance = yes
+
+[broadcast]
+enabled = yes
+ax25 = yes
+ax25_auto = yes
+ax25_auto_interval = 300
+
+[ax25]
+frequency1 = 27.205
+
+; Outbound proxy peer (HBX to remote Main)
+[transport.mains_proxy1]
+peer_id = main-east
+circuit_host = 192.0.2.10
+circuit_port = 7323
+link_id = main-west
+link_password = changeme
+wire = circuit
+duplex = full
+
+; Inbound registry for remote peer (match their link_id on LINK_AUTH)
+[transport.mains_proxy2]
+peer_id = main-east
+link_id = main-east
+link_password = changeme
+
+; RF bridge — remote Secondary packet_radio
+[transport.packet_radio1]
+link_id = secondary-1
+link_password = changeme
+frequency_mhz = 27.205
diff --git a/share/hybbx-secondary.ini.example b/share/hybbx-secondary.ini.example
new file mode 100644
index 0000000..ab9db5e
--- /dev/null
+++ b/share/hybbx-secondary.ini.example
@@ -0,0 +1,64 @@
+; SUPERSEDED — use share/hybbxsd-secondary.ini.example (hybbxsd Secondary instance)
+; HyBBX v2.8.0 — Secondary (RF host → Main HBX hub)
+; Pair with: share/hybbx-main.ini.example or share/hybbx-mesh.ini.example
+; Prerequisite: max25d preps serial on this host — HyBBX KISS attach only.
+; link_id / link_password / frequency_mhz must match Main [transport.packet_radioN]
+
+[service]
+name = hyBBX-secondary
+login_announce = no
+; login_announce = yes — "*** User login: user@plugin" (not Guest; not invisible Sysop);
+; /who and /online list as user@plugin
+
+[storage]
+backend = flatfile
+path = data-secondary
+
+[auth]
+auto_login = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+
+[monitor]
+enabled = yes
+follow_hybbx = yes
+follow_security = yes
+invisible-sysop = no
+invite_timeout_sec = 20
+; allow = Alice,Bob
+; invisible-sysop = yes — Sysop always hidden from /who (even without /monitor on)
+
+[networks]
+ax25 = yes
+baycom = no
+tmodem = no
+circuit = no
+
+[max25]
+check = yes
+host = 127.0.0.1
+port = 7325
+; error/voice STATUS: max25d [reporting] only (3×20s passes, 50% min good, defaults)
+
+[transport.packet_radio1]
+circuit_host = main.example.com
+circuit_port = 7323
+link_id = secondary-1
+link_password = changeme
+frequency_mhz = 27.205
+tnc = tnc2c
+protocol = kiss
+device = /dev/ttyUSB0
+baud = 9600
+kiss_entry = none
+modem = tcm3105
+radio_baud = 1200
+radio_band = amateur
+radio_duplex = half
+
+[ax25]
+frequency1 = 27.205
diff --git a/share/hybbx-standalone.ini.example b/share/hybbx-standalone.ini.example
new file mode 100644
index 0000000..86cff32
--- /dev/null
+++ b/share/hybbx-standalone.ini.example
@@ -0,0 +1,125 @@
+; HyBBX v2.8.0 — Standalone Main (users + local RF on one host)
+; Copy: cp share/hybbx-standalone.ini.example hybbx.ini
+; Prerequisite: max25d (MAX25-Stack) preps serial/KISS — HyBBX attach only.
+; PC-COM / BayCom/based: max25d max25e0 → /tmp/max25-bcpr/kiss-bc0
+; Docs: docs/MANUAL.md · docs/TNCS.md · docs/BAYCOM.md · docs/TOPOLOGY.md
+
+[instance]
+standalone = yes
+
+[service]
+name = hyBBX
+; Process owner — required when started as root (hybbxd must not stay root)
+user = hybbx
+group = hybbx
+login_announce = no
+; login_announce = yes — "*** User login: user@plugin" (not Guest; not invisible Sysop);
+; /who and /online list as user@plugin
+
+[storage]
+backend = flatfile
+path = data
+
+[auth]
+auto_login = yes
+
+[traffic]
+baud = 2400
+line_width = 80
+pace_output = yes
+ansi = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+
+[monitor]
+enabled = yes
+follow_hybbx = yes
+follow_security = yes
+invisible-sysop = no
+invite_timeout_sec = 20
+; allow = Alice,Bob
+; invisible-sysop = yes — Sysop always hidden from /who (even without /monitor on)
+
+[security]
+enabled = yes
+telnet = yes
+ssh = yes
+circuit = yes
+
+[mail]
+enabled = yes
+path = mail
+
+[networks]
+; Standalone Main: enable local RF transports (TNC and/or BayCom/based)
+ax25 = no
+baycom = yes
+tmodem = no
+ssh = yes
+circuit = yes
+
+[max25]
+check = yes
+host = 127.0.0.1
+port = 7325
+; error/voice STATUS: max25d [reporting] only (3×20s passes, 50% min good, defaults)
+
+[circuit]
+bind = 127.0.0.1
+port = 7323
+link_auth = yes
+link_password = changeme
+max_links = 8
+balance = yes
+
+[broadcast]
+enabled = yes
+ax25 = yes
+ax25_auto = yes
+ax25_auto_interval = 300
+ax25_auto_stagger = 0
+ax25_auto_message = Broadcast: @service@ online
+ax25_mycall = HYBBX
+ax25_dest = QST
+
+[ax25]
+frequency1 = 27.205
+frequency1_label = local-A
+
+; BayCom/based via MAX25 prep — KISS attach (PC-COM / SER12 userspace path)
+[transport.baycom1]
+backend = kiss
+device = /tmp/max25-bcpr/kiss-bc0
+serial_baud = 1200
+radio_baud = 1200
+txdelay = 35
+slot = 10
+persist = 128
+txtail = 2
+circuit_host = 127.0.0.1
+circuit_port = 7323
+link_id = local-pccom
+link_password = changeme
+link_role = link
+frequency_mhz = 27.205
+
+; Optional TNC path (enable [networks] ax25=yes and comment out baycom if TNC-only)
+; [transport.packet_radio1]
+; link_id = local-radio1
+; link_password = changeme
+; frequency_mhz = 27.205
+; circuit_host = 127.0.0.1
+; circuit_port = 7323
+; tnc = tnc2c
+; protocol = kiss
+; device = /dev/ttyUSB0
+; baud = 9600
+; kiss_entry = none
+; modem = tcm3105
+; radio_baud = 1200
+; radio_band = amateur
+; radio_duplex = half
diff --git a/share/hybbx.ini.example b/share/hybbx.ini.example
new file mode 100644
index 0000000..f730f46
--- /dev/null
+++ b/share/hybbx.ini.example
@@ -0,0 +1,17 @@
+; HyBBX v2.8.0 — INI topology (three instance binaries)
+;
+; Layout (hardcoded per binary):
+;
+; hybbxd Main — local BBX; WebSocket→reverse-proxy; mains_proxy↔Secondary
+; hybbxsd Secondary — peer Mains via mains_proxy; transport plugins; no user BBX
+; hybbxpd Proxy — TCP hybrid bridge; circuit→Main; no WebSocket/RF in-process
+;
+; Templates:
+; hybbxd-main.ini.example Main (hybbxd)
+; hybbxsd-secondary.ini.example Secondary (hybbxsd)
+; hybbxpd-proxy.ini.example Proxy (hybbxpd)
+;
+; Legacy (superseded): hybbx-main.ini.example, hybbx-secondary.ini.example,
+; hybbx-mesh.ini.example, hybbx-standalone.ini.example
+;
+; Default install: hybbxd-main.ini.example → hybbx.ini
diff --git a/share/hybbxd-main.ini.example b/share/hybbxd-main.ini.example
new file mode 100644
index 0000000..efcadf6
--- /dev/null
+++ b/share/hybbxd-main.ini.example
@@ -0,0 +1,82 @@
+; HyBBX Main — binary: hybbxd
+; Local BBX (users/mail/chat). Remote users: WebSocket → reverse-proxy only.
+; HBX circuit hub for Proxy (hybbxpd). mains_proxy for Secondary peers (hybbxsd).
+; Copy: cp share/hybbxd-main.ini.example local/hybbx.ini
+
+[service]
+name = hyBBX
+; Required when hybbxd is started as root — process drops here before plugins bind
+user = hybbx
+group = hybbx
+login_announce = no
+
+[storage]
+backend = flatfile
+path = data
+
+[auth]
+auto_login = yes
+
+[traffic]
+baud = 2400
+line_width = 80
+pace_output = yes
+ansi = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+[monitor]
+enabled = yes
+follow_hybbx = yes
+follow_security = yes
+invisible-sysop = no
+invite_timeout_sec = 20
+
+[security]
+enabled = yes
+telnet = no
+ssh = no
+websocket = yes
+circuit = yes
+
+[mail]
+enabled = yes
+path = mail
+
+[networks]
+; Main layout: WebSocket user path + circuit hub + optional mains_proxy
+telnet = no
+ssh = no
+websocket = yes
+ax25 = no
+baycom = no
+tmodem = no
+ardop = no
+crdop = no
+circuit = yes
+mains_proxy = no
+
+[transport.websocket]
+bind = 127.0.0.1
+port = 8080
+; Put nginx/caddy reverse-proxy in front for public TLS.
+
+[circuit]
+enabled = yes
+bind = 0.0.0.0
+port = 7323
+link_auth = yes
+link_password = changeme
+max_links = 8
+balance = yes
+
+; Example Proxy registry row (hybbxpd attaches with matching link_id/password)
+; [transport.packet_radio1] — superseded: RF via MAX25 + hybbxpd, not Main packet_radio
+[transport.baycom1]
+link_id = proxy-1
+link_password = changeme
+link_role = link
+; Bridge credentials only on Main — no local device on hybbxd
diff --git a/share/hybbxpd-proxy.ini.example b/share/hybbxpd-proxy.ini.example
new file mode 100644
index 0000000..26a4f4b
--- /dev/null
+++ b/share/hybbxpd-proxy.ini.example
@@ -0,0 +1,61 @@
+; HyBBX Proxy — binary: hybbxpd
+; Hybrid bridge (router/gateway/relay posture). TCP/IP primary.
+; No WebSocket. No in-process AX.25/packet_radio — RF via MAX25-Stack
+; (relay / gateway / digipeater for hyBBX).
+; Attach to Main (hybbxd) over HBX circuit.
+; Copy: cp share/hybbxpd-proxy.ini.example local/hybbxpd.ini
+
+[service]
+name = hyBBX-proxy
+
+[storage]
+backend = flatfile
+path = data-proxy
+
+[auth]
+auto_login = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+[networks]
+telnet = yes
+ssh = yes
+websocket = no
+ax25 = no
+baycom = no
+tmodem = no
+ardop = no
+crdop = no
+circuit = yes
+mains_proxy = no
+
+[transport.telnet]
+bind = 127.0.0.1
+port = 2323
+
+[transport.ssh]
+bind = 0.0.0.0
+port = 3232
+
+; Client attach toward Main hub (not a circuit listener)
+[circuit]
+enabled = yes
+; Proxy uses circuit_host on edge transports — hub stays on Main.
+
+; Example: TCP/hybrid edge toward Main (credentials must match Main bridge row)
+[transport.baycom1]
+enabled = no
+; Prefer MAX25 relay/gateway/digipeater instead of in-process BayCom RF.
+circuit_host = 192.0.2.1
+circuit_port = 7323
+link_id = proxy-1
+link_password = changeme
+link_role = link
+
+[max25]
+check = yes
+host = 127.0.0.1
+port = 7325
diff --git a/share/hybbxsd-secondary.ini.example b/share/hybbxsd-secondary.ini.example
new file mode 100644
index 0000000..25d737e
--- /dev/null
+++ b/share/hybbxsd-secondary.ini.example
@@ -0,0 +1,41 @@
+; HyBBX Secondary — binary: hybbxsd
+; Peer other Mains via mains_proxy only. Not a local user BBX (no hybbxd behaviour).
+; Copy: cp share/hybbxsd-secondary.ini.example local/hybbxsd.ini
+
+[service]
+name = hyBBX-secondary-peer
+
+[storage]
+backend = flatfile
+path = data-secondary
+
+[auth]
+auto_login = no
+
+[log]
+enabled = yes
+dir = logs
+level = warn
+
+[networks]
+; Secondary = other-Main peers (mains_proxy). No user telnet/ssh/websocket.
+telnet = no
+ssh = no
+websocket = no
+ax25 = no
+baycom = no
+tmodem = no
+ardop = no
+crdop = no
+circuit = no
+mains_proxy = yes
+
+; Reciprocal peer on remote Main (hybbxd with mains_proxy=yes)
+[transport.mains_proxy1]
+enabled = yes
+peer_id = main-east
+circuit_host = 192.0.2.10
+circuit_port = 7323
+link_id = mesh-west-east
+link_password = changeme
+link_role = proxy
diff --git a/share/logs.gitkeep b/share/logs.gitkeep
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/share/logs.gitkeep
diff --git a/share/reverse-proxy/README.txt b/share/reverse-proxy/README.txt
new file mode 100644
index 0000000..a11d1cc
--- /dev/null
+++ b/share/reverse-proxy/README.txt
@@ -0,0 +1,6 @@
+HyBBX reverse-proxy examples. WebSocket plugin = session data on loopback only.
+
+1. HTTPD_DOCROOT=/srv/www # your httpd document root
+2. cp -r docroot/hybbx-websocket $HTTPD_DOCROOT/
+3. Add nginx/apache/lighttpd snippet (adjust paths)
+4. hybbx.ini [transport.websocket] — see docs/WEBSOCKET.md
diff --git a/share/reverse-proxy/apache2.conf.example b/share/reverse-proxy/apache2.conf.example
new file mode 100644
index 0000000..a361532
--- /dev/null
+++ b/share/reverse-proxy/apache2.conf.example
@@ -0,0 +1,24 @@
+# HyBBX — Apache httpd 2.4. HTTPD_DOCROOT example: /srv/www
+
+<IfModule mod_proxy.c>
+ ProxyPreserveHost On
+ ProxyTimeout 3600
+ SSLProxyEngine On
+ SSLProxyVerify none
+ SSLProxyCheckPeerCN off
+ SSLProxyCheckPeerName off
+
+ ProxyPass "/hybbx-websocket/ws" "wss://127.0.0.1:4591/hybbx" timeout=3600 keepalive=On
+ ProxyPassReverse "/hybbx-websocket/ws" "wss://127.0.0.1:4591/hybbx"
+</IfModule>
+
+Alias /hybbx-websocket /srv/www/hybbx-websocket
+<Directory /srv/www/hybbx-websocket>
+ DirectoryIndex index.php
+ Require all granted
+ <FilesMatch \.php$>
+ SetHandler application/x-httpd-php
+ </FilesMatch>
+</Directory>
+
+# UI: cp -r reverse-proxy/docroot/hybbx-websocket $HTTPD_DOCROOT/
diff --git a/share/reverse-proxy/docroot/README.txt b/share/reverse-proxy/docroot/README.txt
new file mode 100644
index 0000000..0c820fe
--- /dev/null
+++ b/share/reverse-proxy/docroot/README.txt
@@ -0,0 +1,7 @@
+Copy docroot/hybbx-websocket/ to HTTPD_DOCROOT (not HYBBX_ROOT).
+HyBBX websocket plugin ships session data only on loopback :4591.
+
+ HTTPD_DOCROOT=/srv/www
+ cp -r docroot/hybbx-websocket $HTTPD_DOCROOT/
+
+Match URL paths in hybbx.ini and the httpd snippets in docs/WEBSOCKET.md.
diff --git a/share/reverse-proxy/docroot/hybbx-websocket/hybbx-terminal.js b/share/reverse-proxy/docroot/hybbx-websocket/hybbx-terminal.js
new file mode 100644
index 0000000..50fb826
--- /dev/null
+++ b/share/reverse-proxy/docroot/hybbx-websocket/hybbx-terminal.js
@@ -0,0 +1,95 @@
+(function () {
+ 'use strict';
+
+ var term = document.getElementById('term');
+ var status = document.getElementById('status');
+ var form = document.getElementById('input-bar');
+ var input = document.getElementById('cmd');
+ var wsUrl = window.HYBBX_WS_URL;
+ var ws = null;
+ var reconnectTimer = null;
+ var reconnectDelayMs = 2000;
+ var reconnectAttempt = 0;
+
+ function append(text) {
+ term.textContent += text;
+ term.scrollTop = term.scrollHeight;
+ }
+
+ function setStatus(s) {
+ status.textContent = s;
+ }
+
+ function scheduleReconnect() {
+ if (reconnectTimer) {
+ return;
+ }
+
+ reconnectAttempt += 1;
+ setStatus('reconnecting in ' + (reconnectDelayMs / 1000) + 's');
+ reconnectTimer = window.setTimeout(function () {
+ reconnectTimer = null;
+ connect();
+ }, reconnectDelayMs);
+ }
+
+ function setInputEnabled(enabled) {
+ input.disabled = !enabled;
+ if (enabled) {
+ input.focus();
+ }
+ }
+
+ function sendLine(text) {
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ return;
+ }
+
+ ws.send(text + '\r');
+ }
+
+ function connect() {
+ setStatus('connecting');
+ setInputEnabled(false);
+ ws = new WebSocket(wsUrl);
+
+ ws.onopen = function () {
+ reconnectAttempt = 0;
+ setStatus('connected');
+ setInputEnabled(true);
+ };
+
+ ws.onmessage = function (ev) {
+ append(ev.data);
+ };
+
+ ws.onclose = function (ev) {
+ var reason = ev.reason ? ' (' + ev.reason + ')' : '';
+
+ append('\n[websocket disconnected: code ' + ev.code + reason + ']\n');
+ setStatus('disconnected: ' + ev.code);
+ ws = null;
+ setInputEnabled(false);
+ scheduleReconnect();
+ };
+
+ ws.onerror = function () {
+ setStatus('error');
+ setInputEnabled(false);
+ };
+ }
+
+ form.addEventListener('submit', function (ev) {
+ ev.preventDefault();
+
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
+ return;
+ }
+
+ sendLine(input.value);
+ input.value = '';
+ input.focus();
+ });
+
+ connect();
+})();
diff --git a/share/reverse-proxy/docroot/hybbx-websocket/index.php b/share/reverse-proxy/docroot/hybbx-websocket/index.php
new file mode 100644
index 0000000..52a060c
--- /dev/null
+++ b/share/reverse-proxy/docroot/hybbx-websocket/index.php
@@ -0,0 +1,89 @@
+<?php
+/**
+ * HyBBX browser terminal — copy this folder to your httpd document root.
+ * WebSocket data comes from hybbx only; this file is not part of the daemon.
+ */
+$base = rtrim(dirname($_SERVER['SCRIPT_NAME'] ?? ''), '/');
+$ws_path = ($base !== '' ? $base : '/hybbx-websocket') . '/ws';
+$scheme = (!empty($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off') ? 'wss' : 'ws';
+$host = $_SERVER['HTTP_HOST'] ?? 'localhost';
+$ws_url = $scheme . '://' . $host . $ws_path;
+?><!DOCTYPE html>
+<html lang="en">
+<head>
+ <meta charset="utf-8">
+ <meta name="viewport" content="width=device-width, initial-scale=1">
+ <title>HyBBX</title>
+ <style>
+ html, body { height: 100%; margin: 0; }
+ body {
+ display: flex;
+ flex-direction: column;
+ background: #0a0a12;
+ color: #c8d0d8;
+ font-family: ui-monospace, "Cascadia Mono", "Consolas", monospace;
+ }
+ #status {
+ position: fixed;
+ top: 0;
+ right: 0;
+ padding: .5rem 1rem;
+ font-size: .8rem;
+ background: #1a1a28;
+ z-index: 1;
+ }
+ #term {
+ flex: 1;
+ min-height: 0;
+ box-sizing: border-box;
+ width: 100%;
+ margin: 0;
+ padding: 1rem;
+ padding-bottom: .5rem;
+ white-space: pre-wrap;
+ overflow-y: auto;
+ overflow-x: hidden;
+ }
+ #input-bar {
+ flex: none;
+ display: flex;
+ width: 100%;
+ box-sizing: border-box;
+ border-top: 1px solid #2a2a38;
+ background: #12121c;
+ }
+ #cmd {
+ flex: 1;
+ width: 100%;
+ min-width: 0;
+ box-sizing: border-box;
+ border: none;
+ border-radius: 0;
+ background: #12121c;
+ color: #e0e4e8;
+ font: inherit;
+ font-size: 1rem;
+ line-height: 1.4;
+ padding: .75rem 1rem;
+ outline: none;
+ }
+ #cmd::placeholder { color: #6a7280; }
+ #cmd:focus { background: #161622; }
+ #cmd:disabled { opacity: .55; cursor: not-allowed; }
+ </style>
+</head>
+<body>
+ <div id="status">disconnected</div>
+ <pre id="term"></pre>
+ <form id="input-bar" autocomplete="off">
+ <input type="text" id="cmd" disabled
+ autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false"
+ enterkeyhint="send"
+ placeholder="Type command or text — Enter to send">
+ </form>
+ <script>
+ window.HYBBX_WS_URL = <?php echo json_encode($ws_url, JSON_UNESCAPED_SLASHES); ?>;
+ </script>
+ <script src="hybbx-terminal.js"></script>
+</body>
+</html>
diff --git a/share/reverse-proxy/lighttpd.conf.example b/share/reverse-proxy/lighttpd.conf.example
new file mode 100644
index 0000000..1ef9244
--- /dev/null
+++ b/share/reverse-proxy/lighttpd.conf.example
@@ -0,0 +1,26 @@
+# HyBBX — lighttpd 1.4. HTTPD_DOCROOT example: /srv/www
+
+# Raise idle limits for upgraded WebSocket connections.
+server.max-read-idle = 3600
+server.max-write-idle = 3600
+
+$HTTP["url"] == "/hybbx-websocket/ws" {
+ proxy.server = ( "" => (
+ "hybbx" => (
+ "proxy" => "https://127.0.0.1:4591/hybbx",
+ "upgrade" => "enable"
+ )
+ ))
+ proxy.header = ( "upgrade" => "enable" )
+}
+
+alias.url += ( "/hybbx-websocket/" => "/srv/www/hybbx-websocket/" )
+index-file.names += ( "index.php" )
+
+fastcgi.server += ( ".php" =>
+ ( "localhost" =>
+ ( "socket" => "/run/php-fpm/www.sock" )
+ )
+)
+
+# UI: cp -r reverse-proxy/docroot/hybbx-websocket $HTTPD_DOCROOT/
diff --git a/share/reverse-proxy/nginx.conf.example b/share/reverse-proxy/nginx.conf.example
new file mode 100644
index 0000000..c916379
--- /dev/null
+++ b/share/reverse-proxy/nginx.conf.example
@@ -0,0 +1,28 @@
+# HyBBX — nginx. Set HTTPD_DOCROOT (example: /srv/www).
+
+location = /hybbx-websocket/ws {
+ proxy_pass https://127.0.0.1:4591/hybbx;
+ proxy_ssl_verify off;
+ proxy_http_version 1.1;
+ proxy_socket_keepalive on;
+ proxy_set_header Upgrade $http_upgrade;
+ proxy_set_header Connection "upgrade";
+ proxy_set_header Host $host;
+ proxy_set_header X-Real-IP $remote_addr;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_read_timeout 3600s;
+ proxy_send_timeout 3600s;
+}
+
+location /hybbx-websocket/ {
+ root /srv/www;
+ index index.php;
+ location ~ \.php$ {
+ include fastcgi_params;
+ fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
+ fastcgi_pass unix:/run/php-fpm/www.sock;
+ }
+}
+
+# UI: cp -r reverse-proxy/docroot/hybbx-websocket $HTTPD_DOCROOT/
+# Plain ws: proxy_pass http://127.0.0.1:4591/hybbx;
diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
new file mode 100644
index 0000000..e26bbcd
--- /dev/null
+++ b/src/CMakeLists.txt
@@ -0,0 +1,204 @@
+if(HYBBX_BUILD_DAEMON)
+ add_library(hybbx_core STATIC
+ core/registry.c
+ core/service.c
+ core/config.c
+ core/instance.c
+ core/circuit.c
+ core/circuit_tcp.c
+ core/circuit_bridge.c
+ core/circuit_balance.c
+ core/bandwidth_policy.c
+ core/broadcast.c
+ core/link.c
+ core/command.c
+ core/command_parse.c
+ core/commands_registry.c
+ core/auth.c
+ core/password.c
+ core/crypto_config.c
+ core/crypto_backends.c
+ core/crypto.c
+ core/traffic.c
+ core/storage.c
+ core/storage_flatfile.c
+ core/storage_sql.c
+ core/mail_sql.c
+ core/proxymail.c
+ core/proxychat.c
+ core/session.c
+ core/chat.c
+ core/conference.c
+ core/mail.c
+ core/messages.c
+ core/mains_proxy.c
+ core/networks.c
+ core/instance.c
+ core/crdop.c
+ core/terminal.c
+ core/texts.c
+ core/util.c
+ core/log.c
+ core/monitor.c
+ core/max25.c
+ core/security.c
+ core/security_ban.c
+ core/daemon_wrap.c
+ core/privilege.c
+ core/rf_tx_pace.c
+ clients/circuit_client.c
+ clients/link_auth.c
+ ${CMAKE_SOURCE_DIR}/third_party/tinysha256/tinysha256.c
+ ${CMAKE_SOURCE_DIR}/third_party/monocypher/monocypher.c
+ ${CMAKE_SOURCE_DIR}/third_party/tinyaes/aes.c
+ ${CMAKE_SOURCE_DIR}/third_party/tinyaes/aes256gcm.c
+ )
+
+ if(HYBBX_HAVE_OPENSSL)
+ target_sources(hybbx_core PRIVATE core/crypto_openssl.c)
+ endif()
+
+ if(HYBBX_HAVE_LIBSODIUM)
+ target_sources(hybbx_core PRIVATE core/crypto_libsodium.c)
+ endif()
+
+ add_library(HyBBX::core ALIAS hybbx_core)
+
+ target_include_directories(hybbx_core
+ PUBLIC
+ $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
+ $<INSTALL_INTERFACE:${HYBBX_INSTALL_HOME}/include>
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}/core
+ ${CMAKE_SOURCE_DIR}/third_party/tinysha256
+ ${CMAKE_SOURCE_DIR}/third_party/monocypher
+ ${CMAKE_SOURCE_DIR}/third_party/tinyaes
+ )
+
+ target_compile_definitions(hybbx_core
+ PRIVATE
+ HYBBX_VERSION_MAJOR=${PROJECT_VERSION_MAJOR}
+ HYBBX_VERSION_MINOR=${PROJECT_VERSION_MINOR}
+ HYBBX_VERSION_PATCH=${PROJECT_VERSION_PATCH}
+ )
+
+ hybbx_apply_hardening(hybbx_core)
+
+ find_package(Threads REQUIRED)
+ target_link_libraries(hybbx_core PUBLIC Threads::Threads)
+
+ if(HYBBX_HAVE_OPENSSL)
+ target_compile_definitions(hybbx_core PRIVATE HYBBX_HAVE_OPENSSL)
+ target_link_libraries(hybbx_core PRIVATE OpenSSL::Crypto)
+ endif()
+
+ if(HYBBX_HAVE_LIBSODIUM)
+ target_compile_definitions(hybbx_core PRIVATE HYBBX_HAVE_LIBSODIUM)
+ target_include_directories(hybbx_core PRIVATE ${LIBSODIUM_INCLUDE_DIRS})
+ target_link_directories(hybbx_core PRIVATE ${LIBSODIUM_LIBRARY_DIRS})
+ target_link_libraries(hybbx_core PRIVATE ${LIBSODIUM_LIBRARIES})
+ endif()
+
+ if(HYBBX_HAVE_SQLITE)
+ target_compile_definitions(hybbx_core PRIVATE HYBBX_HAVE_SQLITE)
+ target_include_directories(hybbx_core PRIVATE ${SQLITE3_INCLUDE_DIRS})
+ target_link_directories(hybbx_core PRIVATE ${SQLITE3_LIBRARY_DIRS})
+ target_link_libraries(hybbx_core PRIVATE ${SQLITE3_LIBRARIES})
+ endif()
+
+ add_executable(hybbxd main.c instance_role_main.c)
+ add_executable(hybbxsd main.c instance_role_secondary.c)
+ add_executable(hybbxpd main.c instance_role_proxy.c)
+
+ foreach(_bin IN ITEMS hybbxd hybbxsd hybbxpd)
+ target_link_libraries(${_bin} PRIVATE hybbx_core)
+ hybbx_apply_hardening_executable(${_bin})
+ endforeach()
+
+ install(TARGETS hybbxd hybbxsd hybbxpd hybbx_core
+ EXPORT HyBBXTargets
+ RUNTIME DESTINATION ${HYBBX_INSTALL_HOME}
+ )
+
+ if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_core
+ EXPORT HyBBXTargets
+ LIBRARY DESTINATION ${HYBBX_INSTALL_HOME}/lib
+ ARCHIVE DESTINATION ${HYBBX_INSTALL_HOME}/lib
+ )
+
+ install(DIRECTORY ${CMAKE_SOURCE_DIR}/include/hybbx
+ DESTINATION ${HYBBX_INSTALL_HOME}/include
+ FILES_MATCHING PATTERN "*.h"
+ )
+
+ install(EXPORT HyBBXTargets
+ FILE HyBBXTargets.cmake
+ NAMESPACE HyBBX::
+ DESTINATION ${HYBBX_INSTALL_HOME}/lib/cmake/HyBBX
+ )
+ endif()
+
+ install(FILES
+ ${CMAKE_SOURCE_DIR}/share/hybbx.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbxd-main.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbxsd-secondary.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbxpd-proxy.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbx-standalone.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbx-main.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbx-mesh.ini.example
+ ${CMAKE_SOURCE_DIR}/share/hybbx-secondary.ini.example
+ DESTINATION ${HYBBX_INSTALL_HOME}
+ )
+
+ install(FILES ${CMAKE_SOURCE_DIR}/share/hybbxd-main.ini.example
+ DESTINATION ${HYBBX_INSTALL_HOME}
+ RENAME hybbx.ini
+ )
+
+ install(FILES ${CMAKE_SOURCE_DIR}/share/commands.yaml
+ ${CMAKE_SOURCE_DIR}/share/areas.yaml
+ DESTINATION ${HYBBX_INSTALL_HOME}
+ )
+
+ install(DIRECTORY ${CMAKE_SOURCE_DIR}/text/
+ DESTINATION ${HYBBX_INSTALL_HOME}/text
+ FILES_MATCHING PATTERN "*.txt"
+ )
+
+ install(FILES ${CMAKE_SOURCE_DIR}/share/THIRD_PARTY_NOTICES.txt
+ DESTINATION ${HYBBX_INSTALL_HOME}
+ )
+
+ install(DIRECTORY ${CMAKE_SOURCE_DIR}/share/reverse-proxy/
+ DESTINATION ${HYBBX_INSTALL_HOME}/reverse-proxy
+ )
+
+ install(DIRECTORY ${CMAKE_SOURCE_DIR}/share/cgi/
+ DESTINATION ${HYBBX_INSTALL_HOME}/cgi
+ FILE_PERMISSIONS OWNER_READ OWNER_WRITE OWNER_EXECUTE GROUP_READ GROUP_EXECUTE WORLD_READ WORLD_EXECUTE
+ )
+
+ install(FILES ${CMAKE_SOURCE_DIR}/share/data.gitkeep
+ DESTINATION ${HYBBX_INSTALL_HOME}/data
+ RENAME .gitkeep
+ )
+
+ install(FILES ${CMAKE_SOURCE_DIR}/share/logs.gitkeep
+ DESTINATION ${HYBBX_INSTALL_HOME}/logs
+ RENAME .gitkeep
+ )
+
+ install(DIRECTORY ${CMAKE_SOURCE_DIR}/share/fail2ban/
+ DESTINATION ${HYBBX_INSTALL_HOME}/fail2ban
+ )
+
+ install(PROGRAMS ${CMAKE_SOURCE_DIR}/scripts/hybbx.sh
+ DESTINATION ${HYBBX_INSTALL_HOME}
+ RENAME hybbx-start
+ )
+endif()
+
+if(HYBBX_BUILD_CLIENTS)
+ add_subdirectory(clients)
+endif()
diff --git a/src/clients/CMakeLists.txt b/src/clients/CMakeLists.txt
new file mode 100644
index 0000000..2181fbc
--- /dev/null
+++ b/src/clients/CMakeLists.txt
@@ -0,0 +1,96 @@
+# Standalone CLI clients (hybbx-telnet, hybbx-terminal, hybbx-ssh).
+# No centralized daemon; flags/env only — see src/clients/*.c for options.
+
+if(HYBBX_PLATFORM_AMIGAOS)
+ set(_hybbx_client_sources
+ ${CMAKE_SOURCE_DIR}/src/core/util.c
+ ${CMAKE_SOURCE_DIR}/src/core/traffic.c
+ ${CMAKE_SOURCE_DIR}/src/core/terminal.c
+ client_line.c
+ client_display.c
+ )
+else()
+ set(_hybbx_client_sources
+ ${CMAKE_SOURCE_DIR}/src/core/util.c
+ ${CMAKE_SOURCE_DIR}/src/core/circuit.c
+ ${CMAKE_SOURCE_DIR}/src/core/traffic.c
+ ${CMAKE_SOURCE_DIR}/src/core/terminal.c
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio/ax25.c
+ circuit_client.c
+ link_auth.c
+ client_line.c
+ client_display.c
+ )
+endif()
+
+add_library(hybbx_client STATIC ${_hybbx_client_sources})
+
+target_include_directories(hybbx_client
+ PUBLIC
+ $<BUILD_INTERFACE:${CMAKE_SOURCE_DIR}/include>
+ PRIVATE
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+)
+
+target_compile_definitions(hybbx_client PRIVATE HYBBX_CLIENT_BUILD=1)
+hybbx_apply_hardening(hybbx_client)
+
+if(HYBBX_PLATFORM_AMIGAOS)
+ target_compile_options(hybbx_client PRIVATE -mcrt=clib2)
+endif()
+
+add_library(HyBBX::client ALIAS hybbx_client)
+
+if(HYBBX_BUILD_CLIENT_TELNET)
+ add_executable(hybbx-telnet
+ hybbx_telnet.c
+ ${CMAKE_SOURCE_DIR}/plugins/telnet/telnet_proto.c
+ )
+ target_include_directories(hybbx-telnet PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/telnet
+ )
+ target_link_libraries(hybbx-telnet PRIVATE hybbx_client)
+ hybbx_apply_hardening_executable(hybbx-telnet)
+ if(HYBBX_PLATFORM_AMIGAOS)
+ target_compile_options(hybbx-telnet PRIVATE -mcrt=clib2)
+ target_link_options(hybbx-telnet PRIVATE -mcrt=clib2)
+ target_link_libraries(hybbx-telnet PRIVATE net unix)
+ endif()
+ install(TARGETS hybbx-telnet RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_BUILD_CLIENT_TERMINAL)
+ add_executable(hybbx-terminal hybbx_terminal.c)
+ target_include_directories(hybbx-terminal PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ ${CMAKE_SOURCE_DIR}/plugins/packet_radio
+ )
+ target_link_libraries(hybbx-terminal PRIVATE hybbx_client)
+ hybbx_apply_hardening_executable(hybbx-terminal)
+ install(TARGETS hybbx-terminal RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_BUILD_CLIENT_SSH AND HYBBX_HAVE_LIBSSH)
+ find_package(PkgConfig REQUIRED)
+ pkg_check_modules(LIBSSH REQUIRED IMPORTED_TARGET libssh)
+
+ add_executable(hybbx-ssh hybbx_ssh.c)
+ target_include_directories(hybbx-ssh PRIVATE
+ ${CMAKE_SOURCE_DIR}/include
+ ${CMAKE_CURRENT_SOURCE_DIR}
+ )
+ target_link_libraries(hybbx-ssh PRIVATE hybbx_client PkgConfig::LIBSSH)
+ hybbx_apply_hardening_executable(hybbx-ssh)
+ install(TARGETS hybbx-ssh RUNTIME DESTINATION ${HYBBX_INSTALL_HOME})
+endif()
+
+if(HYBBX_INSTALL_DEV)
+ install(TARGETS hybbx_client
+ EXPORT HyBBXTargets
+ ARCHIVE DESTINATION ${HYBBX_INSTALL_HOME}/lib
+ )
+endif()
diff --git a/src/clients/circuit_client.c b/src/clients/circuit_client.c
new file mode 100644
index 0000000..778ca87
--- /dev/null
+++ b/src/clients/circuit_client.c
@@ -0,0 +1,294 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/circuit.h"
+#include "hybbx/link.h"
+#include "hybbx/socket.h"
+#include "hybbx/util.h"
+
+#include <arpa/inet.h>
+#include <errno.h>
+#include <fcntl.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <poll.h>
+#include <stdio.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_CIRCUIT_LINK_POLL_MS 50
+#define HYBBX_CIRCUIT_AUTH_TIMEOUT_MS 15000
+
+static int set_socket_options(int fd)
+{
+ int on = 1;
+ int flags;
+
+ if (setsockopt(fd, SOL_SOCKET, SO_REUSEADDR, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ if (setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on)) != 0) {
+ return -1;
+ }
+
+ hybbx_socket_nosigpipe(fd);
+
+ flags = fcntl(fd, F_GETFL, 0);
+ if (flags < 0 || fcntl(fd, F_SETFL, flags | O_NONBLOCK) != 0) {
+ return -1;
+ }
+
+ return 0;
+}
+
+hybbx_result_t hybbx_circuit_link_connect(const char *host, unsigned port,
+ int *out_fd)
+{
+ struct sockaddr_in6 addr6;
+ struct sockaddr_in addr4;
+ int fd;
+ int rc;
+
+ if (host == NULL || out_fd == NULL || port == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ if (strchr(host, ':') != NULL) {
+ memset(&addr6, 0, sizeof(addr6));
+ addr6.sin6_family = AF_INET6;
+ addr6.sin6_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET6, host, &addr6.sin6_addr) != 1) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fd = socket(AF_INET6, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr6, sizeof(addr6));
+ } else {
+ memset(&addr4, 0, sizeof(addr4));
+ addr4.sin_family = AF_INET;
+ addr4.sin_port = htons((uint16_t)port);
+ if (inet_pton(AF_INET, host, &addr4.sin_addr) != 1) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ rc = connect(fd, (struct sockaddr *)&addr4, sizeof(addr4));
+ }
+
+ if (rc != 0) {
+ close(fd);
+ return HYBBX_ERR_IO;
+ }
+
+ (void)set_socket_options(fd);
+ *out_fd = fd;
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_circuit_link_write(int fd, const uint8_t *frame,
+ size_t len)
+{
+ ssize_t sent;
+ size_t off = 0;
+
+ if (fd < 0 || frame == NULL || len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ while (off < len) {
+ sent = send(fd, frame + off, len - off, MSG_NOSIGNAL);
+ if (sent < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (sent == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)sent;
+ }
+
+ return HYBBX_OK;
+}
+
+hybbx_result_t hybbx_circuit_link_read(int fd, uint8_t *buf, size_t buf_len,
+ size_t *read_len)
+{
+ ssize_t n;
+
+ if (fd < 0 || buf == NULL || read_len == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ n = recv(fd, buf, buf_len, 0);
+ if (n < 0) {
+#if EAGAIN == EWOULDBLOCK
+ if (errno == EAGAIN) {
+#else
+ if (errno == EAGAIN || errno == EWOULDBLOCK) {
+#endif
+ *read_len = 0;
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ *read_len = 0;
+ return HYBBX_ERR_IO;
+ }
+
+ *read_len = (size_t)n;
+ return HYBBX_OK;
+}
+
+typedef struct link_ack_wait {
+ int done;
+ int ok;
+} link_ack_wait_t;
+
+static void on_link_auth_ack(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ link_ack_wait_t *wait = (link_ack_wait_t *)userdata;
+ size_t i;
+
+ (void)flags;
+
+ if (wait == NULL || wait->done || proto != HYBBX_CIRCUIT_PROTO_LINK_AUTH_ACK) {
+ return;
+ }
+
+ if (len == 0 || payload == NULL) {
+ return;
+ }
+
+ for (i = 0; i + 6 <= len; i++) {
+ if (memcmp(payload + i, "ok=yes", 6) == 0) {
+ wait->ok = 1;
+ break;
+ }
+ }
+ wait->done = 1;
+}
+
+hybbx_result_t hybbx_circuit_link_authenticate(int fd,
+ const char *password,
+ const char *role,
+ const char *id)
+{
+ return hybbx_circuit_link_authenticate_ex(fd, password, role, id, NULL);
+}
+
+hybbx_result_t hybbx_circuit_link_authenticate_ex(int fd,
+ const char *password,
+ const char *role,
+ const char *id,
+ const hybbx_circuit_link_qos_t *qos)
+{
+ hybbx_link_auth_t auth;
+ char payload[HYBBX_LINK_AUTH_PAYLOAD_MAX];
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t payload_len;
+ size_t frame_len;
+ hybbx_circuit_decoder_t dec;
+ link_ack_wait_t wait;
+ uint8_t buf[256];
+ unsigned elapsed = 0;
+
+ if (fd < 0 || password == NULL || id == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_link_auth_clear(&auth);
+ hybbx_strlcpy(auth.password, password, sizeof(auth.password));
+ hybbx_strlcpy(auth.id, id, sizeof(auth.id));
+ if (role != NULL && role[0] != '\0') {
+ hybbx_strlcpy(auth.role, role, sizeof(auth.role));
+ } else {
+ hybbx_strlcpy(auth.role, "link", sizeof(auth.role));
+ }
+
+ if (qos != NULL) {
+ if (qos->baud > 0) {
+ auth.baud = qos->baud;
+ }
+ if (qos->duplex > 0) {
+ auth.duplex = qos->duplex;
+ }
+ if (qos->bandwidth != NULL && qos->bandwidth[0] != '\0') {
+ hybbx_strlcpy(auth.bandwidth, qos->bandwidth, sizeof(auth.bandwidth));
+ }
+ if (qos->frequency_mhz != NULL && qos->frequency_mhz[0] != '\0') {
+ hybbx_strlcpy(auth.frequency_mhz, qos->frequency_mhz,
+ sizeof(auth.frequency_mhz));
+ }
+ }
+
+ payload_len = hybbx_link_auth_format(&auth, payload, sizeof(payload));
+ if (payload_len == 0) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode_link_msg(HYBBX_CIRCUIT_PROTO_LINK_AUTH,
+ payload, payload_len,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_circuit_link_write(fd, frame, frame_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+
+ hybbx_circuit_decoder_init(&dec);
+ memset(&wait, 0, sizeof(wait));
+
+ while (!wait.done && elapsed < HYBBX_CIRCUIT_AUTH_TIMEOUT_MS) {
+ struct pollfd pfd;
+ int pr;
+ size_t read_len;
+
+ pfd.fd = fd;
+ pfd.events = POLLIN;
+ pfd.revents = 0;
+ pr = poll(&pfd, 1, HYBBX_CIRCUIT_LINK_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (pr == 0) {
+ elapsed += HYBBX_CIRCUIT_LINK_POLL_MS;
+ continue;
+ }
+ if ((pfd.revents & POLLIN) == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ if (hybbx_circuit_link_read(fd, buf, sizeof(buf), &read_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (read_len == 0) {
+ elapsed += HYBBX_CIRCUIT_LINK_POLL_MS;
+ continue;
+ }
+
+ hybbx_circuit_decoder_feed(&dec, buf, read_len, on_link_auth_ack, &wait);
+ }
+
+ return wait.ok ? HYBBX_OK : HYBBX_ERR_DENIED;
+}
diff --git a/src/clients/client_display.c b/src/clients/client_display.c
new file mode 100644
index 0000000..0f6a183
--- /dev/null
+++ b/src/clients/client_display.c
@@ -0,0 +1,87 @@
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_display.h"
+
+#include "hybbx/terminal.h"
+#include "hybbx/traffic.h"
+
+#include <stdio.h>
+#include <unistd.h>
+
+void hybbx_client_display_init(hybbx_client_display_t *disp,
+ const hybbx_traffic_config_t *traffic)
+{
+ if (disp == NULL) {
+ return;
+ }
+
+ disp->traffic = traffic != NULL ? traffic : hybbx_traffic_config_get();
+ disp->col = 0;
+}
+
+static void pace_byte(unsigned baud)
+{
+ unsigned delay_us;
+
+ if (baud == 0) {
+ return;
+ }
+
+ delay_us = hybbx_traffic_byte_delay_us(baud);
+ if (delay_us > 0) {
+ usleep(delay_us);
+ }
+}
+
+void hybbx_client_display_byte(hybbx_client_display_t *disp, uint8_t byte)
+{
+ const hybbx_traffic_config_t *cfg;
+
+ if (disp == NULL) {
+ return;
+ }
+
+ cfg = disp->traffic;
+ if (cfg == NULL) {
+ fputc((int)byte, stdout);
+ fflush(stdout);
+ return;
+ }
+
+ if (!cfg->ansi && byte == 0x1b) {
+ return;
+ }
+
+ if (cfg->line_width > 0 && (byte == '\n' || byte == '\r')) {
+ disp->col = 0;
+ } else if (cfg->line_width > 0 && byte >= 0x20 && byte != 0x7f) {
+ disp->col++;
+ if (disp->col > cfg->line_width) {
+ fputc('\n', stdout);
+ disp->col = 1;
+ }
+ }
+
+ fputc((int)byte, stdout);
+ fflush(stdout);
+
+ if (cfg->pace_output) {
+ pace_byte(cfg->baud);
+ }
+}
+
+void hybbx_client_display_write(hybbx_client_display_t *disp,
+ const uint8_t *data, size_t len)
+{
+ size_t i;
+
+ if (disp == NULL || data == NULL) {
+ return;
+ }
+
+ for (i = 0; i < len; i++) {
+ hybbx_client_display_byte(disp, data[i]);
+ }
+}
diff --git a/src/clients/client_display.h b/src/clients/client_display.h
new file mode 100644
index 0000000..a457f4c
--- /dev/null
+++ b/src/clients/client_display.h
@@ -0,0 +1,30 @@
+#ifndef HYBBX_CLIENT_DISPLAY_H
+#define HYBBX_CLIENT_DISPLAY_H
+
+#include "hybbx/traffic.h"
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+typedef struct hybbx_client_display {
+ const hybbx_traffic_config_t *traffic;
+ unsigned col;
+} hybbx_client_display_t;
+
+void hybbx_client_display_init(hybbx_client_display_t *disp,
+ const hybbx_traffic_config_t *traffic);
+
+void hybbx_client_display_byte(hybbx_client_display_t *disp, uint8_t byte);
+
+void hybbx_client_display_write(hybbx_client_display_t *disp,
+ const uint8_t *data, size_t len);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif /* HYBBX_CLIENT_DISPLAY_H */
diff --git a/src/clients/client_line.c b/src/clients/client_line.c
new file mode 100644
index 0000000..ab8c07b
--- /dev/null
+++ b/src/clients/client_line.c
@@ -0,0 +1,495 @@
+#include "client_line.h"
+
+#include "hybbx/util.h"
+
+#include <errno.h>
+#include <string.h>
+#include <termios.h>
+#include <unistd.h>
+
+#define CLIENT_ESC_NONE 0u
+#define CLIENT_ESC_ESC 1u
+#define CLIENT_ESC_CSI 2u
+#define CLIENT_ESC_SS3 3u
+
+static int history_should_store(const char *line)
+{
+ if (line == NULL || line[0] == '\0') {
+ return 0;
+ }
+
+ return strncmp(line, "/login ", 7) != 0 &&
+ strncmp(line, "/register ", 10) != 0 &&
+ strncmp(line, "/changeme ", 10) != 0 &&
+ strncmp(line, "/userchange ", 12) != 0;
+}
+
+static hybbx_result_t write_all(int fd, const char *buf, size_t len)
+{
+ size_t off = 0;
+
+ while (off < len) {
+ ssize_t n = write(fd, buf + off, len - off);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t move_back(hybbx_client_line_editor_t *ed, size_t count)
+{
+ char buf[64];
+ size_t sent = 0;
+
+ if (ed == NULL || !ed->raw_enabled || count == 0) {
+ return HYBBX_OK;
+ }
+
+ memset(buf, '\b', sizeof(buf));
+ while (sent < count) {
+ size_t chunk = count - sent;
+
+ if (chunk > sizeof(buf)) {
+ chunk = sizeof(buf);
+ }
+ if (write_all(STDOUT_FILENO, buf, chunk) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ sent += chunk;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t refresh_suffix(hybbx_client_line_editor_t *ed)
+{
+ size_t suffix_len;
+
+ if (ed == NULL || !ed->raw_enabled) {
+ return HYBBX_OK;
+ }
+
+ suffix_len = ed->line_len - ed->line_cursor;
+ if (suffix_len > 0 &&
+ write_all(STDOUT_FILENO, ed->line + ed->line_cursor, suffix_len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (write_all(STDOUT_FILENO, "\x1b[K", 3) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return move_back(ed, suffix_len);
+}
+
+static hybbx_result_t cursor_left(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+ ed->line_cursor--;
+ return ed->raw_enabled ? write_all(STDOUT_FILENO, "\b", 1) : HYBBX_OK;
+}
+
+static hybbx_result_t cursor_right(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor >= ed->line_len) {
+ return HYBBX_OK;
+ }
+ if (ed->raw_enabled &&
+ write_all(STDOUT_FILENO, ed->line + ed->line_cursor, 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ ed->line_cursor++;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t cursor_home(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ return move_back(ed, ed->line_cursor);
+}
+
+static hybbx_result_t cursor_end(hybbx_client_line_editor_t *ed)
+{
+ while (ed != NULL && ed->line_cursor < ed->line_len) {
+ if (cursor_right(ed) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+ return HYBBX_OK;
+}
+
+static hybbx_result_t delete_forward(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor >= ed->line_len) {
+ return HYBBX_OK;
+ }
+
+ memmove(ed->line + ed->line_cursor, ed->line + ed->line_cursor + 1,
+ ed->line_len - ed->line_cursor);
+ ed->line_len--;
+ ed->line[ed->line_len] = '\0';
+ return refresh_suffix(ed);
+}
+
+static hybbx_result_t backspace_char(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || ed->line_cursor == 0) {
+ return HYBBX_OK;
+ }
+
+ ed->line_cursor--;
+ memmove(ed->line + ed->line_cursor, ed->line + ed->line_cursor + 1,
+ ed->line_len - ed->line_cursor);
+ ed->line_len--;
+ ed->line[ed->line_len] = '\0';
+ if (ed->raw_enabled && write_all(STDOUT_FILENO, "\b", 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return refresh_suffix(ed);
+}
+
+static hybbx_result_t insert_char(hybbx_client_line_editor_t *ed, char ch)
+{
+ if (ed == NULL || (unsigned char)ch < 0x20u || ch == 0x7f) {
+ return HYBBX_OK;
+ }
+ if (ed->line_len + 1 >= sizeof(ed->line)) {
+ return HYBBX_OK;
+ }
+
+ if (ed->line_cursor < ed->line_len) {
+ memmove(ed->line + ed->line_cursor + 1, ed->line + ed->line_cursor,
+ ed->line_len - ed->line_cursor);
+ }
+ ed->line[ed->line_cursor] = ch;
+ ed->line_len++;
+ ed->line_cursor++;
+ ed->line[ed->line_len] = '\0';
+
+ if (!ed->raw_enabled) {
+ return HYBBX_OK;
+ }
+ if (write_all(STDOUT_FILENO, &ch, 1) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ return refresh_suffix(ed);
+}
+
+static void history_add(hybbx_client_line_editor_t *ed, const char *line)
+{
+ if (ed == NULL || !history_should_store(line)) {
+ return;
+ }
+
+ hybbx_strlcpy(ed->history[ed->history_next], line, sizeof(ed->history[0]));
+ ed->history_next = (ed->history_next + 1u) % HYBBX_HISTORY_MAX;
+ if (ed->history_count < HYBBX_HISTORY_MAX) {
+ ed->history_count++;
+ }
+ ed->history_view = -1;
+ ed->history_saved_line[0] = '\0';
+}
+
+static hybbx_result_t line_replace(hybbx_client_line_editor_t *ed, const char *line)
+{
+ size_t len;
+
+ if (ed == NULL || line == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ len = strlen(line);
+ if (len >= sizeof(ed->line)) {
+ len = sizeof(ed->line) - 1;
+ }
+
+ if (ed->raw_enabled) {
+ if (move_back(ed, ed->line_cursor) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (len > 0 && write_all(STDOUT_FILENO, line, len) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ if (write_all(STDOUT_FILENO, "\x1b[K", 3) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ memcpy(ed->line, line, len);
+ ed->line[len] = '\0';
+ ed->line_len = len;
+ ed->line_cursor = len;
+ return HYBBX_OK;
+}
+
+static hybbx_result_t history_up(hybbx_client_line_editor_t *ed)
+{
+ unsigned slot;
+
+ if (ed == NULL || ed->history_count == 0) {
+ return HYBBX_OK;
+ }
+
+ if (ed->history_view < 0) {
+ hybbx_strlcpy(ed->history_saved_line, ed->line, sizeof(ed->history_saved_line));
+ ed->history_view = 0;
+ } else if ((unsigned)(ed->history_view + 1) < ed->history_count) {
+ ed->history_view++;
+ } else {
+ return HYBBX_OK;
+ }
+
+ slot = (ed->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)ed->history_view) % HYBBX_HISTORY_MAX;
+ return line_replace(ed, ed->history[slot]);
+}
+
+static hybbx_result_t history_down(hybbx_client_line_editor_t *ed)
+{
+ unsigned slot;
+
+ if (ed == NULL || ed->history_view < 0) {
+ return HYBBX_OK;
+ }
+ if (ed->history_view == 0) {
+ ed->history_view = -1;
+ return line_replace(ed, ed->history_saved_line);
+ }
+
+ ed->history_view--;
+ slot = (ed->history_next + HYBBX_HISTORY_MAX - 1u -
+ (unsigned)ed->history_view) % HYBBX_HISTORY_MAX;
+ return line_replace(ed, ed->history[slot]);
+}
+
+static void escape_reset(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL) {
+ return;
+ }
+ ed->esc_state = CLIENT_ESC_NONE;
+ ed->csi_len = 0;
+}
+
+static int csi_is_final(unsigned char ch)
+{
+ return ch >= 0x40u && ch <= 0x7eu;
+}
+
+static hybbx_result_t apply_csi(hybbx_client_line_editor_t *ed,
+ const char *params, char final_ch)
+{
+ (void)params;
+
+ if (final_ch == 'A') {
+ return history_up(ed);
+ }
+ if (final_ch == 'B') {
+ return history_down(ed);
+ }
+ if (final_ch == 'C') {
+ return cursor_right(ed);
+ }
+ if (final_ch == 'D') {
+ return cursor_left(ed);
+ }
+ if (final_ch == 'H') {
+ return cursor_home(ed);
+ }
+ if (final_ch == 'F') {
+ return cursor_end(ed);
+ }
+ if (final_ch == '~') {
+ if (strcmp(params, "1") == 0 || strcmp(params, "7") == 0) {
+ return cursor_home(ed);
+ }
+ if (strcmp(params, "3") == 0) {
+ return delete_forward(ed);
+ }
+ if (strcmp(params, "4") == 0 || strcmp(params, "8") == 0) {
+ return cursor_end(ed);
+ }
+ }
+ return HYBBX_OK;
+}
+
+static int filter_escape_byte(hybbx_client_line_editor_t *ed, unsigned char byte)
+{
+ if (ed == NULL) {
+ return 0;
+ }
+ if (ed->esc_state == CLIENT_ESC_NONE && byte == 0x9bu) {
+ ed->esc_state = CLIENT_ESC_CSI;
+ ed->csi_len = 0;
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_NONE && byte == 0x1bu) {
+ ed->esc_state = CLIENT_ESC_ESC;
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_ESC) {
+ if (byte == '[') {
+ ed->esc_state = CLIENT_ESC_CSI;
+ ed->csi_len = 0;
+ return 1;
+ }
+ if (byte == 'O') {
+ ed->esc_state = CLIENT_ESC_SS3;
+ return 1;
+ }
+ escape_reset(ed);
+ return 0;
+ }
+ if (ed->esc_state == CLIENT_ESC_SS3) {
+ if (byte == 'C') {
+ (void)cursor_right(ed);
+ } else if (byte == 'D') {
+ (void)cursor_left(ed);
+ } else if (byte == 'H') {
+ (void)cursor_home(ed);
+ } else if (byte == 'F') {
+ (void)cursor_end(ed);
+ }
+ escape_reset(ed);
+ return 1;
+ }
+ if (ed->esc_state == CLIENT_ESC_CSI) {
+ char params[24];
+
+ if (ed->csi_len + 1 >= sizeof(ed->csi_buf)) {
+ escape_reset(ed);
+ return 1;
+ }
+ ed->csi_buf[ed->csi_len++] = (char)byte;
+ if (!csi_is_final(byte)) {
+ return 1;
+ }
+
+ if (ed->csi_len > 1) {
+ memcpy(params, ed->csi_buf, ed->csi_len - 1);
+ params[ed->csi_len - 1] = '\0';
+ } else {
+ params[0] = '\0';
+ }
+ escape_reset(ed);
+ (void)apply_csi(ed, params, (char)byte);
+ return 1;
+ }
+ return 0;
+}
+
+int hybbx_client_line_editor_start(hybbx_client_line_editor_t *ed, int stdin_fd)
+{
+ struct termios raw;
+
+ if (ed == NULL) {
+ return -1;
+ }
+
+ memset(ed, 0, sizeof(*ed));
+ ed->stdin_fd = stdin_fd;
+ ed->history_view = -1;
+
+ if (!isatty(stdin_fd)) {
+ return 0;
+ }
+ if (tcgetattr(stdin_fd, &ed->termios_saved) != 0) {
+ return -1;
+ }
+
+ raw = ed->termios_saved;
+ raw.c_iflag &= ~(ICRNL | IXON);
+ raw.c_lflag &= ~(ECHO | ICANON | IEXTEN);
+ raw.c_cc[VMIN] = 1;
+ raw.c_cc[VTIME] = 0;
+
+ if (tcsetattr(stdin_fd, TCSAFLUSH, &raw) != 0) {
+ return -1;
+ }
+
+ ed->raw_enabled = 1;
+ return 0;
+}
+
+void hybbx_client_line_editor_stop(hybbx_client_line_editor_t *ed)
+{
+ if (ed == NULL || !ed->raw_enabled) {
+ return;
+ }
+ (void)tcsetattr(ed->stdin_fd, TCSAFLUSH, &ed->termios_saved);
+ ed->raw_enabled = 0;
+}
+
+hybbx_result_t hybbx_client_line_editor_poll(hybbx_client_line_editor_t *ed,
+ char *out_line, size_t out_len,
+ int *have_line, int *eof_seen)
+{
+ unsigned char buf[64];
+ ssize_t n;
+ size_t i;
+
+ if (ed == NULL || out_line == NULL || out_len == 0 ||
+ have_line == NULL || eof_seen == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ *have_line = 0;
+ *eof_seen = 0;
+
+ n = read(ed->stdin_fd, buf, sizeof(buf));
+ if (n < 0) {
+ if (errno == EINTR) {
+ return HYBBX_OK;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ *eof_seen = 1;
+ return HYBBX_OK;
+ }
+
+ for (i = 0; i < (size_t)n; i++) {
+ unsigned char ch = buf[i];
+
+ if (filter_escape_byte(ed, ch)) {
+ continue;
+ }
+ if (ch == '\b' || ch == 127) {
+ if (backspace_char(ed) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ continue;
+ }
+ if (ch == '\r' || ch == '\n') {
+ if (ed->raw_enabled &&
+ write_all(STDOUT_FILENO, "\r\n", 2) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ hybbx_strlcpy(out_line, ed->line, out_len);
+ history_add(ed, ed->line);
+ ed->line[0] = '\0';
+ ed->line_len = 0;
+ ed->line_cursor = 0;
+ ed->history_view = -1;
+ *have_line = 1;
+ return HYBBX_OK;
+ }
+ if (insert_char(ed, (char)ch) != HYBBX_OK) {
+ return HYBBX_ERR_IO;
+ }
+ }
+
+ return HYBBX_OK;
+}
diff --git a/src/clients/client_line.h b/src/clients/client_line.h
new file mode 100644
index 0000000..928efe0
--- /dev/null
+++ b/src/clients/client_line.h
@@ -0,0 +1,33 @@
+#ifndef HYBBX_CLIENT_LINE_H
+#define HYBBX_CLIENT_LINE_H
+
+#include "hybbx/limits.h"
+#include "hybbx/types.h"
+
+#include <stddef.h>
+#include <termios.h>
+
+typedef struct hybbx_client_line_editor {
+ char line[HYBBX_LINE_MAX];
+ size_t line_len;
+ size_t line_cursor;
+ char history[HYBBX_HISTORY_MAX][HYBBX_LINE_MAX];
+ unsigned history_count;
+ unsigned history_next;
+ int history_view;
+ char history_saved_line[HYBBX_LINE_MAX];
+ int raw_enabled;
+ int stdin_fd;
+ unsigned char esc_state;
+ char csi_buf[24];
+ size_t csi_len;
+ struct termios termios_saved;
+} hybbx_client_line_editor_t;
+
+int hybbx_client_line_editor_start(hybbx_client_line_editor_t *ed, int stdin_fd);
+void hybbx_client_line_editor_stop(hybbx_client_line_editor_t *ed);
+hybbx_result_t hybbx_client_line_editor_poll(hybbx_client_line_editor_t *ed,
+ char *out_line, size_t out_len,
+ int *have_line, int *eof_seen);
+
+#endif /* HYBBX_CLIENT_LINE_H */
diff --git a/src/clients/hybbx_ssh.c b/src/clients/hybbx_ssh.c
new file mode 100644
index 0000000..c2d6d88
--- /dev/null
+++ b/src/clients/hybbx_ssh.c
@@ -0,0 +1,495 @@
+/**
+ * hybbx-ssh — HyBBX-native SSH client (CLI only, no GUI).
+ *
+ * Encrypted transport to HyBBX [transport.ssh] (default port 3232).
+ * Wire SSH username/password satisfy the libssh handshake only; HyBBX
+ * accounts use guest auto-login or /login like telnet.
+ * Host keys are not verified — no known_hosts prompt.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/limits.h"
+#include "hybbx/ssh.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+
+#include <libssh/libssh.h>
+
+#include <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_SSH_POLL_MS 50
+#define HYBBX_SSH_WIRE_USER "hybbx"
+#define HYBBX_SSH_WIRE_PASSWORD "hybbx"
+
+typedef struct hybbx_ssh_client_config {
+ char host[256];
+ unsigned port;
+ char wire_user[64];
+ char wire_password[128];
+ char login_user[64];
+ char login_password[128];
+ unsigned baud;
+ unsigned line_width;
+ int pace_output;
+ int ansi;
+ int verbose;
+ unsigned timeout_sec;
+ int ipv6;
+} hybbx_ssh_client_config_t;
+
+typedef struct ssh_session_ctx {
+ ssh_session session;
+ ssh_channel channel;
+ hybbx_client_display_t display;
+ hybbx_client_line_editor_t editor;
+ int editor_ready;
+} ssh_session_ctx_t;
+
+static void config_defaults(hybbx_ssh_client_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_SSH_DEFAULT_PORT;
+ hybbx_strlcpy(cfg->wire_user, HYBBX_SSH_WIRE_USER, sizeof(cfg->wire_user));
+ hybbx_strlcpy(cfg->wire_password, HYBBX_SSH_WIRE_PASSWORD,
+ sizeof(cfg->wire_password));
+ cfg->baud = HYBBX_BAUD2400;
+ cfg->line_width = HYBBX_LINE_WIDTH;
+ cfg->pace_output = 1;
+ cfg->timeout_sec = 30;
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-ssh — HyBBX SSH client (encrypted transport, CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Remote host (default 127.0.0.1)\n"
+ " -p, --port PORT SSH port (default 3232)\n"
+ " -u, --user USER Send /login USER after connect\n"
+ " -P, --password PASS Password for /login\n"
+ " --wire-user USER SSH wire username (default hybbx)\n"
+ " --wire-pass PASS SSH wire password (default hybbx)\n"
+ " --baud N Pace output like HyBBX (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80, 0=off)\n"
+ " --pace yes|no Output pacing (default yes)\n"
+ " --ansi yes|no Pass ANSI sequences (default no)\n"
+ " -6, --ipv6 Prefer IPv6 when connecting\n"
+ " -t, --timeout SEC Connect timeout (default 30)\n"
+ " -v, --verbose Log SSH progress on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "SSH wire credentials are not HyBBX accounts — any value is accepted\n"
+ "by the server. Use -u/-P for HyBBX /login after connect.\n"
+ "\n"
+ "Environment: HYBBX_HOST, HYBBX_PORT, HYBBX_SSH_WIRE_USER,\n"
+ " HYBBX_SSH_WIRE_PASS\n"
+ "\n",
+ prog);
+}
+
+static void log_ssh_error(ssh_session session, const char *what)
+{
+ const char *msg = ssh_get_error(session);
+
+ if (msg == NULL || msg[0] == '\0') {
+ msg = "unknown error";
+ }
+ fprintf(stderr, "hybbx-ssh: %s: %s\n", what, msg);
+}
+
+static int ssh_connect_session(const hybbx_ssh_client_config_t *cfg,
+ ssh_session *out_session,
+ ssh_channel *out_channel)
+{
+ ssh_session session;
+ ssh_channel channel;
+ char portbuf[16];
+ long timeout_sec;
+ int strict = 0;
+ int process_config = 0;
+ int pubkey_auth = 0;
+ int nodelay = 1;
+ int rc;
+
+ session = ssh_new();
+ if (session == NULL) {
+ fprintf(stderr, "hybbx-ssh: ssh_new failed\n");
+ return -1;
+ }
+
+ snprintf(portbuf, sizeof(portbuf), "%u", cfg->port);
+ timeout_sec = (long)cfg->timeout_sec;
+
+ (void)ssh_options_set(session, SSH_OPTIONS_HOST, cfg->host);
+ (void)ssh_options_set(session, SSH_OPTIONS_PORT_STR, portbuf);
+ (void)ssh_options_set(session, SSH_OPTIONS_USER, cfg->wire_user);
+ (void)ssh_options_set(session, SSH_OPTIONS_STRICTHOSTKEYCHECK, &strict);
+ (void)ssh_options_set(session, SSH_OPTIONS_PROCESS_CONFIG, &process_config);
+ (void)ssh_options_set(session, SSH_OPTIONS_PUBKEY_AUTH, &pubkey_auth);
+ (void)ssh_options_set(session, SSH_OPTIONS_TIMEOUT, &timeout_sec);
+ (void)ssh_options_set(session, SSH_OPTIONS_NODELAY, &nodelay);
+
+ if (cfg->ipv6) {
+ (void)ssh_options_set(session, SSH_OPTIONS_BINDADDR, "::");
+ }
+
+ rc = ssh_connect(session);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "connect");
+ ssh_free(session);
+ return -1;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-ssh: SSH handshake complete with %s:%u\n",
+ cfg->host, cfg->port);
+ }
+
+ rc = ssh_userauth_password(session, NULL, cfg->wire_password);
+ if (rc != SSH_AUTH_SUCCESS) {
+ log_ssh_error(session, "wire authentication");
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ channel = ssh_channel_new(session);
+ if (channel == NULL) {
+ log_ssh_error(session, "channel_new");
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_open_session(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_open_session");
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_request_pty(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_request_pty");
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ rc = ssh_channel_request_shell(channel);
+ if (rc != SSH_OK) {
+ log_ssh_error(session, "channel_request_shell");
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+ return -1;
+ }
+
+ ssh_set_blocking(session, 0);
+
+ *out_session = session;
+ *out_channel = channel;
+ return 0;
+}
+
+static hybbx_result_t send_line(ssh_channel channel, const char *line)
+{
+ char buf[HYBBX_LINE_MAX + 2];
+ size_t len;
+ int rc;
+
+ snprintf(buf, sizeof(buf), "%s\n", line);
+ len = strlen(buf);
+
+ rc = ssh_channel_write(channel, buf, (uint32_t)len);
+ if (rc < 0 || (size_t)rc != len) {
+ return HYBBX_ERR_IO;
+ }
+
+ return HYBBX_OK;
+}
+
+static void maybe_auto_login(ssh_channel channel,
+ const hybbx_ssh_client_config_t *cfg)
+{
+ char line[192];
+
+ if (cfg->login_user[0] == '\0') {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "/login %s", cfg->login_user);
+ (void)send_line(channel, line);
+ if (cfg->login_password[0] != '\0') {
+ (void)send_line(channel, cfg->login_password);
+ }
+}
+
+static int drain_channel(ssh_session_ctx_t *ctx, int verbose)
+{
+ uint8_t buf[512];
+ int n;
+
+ for (;;) {
+ n = ssh_channel_read_nonblocking(ctx->channel, buf, sizeof(buf), 0);
+ if (n == SSH_AGAIN) {
+ return 0;
+ }
+ if (n <= 0) {
+ if (ssh_channel_is_eof(ctx->channel) || ssh_channel_is_closed(ctx->channel)) {
+ return -1;
+ }
+ return 0;
+ }
+
+ if (verbose) {
+ fprintf(stderr, "hybbx-ssh: recv %d bytes\n", n);
+ }
+
+ hybbx_client_display_write(&ctx->display, buf, (size_t)n);
+ }
+}
+
+static int run_session(ssh_session_ctx_t *ctx, const hybbx_ssh_client_config_t *cfg)
+{
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+
+ maybe_auto_login(ctx->channel, cfg);
+
+ while (running) {
+ struct pollfd pfds[2];
+ socket_t ssh_fd;
+ int pr;
+ int nready = 1;
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+
+ ssh_fd = ssh_get_fd(ctx->session);
+ pfds[1].fd = (int)ssh_fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+ nready = 2;
+
+ pr = poll(pfds, (unsigned int)nready, HYBBX_SSH_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (drain_channel(ctx, cfg->verbose) != 0) {
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!ctx->editor_ready ||
+ hybbx_client_line_editor_poll(&ctx->editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(ctx->channel, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0 && drain_channel(ctx, cfg->verbose) != 0) {
+ break;
+ }
+ }
+
+ return 0;
+}
+
+static void session_ctx_init(ssh_session_ctx_t *ctx,
+ ssh_session session,
+ ssh_channel channel,
+ const hybbx_ssh_client_config_t *cfg)
+{
+ hybbx_traffic_config_t traffic;
+
+ memset(ctx, 0, sizeof(*ctx));
+ ctx->session = session;
+ ctx->channel = channel;
+
+ hybbx_traffic_config_defaults(&traffic);
+ traffic.baud = cfg->baud;
+ traffic.line_width = cfg->line_width;
+ traffic.pace_output = cfg->pace_output;
+ traffic.ansi = cfg->ansi;
+ hybbx_client_display_init(&ctx->display, &traffic);
+
+ if (hybbx_client_line_editor_start(&ctx->editor, STDIN_FILENO) == 0) {
+ ctx->editor_ready = 1;
+ }
+}
+
+static void session_ctx_fini(ssh_session_ctx_t *ctx)
+{
+ if (ctx->editor_ready) {
+ hybbx_client_line_editor_stop(&ctx->editor);
+ ctx->editor_ready = 0;
+ }
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_ssh_client_config_t cfg;
+ ssh_session session = NULL;
+ ssh_channel channel = NULL;
+ ssh_session_ctx_t ctx;
+ int opt;
+ int argi = 1;
+ const char *env;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"user", required_argument, NULL, 'u'},
+ {"password", required_argument, NULL, 'P'},
+ {"wire-user", required_argument, NULL, 1004},
+ {"wire-pass", required_argument, NULL, 1005},
+ {"baud", required_argument, NULL, 1000},
+ {"line-width", required_argument, NULL, 1001},
+ {"pace", required_argument, NULL, 1002},
+ {"ansi", required_argument, NULL, 1003},
+ {"ipv6", no_argument, NULL, '6'},
+ {"timeout", required_argument, NULL, 't'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+
+ env = getenv("HYBBX_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+ env = getenv("HYBBX_SSH_WIRE_USER");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.wire_user, env, sizeof(cfg.wire_user));
+ }
+ env = getenv("HYBBX_SSH_WIRE_PASS");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.wire_password, env, sizeof(cfg.wire_password));
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:u:P:6t:vh", long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'u':
+ hybbx_strlcpy(cfg.login_user, optarg, sizeof(cfg.login_user));
+ break;
+ case 'P':
+ hybbx_strlcpy(cfg.login_password, optarg, sizeof(cfg.login_password));
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 't':
+ cfg.timeout_sec = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ cfg.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1001:
+ cfg.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1002:
+ cfg.pace_output = hybbx_parse_bool(optarg, cfg.pace_output);
+ break;
+ case 1003:
+ cfg.ansi = hybbx_parse_bool(optarg, cfg.ansi);
+ break;
+ case 1004:
+ hybbx_strlcpy(cfg.wire_user, optarg, sizeof(cfg.wire_user));
+ break;
+ case 1005:
+ hybbx_strlcpy(cfg.wire_password, optarg, sizeof(cfg.wire_password));
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ if (ssh_connect_session(&cfg, &session, &channel) != 0) {
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr,
+ "hybbx-ssh: shell ready on %s:%u (baud=%u width=%u wire=%s)\n",
+ cfg.host, cfg.port, cfg.baud, cfg.line_width, cfg.wire_user);
+ }
+
+ session_ctx_init(&ctx, session, channel, &cfg);
+ run_session(&ctx, &cfg);
+ session_ctx_fini(&ctx);
+
+ ssh_channel_send_eof(channel);
+ ssh_channel_close(channel);
+ ssh_channel_free(channel);
+ ssh_disconnect(session);
+ ssh_free(session);
+
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/hybbx_telnet.c b/src/clients/hybbx_telnet.c
new file mode 100644
index 0000000..c19e747
--- /dev/null
+++ b/src/clients/hybbx_telnet.c
@@ -0,0 +1,544 @@
+/**
+ * hybbx-telnet — HyBBX-native telnet client (CLI only, no GUI).
+ *
+ * Full telnet negotiation support for testing HyBBX servers.
+ * Optional 2400 baud pacing and 80-column display profile.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/limits.h"
+#include "hybbx/socket.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+#include "telnet_proto.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <netinet/in.h>
+#include <netinet/tcp.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#if defined(__AMIGA__)
+#include <arpa/inet.h>
+#include <netdb.h>
+#include <sys/select.h>
+#include <sys/socket.h>
+#else
+#include <netdb.h>
+#include <poll.h>
+#endif
+
+#define HYBBX_TELNET_DEFAULT_PORT 2323u
+#define HYBBX_TELNET_POLL_MS 50
+
+typedef struct hybbx_telnet_client_config {
+ char host[256];
+ unsigned port;
+ char login_user[64];
+ char login_password[128];
+ unsigned baud;
+ unsigned line_width;
+ int pace_output;
+ int ansi;
+ int verbose;
+ unsigned timeout_sec;
+ int ipv6;
+} hybbx_telnet_client_config_t;
+
+static void config_defaults(hybbx_telnet_client_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_TELNET_DEFAULT_PORT;
+ cfg->baud = HYBBX_BAUD2400;
+ cfg->line_width = HYBBX_LINE_WIDTH;
+ cfg->pace_output = 1;
+ cfg->timeout_sec = 30;
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-telnet — HyBBX telnet client for testing (CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Remote host (default 127.0.0.1)\n"
+ " -p, --port PORT Telnet port (default 2323)\n"
+ " -u, --user USER Send /login USER after connect\n"
+ " -P, --password PASS Password for /login\n"
+ " --baud N Pace output like HyBBX (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80, 0=off)\n"
+ " --pace yes|no Output pacing (default yes)\n"
+ " --ansi yes|no Pass ANSI sequences (default no)\n"
+ " -6, --ipv6 Prefer IPv6 when resolving host\n"
+ " -t, --timeout SEC Connect timeout (default 30)\n"
+ " -v, --verbose Log telnet negotiation on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "Environment: HYBBX_HOST, HYBBX_PORT\n"
+ "\n",
+ prog);
+}
+
+static int tcp_connect(const hybbx_telnet_client_config_t *cfg, int *out_fd)
+{
+#if defined(__AMIGA__)
+ struct sockaddr_in addr;
+ struct hostent *he;
+ int fd;
+ int on = 1;
+
+ if (cfg->ipv6) {
+ fprintf(stderr, "hybbx-telnet: IPv6 is not supported on AmigaOS\n");
+ return -1;
+ }
+
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ fprintf(stderr, "hybbx-telnet: socket failed for %s:%u\n",
+ cfg->host, cfg->port);
+ return -1;
+ }
+
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)cfg->port);
+
+ if (inet_aton(cfg->host, &addr.sin_addr) == 0) {
+ he = gethostbyname(cfg->host);
+ if (he == NULL || he->h_addrtype != AF_INET || he->h_length == 0) {
+ fprintf(stderr, "hybbx-telnet: unknown host %s\n", cfg->host);
+ close(fd);
+ return -1;
+ }
+ memcpy(&addr.sin_addr, he->h_addr, (size_t)he->h_length);
+ }
+
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
+ hybbx_socket_nosigpipe(fd);
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) != 0) {
+ fprintf(stderr, "hybbx-telnet: could not connect to %s:%u\n",
+ cfg->host, cfg->port);
+ close(fd);
+ return -1;
+ }
+
+ *out_fd = fd;
+ return 0;
+#else
+ char portbuf[16];
+ struct addrinfo hints;
+ struct addrinfo *res = NULL;
+ struct addrinfo *ai;
+ int fd = -1;
+ int rc;
+
+ snprintf(portbuf, sizeof(portbuf), "%u", cfg->port);
+ memset(&hints, 0, sizeof(hints));
+ hints.ai_socktype = SOCK_STREAM;
+ hints.ai_family = cfg->ipv6 ? AF_INET6 : AF_UNSPEC;
+
+ rc = getaddrinfo(cfg->host, portbuf, &hints, &res);
+ if (rc != 0) {
+ fprintf(stderr, "hybbx-telnet: connect %s:%u failed: %s\n",
+ cfg->host, cfg->port, gai_strerror(rc));
+ return -1;
+ }
+
+ for (ai = res; ai != NULL; ai = ai->ai_next) {
+ int on = 1;
+
+ fd = socket(ai->ai_family, ai->ai_socktype, ai->ai_protocol);
+ if (fd < 0) {
+ continue;
+ }
+
+ (void)setsockopt(fd, IPPROTO_TCP, TCP_NODELAY, &on, sizeof(on));
+ hybbx_socket_nosigpipe(fd);
+ if (connect(fd, ai->ai_addr, ai->ai_addrlen) == 0) {
+ break;
+ }
+ close(fd);
+ fd = -1;
+ }
+
+ freeaddrinfo(res);
+
+ if (fd < 0) {
+ fprintf(stderr, "hybbx-telnet: could not connect to %s:%u\n",
+ cfg->host, cfg->port);
+ return -1;
+ }
+
+ *out_fd = fd;
+ return 0;
+#endif
+}
+
+typedef struct display_ctx {
+ hybbx_client_display_t display;
+} display_ctx_t;
+
+static void on_telnet_data(void *userdata, const uint8_t *data, size_t len)
+{
+ display_ctx_t *ctx = (display_ctx_t *)userdata;
+
+ hybbx_client_display_write(&ctx->display, data, len);
+}
+
+static hybbx_result_t send_raw(int fd, const uint8_t *data, size_t len)
+{
+ size_t off = 0;
+
+ while (off < len) {
+ ssize_t n = send(fd, data + off, len - off, MSG_NOSIGNAL);
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ return HYBBX_ERR_IO;
+ }
+ if (n == 0) {
+ return HYBBX_ERR_IO;
+ }
+ off += (size_t)n;
+ }
+
+ return HYBBX_OK;
+}
+
+static hybbx_result_t send_line(int fd, const char *line)
+{
+ size_t len = strlen(line);
+ uint8_t stack[256];
+ uint8_t *buf = stack;
+ size_t cap = sizeof(stack);
+ size_t need = len;
+ size_t i;
+ size_t pos = 0;
+ hybbx_result_t rc;
+
+ for (i = 0; i < len; i++) {
+ if (line[i] == (char)255) {
+ need++;
+ }
+ }
+ need += 1;
+
+ if (need > cap) {
+ buf = malloc(need);
+ if (buf == NULL) {
+ return HYBBX_ERR_NOMEM;
+ }
+ cap = need;
+ }
+
+ for (i = 0; i < len; i++) {
+ buf[pos++] = (uint8_t)line[i];
+ if (line[i] == (char)255) {
+ buf[pos++] = 255;
+ }
+ }
+ buf[pos++] = (uint8_t)'\n';
+
+ rc = send_raw(fd, buf, pos);
+ if (buf != stack) {
+ free(buf);
+ }
+ return rc;
+}
+
+static void maybe_auto_login(int fd, const hybbx_telnet_client_config_t *cfg)
+{
+ char line[192];
+
+ if (cfg->login_user[0] == '\0') {
+ return;
+ }
+
+ snprintf(line, sizeof(line), "/login %s", cfg->login_user);
+ (void)send_line(fd, line);
+ if (cfg->login_password[0] != '\0') {
+ (void)send_line(fd, cfg->login_password);
+ }
+}
+
+static int run_session(int fd, const hybbx_telnet_client_config_t *cfg)
+{
+ hybbx_telnet_parser_t parser;
+ display_ctx_t display;
+ hybbx_client_line_editor_t editor;
+ hybbx_traffic_config_t traffic;
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+ int editor_ready = 0;
+
+ hybbx_traffic_config_defaults(&traffic);
+ traffic.baud = cfg->baud;
+ traffic.line_width = cfg->line_width;
+ traffic.pace_output = cfg->pace_output;
+ traffic.ansi = cfg->ansi;
+ hybbx_client_display_init(&display.display, &traffic);
+ hybbx_telnet_parser_init(&parser);
+ if (hybbx_client_line_editor_start(&editor, STDIN_FILENO) == 0) {
+ editor_ready = 1;
+ }
+
+ (void)hybbx_telnet_send_greeting(fd);
+ maybe_auto_login(fd, cfg);
+
+ while (running) {
+#if defined(__AMIGA__)
+ fd_set rfds;
+ struct timeval tv;
+ int maxfd;
+ int pr;
+
+ FD_ZERO(&rfds);
+ FD_SET(STDIN_FILENO, &rfds);
+ FD_SET(fd, &rfds);
+ maxfd = fd;
+ if (STDIN_FILENO > maxfd) {
+ maxfd = STDIN_FILENO;
+ }
+ tv.tv_sec = HYBBX_TELNET_POLL_MS / 1000;
+#if defined(__AMIGA__)
+ tv.tv_usec = (unsigned long)((HYBBX_TELNET_POLL_MS % 1000) * 1000);
+#else
+ tv.tv_usec = (HYBBX_TELNET_POLL_MS % 1000) * 1000;
+#endif
+
+ pr = select(maxfd + 1, &rfds, NULL, NULL, &tv);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if (FD_ISSET(STDIN_FILENO, &rfds)) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(fd, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if (FD_ISSET(fd, &rfds)) {
+ uint8_t buf[512];
+ ssize_t n = recv(fd, buf, sizeof(buf), 0);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-telnet: recv %zd bytes\n", n);
+ }
+
+ if (hybbx_telnet_parser_feed(&parser, fd, buf, (size_t)n,
+ on_telnet_data, &display) != HYBBX_OK) {
+ break;
+ }
+ }
+#else
+ struct pollfd pfds[2];
+ int pr;
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ pfds[1].fd = fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+
+ pr = poll(pfds, 2, HYBBX_TELNET_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ if (send_line(fd, inbuf) != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0) {
+ uint8_t buf[512];
+ ssize_t n = recv(fd, buf, sizeof(buf), 0);
+
+ if (n < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+ if (n == 0) {
+ break;
+ }
+
+ if (cfg->verbose) {
+ fprintf(stderr, "hybbx-telnet: recv %zd bytes\n", n);
+ }
+
+ if (hybbx_telnet_parser_feed(&parser, fd, buf, (size_t)n,
+ on_telnet_data, &display) != HYBBX_OK) {
+ break;
+ }
+ }
+#endif
+ }
+
+ if (editor_ready) {
+ hybbx_client_line_editor_stop(&editor);
+ }
+
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_telnet_client_config_t cfg;
+ int fd = -1;
+ int opt;
+ int argi = 1;
+ const char *env;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"user", required_argument, NULL, 'u'},
+ {"password", required_argument, NULL, 'P'},
+ {"baud", required_argument, NULL, 1000},
+ {"line-width", required_argument, NULL, 1001},
+ {"pace", required_argument, NULL, 1002},
+ {"ansi", required_argument, NULL, 1003},
+ {"ipv6", no_argument, NULL, '6'},
+ {"timeout", required_argument, NULL, 't'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+
+ env = getenv("HYBBX_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:u:P:6t:vh",
+ long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'u':
+ hybbx_strlcpy(cfg.login_user, optarg, sizeof(cfg.login_user));
+ break;
+ case 'P':
+ hybbx_strlcpy(cfg.login_password, optarg, sizeof(cfg.login_password));
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 't':
+ cfg.timeout_sec = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ cfg.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1001:
+ cfg.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1002:
+ cfg.pace_output = hybbx_parse_bool(optarg, cfg.pace_output);
+ break;
+ case 1003:
+ cfg.ansi = hybbx_parse_bool(optarg, cfg.ansi);
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ (void)cfg.timeout_sec;
+
+ if (tcp_connect(&cfg, &fd) != 0) {
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr, "hybbx-telnet: connected to %s:%u (baud=%u width=%u)\n",
+ cfg.host, cfg.port, cfg.baud, cfg.line_width);
+ }
+
+ run_session(fd, &cfg);
+ close(fd);
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/hybbx_terminal.c b/src/clients/hybbx_terminal.c
new file mode 100644
index 0000000..b6e09e1
--- /dev/null
+++ b/src/clients/hybbx_terminal.c
@@ -0,0 +1,433 @@
+/**
+ * hybbx-terminal — HyBBX AX.25 / HBX circuit terminal client (CLI only).
+ *
+ * Connects to the internal HBX circuit hub, authenticates with link password,
+ * and exchanges TERMINAL and AX.25 UI frames for operator sessions.
+ */
+
+#if defined(__linux__) || defined(__GLIBC__)
+#define _DEFAULT_SOURCE 1
+#endif
+
+#include "client_line.h"
+#include "client_display.h"
+#include "hybbx/ax25.h"
+#include "hybbx/circuit.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/limits.h"
+#include "hybbx/link.h"
+#include "hybbx/traffic.h"
+#include "hybbx/util.h"
+
+#include <errno.h>
+#include <getopt.h>
+#include <poll.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+
+#define HYBBX_TERMINAL_POLL_MS 50
+
+typedef struct hybbx_terminal_config {
+ char host[256];
+ unsigned port;
+ char link_id[HYBBX_LINK_ID_MAX];
+ char link_password[128];
+ char link_role[HYBBX_LINK_ROLE_MAX];
+ char mycall[32];
+ char dest[32];
+ char via[128];
+ int ax25_ui;
+ int verbose;
+ int ipv6;
+} hybbx_terminal_config_t;
+
+typedef struct terminal_session {
+ int fd;
+ const hybbx_terminal_config_t *cfg;
+ hybbx_circuit_decoder_t dec;
+ hybbx_client_display_t display;
+ hybbx_ax25_path_t path;
+ int path_ready;
+} terminal_session_t;
+
+static void config_defaults(hybbx_terminal_config_t *cfg)
+{
+ memset(cfg, 0, sizeof(*cfg));
+ hybbx_strlcpy(cfg->host, "127.0.0.1", sizeof(cfg->host));
+ cfg->port = HYBBX_CIRCUIT_DEFAULT_PORT;
+ hybbx_strlcpy(cfg->link_id, "terminal-1", sizeof(cfg->link_id));
+ hybbx_strlcpy(cfg->link_role, "gateway", sizeof(cfg->link_role));
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "hybbx-terminal — HyBBX AX.25 / HBX circuit terminal client (CLI only)\n"
+ "\n"
+ "Usage: %s [options] [host [port]]\n"
+ "\n"
+ "Options:\n"
+ " -H, --host HOST Circuit hub host (default 127.0.0.1)\n"
+ " -p, --port PORT Circuit hub port (default 7323)\n"
+ " --link-id ID Link identity for password auth\n"
+ " --link-password P Link password (HBX LINK_AUTH)\n"
+ " --link-role ROLE link/repeater edge role: gateway | repeater | link | digipeater\n"
+ " --mycall CALL AX.25 source (CALL or CALL-SSID)\n"
+ " --dest CALL AX.25 destination\n"
+ " --via LIST Comma-separated digipeater path\n"
+ " --ax25-ui Send lines as AX.25 UI frames (needs mycall/dest)\n"
+ " --baud N Pace output (default 2400, 0=off)\n"
+ " --line-width N Wrap display columns (default 80)\n"
+ " --pace yes|no Output pacing\n"
+ " --ansi yes|no Pass ANSI sequences\n"
+ " -6, --ipv6 Prefer IPv6 (reserved)\n"
+ " -v, --verbose Log HBX frames on stderr\n"
+ " -h, --help Show help\n"
+ "\n"
+ "Environment: HYBBX_CIRCUIT_HOST, HYBBX_CIRCUIT_PORT\n"
+ "\n",
+ prog);
+}
+
+static int build_ax25_path(const hybbx_terminal_config_t *cfg,
+ hybbx_ax25_path_t *path)
+{
+ const char *cursor;
+ char token[32];
+
+ memset(path, 0, sizeof(*path));
+
+ if (cfg->dest[0] == '\0' || cfg->mycall[0] == '\0') {
+ return 0;
+ }
+
+ if (hybbx_ax25_address_parse(cfg->dest, &path->dest) != HYBBX_OK) {
+ return 0;
+ }
+ if (hybbx_ax25_address_parse(cfg->mycall, &path->source) != HYBBX_OK) {
+ return 0;
+ }
+
+ cursor = cfg->via;
+ while (cursor != NULL && *cursor != '\0' && path->digi_count < HYBBX_AX25_MAX_DIGI) {
+ const char *comma = strchr(cursor, ',');
+ size_t len;
+
+ if (comma != NULL) {
+ len = (size_t)(comma - cursor);
+ } else {
+ len = strlen(cursor);
+ }
+ if (len >= sizeof(token)) {
+ len = sizeof(token) - 1;
+ }
+ memcpy(token, cursor, len);
+ token[len] = '\0';
+
+ if (hybbx_ax25_address_parse(token, &path->digi[path->digi_count]) == HYBBX_OK) {
+ path->digi_count++;
+ }
+
+ cursor = comma != NULL ? comma + 1 : NULL;
+ }
+
+ return 1;
+}
+
+static void on_circuit_frame(hybbx_circuit_proto_t proto, uint16_t flags,
+ const uint8_t *payload, size_t len,
+ void *userdata)
+{
+ terminal_session_t *sess = (terminal_session_t *)userdata;
+ uint8_t ui[HYBBX_AX25_PAYLOAD_MAX];
+ hybbx_ax25_path_t path;
+ size_t ui_len;
+
+ (void)flags;
+
+ if (sess == NULL || len == 0) {
+ return;
+ }
+
+ if (sess->cfg->verbose) {
+ fprintf(stderr, "hybbx-terminal: rx proto=%s (%u bytes)\n",
+ hybbx_circuit_proto_name(proto), (unsigned)len);
+ }
+
+ switch (proto) {
+ case HYBBX_CIRCUIT_PROTO_TERMINAL:
+ hybbx_client_display_write(&sess->display, payload, len);
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25_UI:
+ ui_len = hybbx_circuit_unpack_ax25_ui(payload, len, &path, ui, sizeof(ui));
+ if (ui_len > 0) {
+ hybbx_client_display_write(&sess->display, ui, ui_len);
+ }
+ break;
+ case HYBBX_CIRCUIT_PROTO_AX25:
+ ui_len = hybbx_ax25_parse_ui(payload, len, &path, ui, sizeof(ui));
+ if (ui_len > 0) {
+ hybbx_client_display_write(&sess->display, ui, ui_len);
+ }
+ break;
+ default:
+ break;
+ }
+}
+
+static hybbx_result_t send_terminal_line(terminal_session_t *sess,
+ const char *line)
+{
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t len = strlen(line);
+
+ frame_len = hybbx_circuit_encode_terminal(line, len, frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(sess->fd, frame, frame_len);
+}
+
+static hybbx_result_t send_ax25_ui_line(terminal_session_t *sess,
+ const char *line)
+{
+ uint8_t frame[HYBBX_CIRCUIT_MAX_FRAME];
+ size_t frame_len;
+ size_t len = strlen(line);
+
+ if (!sess->path_ready) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ frame_len = hybbx_circuit_encode_ax25_ui(&sess->path,
+ (const uint8_t *)line, len,
+ HYBBX_CIRCUIT_FLAG_TX,
+ frame, sizeof(frame));
+ if (frame_len == 0) {
+ return HYBBX_ERR_IO;
+ }
+
+ return hybbx_circuit_link_write(sess->fd, frame, frame_len);
+}
+
+static int run_session(terminal_session_t *sess)
+{
+ hybbx_client_line_editor_t editor;
+ char inbuf[HYBBX_LINE_MAX + 2];
+ int running = 1;
+ int editor_ready = 0;
+
+ if (hybbx_client_line_editor_start(&editor, STDIN_FILENO) == 0) {
+ editor_ready = 1;
+ }
+
+ while (running) {
+ struct pollfd pfds[2];
+ int pr;
+ uint8_t buf[512];
+
+ pfds[0].fd = STDIN_FILENO;
+ pfds[0].events = POLLIN;
+ pfds[0].revents = 0;
+ pfds[1].fd = sess->fd;
+ pfds[1].events = POLLIN;
+ pfds[1].revents = 0;
+
+ pr = poll(pfds, 2, HYBBX_TERMINAL_POLL_MS);
+ if (pr < 0) {
+ if (errno == EINTR) {
+ continue;
+ }
+ break;
+ }
+
+ if ((pfds[0].revents & POLLIN) != 0) {
+ int have_line = 0;
+ int eof_seen = 0;
+
+ if (!editor_ready ||
+ hybbx_client_line_editor_poll(&editor, inbuf, sizeof(inbuf),
+ &have_line, &eof_seen) != HYBBX_OK) {
+ running = 0;
+ } else if (eof_seen) {
+ running = 0;
+ } else if (have_line) {
+ hybbx_result_t rc;
+
+ if (sess->cfg->ax25_ui && sess->path_ready) {
+ rc = send_ax25_ui_line(sess, inbuf);
+ } else {
+ rc = send_terminal_line(sess, inbuf);
+ }
+ if (rc != HYBBX_OK) {
+ break;
+ }
+ }
+ }
+
+ if ((pfds[1].revents & (POLLERR | POLLHUP | POLLNVAL)) != 0) {
+ break;
+ }
+
+ if ((pfds[1].revents & POLLIN) != 0) {
+ size_t read_len;
+
+ if (hybbx_circuit_link_read(sess->fd, buf, sizeof(buf),
+ &read_len) != HYBBX_OK) {
+ break;
+ }
+ if (read_len == 0) {
+ continue;
+ }
+
+ hybbx_circuit_decoder_feed(&sess->dec, buf, read_len,
+ on_circuit_frame, sess);
+ }
+ }
+
+ if (editor_ready) {
+ hybbx_client_line_editor_stop(&editor);
+ }
+
+ return 0;
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_terminal_config_t cfg;
+ terminal_session_t sess;
+ const char *env;
+ int opt;
+ int argi;
+ hybbx_traffic_config_t traffic;
+
+ static const struct option long_opts[] = {
+ {"host", required_argument, NULL, 'H'},
+ {"port", required_argument, NULL, 'p'},
+ {"link-id", required_argument, NULL, 1000},
+ {"link-password", required_argument, NULL, 1001},
+ {"link-role", required_argument, NULL, 1002},
+ {"mycall", required_argument, NULL, 1003},
+ {"dest", required_argument, NULL, 1004},
+ {"via", required_argument, NULL, 1005},
+ {"ax25-ui", no_argument, NULL, 1006},
+ {"baud", required_argument, NULL, 1007},
+ {"line-width", required_argument, NULL, 1008},
+ {"pace", required_argument, NULL, 1009},
+ {"ansi", required_argument, NULL, 1010},
+ {"ipv6", no_argument, NULL, '6'},
+ {"verbose", no_argument, NULL, 'v'},
+ {"help", no_argument, NULL, 'h'},
+ {NULL, 0, NULL, 0}
+ };
+
+ config_defaults(&cfg);
+ hybbx_traffic_config_defaults(&traffic);
+
+ env = getenv("HYBBX_CIRCUIT_HOST");
+ if (env != NULL && env[0] != '\0') {
+ hybbx_strlcpy(cfg.host, env, sizeof(cfg.host));
+ }
+ env = getenv("HYBBX_CIRCUIT_PORT");
+ if (env != NULL && env[0] != '\0') {
+ cfg.port = (unsigned)strtoul(env, NULL, 10);
+ }
+
+ while ((opt = getopt_long(argc, argv, "H:p:6vh", long_opts, NULL)) != -1) {
+ switch (opt) {
+ case 'H':
+ hybbx_strlcpy(cfg.host, optarg, sizeof(cfg.host));
+ break;
+ case 'p':
+ cfg.port = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case '6':
+ cfg.ipv6 = 1;
+ break;
+ case 'v':
+ cfg.verbose = 1;
+ break;
+ case 'h':
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ case 1000:
+ hybbx_strlcpy(cfg.link_id, optarg, sizeof(cfg.link_id));
+ break;
+ case 1001:
+ hybbx_strlcpy(cfg.link_password, optarg, sizeof(cfg.link_password));
+ break;
+ case 1002:
+ hybbx_strlcpy(cfg.link_role, optarg, sizeof(cfg.link_role));
+ break;
+ case 1003:
+ hybbx_strlcpy(cfg.mycall, optarg, sizeof(cfg.mycall));
+ break;
+ case 1004:
+ hybbx_strlcpy(cfg.dest, optarg, sizeof(cfg.dest));
+ break;
+ case 1005:
+ hybbx_strlcpy(cfg.via, optarg, sizeof(cfg.via));
+ break;
+ case 1006:
+ cfg.ax25_ui = 1;
+ break;
+ case 1007:
+ traffic.baud = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1008:
+ traffic.line_width = (unsigned)strtoul(optarg, NULL, 10);
+ break;
+ case 1009:
+ traffic.pace_output = hybbx_parse_bool(optarg, traffic.pace_output);
+ break;
+ case 1010:
+ traffic.ansi = hybbx_parse_bool(optarg, traffic.ansi);
+ break;
+ default:
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+ }
+
+ argi = optind;
+ if (argi < argc) {
+ hybbx_strlcpy(cfg.host, argv[argi++], sizeof(cfg.host));
+ }
+ if (argi < argc) {
+ cfg.port = (unsigned)strtoul(argv[argi++], NULL, 10);
+ }
+
+ memset(&sess, 0, sizeof(sess));
+ sess.cfg = &cfg;
+ hybbx_circuit_decoder_init(&sess.dec);
+ hybbx_client_display_init(&sess.display, &traffic);
+ sess.path_ready = build_ax25_path(&cfg, &sess.path);
+
+ if (hybbx_circuit_link_connect(cfg.host, cfg.port, &sess.fd) != HYBBX_OK) {
+ fprintf(stderr, "hybbx-terminal: connect %s:%u failed\n",
+ cfg.host, cfg.port);
+ return EXIT_FAILURE;
+ }
+
+ if (cfg.link_password[0] != '\0') {
+ if (hybbx_circuit_link_authenticate(sess.fd, cfg.link_password,
+ cfg.link_role, cfg.link_id) != HYBBX_OK) {
+ fprintf(stderr, "hybbx-terminal: link authentication failed\n");
+ close(sess.fd);
+ return EXIT_FAILURE;
+ }
+ }
+
+ if (cfg.verbose) {
+ fprintf(stderr, "hybbx-terminal: connected to %s:%u (ax25-ui=%s)\n",
+ cfg.host, cfg.port, cfg.ax25_ui ? "yes" : "no");
+ }
+
+ run_session(&sess);
+ close(sess.fd);
+ return EXIT_SUCCESS;
+}
diff --git a/src/clients/link_auth.c b/src/clients/link_auth.c
new file mode 100644
index 0000000..4ba53e5
--- /dev/null
+++ b/src/clients/link_auth.c
@@ -0,0 +1,182 @@
+#include "hybbx/link.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static const char *find_kv_line(const char *payload, size_t len,
+ const char *key,
+ char *scratch, size_t scratch_len)
+{
+ const char *cursor = payload;
+ const char *end = payload + len;
+ size_t key_len = strlen(key);
+
+ while (cursor < end) {
+ const char *line_end = memchr(cursor, '\n', (size_t)(end - cursor));
+ const char *line_stop = line_end != NULL ? line_end : end;
+ const char *eq = memchr(cursor, '=', (size_t)(line_stop - cursor));
+
+ if (eq != NULL && (size_t)(eq - cursor) == key_len &&
+ memcmp(cursor, key, key_len) == 0) {
+ const char *value = eq + 1;
+ size_t value_len = (size_t)(line_stop - value);
+
+ while (value_len > 0 &&
+ (value[value_len - 1] == '\r' ||
+ value[value_len - 1] == '\n' ||
+ value[value_len - 1] == ' ')) {
+ value_len--;
+ }
+ if (value_len >= scratch_len) {
+ value_len = scratch_len - 1;
+ }
+ memcpy(scratch, value, value_len);
+ scratch[value_len] = '\0';
+ return scratch;
+ }
+
+ if (line_end == NULL) {
+ break;
+ }
+ cursor = line_end + 1;
+ }
+
+ return NULL;
+}
+
+void hybbx_link_auth_clear(hybbx_link_auth_t *auth)
+{
+ if (auth == NULL) {
+ return;
+ }
+
+ memset(auth, 0, sizeof(*auth));
+}
+
+size_t hybbx_link_auth_format(const hybbx_link_auth_t *auth,
+ char *out, size_t out_cap)
+{
+ int n;
+
+ if (auth == NULL || out == NULL || out_cap == 0) {
+ return 0;
+ }
+
+ if (auth->password[0] == '\0' || auth->id[0] == '\0') {
+ return 0;
+ }
+
+ n = snprintf(out, out_cap,
+ "password=%s\nrole=%s\nid=%s\n",
+ auth->password,
+ auth->role[0] != '\0' ? auth->role : "link",
+ auth->id);
+ if (n < 0 || (size_t)n >= out_cap) {
+ return 0;
+ }
+
+ if (auth->baud > 0) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "baud=%u\n", auth->baud);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->duplex == 1) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "duplex=half\n");
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ } else if (auth->duplex == 2) {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "duplex=full\n");
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->bandwidth[0] != '\0') {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "bandwidth=%s\n", auth->bandwidth);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ if (auth->frequency_mhz[0] != '\0') {
+ int extra = snprintf(out + (size_t)n, out_cap - (size_t)n,
+ "frequency_mhz=%s\n", auth->frequency_mhz);
+ if (extra < 0 || (size_t)n + (size_t)extra >= out_cap) {
+ return 0;
+ }
+ n += extra;
+ }
+
+ return (size_t)n;
+}
+
+hybbx_result_t hybbx_link_auth_parse(const char *payload, size_t len,
+ hybbx_link_auth_t *auth)
+{
+ char scratch[128];
+
+ if (payload == NULL || auth == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+
+ hybbx_link_auth_clear(auth);
+
+ if (find_kv_line(payload, len, "password", scratch, sizeof(scratch)) == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ hybbx_strlcpy(auth->password, scratch, sizeof(auth->password));
+
+ if (find_kv_line(payload, len, "id", scratch, sizeof(scratch)) == NULL) {
+ return HYBBX_ERR_INVALID;
+ }
+ hybbx_strlcpy(auth->id, scratch, sizeof(auth->id));
+
+ if (find_kv_line(payload, len, "role", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->role, scratch, sizeof(auth->role));
+ } else {
+ hybbx_strlcpy(auth->role, "link", sizeof(auth->role));
+ }
+
+ if (find_kv_line(payload, len, "baud", scratch, sizeof(scratch)) != NULL) {
+ unsigned long baud = strtoul(scratch, NULL, 10);
+ if (baud > 0) {
+ auth->baud = (unsigned)baud;
+ }
+ }
+
+ if (find_kv_line(payload, len, "duplex", scratch, sizeof(scratch)) != NULL) {
+ if (strcmp(scratch, "full") == 0 || strcmp(scratch, "full-duplex") == 0) {
+ auth->duplex = 2;
+ } else if (strcmp(scratch, "half") == 0 ||
+ strcmp(scratch, "half-duplex") == 0 ||
+ strcmp(scratch, "simplex") == 0) {
+ auth->duplex = 1;
+ }
+ }
+
+ if (find_kv_line(payload, len, "bandwidth", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->bandwidth, scratch, sizeof(auth->bandwidth));
+ }
+
+ if (find_kv_line(payload, len, "frequency_mhz", scratch, sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->frequency_mhz, scratch, sizeof(auth->frequency_mhz));
+ } else if (find_kv_line(payload, len, "frequency", scratch,
+ sizeof(scratch)) != NULL) {
+ hybbx_strlcpy(auth->frequency_mhz, scratch, sizeof(auth->frequency_mhz));
+ }
+
+ return HYBBX_OK;
+}
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
+}
diff --git a/src/instance_role_main.c b/src/instance_role_main.c
new file mode 100644
index 0000000..e61eb81
--- /dev/null
+++ b/src/instance_role_main.c
@@ -0,0 +1,7 @@
+#include "hybbx/instance.h"
+
+/* Linked only into hybbxd. */
+hybbx_instance_role_t hybbx_instance_role(void)
+{
+ return HYBBX_INSTANCE_MAIN;
+}
diff --git a/src/instance_role_proxy.c b/src/instance_role_proxy.c
new file mode 100644
index 0000000..2f97b94
--- /dev/null
+++ b/src/instance_role_proxy.c
@@ -0,0 +1,7 @@
+#include "hybbx/instance.h"
+
+/* Linked only into hybbxpd. */
+hybbx_instance_role_t hybbx_instance_role(void)
+{
+ return HYBBX_INSTANCE_PROXY;
+}
diff --git a/src/instance_role_secondary.c b/src/instance_role_secondary.c
new file mode 100644
index 0000000..b8a8704
--- /dev/null
+++ b/src/instance_role_secondary.c
@@ -0,0 +1,7 @@
+#include "hybbx/instance.h"
+
+/* Linked only into hybbxsd. */
+hybbx_instance_role_t hybbx_instance_role(void)
+{
+ return HYBBX_INSTANCE_SECONDARY;
+}
diff --git a/src/main.c b/src/main.c
new file mode 100644
index 0000000..670de7a
--- /dev/null
+++ b/src/main.c
@@ -0,0 +1,371 @@
+/*
+ * HyBBX instance daemon — hybbxd (Main) / hybbxsd (Secondary) / hybbxpd (Proxy).
+ * Role is linked via instance_role_*.c. Plugins: CMake HYBBX_PLUGIN_*.
+ * Config: -c path/to/hybbx.ini
+ */
+#if defined(__linux__)
+#define _DEFAULT_SOURCE
+#endif
+
+#include "hybbx/hybbx.h"
+#include "hybbx/instance.h"
+#include "hybbx/service.h"
+#include "hybbx/registry.h"
+#include "hybbx/config.h"
+#include "hybbx/command.h"
+#include "hybbx/commands_registry.h"
+#include "hybbx/daemon_wrap.h"
+#include "hybbx/util.h"
+#include "hybbx/limits.h"
+#include "hybbx/instance.h"
+#include "hybbx/privilege.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <unistd.h>
+#include <libgen.h>
+
+#ifdef HYBBX_HAVE_PLUGIN_TELNET
+extern const hybbx_transport_plugin_t hybbx_plugin_telnet;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_PACKET_RADIO
+extern const hybbx_transport_plugin_t hybbx_plugin_packet_radio;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_ARDOP
+extern const hybbx_transport_plugin_t hybbx_plugin_ardop;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_CRDOP
+extern const hybbx_transport_plugin_t hybbx_plugin_crdop;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_SSH
+extern const hybbx_transport_plugin_t hybbx_plugin_ssh;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_WEBSOCKET
+extern const hybbx_transport_plugin_t hybbx_plugin_websocket;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_BAYCOM
+extern const hybbx_transport_plugin_t hybbx_plugin_baycom;
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_MAINS_PROXY
+extern const hybbx_transport_plugin_t hybbx_plugin_mains_proxy;
+#endif
+
+static void register_builtin_plugins(void)
+{
+ /* Register every compiled plugin; instance/INI gating is at apply time. */
+#ifdef HYBBX_HAVE_PLUGIN_TELNET
+ hybbx_registry_register(&hybbx_plugin_telnet);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_PACKET_RADIO
+ hybbx_registry_register(&hybbx_plugin_packet_radio);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_ARDOP
+ hybbx_registry_register(&hybbx_plugin_ardop);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_CRDOP
+ hybbx_registry_register(&hybbx_plugin_crdop);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_SSH
+ hybbx_registry_register(&hybbx_plugin_ssh);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_WEBSOCKET
+ hybbx_registry_register(&hybbx_plugin_websocket);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_BAYCOM
+ hybbx_registry_register(&hybbx_plugin_baycom);
+#endif
+#ifdef HYBBX_HAVE_PLUGIN_MAINS_PROXY
+ hybbx_registry_register(&hybbx_plugin_mains_proxy);
+#endif
+}
+
+static void print_usage(const char *prog)
+{
+ fprintf(stderr,
+ "HyBBX %s — %s instance (%s)\n"
+ "\n"
+ "Usage: %s [options]\n"
+ "\n"
+ "Options:\n"
+ " -c, --config <file> Load INI configuration file\n"
+ " -f, --foreground Run in foreground (default)\n"
+ " --screen [name] Run detached in GNU screen (default: %s)\n"
+ " --tmux [name] Run detached in tmux (default: %s)\n"
+ " --attach Attach to screen/tmux session (with --screen/--tmux)\n"
+ " -h, --help Show this help\n"
+ " -V, --version Show version\n"
+ "\n"
+ "Instances: hybbxd=Main hybbxsd=Secondary hybbxpd=Proxy\n"
+ "\n",
+ HYBBX_VERSION_STRING,
+ hybbx_instance_role_name(),
+ hybbx_instance_binary_name(),
+ prog,
+ hybbx_instance_binary_name(),
+ hybbx_instance_binary_name());
+}
+
+static void print_version(void)
+{
+ printf("HyBBX %s — %s (%s)%s\n",
+ HYBBX_VERSION_STRING,
+ hybbx_instance_role_name(),
+ hybbx_instance_binary_name(),
+ hybbx_instance_offers_user_bbx() ? "" : " [no user BBX]");
+}
+
+static void list_plugins_cb(const hybbx_transport_plugin_t *plugin, void *userdata)
+{
+ (void)userdata;
+ printf(" - %s (kind=%d, version=%u)\n",
+ plugin->name, (int)plugin->kind, plugin->version);
+}
+
+static int path_is_readable(const char *path)
+{
+ return path != NULL && path[0] != '\0' && access(path, R_OK) == 0;
+}
+
+/**
+ * When -c is omitted: HYBBX_CONFIG, then <binary>/hybbx.ini (install layout).
+ */
+static const char *discover_config_path(char *buf, size_t buflen,
+ const char *argv0)
+{
+ const char *env;
+
+ if (buf == NULL || buflen == 0) {
+ return NULL;
+ }
+
+ env = getenv("HYBBX_CONFIG");
+ if (path_is_readable(env)) {
+ snprintf(buf, buflen, "%s", env);
+ return buf;
+ }
+
+ if (argv0 == NULL || argv0[0] == '\0') {
+ return NULL;
+ }
+
+#if defined(__linux__)
+ {
+ char exe[HYBBX_PATH_MAX];
+ char etc[HYBBX_PATH_MAX];
+ ssize_t n = readlink("/proc/self/exe", exe, sizeof(exe) - 1);
+
+ if (n > 0) {
+ char *slash;
+ size_t dir_len;
+
+ exe[n] = '\0';
+ slash = strrchr(exe, '/');
+ if (slash != NULL) {
+ dir_len = (size_t)(slash - exe);
+ if (dir_len + 1 < sizeof(etc)) {
+ memcpy(etc, exe, dir_len);
+ etc[dir_len] = '\0';
+ if (hybbx_path_join(buf, buflen, etc, HYBBX_FILE_CONFIG) ==
+ HYBBX_OK &&
+ path_is_readable(buf)) {
+ return buf;
+ }
+ }
+ }
+ }
+ }
+#endif
+
+ snprintf(buf, buflen, "%s", argv0);
+ {
+ char path_copy[HYBBX_PATH_MAX];
+ char candidate[HYBBX_PATH_MAX];
+ char *dir;
+
+ snprintf(path_copy, sizeof(path_copy), "%s", argv0);
+ dir = dirname(path_copy);
+ if (hybbx_path_join(candidate, sizeof(candidate), dir,
+ HYBBX_FILE_CONFIG) == HYBBX_OK &&
+ path_is_readable(candidate)) {
+ snprintf(buf, buflen, "%s", candidate);
+ return buf;
+ }
+ }
+
+ return NULL;
+}
+
+static void setup_install_root(const char *config_path)
+{
+ const char *env;
+
+ env = getenv(HYBBX_ENV_ROOT);
+ if (env != NULL && env[0] != '\0') {
+ hybbx_install_root_set(env);
+ return;
+ }
+
+ if (config_path != NULL && config_path[0] != '\0') {
+ char dir[HYBBX_PATH_MAX];
+
+ if (hybbx_path_dirname(config_path, dir, sizeof(dir)) == HYBBX_OK) {
+ hybbx_install_root_set(dir);
+ }
+ }
+}
+
+int main(int argc, char *argv[])
+{
+ hybbx_service_t *service;
+ hybbx_config_t config;
+ const char *config_path = NULL;
+ char discovered_config[HYBBX_PATH_MAX];
+ hybbx_daemon_launch_opts_t launch;
+ int have_config = 0;
+ int i;
+ hybbx_result_t rc;
+
+ hybbx_daemon_launch_opts_defaults(&launch);
+
+ for (i = 1; i < argc; i++) {
+ if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
+ print_usage(argv[0]);
+ return EXIT_SUCCESS;
+ }
+ if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
+ print_version();
+ return EXIT_SUCCESS;
+ }
+ if (strcmp(argv[i], "-f") == 0 || strcmp(argv[i], "--foreground") == 0) {
+ launch.foreground = 1;
+ continue;
+ }
+ if (strcmp(argv[i], "--attach") == 0) {
+ launch.attach = 1;
+ continue;
+ }
+ if (strcmp(argv[i], "--screen") == 0) {
+ launch.use_screen = 1;
+ if (i + 1 < argc && argv[i + 1][0] != '-' &&
+ strcmp(argv[i + 1], "-c") != 0 &&
+ strcmp(argv[i + 1], "--config") != 0) {
+ hybbx_strlcpy(launch.session, argv[++i], sizeof(launch.session));
+ }
+ continue;
+ }
+ if (strcmp(argv[i], "--tmux") == 0) {
+ launch.use_tmux = 1;
+ if (i + 1 < argc && argv[i + 1][0] != '-' &&
+ strcmp(argv[i + 1], "-c") != 0 &&
+ strcmp(argv[i + 1], "--config") != 0) {
+ hybbx_strlcpy(launch.session, argv[++i], sizeof(launch.session));
+ }
+ continue;
+ }
+ if (strcmp(argv[i], "-c") == 0 || strcmp(argv[i], "--config") == 0) {
+ if (i + 1 >= argc) {
+ fprintf(stderr, "Missing argument for %s\n", argv[i]);
+ return EXIT_FAILURE;
+ }
+ config_path = argv[++i];
+ continue;
+ }
+
+ fprintf(stderr, "Unknown option: %s\n", argv[i]);
+ print_usage(argv[0]);
+ return EXIT_FAILURE;
+ }
+
+ rc = hybbx_daemon_apply_launch_opts(&launch, argv[0], argc, argv);
+ if (rc != HYBBX_OK) {
+ return EXIT_FAILURE;
+ }
+
+ if (config_path == NULL) {
+ config_path = discover_config_path(discovered_config,
+ sizeof(discovered_config), argv[0]);
+ }
+
+ register_builtin_plugins();
+
+ printf("HyBBX %s starting (%s / %s)\n",
+ HYBBX_VERSION_STRING,
+ hybbx_instance_binary_name(),
+ hybbx_instance_role_name());
+
+ service = hybbx_service_create(NULL);
+ if (service == NULL) {
+ fprintf(stderr, "Failed to create service (out of memory?)\n");
+ return EXIT_FAILURE;
+ }
+
+ if (config_path != NULL) {
+ rc = hybbx_config_load(&config, config_path);
+
+ if (rc != HYBBX_OK) {
+ fprintf(stderr, "Failed to load config '%s'\n", config_path);
+ hybbx_service_destroy(service);
+ return EXIT_FAILURE;
+ }
+
+ have_config = 1;
+ printf("Loaded configuration: %s\n", config_path);
+
+ rc = hybbx_privilege_apply_from_config(&config);
+ if (rc != HYBBX_OK) {
+ fprintf(stderr,
+ "Privilege setup failed — %s\n",
+ hybbx_result_name(rc));
+ hybbx_config_free(&config);
+ hybbx_service_destroy(service);
+ return EXIT_FAILURE;
+ }
+
+ setup_install_root(config_path);
+
+ rc = hybbx_commands_registry_init();
+ if (rc != HYBBX_OK) {
+ fprintf(stderr, "Failed to load command registry\n");
+ hybbx_config_free(&config);
+ hybbx_service_destroy(service);
+ return EXIT_FAILURE;
+ }
+
+ rc = hybbx_service_apply_config(service, &config, config_path);
+ if (rc != HYBBX_OK) {
+ fprintf(stderr,
+ "Failed to apply configuration (storage path writable? "
+ "telnet/circuit bind ok?) — %s\n",
+ hybbx_result_name(rc));
+ hybbx_commands_registry_shutdown();
+ hybbx_config_free(&config);
+ hybbx_service_destroy(service);
+ return EXIT_FAILURE;
+ }
+
+ printf("Available transport plugins:\n");
+ hybbx_registry_foreach(list_plugins_cb, NULL);
+ } else {
+ fprintf(stderr,
+ "No configuration loaded. Use -c <hybbx.ini>, set HYBBX_CONFIG, "
+ "or run hybbx-start.\n");
+ fprintf(stderr, "No transports will listen without a config file.\n");
+ }
+
+ hybbx_service_set_launch_binary(service, argv[0]);
+
+ hybbx_service_run(service);
+
+ if (hybbx_service_shutdown_mode(service) == HYBBX_SHUTDOWN_RESTART) {
+ hybbx_service_restart_exec(service);
+ }
+
+ if (have_config) {
+ hybbx_config_free(&config);
+ }
+ hybbx_commands_registry_shutdown();
+ hybbx_service_destroy(service);
+
+ return EXIT_SUCCESS;
+}
diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt
new file mode 100644
index 0000000..882aa94
--- /dev/null
+++ b/tests/CMakeLists.txt
@@ -0,0 +1,33 @@
+add_executable(hybbx_test_util test_util.c)
+target_link_libraries(hybbx_test_util PRIVATE hybbx_core)
+
+add_executable(hybbx_test_config test_config.c)
+target_link_libraries(hybbx_test_config PRIVATE hybbx_core)
+
+add_executable(hybbx_test_mains_proxy test_mains_proxy.c)
+target_link_libraries(hybbx_test_mains_proxy PRIVATE hybbx_core hybbx_plugin_mains_proxy hybbx_plugin_packet_radio)
+
+add_executable(hybbx_test_mains_proxy_e2e test_mains_proxy_e2e.c)
+target_link_libraries(hybbx_test_mains_proxy_e2e PRIVATE hybbx_core hybbx_plugin_mains_proxy hybbx_plugin_packet_radio)
+
+add_executable(hybbx_test_storage_smoke test_storage_smoke.c)
+target_link_libraries(hybbx_test_storage_smoke PRIVATE hybbx_core)
+
+function(hybbx_add_instance_test name role_src)
+ add_executable(${name} test_instance.c ${role_src})
+ target_link_libraries(${name} PRIVATE hybbx_core)
+ add_test(NAME ${name} COMMAND ${name})
+endfunction()
+
+hybbx_add_instance_test(hybbx_test_instance_main
+ ${CMAKE_SOURCE_DIR}/src/instance_role_main.c)
+hybbx_add_instance_test(hybbx_test_instance_secondary
+ ${CMAKE_SOURCE_DIR}/src/instance_role_secondary.c)
+hybbx_add_instance_test(hybbx_test_instance_proxy
+ ${CMAKE_SOURCE_DIR}/src/instance_role_proxy.c)
+
+add_test(NAME util COMMAND hybbx_test_util)
+add_test(NAME config COMMAND hybbx_test_config)
+add_test(NAME mains_proxy COMMAND hybbx_test_mains_proxy)
+add_test(NAME mains_proxy_e2e COMMAND hybbx_test_mains_proxy_e2e $<TARGET_FILE:hybbxd>)
+add_test(NAME storage_smoke COMMAND hybbx_test_storage_smoke)
diff --git a/tests/test_config.c b/tests/test_config.c
new file mode 100644
index 0000000..006d6b5
--- /dev/null
+++ b/tests/test_config.c
@@ -0,0 +1,60 @@
+#include "hybbx/config.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned g_failures;
+
+static void check_str(const char *label, const char *got, const char *want)
+{
+ if (got == NULL || want == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s: got '%s' want '%s'\n",
+ label, got != NULL ? got : "(null)", want);
+ g_failures++;
+ }
+}
+
+int main(void)
+{
+ hybbx_config_t cfg;
+ const char *path = "hybbx_test_config.ini";
+ FILE *fp;
+
+ fp = fopen(path, "w");
+ if (fp == NULL) {
+ fprintf(stderr, "FAIL fopen\n");
+ return 1;
+ }
+
+ fputs("[log]\n", fp);
+ fputs("dir = logs ; relative to install root\n", fp);
+ fputs("level = info # debug level\n", fp);
+ fputs("[quoted]\n", fp);
+ fputs("path = \"data;keep\" ; trailing comment\n", fp);
+ fclose(fp);
+
+ if (hybbx_config_load(&cfg, path) != HYBBX_OK) {
+ fprintf(stderr, "FAIL config load\n");
+ remove(path);
+ return 1;
+ }
+
+ check_str("inline semicolon", hybbx_config_get(&cfg, "log", "dir", NULL),
+ "logs");
+ check_str("inline hash", hybbx_config_get(&cfg, "log", "level", NULL),
+ "info");
+ check_str("quoted semicolon",
+ hybbx_config_get(&cfg, "quoted", "path", NULL), "data;keep");
+
+ hybbx_config_free(&cfg);
+ remove(path);
+
+ if (g_failures != 0) {
+ fprintf(stderr, "%u failure(s)\n", g_failures);
+ return 1;
+ }
+
+ puts("test_config: ok");
+ return 0;
+}
diff --git a/tests/test_instance.c b/tests/test_instance.c
new file mode 100644
index 0000000..dd25cea
--- /dev/null
+++ b/tests/test_instance.c
@@ -0,0 +1,170 @@
+#include "hybbx/instance.h"
+#include "hybbx/networks.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static int failures;
+
+static void expect_int(const char *label, int got, int want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %d want %d\n", label, got, want);
+ failures++;
+ }
+}
+
+static void test_enforce_hub(void)
+{
+ hybbx_networks_config_t net;
+
+ hybbx_networks_config_defaults(&net);
+ net.telnet = 1;
+ net.ax25 = 1;
+ net.baycom = 1;
+ net.ardop = 1;
+ net.crdop = 1;
+ net.websocket = 0;
+ net.mains_proxy = 0;
+
+ if (hybbx_networks_enforce_instance(&net) != HYBBX_OK) {
+ fprintf(stderr, "FAIL enforce returned error\n");
+ failures++;
+ return;
+ }
+
+ switch (hybbx_instance_role()) {
+ case HYBBX_INSTANCE_MAIN:
+ expect_int("main ax25 off", net.ax25, 0);
+ expect_int("main baycom off", net.baycom, 0);
+ expect_int("main ardop off", net.ardop, 0);
+ expect_int("main crdop off", net.crdop, 0);
+ expect_int("main telnet off", net.telnet, 0);
+ expect_int("main websocket on", net.websocket, 1);
+ expect_int("main circuit on", net.circuit, 1);
+ break;
+
+ case HYBBX_INSTANCE_SECONDARY:
+ expect_int("secondary ax25 kept", net.ax25, 1);
+ expect_int("secondary baycom kept", net.baycom, 1);
+ expect_int("secondary telnet off", net.telnet, 0);
+ expect_int("secondary websocket off", net.websocket, 0);
+ expect_int("secondary mains_proxy on", net.mains_proxy, 1);
+ break;
+
+ case HYBBX_INSTANCE_PROXY:
+ expect_int("proxy ax25 kept", net.ax25, 1);
+ expect_int("proxy baycom kept", net.baycom, 1);
+ expect_int("proxy ardop kept", net.ardop, 1);
+ expect_int("proxy crdop kept", net.crdop, 1);
+ expect_int("proxy websocket off", net.websocket, 0);
+ expect_int("proxy circuit on", net.circuit, 1);
+ break;
+
+ default:
+ fprintf(stderr, "FAIL unknown role\n");
+ failures++;
+ break;
+ }
+}
+
+static void test_plugin_allow_hub(void)
+{
+ switch (hybbx_instance_role()) {
+ case HYBBX_INSTANCE_MAIN:
+ expect_int("main packet_radio denied",
+ hybbx_instance_plugin_allowed("packet_radio"), 0);
+ expect_int("main baycom denied",
+ hybbx_instance_plugin_allowed("baycom"), 0);
+ expect_int("main ardop denied",
+ hybbx_instance_plugin_allowed("ardop"), 0);
+ expect_int("main crdop denied",
+ hybbx_instance_plugin_allowed("crdop"), 0);
+ expect_int("main websocket", hybbx_instance_plugin_allowed("websocket"), 1);
+ expect_int("main telnet", hybbx_instance_plugin_allowed("telnet"), 0);
+ expect_int("main user_bbx", hybbx_instance_offers_user_bbx(), 1);
+ break;
+
+ case HYBBX_INSTANCE_SECONDARY:
+ expect_int("secondary packet_radio",
+ hybbx_instance_plugin_allowed("packet_radio"), 1);
+ expect_int("secondary baycom",
+ hybbx_instance_plugin_allowed("baycom"), 1);
+ expect_int("secondary mains_proxy",
+ hybbx_instance_plugin_allowed("mains_proxy"), 1);
+ expect_int("secondary telnet",
+ hybbx_instance_plugin_allowed("telnet"), 0);
+ expect_int("secondary websocket",
+ hybbx_instance_plugin_allowed("websocket"), 0);
+ expect_int("secondary user_bbx", hybbx_instance_offers_user_bbx(), 0);
+ break;
+
+ case HYBBX_INSTANCE_PROXY:
+ expect_int("proxy packet_radio",
+ hybbx_instance_plugin_allowed("packet_radio"), 1);
+ expect_int("proxy baycom",
+ hybbx_instance_plugin_allowed("baycom"), 1);
+ expect_int("proxy telnet", hybbx_instance_plugin_allowed("telnet"), 1);
+ expect_int("proxy ssh", hybbx_instance_plugin_allowed("ssh"), 1);
+ expect_int("proxy websocket",
+ hybbx_instance_plugin_allowed("websocket"), 0);
+ expect_int("proxy mains_proxy",
+ hybbx_instance_plugin_allowed("mains_proxy"), 1);
+ expect_int("proxy user_bbx", hybbx_instance_offers_user_bbx(), 0);
+ break;
+
+ default:
+ break;
+ }
+}
+
+static void test_standalone_main(void)
+{
+ hybbx_networks_config_t net;
+
+ if (hybbx_instance_role() != HYBBX_INSTANCE_MAIN) {
+ return;
+ }
+
+ hybbx_instance_set_standalone(1);
+
+ expect_int("standalone baycom allowed",
+ hybbx_instance_plugin_allowed("baycom"), 1);
+ expect_int("standalone packet_radio allowed",
+ hybbx_instance_plugin_allowed("packet_radio"), 1);
+ expect_int("standalone ardop allowed",
+ hybbx_instance_plugin_allowed("ardop"), 1);
+
+ hybbx_networks_config_defaults(&net);
+ net.telnet = 1;
+ net.ax25 = 1;
+ net.baycom = 1;
+ net.ardop = 1;
+ net.crdop = 1;
+
+ if (hybbx_networks_enforce_instance(&net) != HYBBX_OK) {
+ fprintf(stderr, "FAIL standalone enforce returned error\n");
+ failures++;
+ hybbx_instance_set_standalone(0);
+ return;
+ }
+
+ expect_int("standalone ax25 kept", net.ax25, 1);
+ expect_int("standalone baycom kept", net.baycom, 1);
+ expect_int("standalone ardop kept", net.ardop, 1);
+ expect_int("standalone crdop kept", net.crdop, 1);
+
+ hybbx_instance_set_standalone(0);
+}
+
+int main(void)
+{
+ printf("instance tests role=%s binary=%s\n",
+ hybbx_instance_role_name(),
+ hybbx_instance_binary_name());
+ test_plugin_allow_hub();
+ test_enforce_hub();
+ test_standalone_main();
+ return failures == 0 ? EXIT_SUCCESS : EXIT_FAILURE;
+}
diff --git a/tests/test_mains_proxy.c b/tests/test_mains_proxy.c
new file mode 100644
index 0000000..b95a904
--- /dev/null
+++ b/tests/test_mains_proxy.c
@@ -0,0 +1,189 @@
+#include "hybbx/mains_proxy.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned g_failures;
+
+static void check_str(const char *label, const char *got, const char *want)
+{
+ if (got == NULL || want == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s: got '%s' want '%s'\n",
+ label, got != NULL ? got : "(null)", want);
+ g_failures++;
+ }
+}
+
+static void check_int(const char *label, int got, int want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %d want %d\n", label, got, want);
+ g_failures++;
+ }
+}
+
+static void check_uint(const char *label, unsigned got, unsigned want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %u want %u\n", label, got, want);
+ g_failures++;
+ }
+}
+
+static void parse_one_peer(const char *section,
+ hybbx_mains_proxy_peer_config_t *out)
+{
+ hybbx_result_t rc = hybbx_mains_proxy_peer_parse(section, out);
+ if (rc != HYBBX_OK) {
+ fprintf(stderr, "FAIL peer_parse returned %d for '%s'\n", (int)rc, section);
+ g_failures++;
+ }
+}
+
+int main(void)
+{
+ hybbx_mains_proxy_peer_config_t peer;
+ const char *multi;
+ const char *sep;
+ char section_buf[1024];
+
+ /* 1. semicolon-separated section (serialized by hybbx_config_format_section) */
+ parse_one_peer(
+ "peer_id=alpha;circuit_host=127.0.0.1;circuit_port=7323;"
+ "link_id=alpha-main;link_password=secret;enabled=yes;wire=circuit;"
+ "duplex=full;use_secondary=yes",
+ &peer);
+ check_str("semicolon peer_id", peer.peer_id, "alpha");
+ check_str("semicolon circuit_host", peer.circuit_host, "127.0.0.1");
+ check_uint("semicolon circuit_port", peer.circuit_port, 7323u);
+ check_str("semicolon link_id", peer.link_id, "alpha-main");
+ check_str("semicolon link_password", peer.link_password, "secret");
+ check_int("semicolon enabled", peer.enabled, 1);
+ check_int("semicolon wire", (int)peer.wire, (int)HYBBX_MAINS_PROXY_WIRE_CIRCUIT);
+ check_int("semicolon duplex", (int)peer.duplex, (int)HYBBX_MAINS_PROXY_DUPLEX_FULL);
+ check_int("semicolon use_secondary", peer.use_secondary, 1);
+
+ /* 2. newline-separated section */
+ parse_one_peer(
+ "peer_id=beta\ncircuit_host=127.0.0.2\ncircuit_port=7324\n"
+ "link_id=beta-main\nlink_password=other\nenabled=no\n"
+ "wire=ax25\nduplex=half\nuse_secondary=no",
+ &peer);
+ check_str("newline peer_id", peer.peer_id, "beta");
+ check_str("newline circuit_host", peer.circuit_host, "127.0.0.2");
+ check_uint("newline circuit_port", peer.circuit_port, 7324u);
+ check_str("newline link_id", peer.link_id, "beta-main");
+ check_str("newline link_password", peer.link_password, "other");
+ check_int("newline enabled", peer.enabled, 0);
+ check_int("newline wire", (int)peer.wire, (int)HYBBX_MAINS_PROXY_WIRE_AX25);
+ check_int("newline duplex", (int)peer.duplex, (int)HYBBX_MAINS_PROXY_DUPLEX_HALF);
+ check_int("newline use_secondary", peer.use_secondary, 0);
+
+ /* 3. multiple peers separated by instance separator */
+ multi =
+ "peer_id=first;circuit_host=127.0.0.1;enabled=yes\x1e"
+ "peer_id=second;circuit_host=127.0.0.2;enabled=yes\x1e"
+ "enabled=no\x1e"
+ "peer_id=fourth;circuit_host=127.0.0.4;enabled=yes";
+
+ sep = multi;
+ for (unsigned i = 0; i < 4; i++) {
+ const char *next = strchr(sep, HYBBX_MAINS_PROXY_INSTANCE_SEP);
+ size_t len;
+
+ if (next != NULL) {
+ len = (size_t)(next - sep);
+ } else {
+ len = strlen(sep);
+ }
+
+ if (len >= sizeof(section_buf)) {
+ len = sizeof(section_buf) - 1;
+ }
+ memcpy(section_buf, sep, len);
+ section_buf[len] = '\0';
+
+ parse_one_peer(section_buf, &peer);
+
+ switch (i) {
+ case 0:
+ check_str("multi[0] peer_id", peer.peer_id, "first");
+ check_str("multi[0] circuit_host", peer.circuit_host, "127.0.0.1");
+ check_int("multi[0] enabled", peer.enabled, 1);
+ break;
+ case 1:
+ check_str("multi[1] peer_id", peer.peer_id, "second");
+ check_str("multi[1] circuit_host", peer.circuit_host, "127.0.0.2");
+ check_int("multi[1] enabled", peer.enabled, 1);
+ break;
+ case 2:
+ /* section with only enabled=no: disabled peer preserves defaults */
+ check_int("multi[2] enabled", peer.enabled, 0);
+ check_str("multi[2] peer_id", peer.peer_id, "");
+ check_str("multi[2] circuit_host", peer.circuit_host, "");
+ break;
+ case 3:
+ check_str("multi[3] peer_id", peer.peer_id, "fourth");
+ check_str("multi[3] circuit_host", peer.circuit_host, "127.0.0.4");
+ check_int("multi[3] enabled", peer.enabled, 1);
+ break;
+ }
+
+ if (next == NULL) {
+ break;
+ }
+ sep = next + 1;
+ }
+
+ /* 4. missing credentials: empty peer_id, empty link_password, invalid port */
+ parse_one_peer(
+ "circuit_host=127.0.0.1;circuit_port=99999;link_id=main;enabled=yes",
+ &peer);
+ check_str("missing peer_id", peer.peer_id, "");
+ check_str("missing link_password", peer.link_password, "");
+ /* invalid port should fall back to default */
+ check_uint("invalid port default", peer.circuit_port,
+ (unsigned)HYBBX_CIRCUIT_DEFAULT_PORT);
+
+ /* 5. legacy host/port keys: host maps to circuit_host, port is ignored */
+ parse_one_peer(
+ "peer_id=legacy;circuit_host=127.0.0.1;host=127.0.0.9;port=9999;"
+ "link_id=main;link_password=secret;enabled=yes",
+ &peer);
+ check_str("legacy host ignored", peer.circuit_host, "127.0.0.1");
+ check_uint("legacy port ignored", peer.port, 9999u);
+
+ /* 6. empty section should yield defaults */
+ parse_one_peer("", &peer);
+ check_str("empty peer_id", peer.peer_id, "");
+ check_int("empty enabled default", peer.enabled, 1);
+ check_int("empty wire default", (int)peer.wire,
+ (int)HYBBX_MAINS_PROXY_WIRE_CIRCUIT);
+ check_int("empty duplex default", (int)peer.duplex,
+ (int)HYBBX_MAINS_PROXY_DUPLEX_FULL);
+ check_int("empty use_secondary default", peer.use_secondary, 1);
+
+
+ /* 7. mesh active: stopped mesh reports inactive */
+ hybbx_mains_proxy_mesh_stop(NULL);
+ check_int("mesh_active when stopped", hybbx_mains_proxy_mesh_active(), 0);
+
+ /* 8. peer parse leaves legacy host intact when circuit_host is set */
+ parse_one_peer(
+ "peer_id=explicit;circuit_host=127.0.0.1;host=127.0.0.9;"
+ "link_id=main;link_password=secret;enabled=yes",
+ &peer);
+ check_str("circuit_host wins over host", peer.circuit_host, "127.0.0.1");
+
+ if (g_failures != 0) {
+ fprintf(stderr, "%u failure(s)\n", g_failures);
+ return 1;
+ }
+
+ puts("test_mains_proxy: ok");
+ return 0;
+}
+
+/* Test kept separate above; this file ends after main() in the original. */
diff --git a/tests/test_mains_proxy_e2e.c b/tests/test_mains_proxy_e2e.c
new file mode 100644
index 0000000..bb93f0c
--- /dev/null
+++ b/tests/test_mains_proxy_e2e.c
@@ -0,0 +1,360 @@
+#include "hybbx/mains_proxy.h"
+#include "hybbx/circuit_tcp.h"
+#include "hybbx/util.h"
+
+#include <arpa/inet.h>
+#include <sys/socket.h>
+
+#include <errno.h>
+#include <fcntl.h>
+#include <poll.h>
+#include <signal.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <sys/types.h>
+#include <sys/wait.h>
+#include <unistd.h>
+#include <dirent.h>
+
+static unsigned g_failures;
+static pid_t g_pid_a = -1;
+static pid_t g_pid_b = -1;
+
+static void check_int(const char *label, int got, int want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %d want %d\n", label, got, want);
+ g_failures++;
+ }
+}
+
+static int write_file(const char *path, const char *text)
+{
+ FILE *fp = fopen(path, "w");
+ if (fp == NULL) {
+ return -1;
+ }
+ if (fputs(text, fp) == EOF) {
+ fclose(fp);
+ return -1;
+ }
+ fclose(fp);
+ return 0;
+}
+
+static int mkdir_p(const char *path)
+{
+ char tmp[512];
+ char *p;
+ size_t len;
+
+ if (path == NULL || path[0] == '\0') {
+ return -1;
+ }
+
+ hybbx_strlcpy(tmp, path, sizeof(tmp));
+ len = strlen(tmp);
+ if (tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+
+ for (p = tmp + 1; *p != '\0'; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ (void)mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ (void)mkdir(tmp, 0755);
+ return 0;
+}
+
+static int copy_file(const char *src, const char *dst)
+{
+ char cmd[1024];
+ snprintf(cmd, sizeof(cmd), "cp '%s' '%s'", src, dst);
+ return system(cmd);
+}
+
+static int copy_dir(const char *src, const char *dst)
+{
+ char cmd[1024];
+ snprintf(cmd, sizeof(cmd), "cp -r '%s' '%s'", src, dst);
+ return system(cmd);
+}
+
+static void write_main_ini(const char *root, const char *name,
+ unsigned circuit_port, unsigned peer_port,
+ const char *local_link_id,
+ const char *remote_link_id,
+ unsigned websocket_port)
+{
+ char path[512];
+ char text[2048];
+
+ snprintf(path, sizeof(path), "%s/data", root);
+ mkdir_p(path);
+ snprintf(path, sizeof(path), "%s/logs", root);
+ mkdir_p(path);
+ snprintf(path, sizeof(path), "%s/mail", root);
+ mkdir_p(path);
+ copy_file("/home/akb/Code2/10-PROJECTS/hyBBX/share/commands.yaml", root);
+ copy_file("/home/akb/Code2/10-PROJECTS/hyBBX/share/areas.yaml", root);
+ copy_dir("/home/akb/Code2/10-PROJECTS/hyBBX/text", root);
+
+ snprintf(path, sizeof(path), "%s/hybbx.ini", root);
+ snprintf(text, sizeof(text),
+ "[service]\n"
+ "name = %s\n"
+ "login_announce = no\n"
+ "\n"
+ "[storage]\n"
+ "backend = flatfile\n"
+ "path = data\n"
+ "\n"
+ "[auth]\n"
+ "auto_login = yes\n"
+ "\n"
+ "[log]\n"
+ "enabled = yes\n"
+ "dir = logs\n"
+ "level = info\n"
+ "\n"
+ "[security]\n"
+ "enabled = yes\n"
+ "telnet = no\n"
+ "ssh = no\n"
+ "websocket = no\n"
+ "circuit = yes\n"
+ "\n"
+ "[mail]\n"
+ "enabled = yes\n"
+ "path = mail\n"
+ "\n"
+ "[networks]\n"
+ "telnet = no\n"
+ "ssh = no\n"
+ "websocket = no\n"
+ "ax25 = no\n"
+ "baycom = no\n"
+ "tmodem = no\n"
+ "ardop = no\n"
+ "crdop = no\n"
+ "circuit = yes\n"
+ "mains_proxy = yes\n"
+ "\n"
+ "[circuit]\n"
+ "enabled = yes\n"
+ "bind = 127.0.0.1\n"
+ "port = %u\n"
+ "link_auth = yes\n"
+ "link_password = secret\n"
+ "max_links = 8\n"
+ "balance = yes\n"
+ "\n"
+ "[transport.websocket]\n"
+ "bind = 127.0.0.1\n"
+ "port = %u\n"
+ "\n"
+ "[transport.mains_proxy1]\n"
+ "peer_id = peer\n"
+ "circuit_host = 127.0.0.1\n"
+ "circuit_port = %u\n"
+ "link_id = %s\n"
+ "link_password = secret\n"
+ "wire = circuit\n"
+ "duplex = full\n"
+ "\n"
+ "[transport.mains_proxy2]\n"
+ "peer_id = peer\n"
+ "link_id = %s\n"
+ "link_password = secret\n",
+ name, circuit_port, websocket_port, peer_port,
+ local_link_id, remote_link_id);
+
+ if (write_file(path, text) != 0) {
+ fprintf(stderr, "FAIL cannot write %s\n", path);
+ g_failures++;
+ }
+}
+
+static pid_t start_hybbxd(const char *binary, const char *root)
+{
+ pid_t pid = fork();
+ if (pid < 0) {
+ return -1;
+ }
+ if (pid == 0) {
+ char config_path[512];
+ int null_fd = open("/dev/null", O_RDONLY);
+ int log_fd;
+ char log_path[512];
+
+ snprintf(log_path, sizeof(log_path), "%s/logs/test-stderr.log", root);
+ log_fd = open(log_path, O_WRONLY | O_CREAT | O_TRUNC, 0644);
+
+ setsid();
+ if (null_fd >= 0) {
+ dup2(null_fd, STDIN_FILENO);
+ close(null_fd);
+ }
+ if (log_fd >= 0) {
+ dup2(log_fd, STDOUT_FILENO);
+ dup2(log_fd, STDERR_FILENO);
+ close(log_fd);
+ }
+
+ snprintf(config_path, sizeof(config_path), "%s/hybbx.ini", root);
+ setenv("HYBBX_ROOT", root, 1);
+ execl(binary, binary, "-c", config_path, "-f", (char *)NULL);
+ _exit(127);
+ }
+ return pid;
+}
+
+static void stop_process(pid_t pid)
+{
+ if (pid > 0) {
+ kill(pid, SIGTERM);
+ waitpid(pid, NULL, 0);
+ }
+}
+
+static void cleanup_dirs(const char *a, const char *b)
+{
+ char cmd[1024];
+ snprintf(cmd, sizeof(cmd), "rm -rf '%s' '%s'", a, b);
+ (void)system(cmd);
+}
+
+static int log_contains(const char *root, const char *needle)
+{
+ char path[512];
+ char buf[4096];
+ FILE *fp;
+ size_t n;
+
+ snprintf(path, sizeof(path), "%s/logs/test-stderr.log", root);
+ fp = fopen(path, "r");
+ if (fp == NULL) {
+ return 0;
+ }
+
+ while ((n = fread(buf, 1, sizeof(buf) - 1, fp)) > 0) {
+ buf[n] = '\0';
+ if (strstr(buf, needle) != NULL) {
+ fclose(fp);
+ return 1;
+ }
+ }
+ fclose(fp);
+ return 0;
+}
+
+static int wait_for_port(unsigned port, unsigned timeout_ms)
+{
+ unsigned elapsed = 0;
+ int fd;
+ struct sockaddr_in addr;
+
+ while (elapsed < timeout_ms) {
+ fd = socket(AF_INET, SOCK_STREAM, 0);
+ if (fd < 0) {
+ return -1;
+ }
+ memset(&addr, 0, sizeof(addr));
+ addr.sin_family = AF_INET;
+ addr.sin_port = htons((uint16_t)port);
+ inet_pton(AF_INET, "127.0.0.1", &addr.sin_addr);
+
+ if (connect(fd, (struct sockaddr *)&addr, sizeof(addr)) == 0) {
+ close(fd);
+ return 0;
+ }
+ close(fd);
+ usleep(100000);
+ elapsed += 100;
+ }
+ return -1;
+}
+
+int main(int argc, char **argv)
+{
+ const char *binary = (argc > 1) ? argv[1] : "/tmp/hybbx-p01-build/src/hybbxd";
+ char root_a[256];
+ char root_b[256];
+ int active;
+ unsigned elapsed;
+
+ snprintf(root_a, sizeof(root_a), "/tmp/hybbx-e2e-a-%d", (int)getpid());
+ snprintf(root_b, sizeof(root_b), "/tmp/hybbx-e2e-b-%d", (int)getpid());
+
+ cleanup_dirs(root_a, root_b);
+
+ /* Main A connects outbound as main-a; Main B accepts id=main-a. */
+ write_main_ini(root_a, "main-a", 17323, 17324, "main-a", "main-b", 14591);
+ /* Main B connects outbound as main-b; Main A accepts id=main-b. */
+ write_main_ini(root_b, "main-b", 17324, 17323, "main-b", "main-a", 14592);
+
+ g_pid_a = start_hybbxd(binary, root_a);
+ g_pid_b = start_hybbxd(binary, root_b);
+
+ check_int("start main-a", g_pid_a > 0, 1);
+ check_int("start main-b", g_pid_b > 0, 1);
+
+ if (g_pid_a <= 0 || g_pid_b <= 0) {
+ stop_process(g_pid_b);
+ cleanup_dirs(root_a, root_b);
+ return 1;
+ }
+
+ /* Wait for both circuit hubs to accept connections. */
+ check_int("port a ready", wait_for_port(17323, 5000), 0);
+ check_int("port b ready", wait_for_port(17324, 5000), 0);
+
+ /* Wait for mains_proxy to establish outbound links on each Main. */
+ {
+ int a_linked = 0;
+ int b_linked = 0;
+ elapsed = 0;
+ while (elapsed < 10000) {
+ a_linked = log_contains(root_a, "[mains_proxy] linked to peer");
+ b_linked = log_contains(root_b, "[mains_proxy] linked to peer");
+ if (a_linked && b_linked) {
+ break;
+ }
+ usleep(100000);
+ elapsed += 100;
+ }
+ check_int("main-a linked via mains_proxy", a_linked, 1);
+ check_int("main-b linked via mains_proxy", b_linked, 1);
+ }
+
+ /* P0.2: verify both Mains accepted the peer link via HBX auth. */
+ {
+ int a_has_main_b = log_contains(root_a, "link authenticated id=main-b");
+ int b_has_main_a = log_contains(root_b, "link authenticated id=main-a");
+ check_int("main-a sees main-b authenticated", a_has_main_b, 1);
+ check_int("main-b sees main-a authenticated", b_has_main_a, 1);
+ }
+
+
+ stop_process(g_pid_a);
+ stop_process(g_pid_b);
+
+ if (g_failures == 0) {
+ cleanup_dirs(root_a, root_b);
+ } else {
+ fprintf(stderr, " left dirs: %s %s\n", root_a, root_b);
+ }
+
+ if (g_failures != 0) {
+ fprintf(stderr, "%u failure(s)\n", g_failures);
+ return 1;
+ }
+
+ puts("test_mains_proxy_e2e: ok");
+ return 0;
+}
diff --git a/tests/test_storage_smoke.c b/tests/test_storage_smoke.c
new file mode 100644
index 0000000..77fd3cf
--- /dev/null
+++ b/tests/test_storage_smoke.c
@@ -0,0 +1,235 @@
+/*
+ * test_storage_smoke.c — P0.4: flatfile + SQLite core smoke
+ *
+ * Tests: open/close, register_user, find_user, resolve_user,
+ * count_level, update_user, delete_user, session_begin/end.
+ * Runs against both flatfile and SQLite backends in temporary directories.
+ */
+#include "hybbx/storage.h"
+#include "hybbx/auth.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <sys/stat.h>
+#include <unistd.h>
+
+static unsigned g_failures;
+
+static void check_rc(const char *label, hybbx_result_t got, hybbx_result_t want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %d want %d\n", label, (int)got, (int)want);
+ g_failures++;
+ }
+}
+
+static void check_str(const char *label, const char *got, const char *want)
+{
+ if (got == NULL || want == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s: got '%s' want '%s'\n",
+ label, got != NULL ? got : "(null)", want);
+ g_failures++;
+ }
+}
+
+static void check_uint64(const char *label, uint64_t got, uint64_t want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %llu want %llu\n",
+ label, (unsigned long long)got, (unsigned long long)want);
+ g_failures++;
+ }
+}
+
+static void check_nonzero(const char *label, uint64_t val)
+{
+ if (val == 0) {
+ fprintf(stderr, "FAIL %s: expected non-zero\n", label);
+ g_failures++;
+ }
+}
+
+static void mkdir_p(const char *path)
+{
+ char tmp[512];
+ char *p;
+ size_t len;
+
+ hybbx_strlcpy(tmp, path, sizeof(tmp));
+ len = strlen(tmp);
+ if (len > 0 && tmp[len - 1] == '/') {
+ tmp[len - 1] = '\0';
+ }
+ for (p = tmp + 1; *p; p++) {
+ if (*p == '/') {
+ *p = '\0';
+ mkdir(tmp, 0755);
+ *p = '/';
+ }
+ }
+ mkdir(tmp, 0755);
+}
+
+static void rm_rf(const char *path)
+{
+ char cmd[512];
+ snprintf(cmd, sizeof(cmd), "rm -rf '%s'", path);
+ (void)system(cmd);
+}
+
+static hybbx_storage_t *open_backend(hybbx_storage_backend_kind_t backend,
+ const char *path)
+{
+ hybbx_storage_options_t opts;
+
+ memset(&opts, 0, sizeof(opts));
+ opts.backend = backend;
+ opts.path = path;
+ return hybbx_storage_open(&opts);
+}
+
+/*
+ * Run the full CRUD + session smoke against one backend.
+ */
+static void smoke_backend(hybbx_storage_backend_kind_t backend,
+ const char *label,
+ const char *path)
+{
+ hybbx_storage_t *st;
+ hybbx_user_registration_t reg;
+ hybbx_user_record_t user;
+ hybbx_user_record_t found;
+ hybbx_session_record_t sess;
+ size_t count;
+ char test_user[64];
+
+ snprintf(test_user, sizeof(test_user), "smk_%s", label);
+
+ rm_rf(path);
+ mkdir_p(path);
+
+ st = open_backend(backend, path);
+ if (st == NULL) {
+ fprintf(stderr, "FAIL %s: storage_open returned NULL\n", label);
+ g_failures++;
+ return;
+ }
+
+ check_uint64("backend kind",
+ (uint64_t)hybbx_storage_backend(st), (uint64_t)backend);
+
+ /* register_user — level defaults to HYBBX_LEVEL_USER */
+ memset(&reg, 0, sizeof(reg));
+ hybbx_strlcpy(reg.username, test_user, sizeof(reg.username));
+ hybbx_strlcpy(reg.password, "testpass123", sizeof(reg.password));
+ hybbx_strlcpy(reg.nickname, test_user, sizeof(reg.nickname));
+ hybbx_strlcpy(reg.full_name, "Smoke Test", sizeof(reg.full_name));
+ hybbx_strlcpy(reg.country, "DE", sizeof(reg.country));
+ hybbx_strlcpy(reg.location, "localhost", sizeof(reg.location));
+ hybbx_strlcpy(reg.email, "smoke@test.local", sizeof(reg.email));
+
+ check_rc("register_user",
+ hybbx_storage_register_user(st, &reg, &user), HYBBX_OK);
+ check_nonzero("user id", user.id);
+ check_str("user username", user.username, test_user);
+ check_uint64("user level", (uint64_t)user.level,
+ (uint64_t)HYBBX_LEVEL_USER);
+
+ /* find_user */
+ memset(&found, 0, sizeof(found));
+ check_rc("find_user",
+ hybbx_storage_find_user(st, test_user, &found), HYBBX_OK);
+ check_uint64("found id matches", found.id, user.id);
+ check_str("found username", found.username, test_user);
+
+ /* find_user nonexistent */
+ check_rc("find_user missing",
+ hybbx_storage_find_user(st, "no_such_user", &found),
+ HYBBX_ERR_NOT_FOUND);
+
+ /* resolve_user (by name) */
+ memset(&found, 0, sizeof(found));
+ check_rc("resolve_user",
+ hybbx_storage_resolve_user(st, test_user, &found), HYBBX_OK);
+ check_uint64("resolve id matches", found.id, user.id);
+
+ /* count_level */
+ count = 0;
+ check_rc("count_level",
+ hybbx_storage_count_level(st, HYBBX_LEVEL_USER, &count),
+ HYBBX_OK);
+ /* count >= 1 (our user) */
+
+ /* update_user */
+ user.level = HYBBX_LEVEL_SYSOP;
+ check_rc("update_user",
+ hybbx_storage_update_user(st, &user), HYBBX_OK);
+
+ memset(&found, 0, sizeof(found));
+ check_rc("find after update",
+ hybbx_storage_find_user(st, test_user, &found), HYBBX_OK);
+ check_uint64("updated level", (uint64_t)found.level,
+ (uint64_t)HYBBX_LEVEL_SYSOP);
+
+ /* session_begin */
+ memset(&sess, 0, sizeof(sess));
+ check_rc("session_begin",
+ hybbx_storage_session_begin(st, &user, "test", &sess), HYBBX_OK);
+ check_nonzero("session id", sess.session_id);
+
+ /* session_end */
+ check_rc("session_end",
+ hybbx_storage_session_end(st, sess.session_id), HYBBX_OK);
+
+ /* delete_user */
+ check_rc("delete_user",
+ hybbx_storage_delete_user(st, user.id), HYBBX_OK);
+
+ /* find after delete */
+ check_rc("find after delete",
+ hybbx_storage_find_user(st, test_user, &found),
+ HYBBX_ERR_NOT_FOUND);
+
+ /* Persistence: close and reopen, verify functional */
+ hybbx_storage_close(st);
+ st = open_backend(backend, path);
+ if (st == NULL) {
+ fprintf(stderr, "FAIL %s: reopen returned NULL\n", label);
+ g_failures++;
+ return;
+ }
+
+ /* Re-register after reopen to verify backend is functional */
+ check_rc("re-register after reopen",
+ hybbx_storage_register_user(st, &reg, &user), HYBBX_OK);
+ check_nonzero("re-registered id", user.id);
+
+ hybbx_storage_delete_user(st, user.id);
+ hybbx_storage_close(st);
+
+ rm_rf(path);
+}
+
+int main(void)
+{
+ char path_ff[256];
+ char path_sql[256];
+
+ snprintf(path_ff, sizeof(path_ff), "/tmp/hybbx-smoke-flatfile-%d",
+ (int)getpid());
+ snprintf(path_sql, sizeof(path_sql), "/tmp/hybbx-smoke-sqlite-%d",
+ (int)getpid());
+
+ smoke_backend(HYBBX_STORAGE_FLATFILE, "flatfile", path_ff);
+ smoke_backend(HYBBX_STORAGE_SQLITE, "sqlite", path_sql);
+
+ if (g_failures != 0) {
+ fprintf(stderr, "%u failure(s)\n", g_failures);
+ return 1;
+ }
+
+ puts("test_storage_smoke: ok");
+ return 0;
+}
diff --git a/tests/test_util.c b/tests/test_util.c
new file mode 100644
index 0000000..cbd4e1b
--- /dev/null
+++ b/tests/test_util.c
@@ -0,0 +1,152 @@
+#include "hybbx/limits.h"
+#include "hybbx/log.h"
+#include "hybbx/util.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+
+static unsigned g_failures;
+
+static void check_int(const char *label, int got, int want)
+{
+ if (got != want) {
+ fprintf(stderr, "FAIL %s: got %d want %d\n", label, got, want);
+ g_failures++;
+ }
+}
+
+static void check_str(const char *label, const char *got, const char *want)
+{
+ if (got == NULL || want == NULL || strcmp(got, want) != 0) {
+ fprintf(stderr, "FAIL %s: got '%s' want '%s'\n",
+ label, got != NULL ? got : "(null)", want);
+ g_failures++;
+ }
+}
+
+int main(void)
+{
+ check_int("true yes", hybbx_bool_is_true("yes"), 1);
+ check_int("true enable", hybbx_bool_is_true("enable"), 1);
+ check_int("false no", hybbx_bool_is_false("no"), 1);
+ check_int("false disable", hybbx_bool_is_false("disable"), 1);
+
+ check_int("parse yes", hybbx_parse_bool("yes", 0), 1);
+ check_int("parse false", hybbx_parse_bool("false", 1), 0);
+ check_int("parse empty", hybbx_parse_bool("", 1), 1);
+ check_int("parse garbage", hybbx_parse_bool("maybe", 0), 0);
+
+ check_str("bool yes", hybbx_bool_to_string(1), HYBBX_BOOL_YES);
+ check_str("bool no", hybbx_bool_to_string(0), HYBBX_BOOL_NO);
+
+ check_str("result ok", hybbx_result_name(HYBBX_OK), "ok");
+ check_str("result invalid", hybbx_result_name(HYBBX_ERR_INVALID), "invalid");
+ check_str("result unknown", hybbx_result_name((hybbx_result_t)-99), "unknown");
+
+ check_int("log parse debug", (int)hybbx_log_parse_level("debug"),
+ (int)HYBBX_LOG_DEBUG);
+ check_int("log parse stats", (int)hybbx_log_parse_level("stats"),
+ (int)HYBBX_LOG_STATS);
+ check_int("log parse info", (int)hybbx_log_parse_level("info"),
+ (int)HYBBX_LOG_INFO);
+ check_int("log parse warn", (int)hybbx_log_parse_level("warn"),
+ (int)HYBBX_LOG_WARN);
+ check_int("log parse unknown", (int)hybbx_log_parse_level("verbose"),
+ (int)HYBBX_LOG_WARN);
+ check_str("log name stats", hybbx_log_level_name(HYBBX_LOG_STATS), "stats");
+
+ {
+ char path[HYBBX_PATH_MAX];
+ const char *home = getenv("HOME");
+
+ if (hybbx_path_expand(path, sizeof(path), NULL) == HYBBX_OK) {
+ check_str("path expand null", path, HYBBX_DIR_DATA);
+ }
+ if (home != NULL && home[0] != '\0') {
+ char want[HYBBX_PATH_MAX];
+
+ snprintf(want, sizeof(want), "%s/custom", home);
+ if (hybbx_path_expand(path, sizeof(path), "~/custom") == HYBBX_OK) {
+ check_str("path expand tilde", path, want);
+ }
+ if (hybbx_path_expand(path, sizeof(path), "~") == HYBBX_OK) {
+ check_str("path expand home only", path, home);
+ }
+ }
+ }
+
+ {
+ char path[HYBBX_PATH_MAX];
+ char want[HYBBX_PATH_MAX];
+
+ hybbx_install_root_set("/opt/hybbx");
+ if (hybbx_path_resolve(path, sizeof(path), HYBBX_DIR_DATA) == HYBBX_OK) {
+ snprintf(want, sizeof(want), "/opt/hybbx/%s", HYBBX_DIR_DATA);
+ check_str("path resolve data", path, want);
+ }
+ if (hybbx_path_resolve(path, sizeof(path), HYBBX_DIR_LOGS) == HYBBX_OK) {
+ snprintf(want, sizeof(want), "/opt/hybbx/%s", HYBBX_DIR_LOGS);
+ check_str("path resolve logs", path, want);
+ }
+ hybbx_install_root_set(NULL);
+ }
+
+ {
+ char os_name[HYBBX_PATH_MAX];
+
+ if (hybbx_platform_os_name(os_name, sizeof(os_name)) != HYBBX_OK) {
+ fprintf(stderr, "FAIL platform os name: lookup failed\n");
+ g_failures++;
+ } else if (os_name[0] == '\0') {
+ fprintf(stderr, "FAIL platform os name: empty\n");
+ g_failures++;
+#if defined(__linux__)
+ } else if (strcmp(os_name, "Linux") != 0) {
+ fprintf(stderr, "FAIL platform os name: got '%s' want 'Linux'\n",
+ os_name);
+ g_failures++;
+#endif
+ }
+ }
+
+ {
+ struct tm tm_ref = {0};
+ char time_buf[32];
+ char date_buf[32];
+ hybbx_time_format_t fmt;
+
+ tm_ref.tm_year = 126;
+ tm_ref.tm_mon = 6;
+ tm_ref.tm_mday = 8;
+ tm_ref.tm_hour = 15;
+ tm_ref.tm_min = 4;
+ tm_ref.tm_sec = 5;
+
+ hybbx_time_format_defaults(&fmt);
+ if (hybbx_time_format_time(time_buf, sizeof(time_buf), &tm_ref,
+ &fmt) == HYBBX_OK) {
+ check_str("time 24h seconds", time_buf, "15:04:05");
+ }
+
+ fmt.date_format = HYBBX_DATE_US;
+ if (hybbx_time_format_date(date_buf, sizeof(date_buf), &tm_ref,
+ &fmt) == HYBBX_OK) {
+ check_str("date us", date_buf, "07/08/2026");
+ }
+
+ fmt.date_format = HYBBX_DATE_ISO;
+ if (hybbx_time_format_date(date_buf, sizeof(date_buf), &tm_ref,
+ &fmt) == HYBBX_OK) {
+ check_str("date iso", date_buf, "2026/07/08");
+ }
+ }
+
+ if (g_failures != 0) {
+ fprintf(stderr, "%u test(s) failed\n", g_failures);
+ return EXIT_FAILURE;
+ }
+
+ puts("test_util: ok");
+ return EXIT_SUCCESS;
+}
diff --git a/text/banner.txt b/text/banner.txt
new file mode 100644
index 0000000..d86bc15
--- /dev/null
+++ b/text/banner.txt
@@ -0,0 +1,4 @@
+@service@
+@version@ %date% %time%
+
+/help /login /register
diff --git a/text/motd.txt b/text/motd.txt
new file mode 100644
index 0000000..7e1151c
--- /dev/null
+++ b/text/motd.txt
@@ -0,0 +1,5 @@
+Hello, @username@.
+
+%date% %time%
+
+/news /who /msg
diff --git a/text/news.txt b/text/news.txt
new file mode 100644
index 0000000..9823220
--- /dev/null
+++ b/text/news.txt
@@ -0,0 +1 @@
+HyBBX 2.4.1 — Menu/index: General command list wraps at 80 columns (/session /version visible). BayCom: optional transport plugin (CMake ON; example INI `baycom=no`). MAX25 attach: `kiss_entry=none`, `[max25] check=yes`. Includes v2.0.0-p2 baseline (menu registry, TNC recovery, broadcast fixes).
diff --git a/text/rules.txt b/text/rules.txt
new file mode 100644
index 0000000..837efbc
--- /dev/null
+++ b/text/rules.txt
@@ -0,0 +1,50 @@
+HyBBX — Terms of use (v2.4.0)
+
+HyBBX is a session service for low-bandwidth links: mail, chat,
+conference, and commands over telnet, SSH, or WebSocket. RF paths
+(packet radio, ARDOP, and similar) connect via HBX circuits; on-air use
+depends on your site and licence.
+
+1. Your responsibility
+Access is at your own risk. You answer for what you send, post, and do
+on this system and on the air. The operator is not responsible for how
+users or link partners behave.
+
+2. Identity
+Real names are not required. A nickname or amateur callsign is enough.
+For RF and circuit links, your CALL or link identity (CALLID) must be
+truthful where your licence or local rules require it. Account data used
+for administration may be seen by Sysop or Admin only.
+
+3. Data
+User data is not sold. Mail and accounts remain on this host unless
+disclosure is required by law.
+
+4. Logging and security
+Sessions and security events may be recorded (for example in
+security.log) to run the service, fix faults, and limit abuse.
+
+HyBBX may temporarily block an IP address, a callsign, or a circuit
+link identity (CALLID) after repeated login failures, link-auth
+failures, connection flooding, or other clear abuse. Blocks are short
+cool-downs by default, not permanent bans. Normal chat and mail traffic
+is only paced or size-limited — busy use on a slow link is expected and
+does not by itself trigger a block.
+
+5. Spam and traffic limits
+Message length, mail quotas, output pacing, and similar limits keep the
+system usable on slow links. They are not punishment. Only repeated,
+excessive spam or misuse may lead to a security block as in section 4.
+
+6. Acceptable use
+Do not break the law. Do not engage in non-social behaviour: threats,
+harassment, hate, or deliberate disruption of others. Do not attempt
+to break into accounts, flood logins, or damage the system or its users.
+Unlawful content or conduct may be reported to authorities where required.
+
+Sysop, Admin, and automated security may limit, block, or revoke access without
+prior notice when abuse is evident.
+
+7. Changes
+The operator may revise this notice in text/rules.txt. Read the current
+text anytime with /rules or /legal.
diff --git a/text/version.txt b/text/version.txt
new file mode 100644
index 0000000..09a2c98
--- /dev/null
+++ b/text/version.txt
@@ -0,0 +1,2 @@
+HyBBX @version@
+Operating system: @os@
diff --git a/third_party/monocypher/monocypher.c b/third_party/monocypher/monocypher.c
new file mode 100644
index 0000000..6e323a8
--- /dev/null
+++ b/third_party/monocypher/monocypher.c
@@ -0,0 +1,2956 @@
+// Monocypher version __git__
+//
+// This file is dual-licensed. Choose whichever licence you want from
+// the two licences listed below.
+//
+// The first licence is a regular 2-clause BSD licence. The second licence
+// is the CC-0 from Creative Commons. It is intended to release Monocypher
+// to the public domain. The BSD licence serves as a fallback option.
+//
+// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0
+//
+// ------------------------------------------------------------------------
+//
+// Copyright (c) 2017-2020, Loup Vaillant
+// All rights reserved.
+//
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// 1. Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the
+// distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+//
+// ------------------------------------------------------------------------
+//
+// Written in 2017-2020 by Loup Vaillant
+//
+// To the extent possible under law, the author(s) have dedicated all copyright
+// and related neighboring rights to this software to the public domain
+// worldwide. This software is distributed without any warranty.
+//
+// You should have received a copy of the CC0 Public Domain Dedication along
+// with this software. If not, see
+// <https://creativecommons.org/publicdomain/zero/1.0/>
+
+#include "monocypher.h"
+
+#ifdef MONOCYPHER_CPP_NAMESPACE
+namespace MONOCYPHER_CPP_NAMESPACE {
+#endif
+
+/////////////////
+/// Utilities ///
+/////////////////
+#define FOR_T(type, i, start, end) for (type i = (start); i < (end); i++)
+#define FOR(i, start, end) FOR_T(size_t, i, start, end)
+#define COPY(dst, src, size) FOR(_i_, 0, size) (dst)[_i_] = (src)[_i_]
+#define ZERO(buf, size) FOR(_i_, 0, size) (buf)[_i_] = 0
+#define WIPE_CTX(ctx) crypto_wipe(ctx , sizeof(*(ctx)))
+#define WIPE_BUFFER(buffer) crypto_wipe(buffer, sizeof(buffer))
+#define MIN(a, b) ((a) <= (b) ? (a) : (b))
+#define MAX(a, b) ((a) >= (b) ? (a) : (b))
+
+typedef int8_t i8;
+typedef uint8_t u8;
+typedef int16_t i16;
+typedef uint32_t u32;
+typedef int32_t i32;
+typedef int64_t i64;
+typedef uint64_t u64;
+
+static const u8 zero[128] = {0};
+
+// returns the smallest positive integer y such that
+// (x + y) % pow_2 == 0
+// Basically, y is the "gap" missing to align x.
+// Only works when pow_2 is a power of 2.
+// Note: we use ~x+1 instead of -x to avoid compiler warnings
+static size_t gap(size_t x, size_t pow_2)
+{
+ return (~x + 1) & (pow_2 - 1);
+}
+
+static u32 load24_le(const u8 s[3])
+{
+ return
+ ((u32)s[0] << 0) |
+ ((u32)s[1] << 8) |
+ ((u32)s[2] << 16);
+}
+
+static u32 load32_le(const u8 s[4])
+{
+ return
+ ((u32)s[0] << 0) |
+ ((u32)s[1] << 8) |
+ ((u32)s[2] << 16) |
+ ((u32)s[3] << 24);
+}
+
+static u64 load64_le(const u8 s[8])
+{
+ return load32_le(s) | ((u64)load32_le(s+4) << 32);
+}
+
+static void store32_le(u8 out[4], u32 in)
+{
+ out[0] = in & 0xff;
+ out[1] = (in >> 8) & 0xff;
+ out[2] = (in >> 16) & 0xff;
+ out[3] = (in >> 24) & 0xff;
+}
+
+static void store64_le(u8 out[8], u64 in)
+{
+ store32_le(out , (u32)in );
+ store32_le(out + 4, in >> 32);
+}
+
+static void load32_le_buf (u32 *dst, const u8 *src, size_t size) {
+ FOR(i, 0, size) { dst[i] = load32_le(src + i*4); }
+}
+static void load64_le_buf (u64 *dst, const u8 *src, size_t size) {
+ FOR(i, 0, size) { dst[i] = load64_le(src + i*8); }
+}
+static void store32_le_buf(u8 *dst, const u32 *src, size_t size) {
+ FOR(i, 0, size) { store32_le(dst + i*4, src[i]); }
+}
+static void store64_le_buf(u8 *dst, const u64 *src, size_t size) {
+ FOR(i, 0, size) { store64_le(dst + i*8, src[i]); }
+}
+
+static u64 rotr64(u64 x, u64 n) { return (x >> n) ^ (x << (64 - n)); }
+static u32 rotl32(u32 x, u32 n) { return (x << n) ^ (x >> (32 - n)); }
+
+static int neq0(u64 diff)
+{
+ // constant time comparison to zero
+ // return diff != 0 ? -1 : 0
+ u64 half = (diff >> 32) | ((u32)diff);
+ return (1 & ((half - 1) >> 32)) - 1;
+}
+
+static u64 x16(const u8 a[16], const u8 b[16])
+{
+ return (load64_le(a + 0) ^ load64_le(b + 0))
+ | (load64_le(a + 8) ^ load64_le(b + 8));
+}
+static u64 x32(const u8 a[32],const u8 b[32]){return x16(a,b)| x16(a+16, b+16);}
+static u64 x64(const u8 a[64],const u8 b[64]){return x32(a,b)| x32(a+32, b+32);}
+int crypto_verify16(const u8 a[16], const u8 b[16]){ return neq0(x16(a, b)); }
+int crypto_verify32(const u8 a[32], const u8 b[32]){ return neq0(x32(a, b)); }
+int crypto_verify64(const u8 a[64], const u8 b[64]){ return neq0(x64(a, b)); }
+
+void crypto_wipe(void *secret, size_t size)
+{
+ volatile u8 *v_secret = (u8*)secret;
+ ZERO(v_secret, size);
+}
+
+/////////////////
+/// Chacha 20 ///
+/////////////////
+#define QUARTERROUND(a, b, c, d) \
+ a += b; d = rotl32(d ^ a, 16); \
+ c += d; b = rotl32(b ^ c, 12); \
+ a += b; d = rotl32(d ^ a, 8); \
+ c += d; b = rotl32(b ^ c, 7)
+
+static void chacha20_rounds(u32 out[16], const u32 in[16])
+{
+ // The temporary variables make Chacha20 10% faster.
+ u32 t0 = in[ 0]; u32 t1 = in[ 1]; u32 t2 = in[ 2]; u32 t3 = in[ 3];
+ u32 t4 = in[ 4]; u32 t5 = in[ 5]; u32 t6 = in[ 6]; u32 t7 = in[ 7];
+ u32 t8 = in[ 8]; u32 t9 = in[ 9]; u32 t10 = in[10]; u32 t11 = in[11];
+ u32 t12 = in[12]; u32 t13 = in[13]; u32 t14 = in[14]; u32 t15 = in[15];
+
+ FOR (i, 0, 10) { // 20 rounds, 2 rounds per loop.
+ QUARTERROUND(t0, t4, t8 , t12); // column 0
+ QUARTERROUND(t1, t5, t9 , t13); // column 1
+ QUARTERROUND(t2, t6, t10, t14); // column 2
+ QUARTERROUND(t3, t7, t11, t15); // column 3
+ QUARTERROUND(t0, t5, t10, t15); // diagonal 0
+ QUARTERROUND(t1, t6, t11, t12); // diagonal 1
+ QUARTERROUND(t2, t7, t8 , t13); // diagonal 2
+ QUARTERROUND(t3, t4, t9 , t14); // diagonal 3
+ }
+ out[ 0] = t0; out[ 1] = t1; out[ 2] = t2; out[ 3] = t3;
+ out[ 4] = t4; out[ 5] = t5; out[ 6] = t6; out[ 7] = t7;
+ out[ 8] = t8; out[ 9] = t9; out[10] = t10; out[11] = t11;
+ out[12] = t12; out[13] = t13; out[14] = t14; out[15] = t15;
+}
+
+static const u8 *chacha20_constant = (const u8*)"expand 32-byte k"; // 16 bytes
+
+void crypto_chacha20_h(u8 out[32], const u8 key[32], const u8 in [16])
+{
+ u32 block[16];
+ load32_le_buf(block , chacha20_constant, 4);
+ load32_le_buf(block + 4, key , 8);
+ load32_le_buf(block + 12, in , 4);
+
+ chacha20_rounds(block, block);
+
+ // prevent reversal of the rounds by revealing only half of the buffer.
+ store32_le_buf(out , block , 4); // constant
+ store32_le_buf(out+16, block+12, 4); // counter and nonce
+ WIPE_BUFFER(block);
+}
+
+u64 crypto_chacha20_djb(u8 *cipher_text, const u8 *plain_text,
+ size_t text_size, const u8 key[32], const u8 nonce[8],
+ u64 ctr)
+{
+ u32 input[16];
+ load32_le_buf(input , chacha20_constant, 4);
+ load32_le_buf(input + 4, key , 8);
+ load32_le_buf(input + 14, nonce , 2);
+ input[12] = (u32) ctr;
+ input[13] = (u32)(ctr >> 32);
+
+ // Whole blocks
+ u32 pool[16];
+ size_t nb_blocks = text_size >> 6;
+ FOR (i, 0, nb_blocks) {
+ chacha20_rounds(pool, input);
+ if (plain_text != 0) {
+ FOR (j, 0, 16) {
+ u32 p = pool[j] + input[j];
+ store32_le(cipher_text, p ^ load32_le(plain_text));
+ cipher_text += 4;
+ plain_text += 4;
+ }
+ } else {
+ FOR (j, 0, 16) {
+ u32 p = pool[j] + input[j];
+ store32_le(cipher_text, p);
+ cipher_text += 4;
+ }
+ }
+ input[12]++;
+ if (input[12] == 0) {
+ input[13]++;
+ }
+ }
+ text_size &= 63;
+
+ // Last (incomplete) block
+ if (text_size > 0) {
+ if (plain_text == 0) {
+ plain_text = zero;
+ }
+ chacha20_rounds(pool, input);
+ u8 tmp[64];
+ FOR (i, 0, 16) {
+ store32_le(tmp + i*4, pool[i] + input[i]);
+ }
+ FOR (i, 0, text_size) {
+ cipher_text[i] = tmp[i] ^ plain_text[i];
+ }
+ WIPE_BUFFER(tmp);
+ }
+ ctr = input[12] + ((u64)input[13] << 32) + (text_size > 0);
+
+ WIPE_BUFFER(pool);
+ WIPE_BUFFER(input);
+ return ctr;
+}
+
+u32 crypto_chacha20_ietf(u8 *cipher_text, const u8 *plain_text,
+ size_t text_size,
+ const u8 key[32], const u8 nonce[12], u32 ctr)
+{
+ u64 big_ctr = ctr + ((u64)load32_le(nonce) << 32);
+ return (u32)crypto_chacha20_djb(cipher_text, plain_text, text_size,
+ key, nonce + 4, big_ctr);
+}
+
+u64 crypto_chacha20_x(u8 *cipher_text, const u8 *plain_text,
+ size_t text_size,
+ const u8 key[32], const u8 nonce[24], u64 ctr)
+{
+ u8 sub_key[32];
+ crypto_chacha20_h(sub_key, key, nonce);
+ ctr = crypto_chacha20_djb(cipher_text, plain_text, text_size,
+ sub_key, nonce + 16, ctr);
+ WIPE_BUFFER(sub_key);
+ return ctr;
+}
+
+/////////////////
+/// Poly 1305 ///
+/////////////////
+
+// h = (h + c) * r
+// preconditions:
+// ctx->h <= 4_ffffffff_ffffffff_ffffffff_ffffffff
+// ctx->r <= 0ffffffc_0ffffffc_0ffffffc_0fffffff
+// end <= 1
+// Postcondition:
+// ctx->h <= 4_ffffffff_ffffffff_ffffffff_ffffffff
+static void poly_blocks(crypto_poly1305_ctx *ctx, const u8 *in,
+ size_t nb_blocks, unsigned end)
+{
+ // Local all the things!
+ const u32 r0 = ctx->r[0];
+ const u32 r1 = ctx->r[1];
+ const u32 r2 = ctx->r[2];
+ const u32 r3 = ctx->r[3];
+ const u32 rr0 = (r0 >> 2) * 5; // lose 2 bits...
+ const u32 rr1 = (r1 >> 2) + r1; // rr1 == (r1 >> 2) * 5
+ const u32 rr2 = (r2 >> 2) + r2; // rr1 == (r2 >> 2) * 5
+ const u32 rr3 = (r3 >> 2) + r3; // rr1 == (r3 >> 2) * 5
+ const u32 rr4 = r0 & 3; // ...recover 2 bits
+ u32 h0 = ctx->h[0];
+ u32 h1 = ctx->h[1];
+ u32 h2 = ctx->h[2];
+ u32 h3 = ctx->h[3];
+ u32 h4 = ctx->h[4];
+
+ FOR (i, 0, nb_blocks) {
+ // h + c, without carry propagation
+ const u64 s0 = (u64)h0 + load32_le(in); in += 4;
+ const u64 s1 = (u64)h1 + load32_le(in); in += 4;
+ const u64 s2 = (u64)h2 + load32_le(in); in += 4;
+ const u64 s3 = (u64)h3 + load32_le(in); in += 4;
+ const u32 s4 = h4 + end;
+
+ // (h + c) * r, without carry propagation
+ const u64 x0 = s0*r0+ s1*rr3+ s2*rr2+ s3*rr1+ s4*rr0;
+ const u64 x1 = s0*r1+ s1*r0 + s2*rr3+ s3*rr2+ s4*rr1;
+ const u64 x2 = s0*r2+ s1*r1 + s2*r0 + s3*rr3+ s4*rr2;
+ const u64 x3 = s0*r3+ s1*r2 + s2*r1 + s3*r0 + s4*rr3;
+ const u32 x4 = s4*rr4;
+
+ // partial reduction modulo 2^130 - 5
+ const u32 u5 = x4 + (x3 >> 32); // u5 <= 7ffffff5
+ const u64 u0 = (u5 >> 2) * 5 + (x0 & 0xffffffff);
+ const u64 u1 = (u0 >> 32) + (x1 & 0xffffffff) + (x0 >> 32);
+ const u64 u2 = (u1 >> 32) + (x2 & 0xffffffff) + (x1 >> 32);
+ const u64 u3 = (u2 >> 32) + (x3 & 0xffffffff) + (x2 >> 32);
+ const u32 u4 = (u3 >> 32) + (u5 & 3); // u4 <= 4
+
+ // Update the hash
+ h0 = u0 & 0xffffffff;
+ h1 = u1 & 0xffffffff;
+ h2 = u2 & 0xffffffff;
+ h3 = u3 & 0xffffffff;
+ h4 = u4;
+ }
+ ctx->h[0] = h0;
+ ctx->h[1] = h1;
+ ctx->h[2] = h2;
+ ctx->h[3] = h3;
+ ctx->h[4] = h4;
+}
+
+void crypto_poly1305_init(crypto_poly1305_ctx *ctx, const u8 key[32])
+{
+ ZERO(ctx->h, 5); // Initial hash is zero
+ ctx->c_idx = 0;
+ // load r and pad (r has some of its bits cleared)
+ load32_le_buf(ctx->r , key , 4);
+ load32_le_buf(ctx->pad, key+16, 4);
+ FOR (i, 0, 1) { ctx->r[i] &= 0x0fffffff; }
+ FOR (i, 1, 4) { ctx->r[i] &= 0x0ffffffc; }
+}
+
+void crypto_poly1305_update(crypto_poly1305_ctx *ctx,
+ const u8 *message, size_t message_size)
+{
+ // Avoid undefined NULL pointer increments with empty messages
+ if (message_size == 0) {
+ return;
+ }
+
+ // Align ourselves with block boundaries
+ size_t aligned = MIN(gap(ctx->c_idx, 16), message_size);
+ FOR (i, 0, aligned) {
+ ctx->c[ctx->c_idx] = *message;
+ ctx->c_idx++;
+ message++;
+ message_size--;
+ }
+
+ // If block is complete, process it
+ if (ctx->c_idx == 16) {
+ poly_blocks(ctx, ctx->c, 1, 1);
+ ctx->c_idx = 0;
+ }
+
+ // Process the message block by block
+ size_t nb_blocks = message_size >> 4;
+ poly_blocks(ctx, message, nb_blocks, 1);
+ message += nb_blocks << 4;
+ message_size &= 15;
+
+ // remaining bytes (we never complete a block here)
+ FOR (i, 0, message_size) {
+ ctx->c[ctx->c_idx] = message[i];
+ ctx->c_idx++;
+ }
+}
+
+void crypto_poly1305_final(crypto_poly1305_ctx *ctx, u8 mac[16])
+{
+ // Process the last block (if any)
+ // We move the final 1 according to remaining input length
+ // (this will add less than 2^130 to the last input block)
+ if (ctx->c_idx != 0) {
+ ZERO(ctx->c + ctx->c_idx, 16 - ctx->c_idx);
+ ctx->c[ctx->c_idx] = 1;
+ poly_blocks(ctx, ctx->c, 1, 0);
+ }
+
+ // check if we should subtract 2^130-5 by performing the
+ // corresponding carry propagation.
+ u64 c = 5;
+ FOR (i, 0, 4) {
+ c += ctx->h[i];
+ c >>= 32;
+ }
+ c += ctx->h[4];
+ c = (c >> 2) * 5; // shift the carry back to the beginning
+ // c now indicates how many times we should subtract 2^130-5 (0 or 1)
+ FOR (i, 0, 4) {
+ c += (u64)ctx->h[i] + ctx->pad[i];
+ store32_le(mac + i*4, (u32)c);
+ c = c >> 32;
+ }
+ WIPE_CTX(ctx);
+}
+
+void crypto_poly1305(u8 mac[16], const u8 *message,
+ size_t message_size, const u8 key[32])
+{
+ crypto_poly1305_ctx ctx;
+ crypto_poly1305_init (&ctx, key);
+ crypto_poly1305_update(&ctx, message, message_size);
+ crypto_poly1305_final (&ctx, mac);
+}
+
+////////////////
+/// BLAKE2 b ///
+////////////////
+static const u64 iv[8] = {
+ 0x6a09e667f3bcc908, 0xbb67ae8584caa73b,
+ 0x3c6ef372fe94f82b, 0xa54ff53a5f1d36f1,
+ 0x510e527fade682d1, 0x9b05688c2b3e6c1f,
+ 0x1f83d9abfb41bd6b, 0x5be0cd19137e2179,
+};
+
+static void blake2b_compress(crypto_blake2b_ctx *ctx, int is_last_block)
+{
+ static const u8 sigma[12][16] = {
+ { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 },
+ { 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 },
+ { 11, 8, 12, 0, 5, 2, 15, 13, 10, 14, 3, 6, 7, 1, 9, 4 },
+ { 7, 9, 3, 1, 13, 12, 11, 14, 2, 6, 5, 10, 4, 0, 15, 8 },
+ { 9, 0, 5, 7, 2, 4, 10, 15, 14, 1, 11, 12, 6, 8, 3, 13 },
+ { 2, 12, 6, 10, 0, 11, 8, 3, 4, 13, 7, 5, 15, 14, 1, 9 },
+ { 12, 5, 1, 15, 14, 13, 4, 10, 0, 7, 6, 3, 9, 2, 8, 11 },
+ { 13, 11, 7, 14, 12, 1, 3, 9, 5, 0, 15, 4, 8, 6, 2, 10 },
+ { 6, 15, 14, 9, 11, 3, 0, 8, 12, 2, 13, 7, 1, 4, 10, 5 },
+ { 10, 2, 8, 4, 7, 6, 1, 5, 15, 11, 9, 14, 3, 12, 13, 0 },
+ { 0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15 },
+ { 14, 10, 4, 8, 9, 15, 13, 6, 1, 12, 0, 2, 11, 7, 5, 3 },
+ };
+
+ // increment input offset
+ u64 *x = ctx->input_offset;
+ size_t y = ctx->input_idx;
+ x[0] += y;
+ if (x[0] < y) {
+ x[1]++;
+ }
+
+ // init work vector
+ u64 v0 = ctx->hash[0]; u64 v8 = iv[0];
+ u64 v1 = ctx->hash[1]; u64 v9 = iv[1];
+ u64 v2 = ctx->hash[2]; u64 v10 = iv[2];
+ u64 v3 = ctx->hash[3]; u64 v11 = iv[3];
+ u64 v4 = ctx->hash[4]; u64 v12 = iv[4] ^ ctx->input_offset[0];
+ u64 v5 = ctx->hash[5]; u64 v13 = iv[5] ^ ctx->input_offset[1];
+ u64 v6 = ctx->hash[6]; u64 v14 = iv[6] ^ (u64)~(is_last_block - 1);
+ u64 v7 = ctx->hash[7]; u64 v15 = iv[7];
+
+ // mangle work vector
+ u64 *input = ctx->input;
+#define BLAKE2_G(a, b, c, d, x, y) \
+ a += b + x; d = rotr64(d ^ a, 32); \
+ c += d; b = rotr64(b ^ c, 24); \
+ a += b + y; d = rotr64(d ^ a, 16); \
+ c += d; b = rotr64(b ^ c, 63)
+#define BLAKE2_ROUND(i) \
+ BLAKE2_G(v0, v4, v8 , v12, input[sigma[i][ 0]], input[sigma[i][ 1]]); \
+ BLAKE2_G(v1, v5, v9 , v13, input[sigma[i][ 2]], input[sigma[i][ 3]]); \
+ BLAKE2_G(v2, v6, v10, v14, input[sigma[i][ 4]], input[sigma[i][ 5]]); \
+ BLAKE2_G(v3, v7, v11, v15, input[sigma[i][ 6]], input[sigma[i][ 7]]); \
+ BLAKE2_G(v0, v5, v10, v15, input[sigma[i][ 8]], input[sigma[i][ 9]]); \
+ BLAKE2_G(v1, v6, v11, v12, input[sigma[i][10]], input[sigma[i][11]]); \
+ BLAKE2_G(v2, v7, v8 , v13, input[sigma[i][12]], input[sigma[i][13]]); \
+ BLAKE2_G(v3, v4, v9 , v14, input[sigma[i][14]], input[sigma[i][15]])
+
+#ifdef BLAKE2_NO_UNROLLING
+ FOR (i, 0, 12) {
+ BLAKE2_ROUND(i);
+ }
+#else
+ BLAKE2_ROUND(0); BLAKE2_ROUND(1); BLAKE2_ROUND(2); BLAKE2_ROUND(3);
+ BLAKE2_ROUND(4); BLAKE2_ROUND(5); BLAKE2_ROUND(6); BLAKE2_ROUND(7);
+ BLAKE2_ROUND(8); BLAKE2_ROUND(9); BLAKE2_ROUND(10); BLAKE2_ROUND(11);
+#endif
+
+ // update hash
+ ctx->hash[0] ^= v0 ^ v8; ctx->hash[1] ^= v1 ^ v9;
+ ctx->hash[2] ^= v2 ^ v10; ctx->hash[3] ^= v3 ^ v11;
+ ctx->hash[4] ^= v4 ^ v12; ctx->hash[5] ^= v5 ^ v13;
+ ctx->hash[6] ^= v6 ^ v14; ctx->hash[7] ^= v7 ^ v15;
+}
+
+void crypto_blake2b_keyed_init(crypto_blake2b_ctx *ctx, size_t hash_size,
+ const u8 *key, size_t key_size)
+{
+ // initial hash
+ COPY(ctx->hash, iv, 8);
+ ctx->hash[0] ^= 0x01010000 ^ (key_size << 8) ^ hash_size;
+
+ ctx->input_offset[0] = 0; // beginning of the input, no offset
+ ctx->input_offset[1] = 0; // beginning of the input, no offset
+ ctx->hash_size = hash_size;
+ ctx->input_idx = 0;
+ ZERO(ctx->input, 16);
+
+ // if there is a key, the first block is that key (padded with zeroes)
+ if (key_size > 0) {
+ u8 key_block[128] = {0};
+ COPY(key_block, key, key_size);
+ // same as calling crypto_blake2b_update(ctx, key_block , 128)
+ load64_le_buf(ctx->input, key_block, 16);
+ ctx->input_idx = 128;
+ }
+}
+
+void crypto_blake2b_init(crypto_blake2b_ctx *ctx, size_t hash_size)
+{
+ crypto_blake2b_keyed_init(ctx, hash_size, 0, 0);
+}
+
+void crypto_blake2b_update(crypto_blake2b_ctx *ctx,
+ const u8 *message, size_t message_size)
+{
+ // Avoid undefined NULL pointer increments with empty messages
+ if (message_size == 0) {
+ return;
+ }
+
+ // Align with word boundaries
+ if ((ctx->input_idx & 7) != 0) {
+ size_t nb_bytes = MIN(gap(ctx->input_idx, 8), message_size);
+ size_t word = ctx->input_idx >> 3;
+ size_t byte = ctx->input_idx & 7;
+ FOR (i, 0, nb_bytes) {
+ ctx->input[word] |= (u64)message[i] << ((byte + i) << 3);
+ }
+ ctx->input_idx += nb_bytes;
+ message += nb_bytes;
+ message_size -= nb_bytes;
+ }
+
+ // Align with block boundaries (faster than byte by byte)
+ if ((ctx->input_idx & 127) != 0) {
+ size_t nb_words = MIN(gap(ctx->input_idx, 128), message_size) >> 3;
+ load64_le_buf(ctx->input + (ctx->input_idx >> 3), message, nb_words);
+ ctx->input_idx += nb_words << 3;
+ message += nb_words << 3;
+ message_size -= nb_words << 3;
+ }
+
+ // Process block by block
+ size_t nb_blocks = message_size >> 7;
+ FOR (i, 0, nb_blocks) {
+ if (ctx->input_idx == 128) {
+ blake2b_compress(ctx, 0);
+ }
+ load64_le_buf(ctx->input, message, 16);
+ message += 128;
+ ctx->input_idx = 128;
+ }
+ message_size &= 127;
+
+ if (message_size != 0) {
+ // Compress block & flush input buffer as needed
+ if (ctx->input_idx == 128) {
+ blake2b_compress(ctx, 0);
+ ctx->input_idx = 0;
+ }
+ if (ctx->input_idx == 0) {
+ ZERO(ctx->input, 16);
+ }
+ // Fill remaining words (faster than byte by byte)
+ size_t nb_words = message_size >> 3;
+ load64_le_buf(ctx->input, message, nb_words);
+ ctx->input_idx += nb_words << 3;
+ message += nb_words << 3;
+ message_size -= nb_words << 3;
+
+ // Fill remaining bytes
+ FOR (i, 0, message_size) {
+ size_t word = ctx->input_idx >> 3;
+ size_t byte = ctx->input_idx & 7;
+ ctx->input[word] |= (u64)message[i] << (byte << 3);
+ ctx->input_idx++;
+ }
+ }
+}
+
+void crypto_blake2b_final(crypto_blake2b_ctx *ctx, u8 *hash)
+{
+ blake2b_compress(ctx, 1); // compress the last block
+ size_t hash_size = MIN(ctx->hash_size, 64);
+ size_t nb_words = hash_size >> 3;
+ store64_le_buf(hash, ctx->hash, nb_words);
+ FOR (i, nb_words << 3, hash_size) {
+ hash[i] = (ctx->hash[i >> 3] >> (8 * (i & 7))) & 0xff;
+ }
+ WIPE_CTX(ctx);
+}
+
+void crypto_blake2b_keyed(u8 *hash, size_t hash_size,
+ const u8 *key, size_t key_size,
+ const u8 *message, size_t message_size)
+{
+ crypto_blake2b_ctx ctx;
+ crypto_blake2b_keyed_init(&ctx, hash_size, key, key_size);
+ crypto_blake2b_update (&ctx, message, message_size);
+ crypto_blake2b_final (&ctx, hash);
+}
+
+void crypto_blake2b(u8 *hash, size_t hash_size, const u8 *msg, size_t msg_size)
+{
+ crypto_blake2b_keyed(hash, hash_size, 0, 0, msg, msg_size);
+}
+
+//////////////
+/// Argon2 ///
+//////////////
+// references to R, Z, Q etc. come from the spec
+
+// Argon2 operates on 1024 byte blocks.
+typedef struct { u64 a[128]; } blk;
+
+// updates a BLAKE2 hash with a 32 bit word, little endian.
+static void blake_update_32(crypto_blake2b_ctx *ctx, u32 input)
+{
+ u8 buf[4];
+ store32_le(buf, input);
+ crypto_blake2b_update(ctx, buf, 4);
+ WIPE_BUFFER(buf);
+}
+
+static void blake_update_32_buf(crypto_blake2b_ctx *ctx,
+ const u8 *buf, u32 size)
+{
+ blake_update_32(ctx, size);
+ crypto_blake2b_update(ctx, buf, size);
+}
+
+
+static void copy_block(blk *o,const blk*in){FOR(i, 0, 128) o->a[i] = in->a[i];}
+static void xor_block(blk *o,const blk*in){FOR(i, 0, 128) o->a[i] ^= in->a[i];}
+
+// Hash with a virtually unlimited digest size.
+// Doesn't extract more entropy than the base hash function.
+// Mainly used for filling a whole kilobyte block with pseudo-random bytes.
+// (One could use a stream cipher with a seed hash as the key, but
+// this would introduce another dependency —and point of failure.)
+static void extended_hash(u8 *digest, u32 digest_size,
+ const u8 *input , u32 input_size)
+{
+ crypto_blake2b_ctx ctx;
+ crypto_blake2b_init (&ctx, MIN(digest_size, 64));
+ blake_update_32 (&ctx, digest_size);
+ crypto_blake2b_update(&ctx, input, input_size);
+ crypto_blake2b_final (&ctx, digest);
+
+ if (digest_size > 64) {
+ // the conversion to u64 avoids integer overflow on
+ // ludicrously big hash sizes.
+ u32 r = (u32)(((u64)digest_size + 31) >> 5) - 2;
+ u32 i = 1;
+ u32 in = 0;
+ u32 out = 32;
+ while (i < r) {
+ // Input and output overlap. This is intentional
+ crypto_blake2b(digest + out, 64, digest + in, 64);
+ i += 1;
+ in += 32;
+ out += 32;
+ }
+ crypto_blake2b(digest + out, digest_size - (32 * r), digest + in , 64);
+ }
+}
+
+#define LSB(x) ((u64)(u32)x)
+#define G(a, b, c, d) \
+ a += b + ((LSB(a) * LSB(b)) << 1); d ^= a; d = rotr64(d, 32); \
+ c += d + ((LSB(c) * LSB(d)) << 1); b ^= c; b = rotr64(b, 24); \
+ a += b + ((LSB(a) * LSB(b)) << 1); d ^= a; d = rotr64(d, 16); \
+ c += d + ((LSB(c) * LSB(d)) << 1); b ^= c; b = rotr64(b, 63)
+#define ROUND(v0, v1, v2, v3, v4, v5, v6, v7, \
+ v8, v9, v10, v11, v12, v13, v14, v15) \
+ G(v0, v4, v8, v12); G(v1, v5, v9, v13); \
+ G(v2, v6, v10, v14); G(v3, v7, v11, v15); \
+ G(v0, v5, v10, v15); G(v1, v6, v11, v12); \
+ G(v2, v7, v8, v13); G(v3, v4, v9, v14)
+
+// Core of the compression function G. Computes Z from R in place.
+static void g_rounds(blk *b)
+{
+ // column rounds (work_block = Q)
+ for (int i = 0; i < 128; i += 16) {
+ ROUND(b->a[i ], b->a[i+ 1], b->a[i+ 2], b->a[i+ 3],
+ b->a[i+ 4], b->a[i+ 5], b->a[i+ 6], b->a[i+ 7],
+ b->a[i+ 8], b->a[i+ 9], b->a[i+10], b->a[i+11],
+ b->a[i+12], b->a[i+13], b->a[i+14], b->a[i+15]);
+ }
+ // row rounds (b = Z)
+ for (int i = 0; i < 16; i += 2) {
+ ROUND(b->a[i ], b->a[i+ 1], b->a[i+ 16], b->a[i+ 17],
+ b->a[i+32], b->a[i+33], b->a[i+ 48], b->a[i+ 49],
+ b->a[i+64], b->a[i+65], b->a[i+ 80], b->a[i+ 81],
+ b->a[i+96], b->a[i+97], b->a[i+112], b->a[i+113]);
+ }
+}
+
+const crypto_argon2_extras crypto_argon2_no_extras = { 0, 0, 0, 0 };
+
+void crypto_argon2(u8 *hash, u32 hash_size, void *work_area,
+ crypto_argon2_config config,
+ crypto_argon2_inputs inputs,
+ crypto_argon2_extras extras)
+{
+ const u32 segment_size = config.nb_blocks / config.nb_lanes / 4;
+ const u32 lane_size = segment_size * 4;
+ const u32 nb_blocks = lane_size * config.nb_lanes; // rounding down
+
+ // work area seen as blocks (must be suitably aligned)
+ blk *blocks = (blk*)work_area;
+ {
+ u8 initial_hash[72]; // 64 bytes plus 2 words for future hashes
+ crypto_blake2b_ctx ctx;
+ crypto_blake2b_init (&ctx, 64);
+ blake_update_32 (&ctx, config.nb_lanes ); // p: number of "threads"
+ blake_update_32 (&ctx, hash_size);
+ blake_update_32 (&ctx, config.nb_blocks);
+ blake_update_32 (&ctx, config.nb_passes);
+ blake_update_32 (&ctx, 0x13); // v: version number
+ blake_update_32 (&ctx, config.algorithm); // y: Argon2i, Argon2d...
+ blake_update_32_buf (&ctx, inputs.pass, inputs.pass_size);
+ blake_update_32_buf (&ctx, inputs.salt, inputs.salt_size);
+ blake_update_32_buf (&ctx, extras.key, extras.key_size);
+ blake_update_32_buf (&ctx, extras.ad, extras.ad_size);
+ crypto_blake2b_final(&ctx, initial_hash); // fill 64 first bytes only
+
+ // fill first 2 blocks of each lane
+ u8 hash_area[1024];
+ FOR_T(u32, l, 0, config.nb_lanes) {
+ FOR_T(u32, i, 0, 2) {
+ store32_le(initial_hash + 64, i); // first additional word
+ store32_le(initial_hash + 68, l); // second additional word
+ extended_hash(hash_area, 1024, initial_hash, 72);
+ load64_le_buf(blocks[l * lane_size + i].a, hash_area, 128);
+ }
+ }
+
+ WIPE_BUFFER(initial_hash);
+ WIPE_BUFFER(hash_area);
+ }
+
+ // Argon2i and Argon2id start with constant time indexing
+ int constant_time = config.algorithm != CRYPTO_ARGON2_D;
+
+ // Fill (and re-fill) the rest of the blocks
+ //
+ // Note: even though each segment within the same slice can be
+ // computed in parallel, (one thread per lane), we are computing
+ // them sequentially, because Monocypher doesn't support threads.
+ //
+ // Yet optimal performance (and therefore security) requires one
+ // thread per lane. The only reason Monocypher supports multiple
+ // lanes is compatibility.
+ blk tmp;
+ FOR_T(u32, pass, 0, config.nb_passes) {
+ FOR_T(u32, slice, 0, 4) {
+ // On the first slice of the first pass,
+ // blocks 0 and 1 are already filled, hence pass_offset.
+ u32 pass_offset = pass == 0 && slice == 0 ? 2 : 0;
+ u32 slice_offset = slice * segment_size;
+
+ // Argon2id switches back to non-constant time indexing
+ // after the first two slices of the first pass
+ if (slice == 2 && config.algorithm == CRYPTO_ARGON2_ID) {
+ constant_time = 0;
+ }
+
+ // Each iteration of the following loop may be performed in
+ // a separate thread. All segments must be fully completed
+ // before we start filling the next slice.
+ FOR_T(u32, segment, 0, config.nb_lanes) {
+ blk index_block;
+ u32 index_ctr = 1;
+ FOR_T (u32, block, pass_offset, segment_size) {
+ // Current and previous blocks
+ u32 lane_offset = segment * lane_size;
+ blk *segment_start = blocks + lane_offset + slice_offset;
+ blk *current = segment_start + block;
+ blk *previous =
+ block == 0 && slice_offset == 0
+ ? segment_start + lane_size - 1
+ : segment_start + block - 1;
+
+ u64 index_seed;
+ if (constant_time) {
+ if (block == pass_offset || (block % 128) == 0) {
+ // Fill or refresh deterministic indices block
+
+ // seed the beginning of the block...
+ ZERO(index_block.a, 128);
+ index_block.a[0] = pass;
+ index_block.a[1] = segment;
+ index_block.a[2] = slice;
+ index_block.a[3] = nb_blocks;
+ index_block.a[4] = config.nb_passes;
+ index_block.a[5] = config.algorithm;
+ index_block.a[6] = index_ctr;
+ index_ctr++;
+
+ // ... then shuffle it
+ copy_block(&tmp, &index_block);
+ g_rounds (&index_block);
+ xor_block (&index_block, &tmp);
+ copy_block(&tmp, &index_block);
+ g_rounds (&index_block);
+ xor_block (&index_block, &tmp);
+ }
+ index_seed = index_block.a[block % 128];
+ } else {
+ index_seed = previous->a[0];
+ }
+
+ // Establish the reference set. *Approximately* comprises:
+ // - The last 3 slices (if they exist yet)
+ // - The already constructed blocks in the current segment
+ u32 next_slice = ((slice + 1) % 4) * segment_size;
+ u32 window_start = pass == 0 ? 0 : next_slice;
+ u32 nb_segments = pass == 0 ? slice : 3;
+ u64 lane =
+ pass == 0 && slice == 0
+ ? segment
+ : (index_seed >> 32) % config.nb_lanes;
+ u32 window_size =
+ nb_segments * segment_size +
+ (lane == segment ? block-1 :
+ block == 0 ? (u32)-1 : 0);
+
+ // Find reference block
+ u64 j1 = index_seed & 0xffffffff; // block selector
+ u64 x = (j1 * j1) >> 32;
+ u64 y = (window_size * x) >> 32;
+ u64 z = (window_size - 1) - y;
+ u64 ref = (window_start + z) % lane_size;
+ u32 index = lane * lane_size + (u32)ref;
+ blk *reference = blocks + index;
+
+ // Shuffle the previous & reference block
+ // into the current block
+ copy_block(&tmp, previous);
+ xor_block (&tmp, reference);
+ if (pass == 0) { copy_block(current, &tmp); }
+ else { xor_block (current, &tmp); }
+ g_rounds (&tmp);
+ xor_block (current, &tmp);
+ }
+ }
+ }
+ }
+
+ // Wipe temporary block
+ volatile u64* p = tmp.a;
+ ZERO(p, 128);
+
+ // XOR last blocks of each lane
+ blk *last_block = blocks + lane_size - 1;
+ FOR_T (u32, lane, 1, config.nb_lanes) {
+ blk *next_block = last_block + lane_size;
+ xor_block(next_block, last_block);
+ last_block = next_block;
+ }
+
+ // Serialize last block
+ u8 final_block[1024];
+ store64_le_buf(final_block, last_block->a, 128);
+
+ // Wipe work area
+ p = (u64*)work_area;
+ ZERO(p, 128 * nb_blocks);
+
+ // Hash the very last block with H' into the output hash
+ extended_hash(hash, hash_size, final_block, 1024);
+ WIPE_BUFFER(final_block);
+}
+
+////////////////////////////////////
+/// Arithmetic modulo 2^255 - 19 ///
+////////////////////////////////////
+// Originally taken from SUPERCOP's ref10 implementation.
+// A bit bigger than TweetNaCl, over 4 times faster.
+
+// field element
+typedef i32 fe[10];
+
+// field constants
+//
+// fe_one : 1
+// sqrtm1 : sqrt(-1)
+// d : -121665 / 121666
+// D2 : 2 * -121665 / 121666
+// lop_x, lop_y: low order point in Edwards coordinates
+// ufactor : -sqrt(-1) * 2
+// A2 : 486662^2 (A squared)
+static const fe fe_one = {1};
+static const fe sqrtm1 = {
+ -32595792, -7943725, 9377950, 3500415, 12389472,
+ -272473, -25146209, -2005654, 326686, 11406482,
+};
+static const fe d = {
+ -10913610, 13857413, -15372611, 6949391, 114729,
+ -8787816, -6275908, -3247719, -18696448, -12055116,
+};
+static const fe D2 = {
+ -21827239, -5839606, -30745221, 13898782, 229458,
+ 15978800, -12551817, -6495438, 29715968, 9444199,
+};
+static const fe lop_x = {
+ 21352778, 5345713, 4660180, -8347857, 24143090,
+ 14568123, 30185756, -12247770, -33528939, 8345319,
+};
+static const fe lop_y = {
+ -6952922, -1265500, 6862341, -7057498, -4037696,
+ -5447722, 31680899, -15325402, -19365852, 1569102,
+};
+static const fe ufactor = {
+ -1917299, 15887451, -18755900, -7000830, -24778944,
+ 544946, -16816446, 4011309, -653372, 10741468,
+};
+static const fe A2 = {
+ 12721188, 3529, 0, 0, 0, 0, 0, 0, 0, 0,
+};
+
+static void fe_0(fe h) { ZERO(h , 10); }
+static void fe_1(fe h) { h[0] = 1; ZERO(h+1, 9); }
+
+static void fe_copy(fe h,const fe f ){FOR(i,0,10) h[i] = f[i]; }
+static void fe_neg (fe h,const fe f ){FOR(i,0,10) h[i] = -f[i]; }
+static void fe_add (fe h,const fe f,const fe g){FOR(i,0,10) h[i] = f[i] + g[i];}
+static void fe_sub (fe h,const fe f,const fe g){FOR(i,0,10) h[i] = f[i] - g[i];}
+
+static void fe_cswap(fe f, fe g, int b)
+{
+ i32 mask = -b; // -1 = 0xffffffff
+ FOR (i, 0, 10) {
+ i32 x = (f[i] ^ g[i]) & mask;
+ f[i] = f[i] ^ x;
+ g[i] = g[i] ^ x;
+ }
+}
+
+static void fe_ccopy(fe f, const fe g, int b)
+{
+ i32 mask = -b; // -1 = 0xffffffff
+ FOR (i, 0, 10) {
+ i32 x = (f[i] ^ g[i]) & mask;
+ f[i] = f[i] ^ x;
+ }
+}
+
+
+// Signed carry propagation
+// ------------------------
+//
+// Let t be a number. It can be uniquely decomposed thus:
+//
+// t = h*2^26 + l
+// such that -2^25 <= l < 2^25
+//
+// Let c = (t + 2^25) / 2^26 (rounded down)
+// c = (h*2^26 + l + 2^25) / 2^26 (rounded down)
+// c = h + (l + 2^25) / 2^26 (rounded down)
+// c = h (exactly)
+// Because 0 <= l + 2^25 < 2^26
+//
+// Let u = t - c*2^26
+// u = h*2^26 + l - h*2^26
+// u = l
+// Therefore, -2^25 <= u < 2^25
+//
+// Additionally, if |t| < x, then |h| < x/2^26 (rounded down)
+//
+// Notations:
+// - In C, 1<<25 means 2^25.
+// - In C, x>>25 means floor(x / (2^25)).
+// - All of the above applies with 25 & 24 as well as 26 & 25.
+//
+//
+// Note on negative right shifts
+// -----------------------------
+//
+// In C, x >> n, where x is a negative integer, is implementation
+// defined. In practice, all platforms do arithmetic shift, which is
+// equivalent to division by 2^26, rounded down. Some compilers, like
+// GCC, even guarantee it.
+//
+// If we ever stumble upon a platform that does not propagate the sign
+// bit (we won't), visible failures will show at the slightest test, and
+// the signed shifts can be replaced by the following:
+//
+// typedef struct { i64 x:39; } s25;
+// typedef struct { i64 x:38; } s26;
+// i64 shift25(i64 x) { s25 s; s.x = ((u64)x)>>25; return s.x; }
+// i64 shift26(i64 x) { s26 s; s.x = ((u64)x)>>26; return s.x; }
+//
+// Current compilers cannot optimise this, causing a 30% drop in
+// performance. Fairly expensive for something that never happens.
+//
+//
+// Precondition
+// ------------
+//
+// |t0| < 2^63
+// |t1|..|t9| < 2^62
+//
+// Algorithm
+// ---------
+// c = t0 + 2^25 / 2^26 -- |c| <= 2^36
+// t0 -= c * 2^26 -- |t0| <= 2^25
+// t1 += c -- |t1| <= 2^63
+//
+// c = t4 + 2^25 / 2^26 -- |c| <= 2^36
+// t4 -= c * 2^26 -- |t4| <= 2^25
+// t5 += c -- |t5| <= 2^63
+//
+// c = t1 + 2^24 / 2^25 -- |c| <= 2^38
+// t1 -= c * 2^25 -- |t1| <= 2^24
+// t2 += c -- |t2| <= 2^63
+//
+// c = t5 + 2^24 / 2^25 -- |c| <= 2^38
+// t5 -= c * 2^25 -- |t5| <= 2^24
+// t6 += c -- |t6| <= 2^63
+//
+// c = t2 + 2^25 / 2^26 -- |c| <= 2^37
+// t2 -= c * 2^26 -- |t2| <= 2^25 < 1.1 * 2^25 (final t2)
+// t3 += c -- |t3| <= 2^63
+//
+// c = t6 + 2^25 / 2^26 -- |c| <= 2^37
+// t6 -= c * 2^26 -- |t6| <= 2^25 < 1.1 * 2^25 (final t6)
+// t7 += c -- |t7| <= 2^63
+//
+// c = t3 + 2^24 / 2^25 -- |c| <= 2^38
+// t3 -= c * 2^25 -- |t3| <= 2^24 < 1.1 * 2^24 (final t3)
+// t4 += c -- |t4| <= 2^25 + 2^38 < 2^39
+//
+// c = t7 + 2^24 / 2^25 -- |c| <= 2^38
+// t7 -= c * 2^25 -- |t7| <= 2^24 < 1.1 * 2^24 (final t7)
+// t8 += c -- |t8| <= 2^63
+//
+// c = t4 + 2^25 / 2^26 -- |c| <= 2^13
+// t4 -= c * 2^26 -- |t4| <= 2^25 < 1.1 * 2^25 (final t4)
+// t5 += c -- |t5| <= 2^24 + 2^13 < 1.1 * 2^24 (final t5)
+//
+// c = t8 + 2^25 / 2^26 -- |c| <= 2^37
+// t8 -= c * 2^26 -- |t8| <= 2^25 < 1.1 * 2^25 (final t8)
+// t9 += c -- |t9| <= 2^63
+//
+// c = t9 + 2^24 / 2^25 -- |c| <= 2^38
+// t9 -= c * 2^25 -- |t9| <= 2^24 < 1.1 * 2^24 (final t9)
+// t0 += c * 19 -- |t0| <= 2^25 + 2^38*19 < 2^44
+//
+// c = t0 + 2^25 / 2^26 -- |c| <= 2^18
+// t0 -= c * 2^26 -- |t0| <= 2^25 < 1.1 * 2^25 (final t0)
+// t1 += c -- |t1| <= 2^24 + 2^18 < 1.1 * 2^24 (final t1)
+//
+// Postcondition
+// -------------
+// |t0|, |t2|, |t4|, |t6|, |t8| < 1.1 * 2^25
+// |t1|, |t3|, |t5|, |t7|, |t9| < 1.1 * 2^24
+#define FE_CARRY \
+ i64 c; \
+ c = (t0 + ((i64)1<<25)) >> 26; t0 -= c * ((i64)1 << 26); t1 += c; \
+ c = (t4 + ((i64)1<<25)) >> 26; t4 -= c * ((i64)1 << 26); t5 += c; \
+ c = (t1 + ((i64)1<<24)) >> 25; t1 -= c * ((i64)1 << 25); t2 += c; \
+ c = (t5 + ((i64)1<<24)) >> 25; t5 -= c * ((i64)1 << 25); t6 += c; \
+ c = (t2 + ((i64)1<<25)) >> 26; t2 -= c * ((i64)1 << 26); t3 += c; \
+ c = (t6 + ((i64)1<<25)) >> 26; t6 -= c * ((i64)1 << 26); t7 += c; \
+ c = (t3 + ((i64)1<<24)) >> 25; t3 -= c * ((i64)1 << 25); t4 += c; \
+ c = (t7 + ((i64)1<<24)) >> 25; t7 -= c * ((i64)1 << 25); t8 += c; \
+ c = (t4 + ((i64)1<<25)) >> 26; t4 -= c * ((i64)1 << 26); t5 += c; \
+ c = (t8 + ((i64)1<<25)) >> 26; t8 -= c * ((i64)1 << 26); t9 += c; \
+ c = (t9 + ((i64)1<<24)) >> 25; t9 -= c * ((i64)1 << 25); t0 += c * 19; \
+ c = (t0 + ((i64)1<<25)) >> 26; t0 -= c * ((i64)1 << 26); t1 += c; \
+ h[0]=(i32)t0; h[1]=(i32)t1; h[2]=(i32)t2; h[3]=(i32)t3; h[4]=(i32)t4; \
+ h[5]=(i32)t5; h[6]=(i32)t6; h[7]=(i32)t7; h[8]=(i32)t8; h[9]=(i32)t9
+
+// Decodes a field element from a byte buffer.
+// mask specifies how many bits we ignore.
+// Traditionally we ignore 1. It's useful for EdDSA,
+// which uses that bit to denote the sign of x.
+// Elligator however uses positive representatives,
+// which means ignoring 2 bits instead.
+static void fe_frombytes_mask(fe h, const u8 s[32], unsigned nb_mask)
+{
+ u32 mask = 0xffffff >> nb_mask;
+ i64 t0 = load32_le(s); // t0 < 2^32
+ i64 t1 = load24_le(s + 4) << 6; // t1 < 2^30
+ i64 t2 = load24_le(s + 7) << 5; // t2 < 2^29
+ i64 t3 = load24_le(s + 10) << 3; // t3 < 2^27
+ i64 t4 = load24_le(s + 13) << 2; // t4 < 2^26
+ i64 t5 = load32_le(s + 16); // t5 < 2^32
+ i64 t6 = load24_le(s + 20) << 7; // t6 < 2^31
+ i64 t7 = load24_le(s + 23) << 5; // t7 < 2^29
+ i64 t8 = load24_le(s + 26) << 4; // t8 < 2^28
+ i64 t9 = (load24_le(s + 29) & mask) << 2; // t9 < 2^25
+ FE_CARRY; // Carry precondition OK
+}
+
+static void fe_frombytes(fe h, const u8 s[32])
+{
+ fe_frombytes_mask(h, s, 1);
+}
+
+
+// Precondition
+// |h[0]|, |h[2]|, |h[4]|, |h[6]|, |h[8]| < 1.1 * 2^25
+// |h[1]|, |h[3]|, |h[5]|, |h[7]|, |h[9]| < 1.1 * 2^24
+//
+// Therefore, |h| < 2^255-19
+// There are two possibilities:
+//
+// - If h is positive, all we need to do is reduce its individual
+// limbs down to their tight positive range.
+// - If h is negative, we also need to add 2^255-19 to it.
+// Or just remove 19 and chop off any excess bit.
+static void fe_tobytes(u8 s[32], const fe h)
+{
+ i32 t[10];
+ COPY(t, h, 10);
+ i32 q = (19 * t[9] + (((i32) 1) << 24)) >> 25;
+ // |t9| < 1.1 * 2^24
+ // -1.1 * 2^24 < t9 < 1.1 * 2^24
+ // -21 * 2^24 < 19 * t9 < 21 * 2^24
+ // -2^29 < 19 * t9 + 2^24 < 2^29
+ // -2^29 / 2^25 < (19 * t9 + 2^24) / 2^25 < 2^29 / 2^25
+ // -16 < (19 * t9 + 2^24) / 2^25 < 16
+ FOR (i, 0, 5) {
+ q += t[2*i ]; q >>= 26; // q = 0 or -1
+ q += t[2*i+1]; q >>= 25; // q = 0 or -1
+ }
+ // q = 0 iff h >= 0
+ // q = -1 iff h < 0
+ // Adding q * 19 to h reduces h to its proper range.
+ q *= 19; // Shift carry back to the beginning
+ FOR (i, 0, 5) {
+ t[i*2 ] += q; q = t[i*2 ] >> 26; t[i*2 ] -= q * ((i32)1 << 26);
+ t[i*2+1] += q; q = t[i*2+1] >> 25; t[i*2+1] -= q * ((i32)1 << 25);
+ }
+ // h is now fully reduced, and q represents the excess bit.
+
+ store32_le(s + 0, ((u32)t[0] >> 0) | ((u32)t[1] << 26));
+ store32_le(s + 4, ((u32)t[1] >> 6) | ((u32)t[2] << 19));
+ store32_le(s + 8, ((u32)t[2] >> 13) | ((u32)t[3] << 13));
+ store32_le(s + 12, ((u32)t[3] >> 19) | ((u32)t[4] << 6));
+ store32_le(s + 16, ((u32)t[5] >> 0) | ((u32)t[6] << 25));
+ store32_le(s + 20, ((u32)t[6] >> 7) | ((u32)t[7] << 19));
+ store32_le(s + 24, ((u32)t[7] >> 13) | ((u32)t[8] << 12));
+ store32_le(s + 28, ((u32)t[8] >> 20) | ((u32)t[9] << 6));
+
+ WIPE_BUFFER(t);
+}
+
+// Precondition
+// -------------
+// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26
+// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25
+//
+// |g0|, |g2|, |g4|, |g6|, |g8| < 1.65 * 2^26
+// |g1|, |g3|, |g5|, |g7|, |g9| < 1.65 * 2^25
+static void fe_mul_small(fe h, const fe f, i32 g)
+{
+ i64 t0 = f[0] * (i64) g; i64 t1 = f[1] * (i64) g;
+ i64 t2 = f[2] * (i64) g; i64 t3 = f[3] * (i64) g;
+ i64 t4 = f[4] * (i64) g; i64 t5 = f[5] * (i64) g;
+ i64 t6 = f[6] * (i64) g; i64 t7 = f[7] * (i64) g;
+ i64 t8 = f[8] * (i64) g; i64 t9 = f[9] * (i64) g;
+ // |t0|, |t2|, |t4|, |t6|, |t8| < 1.65 * 2^26 * 2^31 < 2^58
+ // |t1|, |t3|, |t5|, |t7|, |t9| < 1.65 * 2^25 * 2^31 < 2^57
+
+ FE_CARRY; // Carry precondition OK
+}
+
+// Precondition
+// -------------
+// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26
+// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25
+//
+// |g0|, |g2|, |g4|, |g6|, |g8| < 1.65 * 2^26
+// |g1|, |g3|, |g5|, |g7|, |g9| < 1.65 * 2^25
+static void fe_mul(fe h, const fe f, const fe g)
+{
+ // Everything is unrolled and put in temporary variables.
+ // We could roll the loop, but that would make curve25519 twice as slow.
+ i32 f0 = f[0]; i32 f1 = f[1]; i32 f2 = f[2]; i32 f3 = f[3]; i32 f4 = f[4];
+ i32 f5 = f[5]; i32 f6 = f[6]; i32 f7 = f[7]; i32 f8 = f[8]; i32 f9 = f[9];
+ i32 g0 = g[0]; i32 g1 = g[1]; i32 g2 = g[2]; i32 g3 = g[3]; i32 g4 = g[4];
+ i32 g5 = g[5]; i32 g6 = g[6]; i32 g7 = g[7]; i32 g8 = g[8]; i32 g9 = g[9];
+ i32 F1 = f1*2; i32 F3 = f3*2; i32 F5 = f5*2; i32 F7 = f7*2; i32 F9 = f9*2;
+ i32 G1 = g1*19; i32 G2 = g2*19; i32 G3 = g3*19;
+ i32 G4 = g4*19; i32 G5 = g5*19; i32 G6 = g6*19;
+ i32 G7 = g7*19; i32 G8 = g8*19; i32 G9 = g9*19;
+ // |F1|, |F3|, |F5|, |F7|, |F9| < 1.65 * 2^26
+ // |G0|, |G2|, |G4|, |G6|, |G8| < 2^31
+ // |G1|, |G3|, |G5|, |G7|, |G9| < 2^30
+
+ i64 t0 = f0*(i64)g0 + F1*(i64)G9 + f2*(i64)G8 + F3*(i64)G7 + f4*(i64)G6
+ + F5*(i64)G5 + f6*(i64)G4 + F7*(i64)G3 + f8*(i64)G2 + F9*(i64)G1;
+ i64 t1 = f0*(i64)g1 + f1*(i64)g0 + f2*(i64)G9 + f3*(i64)G8 + f4*(i64)G7
+ + f5*(i64)G6 + f6*(i64)G5 + f7*(i64)G4 + f8*(i64)G3 + f9*(i64)G2;
+ i64 t2 = f0*(i64)g2 + F1*(i64)g1 + f2*(i64)g0 + F3*(i64)G9 + f4*(i64)G8
+ + F5*(i64)G7 + f6*(i64)G6 + F7*(i64)G5 + f8*(i64)G4 + F9*(i64)G3;
+ i64 t3 = f0*(i64)g3 + f1*(i64)g2 + f2*(i64)g1 + f3*(i64)g0 + f4*(i64)G9
+ + f5*(i64)G8 + f6*(i64)G7 + f7*(i64)G6 + f8*(i64)G5 + f9*(i64)G4;
+ i64 t4 = f0*(i64)g4 + F1*(i64)g3 + f2*(i64)g2 + F3*(i64)g1 + f4*(i64)g0
+ + F5*(i64)G9 + f6*(i64)G8 + F7*(i64)G7 + f8*(i64)G6 + F9*(i64)G5;
+ i64 t5 = f0*(i64)g5 + f1*(i64)g4 + f2*(i64)g3 + f3*(i64)g2 + f4*(i64)g1
+ + f5*(i64)g0 + f6*(i64)G9 + f7*(i64)G8 + f8*(i64)G7 + f9*(i64)G6;
+ i64 t6 = f0*(i64)g6 + F1*(i64)g5 + f2*(i64)g4 + F3*(i64)g3 + f4*(i64)g2
+ + F5*(i64)g1 + f6*(i64)g0 + F7*(i64)G9 + f8*(i64)G8 + F9*(i64)G7;
+ i64 t7 = f0*(i64)g7 + f1*(i64)g6 + f2*(i64)g5 + f3*(i64)g4 + f4*(i64)g3
+ + f5*(i64)g2 + f6*(i64)g1 + f7*(i64)g0 + f8*(i64)G9 + f9*(i64)G8;
+ i64 t8 = f0*(i64)g8 + F1*(i64)g7 + f2*(i64)g6 + F3*(i64)g5 + f4*(i64)g4
+ + F5*(i64)g3 + f6*(i64)g2 + F7*(i64)g1 + f8*(i64)g0 + F9*(i64)G9;
+ i64 t9 = f0*(i64)g9 + f1*(i64)g8 + f2*(i64)g7 + f3*(i64)g6 + f4*(i64)g5
+ + f5*(i64)g4 + f6*(i64)g3 + f7*(i64)g2 + f8*(i64)g1 + f9*(i64)g0;
+ // t0 < 0.67 * 2^61
+ // t1 < 0.41 * 2^61
+ // t2 < 0.52 * 2^61
+ // t3 < 0.32 * 2^61
+ // t4 < 0.38 * 2^61
+ // t5 < 0.22 * 2^61
+ // t6 < 0.23 * 2^61
+ // t7 < 0.13 * 2^61
+ // t8 < 0.09 * 2^61
+ // t9 < 0.03 * 2^61
+
+ FE_CARRY; // Everything below 2^62, Carry precondition OK
+}
+
+// Precondition
+// -------------
+// |f0|, |f2|, |f4|, |f6|, |f8| < 1.65 * 2^26
+// |f1|, |f3|, |f5|, |f7|, |f9| < 1.65 * 2^25
+//
+// Note: we could use fe_mul() for this, but this is significantly faster
+static void fe_sq(fe h, const fe f)
+{
+ i32 f0 = f[0]; i32 f1 = f[1]; i32 f2 = f[2]; i32 f3 = f[3]; i32 f4 = f[4];
+ i32 f5 = f[5]; i32 f6 = f[6]; i32 f7 = f[7]; i32 f8 = f[8]; i32 f9 = f[9];
+ i32 f0_2 = f0*2; i32 f1_2 = f1*2; i32 f2_2 = f2*2; i32 f3_2 = f3*2;
+ i32 f4_2 = f4*2; i32 f5_2 = f5*2; i32 f6_2 = f6*2; i32 f7_2 = f7*2;
+ i32 f5_38 = f5*38; i32 f6_19 = f6*19; i32 f7_38 = f7*38;
+ i32 f8_19 = f8*19; i32 f9_38 = f9*38;
+ // |f0_2| , |f2_2| , |f4_2| , |f6_2| , |f8_2| < 1.65 * 2^27
+ // |f1_2| , |f3_2| , |f5_2| , |f7_2| , |f9_2| < 1.65 * 2^26
+ // |f5_38|, |f6_19|, |f7_38|, |f8_19|, |f9_38| < 2^31
+
+ i64 t0 = f0 *(i64)f0 + f1_2*(i64)f9_38 + f2_2*(i64)f8_19
+ + f3_2*(i64)f7_38 + f4_2*(i64)f6_19 + f5 *(i64)f5_38;
+ i64 t1 = f0_2*(i64)f1 + f2 *(i64)f9_38 + f3_2*(i64)f8_19
+ + f4 *(i64)f7_38 + f5_2*(i64)f6_19;
+ i64 t2 = f0_2*(i64)f2 + f1_2*(i64)f1 + f3_2*(i64)f9_38
+ + f4_2*(i64)f8_19 + f5_2*(i64)f7_38 + f6 *(i64)f6_19;
+ i64 t3 = f0_2*(i64)f3 + f1_2*(i64)f2 + f4 *(i64)f9_38
+ + f5_2*(i64)f8_19 + f6 *(i64)f7_38;
+ i64 t4 = f0_2*(i64)f4 + f1_2*(i64)f3_2 + f2 *(i64)f2
+ + f5_2*(i64)f9_38 + f6_2*(i64)f8_19 + f7 *(i64)f7_38;
+ i64 t5 = f0_2*(i64)f5 + f1_2*(i64)f4 + f2_2*(i64)f3
+ + f6 *(i64)f9_38 + f7_2*(i64)f8_19;
+ i64 t6 = f0_2*(i64)f6 + f1_2*(i64)f5_2 + f2_2*(i64)f4
+ + f3_2*(i64)f3 + f7_2*(i64)f9_38 + f8 *(i64)f8_19;
+ i64 t7 = f0_2*(i64)f7 + f1_2*(i64)f6 + f2_2*(i64)f5
+ + f3_2*(i64)f4 + f8 *(i64)f9_38;
+ i64 t8 = f0_2*(i64)f8 + f1_2*(i64)f7_2 + f2_2*(i64)f6
+ + f3_2*(i64)f5_2 + f4 *(i64)f4 + f9 *(i64)f9_38;
+ i64 t9 = f0_2*(i64)f9 + f1_2*(i64)f8 + f2_2*(i64)f7
+ + f3_2*(i64)f6 + f4 *(i64)f5_2;
+ // t0 < 0.67 * 2^61
+ // t1 < 0.41 * 2^61
+ // t2 < 0.52 * 2^61
+ // t3 < 0.32 * 2^61
+ // t4 < 0.38 * 2^61
+ // t5 < 0.22 * 2^61
+ // t6 < 0.23 * 2^61
+ // t7 < 0.13 * 2^61
+ // t8 < 0.09 * 2^61
+ // t9 < 0.03 * 2^61
+
+ FE_CARRY;
+}
+
+// Parity check. Returns 0 if even, 1 if odd
+static int fe_isodd(const fe f)
+{
+ u8 s[32];
+ fe_tobytes(s, f);
+ u8 isodd = s[0] & 1;
+ WIPE_BUFFER(s);
+ return isodd;
+}
+
+// Returns 1 if equal, 0 if not equal
+static int fe_isequal(const fe f, const fe g)
+{
+ u8 fs[32];
+ u8 gs[32];
+ fe_tobytes(fs, f);
+ fe_tobytes(gs, g);
+ int isdifferent = crypto_verify32(fs, gs);
+ WIPE_BUFFER(fs);
+ WIPE_BUFFER(gs);
+ return 1 + isdifferent;
+}
+
+// Inverse square root.
+// Returns true if x is a square, false otherwise.
+// After the call:
+// isr = sqrt(1/x) if x is a non-zero square.
+// isr = sqrt(sqrt(-1)/x) if x is not a square.
+// isr = 0 if x is zero.
+// We do not guarantee the sign of the square root.
+//
+// Notes:
+// Let quartic = x^((p-1)/4)
+//
+// x^((p-1)/2) = chi(x)
+// quartic^2 = chi(x)
+// quartic = sqrt(chi(x))
+// quartic = 1 or -1 or sqrt(-1) or -sqrt(-1)
+//
+// Note that x is a square if quartic is 1 or -1
+// There are 4 cases to consider:
+//
+// if quartic = 1 (x is a square)
+// then x^((p-1)/4) = 1
+// x^((p-5)/4) * x = 1
+// x^((p-5)/4) = 1/x
+// x^((p-5)/8) = sqrt(1/x) or -sqrt(1/x)
+//
+// if quartic = -1 (x is a square)
+// then x^((p-1)/4) = -1
+// x^((p-5)/4) * x = -1
+// x^((p-5)/4) = -1/x
+// x^((p-5)/8) = sqrt(-1) / sqrt(x)
+// x^((p-5)/8) * sqrt(-1) = sqrt(-1)^2 / sqrt(x)
+// x^((p-5)/8) * sqrt(-1) = -1/sqrt(x)
+// x^((p-5)/8) * sqrt(-1) = -sqrt(1/x) or sqrt(1/x)
+//
+// if quartic = sqrt(-1) (x is not a square)
+// then x^((p-1)/4) = sqrt(-1)
+// x^((p-5)/4) * x = sqrt(-1)
+// x^((p-5)/4) = sqrt(-1)/x
+// x^((p-5)/8) = sqrt(sqrt(-1)/x) or -sqrt(sqrt(-1)/x)
+//
+// Note that the product of two non-squares is always a square:
+// For any non-squares a and b, chi(a) = -1 and chi(b) = -1.
+// Since chi(x) = x^((p-1)/2), chi(a)*chi(b) = chi(a*b) = 1.
+// Therefore a*b is a square.
+//
+// Since sqrt(-1) and x are both non-squares, their product is a
+// square, and we can compute their square root.
+//
+// if quartic = -sqrt(-1) (x is not a square)
+// then x^((p-1)/4) = -sqrt(-1)
+// x^((p-5)/4) * x = -sqrt(-1)
+// x^((p-5)/4) = -sqrt(-1)/x
+// x^((p-5)/8) = sqrt(-sqrt(-1)/x)
+// x^((p-5)/8) = sqrt( sqrt(-1)/x) * sqrt(-1)
+// x^((p-5)/8) * sqrt(-1) = sqrt( sqrt(-1)/x) * sqrt(-1)^2
+// x^((p-5)/8) * sqrt(-1) = sqrt( sqrt(-1)/x) * -1
+// x^((p-5)/8) * sqrt(-1) = -sqrt(sqrt(-1)/x) or sqrt(sqrt(-1)/x)
+static int invsqrt(fe isr, const fe x)
+{
+ fe t0, t1, t2;
+
+ // t0 = x^((p-5)/8)
+ // Can be achieved with a simple double & add ladder,
+ // but it would be slower.
+ fe_sq(t0, x);
+ fe_sq(t1,t0); fe_sq(t1, t1); fe_mul(t1, x, t1);
+ fe_mul(t0, t0, t1);
+ fe_sq(t0, t0); fe_mul(t0, t1, t0);
+ fe_sq(t1, t0); FOR (i, 1, 5) { fe_sq(t1, t1); } fe_mul(t0, t1, t0);
+ fe_sq(t1, t0); FOR (i, 1, 10) { fe_sq(t1, t1); } fe_mul(t1, t1, t0);
+ fe_sq(t2, t1); FOR (i, 1, 20) { fe_sq(t2, t2); } fe_mul(t1, t2, t1);
+ fe_sq(t1, t1); FOR (i, 1, 10) { fe_sq(t1, t1); } fe_mul(t0, t1, t0);
+ fe_sq(t1, t0); FOR (i, 1, 50) { fe_sq(t1, t1); } fe_mul(t1, t1, t0);
+ fe_sq(t2, t1); FOR (i, 1, 100) { fe_sq(t2, t2); } fe_mul(t1, t2, t1);
+ fe_sq(t1, t1); FOR (i, 1, 50) { fe_sq(t1, t1); } fe_mul(t0, t1, t0);
+ fe_sq(t0, t0); FOR (i, 1, 2) { fe_sq(t0, t0); } fe_mul(t0, t0, x);
+
+ // quartic = x^((p-1)/4)
+ i32 *quartic = t1;
+ fe_sq (quartic, t0);
+ fe_mul(quartic, quartic, x);
+
+ i32 *check = t2;
+ fe_0 (check); int z0 = fe_isequal(x , check);
+ fe_1 (check); int p1 = fe_isequal(quartic, check);
+ fe_neg(check, check ); int m1 = fe_isequal(quartic, check);
+ fe_neg(check, sqrtm1); int ms = fe_isequal(quartic, check);
+
+ // if quartic == -1 or sqrt(-1)
+ // then isr = x^((p-1)/4) * sqrt(-1)
+ // else isr = x^((p-1)/4)
+ fe_mul(isr, t0, sqrtm1);
+ fe_ccopy(isr, t0, 1 - (m1 | ms));
+
+ WIPE_BUFFER(t0);
+ WIPE_BUFFER(t1);
+ WIPE_BUFFER(t2);
+ return p1 | m1 | z0;
+}
+
+// Inverse in terms of inverse square root.
+// Requires two additional squarings to get rid of the sign.
+//
+// 1/x = x * (+invsqrt(x^2))^2
+// = x * (-invsqrt(x^2))^2
+//
+// A fully optimised exponentiation by p-1 would save 6 field
+// multiplications, but it would require more code.
+static void fe_invert(fe out, const fe x)
+{
+ fe tmp;
+ fe_sq(tmp, x);
+ invsqrt(tmp, tmp);
+ fe_sq(tmp, tmp);
+ fe_mul(out, tmp, x);
+ WIPE_BUFFER(tmp);
+}
+
+// trim a scalar for scalar multiplication
+void crypto_eddsa_trim_scalar(u8 out[32], const u8 in[32])
+{
+ COPY(out, in, 32);
+ out[ 0] &= 248;
+ out[31] &= 127;
+ out[31] |= 64;
+}
+
+// get bit from scalar at position i
+static int scalar_bit(const u8 s[32], int i)
+{
+ if (i < 0) { return 0; } // handle -1 for sliding windows
+ return (s[i>>3] >> (i&7)) & 1;
+}
+
+///////////////
+/// X-25519 /// Taken from SUPERCOP's ref10 implementation.
+///////////////
+static void scalarmult(u8 q[32], const u8 scalar[32], const u8 p[32],
+ int nb_bits)
+{
+ // computes the scalar product
+ fe x1;
+ fe_frombytes(x1, p);
+
+ // computes the actual scalar product (the result is in x2 and z2)
+ fe x2, z2, x3, z3, t0, t1;
+ // Montgomery ladder
+ // In projective coordinates, to avoid divisions: x = X / Z
+ // We don't care about the y coordinate, it's only 1 bit of information
+ fe_1(x2); fe_0(z2); // "zero" point
+ fe_copy(x3, x1); fe_1(z3); // "one" point
+ int swap = 0;
+ for (int pos = nb_bits-1; pos >= 0; --pos) {
+ // constant time conditional swap before ladder step
+ int b = scalar_bit(scalar, pos);
+ swap ^= b; // xor trick avoids swapping at the end of the loop
+ fe_cswap(x2, x3, swap);
+ fe_cswap(z2, z3, swap);
+ swap = b; // anticipates one last swap after the loop
+
+ // Montgomery ladder step: replaces (P2, P3) by (P2*2, P2+P3)
+ // with differential addition
+ fe_sub(t0, x3, z3);
+ fe_sub(t1, x2, z2);
+ fe_add(x2, x2, z2);
+ fe_add(z2, x3, z3);
+ fe_mul(z3, t0, x2);
+ fe_mul(z2, z2, t1);
+ fe_sq (t0, t1 );
+ fe_sq (t1, x2 );
+ fe_add(x3, z3, z2);
+ fe_sub(z2, z3, z2);
+ fe_mul(x2, t1, t0);
+ fe_sub(t1, t1, t0);
+ fe_sq (z2, z2 );
+ fe_mul_small(z3, t1, 121666);
+ fe_sq (x3, x3 );
+ fe_add(t0, t0, z3);
+ fe_mul(z3, x1, z2);
+ fe_mul(z2, t1, t0);
+ }
+ // last swap is necessary to compensate for the xor trick
+ // Note: after this swap, P3 == P2 + P1.
+ fe_cswap(x2, x3, swap);
+ fe_cswap(z2, z3, swap);
+
+ // normalises the coordinates: x == X / Z
+ fe_invert(z2, z2);
+ fe_mul(x2, x2, z2);
+ fe_tobytes(q, x2);
+
+ WIPE_BUFFER(x1);
+ WIPE_BUFFER(x2); WIPE_BUFFER(z2); WIPE_BUFFER(t0);
+ WIPE_BUFFER(x3); WIPE_BUFFER(z3); WIPE_BUFFER(t1);
+}
+
+void crypto_x25519(u8 raw_shared_secret[32],
+ const u8 your_secret_key [32],
+ const u8 their_public_key [32])
+{
+ // restrict the possible scalar values
+ u8 e[32];
+ crypto_eddsa_trim_scalar(e, your_secret_key);
+ scalarmult(raw_shared_secret, e, their_public_key, 255);
+ WIPE_BUFFER(e);
+}
+
+void crypto_x25519_public_key(u8 public_key[32],
+ const u8 secret_key[32])
+{
+ static const u8 base_point[32] = {9};
+ crypto_x25519(public_key, secret_key, base_point);
+}
+
+///////////////////////////
+/// Arithmetic modulo L ///
+///////////////////////////
+static const u32 L[8] = {
+ 0x5cf5d3ed, 0x5812631a, 0xa2f79cd6, 0x14def9de,
+ 0x00000000, 0x00000000, 0x00000000, 0x10000000,
+};
+
+// p = a*b + p
+static void multiply(u32 p[16], const u32 a[8], const u32 b[8])
+{
+ FOR (i, 0, 8) {
+ u64 carry = 0;
+ FOR (j, 0, 8) {
+ carry += p[i+j] + (u64)a[i] * b[j];
+ p[i+j] = (u32)carry;
+ carry >>= 32;
+ }
+ p[i+8] = (u32)carry;
+ }
+}
+
+static int is_above_l(const u32 x[8])
+{
+ // We work with L directly, in a 2's complement encoding
+ // (-L == ~L + 1)
+ u64 carry = 1;
+ FOR (i, 0, 8) {
+ carry += (u64)x[i] + (~L[i] & 0xffffffff);
+ carry >>= 32;
+ }
+ return (int)carry; // carry is either 0 or 1
+}
+
+// Final reduction modulo L, by conditionally removing L.
+// if x < l , then r = x
+// if l <= x 2*l, then r = x-l
+// otherwise the result will be wrong
+static void remove_l(u32 r[8], const u32 x[8])
+{
+ u64 carry = (u64)is_above_l(x);
+ u32 mask = ~(u32)carry + 1; // carry == 0 or 1
+ FOR (i, 0, 8) {
+ carry += (u64)x[i] + (~L[i] & mask);
+ r[i] = (u32)carry;
+ carry >>= 32;
+ }
+}
+
+// Full reduction modulo L (Barrett reduction)
+static void mod_l(u8 reduced[32], const u32 x[16])
+{
+ static const u32 r[9] = {
+ 0x0a2c131b,0xed9ce5a3,0x086329a7,0x2106215d,
+ 0xffffffeb,0xffffffff,0xffffffff,0xffffffff,0xf,
+ };
+ // xr = x * r
+ u32 xr[25] = {0};
+ FOR (i, 0, 9) {
+ u64 carry = 0;
+ FOR (j, 0, 16) {
+ carry += xr[i+j] + (u64)r[i] * x[j];
+ xr[i+j] = (u32)carry;
+ carry >>= 32;
+ }
+ xr[i+16] = (u32)carry;
+ }
+ // xr = floor(xr / 2^512) * L
+ // Since the result is guaranteed to be below 2*L,
+ // it is enough to only compute the first 256 bits.
+ // The division is performed by saying xr[i+16]. (16 * 32 = 512)
+ ZERO(xr, 8);
+ FOR (i, 0, 8) {
+ u64 carry = 0;
+ FOR (j, 0, 8-i) {
+ carry += xr[i+j] + (u64)xr[i+16] * L[j];
+ xr[i+j] = (u32)carry;
+ carry >>= 32;
+ }
+ }
+ // xr = x - xr
+ u64 carry = 1;
+ FOR (i, 0, 8) {
+ carry += (u64)x[i] + (~xr[i] & 0xffffffff);
+ xr[i] = (u32)carry;
+ carry >>= 32;
+ }
+ // Final reduction modulo L (conditional subtraction)
+ remove_l(xr, xr);
+ store32_le_buf(reduced, xr, 8);
+
+ WIPE_BUFFER(xr);
+}
+
+void crypto_eddsa_reduce(u8 reduced[32], const u8 expanded[64])
+{
+ u32 x[16];
+ load32_le_buf(x, expanded, 16);
+ mod_l(reduced, x);
+ WIPE_BUFFER(x);
+}
+
+// r = (a * b) + c
+void crypto_eddsa_mul_add(u8 r[32],
+ const u8 a[32], const u8 b[32], const u8 c[32])
+{
+ u32 A[8]; load32_le_buf(A, a, 8);
+ u32 B[8]; load32_le_buf(B, b, 8);
+ u32 p[16]; load32_le_buf(p, c, 8); ZERO(p + 8, 8);
+ multiply(p, A, B);
+ mod_l(r, p);
+ WIPE_BUFFER(p);
+ WIPE_BUFFER(A);
+ WIPE_BUFFER(B);
+}
+
+///////////////
+/// Ed25519 ///
+///////////////
+
+// Point (group element, ge) in a twisted Edwards curve,
+// in extended projective coordinates.
+// ge : x = X/Z, y = Y/Z, T = XY/Z
+// ge_cached : Yp = X+Y, Ym = X-Y, T2 = T*D2
+// ge_precomp: Z = 1
+typedef struct { fe X; fe Y; fe Z; fe T; } ge;
+typedef struct { fe Yp; fe Ym; fe Z; fe T2; } ge_cached;
+typedef struct { fe Yp; fe Ym; fe T2; } ge_precomp;
+
+static void ge_zero(ge *p)
+{
+ fe_0(p->X);
+ fe_1(p->Y);
+ fe_1(p->Z);
+ fe_0(p->T);
+}
+
+static void ge_tobytes(u8 s[32], const ge *h)
+{
+ fe recip, x, y;
+ fe_invert(recip, h->Z);
+ fe_mul(x, h->X, recip);
+ fe_mul(y, h->Y, recip);
+ fe_tobytes(s, y);
+ s[31] ^= fe_isodd(x) << 7;
+
+ WIPE_BUFFER(recip);
+ WIPE_BUFFER(x);
+ WIPE_BUFFER(y);
+}
+
+// h = -s, where s is a point encoded in 32 bytes
+//
+// Variable time! Inputs must not be secret!
+// => Use only to *check* signatures.
+//
+// From the specifications:
+// The encoding of s contains y and the sign of x
+// x = sqrt((y^2 - 1) / (d*y^2 + 1))
+// In extended coordinates:
+// X = x, Y = y, Z = 1, T = x*y
+//
+// Note that num * den is a square iff num / den is a square
+// If num * den is not a square, the point was not on the curve.
+// From the above:
+// Let num = y^2 - 1
+// Let den = d*y^2 + 1
+// x = sqrt((y^2 - 1) / (d*y^2 + 1))
+// x = sqrt(num / den)
+// x = sqrt(num^2 / (num * den))
+// x = num * sqrt(1 / (num * den))
+//
+// Therefore, we can just compute:
+// num = y^2 - 1
+// den = d*y^2 + 1
+// isr = invsqrt(num * den) // abort if not square
+// x = num * isr
+// Finally, negate x if its sign is not as specified.
+static int ge_frombytes_neg_vartime(ge *h, const u8 s[32])
+{
+ fe_frombytes(h->Y, s);
+ fe_1(h->Z);
+ fe_sq (h->T, h->Y); // t = y^2
+ fe_mul(h->X, h->T, d ); // x = d*y^2
+ fe_sub(h->T, h->T, h->Z); // t = y^2 - 1
+ fe_add(h->X, h->X, h->Z); // x = d*y^2 + 1
+ fe_mul(h->X, h->T, h->X); // x = (y^2 - 1) * (d*y^2 + 1)
+ int is_square = invsqrt(h->X, h->X);
+ if (!is_square) {
+ return -1; // Not on the curve, abort
+ }
+ fe_mul(h->X, h->T, h->X); // x = sqrt((y^2 - 1) / (d*y^2 + 1))
+ if (fe_isodd(h->X) == (s[31] >> 7)) {
+ fe_neg(h->X, h->X);
+ }
+ fe_mul(h->T, h->X, h->Y);
+ return 0;
+}
+
+static void ge_cache(ge_cached *c, const ge *p)
+{
+ fe_add (c->Yp, p->Y, p->X);
+ fe_sub (c->Ym, p->Y, p->X);
+ fe_copy(c->Z , p->Z );
+ fe_mul (c->T2, p->T, D2 );
+}
+
+// Internal buffers are not wiped! Inputs must not be secret!
+// => Use only to *check* signatures.
+static void ge_add(ge *s, const ge *p, const ge_cached *q)
+{
+ fe a, b;
+ fe_add(a , p->Y, p->X );
+ fe_sub(b , p->Y, p->X );
+ fe_mul(a , a , q->Yp);
+ fe_mul(b , b , q->Ym);
+ fe_add(s->Y, a , b );
+ fe_sub(s->X, a , b );
+
+ fe_add(s->Z, p->Z, p->Z );
+ fe_mul(s->Z, s->Z, q->Z );
+ fe_mul(s->T, p->T, q->T2);
+ fe_add(a , s->Z, s->T );
+ fe_sub(b , s->Z, s->T );
+
+ fe_mul(s->T, s->X, s->Y);
+ fe_mul(s->X, s->X, b );
+ fe_mul(s->Y, s->Y, a );
+ fe_mul(s->Z, a , b );
+}
+
+// Internal buffers are not wiped! Inputs must not be secret!
+// => Use only to *check* signatures.
+static void ge_sub(ge *s, const ge *p, const ge_cached *q)
+{
+ ge_cached neg;
+ fe_copy(neg.Ym, q->Yp);
+ fe_copy(neg.Yp, q->Ym);
+ fe_copy(neg.Z , q->Z );
+ fe_neg (neg.T2, q->T2);
+ ge_add(s, p, &neg);
+}
+
+static void ge_madd(ge *s, const ge *p, const ge_precomp *q, fe a, fe b)
+{
+ fe_add(a , p->Y, p->X );
+ fe_sub(b , p->Y, p->X );
+ fe_mul(a , a , q->Yp);
+ fe_mul(b , b , q->Ym);
+ fe_add(s->Y, a , b );
+ fe_sub(s->X, a , b );
+
+ fe_add(s->Z, p->Z, p->Z );
+ fe_mul(s->T, p->T, q->T2);
+ fe_add(a , s->Z, s->T );
+ fe_sub(b , s->Z, s->T );
+
+ fe_mul(s->T, s->X, s->Y);
+ fe_mul(s->X, s->X, b );
+ fe_mul(s->Y, s->Y, a );
+ fe_mul(s->Z, a , b );
+}
+
+// Internal buffers are not wiped! Inputs must not be secret!
+// => Use only to *check* signatures.
+static void ge_msub(ge *s, const ge *p, const ge_precomp *q, fe a, fe b)
+{
+ ge_precomp neg;
+ fe_copy(neg.Ym, q->Yp);
+ fe_copy(neg.Yp, q->Ym);
+ fe_neg (neg.T2, q->T2);
+ ge_madd(s, p, &neg, a, b);
+}
+
+static void ge_double(ge *s, const ge *p, ge *q)
+{
+ fe_sq (q->X, p->X);
+ fe_sq (q->Y, p->Y);
+ fe_sq (q->Z, p->Z); // qZ = pZ^2
+ fe_mul_small(q->Z, q->Z, 2); // qZ = pZ^2 * 2
+ fe_add(q->T, p->X, p->Y);
+ fe_sq (s->T, q->T);
+ fe_add(q->T, q->Y, q->X);
+ fe_sub(q->Y, q->Y, q->X);
+ fe_sub(q->X, s->T, q->T);
+ fe_sub(q->Z, q->Z, q->Y);
+
+ fe_mul(s->X, q->X , q->Z);
+ fe_mul(s->Y, q->T , q->Y);
+ fe_mul(s->Z, q->Y , q->Z);
+ fe_mul(s->T, q->X , q->T);
+}
+
+// 5-bit signed window in cached format (Niels coordinates, Z=1)
+static const ge_precomp b_window[8] = {
+ {{25967493,-14356035,29566456,3660896,-12694345,
+ 4014787,27544626,-11754271,-6079156,2047605,},
+ {-12545711,934262,-2722910,3049990,-727428,
+ 9406986,12720692,5043384,19500929,-15469378,},
+ {-8738181,4489570,9688441,-14785194,10184609,
+ -12363380,29287919,11864899,-24514362,-4438546,},},
+ {{15636291,-9688557,24204773,-7912398,616977,
+ -16685262,27787600,-14772189,28944400,-1550024,},
+ {16568933,4717097,-11556148,-1102322,15682896,
+ -11807043,16354577,-11775962,7689662,11199574,},
+ {30464156,-5976125,-11779434,-15670865,23220365,
+ 15915852,7512774,10017326,-17749093,-9920357,},},
+ {{10861363,11473154,27284546,1981175,-30064349,
+ 12577861,32867885,14515107,-15438304,10819380,},
+ {4708026,6336745,20377586,9066809,-11272109,
+ 6594696,-25653668,12483688,-12668491,5581306,},
+ {19563160,16186464,-29386857,4097519,10237984,
+ -4348115,28542350,13850243,-23678021,-15815942,},},
+ {{5153746,9909285,1723747,-2777874,30523605,
+ 5516873,19480852,5230134,-23952439,-15175766,},
+ {-30269007,-3463509,7665486,10083793,28475525,
+ 1649722,20654025,16520125,30598449,7715701,},
+ {28881845,14381568,9657904,3680757,-20181635,
+ 7843316,-31400660,1370708,29794553,-1409300,},},
+ {{-22518993,-6692182,14201702,-8745502,-23510406,
+ 8844726,18474211,-1361450,-13062696,13821877,},
+ {-6455177,-7839871,3374702,-4740862,-27098617,
+ -10571707,31655028,-7212327,18853322,-14220951,},
+ {4566830,-12963868,-28974889,-12240689,-7602672,
+ -2830569,-8514358,-10431137,2207753,-3209784,},},
+ {{-25154831,-4185821,29681144,7868801,-6854661,
+ -9423865,-12437364,-663000,-31111463,-16132436,},
+ {25576264,-2703214,7349804,-11814844,16472782,
+ 9300885,3844789,15725684,171356,6466918,},
+ {23103977,13316479,9739013,-16149481,817875,
+ -15038942,8965339,-14088058,-30714912,16193877,},},
+ {{-33521811,3180713,-2394130,14003687,-16903474,
+ -16270840,17238398,4729455,-18074513,9256800,},
+ {-25182317,-4174131,32336398,5036987,-21236817,
+ 11360617,22616405,9761698,-19827198,630305,},
+ {-13720693,2639453,-24237460,-7406481,9494427,
+ -5774029,-6554551,-15960994,-2449256,-14291300,},},
+ {{-3151181,-5046075,9282714,6866145,-31907062,
+ -863023,-18940575,15033784,25105118,-7894876,},
+ {-24326370,15950226,-31801215,-14592823,-11662737,
+ -5090925,1573892,-2625887,2198790,-15804619,},
+ {-3099351,10324967,-2241613,7453183,-5446979,
+ -2735503,-13812022,-16236442,-32461234,-12290683,},},
+};
+
+// Incremental sliding windows (left to right)
+// Based on Roberto Maria Avanzi[2005]
+typedef struct {
+ i16 next_index; // position of the next signed digit
+ i8 next_digit; // next signed digit (odd number below 2^window_width)
+ u8 next_check; // point at which we must check for a new window
+} slide_ctx;
+
+static void slide_init(slide_ctx *ctx, const u8 scalar[32])
+{
+ // scalar is guaranteed to be below L, either because we checked (s),
+ // or because we reduced it modulo L (h_ram). L is under 2^253, so
+ // so bits 253 to 255 are guaranteed to be zero. No need to test them.
+ //
+ // Note however that L is very close to 2^252, so bit 252 is almost
+ // always zero. If we were to start at bit 251, the tests wouldn't
+ // catch the off-by-one error (constructing one that does would be
+ // prohibitively expensive).
+ //
+ // We should still check bit 252, though.
+ int i = 252;
+ while (i > 0 && scalar_bit(scalar, i) == 0) {
+ i--;
+ }
+ ctx->next_check = (u8)(i + 1);
+ ctx->next_index = -1;
+ ctx->next_digit = -1;
+}
+
+static int slide_step(slide_ctx *ctx, int width, int i, const u8 scalar[32])
+{
+ if (i == ctx->next_check) {
+ if (scalar_bit(scalar, i) == scalar_bit(scalar, i - 1)) {
+ ctx->next_check--;
+ } else {
+ // compute digit of next window
+ int w = MIN(width, i + 1);
+ int v = -(scalar_bit(scalar, i) << (w-1));
+ FOR_T (int, j, 0, w-1) {
+ v += scalar_bit(scalar, i-(w-1)+j) << j;
+ }
+ v += scalar_bit(scalar, i-w);
+ int lsb = v & (~v + 1); // smallest bit of v
+ int s = // log2(lsb)
+ (((lsb & 0xAA) != 0) << 0) |
+ (((lsb & 0xCC) != 0) << 1) |
+ (((lsb & 0xF0) != 0) << 2);
+ ctx->next_index = (i16)(i-(w-1)+s);
+ ctx->next_digit = (i8) (v >> s );
+ ctx->next_check -= (u8) w;
+ }
+ }
+ return i == ctx->next_index ? ctx->next_digit: 0;
+}
+
+#define P_W_WIDTH 3 // Affects the size of the stack
+#define B_W_WIDTH 5 // Affects the size of the binary
+#define P_W_SIZE (1<<(P_W_WIDTH-2))
+
+int crypto_eddsa_check_equation(const u8 signature[64], const u8 public_key[32],
+ const u8 h[32])
+{
+ ge minus_A; // -public_key
+ ge minus_R; // -first_half_of_signature
+ const u8 *s = signature + 32;
+
+ // Check that A and R are on the curve
+ // Check that 0 <= S < L (prevents malleability)
+ // *Allow* non-cannonical encoding for A and R
+ {
+ u32 s32[8];
+ load32_le_buf(s32, s, 8);
+ if (ge_frombytes_neg_vartime(&minus_A, public_key) ||
+ ge_frombytes_neg_vartime(&minus_R, signature) ||
+ is_above_l(s32)) {
+ return -1;
+ }
+ }
+
+ // look-up table for minus_A
+ ge_cached lutA[P_W_SIZE];
+ {
+ ge minus_A2, tmp;
+ ge_double(&minus_A2, &minus_A, &tmp);
+ ge_cache(&lutA[0], &minus_A);
+ FOR (i, 1, P_W_SIZE) {
+ ge_add(&tmp, &minus_A2, &lutA[i-1]);
+ ge_cache(&lutA[i], &tmp);
+ }
+ }
+
+ // sum = [s]B - [h]A
+ // Merged double and add ladder, fused with sliding
+ slide_ctx h_slide; slide_init(&h_slide, h);
+ slide_ctx s_slide; slide_init(&s_slide, s);
+ int i = MAX(h_slide.next_check, s_slide.next_check);
+ ge *sum = &minus_A; // reuse minus_A for the sum
+ ge_zero(sum);
+ while (i >= 0) {
+ ge tmp;
+ ge_double(sum, sum, &tmp);
+ int h_digit = slide_step(&h_slide, P_W_WIDTH, i, h);
+ int s_digit = slide_step(&s_slide, B_W_WIDTH, i, s);
+ if (h_digit > 0) { ge_add(sum, sum, &lutA[ h_digit / 2]); }
+ if (h_digit < 0) { ge_sub(sum, sum, &lutA[-h_digit / 2]); }
+ fe t1, t2;
+ if (s_digit > 0) { ge_madd(sum, sum, b_window + s_digit/2, t1, t2); }
+ if (s_digit < 0) { ge_msub(sum, sum, b_window + -s_digit/2, t1, t2); }
+ i--;
+ }
+
+ // Compare [8](sum-R) and the zero point
+ // The multiplication by 8 eliminates any low-order component
+ // and ensures consistency with batched verification.
+ ge_cached cached;
+ u8 check[32];
+ static const u8 zero_point[32] = {1}; // Point of order 1
+ ge_cache(&cached, &minus_R);
+ ge_add(sum, sum, &cached);
+ ge_double(sum, sum, &minus_R); // reuse minus_R as temporary
+ ge_double(sum, sum, &minus_R); // reuse minus_R as temporary
+ ge_double(sum, sum, &minus_R); // reuse minus_R as temporary
+ ge_tobytes(check, sum);
+ return crypto_verify32(check, zero_point);
+}
+
+// 5-bit signed comb in cached format (Niels coordinates, Z=1)
+static const ge_precomp b_comb_low[8] = {
+ {{-6816601,-2324159,-22559413,124364,18015490,
+ 8373481,19993724,1979872,-18549925,9085059,},
+ {10306321,403248,14839893,9633706,8463310,
+ -8354981,-14305673,14668847,26301366,2818560,},
+ {-22701500,-3210264,-13831292,-2927732,-16326337,
+ -14016360,12940910,177905,12165515,-2397893,},},
+ {{-12282262,-7022066,9920413,-3064358,-32147467,
+ 2927790,22392436,-14852487,2719975,16402117,},
+ {-7236961,-4729776,2685954,-6525055,-24242706,
+ -15940211,-6238521,14082855,10047669,12228189,},
+ {-30495588,-12893761,-11161261,3539405,-11502464,
+ 16491580,-27286798,-15030530,-7272871,-15934455,},},
+ {{17650926,582297,-860412,-187745,-12072900,
+ -10683391,-20352381,15557840,-31072141,-5019061,},
+ {-6283632,-2259834,-4674247,-4598977,-4089240,
+ 12435688,-31278303,1060251,6256175,10480726,},
+ {-13871026,2026300,-21928428,-2741605,-2406664,
+ -8034988,7355518,15733500,-23379862,7489131,},},
+ {{6883359,695140,23196907,9644202,-33430614,
+ 11354760,-20134606,6388313,-8263585,-8491918,},
+ {-7716174,-13605463,-13646110,14757414,-19430591,
+ -14967316,10359532,-11059670,-21935259,12082603,},
+ {-11253345,-15943946,10046784,5414629,24840771,
+ 8086951,-6694742,9868723,15842692,-16224787,},},
+ {{9639399,11810955,-24007778,-9320054,3912937,
+ -9856959,996125,-8727907,-8919186,-14097242,},
+ {7248867,14468564,25228636,-8795035,14346339,
+ 8224790,6388427,-7181107,6468218,-8720783,},
+ {15513115,15439095,7342322,-10157390,18005294,
+ -7265713,2186239,4884640,10826567,7135781,},},
+ {{-14204238,5297536,-5862318,-6004934,28095835,
+ 4236101,-14203318,1958636,-16816875,3837147,},
+ {-5511166,-13176782,-29588215,12339465,15325758,
+ -15945770,-8813185,11075932,-19608050,-3776283,},
+ {11728032,9603156,-4637821,-5304487,-7827751,
+ 2724948,31236191,-16760175,-7268616,14799772,},},
+ {{-28842672,4840636,-12047946,-9101456,-1445464,
+ 381905,-30977094,-16523389,1290540,12798615,},
+ {27246947,-10320914,14792098,-14518944,5302070,
+ -8746152,-3403974,-4149637,-27061213,10749585,},
+ {25572375,-6270368,-15353037,16037944,1146292,
+ 32198,23487090,9585613,24714571,-1418265,},},
+ {{19844825,282124,-17583147,11004019,-32004269,
+ -2716035,6105106,-1711007,-21010044,14338445,},
+ {8027505,8191102,-18504907,-12335737,25173494,
+ -5923905,15446145,7483684,-30440441,10009108,},
+ {-14134701,-4174411,10246585,-14677495,33553567,
+ -14012935,23366126,15080531,-7969992,7663473,},},
+};
+
+static const ge_precomp b_comb_high[8] = {
+ {{33055887,-4431773,-521787,6654165,951411,
+ -6266464,-5158124,6995613,-5397442,-6985227,},
+ {4014062,6967095,-11977872,3960002,8001989,
+ 5130302,-2154812,-1899602,-31954493,-16173976,},
+ {16271757,-9212948,23792794,731486,-25808309,
+ -3546396,6964344,-4767590,10976593,10050757,},},
+ {{2533007,-4288439,-24467768,-12387405,-13450051,
+ 14542280,12876301,13893535,15067764,8594792,},
+ {20073501,-11623621,3165391,-13119866,13188608,
+ -11540496,-10751437,-13482671,29588810,2197295,},
+ {-1084082,11831693,6031797,14062724,14748428,
+ -8159962,-20721760,11742548,31368706,13161200,},},
+ {{2050412,-6457589,15321215,5273360,25484180,
+ 124590,-18187548,-7097255,-6691621,-14604792,},
+ {9938196,2162889,-6158074,-1711248,4278932,
+ -2598531,-22865792,-7168500,-24323168,11746309,},
+ {-22691768,-14268164,5965485,9383325,20443693,
+ 5854192,28250679,-1381811,-10837134,13717818,},},
+ {{-8495530,16382250,9548884,-4971523,-4491811,
+ -3902147,6182256,-12832479,26628081,10395408,},
+ {27329048,-15853735,7715764,8717446,-9215518,
+ -14633480,28982250,-5668414,4227628,242148,},
+ {-13279943,-7986904,-7100016,8764468,-27276630,
+ 3096719,29678419,-9141299,3906709,11265498,},},
+ {{11918285,15686328,-17757323,-11217300,-27548967,
+ 4853165,-27168827,6807359,6871949,-1075745,},
+ {-29002610,13984323,-27111812,-2713442,28107359,
+ -13266203,6155126,15104658,3538727,-7513788,},
+ {14103158,11233913,-33165269,9279850,31014152,
+ 4335090,-1827936,4590951,13960841,12787712,},},
+ {{1469134,-16738009,33411928,13942824,8092558,
+ -8778224,-11165065,1437842,22521552,-2792954,},
+ {31352705,-4807352,-25327300,3962447,12541566,
+ -9399651,-27425693,7964818,-23829869,5541287,},
+ {-25732021,-6864887,23848984,3039395,-9147354,
+ 6022816,-27421653,10590137,25309915,-1584678,},},
+ {{-22951376,5048948,31139401,-190316,-19542447,
+ -626310,-17486305,-16511925,-18851313,-12985140,},
+ {-9684890,14681754,30487568,7717771,-10829709,
+ 9630497,30290549,-10531496,-27798994,-13812825,},
+ {5827835,16097107,-24501327,12094619,7413972,
+ 11447087,28057551,-1793987,-14056981,4359312,},},
+ {{26323183,2342588,-21887793,-1623758,-6062284,
+ 2107090,-28724907,9036464,-19618351,-13055189,},
+ {-29697200,14829398,-4596333,14220089,-30022969,
+ 2955645,12094100,-13693652,-5941445,7047569,},
+ {-3201977,14413268,-12058324,-16417589,-9035655,
+ -7224648,9258160,1399236,30397584,-5684634,},},
+};
+
+static void lookup_add(ge *p, ge_precomp *tmp_c, fe tmp_a, fe tmp_b,
+ const ge_precomp comb[8], const u8 scalar[32], int i)
+{
+ u8 teeth = (u8)((scalar_bit(scalar, i) ) +
+ (scalar_bit(scalar, i + 32) << 1) +
+ (scalar_bit(scalar, i + 64) << 2) +
+ (scalar_bit(scalar, i + 96) << 3));
+ u8 high = teeth >> 3;
+ u8 index = (teeth ^ (high - 1)) & 7;
+ FOR (j, 0, 8) {
+ i32 select = 1 & (((j ^ index) - 1) >> 8);
+ fe_ccopy(tmp_c->Yp, comb[j].Yp, select);
+ fe_ccopy(tmp_c->Ym, comb[j].Ym, select);
+ fe_ccopy(tmp_c->T2, comb[j].T2, select);
+ }
+ fe_neg(tmp_a, tmp_c->T2);
+ fe_cswap(tmp_c->T2, tmp_a , high ^ 1);
+ fe_cswap(tmp_c->Yp, tmp_c->Ym, high ^ 1);
+ ge_madd(p, p, tmp_c, tmp_a, tmp_b);
+}
+
+// p = [scalar]B, where B is the base point
+static void ge_scalarmult_base(ge *p, const u8 scalar[32])
+{
+ // twin 4-bits signed combs, from Mike Hamburg's
+ // Fast and compact elliptic-curve cryptography (2012)
+ // 1 / 2 modulo L
+ static const u8 half_mod_L[32] = {
+ 247,233,122,46,141,49,9,44,107,206,123,81,239,124,111,10,
+ 0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,8,
+ };
+ // (2^256 - 1) / 2 modulo L
+ static const u8 half_ones[32] = {
+ 142,74,204,70,186,24,118,107,184,231,190,57,250,173,119,99,
+ 255,255,255,255,255,255,255,255,255,255,255,255,255,255,255,7,
+ };
+
+ // All bits set form: 1 means 1, 0 means -1
+ u8 s_scalar[32];
+ crypto_eddsa_mul_add(s_scalar, scalar, half_mod_L, half_ones);
+
+ // Double and add ladder
+ fe tmp_a, tmp_b; // temporaries for addition
+ ge_precomp tmp_c; // temporary for comb lookup
+ ge tmp_d; // temporary for doubling
+ fe_1(tmp_c.Yp);
+ fe_1(tmp_c.Ym);
+ fe_0(tmp_c.T2);
+
+ // Save a double on the first iteration
+ ge_zero(p);
+ lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_low , s_scalar, 31);
+ lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_high, s_scalar, 31+128);
+ // Regular double & add for the rest
+ for (int i = 30; i >= 0; i--) {
+ ge_double(p, p, &tmp_d);
+ lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_low , s_scalar, i);
+ lookup_add(p, &tmp_c, tmp_a, tmp_b, b_comb_high, s_scalar, i+128);
+ }
+ // Note: we could save one addition at the end if we assumed the
+ // scalar fit in 252 bits. Which it does in practice if it is
+ // selected at random. However, non-random, non-hashed scalars
+ // *can* overflow 252 bits in practice. Better account for that
+ // than leaving that kind of subtle corner case.
+
+ WIPE_BUFFER(tmp_a); WIPE_CTX(&tmp_d);
+ WIPE_BUFFER(tmp_b); WIPE_CTX(&tmp_c);
+ WIPE_BUFFER(s_scalar);
+}
+
+void crypto_eddsa_scalarbase(u8 point[32], const u8 scalar[32])
+{
+ ge P;
+ ge_scalarmult_base(&P, scalar);
+ ge_tobytes(point, &P);
+ WIPE_CTX(&P);
+}
+
+void crypto_eddsa_key_pair(u8 secret_key[64], u8 public_key[32], u8 seed[32])
+{
+ // To allow overlaps, observable writes happen in this order:
+ // 1. seed
+ // 2. secret_key
+ // 3. public_key
+ u8 a[64];
+ COPY(a, seed, 32);
+ crypto_wipe(seed, 32);
+ COPY(secret_key, a, 32);
+ crypto_blake2b(a, 64, a, 32);
+ crypto_eddsa_trim_scalar(a, a);
+ crypto_eddsa_scalarbase(secret_key + 32, a);
+ COPY(public_key, secret_key + 32, 32);
+ WIPE_BUFFER(a);
+}
+
+static void hash_reduce(u8 h[32],
+ const u8 *a, size_t a_size,
+ const u8 *b, size_t b_size,
+ const u8 *c, size_t c_size)
+{
+ u8 hash[64];
+ crypto_blake2b_ctx ctx;
+ crypto_blake2b_init (&ctx, 64);
+ crypto_blake2b_update(&ctx, a, a_size);
+ crypto_blake2b_update(&ctx, b, b_size);
+ crypto_blake2b_update(&ctx, c, c_size);
+ crypto_blake2b_final (&ctx, hash);
+ crypto_eddsa_reduce(h, hash);
+}
+
+// Digital signature of a message with from a secret key.
+//
+// The secret key comprises two parts:
+// - The seed that generates the key (secret_key[ 0..31])
+// - The public key (secret_key[32..63])
+//
+// The seed and the public key are bundled together to make sure users
+// don't use mismatched seeds and public keys, which would instantly
+// leak the secret scalar and allow forgeries (allowing this to happen
+// has resulted in critical vulnerabilities in the wild).
+//
+// The seed is hashed to derive the secret scalar and a secret prefix.
+// The sole purpose of the prefix is to generate a secret random nonce.
+// The properties of that nonce must be as follows:
+// - Unique: we need a different one for each message.
+// - Secret: third parties must not be able to predict it.
+// - Random: any detectable bias would break all security.
+//
+// There are two ways to achieve these properties. The obvious one is
+// to simply generate a random number. Here that would be a parameter
+// (Monocypher doesn't have an RNG). It works, but then users may reuse
+// the nonce by accident, which _also_ leaks the secret scalar and
+// allows forgeries. This has happened in the wild too.
+//
+// This is no good, so instead we generate that nonce deterministically
+// by reducing modulo L a hash of the secret prefix and the message.
+// The secret prefix makes the nonce unpredictable, the message makes it
+// unique, and the hash/reduce removes all bias.
+//
+// The cost of that safety is hashing the message twice. If that cost
+// is unacceptable, there are two alternatives:
+//
+// - Signing a hash of the message instead of the message itself. This
+// is fine as long as the hash is collision resistant. It is not
+// compatible with existing "pure" signatures, but at least it's safe.
+//
+// - Using a random nonce. Please exercise **EXTREME CAUTION** if you
+// ever do that. It is absolutely **critical** that the nonce is
+// really an unbiased random number between 0 and L-1, never reused,
+// and wiped immediately.
+//
+// To lower the likelihood of complete catastrophe if the RNG is
+// either flawed or misused, you can hash the RNG output together with
+// the secret prefix and the beginning of the message, and use the
+// reduction of that hash instead of the RNG output itself. It's not
+// foolproof (you'd need to hash the whole message) but it helps.
+//
+// Signing a message involves the following operations:
+//
+// scalar, prefix = HASH(secret_key)
+// r = HASH(prefix || message) % L
+// R = [r]B
+// h = HASH(R || public_key || message) % L
+// S = ((h * a) + r) % L
+// signature = R || S
+void crypto_eddsa_sign(u8 signature [64], const u8 secret_key[64],
+ const u8 *message, size_t message_size)
+{
+ u8 a[64]; // secret scalar and prefix
+ u8 r[32]; // secret deterministic "random" nonce
+ u8 h[32]; // publically verifiable hash of the message (not wiped)
+ u8 R[32]; // first half of the signature (allows overlapping inputs)
+
+ crypto_blake2b(a, 64, secret_key, 32);
+ crypto_eddsa_trim_scalar(a, a);
+ hash_reduce(r, a + 32, 32, message, message_size, 0, 0);
+ crypto_eddsa_scalarbase(R, r);
+ hash_reduce(h, R, 32, secret_key + 32, 32, message, message_size);
+ COPY(signature, R, 32);
+ crypto_eddsa_mul_add(signature + 32, h, a, r);
+
+ WIPE_BUFFER(a);
+ WIPE_BUFFER(r);
+}
+
+// To check the signature R, S of the message M with the public key A,
+// there are 3 steps:
+//
+// compute h = HASH(R || A || message) % L
+// check that A is on the curve.
+// check that R == [s]B - [h]A
+//
+// The last two steps are done in crypto_eddsa_check_equation()
+int crypto_eddsa_check(const u8 signature[64], const u8 public_key[32],
+ const u8 *message, size_t message_size)
+{
+ u8 h[32];
+ hash_reduce(h, signature, 32, public_key, 32, message, message_size);
+ return crypto_eddsa_check_equation(signature, public_key, h);
+}
+
+/////////////////////////
+/// EdDSA <--> X25519 ///
+/////////////////////////
+void crypto_eddsa_to_x25519(u8 x25519[32], const u8 eddsa[32])
+{
+ // (u, v) = ((1+y)/(1-y), sqrt(-486664)*u/x)
+ // Only converting y to u, the sign of x is ignored.
+ fe t1, t2;
+ fe_frombytes(t2, eddsa);
+ fe_add(t1, fe_one, t2);
+ fe_sub(t2, fe_one, t2);
+ fe_invert(t2, t2);
+ fe_mul(t1, t1, t2);
+ fe_tobytes(x25519, t1);
+ WIPE_BUFFER(t1);
+ WIPE_BUFFER(t2);
+}
+
+void crypto_x25519_to_eddsa(u8 eddsa[32], const u8 x25519[32])
+{
+ // (x, y) = (sqrt(-486664)*u/v, (u-1)/(u+1))
+ // Only converting u to y, x is assumed positive.
+ fe t1, t2;
+ fe_frombytes(t2, x25519);
+ fe_sub(t1, t2, fe_one);
+ fe_add(t2, t2, fe_one);
+ fe_invert(t2, t2);
+ fe_mul(t1, t1, t2);
+ fe_tobytes(eddsa, t1);
+ WIPE_BUFFER(t1);
+ WIPE_BUFFER(t2);
+}
+
+/////////////////////////////////////////////
+/// Dirty ephemeral public key generation ///
+/////////////////////////////////////////////
+
+// Those functions generates a public key, *without* clearing the
+// cofactor. Sending that key over the network leaks 3 bits of the
+// private key. Use only to generate ephemeral keys that will be hidden
+// with crypto_curve_to_hidden().
+//
+// The public key is otherwise compatible with crypto_x25519(), which
+// properly clears the cofactor.
+//
+// Note that the distribution of the resulting public keys is almost
+// uniform. Flipping the sign of the v coordinate (not provided by this
+// function), covers the entire key space almost perfectly, where
+// "almost" means a 2^-128 bias (undetectable). This uniformity is
+// needed to ensure the proper randomness of the resulting
+// representatives (once we apply crypto_curve_to_hidden()).
+//
+// Recall that Curve25519 has order C = 2^255 + e, with e < 2^128 (not
+// to be confused with the prime order of the main subgroup, L, which is
+// 8 times less than that).
+//
+// Generating all points would require us to multiply a point of order C
+// (the base point plus any point of order 8) by all scalars from 0 to
+// C-1. Clamping limits us to scalars between 2^254 and 2^255 - 1. But
+// by negating the resulting point at random, we also cover scalars from
+// -2^255 + 1 to -2^254 (which modulo C is congruent to e+1 to 2^254 + e).
+//
+// In practice:
+// - Scalars from 0 to e + 1 are never generated
+// - Scalars from 2^255 to 2^255 + e are never generated
+// - Scalars from 2^254 + 1 to 2^254 + e are generated twice
+//
+// Since e < 2^128, detecting this bias requires observing over 2^100
+// representatives from a given source (this will never happen), *and*
+// recovering enough of the private key to determine that they do, or do
+// not, belong to the biased set (this practically requires solving
+// discrete logarithm, which is conjecturally intractable).
+//
+// In practice, this means the bias is impossible to detect.
+
+// s + (x*L) % 8*L
+// Guaranteed to fit in 256 bits iff s fits in 255 bits.
+// L < 2^253
+// x%8 < 2^3
+// L * (x%8) < 2^255
+// s < 2^255
+// s + L * (x%8) < 2^256
+static void add_xl(u8 s[32], u8 x)
+{
+ u64 mod8 = x & 7;
+ u64 carry = 0;
+ FOR (i , 0, 8) {
+ carry = carry + load32_le(s + 4*i) + L[i] * mod8;
+ store32_le(s + 4*i, (u32)carry);
+ carry >>= 32;
+ }
+}
+
+// "Small" dirty ephemeral key.
+// Use if you need to shrink the size of the binary, and can afford to
+// slow down by a factor of two (compared to the fast version)
+//
+// This version works by decoupling the cofactor from the main factor.
+//
+// - The trimmed scalar determines the main factor
+// - The clamped bits of the scalar determine the cofactor.
+//
+// Cofactor and main factor are combined into a single scalar, which is
+// then multiplied by a point of order 8*L (unlike the base point, which
+// has prime order). That "dirty" base point is the addition of the
+// regular base point (9), and a point of order 8.
+void crypto_x25519_dirty_small(u8 public_key[32], const u8 secret_key[32])
+{
+ // Base point of order 8*L
+ // Raw scalar multiplication with it does not clear the cofactor,
+ // and the resulting public key will reveal 3 bits of the scalar.
+ //
+ // The low order component of this base point has been chosen
+ // to yield the same results as crypto_x25519_dirty_fast().
+ static const u8 dirty_base_point[32] = {
+ 0xd8, 0x86, 0x1a, 0xa2, 0x78, 0x7a, 0xd9, 0x26,
+ 0x8b, 0x74, 0x74, 0xb6, 0x82, 0xe3, 0xbe, 0xc3,
+ 0xce, 0x36, 0x9a, 0x1e, 0x5e, 0x31, 0x47, 0xa2,
+ 0x6d, 0x37, 0x7c, 0xfd, 0x20, 0xb5, 0xdf, 0x75,
+ };
+ // separate the main factor & the cofactor of the scalar
+ u8 scalar[32];
+ crypto_eddsa_trim_scalar(scalar, secret_key);
+
+ // Separate the main factor and the cofactor
+ //
+ // The scalar is trimmed, so its cofactor is cleared. The three
+ // least significant bits however still have a main factor. We must
+ // remove it for X25519 compatibility.
+ //
+ // cofactor = lsb * L (modulo 8*L)
+ // combined = scalar + cofactor (modulo 8*L)
+ add_xl(scalar, secret_key[0]);
+ scalarmult(public_key, scalar, dirty_base_point, 256);
+ WIPE_BUFFER(scalar);
+}
+
+// Select low order point
+// We're computing the [cofactor]lop scalar multiplication, where:
+//
+// cofactor = tweak & 7.
+// lop = (lop_x, lop_y)
+// lop_x = sqrt((sqrt(d + 1) + 1) / d)
+// lop_y = -lop_x * sqrtm1
+//
+// The low order point has order 8. There are 4 such points. We've
+// chosen the one whose both coordinates are positive (below p/2).
+// The 8 low order points are as follows:
+//
+// [0]lop = ( 0 , 1 )
+// [1]lop = ( lop_x , lop_y)
+// [2]lop = ( sqrt(-1), -0 )
+// [3]lop = ( lop_x , -lop_y)
+// [4]lop = (-0 , -1 )
+// [5]lop = (-lop_x , -lop_y)
+// [6]lop = (-sqrt(-1), 0 )
+// [7]lop = (-lop_x , lop_y)
+//
+// The x coordinate is either 0, sqrt(-1), lop_x, or their opposite.
+// The y coordinate is either 0, -1 , lop_y, or their opposite.
+// The pattern for both is the same, except for a rotation of 2 (modulo 8)
+//
+// This helper function captures the pattern, and we can use it thus:
+//
+// select_lop(x, lop_x, sqrtm1, cofactor);
+// select_lop(y, lop_y, fe_one, cofactor + 2);
+//
+// This is faster than an actual scalar multiplication,
+// and requires less code than naive constant time look up.
+static void select_lop(fe out, const fe x, const fe k, u8 cofactor)
+{
+ fe tmp;
+ fe_0(out);
+ fe_ccopy(out, k , (cofactor >> 1) & 1); // bit 1
+ fe_ccopy(out, x , (cofactor >> 0) & 1); // bit 0
+ fe_neg (tmp, out);
+ fe_ccopy(out, tmp, (cofactor >> 2) & 1); // bit 2
+ WIPE_BUFFER(tmp);
+}
+
+// "Fast" dirty ephemeral key
+// We use this one by default.
+//
+// This version works by performing a regular scalar multiplication,
+// then add a low order point. The scalar multiplication is done in
+// Edwards space for more speed (*2 compared to the "small" version).
+// The cost is a bigger binary for programs that don't also sign messages.
+void crypto_x25519_dirty_fast(u8 public_key[32], const u8 secret_key[32])
+{
+ // Compute clean scalar multiplication
+ u8 scalar[32];
+ ge pk;
+ crypto_eddsa_trim_scalar(scalar, secret_key);
+ ge_scalarmult_base(&pk, scalar);
+
+ // Compute low order point
+ fe t1, t2;
+ select_lop(t1, lop_x, sqrtm1, secret_key[0]);
+ select_lop(t2, lop_y, fe_one, secret_key[0] + 2);
+ ge_precomp low_order_point;
+ fe_add(low_order_point.Yp, t2, t1);
+ fe_sub(low_order_point.Ym, t2, t1);
+ fe_mul(low_order_point.T2, t2, t1);
+ fe_mul(low_order_point.T2, low_order_point.T2, D2);
+
+ // Add low order point to the public key
+ ge_madd(&pk, &pk, &low_order_point, t1, t2);
+
+ // Convert to Montgomery u coordinate (we ignore the sign)
+ fe_add(t1, pk.Z, pk.Y);
+ fe_sub(t2, pk.Z, pk.Y);
+ fe_invert(t2, t2);
+ fe_mul(t1, t1, t2);
+
+ fe_tobytes(public_key, t1);
+
+ WIPE_BUFFER(t1); WIPE_CTX(&pk);
+ WIPE_BUFFER(t2); WIPE_CTX(&low_order_point);
+ WIPE_BUFFER(scalar);
+}
+
+///////////////////
+/// Elligator 2 ///
+///////////////////
+static const fe A = {486662};
+
+// Elligator direct map
+//
+// Computes the point corresponding to a representative, encoded in 32
+// bytes (little Endian). Since positive representatives fits in 254
+// bits, The two most significant bits are ignored.
+//
+// From the paper:
+// w = -A / (fe(1) + non_square * r^2)
+// e = chi(w^3 + A*w^2 + w)
+// u = e*w - (fe(1)-e)*(A//2)
+// v = -e * sqrt(u^3 + A*u^2 + u)
+//
+// We ignore v because we don't need it for X25519 (the Montgomery
+// ladder only uses u).
+//
+// Note that e is either 0, 1 or -1
+// if e = 0 u = 0 and v = 0
+// if e = 1 u = w
+// if e = -1 u = -w - A = w * non_square * r^2
+//
+// Let r1 = non_square * r^2
+// Let r2 = 1 + r1
+// Note that r2 cannot be zero, -1/non_square is not a square.
+// We can (tediously) verify that:
+// w^3 + A*w^2 + w = (A^2*r1 - r2^2) * A / r2^3
+// Therefore:
+// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3))
+// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3)) * 1
+// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3)) * chi(r2^6)
+// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * (A / r2^3) * r2^6)
+// chi(w^3 + A*w^2 + w) = chi((A^2*r1 - r2^2) * A * r2^3)
+// Corollary:
+// e = 1 if (A^2*r1 - r2^2) * A * r2^3) is a non-zero square
+// e = -1 if (A^2*r1 - r2^2) * A * r2^3) is not a square
+// Note that w^3 + A*w^2 + w (and therefore e) can never be zero:
+// w^3 + A*w^2 + w = w * (w^2 + A*w + 1)
+// w^3 + A*w^2 + w = w * (w^2 + A*w + A^2/4 - A^2/4 + 1)
+// w^3 + A*w^2 + w = w * (w + A/2)^2 - A^2/4 + 1)
+// which is zero only if:
+// w = 0 (impossible)
+// (w + A/2)^2 = A^2/4 - 1 (impossible, because A^2/4-1 is not a square)
+//
+// Let isr = invsqrt((A^2*r1 - r2^2) * A * r2^3)
+// isr = sqrt(1 / ((A^2*r1 - r2^2) * A * r2^3)) if e = 1
+// isr = sqrt(sqrt(-1) / ((A^2*r1 - r2^2) * A * r2^3)) if e = -1
+//
+// if e = 1
+// let u1 = -A * (A^2*r1 - r2^2) * A * r2^2 * isr^2
+// u1 = w
+// u1 = u
+//
+// if e = -1
+// let ufactor = -non_square * sqrt(-1) * r^2
+// let vfactor = sqrt(ufactor)
+// let u2 = -A * (A^2*r1 - r2^2) * A * r2^2 * isr^2 * ufactor
+// u2 = w * -1 * -non_square * r^2
+// u2 = w * non_square * r^2
+// u2 = u
+void crypto_elligator_map(u8 curve[32], const u8 hidden[32])
+{
+ fe r, u, t1, t2, t3;
+ fe_frombytes_mask(r, hidden, 2); // r is encoded in 254 bits.
+ fe_sq(r, r);
+ fe_add(t1, r, r);
+ fe_add(u, t1, fe_one);
+ fe_sq (t2, u);
+ fe_mul(t3, A2, t1);
+ fe_sub(t3, t3, t2);
+ fe_mul(t3, t3, A);
+ fe_mul(t1, t2, u);
+ fe_mul(t1, t3, t1);
+ int is_square = invsqrt(t1, t1);
+ fe_mul(u, r, ufactor);
+ fe_ccopy(u, fe_one, is_square);
+ fe_sq (t1, t1);
+ fe_mul(u, u, A);
+ fe_mul(u, u, t3);
+ fe_mul(u, u, t2);
+ fe_mul(u, u, t1);
+ fe_neg(u, u);
+ fe_tobytes(curve, u);
+
+ WIPE_BUFFER(t1); WIPE_BUFFER(r);
+ WIPE_BUFFER(t2); WIPE_BUFFER(u);
+ WIPE_BUFFER(t3);
+}
+
+// Elligator inverse map
+//
+// Computes the representative of a point, if possible. If not, it does
+// nothing and returns -1. Note that the success of the operation
+// depends only on the point (more precisely its u coordinate). The
+// tweak parameter is used only upon success
+//
+// The tweak should be a random byte. Beyond that, its contents are an
+// implementation detail. Currently, the tweak comprises:
+// - Bit 1 : sign of the v coordinate (0 if positive, 1 if negative)
+// - Bit 2-5: not used
+// - Bits 6-7: random padding
+//
+// From the paper:
+// Let sq = -non_square * u * (u+A)
+// if sq is not a square, or u = -A, there is no mapping
+// Assuming there is a mapping:
+// if v is positive: r = sqrt(-u / (non_square * (u+A)))
+// if v is negative: r = sqrt(-(u+A) / (non_square * u ))
+//
+// We compute isr = invsqrt(-non_square * u * (u+A))
+// if it wasn't a square, abort.
+// else, isr = sqrt(-1 / (non_square * u * (u+A))
+//
+// If v is positive, we return isr * u:
+// isr * u = sqrt(-1 / (non_square * u * (u+A)) * u
+// isr * u = sqrt(-u / (non_square * (u+A))
+//
+// If v is negative, we return isr * (u+A):
+// isr * (u+A) = sqrt(-1 / (non_square * u * (u+A)) * (u+A)
+// isr * (u+A) = sqrt(-(u+A) / (non_square * u)
+int crypto_elligator_rev(u8 hidden[32], const u8 public_key[32], u8 tweak)
+{
+ fe t1, t2, t3;
+ fe_frombytes(t1, public_key); // t1 = u
+
+ fe_add(t2, t1, A); // t2 = u + A
+ fe_mul(t3, t1, t2);
+ fe_mul_small(t3, t3, -2);
+ int is_square = invsqrt(t3, t3); // t3 = sqrt(-1 / non_square * u * (u+A))
+ if (is_square) {
+ // The only variable time bit. This ultimately reveals how many
+ // tries it took us to find a representable key.
+ // This does not affect security as long as we try keys at random.
+
+ fe_ccopy (t1, t2, tweak & 1); // multiply by u if v is positive,
+ fe_mul (t3, t1, t3); // multiply by u+A otherwise
+ fe_mul_small(t1, t3, 2);
+ fe_neg (t2, t3);
+ fe_ccopy (t3, t2, fe_isodd(t1));
+ fe_tobytes(hidden, t3);
+
+ // Pad with two random bits
+ hidden[31] |= tweak & 0xc0;
+ }
+
+ WIPE_BUFFER(t1);
+ WIPE_BUFFER(t2);
+ WIPE_BUFFER(t3);
+ return is_square - 1;
+}
+
+void crypto_elligator_key_pair(u8 hidden[32], u8 secret_key[32], u8 seed[32])
+{
+ u8 pk [32]; // public key
+ u8 buf[64]; // seed + representative
+ COPY(buf + 32, seed, 32);
+ do {
+ crypto_chacha20_djb(buf, 0, 64, buf+32, zero, 0);
+ crypto_x25519_dirty_fast(pk, buf); // or the "small" version
+ } while(crypto_elligator_rev(buf+32, pk, buf[32]));
+ // Note that the return value of crypto_elligator_rev() is
+ // independent from its tweak parameter.
+ // Therefore, buf[32] is not actually reused. Either we loop one
+ // more time and buf[32] is used for the new seed, or we succeeded,
+ // and buf[32] becomes the tweak parameter.
+
+ crypto_wipe(seed, 32);
+ COPY(hidden , buf + 32, 32);
+ COPY(secret_key, buf , 32);
+ WIPE_BUFFER(buf);
+ WIPE_BUFFER(pk);
+}
+
+///////////////////////
+/// Scalar division ///
+///////////////////////
+
+// Montgomery reduction.
+// Divides x by (2^256), and reduces the result modulo L
+//
+// Precondition:
+// x < L * 2^256
+// Constants:
+// r = 2^256 (makes division by r trivial)
+// k = (r * (1/r) - 1) // L (1/r is computed modulo L )
+// Algorithm:
+// s = (x * k) % r
+// t = x + s*L (t is always a multiple of r)
+// u = (t/r) % L (u is always below 2*L, conditional subtraction is enough)
+static void redc(u32 u[8], u32 x[16])
+{
+ static const u32 k[8] = {
+ 0x12547e1b, 0xd2b51da3, 0xfdba84ff, 0xb1a206f2,
+ 0xffa36bea, 0x14e75438, 0x6fe91836, 0x9db6c6f2,
+ };
+
+ // s = x * k (modulo 2^256)
+ // This is cheaper than the full multiplication.
+ u32 s[8] = {0};
+ FOR (i, 0, 8) {
+ u64 carry = 0;
+ FOR (j, 0, 8-i) {
+ carry += s[i+j] + (u64)x[i] * k[j];
+ s[i+j] = (u32)carry;
+ carry >>= 32;
+ }
+ }
+ u32 t[16] = {0};
+ multiply(t, s, L);
+
+ // t = t + x
+ u64 carry = 0;
+ FOR (i, 0, 16) {
+ carry += (u64)t[i] + x[i];
+ t[i] = (u32)carry;
+ carry >>= 32;
+ }
+
+ // u = (t / 2^256) % L
+ // Note that t / 2^256 is always below 2*L,
+ // So a constant time conditional subtraction is enough
+ remove_l(u, t+8);
+
+ WIPE_BUFFER(s);
+ WIPE_BUFFER(t);
+}
+
+void crypto_x25519_inverse(u8 blind_salt [32], const u8 private_key[32],
+ const u8 curve_point[32])
+{
+ static const u8 Lm2[32] = { // L - 2
+ 0xeb, 0xd3, 0xf5, 0x5c, 0x1a, 0x63, 0x12, 0x58,
+ 0xd6, 0x9c, 0xf7, 0xa2, 0xde, 0xf9, 0xde, 0x14,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x10,
+ };
+ // 1 in Montgomery form
+ u32 m_inv [8] = {
+ 0x8d98951d, 0xd6ec3174, 0x737dcf70, 0xc6ef5bf4,
+ 0xfffffffe, 0xffffffff, 0xffffffff, 0x0fffffff,
+ };
+
+ u8 scalar[32];
+ crypto_eddsa_trim_scalar(scalar, private_key);
+
+ // Convert the scalar in Montgomery form
+ // m_scl = scalar * 2^256 (modulo L)
+ u32 m_scl[8];
+ {
+ u32 tmp[16];
+ ZERO(tmp, 8);
+ load32_le_buf(tmp+8, scalar, 8);
+ mod_l(scalar, tmp);
+ load32_le_buf(m_scl, scalar, 8);
+ WIPE_BUFFER(tmp); // Wipe ASAP to save stack space
+ }
+
+ // Compute the inverse
+ u32 product[16];
+ for (int i = 252; i >= 0; i--) {
+ ZERO(product, 16);
+ multiply(product, m_inv, m_inv);
+ redc(m_inv, product);
+ if (scalar_bit(Lm2, i)) {
+ ZERO(product, 16);
+ multiply(product, m_inv, m_scl);
+ redc(m_inv, product);
+ }
+ }
+ // Convert the inverse *out* of Montgomery form
+ // scalar = m_inv / 2^256 (modulo L)
+ COPY(product, m_inv, 8);
+ ZERO(product + 8, 8);
+ redc(m_inv, product);
+ store32_le_buf(scalar, m_inv, 8); // the *inverse* of the scalar
+
+ // Clear the cofactor of scalar:
+ // cleared = scalar * (3*L + 1) (modulo 8*L)
+ // cleared = scalar + scalar * 3 * L (modulo 8*L)
+ // Note that (scalar * 3) is reduced modulo 8, so we only need the
+ // first byte.
+ add_xl(scalar, scalar[0] * 3);
+
+ // Recall that 8*L < 2^256. However it is also very close to
+ // 2^255. If we spanned the ladder over 255 bits, random tests
+ // wouldn't catch the off-by-one error.
+ scalarmult(blind_salt, scalar, curve_point, 256);
+
+ WIPE_BUFFER(scalar); WIPE_BUFFER(m_scl);
+ WIPE_BUFFER(product); WIPE_BUFFER(m_inv);
+}
+
+////////////////////////////////
+/// Authenticated encryption ///
+////////////////////////////////
+static void lock_auth(u8 mac[16], const u8 auth_key[32],
+ const u8 *ad , size_t ad_size,
+ const u8 *cipher_text, size_t text_size)
+{
+ u8 sizes[16]; // Not secret, not wiped
+ store64_le(sizes + 0, ad_size);
+ store64_le(sizes + 8, text_size);
+ crypto_poly1305_ctx poly_ctx; // auto wiped...
+ crypto_poly1305_init (&poly_ctx, auth_key);
+ crypto_poly1305_update(&poly_ctx, ad , ad_size);
+ crypto_poly1305_update(&poly_ctx, zero , gap(ad_size, 16));
+ crypto_poly1305_update(&poly_ctx, cipher_text, text_size);
+ crypto_poly1305_update(&poly_ctx, zero , gap(text_size, 16));
+ crypto_poly1305_update(&poly_ctx, sizes , 16);
+ crypto_poly1305_final (&poly_ctx, mac); // ...here
+}
+
+void crypto_aead_init_x(crypto_aead_ctx *ctx,
+ u8 const key[32], const u8 nonce[24])
+{
+ crypto_chacha20_h(ctx->key, key, nonce);
+ COPY(ctx->nonce, nonce + 16, 8);
+ ctx->counter = 0;
+}
+
+void crypto_aead_init_djb(crypto_aead_ctx *ctx,
+ const u8 key[32], const u8 nonce[8])
+{
+ COPY(ctx->key , key , 32);
+ COPY(ctx->nonce, nonce, 8);
+ ctx->counter = 0;
+}
+
+void crypto_aead_init_ietf(crypto_aead_ctx *ctx,
+ const u8 key[32], const u8 nonce[12])
+{
+ COPY(ctx->key , key , 32);
+ COPY(ctx->nonce, nonce + 4, 8);
+ ctx->counter = (u64)load32_le(nonce) << 32;
+}
+
+void crypto_aead_write(crypto_aead_ctx *ctx, u8 *cipher_text, u8 mac[16],
+ const u8 *ad, size_t ad_size,
+ const u8 *plain_text, size_t text_size)
+{
+ u8 auth_key[64]; // the last 32 bytes are used for rekeying.
+ crypto_chacha20_djb(auth_key, 0, 64, ctx->key, ctx->nonce, ctx->counter);
+ crypto_chacha20_djb(cipher_text, plain_text, text_size,
+ ctx->key, ctx->nonce, ctx->counter + 1);
+ lock_auth(mac, auth_key, ad, ad_size, cipher_text, text_size);
+ COPY(ctx->key, auth_key + 32, 32);
+ WIPE_BUFFER(auth_key);
+}
+
+int crypto_aead_read(crypto_aead_ctx *ctx, u8 *plain_text, const u8 mac[16],
+ const u8 *ad, size_t ad_size,
+ const u8 *cipher_text, size_t text_size)
+{
+ u8 auth_key[64]; // the last 32 bytes are used for rekeying.
+ u8 real_mac[16];
+ crypto_chacha20_djb(auth_key, 0, 64, ctx->key, ctx->nonce, ctx->counter);
+ lock_auth(real_mac, auth_key, ad, ad_size, cipher_text, text_size);
+ int mismatch = crypto_verify16(mac, real_mac);
+ if (!mismatch) {
+ crypto_chacha20_djb(plain_text, cipher_text, text_size,
+ ctx->key, ctx->nonce, ctx->counter + 1);
+ COPY(ctx->key, auth_key + 32, 32);
+ }
+ WIPE_BUFFER(auth_key);
+ WIPE_BUFFER(real_mac);
+ return mismatch;
+}
+
+void crypto_aead_lock(u8 *cipher_text, u8 mac[16], const u8 key[32],
+ const u8 nonce[24], const u8 *ad, size_t ad_size,
+ const u8 *plain_text, size_t text_size)
+{
+ crypto_aead_ctx ctx;
+ crypto_aead_init_x(&ctx, key, nonce);
+ crypto_aead_write(&ctx, cipher_text, mac, ad, ad_size,
+ plain_text, text_size);
+ crypto_wipe(&ctx, sizeof(ctx));
+}
+
+int crypto_aead_unlock(u8 *plain_text, const u8 mac[16], const u8 key[32],
+ const u8 nonce[24], const u8 *ad, size_t ad_size,
+ const u8 *cipher_text, size_t text_size)
+{
+ crypto_aead_ctx ctx;
+ crypto_aead_init_x(&ctx, key, nonce);
+ int mismatch = crypto_aead_read(&ctx, plain_text, mac, ad, ad_size,
+ cipher_text, text_size);
+ crypto_wipe(&ctx, sizeof(ctx));
+ return mismatch;
+}
+
+#ifdef MONOCYPHER_CPP_NAMESPACE
+}
+#endif
diff --git a/third_party/monocypher/monocypher.h b/third_party/monocypher/monocypher.h
new file mode 100644
index 0000000..cf635e8
--- /dev/null
+++ b/third_party/monocypher/monocypher.h
@@ -0,0 +1,321 @@
+// Monocypher version __git__
+//
+// This file is dual-licensed. Choose whichever licence you want from
+// the two licences listed below.
+//
+// The first licence is a regular 2-clause BSD licence. The second licence
+// is the CC-0 from Creative Commons. It is intended to release Monocypher
+// to the public domain. The BSD licence serves as a fallback option.
+//
+// SPDX-License-Identifier: BSD-2-Clause OR CC0-1.0
+//
+// ------------------------------------------------------------------------
+//
+// Copyright (c) 2017-2019, Loup Vaillant
+// All rights reserved.
+//
+//
+// Redistribution and use in source and binary forms, with or without
+// modification, are permitted provided that the following conditions are
+// met:
+//
+// 1. Redistributions of source code must retain the above copyright
+// notice, this list of conditions and the following disclaimer.
+//
+// 2. Redistributions in binary form must reproduce the above copyright
+// notice, this list of conditions and the following disclaimer in the
+// documentation and/or other materials provided with the
+// distribution.
+//
+// THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+// "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+// LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+// A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+// HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+// SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+// LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+// DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+// THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+// (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+// OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+//
+// ------------------------------------------------------------------------
+//
+// Written in 2017-2019 by Loup Vaillant
+//
+// To the extent possible under law, the author(s) have dedicated all copyright
+// and related neighboring rights to this software to the public domain
+// worldwide. This software is distributed without any warranty.
+//
+// You should have received a copy of the CC0 Public Domain Dedication along
+// with this software. If not, see
+// <https://creativecommons.org/publicdomain/zero/1.0/>
+
+#ifndef MONOCYPHER_H
+#define MONOCYPHER_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#ifdef MONOCYPHER_CPP_NAMESPACE
+namespace MONOCYPHER_CPP_NAMESPACE {
+#elif defined(__cplusplus)
+extern "C" {
+#endif
+
+// Constant time comparisons
+// -------------------------
+
+// Return 0 if a and b are equal, -1 otherwise
+int crypto_verify16(const uint8_t a[16], const uint8_t b[16]);
+int crypto_verify32(const uint8_t a[32], const uint8_t b[32]);
+int crypto_verify64(const uint8_t a[64], const uint8_t b[64]);
+
+
+// Erase sensitive data
+// --------------------
+void crypto_wipe(void *secret, size_t size);
+
+
+// Authenticated encryption
+// ------------------------
+void crypto_aead_lock(uint8_t *cipher_text,
+ uint8_t mac [16],
+ const uint8_t key [32],
+ const uint8_t nonce[24],
+ const uint8_t *ad, size_t ad_size,
+ const uint8_t *plain_text, size_t text_size);
+int crypto_aead_unlock(uint8_t *plain_text,
+ const uint8_t mac [16],
+ const uint8_t key [32],
+ const uint8_t nonce[24],
+ const uint8_t *ad, size_t ad_size,
+ const uint8_t *cipher_text, size_t text_size);
+
+// Authenticated stream
+// --------------------
+typedef struct {
+ uint64_t counter;
+ uint8_t key[32];
+ uint8_t nonce[8];
+} crypto_aead_ctx;
+
+void crypto_aead_init_x(crypto_aead_ctx *ctx,
+ const uint8_t key[32], const uint8_t nonce[24]);
+void crypto_aead_init_djb(crypto_aead_ctx *ctx,
+ const uint8_t key[32], const uint8_t nonce[8]);
+void crypto_aead_init_ietf(crypto_aead_ctx *ctx,
+ const uint8_t key[32], const uint8_t nonce[12]);
+
+void crypto_aead_write(crypto_aead_ctx *ctx,
+ uint8_t *cipher_text,
+ uint8_t mac[16],
+ const uint8_t *ad , size_t ad_size,
+ const uint8_t *plain_text, size_t text_size);
+int crypto_aead_read(crypto_aead_ctx *ctx,
+ uint8_t *plain_text,
+ const uint8_t mac[16],
+ const uint8_t *ad , size_t ad_size,
+ const uint8_t *cipher_text, size_t text_size);
+
+
+// General purpose hash (BLAKE2b)
+// ------------------------------
+
+// Direct interface
+void crypto_blake2b(uint8_t *hash, size_t hash_size,
+ const uint8_t *message, size_t message_size);
+
+void crypto_blake2b_keyed(uint8_t *hash, size_t hash_size,
+ const uint8_t *key, size_t key_size,
+ const uint8_t *message, size_t message_size);
+
+// Incremental interface
+typedef struct {
+ // Do not rely on the size or contents of this type,
+ // for they may change without notice.
+ uint64_t hash[8];
+ uint64_t input_offset[2];
+ uint64_t input[16];
+ size_t input_idx;
+ size_t hash_size;
+} crypto_blake2b_ctx;
+
+void crypto_blake2b_init(crypto_blake2b_ctx *ctx, size_t hash_size);
+void crypto_blake2b_keyed_init(crypto_blake2b_ctx *ctx, size_t hash_size,
+ const uint8_t *key, size_t key_size);
+void crypto_blake2b_update(crypto_blake2b_ctx *ctx,
+ const uint8_t *message, size_t message_size);
+void crypto_blake2b_final(crypto_blake2b_ctx *ctx, uint8_t *hash);
+
+
+// Password key derivation (Argon2)
+// --------------------------------
+#define CRYPTO_ARGON2_D 0
+#define CRYPTO_ARGON2_I 1
+#define CRYPTO_ARGON2_ID 2
+
+typedef struct {
+ uint32_t algorithm; // Argon2d, Argon2i, Argon2id
+ uint32_t nb_blocks; // memory hardness, >= 8 * nb_lanes
+ uint32_t nb_passes; // CPU hardness, >= 1 (>= 3 recommended for Argon2i)
+ uint32_t nb_lanes; // parallelism level (single threaded anyway)
+} crypto_argon2_config;
+
+typedef struct {
+ const uint8_t *pass;
+ const uint8_t *salt;
+ uint32_t pass_size;
+ uint32_t salt_size; // 16 bytes recommended
+} crypto_argon2_inputs;
+
+typedef struct {
+ const uint8_t *key; // may be NULL if no key
+ const uint8_t *ad; // may be NULL if no additional data
+ uint32_t key_size; // 0 if no key (32 bytes recommended otherwise)
+ uint32_t ad_size; // 0 if no additional data
+} crypto_argon2_extras;
+
+extern const crypto_argon2_extras crypto_argon2_no_extras;
+
+void crypto_argon2(uint8_t *hash, uint32_t hash_size, void *work_area,
+ crypto_argon2_config config,
+ crypto_argon2_inputs inputs,
+ crypto_argon2_extras extras);
+
+
+// Key exchange (X-25519)
+// ----------------------
+
+// Shared secrets are not quite random.
+// Hash them to derive an actual shared key.
+void crypto_x25519_public_key(uint8_t public_key[32],
+ const uint8_t secret_key[32]);
+void crypto_x25519(uint8_t raw_shared_secret[32],
+ const uint8_t your_secret_key [32],
+ const uint8_t their_public_key [32]);
+
+// Conversion to EdDSA
+void crypto_x25519_to_eddsa(uint8_t eddsa[32], const uint8_t x25519[32]);
+
+// scalar "division"
+// Used for OPRF. Be aware that exponential blinding is less secure
+// than Diffie-Hellman key exchange.
+void crypto_x25519_inverse(uint8_t blind_salt [32],
+ const uint8_t private_key[32],
+ const uint8_t curve_point[32]);
+
+// "Dirty" versions of x25519_public_key().
+// Use with crypto_elligator_rev().
+// Leaks 3 bits of the private key.
+void crypto_x25519_dirty_small(uint8_t pk[32], const uint8_t sk[32]);
+void crypto_x25519_dirty_fast (uint8_t pk[32], const uint8_t sk[32]);
+
+
+// Signatures
+// ----------
+
+// EdDSA with curve25519 + BLAKE2b
+void crypto_eddsa_key_pair(uint8_t secret_key[64],
+ uint8_t public_key[32],
+ uint8_t seed[32]);
+void crypto_eddsa_sign(uint8_t signature [64],
+ const uint8_t secret_key[64],
+ const uint8_t *message, size_t message_size);
+int crypto_eddsa_check(const uint8_t signature [64],
+ const uint8_t public_key[32],
+ const uint8_t *message, size_t message_size);
+
+// Conversion to X25519
+void crypto_eddsa_to_x25519(uint8_t x25519[32], const uint8_t eddsa[32]);
+
+// EdDSA building blocks
+void crypto_eddsa_trim_scalar(uint8_t out[32], const uint8_t in[32]);
+void crypto_eddsa_reduce(uint8_t reduced[32], const uint8_t expanded[64]);
+void crypto_eddsa_mul_add(uint8_t r[32],
+ const uint8_t a[32],
+ const uint8_t b[32],
+ const uint8_t c[32]);
+void crypto_eddsa_scalarbase(uint8_t point[32], const uint8_t scalar[32]);
+int crypto_eddsa_check_equation(const uint8_t signature[64],
+ const uint8_t public_key[32],
+ const uint8_t h_ram[32]);
+
+
+// Chacha20
+// --------
+
+// Specialised hash.
+// Used to hash X25519 shared secrets.
+void crypto_chacha20_h(uint8_t out[32],
+ const uint8_t key[32],
+ const uint8_t in [16]);
+
+// Unauthenticated stream cipher.
+// Don't forget to add authentication.
+uint64_t crypto_chacha20_djb(uint8_t *cipher_text,
+ const uint8_t *plain_text,
+ size_t text_size,
+ const uint8_t key[32],
+ const uint8_t nonce[8],
+ uint64_t ctr);
+uint32_t crypto_chacha20_ietf(uint8_t *cipher_text,
+ const uint8_t *plain_text,
+ size_t text_size,
+ const uint8_t key[32],
+ const uint8_t nonce[12],
+ uint32_t ctr);
+uint64_t crypto_chacha20_x(uint8_t *cipher_text,
+ const uint8_t *plain_text,
+ size_t text_size,
+ const uint8_t key[32],
+ const uint8_t nonce[24],
+ uint64_t ctr);
+
+
+// Poly 1305
+// ---------
+
+// This is a *one time* authenticator.
+// Disclosing the mac reveals the key.
+// See crypto_lock() on how to use it properly.
+
+// Direct interface
+void crypto_poly1305(uint8_t mac[16],
+ const uint8_t *message, size_t message_size,
+ const uint8_t key[32]);
+
+// Incremental interface
+typedef struct {
+ // Do not rely on the size or contents of this type,
+ // for they may change without notice.
+ uint8_t c[16]; // chunk of the message
+ size_t c_idx; // How many bytes are there in the chunk.
+ uint32_t r [4]; // constant multiplier (from the secret key)
+ uint32_t pad[4]; // random number added at the end (from the secret key)
+ uint32_t h [5]; // accumulated hash
+} crypto_poly1305_ctx;
+
+void crypto_poly1305_init (crypto_poly1305_ctx *ctx, const uint8_t key[32]);
+void crypto_poly1305_update(crypto_poly1305_ctx *ctx,
+ const uint8_t *message, size_t message_size);
+void crypto_poly1305_final (crypto_poly1305_ctx *ctx, uint8_t mac[16]);
+
+
+// Elligator 2
+// -----------
+
+// Elligator mappings proper
+void crypto_elligator_map(uint8_t curve [32], const uint8_t hidden[32]);
+int crypto_elligator_rev(uint8_t hidden[32], const uint8_t curve [32],
+ uint8_t tweak);
+
+// Easy to use key pair generation
+void crypto_elligator_key_pair(uint8_t hidden[32], uint8_t secret_key[32],
+ uint8_t seed[32]);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif // MONOCYPHER_H
diff --git a/third_party/tinyaes/aes.c b/third_party/tinyaes/aes.c
new file mode 100644
index 0000000..4481f7b
--- /dev/null
+++ b/third_party/tinyaes/aes.c
@@ -0,0 +1,572 @@
+/*
+
+This is an implementation of the AES algorithm, specifically ECB, CTR and CBC mode.
+Block size can be chosen in aes.h - available choices are AES128, AES192, AES256.
+
+The implementation is verified against the test vectors in:
+ National Institute of Standards and Technology Special Publication 800-38A 2001 ED
+
+ECB-AES128
+----------
+
+ plain-text:
+ 6bc1bee22e409f96e93d7e117393172a
+ ae2d8a571e03ac9c9eb76fac45af8e51
+ 30c81c46a35ce411e5fbc1191a0a52ef
+ f69f2445df4f9b17ad2b417be66c3710
+
+ key:
+ 2b7e151628aed2a6abf7158809cf4f3c
+
+ resulting cipher
+ 3ad77bb40d7a3660a89ecaf32466ef97
+ f5d3d58503b9699de785895a96fdbaaf
+ 43b1cd7f598ece23881b00e3ed030688
+ 7b0c785e27e8ad3f8223207104725dd4
+
+
+NOTE: String length must be evenly divisible by 16byte (str_len % 16 == 0)
+ You should pad the end of the string with zeros if this is not the case.
+ For AES192/256 the key size is proportionally larger.
+
+*/
+
+
+/*****************************************************************************/
+/* Includes: */
+/*****************************************************************************/
+#include <string.h> // CBC mode, for memset
+#include "aes.h"
+
+/*****************************************************************************/
+/* Defines: */
+/*****************************************************************************/
+// The number of columns comprising a state in AES. This is a constant in AES. Value=4
+#define Nb 4
+
+#if defined(AES256) && (AES256 == 1)
+ #define Nk 8
+ #define Nr 14
+#elif defined(AES192) && (AES192 == 1)
+ #define Nk 6
+ #define Nr 12
+#else
+ #define Nk 4 // The number of 32 bit words in a key.
+ #define Nr 10 // The number of rounds in AES Cipher.
+#endif
+
+// jcallan@github points out that declaring Multiply as a function
+// reduces code size considerably with the Keil ARM compiler.
+// See this link for more information: https://github.com/kokke/tiny-AES-C/pull/3
+#ifndef MULTIPLY_AS_A_FUNCTION
+ #define MULTIPLY_AS_A_FUNCTION 0
+#endif
+
+
+
+
+/*****************************************************************************/
+/* Private variables: */
+/*****************************************************************************/
+// state - array holding the intermediate results during decryption.
+typedef uint8_t state_t[4][4];
+
+
+
+// The lookup-tables are marked const so they can be placed in read-only storage instead of RAM
+// The numbers below can be computed dynamically trading ROM for RAM -
+// This can be useful in (embedded) bootloader applications, where ROM is often limited.
+static const uint8_t sbox[256] = {
+ //0 1 2 3 4 5 6 7 8 9 A B C D E F
+ 0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
+ 0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
+ 0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
+ 0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
+ 0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
+ 0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
+ 0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
+ 0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
+ 0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
+ 0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
+ 0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
+ 0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
+ 0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
+ 0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
+ 0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
+ 0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16 };
+
+#if (defined(CBC) && CBC == 1) || (defined(ECB) && ECB == 1)
+static const uint8_t rsbox[256] = {
+ 0x52, 0x09, 0x6a, 0xd5, 0x30, 0x36, 0xa5, 0x38, 0xbf, 0x40, 0xa3, 0x9e, 0x81, 0xf3, 0xd7, 0xfb,
+ 0x7c, 0xe3, 0x39, 0x82, 0x9b, 0x2f, 0xff, 0x87, 0x34, 0x8e, 0x43, 0x44, 0xc4, 0xde, 0xe9, 0xcb,
+ 0x54, 0x7b, 0x94, 0x32, 0xa6, 0xc2, 0x23, 0x3d, 0xee, 0x4c, 0x95, 0x0b, 0x42, 0xfa, 0xc3, 0x4e,
+ 0x08, 0x2e, 0xa1, 0x66, 0x28, 0xd9, 0x24, 0xb2, 0x76, 0x5b, 0xa2, 0x49, 0x6d, 0x8b, 0xd1, 0x25,
+ 0x72, 0xf8, 0xf6, 0x64, 0x86, 0x68, 0x98, 0x16, 0xd4, 0xa4, 0x5c, 0xcc, 0x5d, 0x65, 0xb6, 0x92,
+ 0x6c, 0x70, 0x48, 0x50, 0xfd, 0xed, 0xb9, 0xda, 0x5e, 0x15, 0x46, 0x57, 0xa7, 0x8d, 0x9d, 0x84,
+ 0x90, 0xd8, 0xab, 0x00, 0x8c, 0xbc, 0xd3, 0x0a, 0xf7, 0xe4, 0x58, 0x05, 0xb8, 0xb3, 0x45, 0x06,
+ 0xd0, 0x2c, 0x1e, 0x8f, 0xca, 0x3f, 0x0f, 0x02, 0xc1, 0xaf, 0xbd, 0x03, 0x01, 0x13, 0x8a, 0x6b,
+ 0x3a, 0x91, 0x11, 0x41, 0x4f, 0x67, 0xdc, 0xea, 0x97, 0xf2, 0xcf, 0xce, 0xf0, 0xb4, 0xe6, 0x73,
+ 0x96, 0xac, 0x74, 0x22, 0xe7, 0xad, 0x35, 0x85, 0xe2, 0xf9, 0x37, 0xe8, 0x1c, 0x75, 0xdf, 0x6e,
+ 0x47, 0xf1, 0x1a, 0x71, 0x1d, 0x29, 0xc5, 0x89, 0x6f, 0xb7, 0x62, 0x0e, 0xaa, 0x18, 0xbe, 0x1b,
+ 0xfc, 0x56, 0x3e, 0x4b, 0xc6, 0xd2, 0x79, 0x20, 0x9a, 0xdb, 0xc0, 0xfe, 0x78, 0xcd, 0x5a, 0xf4,
+ 0x1f, 0xdd, 0xa8, 0x33, 0x88, 0x07, 0xc7, 0x31, 0xb1, 0x12, 0x10, 0x59, 0x27, 0x80, 0xec, 0x5f,
+ 0x60, 0x51, 0x7f, 0xa9, 0x19, 0xb5, 0x4a, 0x0d, 0x2d, 0xe5, 0x7a, 0x9f, 0x93, 0xc9, 0x9c, 0xef,
+ 0xa0, 0xe0, 0x3b, 0x4d, 0xae, 0x2a, 0xf5, 0xb0, 0xc8, 0xeb, 0xbb, 0x3c, 0x83, 0x53, 0x99, 0x61,
+ 0x17, 0x2b, 0x04, 0x7e, 0xba, 0x77, 0xd6, 0x26, 0xe1, 0x69, 0x14, 0x63, 0x55, 0x21, 0x0c, 0x7d };
+#endif
+
+// The round constant word array, Rcon[i], contains the values given by
+// x to the power (i-1) being powers of x (x is denoted as {02}) in the field GF(2^8)
+static const uint8_t Rcon[11] = {
+ 0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36 };
+
+/*
+ * Jordan Goulder points out in PR #12 (https://github.com/kokke/tiny-AES-C/pull/12),
+ * that you can remove most of the elements in the Rcon array, because they are unused.
+ *
+ * From Wikipedia's article on the Rijndael key schedule @ https://en.wikipedia.org/wiki/Rijndael_key_schedule#Rcon
+ *
+ * "Only the first some of these constants are actually used – up to rcon[10] for AES-128 (as 11 round keys are needed),
+ * up to rcon[8] for AES-192, up to rcon[7] for AES-256. rcon[0] is not used in AES algorithm."
+ */
+
+
+/*****************************************************************************/
+/* Private functions: */
+/*****************************************************************************/
+/*
+static uint8_t getSBoxValue(uint8_t num)
+{
+ return sbox[num];
+}
+*/
+#define getSBoxValue(num) (sbox[(num)])
+
+// This function produces Nb(Nr+1) round keys. The round keys are used in each round to decrypt the states.
+static void KeyExpansion(uint8_t* RoundKey, const uint8_t* Key)
+{
+ unsigned i, j, k;
+ uint8_t tempa[4]; // Used for the column/row operations
+
+ // The first round key is the key itself.
+ for (i = 0; i < Nk; ++i)
+ {
+ RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
+ RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
+ RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
+ RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
+ }
+
+ // All other round keys are found from the previous round keys.
+ for (i = Nk; i < Nb * (Nr + 1); ++i)
+ {
+ {
+ k = (i - 1) * 4;
+ tempa[0]=RoundKey[k + 0];
+ tempa[1]=RoundKey[k + 1];
+ tempa[2]=RoundKey[k + 2];
+ tempa[3]=RoundKey[k + 3];
+
+ }
+
+ if (i % Nk == 0)
+ {
+ // This function shifts the 4 bytes in a word to the left once.
+ // [a0,a1,a2,a3] becomes [a1,a2,a3,a0]
+
+ // Function RotWord()
+ {
+ const uint8_t u8tmp = tempa[0];
+ tempa[0] = tempa[1];
+ tempa[1] = tempa[2];
+ tempa[2] = tempa[3];
+ tempa[3] = u8tmp;
+ }
+
+ // SubWord() is a function that takes a four-byte input word and
+ // applies the S-box to each of the four bytes to produce an output word.
+
+ // Function Subword()
+ {
+ tempa[0] = getSBoxValue(tempa[0]);
+ tempa[1] = getSBoxValue(tempa[1]);
+ tempa[2] = getSBoxValue(tempa[2]);
+ tempa[3] = getSBoxValue(tempa[3]);
+ }
+
+ tempa[0] = tempa[0] ^ Rcon[i/Nk];
+ }
+#if defined(AES256) && (AES256 == 1)
+ if (i % Nk == 4)
+ {
+ // Function Subword()
+ {
+ tempa[0] = getSBoxValue(tempa[0]);
+ tempa[1] = getSBoxValue(tempa[1]);
+ tempa[2] = getSBoxValue(tempa[2]);
+ tempa[3] = getSBoxValue(tempa[3]);
+ }
+ }
+#endif
+ j = i * 4; k=(i - Nk) * 4;
+ RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
+ RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
+ RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
+ RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
+ }
+}
+
+void AES_init_ctx(struct AES_ctx* ctx, const uint8_t* key)
+{
+ KeyExpansion(ctx->RoundKey, key);
+}
+#if (defined(CBC) && (CBC == 1)) || (defined(CTR) && (CTR == 1))
+void AES_init_ctx_iv(struct AES_ctx* ctx, const uint8_t* key, const uint8_t* iv)
+{
+ KeyExpansion(ctx->RoundKey, key);
+ memcpy (ctx->Iv, iv, AES_BLOCKLEN);
+}
+void AES_ctx_set_iv(struct AES_ctx* ctx, const uint8_t* iv)
+{
+ memcpy (ctx->Iv, iv, AES_BLOCKLEN);
+}
+#endif
+
+// This function adds the round key to state.
+// The round key is added to the state by an XOR function.
+static void AddRoundKey(uint8_t round, state_t* state, const uint8_t* RoundKey)
+{
+ uint8_t i,j;
+ for (i = 0; i < 4; ++i)
+ {
+ for (j = 0; j < 4; ++j)
+ {
+ (*state)[i][j] ^= RoundKey[(round * Nb * 4) + (i * Nb) + j];
+ }
+ }
+}
+
+// The SubBytes Function Substitutes the values in the
+// state matrix with values in an S-box.
+static void SubBytes(state_t* state)
+{
+ uint8_t i, j;
+ for (i = 0; i < 4; ++i)
+ {
+ for (j = 0; j < 4; ++j)
+ {
+ (*state)[j][i] = getSBoxValue((*state)[j][i]);
+ }
+ }
+}
+
+// The ShiftRows() function shifts the rows in the state to the left.
+// Each row is shifted with different offset.
+// Offset = Row number. So the first row is not shifted.
+static void ShiftRows(state_t* state)
+{
+ uint8_t temp;
+
+ // Rotate first row 1 columns to left
+ temp = (*state)[0][1];
+ (*state)[0][1] = (*state)[1][1];
+ (*state)[1][1] = (*state)[2][1];
+ (*state)[2][1] = (*state)[3][1];
+ (*state)[3][1] = temp;
+
+ // Rotate second row 2 columns to left
+ temp = (*state)[0][2];
+ (*state)[0][2] = (*state)[2][2];
+ (*state)[2][2] = temp;
+
+ temp = (*state)[1][2];
+ (*state)[1][2] = (*state)[3][2];
+ (*state)[3][2] = temp;
+
+ // Rotate third row 3 columns to left
+ temp = (*state)[0][3];
+ (*state)[0][3] = (*state)[3][3];
+ (*state)[3][3] = (*state)[2][3];
+ (*state)[2][3] = (*state)[1][3];
+ (*state)[1][3] = temp;
+}
+
+static uint8_t xtime(uint8_t x)
+{
+ return ((x<<1) ^ (((x>>7) & 1) * 0x1b));
+}
+
+// MixColumns function mixes the columns of the state matrix
+static void MixColumns(state_t* state)
+{
+ uint8_t i;
+ uint8_t Tmp, Tm, t;
+ for (i = 0; i < 4; ++i)
+ {
+ t = (*state)[i][0];
+ Tmp = (*state)[i][0] ^ (*state)[i][1] ^ (*state)[i][2] ^ (*state)[i][3] ;
+ Tm = (*state)[i][0] ^ (*state)[i][1] ; Tm = xtime(Tm); (*state)[i][0] ^= Tm ^ Tmp ;
+ Tm = (*state)[i][1] ^ (*state)[i][2] ; Tm = xtime(Tm); (*state)[i][1] ^= Tm ^ Tmp ;
+ Tm = (*state)[i][2] ^ (*state)[i][3] ; Tm = xtime(Tm); (*state)[i][2] ^= Tm ^ Tmp ;
+ Tm = (*state)[i][3] ^ t ; Tm = xtime(Tm); (*state)[i][3] ^= Tm ^ Tmp ;
+ }
+}
+
+// Multiply is used to multiply numbers in the field GF(2^8)
+// Note: The last call to xtime() is unneeded, but often ends up generating a smaller binary
+// The compiler seems to be able to vectorize the operation better this way.
+// See https://github.com/kokke/tiny-AES-c/pull/34
+#if MULTIPLY_AS_A_FUNCTION
+static uint8_t Multiply(uint8_t x, uint8_t y)
+{
+ return (((y & 1) * x) ^
+ ((y>>1 & 1) * xtime(x)) ^
+ ((y>>2 & 1) * xtime(xtime(x))) ^
+ ((y>>3 & 1) * xtime(xtime(xtime(x)))) ^
+ ((y>>4 & 1) * xtime(xtime(xtime(xtime(x)))))); /* this last call to xtime() can be omitted */
+ }
+#else
+#define Multiply(x, y) \
+ ( ((y & 1) * x) ^ \
+ ((y>>1 & 1) * xtime(x)) ^ \
+ ((y>>2 & 1) * xtime(xtime(x))) ^ \
+ ((y>>3 & 1) * xtime(xtime(xtime(x)))) ^ \
+ ((y>>4 & 1) * xtime(xtime(xtime(xtime(x)))))) \
+
+#endif
+
+#if (defined(CBC) && CBC == 1) || (defined(ECB) && ECB == 1)
+/*
+static uint8_t getSBoxInvert(uint8_t num)
+{
+ return rsbox[num];
+}
+*/
+#define getSBoxInvert(num) (rsbox[(num)])
+
+// MixColumns function mixes the columns of the state matrix.
+// The method used to multiply may be difficult to understand for the inexperienced.
+// Please use the references to gain more information.
+static void InvMixColumns(state_t* state)
+{
+ int i;
+ uint8_t a, b, c, d;
+ for (i = 0; i < 4; ++i)
+ {
+ a = (*state)[i][0];
+ b = (*state)[i][1];
+ c = (*state)[i][2];
+ d = (*state)[i][3];
+
+ (*state)[i][0] = Multiply(a, 0x0e) ^ Multiply(b, 0x0b) ^ Multiply(c, 0x0d) ^ Multiply(d, 0x09);
+ (*state)[i][1] = Multiply(a, 0x09) ^ Multiply(b, 0x0e) ^ Multiply(c, 0x0b) ^ Multiply(d, 0x0d);
+ (*state)[i][2] = Multiply(a, 0x0d) ^ Multiply(b, 0x09) ^ Multiply(c, 0x0e) ^ Multiply(d, 0x0b);
+ (*state)[i][3] = Multiply(a, 0x0b) ^ Multiply(b, 0x0d) ^ Multiply(c, 0x09) ^ Multiply(d, 0x0e);
+ }
+}
+
+
+// The SubBytes Function Substitutes the values in the
+// state matrix with values in an S-box.
+static void InvSubBytes(state_t* state)
+{
+ uint8_t i, j;
+ for (i = 0; i < 4; ++i)
+ {
+ for (j = 0; j < 4; ++j)
+ {
+ (*state)[j][i] = getSBoxInvert((*state)[j][i]);
+ }
+ }
+}
+
+static void InvShiftRows(state_t* state)
+{
+ uint8_t temp;
+
+ // Rotate first row 1 columns to right
+ temp = (*state)[3][1];
+ (*state)[3][1] = (*state)[2][1];
+ (*state)[2][1] = (*state)[1][1];
+ (*state)[1][1] = (*state)[0][1];
+ (*state)[0][1] = temp;
+
+ // Rotate second row 2 columns to right
+ temp = (*state)[0][2];
+ (*state)[0][2] = (*state)[2][2];
+ (*state)[2][2] = temp;
+
+ temp = (*state)[1][2];
+ (*state)[1][2] = (*state)[3][2];
+ (*state)[3][2] = temp;
+
+ // Rotate third row 3 columns to right
+ temp = (*state)[0][3];
+ (*state)[0][3] = (*state)[1][3];
+ (*state)[1][3] = (*state)[2][3];
+ (*state)[2][3] = (*state)[3][3];
+ (*state)[3][3] = temp;
+}
+#endif // #if (defined(CBC) && CBC == 1) || (defined(ECB) && ECB == 1)
+
+// Cipher is the main function that encrypts the PlainText.
+static void Cipher(state_t* state, const uint8_t* RoundKey)
+{
+ uint8_t round = 0;
+
+ // Add the First round key to the state before starting the rounds.
+ AddRoundKey(0, state, RoundKey);
+
+ // There will be Nr rounds.
+ // The first Nr-1 rounds are identical.
+ // These Nr rounds are executed in the loop below.
+ // Last one without MixColumns()
+ for (round = 1; ; ++round)
+ {
+ SubBytes(state);
+ ShiftRows(state);
+ if (round == Nr) {
+ break;
+ }
+ MixColumns(state);
+ AddRoundKey(round, state, RoundKey);
+ }
+ // Add round key to last round
+ AddRoundKey(Nr, state, RoundKey);
+}
+
+#if (defined(CBC) && CBC == 1) || (defined(ECB) && ECB == 1)
+static void InvCipher(state_t* state, const uint8_t* RoundKey)
+{
+ uint8_t round = 0;
+
+ // Add the First round key to the state before starting the rounds.
+ AddRoundKey(Nr, state, RoundKey);
+
+ // There will be Nr rounds.
+ // The first Nr-1 rounds are identical.
+ // These Nr rounds are executed in the loop below.
+ // Last one without InvMixColumn()
+ for (round = (Nr - 1); ; --round)
+ {
+ InvShiftRows(state);
+ InvSubBytes(state);
+ AddRoundKey(round, state, RoundKey);
+ if (round == 0) {
+ break;
+ }
+ InvMixColumns(state);
+ }
+
+}
+#endif // #if (defined(CBC) && CBC == 1) || (defined(ECB) && ECB == 1)
+
+/*****************************************************************************/
+/* Public functions: */
+/*****************************************************************************/
+#if defined(ECB) && (ECB == 1)
+
+
+void AES_ECB_encrypt(const struct AES_ctx* ctx, uint8_t* buf)
+{
+ // The next function call encrypts the PlainText with the Key using AES algorithm.
+ Cipher((state_t*)buf, ctx->RoundKey);
+}
+
+void AES_ECB_decrypt(const struct AES_ctx* ctx, uint8_t* buf)
+{
+ // The next function call decrypts the PlainText with the Key using AES algorithm.
+ InvCipher((state_t*)buf, ctx->RoundKey);
+}
+
+
+#endif // #if defined(ECB) && (ECB == 1)
+
+
+
+
+
+#if defined(CBC) && (CBC == 1)
+
+
+static void XorWithIv(uint8_t* buf, const uint8_t* Iv)
+{
+ uint8_t i;
+ for (i = 0; i < AES_BLOCKLEN; ++i) // The block in AES is always 128bit no matter the key size
+ {
+ buf[i] ^= Iv[i];
+ }
+}
+
+void AES_CBC_encrypt_buffer(struct AES_ctx *ctx, uint8_t* buf, size_t length)
+{
+ size_t i;
+ uint8_t *Iv = ctx->Iv;
+ for (i = 0; i < length; i += AES_BLOCKLEN)
+ {
+ XorWithIv(buf, Iv);
+ Cipher((state_t*)buf, ctx->RoundKey);
+ Iv = buf;
+ buf += AES_BLOCKLEN;
+ }
+ /* store Iv in ctx for next call */
+ memcpy(ctx->Iv, Iv, AES_BLOCKLEN);
+}
+
+void AES_CBC_decrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length)
+{
+ size_t i;
+ uint8_t storeNextIv[AES_BLOCKLEN];
+ for (i = 0; i < length; i += AES_BLOCKLEN)
+ {
+ memcpy(storeNextIv, buf, AES_BLOCKLEN);
+ InvCipher((state_t*)buf, ctx->RoundKey);
+ XorWithIv(buf, ctx->Iv);
+ memcpy(ctx->Iv, storeNextIv, AES_BLOCKLEN);
+ buf += AES_BLOCKLEN;
+ }
+
+}
+
+#endif // #if defined(CBC) && (CBC == 1)
+
+
+
+#if defined(CTR) && (CTR == 1)
+
+/* Symmetrical operation: same function for encrypting as for decrypting. Note any IV/nonce should never be reused with the same key */
+void AES_CTR_xcrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length)
+{
+ uint8_t buffer[AES_BLOCKLEN];
+
+ size_t i;
+ int bi;
+ for (i = 0, bi = AES_BLOCKLEN; i < length; ++i, ++bi)
+ {
+ if (bi == AES_BLOCKLEN) /* we need to regen xor compliment in buffer */
+ {
+
+ memcpy(buffer, ctx->Iv, AES_BLOCKLEN);
+ Cipher((state_t*)buffer,ctx->RoundKey);
+
+ /* Increment Iv and handle overflow */
+ for (bi = (AES_BLOCKLEN - 1); bi >= 0; --bi)
+ {
+ /* inc will overflow */
+ if (ctx->Iv[bi] == 255)
+ {
+ ctx->Iv[bi] = 0;
+ continue;
+ }
+ ctx->Iv[bi] += 1;
+ break;
+ }
+ bi = 0;
+ }
+
+ buf[i] = (buf[i] ^ buffer[bi]);
+ }
+}
+
+#endif // #if defined(CTR) && (CTR == 1)
+
diff --git a/third_party/tinyaes/aes.h b/third_party/tinyaes/aes.h
new file mode 100644
index 0000000..9098901
--- /dev/null
+++ b/third_party/tinyaes/aes.h
@@ -0,0 +1,97 @@
+#ifndef _AES_H_
+#define _AES_H_
+
+#include "hybbx_aes_config.h"
+
+#include <stdint.h>
+#include <stddef.h>
+
+// #define the macros below to 1/0 to enable/disable the mode of operation.
+//
+// CBC enables AES encryption in CBC-mode of operation.
+// CTR enables encryption in counter-mode.
+// ECB enables the basic ECB 16-byte block algorithm. All can be enabled simultaneously.
+
+// The #ifndef-guard allows it to be configured before #include'ing or at compile time.
+#ifndef CBC
+ #define CBC 1
+#endif
+
+#ifndef ECB
+ #define ECB 1
+#endif
+
+#ifndef CTR
+ #define CTR 1
+#endif
+
+
+#ifndef AES128
+#define AES128 1
+#endif
+//#define AES192 1
+#ifndef AES256
+//#define AES256 1
+#endif
+
+#define AES_BLOCKLEN 16 // Block length in bytes - AES is 128b block only
+
+#if defined(AES256) && (AES256 == 1)
+ #define AES_KEYLEN 32
+ #define AES_keyExpSize 240
+#elif defined(AES192) && (AES192 == 1)
+ #define AES_KEYLEN 24
+ #define AES_keyExpSize 208
+#else
+ #define AES_KEYLEN 16 // Key length in bytes
+ #define AES_keyExpSize 176
+#endif
+
+struct AES_ctx
+{
+ uint8_t RoundKey[AES_keyExpSize];
+#if (defined(CBC) && (CBC == 1)) || (defined(CTR) && (CTR == 1))
+ uint8_t Iv[AES_BLOCKLEN];
+#endif
+};
+
+void AES_init_ctx(struct AES_ctx* ctx, const uint8_t* key);
+#if (defined(CBC) && (CBC == 1)) || (defined(CTR) && (CTR == 1))
+void AES_init_ctx_iv(struct AES_ctx* ctx, const uint8_t* key, const uint8_t* iv);
+void AES_ctx_set_iv(struct AES_ctx* ctx, const uint8_t* iv);
+#endif
+
+#if defined(ECB) && (ECB == 1)
+// buffer size is exactly AES_BLOCKLEN bytes;
+// you need only AES_init_ctx as IV is not used in ECB
+// NB: ECB is considered insecure for most uses
+void AES_ECB_encrypt(const struct AES_ctx* ctx, uint8_t* buf);
+void AES_ECB_decrypt(const struct AES_ctx* ctx, uint8_t* buf);
+
+#endif // #if defined(ECB) && (ECB == !)
+
+
+#if defined(CBC) && (CBC == 1)
+// buffer size MUST be mutile of AES_BLOCKLEN;
+// Suggest https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS7 for padding scheme
+// NOTES: you need to set IV in ctx via AES_init_ctx_iv() or AES_ctx_set_iv()
+// no IV should ever be reused with the same key
+void AES_CBC_encrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length);
+void AES_CBC_decrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length);
+
+#endif // #if defined(CBC) && (CBC == 1)
+
+
+#if defined(CTR) && (CTR == 1)
+
+// Same function for encrypting as for decrypting.
+// IV is incremented for every block, and used after encryption as XOR-compliment for output
+// Suggesting https://en.wikipedia.org/wiki/Padding_(cryptography)#PKCS7 for padding scheme
+// NOTES: you need to set IV in ctx with AES_init_ctx_iv() or AES_ctx_set_iv()
+// no IV should ever be reused with the same key
+void AES_CTR_xcrypt_buffer(struct AES_ctx* ctx, uint8_t* buf, size_t length);
+
+#endif // #if defined(CTR) && (CTR == 1)
+
+
+#endif // _AES_H_
diff --git a/third_party/tinyaes/aes256gcm.c b/third_party/tinyaes/aes256gcm.c
new file mode 100644
index 0000000..32bf832
--- /dev/null
+++ b/third_party/tinyaes/aes256gcm.c
@@ -0,0 +1,267 @@
+/*
+ * AES-256-GCM for HyBBX (NIST SP 800-38D) using bundled tiny-AES-c.
+ */
+
+#include "aes256gcm.h"
+#include "aes.h"
+
+#include <stdint.h>
+#include <string.h>
+
+typedef struct {
+ uint64_t hi;
+ uint64_t lo;
+} u128;
+
+static u128 u128_from_be(const uint8_t in[16])
+{
+ u128 v;
+ int i;
+
+ v.hi = 0;
+ v.lo = 0;
+ for (i = 0; i < 8; i++) {
+ v.hi = (v.hi << 8) | in[i];
+ v.lo = (v.lo << 8) | in[8 + i];
+ }
+ return v;
+}
+
+static void u128_to_be(u128 v, uint8_t out[16])
+{
+ int i;
+
+ for (i = 7; i >= 0; i--) {
+ out[i] = (uint8_t)(v.hi & 0xff);
+ v.hi >>= 8;
+ }
+ for (i = 15; i >= 8; i--) {
+ out[i] = (uint8_t)(v.lo & 0xff);
+ v.lo >>= 8;
+ }
+}
+
+static void u128_xor(u128 *a, const u128 *b)
+{
+ a->hi ^= b->hi;
+ a->lo ^= b->lo;
+}
+
+static void u128_shr1(u128 *v)
+{
+ int lsb = (int)(v->lo & 1);
+
+ v->lo = (v->hi << 63) | (v->lo >> 1);
+ v->hi >>= 1;
+ if (lsb) {
+ v->hi ^= 0xe100000000000000ULL;
+ }
+}
+
+static void gcm_gmult(uint8_t x[16], const uint8_t y[16])
+{
+ u128 V = u128_from_be(y);
+ u128 Z = { 0, 0 };
+ int i;
+
+ for (i = 0; i < 128; i++) {
+ if (x[i / 8] & (0x80u >> (i % 8))) {
+ u128_xor(&Z, &V);
+ }
+ if (i == 127) {
+ break;
+ }
+ u128_shr1(&V);
+ }
+
+ u128_to_be(Z, x);
+}
+
+static void xor_block(uint8_t dst[16], const uint8_t a[16], const uint8_t b[16])
+{
+ int i;
+
+ for (i = 0; i < 16; i++) {
+ dst[i] = (uint8_t)(a[i] ^ b[i]);
+ }
+}
+
+static void aes_ecb_encrypt_block(const struct AES_ctx *ctx,
+ const uint8_t in[16], uint8_t out[16])
+{
+ uint8_t buf[16];
+
+ memcpy(buf, in, 16);
+ AES_ECB_encrypt((struct AES_ctx *)ctx, buf);
+ memcpy(out, buf, 16);
+}
+
+static void ghash(const uint8_t h[16], const uint8_t *aad, size_t aad_len,
+ const uint8_t *cipher, size_t cipher_len, uint8_t out[16])
+{
+ uint8_t y[16] = { 0 };
+ uint8_t block[16];
+ size_t i;
+
+ for (i = 0; i + 16 <= aad_len; i += 16) {
+ xor_block(y, y, aad + i);
+ gcm_gmult(y, h);
+ }
+ if (i < aad_len) {
+ memset(block, 0, sizeof(block));
+ memcpy(block, aad + i, aad_len - i);
+ xor_block(y, y, block);
+ gcm_gmult(y, h);
+ }
+
+ for (i = 0; i + 16 <= cipher_len; i += 16) {
+ xor_block(y, y, cipher + i);
+ gcm_gmult(y, h);
+ }
+ if (i < cipher_len) {
+ memset(block, 0, sizeof(block));
+ memcpy(block, cipher + i, cipher_len - i);
+ xor_block(y, y, block);
+ gcm_gmult(y, h);
+ }
+
+ memset(block, 0, sizeof(block));
+ for (i = 0; i < 8; i++) {
+ block[7 - i] = (uint8_t)((aad_len * 8) >> (i * 8));
+ block[15 - i] = (uint8_t)((cipher_len * 8) >> (i * 8));
+ }
+ xor_block(y, y, block);
+ gcm_gmult(y, h);
+ memcpy(out, y, 16);
+}
+
+static void gctr(struct AES_ctx *ctx, const uint8_t icb[16], uint8_t *data,
+ size_t len)
+{
+ uint8_t counter[16];
+ uint8_t stream[16];
+ size_t i;
+
+ memcpy(counter, icb, 16);
+ for (i = 0; i < len; i += 16) {
+ size_t j;
+
+ aes_ecb_encrypt_block(ctx, counter, stream);
+ for (j = 0; j < 16 && i + j < len; j++) {
+ data[i + j] ^= stream[j];
+ }
+ {
+ int k;
+
+ for (k = 15; k >= 12; k--) {
+ counter[k] = (uint8_t)(counter[k] + 1);
+ if (counter[k] != 0) {
+ break;
+ }
+ }
+ }
+ }
+}
+
+static void inc32(uint8_t block[16])
+{
+ int i;
+
+ for (i = 15; i >= 12; i--) {
+ block[i] = (uint8_t)(block[i] + 1);
+ if (block[i] != 0) {
+ break;
+ }
+ }
+}
+
+static int aes256gcm_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])
+{
+ struct AES_ctx ctx;
+ uint8_t h[16] = { 0 };
+ uint8_t j0[16];
+ uint8_t s[16];
+ uint8_t expected[16];
+ int diff = 0;
+ size_t i;
+
+ if (key == NULL || nonce == NULL) {
+ return -1;
+ }
+ if (in_len > 0 && (in == NULL || out == NULL)) {
+ return -1;
+ }
+
+ AES_init_ctx(&ctx, key);
+ aes_ecb_encrypt_block(&ctx, h, h);
+
+ memset(j0, 0, sizeof(j0));
+ memcpy(j0, nonce, 12);
+ j0[15] = 1;
+
+ if (in_len > 0) {
+ memcpy(out, in, in_len);
+ if (encrypt) {
+ memcpy(expected, j0, 16);
+ inc32(expected);
+ gctr(&ctx, expected, out, in_len);
+ }
+ }
+
+ ghash(h, aad, aad_len, out, in_len, s);
+ memcpy(expected, j0, 16);
+ gctr(&ctx, expected, s, 16);
+
+ if (encrypt) {
+ if (tag_out != NULL) {
+ memcpy(tag_out, s, 16);
+ }
+ return 0;
+ }
+
+ if (tag_in == NULL) {
+ return -1;
+ }
+
+ for (i = 0; i < 16; i++) {
+ diff |= (int)(s[i] ^ tag_in[i]);
+ }
+ if (diff != 0) {
+ return -1;
+ }
+
+ if (in_len > 0) {
+ memcpy(expected, j0, 16);
+ inc32(expected);
+ memcpy(out, in, in_len);
+ gctr(&ctx, expected, out, in_len);
+ }
+
+ return 0;
+}
+
+int hybbx_aes256gcm_encrypt(const uint8_t key[HYBBX_AES256GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_AES256GCM_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_AES256GCM_TAG_SIZE])
+{
+ return aes256gcm_crypt(1, key, nonce, aad, aad_len, plaintext,
+ plaintext_len, ciphertext, NULL, tag);
+}
+
+int hybbx_aes256gcm_decrypt(const uint8_t key[HYBBX_AES256GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_AES256GCM_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_AES256GCM_TAG_SIZE])
+{
+ return aes256gcm_crypt(0, key, nonce, aad, aad_len, ciphertext,
+ ciphertext_len, plaintext, tag, NULL);
+}
diff --git a/third_party/tinyaes/aes256gcm.h b/third_party/tinyaes/aes256gcm.h
new file mode 100644
index 0000000..c7a349a
--- /dev/null
+++ b/third_party/tinyaes/aes256gcm.h
@@ -0,0 +1,32 @@
+#ifndef HYBBX_AES256GCM_H
+#define HYBBX_AES256GCM_H
+
+#include <stddef.h>
+#include <stdint.h>
+
+#define HYBBX_AES256GCM_KEY_SIZE 32
+#define HYBBX_AES256GCM_NONCE_SIZE 12
+#define HYBBX_AES256GCM_TAG_SIZE 16
+
+/**
+ * AES-256-GCM encrypt. @p ciphertext length equals @p plaintext_len.
+ * @p tag receives the 16-byte authentication tag.
+ */
+int hybbx_aes256gcm_encrypt(const uint8_t key[HYBBX_AES256GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_AES256GCM_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_AES256GCM_TAG_SIZE]);
+
+/**
+ * AES-256-GCM decrypt. Returns 0 on success (tag valid), -1 on failure.
+ */
+int hybbx_aes256gcm_decrypt(const uint8_t key[HYBBX_AES256GCM_KEY_SIZE],
+ const uint8_t nonce[HYBBX_AES256GCM_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_AES256GCM_TAG_SIZE]);
+
+#endif
diff --git a/third_party/tinyaes/hybbx_aes_config.h b/third_party/tinyaes/hybbx_aes_config.h
new file mode 100644
index 0000000..f4fffd9
--- /dev/null
+++ b/third_party/tinyaes/hybbx_aes_config.h
@@ -0,0 +1,13 @@
+/* HyBBX tiny-AES-c configuration: AES-256, ECB + CTR for GCM. */
+#ifndef HYBBX_AES_CONFIG_H
+#define HYBBX_AES_CONFIG_H
+
+#define CBC 0
+#define ECB 1
+#define CTR 1
+#undef AES128
+#define AES128 0
+#undef AES256
+#define AES256 1
+
+#endif
diff --git a/third_party/tinysha256/README.md b/third_party/tinysha256/README.md
new file mode 100644
index 0000000..b4598ca
--- /dev/null
+++ b/third_party/tinysha256/README.md
@@ -0,0 +1,8 @@
+# tinysha256 (bundled)
+
+Public-domain SHA-256 for HyBBX password hashing.
+
+- `tinysha256.c` / `tinysha256.h` — compiled into `hybbx_core`
+- Adapted from the [983/SHA-256](https://github.com/983/SHA-256) implementation (The Unlicense)
+
+HyBBX stores passwords as `{sha256}` + 64 lowercase hex digits.
diff --git a/third_party/tinysha256/tinysha256.c b/third_party/tinysha256/tinysha256.c
new file mode 100644
index 0000000..d1b2019
--- /dev/null
+++ b/third_party/tinysha256/tinysha256.c
@@ -0,0 +1,210 @@
+#include "tinysha256.h"
+
+static inline uint32_t tinysha256_rotr(uint32_t x, int n)
+{
+ return (x >> n) | (x << (32 - n));
+}
+
+static inline uint32_t tinysha256_step1(uint32_t e, uint32_t f, uint32_t g)
+{
+ return (tinysha256_rotr(e, 6) ^ tinysha256_rotr(e, 11) ^ tinysha256_rotr(e, 25)) +
+ ((e & f) ^ ((~e) & g));
+}
+
+static inline uint32_t tinysha256_step2(uint32_t a, uint32_t b, uint32_t c)
+{
+ return (tinysha256_rotr(a, 2) ^ tinysha256_rotr(a, 13) ^ tinysha256_rotr(a, 22)) +
+ ((a & b) ^ (a & c) ^ (b & c));
+}
+
+static inline void tinysha256_update_w(uint32_t *w, int i, const uint8_t *buffer)
+{
+ int j;
+
+ for (j = 0; j < 16; j++) {
+ if (i < 16) {
+ w[j] = ((uint32_t)buffer[0] << 24) |
+ ((uint32_t)buffer[1] << 16) |
+ ((uint32_t)buffer[2] << 8) |
+ ((uint32_t)buffer[3]);
+ buffer += 4;
+ } else {
+ uint32_t a = w[(j + 1) & 15];
+ uint32_t b = w[(j + 14) & 15];
+ uint32_t s0 = (tinysha256_rotr(a, 7) ^ tinysha256_rotr(a, 18) ^ (a >> 3));
+ uint32_t s1 = (tinysha256_rotr(b, 17) ^ tinysha256_rotr(b, 19) ^ (b >> 10));
+
+ w[j] += w[(j + 9) & 15] + s0 + s1;
+ }
+ }
+}
+
+static void tinysha256_block(tinysha256_ctx_t *ctx)
+{
+ uint32_t *state = ctx->state;
+
+ static const uint32_t k[64] = {
+ 0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5,
+ 0x3956c25b, 0x59f111f1, 0x923f82a4, 0xab1c5ed5,
+ 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3,
+ 0x72be5d74, 0x80deb1fe, 0x9bdc06a7, 0xc19bf174,
+ 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc,
+ 0x2de92c6f, 0x4a7484aa, 0x5cb0a9dc, 0x76f988da,
+ 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
+ 0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967,
+ 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc, 0x53380d13,
+ 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85,
+ 0xa2bfe8a1, 0xa81a664b, 0xc24b8b70, 0xc76c51a3,
+ 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070,
+ 0x19a4c116, 0x1e376c08, 0x2748774c, 0x34b0bcb5,
+ 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
+ 0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208,
+ 0x90befffa, 0xa4506ceb, 0xbef9a3f7, 0xc67178f2,
+ };
+
+ uint32_t a = state[0];
+ uint32_t b = state[1];
+ uint32_t c = state[2];
+ uint32_t d = state[3];
+ uint32_t e = state[4];
+ uint32_t f = state[5];
+ uint32_t g = state[6];
+ uint32_t h = state[7];
+ uint32_t w[16];
+ int i;
+ int j;
+
+ for (i = 0; i < 64; i += 16) {
+ tinysha256_update_w(w, i, ctx->buffer);
+
+ for (j = 0; j < 16; j += 4) {
+ uint32_t temp;
+
+ temp = h + tinysha256_step1(e, f, g) + k[i + j + 0] + w[j + 0];
+ h = temp + d;
+ d = temp + tinysha256_step2(a, b, c);
+ temp = g + tinysha256_step1(h, e, f) + k[i + j + 1] + w[j + 1];
+ g = temp + c;
+ c = temp + tinysha256_step2(d, a, b);
+ temp = f + tinysha256_step1(g, h, e) + k[i + j + 2] + w[j + 2];
+ f = temp + b;
+ b = temp + tinysha256_step2(c, d, a);
+ temp = e + tinysha256_step1(f, g, h) + k[i + j + 3] + w[j + 3];
+ e = temp + a;
+ a = temp + tinysha256_step2(b, c, d);
+ }
+ }
+
+ state[0] += a;
+ state[1] += b;
+ state[2] += c;
+ state[3] += d;
+ state[4] += e;
+ state[5] += f;
+ state[6] += g;
+ state[7] += h;
+}
+
+void tinysha256_init(tinysha256_ctx_t *ctx)
+{
+ ctx->state[0] = 0x6a09e667;
+ ctx->state[1] = 0xbb67ae85;
+ ctx->state[2] = 0x3c6ef372;
+ ctx->state[3] = 0xa54ff53a;
+ ctx->state[4] = 0x510e527f;
+ ctx->state[5] = 0x9b05688c;
+ ctx->state[6] = 0x1f83d9ab;
+ ctx->state[7] = 0x5be0cd19;
+ ctx->n_bits = 0;
+ ctx->buffer_counter = 0;
+}
+
+static void tinysha256_append_byte(tinysha256_ctx_t *ctx, uint8_t byte)
+{
+ ctx->buffer[ctx->buffer_counter++] = byte;
+ ctx->n_bits += 8;
+
+ if (ctx->buffer_counter == 64) {
+ ctx->buffer_counter = 0;
+ tinysha256_block(ctx);
+ }
+}
+
+void tinysha256_append(tinysha256_ctx_t *ctx, const void *src, size_t n_bytes)
+{
+ const uint8_t *bytes = (const uint8_t *)src;
+ size_t i;
+
+ for (i = 0; i < n_bytes; i++) {
+ tinysha256_append_byte(ctx, bytes[i]);
+ }
+}
+
+static void tinysha256_finalize(tinysha256_ctx_t *ctx)
+{
+ int i;
+ uint64_t n_bits = ctx->n_bits;
+
+ tinysha256_append_byte(ctx, 0x80);
+
+ while (ctx->buffer_counter != 56) {
+ tinysha256_append_byte(ctx, 0);
+ }
+
+ for (i = 7; i >= 0; i--) {
+ uint8_t byte = (uint8_t)((n_bits >> (8 * i)) & 0xff);
+
+ tinysha256_append_byte(ctx, byte);
+ }
+}
+
+void tinysha256_finalize_hex(tinysha256_ctx_t *ctx, char *dst_hex65)
+{
+ int i;
+ int j;
+
+ tinysha256_finalize(ctx);
+
+ for (i = 0; i < 8; i++) {
+ for (j = 7; j >= 0; j--) {
+ uint8_t nibble = (uint8_t)((ctx->state[i] >> (j * 4)) & 0xf);
+
+ *dst_hex65++ = "0123456789abcdef"[nibble];
+ }
+ }
+
+ *dst_hex65 = '\0';
+}
+
+void tinysha256_finalize_bytes(tinysha256_ctx_t *ctx, void *dst_bytes32)
+{
+ uint8_t *ptr = (uint8_t *)dst_bytes32;
+ int i;
+ int j;
+
+ tinysha256_finalize(ctx);
+
+ for (i = 0; i < 8; i++) {
+ for (j = 3; j >= 0; j--) {
+ *ptr++ = (uint8_t)((ctx->state[i] >> (j * 8)) & 0xff);
+ }
+ }
+}
+
+void tinysha256_hex(const void *src, size_t n_bytes, char *dst_hex65)
+{
+ tinysha256_ctx_t ctx;
+
+ tinysha256_init(&ctx);
+ tinysha256_append(&ctx, src, n_bytes);
+ tinysha256_finalize_hex(&ctx, dst_hex65);
+}
+
+void tinysha256_bytes(const void *src, size_t n_bytes, void *dst_bytes32)
+{
+ tinysha256_ctx_t ctx;
+
+ tinysha256_init(&ctx);
+ tinysha256_append(&ctx, src, n_bytes);
+ tinysha256_finalize_bytes(&ctx, dst_bytes32);
+}
diff --git a/third_party/tinysha256/tinysha256.h b/third_party/tinysha256/tinysha256.h
new file mode 100644
index 0000000..2e9ae96
--- /dev/null
+++ b/third_party/tinysha256/tinysha256.h
@@ -0,0 +1,30 @@
+#ifndef TINYSHA256_H
+#define TINYSHA256_H
+
+/*
+ * tinysha256 — public-domain SHA-256 for HyBBX (bundled, no external deps).
+ * Adapted from the Unlicense implementation by 983 (https://github.com/983/SHA-256).
+ */
+
+#include <stddef.h>
+#include <stdint.h>
+
+#define TINYSHA256_HEX_SIZE (64 + 1)
+#define TINYSHA256_BYTES_SIZE 32
+
+void tinysha256_hex(const void *src, size_t n_bytes, char *dst_hex65);
+void tinysha256_bytes(const void *src, size_t n_bytes, void *dst_bytes32);
+
+typedef struct tinysha256_ctx {
+ uint32_t state[8];
+ uint8_t buffer[64];
+ uint64_t n_bits;
+ uint8_t buffer_counter;
+} tinysha256_ctx_t;
+
+void tinysha256_init(tinysha256_ctx_t *ctx);
+void tinysha256_append(tinysha256_ctx_t *ctx, const void *data, size_t n_bytes);
+void tinysha256_finalize_hex(tinysha256_ctx_t *ctx, char *dst_hex65);
+void tinysha256_finalize_bytes(tinysha256_ctx_t *ctx, void *dst_bytes32);
+
+#endif /* TINYSHA256_H */
git clone -b <branch> https://cgit.mode42.com/<repo>.git
git clone -b <branch> git://cgit.mode42.com/<repo>.git

info@mode42.com