diff --git a/.gitignore b/.gitignore index 3330c0f..e4ea543 100644 --- a/.gitignore +++ b/.gitignore @@ -9,6 +9,9 @@ host_key.pub .DS_Store test.log *.dSYM/ +demos/*.gif +demos/*.mp4 +demos/*.webm tests/unit/test_utf8 tests/unit/test_message tests/unit/test_chat_room diff --git a/Makefile b/Makefile index 7a010dc..52f4a88 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) -.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info +.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test user-lifecycle-test info all: $(TARGETS) @@ -125,6 +125,7 @@ integration-test: all @cd tests && PORT=$${PORT:-2222} ./test_basic.sh @cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh @cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh + @cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh @cd tests && ./test_tntctl_cli.sh anonymous-access-test: all @@ -143,6 +144,14 @@ stress-test: all @echo "Running stress tests..." @cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30} +soak-test: all + @echo "Running soak tests..." + @cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5} + +user-lifecycle-test: all + @echo "Running user lifecycle tests..." + @cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh + ci-test: @$(MAKE) test PORT=$(CI_TEST_PORT) @$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5)) diff --git a/README.md b/README.md index 893d5dc..f31b8aa 100644 --- a/README.md +++ b/README.md @@ -134,6 +134,21 @@ TNT_PUBLIC_HOST=chat.example.com tnt TNT_LANG=zh tnt ``` +The same operational settings can be passed explicitly, which is often +clearer in package scripts and one-off test deployments: + +```sh +tnt \ + --bind 127.0.0.1 \ + --public-host chat.example.com \ + --max-connections 100 \ + --max-conn-per-ip 10 \ + --max-conn-rate-per-ip 30 \ + --idle-timeout 3600 \ + -p 2222 \ + -d /var/lib/tnt +``` + **Rate limiting:** ```sh # Max total connections (default 64) @@ -218,6 +233,8 @@ make anonymous-access-test # verify default anonymous login behavior make connection-limit-test # verify per-IP concurrency and rate limits make security-test # run security feature checks make stress-test # run configurable concurrent-client stress test +make soak-test # run idle/reconnect/control-plane soak test +make user-lifecycle-test # run a two-user TUI lifecycle test make ci-test # run the same checks as GitHub Actions # Individual tests @@ -227,6 +244,8 @@ cd tests ./test_anonymous_access.sh # anonymous access ./test_connection_limits.sh # per-IP concurrency and rate limits ./test_stress.sh # stress test +./test_soak.sh # soak test +./test_user_lifecycle.sh # two-user TUI lifecycle ``` **Test coverage:** diff --git a/demos/tnt-lifecycle.tape b/demos/tnt-lifecycle.tape new file mode 100644 index 0000000..042f90e --- /dev/null +++ b/demos/tnt-lifecycle.tape @@ -0,0 +1,59 @@ +# TNT lifecycle demo. +# +# Run from the repository root after building: +# +# make +# vhs demos/tnt-lifecycle.tape +# +# The generated GIF is intentionally ignored by git; commit the tape, not the +# rendered artifact. + +Output demos/tnt-lifecycle.gif + +Require ssh + +Set Shell "bash" +Set FontSize 28 +Set Width 1200 +Set Height 720 +Set Theme "Catppuccin Mocha" +Set TypingSpeed 35ms +Set Padding 16 +Set WindowBar Colorful + +Hide +Type "STATE_DIR=$(mktemp -d /tmp/tnt-vhs.XXXXXX); PORT=22333; TNT_LANG=en ./tnt --bind 127.0.0.1 --public-host demo.local --rate-limit 0 --idle-timeout 0 -p $PORT -d $STATE_DIR >/tmp/tnt-vhs.log 2>&1 & TNT_PID=$!; sleep 1; clear" Enter +Show + +Type "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT demo@127.0.0.1" Enter +Sleep 1s +Type "demo" Enter +Sleep 1s +Type "hello from TNT" Enter +Sleep 800ms +Escape +Sleep 500ms +Type ":help" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type ":last 5" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type ":search TNT" Enter +Sleep 2s +Type "q" +Sleep 600ms +Type "i" +Sleep 300ms +Type "/me ships terminal chat over SSH" Enter +Sleep 2s +Ctrl+C +Sleep 300ms +Ctrl+C +Sleep 1s + +Hide +Type "kill $TNT_PID >/dev/null 2>&1; rm -rf $STATE_DIR; clear" Enter +Show diff --git a/docs/CHANGELOG.md b/docs/CHANGELOG.md index be83c75..d8adcf5 100644 --- a/docs/CHANGELOG.md +++ b/docs/CHANGELOG.md @@ -9,6 +9,14 @@ templates for bug reports and feature requests. - Added `tntctl`, a thin local wrapper around the documented SSH exec interface for health, stats, users, tail, post, help, and exit commands. +- Added explicit server configuration flags for bind address, public host, + connection limits, rate limiting, idle timeout, and SSH log verbosity. +- Added a configurable soak test that keeps an interactive session open while + repeatedly checking health, stats, users, reconnects, and post/tail behavior. +- Added a two-user TUI lifecycle regression test and user-lifecycle notes for + the main onboarding, chat, help, history, search, private-message, nickname, + action-message, and exit paths. +- Added a VHS tape draft for recording the core TNT terminal-chat experience. ### Changed - `make install-systemd` now rewrites the installed unit's `ExecStart` to match @@ -29,11 +37,16 @@ - Mention and private-message bell notifications are now queued on the target client and flushed by that client's own session loop, so slow SSH writes do not block the sender's message path. +- Interactive client writes now pass through a bounded per-client outbox and + flush against the remote SSH window from that client's session loop. Exec + sessions still write synchronously to preserve script output ordering. - Private-message inbox access now uses its own mutex instead of sharing the SSH channel write lock, reducing unrelated contention on slow clients. - Client writes now check the SSH channel's remote window before writing and mark the client disconnected when the window is closed, avoiding the most direct slow-reader blocking path. +- `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping + longer runtime checks opt-in for local release validation. - Room capacity and mention notification bookkeeping now follow `TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit. - Updated the roadmap to reflect completed `tntctl`, stable exec contract, and diff --git a/docs/ROADMAP.md b/docs/ROADMAP.md index fa96614..dcc7fd9 100644 --- a/docs/ROADMAP.md +++ b/docs/ROADMAP.md @@ -30,7 +30,8 @@ Goal: make TNT predictable for operators, scripts, and package maintainers. - ✅ normalize command parsing, help text, and error reporting - decide whether the server binary should remain `tnt` or split later into a separate `tntd` daemon name -- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently +- ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`, + `--max-connections`, and related long options consistently - ✅ add man pages for `tnt` and `tntctl` ## Stage 2: Runtime Model @@ -42,8 +43,8 @@ Goal: make long-running operation boring and reliable. notifications - continue replacing ad hoc cross-thread UI mutation with per-client event delivery -- add bounded outbound queues so slow clients cannot stall their own session - loop indefinitely +- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall + interactive output writes - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - make room/client capacity fully runtime-configurable with no hidden compile-time ceiling - document hard guarantees and soft limits @@ -91,7 +92,10 @@ Goal: make regressions harder to introduce. - expand CI coverage across Linux and macOS for build and smoke tests - add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing -- add soak tests for long-lived sessions and slow-client behavior +- ✅ add a configurable soak test for idle sessions, reconnects, and control + interface availability +- add deeper slow-client soak coverage with a deliberately backpressured SSH + client - keep deployment and test docs aligned with actual runtime behavior - require every user-visible interface change to update docs and tests in the same change set @@ -101,10 +105,8 @@ These are the next changes that should happen before new feature work expands th 1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or introduce `tntd` later with a compatibility plan. -2. Add per-client outbound queues and finish untangling client-state ownership. -3. Remove the remaining hidden runtime limits and make them explicit - configuration. -4. Add a long-running soak test that exercises idle sessions, reconnects, and - slow consumers. -5. Replace remaining release placeholders with real maintainer metadata and +2. Finish untangling client-state ownership into a clearer release path. +3. Add deeper slow-client soak coverage with a deliberately backpressured SSH + client. +4. Replace remaining release placeholders with real maintainer metadata and source-archive checksums when cutting a public package release. diff --git a/docs/USER_LIFECYCLE.md b/docs/USER_LIFECYCLE.md new file mode 100644 index 0000000..878bb15 --- /dev/null +++ b/docs/USER_LIFECYCLE.md @@ -0,0 +1,49 @@ +# User Lifecycle + +TNT solves one narrow problem: create a keyboard-first chat room that anyone +with an SSH client can join without installing a custom client. + +The product path should stay short: + +1. Operator installs `tnt`, chooses a state directory, and starts the server. +2. User connects with `ssh -p 2222 host`. +3. User picks a display name or presses Enter for `anonymous`. +4. User lands in INSERT mode at the live tail and can type immediately. +5. User presses Esc to browse history with Vim-style movement. +6. User uses `:help` for the concise manual or `?` for the full key reference. +7. User uses commands only when needed: `:users`, `:msg`, `:inbox`, `:last`, + `:search`, `:nick`, `:mute-joins`, and `:q`. +8. Scripts and operators use `tntctl` or SSH exec commands for `health`, + `stats`, `users`, `tail`, and `post`. + +## TUI Experience Notes + +- The first screen should make the product legible without reading external + docs: this is an SSH chat room, not a shell. +- INSERT mode is the default because most users arrive to send a message. +- NORMAL mode opens at the latest messages, not the oldest history. Users can + move upward for older context and use `G` or End to return to live chat. +- `:help` is a compact manual, while `?` is a full key reference. Do not add + parallel support commands for the same task. +- Command syntax stays ASCII even in localized UI text. Translations explain; + they do not change the command language. +- Private messages are visible only in the recipient inbox and are not written + to `messages.log`. +- Long command output uses a small pager so `:last` and `:search` are readable + on small terminals. + +## Regression Coverage + +`make user-lifecycle-test` runs a two-user SSH TUI journey: + +- second user joins and is visible through `users --json` +- first user opens `?`, checks `:users`, sends a public message, scrolls, uses + `:last` and `:search` +- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends + `/me`, and exits +- second user reads `:inbox` +- exec `tail` sees public messages +- `messages.log` contains public history and excludes private-message content + +This test is intentionally closer to a user story than a unit regression. Keep +it focused on lifecycle guarantees, not every keybinding. diff --git a/include/cli_text.h b/include/cli_text.h index 5fb6420..bbf0edc 100644 --- a/include/cli_text.h +++ b/include/cli_text.h @@ -6,6 +6,7 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, const char *program_name, ui_lang_t lang); const char *cli_text_invalid_port_format(ui_lang_t lang); +const char *cli_text_invalid_value_format(ui_lang_t lang); const char *cli_text_unknown_option_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang); diff --git a/include/client.h b/include/client.h index 889af68..af06833 100644 --- a/include/client.h +++ b/include/client.h @@ -3,11 +3,20 @@ #include "ssh_server.h" /* for client_t */ -/* Send `len` bytes to the client over its SSH channel. Serialised on - * client->io_lock so concurrent senders don't interleave. Returns 0 on - * success, -1 if the channel is gone or a partial write fails. */ +/* Send `len` bytes to the client over its SSH channel. + * + * Exec sessions write synchronously so command output and exit status remain + * ordered. Interactive sessions enqueue into a bounded per-client outbox and + * flush opportunistically from the same client's session loop, so a closed SSH + * window cannot block unrelated room activity. Returns -1 if the channel is + * gone, a write fails, or the bounded outbox is full. */ int client_send(client_t *client, const char *data, size_t len); +/* Flush queued interactive output for this client. Returns 0 when all + * possible progress was made; queued bytes may remain if the remote SSH window + * is currently closed. */ +int client_flush_output(client_t *client); + /* Queue an audible bell for the client's own session loop to send. This * avoids writing to another client's SSH channel from the sender's thread. */ void client_queue_bell(client_t *client); diff --git a/include/common.h b/include/common.h index c179d38..93b5b55 100644 --- a/include/common.h +++ b/include/common.h @@ -29,6 +29,8 @@ #define MAX_MESSAGE_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024 #define MAX_COMMAND_OUTPUT_LEN 8192 +#define CLIENT_OUTBOX_CAPACITY (128 * 1024) +#define CLIENT_OUTBOX_FLUSH_BUDGET 32768 #define DEFAULT_MAX_CLIENTS 64 #define MAX_CONFIGURED_CLIENTS 1024 #define LOG_FILE "messages.log" diff --git a/include/ssh_server.h b/include/ssh_server.h index 7fd2df4..3e35c37 100644 --- a/include/ssh_server.h +++ b/include/ssh_server.h @@ -52,6 +52,10 @@ typedef struct client { _Atomic int pending_bells; /* Bell nudges for this client's loop */ _Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_whispers; /* whispers received since last :inbox view */ + char *outbox; /* Bounded queued output for interactive writes */ + size_t outbox_len; + size_t outbox_pos; + size_t outbox_capacity; /* Per-client whisper inbox. Protected separately from SSH channel I/O * so slow writes do not block in-memory private-message delivery. */ whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; diff --git a/scripts/release_check.sh b/scripts/release_check.sh index 937078f..a35d7fe 100755 --- a/scripts/release_check.sh +++ b/scripts/release_check.sh @@ -20,6 +20,7 @@ Default checks: Environment: RUN_INTEGRATION=1 also run full make test + RUN_SOAK=1 also run the configurable soak test PORT=12720 base port for integration tests Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a @@ -116,6 +117,12 @@ if [ "${RUN_INTEGRATION:-0}" = "1" ]; then make test PORT="${PORT:-12720}" fi +if [ "${RUN_SOAK:-0}" = "1" ]; then + step "running soak test" + make soak-test PORT="$((${PORT:-12720} + 30))" \ + DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}" +fi + tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX") cleanup() { rm -rf "$tmpdir" diff --git a/src/cli_text.c b/src/cli_text.c index 41be3c2..6b3f5b1 100644 --- a/src/cli_text.c +++ b/src/cli_text.c @@ -8,10 +8,18 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, "tnt %s - anonymous SSH chat server\n\n" "Usage: %s [options]\n\n" "Options:\n" - " -p, --port PORT Listen on PORT (default: %d)\n" - " -d, --state-dir DIR Store host key and logs in DIR\n" - " -V, --version Show version\n" - " -h, --help Show this help\n" + " -p, --port PORT Listen on PORT (default: %d)\n" + " -d, --state-dir DIR Store host key and logs in DIR\n" + " --bind ADDR Bind to ADDR (default: 0.0.0.0)\n" + " --public-host HOST Show HOST in startup connection hints\n" + " --max-connections N Global connection limit (default: 64)\n" + " --max-conn-per-ip N Per-IP concurrent session limit\n" + " --max-conn-rate-per-ip N Per-IP connection-rate limit\n" + " --rate-limit 0|1 Disable/enable rate-based blocking\n" + " --idle-timeout SECONDS Idle disconnect timeout\n" + " --ssh-log-level LEVEL libssh log level 0..4\n" + " -V, --version Show version\n" + " -h, --help Show this help\n" "\n" "Environment:\n" " PORT Default listening port\n" @@ -24,10 +32,18 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, "tnt %s - 匿名 SSH 聊天服务器\n\n" "用法: %s [options]\n\n" "选项:\n" - " -p, --port PORT 监听 PORT (默认: %d)\n" - " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" - " -V, --version 显示版本\n" - " -h, --help 显示此帮助\n" + " -p, --port PORT 监听 PORT (默认: %d)\n" + " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" + " --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n" + " --public-host HOST 在启动提示中显示 HOST\n" + " --max-connections N 全局连接数限制 (默认: 64)\n" + " --max-conn-per-ip N 单 IP 并发会话限制\n" + " --max-conn-rate-per-ip N 单 IP 连接速率限制\n" + " --rate-limit 0|1 禁用/启用速率封禁\n" + " --idle-timeout SECONDS 空闲断开时间\n" + " --ssh-log-level LEVEL libssh 日志级别 0..4\n" + " -V, --version 显示版本\n" + " -h, --help 显示此帮助\n" "\n" "环境变量:\n" " PORT 默认监听端口\n" @@ -52,6 +68,12 @@ const char *cli_text_invalid_port_format(ui_lang_t lang) { return i18n_string(text, lang); } +const char *cli_text_invalid_value_format(ui_lang_t lang) { + static const i18n_string_t text = + I18N_STRING("Invalid %s: %s\n", "%s 无效: %s\n"); + return i18n_string(text, lang); +} + const char *cli_text_unknown_option_format(ui_lang_t lang) { static const i18n_string_t text = I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); @@ -60,7 +82,7 @@ const char *cli_text_unknown_option_format(ui_lang_t lang) { const char *cli_text_short_usage_format(ui_lang_t lang) { static const i18n_string_t text = - I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n", - "用法: %s [-p PORT] [-d DIR] [-h]\n"); + I18N_STRING("Usage: %s [options]\n", + "用法: %s [options]\n"); return i18n_string(text, lang); } diff --git a/src/client.c b/src/client.c index 830b89c..d5a5c8d 100644 --- a/src/client.c +++ b/src/client.c @@ -16,11 +16,132 @@ static int client_send_fail(client_t *client) { return -1; } -/* Send data to client via SSH channel */ -int client_send(client_t *client, const char *data, size_t len) { +static bool client_is_exec(const client_t *client) { + return client && (client->exec_command[0] != '\0' || + client->exec_command_too_long); +} + +static int client_write_direct_locked(client_t *client, const char *data, + size_t len, size_t budget, + bool fail_on_closed_window) { size_t total = 0; + while (total < len) { + size_t remaining = len - total; + uint32_t window = ssh_channel_window_size(client->channel); + + if (window == 0) { + if (!fail_on_closed_window) { + break; + } + return client_send_fail(client); + } + + uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining; + if (chunk > window) { + chunk = window; + } + if (budget > 0 && chunk > budget) { + chunk = (uint32_t)budget; + } + + int sent = ssh_channel_write(client->channel, data + total, chunk); + if (sent <= 0) { + return client_send_fail(client); + } + total += (size_t)sent; + + if (budget > 0) { + budget -= (size_t)sent; + if (budget == 0) { + break; + } + } + } + + return (int)total; +} + +static int client_flush_output_locked(client_t *client, size_t budget) { + size_t pending; + int sent; + + if (!client->outbox || client->outbox_pos >= client->outbox_len) { + if (client->outbox) { + client->outbox_pos = 0; + client->outbox_len = 0; + } + return 0; + } + + pending = client->outbox_len - client->outbox_pos; + sent = client_write_direct_locked(client, client->outbox + client->outbox_pos, + pending, budget, false); + if (sent < 0) { + return -1; + } + + client->outbox_pos += (size_t)sent; + if (client->outbox_pos >= client->outbox_len) { + client->outbox_pos = 0; + client->outbox_len = 0; + } + + return 0; +} + +static int client_compact_outbox(client_t *client) { + if (!client->outbox || client->outbox_pos == 0) { + return 0; + } + + if (client->outbox_pos < client->outbox_len) { + memmove(client->outbox, client->outbox + client->outbox_pos, + client->outbox_len - client->outbox_pos); + client->outbox_len -= client->outbox_pos; + } else { + client->outbox_len = 0; + } + client->outbox_pos = 0; + return 0; +} + +static int client_enqueue_output_locked(client_t *client, const char *data, + size_t len) { + if (len == 0) { + return 0; + } + + if (len > CLIENT_OUTBOX_CAPACITY) { + return client_send_fail(client); + } + + if (!client->outbox) { + client->outbox = malloc(CLIENT_OUTBOX_CAPACITY); + if (!client->outbox) { + return client_send_fail(client); + } + client->outbox_capacity = CLIENT_OUTBOX_CAPACITY; + client->outbox_len = 0; + client->outbox_pos = 0; + } + + client_compact_outbox(client); + if (client->outbox_len + len > client->outbox_capacity) { + return client_send_fail(client); + } + + memcpy(client->outbox + client->outbox_len, data, len); + client->outbox_len += len; + return 0; +} + +/* Send data to client via SSH channel */ +int client_send(client_t *client, const char *data, size_t len) { + int rc = 0; + if (!client || !data) return -1; + if (len == 0) return 0; pthread_mutex_lock(&client->io_lock); @@ -29,33 +150,40 @@ int client_send(client_t *client, const char *data, size_t len) { return -1; } - while (total < len) { - size_t remaining = len - total; - uint32_t window = ssh_channel_window_size(client->channel); - if (window == 0) { - pthread_mutex_unlock(&client->io_lock); - return client_send_fail(client); + if (client_is_exec(client)) { + rc = client_write_direct_locked(client, data, len, 0, true); + if (rc >= 0 && (size_t)rc == len) { + rc = 0; + } else if (rc >= 0) { + rc = client_send_fail(client); } - - uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining; - if (chunk > window) { - chunk = window; - } - - int sent = ssh_channel_write(client->channel, data + total, chunk); - if (sent <= 0) { - pthread_mutex_unlock(&client->io_lock); - return client_send_fail(client); - } - total += (size_t)sent; - } - - if (client->exec_command[0] != '\0') { ssh_blocking_flush(client->session, 1000); + } else { + rc = client_enqueue_output_locked(client, data, len); + if (rc == 0) { + rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET); + } } pthread_mutex_unlock(&client->io_lock); - return 0; + return rc; +} + +int client_flush_output(client_t *client) { + int rc; + + if (!client) return 0; + + pthread_mutex_lock(&client->io_lock); + + if (!client->connected || !client->channel) { + pthread_mutex_unlock(&client->io_lock); + return -1; + } + + rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET); + pthread_mutex_unlock(&client->io_lock); + return rc; } void client_queue_bell(client_t *client) { @@ -108,6 +236,7 @@ void client_release(client_t *client) { if (client->channel_cb) { free(client->channel_cb); } + free(client->outbox); pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->ref_lock); diff --git a/src/input.c b/src/input.c index b4c0ab8..c122c0a 100644 --- a/src/input.c +++ b/src/input.c @@ -805,6 +805,10 @@ main_loop: /* Main input loop */ while (client->connected && ssh_channel_is_open(client->channel)) { + if (client_flush_output(client) != 0) { + break; + } + int ready = ssh_channel_poll_timeout(client->channel, 1000, 0); if (ready == SSH_ERROR) { @@ -819,6 +823,10 @@ main_loop: break; } + if (client_flush_output(client) != 0) { + break; + } + if (client_flush_pending_bells(client) != 0) { break; } diff --git a/src/main.c b/src/main.c index 42ed66e..036df2c 100644 --- a/src/main.c +++ b/src/main.c @@ -18,6 +18,62 @@ static void signal_handler(int sig) { _exit(0); } +static bool parse_int_arg(const char *value, int min_val, int max_val, + int *out) { + char *end = NULL; + long val; + + if (!value || value[0] == '\0' || !out) { + return false; + } + + val = strtol(value, &end, 10); + if (!end || *end != '\0' || val < min_val || val > max_val) { + return false; + } + + *out = (int)val; + return true; +} + +static bool is_config_token(const char *value) { + const unsigned char *p = (const unsigned char *)value; + + if (!value || value[0] == '\0') { + return false; + } + while (*p) { + if (*p <= 32 || *p == 127) { + return false; + } + p++; + } + return true; +} + +static int set_env_option(const char *name, const char *value) { + if (setenv(name, value, 1) != 0) { + perror(name); + return -1; + } + return 0; +} + +static int set_numeric_env_option(const char *env_name, const char *opt_name, + const char *value, int min_val, + int max_val, ui_lang_t lang) { + int parsed; + + if (!parse_int_arg(value, min_val, max_val, &parsed)) { + fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value); + return TNT_EXIT_USAGE; + } + if (set_env_option(env_name, value) != 0) { + return TNT_EXIT_ERROR; + } + return TNT_EXIT_OK; +} + int main(int argc, char **argv) { int port = DEFAULT_PORT; ui_lang_t lang = i18n_default_ui_lang(); @@ -36,22 +92,93 @@ int main(int argc, char **argv) { for (int i = 1; i < argc; i++) { if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) && i + 1 < argc) { - char *end; - long val = strtol(argv[i + 1], &end, 10); - if (*end != '\0' || val <= 0 || val > 65535) { + int val; + if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) { fprintf(stderr, cli_text_invalid_port_format(lang), argv[i + 1]); return TNT_EXIT_USAGE; } - port = (int)val; + port = val; i++; } else if ((strcmp(argv[i], "-d") == 0 || strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { - if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) { - perror("setenv TNT_STATE_DIR"); + if (argv[i + 1][0] == '\0') { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) { return TNT_EXIT_ERROR; } i++; + } else if (strcmp(argv[i], "--bind") == 0 && i + 1 < argc) { + if (!is_config_token(argv[i + 1])) { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_BIND_ADDR", argv[i + 1]) != 0) { + return TNT_EXIT_ERROR; + } + i++; + } else if (strcmp(argv[i], "--public-host") == 0 && i + 1 < argc) { + if (!is_config_token(argv[i + 1])) { + fprintf(stderr, cli_text_invalid_value_format(lang), + argv[i], argv[i + 1]); + return TNT_EXIT_USAGE; + } + if (set_env_option("TNT_PUBLIC_HOST", argv[i + 1]) != 0) { + return TNT_EXIT_ERROR; + } + i++; + } else if (strcmp(argv[i], "--max-connections") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONNECTIONS", argv[i], + argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--max-conn-per-ip") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONN_PER_IP", argv[i], + argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0 && + i + 1 < argc) { + int rc = set_numeric_env_option("TNT_MAX_CONN_RATE_PER_IP", + argv[i], argv[i + 1], 1, + MAX_CONFIGURED_CLIENTS, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--rate-limit") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_RATE_LIMIT", argv[i], + argv[i + 1], 0, 1, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--idle-timeout") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_IDLE_TIMEOUT", argv[i], + argv[i + 1], 0, 86400, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; + } else if (strcmp(argv[i], "--ssh-log-level") == 0 && i + 1 < argc) { + int rc = set_numeric_env_option("TNT_SSH_LOG_LEVEL", argv[i], + argv[i + 1], 0, 4, lang); + if (rc != TNT_EXIT_OK) { + return rc; + } + i++; } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) { printf("tnt %s\n", TNT_VERSION); return TNT_EXIT_OK; diff --git a/tests/test_soak.sh b/tests/test_soak.sh new file mode 100755 index 0000000..e44dd05 --- /dev/null +++ b/tests/test_soak.sh @@ -0,0 +1,226 @@ +#!/bin/sh +# Lightweight soak test for TNT. +# Usage: ./test_soak.sh [duration_seconds] [reconnect_count] + +PORT=${PORT:-2222} +DURATION=${1:-8} +RECONNECTS=${2:-5} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-soak-test.XXXXXX") +SERVER_PID="" +IDLE_PID="" + +cleanup() { + if [ -n "$IDLE_PID" ]; then + kill "$IDLE_PID" 2>/dev/null || true + wait "$IDLE_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +case "$DURATION" in + ''|*[!0-9]*) + echo "Error: duration_seconds must be a positive integer" + exit 2 + ;; +esac + +case "$RECONNECTS" in + ''|*[!0-9]*) + echo "Error: reconnect_count must be a positive integer" + exit 2 + ;; +esac + +if [ "$DURATION" -lt 1 ] || [ "$RECONNECTS" -lt 1 ]; then + echo "Error: duration_seconds and reconnect_count must be positive" + exit 2 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping soak test" + exit 0 +fi + +SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" +SSH_TTY_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" + +wait_for_health() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + [ "$out" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT Soak Test ===" +echo "duration=${DURATION}s reconnects=$RECONNECTS port=$PORT" + +TNT_LANG=zh "$BIN" \ + --bind 127.0.0.1 \ + --public-host soak.local \ + --max-connections 32 \ + --max-conn-per-ip 32 \ + --max-conn-rate-per-ip 64 \ + --rate-limit 0 \ + --idle-timeout 0 \ + --ssh-log-level 1 \ + -p "$PORT" \ + -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 +fi + +if grep -q 'ssh -p '"$PORT"' soak.local' "$STATE_DIR/server.log"; then + echo "✓ explicit public host appears in startup hint" + PASS=$((PASS + 1)) +else + echo "✗ explicit public host missing from startup hint" + sed -n '1,80p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +IDLE_READY="$STATE_DIR/idle.ready" +cat >"$STATE_DIR/idle.expect" <"$STATE_DIR/idle.log" 2>&1 & +IDLE_PID=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$IDLE_READY" ] && break + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done + +if [ -f "$IDLE_READY" ]; then + echo "✓ idle interactive session reached chat" + PASS=$((PASS + 1)) +else + echo "✗ idle interactive session did not reach chat" + sed -n '1,160p' "$STATE_DIR/idle.log" + FAIL=$((FAIL + 1)) +fi + +control_failed=0 +for i in $(seq 1 "$DURATION"); do + HEALTH=$(ssh $SSH_OPTS localhost health 2>/dev/null || true) + STATS=$(ssh $SSH_OPTS localhost stats --json 2>/dev/null || true) + USERS=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true) + + if [ "$HEALTH" != "ok" ] || + ! printf '%s\n' "$STATS" | grep -q '"status":"ok"' || + ! printf '%s\n' "$USERS" | grep -q 'soakidle'; then + echo "✗ control interface failed during idle soak at ${i}s" + printf 'health=%s\nstats=%s\nusers=%s\n' "$HEALTH" "$STATS" "$USERS" + FAIL=$((FAIL + 1)) + control_failed=1 + break + fi + sleep 1 +done + +if [ "$control_failed" -eq 0 ]; then + echo "✓ control interface stayed available during idle soak" + PASS=$((PASS + 1)) +fi + +reconnected=0 +for i in $(seq 1 "$RECONNECTS"); do + cat >"$STATE_DIR/reconnect-$i.expect" <"$STATE_DIR/reconnect-$i.log" 2>&1; then + reconnected=$((reconnected + 1)) + else + sed -n '1,120p' "$STATE_DIR/reconnect-$i.log" + break + fi +done + +if [ "$reconnected" -eq "$RECONNECTS" ]; then + echo "✓ repeated reconnects completed" + PASS=$((PASS + 1)) +else + echo "✗ repeated reconnects stopped at $reconnected/$RECONNECTS" + FAIL=$((FAIL + 1)) +fi + +LAST_MESSAGE="soak message $RECONNECTS" +POST=$(ssh $SSH_OPTS soakbot@localhost post "$LAST_MESSAGE" 2>/dev/null || true) +TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true) +if [ "$POST" = "posted" ] && + printf '%s\n' "$TAIL" | grep -q "$LAST_MESSAGE"; then + echo "✓ post/tail path stayed available after reconnect churn" + PASS=$((PASS + 1)) +else + echo "✗ post/tail path failed after reconnect churn" + printf '%s\n' "$POST" + printf '%s\n' "$TAIL" + FAIL=$((FAIL + 1)) +fi + +wait "$IDLE_PID" 2>/dev/null || FAIL=$((FAIL + 1)) +IDLE_PID="" + +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server survived soak test" + PASS=$((PASS + 1)) +else + echo "✗ server exited during soak test" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/test_user_lifecycle.sh b/tests/test_user_lifecycle.sh new file mode 100755 index 0000000..e007ab7 --- /dev/null +++ b/tests/test_user_lifecycle.sh @@ -0,0 +1,275 @@ +#!/bin/sh +# End-to-end user lifecycle test for TNT's interactive TUI. + +PORT=${PORT:-2222} +BIN="../tnt" +PASS=0 +FAIL=0 +STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-lifecycle-test.XXXXXX") +SERVER_PID="" +BOB_PID="" + +cleanup() { + if [ -n "$BOB_PID" ]; then + kill "$BOB_PID" 2>/dev/null || true + wait "$BOB_PID" 2>/dev/null || true + fi + if [ -n "$SERVER_PID" ]; then + kill "$SERVER_PID" 2>/dev/null || true + wait "$SERVER_PID" 2>/dev/null || true + fi + rm -rf "$STATE_DIR" +} + +trap cleanup EXIT + +if ! command -v expect >/dev/null 2>&1; then + echo "expect not installed; skipping user lifecycle test" + exit 0 +fi + +if [ ! -f "$BIN" ]; then + echo "Error: Binary $BIN not found. Run make first." + exit 1 +fi + +SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT" +SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT" +BOB_READY="$STATE_DIR/bob.ready" +ALICE_DONE="$STATE_DIR/alice.done" + +wait_for_health() { + out="" + for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do + if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then + return 1 + fi + out=$(ssh $SSH_EXEC_OPTS localhost health 2>/dev/null || true) + [ "$out" = "ok" ] && return 0 + sleep 1 + done + return 1 +} + +echo "=== TNT User Lifecycle Test ===" + +TNT_LANG=zh "$BIN" \ + --bind 127.0.0.1 \ + --public-host lifecycle.local \ + --max-connections 32 \ + --max-conn-per-ip 32 \ + --max-conn-rate-per-ip 64 \ + --rate-limit 0 \ + --idle-timeout 0 \ + -p "$PORT" \ + -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 & +SERVER_PID=$! + +if wait_for_health; then + echo "✓ server started" + PASS=$((PASS + 1)) +else + echo "✗ server failed to start" + sed -n '1,160p' "$STATE_DIR/server.log" + exit 1 +fi + +cat >"$STATE_DIR/bob.expect" <"$STATE_DIR/bob.log" 2>&1 & +BOB_PID=$! + +for _ in 1 2 3 4 5 6 7 8 9 10; do + [ -f "$BOB_READY" ] && break + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + break + fi + sleep 1 +done + +if [ -f "$BOB_READY" ]; then + echo "✓ second user reached chat" + PASS=$((PASS + 1)) +else + echo "✗ second user did not reach chat" + sed -n '1,180p' "$STATE_DIR/bob.log" + FAIL=$((FAIL + 1)) +fi + +USERS_JSON="" +for _ in 1 2 3 4 5; do + USERS_JSON=$(ssh $SSH_EXEC_OPTS localhost users --json 2>/dev/null || true) + printf '%s\n' "$USERS_JSON" | grep -q '"bob"' && break + sleep 1 +done +if printf '%s\n' "$USERS_JSON" | grep -q '"bob"'; then + echo "✓ exec users sees active TUI user" + PASS=$((PASS + 1)) +else + echo "✗ exec users did not see active TUI user" + printf '%s\n' "$USERS_JSON" + FAIL=$((FAIL + 1)) +fi + +cat >"$STATE_DIR/alice.expect" < alice2" +expect "q:关闭" +send -- "q" +expect "NORMAL" +send -- "i" +expect ":help" +send -- "/me ships lifecycle\r" +sleep 1 +exec touch "$ALICE_DONE" +send -- "\003" +sleep 0.2 +send -- "\003" +expect eof +EOF + +if expect "$STATE_DIR/alice.expect" >"$STATE_DIR/alice.log" 2>&1; then + echo "✓ primary user lifecycle completed" + PASS=$((PASS + 1)) +else + echo "✗ primary user lifecycle failed" + sed -n '1,240p' "$STATE_DIR/alice.log" + FAIL=$((FAIL + 1)) + touch "$ALICE_DONE" +fi + +if wait "$BOB_PID" 2>/dev/null; then + echo "✓ recipient read private-message inbox" + PASS=$((PASS + 1)) +else + echo "✗ recipient inbox journey failed" + sed -n '1,240p' "$STATE_DIR/bob.log" + FAIL=$((FAIL + 1)) +fi +BOB_PID="" + +TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true) +printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' && +printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle' +if [ $? -eq 0 ]; then + echo "✓ exec tail sees public lifecycle messages" + PASS=$((PASS + 1)) +else + echo "✗ exec tail missing lifecycle messages" + printf '%s\n' "$TAIL_OUTPUT" + FAIL=$((FAIL + 1)) +fi + +if grep -q 'alice|hello lifecycle alpha' "$STATE_DIR/messages.log" && + grep -q '系统|alice 更名为 alice2' "$STATE_DIR/messages.log" && + grep -q '*|alice2 ships lifecycle' "$STATE_DIR/messages.log" && + ! grep -q 'private lifecycle ping' "$STATE_DIR/messages.log"; then + echo "✓ persisted history matches public/private boundary" + PASS=$((PASS + 1)) +else + echo "✗ persisted history boundary unexpected" + cat "$STATE_DIR/messages.log" 2>/dev/null || true + FAIL=$((FAIL + 1)) +fi + +if kill -0 "$SERVER_PID" 2>/dev/null; then + echo "✓ server survived user lifecycle" + PASS=$((PASS + 1)) +else + echo "✗ server exited during user lifecycle" + sed -n '1,160p' "$STATE_DIR/server.log" + FAIL=$((FAIL + 1)) +fi + +echo "" +echo "PASSED: $PASS" +echo "FAILED: $FAIL" +[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed" +exit "$FAIL" diff --git a/tests/unit/test_cli_text.c b/tests/unit/test_cli_text.c index 2cb708f..66b5967 100644 --- a/tests/unit/test_cli_text.c +++ b/tests/unit/test_cli_text.c @@ -22,6 +22,8 @@ TEST(help_matches_language) { cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN); assert(strstr(output, "anonymous SSH chat server") != NULL); assert(strstr(output, "Usage: tnt [options]") != NULL); + assert(strstr(output, "--bind ADDR") != NULL); + assert(strstr(output, "--max-connections N") != NULL); assert(strstr(output, "TNT_LANG") != NULL); memset(output, 0, sizeof(output)); @@ -35,6 +37,8 @@ TEST(help_matches_language) { assert(strstr(output, "匿名 SSH 聊天服务器") != NULL); assert(strstr(output, "用法: tnt [options]") != NULL); assert(strstr(output, "[选项]") == NULL); + assert(strstr(output, "--public-host HOST") != NULL); + assert(strstr(output, "--idle-timeout SECONDS") != NULL); assert(strstr(output, "TNT_LANG") != NULL); } @@ -43,14 +47,18 @@ TEST(error_formats_match_language) { "Invalid port: %s\n") == 0); assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH), "端口无效: %s\n") == 0); + assert(strcmp(cli_text_invalid_value_format(UI_LANG_EN), + "Invalid %s: %s\n") == 0); + assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH), + "%s 无效: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN), "Unknown option: %s\n") == 0); assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), "未知选项: %s\n") == 0); assert(strcmp(cli_text_short_usage_format(UI_LANG_EN), - "Usage: %s [-p PORT] [-d DIR] [-h]\n") == 0); + "Usage: %s [options]\n") == 0); assert(strcmp(cli_text_short_usage_format(UI_LANG_ZH), - "用法: %s [-p PORT] [-d DIR] [-h]\n") == 0); + "用法: %s [options]\n") == 0); assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99), "Invalid port: %s\n") == 0); } diff --git a/tnt.1 b/tnt.1 index 357ef2f..4e207c1 100644 --- a/tnt.1 +++ b/tnt.1 @@ -8,6 +8,22 @@ tnt \- anonymous SSH chat server with Vim\-style TUI .IR port ] .RB [ \-d | \-\-state\-dir .IR dir ] +.RB [ \-\-bind +.IR addr ] +.RB [ \-\-public\-host +.IR host ] +.RB [ \-\-max\-connections +.IR n ] +.RB [ \-\-max\-conn\-per\-ip +.IR n ] +.RB [ \-\-max\-conn\-rate\-per\-ip +.IR n ] +.RB [ \-\-rate\-limit +.IR 0|1 ] +.RB [ \-\-idle\-timeout +.IR seconds ] +.RB [ \-\-ssh\-log\-level +.IR level ] .RB [ \-V | \-\-version ] .RB [ \-h | \-\-help ] .SH DESCRIPTION @@ -39,6 +55,61 @@ Overrides the environment variable. Defaults to the current working directory. .TP +.BR \-\-bind " " \fIaddr\fR +Bind the SSH listener to +.IR addr . +Overrides the +.B TNT_BIND_ADDR +environment variable. +The default is 0.0.0.0. +.TP +.BR \-\-public\-host " " \fIhost\fR +Show +.I host +in the startup connection hint. +Overrides the +.B TNT_PUBLIC_HOST +environment variable. +.TP +.BR \-\-max\-connections " " \fIn\fR +Set the global connection limit. +Overrides the +.B TNT_MAX_CONNECTIONS +environment variable. +.TP +.BR \-\-max\-conn\-per\-ip " " \fIn\fR +Set the concurrent session limit per source IP. +Overrides the +.B TNT_MAX_CONN_PER_IP +environment variable. +.TP +.BR \-\-max\-conn\-rate\-per\-ip " " \fIn\fR +Set the connection-rate limit per source IP per 60-second window. +Overrides the +.B TNT_MAX_CONN_RATE_PER_IP +environment variable. +.TP +.BR \-\-rate\-limit " " \fI0|1\fR +Disable or enable rate-based blocking and auth-failure IP blocking. +Explicit capacity limits still apply. +Overrides the +.B TNT_RATE_LIMIT +environment variable. +.TP +.BR \-\-idle\-timeout " " \fIseconds\fR +Disconnect inactive interactive sessions after +.I seconds +seconds. Use 0 to disable. +Overrides the +.B TNT_IDLE_TIMEOUT +environment variable. +.TP +.BR \-\-ssh\-log\-level " " \fIlevel\fR +Set libssh log verbosity from 0 to 4. +Overrides the +.B TNT_SSH_LOG_LEVEL +environment variable. +.TP .BR \-V ", " \-\-version Print version and exit. .TP @@ -176,6 +247,12 @@ Default listening port (default: 2222). .B TNT_STATE_DIR Directory for host key and message log (default: current directory). .TP +.B TNT_BIND_ADDR +Address to bind (default: 0.0.0.0). +.TP +.B TNT_PUBLIC_HOST +Host name shown in startup connection hints (default: localhost). +.TP .B TNT_ACCESS_TOKEN If set, clients must supply this string as their SSH password. Compared in constant time. @@ -204,6 +281,9 @@ Explicit capacity limits still apply (default: 1). .B TNT_IDLE_TIMEOUT Disconnect clients after this many seconds of inactivity. Set to 0 to disable (default: 1800, i.e. 30 minutes). +.TP +.B TNT_SSH_LOG_LEVEL +libssh log verbosity from 0 to 4 (default: 1). .SH FILES .TP .I messages.log