Deepen TUI lifecycle and runtime readiness

This commit is contained in:
m1ngsama 2026-05-26 11:15:55 +08:00
parent 33e2dc4f13
commit d3002dbfde
20 changed files with 1108 additions and 56 deletions

3
.gitignore vendored
View file

@ -9,6 +9,9 @@ host_key.pub
.DS_Store .DS_Store
test.log test.log
*.dSYM/ *.dSYM/
demos/*.gif
demos/*.mp4
demos/*.webm
tests/unit/test_utf8 tests/unit/test_utf8
tests/unit/test_message tests/unit/test_message
tests/unit/test_chat_room tests/unit/test_chat_room

View file

@ -34,7 +34,7 @@ MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222) 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) all: $(TARGETS)
@ -125,6 +125,7 @@ integration-test: all
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh @cd tests && PORT=$${PORT:-2222} ./test_basic.sh
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.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} + 2)) ./test_interactive_input.sh
@cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh
@cd tests && ./test_tntctl_cli.sh @cd tests && ./test_tntctl_cli.sh
anonymous-access-test: all anonymous-access-test: all
@ -143,6 +144,14 @@ stress-test: all
@echo "Running stress tests..." @echo "Running stress tests..."
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30} @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: ci-test:
@$(MAKE) test PORT=$(CI_TEST_PORT) @$(MAKE) test PORT=$(CI_TEST_PORT)
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5)) @$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))

View file

@ -134,6 +134,21 @@ TNT_PUBLIC_HOST=chat.example.com tnt
TNT_LANG=zh 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:** **Rate limiting:**
```sh ```sh
# Max total connections (default 64) # 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 connection-limit-test # verify per-IP concurrency and rate limits
make security-test # run security feature checks make security-test # run security feature checks
make stress-test # run configurable concurrent-client stress test 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 make ci-test # run the same checks as GitHub Actions
# Individual tests # Individual tests
@ -227,6 +244,8 @@ cd tests
./test_anonymous_access.sh # anonymous access ./test_anonymous_access.sh # anonymous access
./test_connection_limits.sh # per-IP concurrency and rate limits ./test_connection_limits.sh # per-IP concurrency and rate limits
./test_stress.sh # stress test ./test_stress.sh # stress test
./test_soak.sh # soak test
./test_user_lifecycle.sh # two-user TUI lifecycle
``` ```
**Test coverage:** **Test coverage:**

59
demos/tnt-lifecycle.tape Normal file
View file

@ -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

View file

@ -9,6 +9,14 @@
templates for bug reports and feature requests. templates for bug reports and feature requests.
- Added `tntctl`, a thin local wrapper around the documented SSH exec - Added `tntctl`, a thin local wrapper around the documented SSH exec
interface for health, stats, users, tail, post, help, and exit commands. 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 ### Changed
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match - `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 - 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 client and flushed by that client's own session loop, so slow SSH writes do
not block the sender's message path. 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 - Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients. SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and - 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 mark the client disconnected when the window is closed, avoiding the most
direct slow-reader blocking path. 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 - Room capacity and mention notification bookkeeping now follow
`TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit. `TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit.
- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and - Updated the roadmap to reflect completed `tntctl`, stable exec contract, and

View file

@ -30,7 +30,8 @@ Goal: make TNT predictable for operators, scripts, and package maintainers.
- ✅ normalize command parsing, help text, and error reporting - ✅ normalize command parsing, help text, and error reporting
- decide whether the server binary should remain `tnt` or split later into a - decide whether the server binary should remain `tnt` or split later into a
separate `tntd` daemon name 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` - ✅ add man pages for `tnt` and `tntctl`
## Stage 2: Runtime Model ## Stage 2: Runtime Model
@ -42,8 +43,8 @@ Goal: make long-running operation boring and reliable.
notifications notifications
- continue replacing ad hoc cross-thread UI mutation with per-client event - continue replacing ad hoc cross-thread UI mutation with per-client event
delivery delivery
- add bounded outbound queues so slow clients cannot stall their own session - ✅ add bounded outbound queues so closed SSH windows cannot immediately stall
loop indefinitely interactive output writes
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - 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 - make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
- document hard guarantees and soft limits - 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 - 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 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 - 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 - 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 1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or
introduce `tntd` later with a compatibility plan. introduce `tntd` later with a compatibility plan.
2. Add per-client outbound queues and finish untangling client-state ownership. 2. Finish untangling client-state ownership into a clearer release path.
3. Remove the remaining hidden runtime limits and make them explicit 3. Add deeper slow-client soak coverage with a deliberately backpressured SSH
configuration. client.
4. Add a long-running soak test that exercises idle sessions, reconnects, and 4. Replace remaining release placeholders with real maintainer metadata and
slow consumers.
5. Replace remaining release placeholders with real maintainer metadata and
source-archive checksums when cutting a public package release. source-archive checksums when cutting a public package release.

49
docs/USER_LIFECYCLE.md Normal file
View file

@ -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.

View file

@ -6,6 +6,7 @@
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, ui_lang_t lang); const char *program_name, ui_lang_t lang);
const char *cli_text_invalid_port_format(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_unknown_option_format(ui_lang_t lang);
const char *cli_text_short_usage_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang);

View file

@ -3,11 +3,20 @@
#include "ssh_server.h" /* for client_t */ #include "ssh_server.h" /* for client_t */
/* Send `len` bytes to the client over its SSH channel. Serialised on /* Send `len` bytes to the client over its SSH channel.
* client->io_lock so concurrent senders don't interleave. Returns 0 on *
* success, -1 if the channel is gone or a partial write fails. */ * 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); 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 /* 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. */ * avoids writing to another client's SSH channel from the sender's thread. */
void client_queue_bell(client_t *client); void client_queue_bell(client_t *client);

View file

@ -29,6 +29,8 @@
#define MAX_MESSAGE_LEN 1024 #define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024 #define MAX_EXEC_COMMAND_LEN 1024
#define MAX_COMMAND_OUTPUT_LEN 8192 #define MAX_COMMAND_OUTPUT_LEN 8192
#define CLIENT_OUTBOX_CAPACITY (128 * 1024)
#define CLIENT_OUTBOX_FLUSH_BUDGET 32768
#define DEFAULT_MAX_CLIENTS 64 #define DEFAULT_MAX_CLIENTS 64
#define MAX_CONFIGURED_CLIENTS 1024 #define MAX_CONFIGURED_CLIENTS 1024
#define LOG_FILE "messages.log" #define LOG_FILE "messages.log"

View file

@ -52,6 +52,10 @@ typedef struct client {
_Atomic int pending_bells; /* Bell nudges for this client's loop */ _Atomic int pending_bells; /* Bell nudges for this client's loop */
_Atomic int unread_mentions; /* @-mentions received since last reset */ _Atomic int unread_mentions; /* @-mentions received since last reset */
_Atomic int unread_whispers; /* whispers received since last :inbox view */ _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 /* Per-client whisper inbox. Protected separately from SSH channel I/O
* so slow writes do not block in-memory private-message delivery. */ * so slow writes do not block in-memory private-message delivery. */
whisper_t whisper_inbox[WHISPER_INBOX_SIZE]; whisper_t whisper_inbox[WHISPER_INBOX_SIZE];

View file

@ -20,6 +20,7 @@ Default checks:
Environment: Environment:
RUN_INTEGRATION=1 also run full make test RUN_INTEGRATION=1 also run full make test
RUN_SOAK=1 also run the configurable soak test
PORT=12720 base port for integration tests PORT=12720 base port for integration tests
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a 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}" make test PORT="${PORT:-12720}"
fi 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") tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
cleanup() { cleanup() {
rm -rf "$tmpdir" rm -rf "$tmpdir"

View file

@ -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" "tnt %s - anonymous SSH chat server\n\n"
"Usage: %s [options]\n\n" "Usage: %s [options]\n\n"
"Options:\n" "Options:\n"
" -p, --port PORT Listen on PORT (default: %d)\n" " -p, --port PORT Listen on PORT (default: %d)\n"
" -d, --state-dir DIR Store host key and logs in DIR\n" " -d, --state-dir DIR Store host key and logs in DIR\n"
" -V, --version Show version\n" " --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
" -h, --help Show this help\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" "\n"
"Environment:\n" "Environment:\n"
" PORT Default listening port\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" "tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n" "用法: %s [options]\n\n"
"选项:\n" "选项:\n"
" -p, --port PORT 监听 PORT (默认: %d)\n" " -p, --port PORT 监听 PORT (默认: %d)\n"
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n" " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
" -V, --version 显示版本\n" " --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n"
" -h, --help 显示此帮助\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"
"环境变量:\n" "环境变量:\n"
" PORT 默认监听端口\n" " PORT 默认监听端口\n"
@ -52,6 +68,12 @@ const char *cli_text_invalid_port_format(ui_lang_t lang) {
return i18n_string(text, 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) { const char *cli_text_unknown_option_format(ui_lang_t lang) {
static const i18n_string_t text = static const i18n_string_t text =
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); 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) { const char *cli_text_short_usage_format(ui_lang_t lang) {
static const i18n_string_t text = static const i18n_string_t text =
I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n", I18N_STRING("Usage: %s [options]\n",
"用法: %s [-p PORT] [-d DIR] [-h]\n"); "用法: %s [options]\n");
return i18n_string(text, lang); return i18n_string(text, lang);
} }

View file

@ -16,11 +16,132 @@ static int client_send_fail(client_t *client) {
return -1; return -1;
} }
/* Send data to client via SSH channel */ static bool client_is_exec(const client_t *client) {
int client_send(client_t *client, const char *data, size_t len) { 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; 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 (!client || !data) return -1;
if (len == 0) return 0;
pthread_mutex_lock(&client->io_lock); pthread_mutex_lock(&client->io_lock);
@ -29,33 +150,40 @@ int client_send(client_t *client, const char *data, size_t len) {
return -1; return -1;
} }
while (total < len) { if (client_is_exec(client)) {
size_t remaining = len - total; rc = client_write_direct_locked(client, data, len, 0, true);
uint32_t window = ssh_channel_window_size(client->channel); if (rc >= 0 && (size_t)rc == len) {
if (window == 0) { rc = 0;
pthread_mutex_unlock(&client->io_lock); } else if (rc >= 0) {
return client_send_fail(client); 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); 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); 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) { void client_queue_bell(client_t *client) {
@ -108,6 +236,7 @@ void client_release(client_t *client) {
if (client->channel_cb) { if (client->channel_cb) {
free(client->channel_cb); free(client->channel_cb);
} }
free(client->outbox);
pthread_mutex_destroy(&client->io_lock); pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->whisper_lock); pthread_mutex_destroy(&client->whisper_lock);
pthread_mutex_destroy(&client->ref_lock); pthread_mutex_destroy(&client->ref_lock);

View file

@ -805,6 +805,10 @@ main_loop:
/* Main input loop */ /* Main input loop */
while (client->connected && ssh_channel_is_open(client->channel)) { 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); int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);
if (ready == SSH_ERROR) { if (ready == SSH_ERROR) {
@ -819,6 +823,10 @@ main_loop:
break; break;
} }
if (client_flush_output(client) != 0) {
break;
}
if (client_flush_pending_bells(client) != 0) { if (client_flush_pending_bells(client) != 0) {
break; break;
} }

View file

@ -18,6 +18,62 @@ static void signal_handler(int sig) {
_exit(0); _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 main(int argc, char **argv) {
int port = DEFAULT_PORT; int port = DEFAULT_PORT;
ui_lang_t lang = i18n_default_ui_lang(); 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++) { for (int i = 1; i < argc; i++) {
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) && if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) &&
i + 1 < argc) { i + 1 < argc) {
char *end; int val;
long val = strtol(argv[i + 1], &end, 10); if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) {
if (*end != '\0' || val <= 0 || val > 65535) {
fprintf(stderr, cli_text_invalid_port_format(lang), fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]); argv[i + 1]);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
port = (int)val; port = val;
i++; i++;
} else if ((strcmp(argv[i], "-d") == 0 || } else if ((strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) { if (argv[i + 1][0] == '\0') {
perror("setenv TNT_STATE_DIR"); 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; return TNT_EXIT_ERROR;
} }
i++; 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) { } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
printf("tnt %s\n", TNT_VERSION); printf("tnt %s\n", TNT_VERSION);
return TNT_EXIT_OK; return TNT_EXIT_OK;

226
tests/test_soak.sh Executable file
View file

@ -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" <<EOF
set timeout [expr {$DURATION + 20}]
spawn ssh $SSH_TTY_OPTS idle@127.0.0.1
sleep 1
send -- "soakidle\r"
expect ""
exec touch "$IDLE_READY"
sleep $DURATION
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
expect "$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" <<EOF
set timeout 10
spawn ssh $SSH_TTY_OPTS reconnect$i@127.0.0.1
sleep 1
send -- "reconnect$i\r"
expect ""
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$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"

275
tests/test_user_lifecycle.sh Executable file
View file

@ -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" <<EOF
set timeout 30
spawn ssh $SSH_OPTS bob@127.0.0.1
sleep 1
send -- "bob\r"
expect ":help"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$ALICE_DONE' \]; do sleep 1; done"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox\r"
expect "私信"
expect "alice"
expect "private lifecycle ping"
expect "q:关闭"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
expect "$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" <<EOF
set timeout 30
spawn ssh $SSH_OPTS alice@127.0.0.1
sleep 1
send -- "alice\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "users\r"
expect "在线用户"
expect "alice"
expect "bob"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "i"
expect ":help"
send -- "hello lifecycle alpha\r"
sleep 1
send -- "\033"
expect "NORMAL"
send -- "k"
sleep 0.2
send -- "G"
expect "NORMAL"
send -- ":"
expect ":"
send -- "last 5\r"
expect "最近"
expect "hello lifecycle alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "加入/离开提示"
expect "已静音"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "msg bob private lifecycle ping\r"
expect "私信已发送给 bob"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick alice2\r"
expect "昵称已修改: alice -> 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"

View file

@ -22,6 +22,8 @@ TEST(help_matches_language) {
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN); cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN);
assert(strstr(output, "anonymous SSH chat server") != NULL); assert(strstr(output, "anonymous SSH chat server") != NULL);
assert(strstr(output, "Usage: tnt [options]") != 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); assert(strstr(output, "TNT_LANG") != NULL);
memset(output, 0, sizeof(output)); memset(output, 0, sizeof(output));
@ -35,6 +37,8 @@ TEST(help_matches_language) {
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL); assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
assert(strstr(output, "用法: tnt [options]") != NULL); assert(strstr(output, "用法: tnt [options]") != NULL);
assert(strstr(output, "[选项]") == 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); assert(strstr(output, "TNT_LANG") != NULL);
} }
@ -43,14 +47,18 @@ TEST(error_formats_match_language) {
"Invalid port: %s\n") == 0); "Invalid port: %s\n") == 0);
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH), assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
"端口无效: %s\n") == 0); "端口无效: %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), assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
"Unknown option: %s\n") == 0); "Unknown option: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
"未知选项: %s\n") == 0); "未知选项: %s\n") == 0);
assert(strcmp(cli_text_short_usage_format(UI_LANG_EN), 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), 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), assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99),
"Invalid port: %s\n") == 0); "Invalid port: %s\n") == 0);
} }

80
tnt.1
View file

@ -8,6 +8,22 @@ tnt \- anonymous SSH chat server with Vim\-style TUI
.IR port ] .IR port ]
.RB [ \-d | \-\-state\-dir .RB [ \-d | \-\-state\-dir
.IR 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 [ \-V | \-\-version ]
.RB [ \-h | \-\-help ] .RB [ \-h | \-\-help ]
.SH DESCRIPTION .SH DESCRIPTION
@ -39,6 +55,61 @@ Overrides the
environment variable. environment variable.
Defaults to the current working directory. Defaults to the current working directory.
.TP .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 .BR \-V ", " \-\-version
Print version and exit. Print version and exit.
.TP .TP
@ -176,6 +247,12 @@ Default listening port (default: 2222).
.B TNT_STATE_DIR .B TNT_STATE_DIR
Directory for host key and message log (default: current directory). Directory for host key and message log (default: current directory).
.TP .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 .B TNT_ACCESS_TOKEN
If set, clients must supply this string as their SSH password. If set, clients must supply this string as their SSH password.
Compared in constant time. Compared in constant time.
@ -204,6 +281,9 @@ Explicit capacity limits still apply (default: 1).
.B TNT_IDLE_TIMEOUT .B TNT_IDLE_TIMEOUT
Disconnect clients after this many seconds of inactivity. Disconnect clients after this many seconds of inactivity.
Set to 0 to disable (default: 1800, i.e. 30 minutes). 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 .SH FILES
.TP .TP
.I messages.log .I messages.log