mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Deepen TUI lifecycle and runtime readiness
This commit is contained in:
parent
33e2dc4f13
commit
d3002dbfde
20 changed files with 1108 additions and 56 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -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
|
||||||
|
|
|
||||||
11
Makefile
11
Makefile
|
|
@ -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))
|
||||||
|
|
|
||||||
19
README.md
19
README.md
|
|
@ -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
59
demos/tnt-lifecycle.tape
Normal 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
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
49
docs/USER_LIFECYCLE.md
Normal 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.
|
||||||
|
|
@ -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);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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];
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,14 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"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"
|
||||||
|
" --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"
|
" -V, --version Show version\n"
|
||||||
" -h, --help Show this help\n"
|
" -h, --help Show this help\n"
|
||||||
"\n"
|
"\n"
|
||||||
|
|
@ -26,6 +34,14 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"选项:\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"
|
||||||
|
" --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"
|
" -V, --version 显示版本\n"
|
||||||
" -h, --help 显示此帮助\n"
|
" -h, --help 显示此帮助\n"
|
||||||
"\n"
|
"\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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
177
src/client.c
177
src/client.c
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
139
src/main.c
139
src/main.c
|
|
@ -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
226
tests/test_soak.sh
Executable 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
275
tests/test_user_lifecycle.sh
Executable 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"
|
||||||
|
|
@ -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
80
tnt.1
|
|
@ -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
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue