Compare commits

...

28 commits

Author SHA1 Message Date
d4b260c160 Centralize runtime config defaults 2026-05-28 11:42:31 +08:00
0da5f51e2e Split release and package publish gates 2026-05-28 11:29:25 +08:00
fe7419709e Polish interactive help lifecycle 2026-05-28 11:19:25 +08:00
a800b026b3 Fix tntctl ASAN link flags 2026-05-28 11:07:44 +08:00
f6d5765d81 Refresh development module map 2026-05-28 10:38:27 +08:00
8affea2508 Generate tntctl command list from exec catalog 2026-05-28 10:36:22 +08:00
f2be702a15 Guard active help surfaces 2026-05-28 10:28:02 +08:00
fab8b315a5 Split tntctl local text catalog 2026-05-28 09:40:55 +08:00
4175bd520f Refresh release readiness roadmap 2026-05-28 09:26:27 +08:00
b71aa89a45 Smoke-test installed log maintenance modes 2026-05-28 09:25:12 +08:00
d22d5160d7 Add Debian source package assembly 2026-05-28 09:23:43 +08:00
d893351c5a Add language-keyed i18n string initializers 2026-05-28 09:14:36 +08:00
57d0f931b5 Add Homebrew service metadata 2026-05-28 09:11:25 +08:00
51f264bca2 Add package system user metadata 2026-05-28 09:09:02 +08:00
b23b1ba194 Localize tntctl help and diagnostics 2026-05-28 09:04:24 +08:00
f0499c32f6 Tighten CLI option diagnostics 2026-05-28 08:59:54 +08:00
797ecbb992 Improve TUI pager and search ergonomics 2026-05-27 19:24:55 +08:00
1c451b7722 Add offline message log recovery modes 2026-05-27 10:26:50 +08:00
3252e4583c Split message log record module 2026-05-27 10:08:32 +08:00
5240756f96 Harden message log maintenance tooling 2026-05-27 09:58:56 +08:00
8b55a3d9ab Add persisted message dump command 2026-05-27 09:37:51 +08:00
7b5a683557 Document message log v1 contract 2026-05-27 09:21:59 +08:00
ceffe59234 Harden message log replay parsing 2026-05-27 09:18:23 +08:00
ec507965b2 Centralize client session ownership release 2026-05-27 09:11:07 +08:00
2b43ce6a3e Refresh client ownership developer docs 2026-05-26 20:19:43 +08:00
cbaf02c769 Document stable 1.x binary naming 2026-05-26 20:16:36 +08:00
13b671cc9f Add slow-client backpressure regression 2026-05-26 14:20:07 +08:00
e603a55cb3 Polish live inbox command output 2026-05-26 12:22:33 +08:00
85 changed files with 3754 additions and 577 deletions

View file

@ -35,6 +35,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Install dependencies (Ubuntu)
if: runner.os == 'Linux'
run: |
@ -97,6 +100,9 @@ jobs:
steps:
- uses: actions/checkout@v4
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Download all artifacts
uses: actions/download-artifact@v4
with:
@ -122,12 +128,24 @@ jobs:
body: |
## Installation
Install the libssh runtime before running TNT:
```bash
# Ubuntu/Debian
sudo apt install libssh-4
# Arch
sudo pacman -S libssh
# macOS
brew install libssh
```
Download the binary for your platform:
**Linux AMD64:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
chmod +x tnt-linux-amd64
chmod +x tntctl-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
@ -136,8 +154,8 @@ jobs:
**Linux ARM64:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
chmod +x tnt-linux-arm64
chmod +x tntctl-linux-arm64
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
@ -146,8 +164,8 @@ jobs:
**macOS Intel:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
chmod +x tnt-darwin-amd64
chmod +x tntctl-darwin-amd64
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
@ -156,8 +174,8 @@ jobs:
**macOS Apple Silicon:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
chmod +x tnt-darwin-arm64
chmod +x tntctl-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
@ -166,7 +184,15 @@ jobs:
**Verify checksums:**
```bash
sha256sum -c checksums.txt
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.txt
# Linux
sha256sum -c checksums.txt --ignore-missing
# macOS
for f in tnt-* tntctl-*; do
grep " $f$" checksums.txt | shasum -a 256 -c -
done
```
## What's Changed

1
.gitignore vendored
View file

@ -24,4 +24,5 @@ tests/unit/test_help_text
tests/unit/test_manual_text
tests/unit/test_support_text
tests/unit/test_cli_text
tests/unit/test_tntctl_text
tests/unit/test_ratelimit

View file

@ -4,6 +4,7 @@
CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
LDFLAGS = -pthread -lssh
CTL_LDFLAGS =
INCLUDES = -Iinclude
DEPFLAGS = -MMD -MP
@ -20,12 +21,12 @@ SRC_DIR = src
INC_DIR = include
OBJ_DIR = obj
SOURCES = $(filter-out $(SRC_DIR)/tntctl.c,$(wildcard $(SRC_DIR)/*.c))
SOURCES = $(filter-out $(SRC_DIR)/tntctl.c $(SRC_DIR)/tntctl_text.c,$(wildcard $(SRC_DIR)/*.c))
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
TARGET = tnt
CTL_TARGET = tntctl
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/config_defaults.o $(OBJ_DIR)/i18n.o
TARGETS = $(TARGET) $(CTL_TARGET)
PREFIX ?= /usr/local
@ -34,7 +35,7 @@ MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test user-lifecycle-test info
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info
all: $(TARGETS)
@ -43,7 +44,7 @@ $(TARGET): $(OBJECTS)
@echo "Build complete: $(TARGET)"
$(CTL_TARGET): $(CTL_OBJECTS)
$(CC) $(CTL_OBJECTS) -o $@
$(CC) $(CTL_OBJECTS) -o $@ $(CTL_LDFLAGS)
@echo "Build complete: $(CTL_TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
@ -94,8 +95,15 @@ release-check:
release-check-strict:
./scripts/release_check.sh --strict
package-publish-check:
./scripts/package_publish_check.sh
debian-source-package:
./scripts/package_debian_source.sh $${OUT_DIR:-dist/debian-source}
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
asan: LDFLAGS += -fsanitize=address
asan: CTL_LDFLAGS += -fsanitize=address
asan: clean $(TARGETS)
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
@ -108,7 +116,7 @@ check:
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
# Test
test: all unit-test integration-test
test: all unit-test script-test integration-test
test-advisory: all unit-test
@echo "Running integration tests..."
@ -120,6 +128,13 @@ unit-test:
@echo "Running unit tests..."
@$(MAKE) -C tests/unit run
script-test: all
@echo "Running script tests..."
@cd tests && ./test_cli_options.sh
@cd tests && ./test_docs_help_surface.sh
@cd tests && ./test_logrotate.sh
@cd tests && ./test_message_log_tool.sh
integration-test: all
@echo "Running integration tests..."
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
@ -148,6 +163,10 @@ soak-test: all
@echo "Running soak tests..."
@cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5}
slow-client-test: all
@echo "Running slow-client tests..."
@cd tests && PORT=$${PORT:-2222} ./test_slow_client.sh $${DURATION:-8} $${BURST_CHARS:-1600}
user-lifecycle-test: all
@echo "Running user lifecycle tests..."
@cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh

View file

@ -48,9 +48,12 @@ PORT=3333 tnt # via env var
### Connecting
```sh
ssh -p 2222 chat.example.com
ssh -p 2222 localhost
```
For a deployed server, replace `localhost` with your public host, for example
`chat.example.com`.
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
## Usage
@ -95,7 +98,7 @@ Ctrl+C - Exit chat
:w <user> <text> - Short alias for :msg
:inbox - Show private messages
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
:lang <en|zh> - Switch UI language for this session
:help - Show concise manual
@ -105,6 +108,10 @@ Up/Down - Browse command history
ESC - Return to NORMAL mode
```
Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox`
is live: press `r` to refresh it manually, and it refreshes when a new private
message arrives while the inbox is open.
**Special messages (INSERT mode)**
```
/me <action> - Send action (e.g. /me waves)
@ -193,6 +200,7 @@ ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users
ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice"
ssh -p 2222 chat.example.com post "/me deploys v2.0"
```
@ -208,9 +216,34 @@ around the same SSH exec interface:
```sh
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
```
### Log Maintenance
Persisted public history is stored as `messages.log` in the TNT state
directory. For manual maintenance, archive and compact it with:
```sh
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
```
The script archives the full log, keeps the last `KEEP_LINES` records in the
active file, compresses the archive when `gzip` is available, and can be
previewed with `--dry-run`.
Installed binaries also include offline checks for the v1 log format:
```sh
tnt --log-check /var/lib/tnt/messages.log
tnt --log-recover /var/lib/tnt/messages.log > messages.recovered.log
```
`--log-check` prints record counts and exits non-zero when invalid records are
found. `--log-recover` writes valid records to stdout and reports skipped
records to stderr; it never edits the source log in place.
## Development
### Building
@ -234,6 +267,7 @@ make connection-limit-test # verify per-IP concurrency and rate limits
make security-test # run security feature checks
make stress-test # run configurable concurrent-client stress test
make soak-test # run idle/reconnect/control-plane soak test
make slow-client-test # run slow interactive-client backpressure test
make user-lifecycle-test # run a two-user TUI lifecycle test
make ci-test # run the same checks as GitHub Actions
@ -245,6 +279,7 @@ cd tests
./test_connection_limits.sh # per-IP concurrency and rate limits
./test_stress.sh # stress test
./test_soak.sh # soak test
./test_slow_client.sh # slow-client backpressure
./test_user_lifecycle.sh # two-user TUI lifecycle
```
@ -253,6 +288,8 @@ cd tests
- Anonymous access: 2 tests
- Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
- Slow-client test: an unread interactive SSH client cannot block health,
stats, post, tail, or server survival checks
### Dependencies
@ -287,6 +324,7 @@ TNT/
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
│ ├── exec.c # SSH exec command dispatch
│ ├── tntctl.c # local wrapper around the SSH exec interface
│ ├── tntctl_text.c # tntctl help and option text
│ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic
@ -357,10 +395,17 @@ Before preparing a release locally:
make release-check
```
Before publishing package recipes, replace placeholder checksums and run:
Longer local preflight can opt into runtime soak and slow-client coverage:
```sh
make release-check-strict
RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
```
Before publishing package recipes, download the final GitHub source archive,
replace placeholder checksums, and run:
```sh
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
```
## Files
@ -372,6 +417,9 @@ motd.txt - Message of the Day (optional, shown to users on connect)
tnt.service - systemd service unit
```
The persisted chat-history format is documented in
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md).
### MOTD (Message of the Day)
Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue.

View file

@ -3,8 +3,25 @@
## Unreleased
### Added
- Added a release tag/version guard used by the GitHub release workflow, so a
`vX.Y.Z` tag must match `TNT_VERSION` before release assets are built.
- Added `make package-publish-check` for verifying Arch/Homebrew source
checksums against the final GitHub source archive after a tag exists.
- Added a `config_defaults` module and unit coverage for runtime default
values, env keys, and accepted numeric ranges.
- Added a dedicated `tntctl_text` module with unit coverage for local
`tntctl` help and validation diagnostics.
- Documented the stable SSH exec interface contract, including exit statuses
and JSON field shapes for package tests, scripts, and future `tntctl` work.
- Documented `messages.log` v1 as the stable TNT 1.x persisted history format,
including parser, sanitization, and partial-record recovery rules.
- Added `dump [N]` / `dump -n N` to the SSH exec interface and `tntctl` for
exporting valid persisted `messages.log` v1 records.
- Added regression-tested manual log archive and compaction coverage for
`scripts/logrotate.sh`.
- Added offline `tnt --log-check` and `tnt --log-recover` modes for auditing
and recovering valid `messages.log` v1 records without editing the source
log in place.
- Added a public security policy, supported-version guidance, and GitHub issue
templates for bug reports and feature requests.
- Added `tntctl`, a thin local wrapper around the documented SSH exec
@ -17,8 +34,31 @@
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.
- Added live `:inbox` refresh behavior: `r` refreshes the inbox manually, and
an open inbox refreshes when a new private message arrives.
- Added `/` in NORMAL mode as a fast history-search entrypoint backed by the
existing `:search` command.
- Added `make slow-client-test`, an opt-in regression for an unread
interactive SSH client under backpressure while health, stats, post, tail,
and server survival stay responsive.
### Changed
- INSERT-mode chrome now only advertises message sending and `Esc` to NORMAL;
`? keys` appears only in NORMAL mode, matching where help keys work.
- Dismissing MOTD now returns first-time users to INSERT mode, and `Ctrl+C`
closes the full key reference before it disconnects from NORMAL mode.
- COMMAND mode now accepts an optional leading `:` in typed commands, matching
the way commands are written in the manual.
- `:search` output and docs now state that the command shows the last 15
matches, avoiding the impression that the pager is a complete result set.
- Release checks now separate tag/source-archive readiness from package-manager
checksum publishing, avoiding self-referential checksum requirements before
the final GitHub source archive exists.
- `tntctl --help` now gets its exec command list from `exec_catalog`, reducing
duplicate command metadata between the local wrapper and SSH exec mode.
- Updated `tnt(1)` to document the current TUI search and pager keys, and
added script coverage to keep active help surfaces free of removed support
commands.
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match
the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr`
produce a unit pointing at `/usr/bin/tnt`.
@ -30,6 +70,9 @@
- The release guide now documents SemVer expectations, manual release review,
smoke testing, and rollback steps.
- Package installs now include `tntctl` and its man page alongside `tnt`.
- The binary naming policy is now explicit: `tnt` remains the stable 1.x
server process name, and any future `tntd` split requires a major-version
compatibility plan.
- SSH exec commands longer than the command buffer are now rejected with a
usage error instead of being truncated and executed.
- SSH exec `post` now persists the message before broadcasting or returning
@ -40,6 +83,28 @@
- 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.
- Session callback refs are now owned and released through `client.c`, so
bootstrap and interactive cleanup no longer need to manually mirror the
main-ref / callback-ref release sequence.
- Message-log replay and search now share one strict record parser and skip
malformed, invalid UTF-8, extra-separator, oversized, or unterminated
records instead of accepting partial replay data.
- `scripts/logrotate.sh` now has validated arguments, stable exit statuses,
dry-run support, archive retention, gzip-aware archives, and a regression
test in the normal test suite.
- `messages.log` v1 record parsing and formatting now live in a dedicated
`message_log` module instead of being embedded in `message.c`.
- Offline message-log recovery shares the same `message_log` parser used by
replay, search, and `dump`, so recovery behavior follows the documented v1
contract.
- The two-user lifecycle test now covers opening `:inbox` before a private
message arrives, matching the way users often leave an inbox page open.
- Help and command-output pagers now accept arrow keys, PgUp/PgDn, Home/End,
and Space/`b` in addition to the existing Vim-style keys.
- Pre-login username entry now handles Ctrl+C/Ctrl+D cancel, Ctrl+U clear
line, and Ctrl+W delete-word before the user joins the room.
- Long COMMAND-mode input is now left-truncated with a visible marker in the
status line instead of wrapping and damaging the TUI.
- Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and
@ -47,6 +112,8 @@
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.
- `make release-check` can also run the slow-client backpressure test with
`RUN_SLOW_CLIENT=1`.
- Room capacity and mention notification bookkeeping now follow
`TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit.
- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and
@ -57,6 +124,27 @@
source release.
- Release documentation now creates the local tag before strict release checks,
matching the strict gate's tag-at-HEAD requirement.
- Startup option parsing now reports missing values for `--bind`, `-p`,
`--idle-timeout`, and related flags with the localized
"option requires argument" diagnostic instead of treating the option as
unknown.
- `tntctl` now reuses the SSH exec command matcher for local command
validation, so `tntctl host --help` reaches the server-side exec help alias
instead of being rejected locally.
- `tntctl` local help and local validation errors now follow `TNT_LANG` and
locale selection, matching the server CLI's i18n behavior.
- Arch and Debian packaging drafts now create the `tnt` system user used by
the packaged systemd unit, and release preflight checks that metadata.
- The Homebrew formula draft now defines a `brew services` entry that runs the
installed `tnt` binary with state under `var/tnt`.
- Added `scripts/package_debian_source.sh` and `make debian-source-package`
to assemble Debian/Ubuntu source-package trees from the current project
without publishing or uploading anything.
- Release preflight now smoke-tests the staged installed `tnt` binary's
`--log-check` and `--log-recover` modes, catching package artifact drift.
- The i18n helper now supports language-keyed string initializers through
`I18N_STRING_MAP`, so future languages can be added incrementally without
changing every existing two-language string initializer.
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
catalog with coverage checks for every message ID.
@ -71,10 +159,15 @@
- Refreshed contributor and development guidance so new commands are added
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
`ssh_server.c` / inline-`strcmp` instructions.
- Refreshed developer ownership guidance to match the current update-sequence
model: room broadcasts update shared state only, while each interactive
client renders and flushes its own SSH channel.
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
reducing duplicate command knowledge in `src/exec.c`.
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
public documentation does not imply a specific production host.
- First-run connection examples now use `localhost`, keeping
`chat.example.com` for deployed public-host examples.
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
`src/exec.c` no longer duplicates `--json` and required-message validation.
- Moved interactive command usage text and first-pass argument-shape checks

View file

@ -35,15 +35,17 @@ Release policy:
- packaging/arch/PKGBUILD
- packaging/homebrew/tnt-chat.rb
- packaging/debian/debian/changelog
- package checksums and maintainer metadata, when preparing public package
recipes
- maintainer metadata, when preparing public package recipes
2. Run the local preflight:
make release-check
For a longer local runtime gate before publishing or production rollout:
RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
3. Commit the release changes and create a local tag. Do not push the tag
until strict checks pass:
git tag v1.0.1
git tag vX.Y.Z
4. Run strict release checks:
make release-check-strict
@ -53,7 +55,7 @@ Release policy:
untracked and would be missing from GitHub's source archive.
5. Push the tag:
git push origin v1.0.1
git push origin vX.Y.Z
6. GitHub Actions automatically:
- Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
@ -74,7 +76,9 @@ RELEASE REVIEW CHECKLIST
Before publishing a draft release:
- Confirm `git tag` points at the intended commit.
- Download every release asset from GitHub, not from the local workspace.
- Verify `checksums.txt` with `sha256sum -c checksums.txt`.
- Verify downloaded assets against `checksums.txt` (`sha256sum -c
checksums.txt --ignore-missing` on Linux, or `shasum -a 256 -c` for each
downloaded asset on macOS).
- Run downloaded `tnt --version` and `tntctl --version`.
- Start a temporary server and check:
ssh -p 2222 server health
@ -84,8 +88,10 @@ Before publishing a draft release:
ssh -p 2222 server "tail -n 1"
- Check runtime dynamic links (`ldd` on Linux, `otool -L` on macOS) and make
sure `libssh` is documented for the target install path.
- Confirm `make release-check-strict` passed after package checksums were
replaced.
- Confirm `make release-check-strict` passed before pushing the tag.
- For package-manager recipes, download the final GitHub source archive,
replace Arch/Homebrew source checksums, then run:
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
ROLLBACK
@ -155,8 +161,8 @@ make && make asan && make release-check
./tnt
# Create release
git tag v1.0.1
git push origin v1.0.1
git tag vX.Y.Z
git push origin vX.Y.Z
# Wait 5 minutes for builds
# Deploy to production manually after validation

View file

@ -16,6 +16,9 @@ make release-check # release preflight
make test # unit + integration tests
make ci-test # local CI-equivalent checks
make stress-test # concurrent-client stress test
make soak-test # idle/reconnect/control-plane soak
make slow-client-test # slow interactive-client backpressure
make user-lifecycle-test # two-user TUI lifecycle
```
## Debug
@ -37,6 +40,7 @@ make check
```
main.c → entry point, signal handling
cli_text.c → startup CLI text
tntctl_text.c → tntctl local help and diagnostics
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching, usage, and argument shape
@ -78,7 +82,8 @@ utf8.c → UTF-8 string handling
## Common Bugs to Avoid
1. Don't use `strtok()` on client data - use `strtok_r()` or copy first
2. Always increment ref_count before using client outside lock
2. Always use `client_addref()` / `client_release()` before using a client
outside `g_room->lock`; never modify `ref_count` directly
3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative)
4. UTF-8 chars are multi-byte - use utf8_* functions

View file

@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Specific version:
```bash
VERSION=v1.0.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
## Manual Install
@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN
```bash
# Linux AMD64
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
chmod +x tnt-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
# macOS ARM64 (Apple Silicon)
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
chmod +x tnt-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
```
@ -107,6 +107,34 @@ sudo rm /var/lib/tnt/motd.txt
No restart required — TNT reads the file on each new connection.
## Manual Log Maintenance
TNT stores public chat history in `messages.log` under the state directory.
Use the maintenance script from a source checkout when the service is stopped
or during a quiet maintenance window:
```bash
sudo systemctl stop tnt
sudo scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
sudo systemctl start tnt
```
The arguments are `LOG_FILE MAX_SIZE_MB KEEP_LINES`. The script archives the
full log, compacts the active log to the last `KEEP_LINES` records, compresses
the archive when `gzip` is available, and keeps the newest five archives by
default. Use `--dry-run` to preview actions, or `--keep-archives N` to change
archive retention.
Before replacing a suspicious log, inspect and recover it offline:
```bash
tnt --log-check /var/lib/tnt/messages.log
tnt --log-recover /var/lib/tnt/messages.log > /var/lib/tnt/messages.recovered.log
```
`--log-recover` writes valid records to stdout and reports skipped records to
stderr. Review the recovered file before replacing the active log.
## Firewall
```bash

View file

@ -55,10 +55,13 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
### Key Design Principles
1. **Fixed-size buffers** - No dynamic allocation in hot paths
2. **Reader-writer locks** - Multiple readers, single writer
3. **Reference counting** - Prevent use-after-free
4. **Ring buffer** - Fixed-size message history (last 100 messages)
1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded
2. **Reader-writer locks** - Multiple readers, single writer for room state
3. **Per-client output ownership** - Each interactive session writes only to
its own SSH channel
4. **Reference counting** - Keep client objects alive across callbacks and
cross-thread lookups
5. **Ring buffer** - Fixed-size in-memory message history (last 100 messages)
---
@ -69,6 +72,7 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
```
src/
├── main.c - CLI entry point and startup option parsing
├── cli_text.c - Server CLI help and option diagnostics
├── ssh_server.c - SSH listener setup and connection accept loop
├── bootstrap.c - SSH authentication/session bootstrap
├── input.c - Interactive session loop and key handling
@ -76,8 +80,12 @@ src/
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec_catalog.c - SSH exec command matching and help metadata
├── exec.c - SSH exec command dispatch
├── chat_room.c - Chat room logic and message broadcasting
├── tntctl.c - Local wrapper around the SSH exec interface
├── tntctl_text.c - tntctl local help and diagnostics
├── chat_room.c - Chat room state, message ring, and update sequence
├── message.c - Message persistence (RFC3339 format)
├── message_log.c - messages.log v1 parsing and formatting
├── message_log_tool.c - Offline messages.log check/recover CLI
├── history_view.c - NORMAL-mode scroll window rules
├── tui.c - Terminal UI rendering (ANSI escape codes)
├── tui_status.c - Mode/status/input-line rendering
@ -100,13 +108,20 @@ include/
├── bootstrap.h - SSH session bootstrap interface
├── chat_room.h - Chat room interface
├── message.h - Message structure and persistence
├── message_log.h - messages.log v1 parser/formatter interface
├── message_log_tool.h - Offline log check/recover interface
├── command_catalog.h - COMMAND-mode command metadata interface
├── exec_catalog.h - SSH exec command metadata interface
├── cli_text.h - Server CLI text interface
├── tntctl_text.h - tntctl text interface
├── history_view.h - Scroll-state helpers
├── tui.h - TUI rendering functions
├── tui_status.h - TUI status/input-line rendering interface
├── i18n.h - Language and shared text IDs
├── help_text.h - Key reference text interface
├── manual.h - Concise manual panel interface
├── manual_text.h - Concise manual text interface
├── system_message.h - Localized system message builders
├── ratelimit.h - Connection limit interface
└── utf8.h - UTF-8 utilities
```
@ -119,12 +134,16 @@ typedef struct client {
ssh_session session;
ssh_channel channel;
char username[MAX_USERNAME_LEN];
int width, height; // Terminal dimensions
_Atomic int width, height; // Terminal dimensions
client_mode_t mode; // INSERT/NORMAL/COMMAND
int scroll_pos;
bool connected;
atomic_bool connected;
char *outbox; // Bounded queued interactive output
size_t outbox_len, outbox_pos;
int ref_count; // Reference counting
pthread_mutex_t ref_lock;
pthread_mutex_t io_lock; // Own SSH channel writes only
bool channel_callback_ref; // Ref held while callbacks are installed
} client_t;
```
@ -134,6 +153,7 @@ typedef struct {
pthread_rwlock_t lock; // Reader-writer lock
struct client **clients; // Dynamic array
int client_count;
uint64_t update_seq; // Bumped when message history changes
message_t *messages; // Ring buffer
int message_count;
} chat_room_t;
@ -189,6 +209,9 @@ make anonymous-access-test # Verify default anonymous login behavior
make connection-limit-test # Verify per-IP concurrency and rate limits
make security-test # Run security feature checks
make stress-test # Run configurable concurrent-client stress test
make soak-test # Run idle/reconnect/control-plane soak test
make slow-client-test # Run slow interactive-client backpressure test
make user-lifecycle-test # Run a two-user TUI lifecycle test
make ci-test # Run the same checks as GitHub Actions
# Individual tests
@ -197,6 +220,9 @@ cd tests
./test_security_features.sh # Security checks
./test_anonymous_access.sh # Anonymous access
./test_stress.sh # Concurrent connections
./test_soak.sh # Idle/reconnect soak
./test_slow_client.sh # Slow-client backpressure
./test_user_lifecycle.sh # Two-user TUI lifecycle
```
### Test Coverage
@ -205,6 +231,10 @@ cd tests
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
- **Anonymous**: Passwordless access, any username
- **Stress**: 10 concurrent clients for 30 seconds
- **Soak**: idle session, reconnect churn, health/stats/users/post/tail
- **Slow client**: unread interactive SSH client cannot block control paths
- **Lifecycle**: two-user TUI story covering help, history, search, private
messages, nickname, action messages, and persistence boundaries
---
@ -244,41 +274,48 @@ while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && !t
### 2. Chat Room (chat_room.c)
**Thread-safe broadcasting:**
**Thread-safe message publication:**
```c
void room_broadcast(chat_room_t *room, const message_t *msg) {
pthread_rwlock_wrlock(&room->lock);
/* Copy client list with ref counting */
client_t **clients_copy = calloc(...);
for (int i = 0; i < count; i++) {
clients_copy[i]->ref_count++;
}
room_add_message(room, msg);
room->update_seq++;
pthread_rwlock_unlock(&room->lock); // Release lock early
/* Render outside lock (avoid deadlock) */
for (int i = 0; i < count; i++) {
tui_render_screen(clients_copy[i]);
client_release(clients_copy[i]);
}
pthread_rwlock_unlock(&room->lock);
}
```
**Why this works:**
- Copy client list while holding write lock
- Increment reference counts
- Release lock BEFORE rendering
- Render to all clients outside lock
- Decrement reference counts (may free clients)
- Broadcast updates shared room state only; it does not render or write to
any SSH channel.
- Each interactive session tracks `room_get_update_seq()` in its own
`input_run_session()` loop.
- When the sequence changes, the client renders and flushes its own output.
- This keeps slow SSH windows local to that client and prevents one recipient
from blocking a sender or the whole room.
- Cross-client lookups, such as mentions and private messages, must call
`client_addref()` before using a client pointer outside `g_room->lock`, then
`client_release()` when done. Do not increment `ref_count` directly.
- Session callback lifetime is owned by `client.c`: `client_install_channel_callbacks()`
takes the callback ref, and `client_release_session()` removes callbacks and
releases both the callback ref and the session main ref.
### 3. Message Persistence (message.c)
See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record
contract.
**Log format:**
```
2024-01-13T10:30:45Z|username|message content
```
Log replay and search use the same strict parser. A record is accepted only
when it has exactly three fields, a strict UTC RFC3339 timestamp, valid UTF-8
username/content, bounded field lengths, and a trailing newline. Unterminated
last lines are treated as partial writes and skipped.
**Optimized loading** (backward scan):
```c
/* Scan backwards from file end */
@ -380,9 +417,13 @@ void utf8_remove_last_word(char *str) {
```sh
tests/test_exec_mode.sh # exec command behavior
tests/test_interactive_input.sh # COMMAND-mode/TUI behavior
tests/test_user_lifecycle.sh # end-to-end two-user TUI behavior
tests/test_slow_client.sh # slow SSH reader/backpressure behavior
tests/unit/test_i18n.c # localized shared text
tests/unit/test_command_catalog.c # interactive command metadata
tests/unit/test_exec_catalog.c # exec command help metadata
tests/unit/test_tntctl_text.c # tntctl local help/diagnostic text
tests/test_docs_help_surface.sh # active help/manual drift checks
```
### Adding a New Keybinding
@ -449,6 +490,10 @@ keys.
fragments.
- Keep placeholders visible and stable, for example `%s`, `%d`,
`<user>`, and `<message>`.
- Use `I18N_STRING(en, zh)` for ordinary two-language entries. Use
`I18N_STRING_MAP(I18N_EN(...), I18N_ZH(...))` when an entry needs
language-keyed initialization so future languages can be added without
changing every existing initializer.
- Every new user-facing string needs tests for at least English fallback
and Chinese output while this project has two UI languages.
@ -457,7 +502,8 @@ keys.
The current `src/i18n_text.c` implementation is a small-project translation
table implemented in C, not a full gettext catalog. It is acceptable for two
languages because message lookup is already split from language parsing in
`src/i18n.c`, but adding more languages should move toward catalog-like
`src/i18n.c`, and localized strings can now be initialized by language key.
Adding many more languages should still move toward external catalog-like
storage instead of adding ad hoc branches for every locale.
Relevant conventions:

View file

@ -37,9 +37,11 @@ tnt -p 2222 -d /var/lib/tnt
## Connect
```sh
ssh -p 2222 chat.example.com
ssh -p 2222 localhost
```
For a deployed server, replace `localhost` with your public host.
Default access rules:
- Any SSH username is accepted.
@ -64,7 +66,10 @@ Esc enter NORMAL mode
i return to INSERT mode
: enter COMMAND mode
? open the full key reference
/ search message history
G or End jump to latest messages
Up/Down recall sent messages in INSERT mode
Tab complete @mention in INSERT mode
Ctrl+C disconnect from NORMAL mode
```
@ -196,9 +201,11 @@ tnt
### 连接
```sh
ssh -p 2222 chat.example.com
ssh -p 2222 localhost
```
部署到公网后,将 `localhost` 替换为你的域名。
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
### 常用操作
@ -209,7 +216,10 @@ Esc 进入 NORMAL 模式
i 回到 INSERT 模式
: 输入命令
? 查看完整按键参考
/ 搜索消息历史
G 或 End 回到最新消息
Up/Down 在 INSERT 模式调出已发送消息
Tab 在 INSERT 模式补全 @mention
:help 查看简明手册
:lang en|zh 切换界面语言
:q 断开连接

View file

@ -3,25 +3,34 @@
This document defines the public surfaces that scripts, package tests, and
operators may rely on.
TNT is still evolving toward a split `tntd` / `tntctl` model. The stable
control surface is the SSH exec interface exposed by the `tnt` daemon.
`tntctl` is a thin wrapper around that same interface.
For 1.x, the public binary names are stable:
- `tnt` is the server process and daemon entrypoint.
- `tntctl` is a thin local wrapper around the SSH exec interface.
TNT will not introduce a separate `tntd` binary during 1.x. If the project
ever splits the server into `tntd`, that change must ship with a major-version
compatibility plan, package migration notes, and a transition period for the
`tnt` command.
## Stability Scope
Stable:
- public binary names for 1.x: `tnt` and `tntctl`
- documented command-line flags in `tnt(1)`
- documented environment variables in `tnt(1)`
- SSH exec command names and argument shapes listed below
- SSH exec exit statuses
- JSON field names and value types for documented `--json` commands
- `messages.log` v1 record format documented in
[MESSAGE_LOG.md](MESSAGE_LOG.md)
Not yet stable:
- exact human-readable diagnostic wording
- interactive TUI layout
- on-disk message log format
- future storage migration tooling
- internal module names and helper functions
## Exit Status
@ -47,6 +56,7 @@ ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users --json
ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice"
```
@ -55,6 +65,7 @@ The same commands can be run through `tntctl`:
```sh
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users
```
@ -119,6 +130,22 @@ Prints recent in-memory messages as tab-separated lines:
The current upper bound is `MAX_MESSAGES`. This command reads the live
in-memory room buffer, not the full persisted log.
### `dump [N]` / `dump -n N`
Exports valid persisted `messages.log` v1 records in chronological order:
```text
2026-05-25T12:00:00Z|alice|hello
```
Without `N`, `dump` exports all valid persisted records. With `N`, it exports
the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or
truncated records are skipped by the same strict parser used for replay and
search.
This command reads the on-disk log, not the live in-memory room buffer. A
missing log produces empty output and exit status `0`.
### `post MESSAGE`
Posts a message as the SSH login name and prints:

106
docs/MESSAGE_LOG.md Normal file
View file

@ -0,0 +1,106 @@
# Message Log
This document defines the persisted chat-history format used by TNT 1.x.
## Format: `messages.log` v1
Each record is one UTF-8 line:
```text
RFC3339_UTC|username|content\n
```
Example:
```text
2026-05-27T12:34:56Z|alice|hello
```
Rules:
- Timestamp is strict UTC RFC3339: `YYYY-MM-DDTHH:MM:SSZ`.
- The separator is literal `|`.
- A valid record has exactly three fields and exactly two separators.
- `username` and `content` must be non-empty valid UTF-8.
- `username` must fit `MAX_USERNAME_LEN`; `content` must fit
`MAX_MESSAGE_LEN`.
- Every complete record ends with `\n`.
The file has no header. The version is defined by this record contract so
existing append-only logs remain readable.
## Write Behavior
`message_save()` sanitizes fields before appending:
- `|`, `\n`, and `\r` in usernames become `_`.
- `|`, `\n`, and `\r` in content become spaces.
- Timestamps are written in UTC.
Private messages are not written to `messages.log`.
## Replay And Search
Replay and search use the same strict parser. TNT skips records that are:
- malformed or missing fields
- invalid UTF-8
- too long
- outside the accepted timestamp window
- terminated without a trailing newline
- written with extra separators
Skipping a bad record is intentional recovery behavior. A truncated final
line is treated as a partial append and ignored rather than replayed.
## Export
`dump [N]` and `dump -n N` export valid persisted records through the SSH exec
interface and `tntctl`. The output format is exactly the v1 record format
above. Without `N`, `dump` exports all valid records; with `N`, it exports the
last `N` valid records.
## Maintenance
`scripts/logrotate.sh` is the manual archive and compaction tool for
`messages.log`:
```sh
scripts/logrotate.sh [--dry-run] [--keep-archives N] LOG_FILE MAX_SIZE_MB KEEP_LINES
```
When the log exceeds `MAX_SIZE_MB`, the script archives the full file, compacts
the active file to the last `KEEP_LINES` records, compresses the archive when
`gzip` is available, and removes older archives beyond the retention limit.
Run it while TNT is stopped or during a quiet maintenance window if strict log
consistency matters.
## Recovery
Installed `tnt` binaries provide offline log checking and recovery:
```sh
tnt --log-check LOG_FILE
tnt --log-recover LOG_FILE > recovered.messages.log
```
`--log-check` prints a summary:
```text
path /var/lib/tnt/messages.log
records_seen 120
valid_records 119
invalid_records 1
first_invalid_line 120
```
It exits `0` when every record is valid and `1` when invalid records are found
or the log cannot be read. `--log-recover` writes only valid v1 records to
stdout, prints the same summary to stderr, and also exits `1` if records were
skipped. It never modifies the source log.
## Compatibility
The v1 record format is stable for TNT 1.x. Future incompatible storage
changes must document downgrade behavior in release notes and provide an
operator-visible migration or export path.

View file

@ -15,6 +15,9 @@ TEST
make connection-limit-test per-IP concurrency/rate-limit checks
make security-test security feature checks
make stress-test concurrent-client stress test
make soak-test idle/reconnect/control-plane soak test
make slow-client-test slow interactive-client backpressure test
make user-lifecycle-test two-user TUI lifecycle test
make ci-test same checks as GitHub Actions
DEBUG
@ -43,9 +46,27 @@ INSERT MODE
limit 1023 bytes/message; over-limit input rings bell
normal opens/follows latest; k/PgUp older, j/PgDn newer
EXEC COMMANDS
health print service health
stats [--json] print room statistics
users [--json] list online users
tail [N] / tail -n N recent in-memory room messages
dump [N] / dump -n N persisted messages.log v1 records
post <message> post as the SSH login name
MAINTENANCE
scripts/logrotate.sh LOG_FILE MAX_SIZE_MB KEEP_LINES
archive and compact messages.log
scripts/logrotate.sh --dry-run ...
preview log maintenance actions
tnt --log-check LOG_FILE audit messages.log v1 records
tnt --log-recover LOG_FILE > OUT
write valid records to stdout
STRUCTURE
src/main.c entry, signals
src/cli_text.c startup CLI text
src/tntctl_text.c tntctl local help and diagnostics
src/command_catalog.c command metadata, usage, argument shape
src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap
@ -54,6 +75,8 @@ STRUCTURE
src/exec_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch
src/message.c persistence, search
src/message_log.c messages.log v1 parsing and formatting
src/message_log_tool.c offline messages.log check/recover CLI
src/history_view.c message viewport / scroll state
src/help_text.c full-screen key reference text
src/manual.c concise manual panel rendering

View file

@ -25,11 +25,12 @@ Goal: make TNT predictable for operators, scripts, and package maintainers.
- `stats`
- `users`
- `tail`
- `dump`
- `post`
- ✅ support text and JSON output modes where machine use is likely
- ✅ normalize command parsing, help text, and error reporting
- decide whether the server binary should remain `tnt` or split later into a
separate `tntd` daemon name
- ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a
major-version compatibility plan
- ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`,
`--max-connections`, and related long options consistently
- ✅ add man pages for `tnt` and `tntctl`
@ -38,52 +39,58 @@ Goal: make TNT predictable for operators, scripts, and package maintainers.
Goal: make long-running operation boring and reliable.
- move client state to a clearer ownership model with one release path
- ✅ move session callback ownership into `client.c` and release sessions
through one `client_release_session()` path
- ✅ remove cross-client SSH channel writes from mention and private-message
notifications
- continue replacing ad hoc cross-thread UI mutation with per-client event
delivery
delivery where new features need cross-client notifications
- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall
interactive output writes
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
- document hard guarantees and soft limits
- ✅ make room/client capacity fully runtime-configurable with no hidden
compile-time ceiling
- ✅ document hard guarantees and soft limits
## Stage 3: Data and Persistence
Goal: make stored history durable, inspectable, and recoverable.
- formalize the message log format and version it
- keep timestamps in a timezone-safe format throughout write and replay
- validate persisted UTF-8 and record structure before replay
- add log rotation and compaction tooling
- provide an offline inspection/export command
- define recovery behavior for truncated or partially corrupted logs
- formalize the message log v1 format
- ✅ keep persisted timestamps in UTC throughout write and replay
- validate persisted UTF-8 and record structure before replay/search
- ✅ provide an inspection/export command for persisted records
- ✅ add log rotation and compaction tooling
- ✅ define broader recovery tooling for truncated or partially corrupted logs
## Stage 4: Interactive UX
Goal: keep the interface efficient for terminal users without sacrificing simplicity.
- keep the current modal editing model, but make its behavior precise and documented
- support resize, cursor movement, command history, and predictable paste behavior
- ✅ keep the current modal editing model precise and documented
- ✅ support resize, command history, pager navigation, and predictable paste
behavior
- add in-line cursor movement/editing only if it can stay simple and testable
- add useful chat commands with clear semantics:
- ✅ `:nick` / `:name` — nickname change with broadcast
- ✅ `/me` — action messages
- ✅ `:last N` — show last N messages from disk history
- ✅ `:search <keyword>` — case-insensitive full-text search
- ✅ `:mute-joins` — per-client join/leave notification toggle
- improve discoverability of NORMAL and COMMAND mode actions
- make status lines and help output concise enough for small terminals
- improve discoverability of NORMAL and COMMAND mode actions
- make status lines and help output concise enough for small terminals
## Stage 5: Operations and Security
Goal: make public deployment manageable.
- provide clear distinction between concurrent session limits and connection-rate limits
- ✅ provide clear distinction between concurrent session limits and
connection-rate limits
- add admin-only controls for read-only mode, mute, and ban
- ✅ expose a minimal health and stats surface suitable for monitoring
- support systemd-friendly readiness and watchdog behavior
- document recommended production defaults for public, private, and localhost-only deployments
- ✅ document recommended production defaults for public, private, and
localhost-only deployments
- tighten CI around authentication, limits, and restart behavior
## Stage 6: Release Quality
@ -94,8 +101,11 @@ Goal: make regressions harder to introduce.
- add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing
- ✅ add a configurable soak test for idle sessions, reconnects, and control
interface availability
- add deeper slow-client soak coverage with a deliberately backpressured SSH
- add deeper slow-client coverage with a deliberately backpressured SSH
client
- ✅ verify staged package installs, systemd unit paths, packaging metadata,
Debian source assembly, Homebrew service metadata, and installed log
maintenance modes in release preflight
- 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
@ -103,10 +113,9 @@ Goal: make regressions harder to introduce.
These are the next changes that should happen before new feature work expands the surface area.
1. Decide the daemon naming path: keep `tnt` as the server binary for 1.x, or
introduce `tntd` later with a compatibility plan.
2. Finish untangling client-state ownership into a clearer release path.
3. Add deeper slow-client soak coverage with a deliberately backpressured SSH
client.
4. Replace remaining release placeholders with real maintainer metadata and
source-archive checksums when cutting a public package release.
1. Replace remaining source-archive checksum placeholders only after the final
GitHub source archive exists, then run `make package-publish-check`.
2. Create or move the `vX.Y.Z` tag only when the release commit is final, then
run `make release-check-strict` before pushing it.
3. Decide whether admin-only moderation controls belong in 1.0.x or should
wait for a later minor release.

View file

@ -11,10 +11,11 @@ The product path should stay short:
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`.
7. User searches from NORMAL with `/term`, or uses commands 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`.
`stats`, `users`, `tail`, `dump`, and `post`.
## TUI Experience Notes
@ -23,12 +24,19 @@ The product path should stay short:
- 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.
- NORMAL mode accepts `/` as the fast path for history search, matching a
common terminal-reader habit while reusing the existing `:search` command.
- INSERT mode keeps a small per-session sent-message history on Up/Down and
completes trailing `@mention` prefixes with Tab.
- `: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`.
- `:inbox` is live enough for normal chat use: it can be refreshed with `r`
and refreshes automatically when a new private message arrives while the
inbox is open.
- Long command output uses a small pager so `:last` and `:search` are readable
on small terminals.
@ -41,7 +49,8 @@ The product path should stay short:
`:last` and `:search`
- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends
`/me`, and exits
- second user reads `:inbox`
- second user opens `:inbox` before the private message arrives and sees it
auto-refresh after delivery
- exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content

View file

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

View file

@ -32,20 +32,18 @@ int client_printf(client_t *client, const char *fmt, ...);
/* Reference counting for safe cross-thread cleanup.
*
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
* (the "main" ref), then adds a second ref before installing the channel
* callbacks (the "callback" ref) so the client outlives any in-flight
* eof / close / window-change callback invocation. The interactive
* session releases both refs in its cleanup path; the final release
* frees the SSH session, channel, callback struct, and the client_t. */
* (the "main" ref). client_install_channel_callbacks() takes a second
* ref owned by client.c while channel callbacks are installed, so the
* client outlives in-flight eof / close / window-change callbacks.
* input_run_session() ends ownership with client_release_session(). */
void client_addref(client_t *client);
void client_release(client_t *client);
void client_release_session(client_t *client);
/* Install the post-bootstrap channel callbacks (window-change, eof, close)
* that target this client_t. Caller MUST have already added one
* client_addref() to keep the client alive across in-flight callback
* invocations; the matching client_release() happens during cleanup in
* input_run_session(). Returns 0 on success, -1 on failure (in which
* case the caller still owns both refs and must release them). */
/* Install the post-bootstrap channel callbacks (window-change, eof, close).
* On success this function takes the callback reference described above.
* On failure no callback reference remains and the caller still owns only
* its original main reference. */
int client_install_channel_callbacks(client_t *client);
#endif /* CLIENT_H */

View file

@ -19,4 +19,9 @@
* path; callers must not hold client->io_lock before dispatching. */
void commands_dispatch(client_t *client);
/* Rebuild the currently visible command output when it is backed by live
* client state, such as :inbox. Returns true if output changed and the caller
* should render it again. */
bool commands_refresh_active_output(client_t *client);
#endif /* COMMANDS_H */

View file

@ -11,6 +11,8 @@
#include <limits.h>
#include <pthread.h>
#include "config_defaults.h"
/* Project Metadata */
#define TNT_VERSION "1.0.1"
@ -23,7 +25,6 @@
#define TNT_EXIT_CONFIG 78
/* Configuration constants */
#define DEFAULT_PORT 2222
#define MAX_MESSAGES 100
#define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024
@ -31,13 +32,17 @@
#define MAX_COMMAND_OUTPUT_LEN 8192
#define CLIENT_OUTBOX_CAPACITY (128 * 1024)
#define CLIENT_OUTBOX_FLUSH_BUDGET 32768
#define DEFAULT_MAX_CLIENTS 64
#define MAX_CONFIGURED_CLIENTS 1024
#define LOG_FILE "messages.log"
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
#define HOST_KEY_FILE "host_key"
#define TNT_DEFAULT_STATE_DIR "."
#define DEFAULT_IDLE_TIMEOUT 1800 /* 30 minutes */
/* Backward-compatible names for older modules while config_defaults owns the
* actual runtime defaults and accepted ranges. */
#define DEFAULT_PORT TNT_DEFAULT_PORT
#define DEFAULT_MAX_CLIENTS TNT_DEFAULT_MAX_CONNECTIONS
#define MAX_CONFIGURED_CLIENTS TNT_MAX_CONFIGURED_CLIENTS
#define DEFAULT_IDLE_TIMEOUT TNT_DEFAULT_IDLE_TIMEOUT
/* ANSI color codes */
#define ANSI_RESET "\033[0m"

47
include/config_defaults.h Normal file
View file

@ -0,0 +1,47 @@
#ifndef CONFIG_DEFAULTS_H
#define CONFIG_DEFAULTS_H
#include <stdbool.h>
#define TNT_STRINGIFY_VALUE(value) #value
#define TNT_STRINGIFY(value) TNT_STRINGIFY_VALUE(value)
#define TNT_DEFAULT_PORT 2222
#define TNT_DEFAULT_PORT_TEXT TNT_STRINGIFY(TNT_DEFAULT_PORT)
#define TNT_DEFAULT_MAX_CONNECTIONS 64
#define TNT_DEFAULT_MAX_CONN_PER_IP 5
#define TNT_DEFAULT_MAX_CONN_RATE_PER_IP 10
#define TNT_DEFAULT_RATE_LIMIT_ENABLED 1
#define TNT_DEFAULT_IDLE_TIMEOUT 1800
#define TNT_MIN_PORT 1
#define TNT_MAX_PORT 65535
#define TNT_MIN_CONFIGURED_CLIENTS 1
#define TNT_MAX_CONFIGURED_CLIENTS 1024
#define TNT_MIN_RATE_LIMIT_ENABLED 0
#define TNT_MAX_RATE_LIMIT_ENABLED 1
#define TNT_MIN_IDLE_TIMEOUT 0
#define TNT_MAX_IDLE_TIMEOUT 86400
#define TNT_MIN_SSH_LOG_LEVEL 0
#define TNT_MAX_SSH_LOG_LEVEL 4
typedef struct {
const char *env_name;
int fallback;
int min_value;
int max_value;
} tnt_int_config_spec_t;
extern const tnt_int_config_spec_t TNT_CONFIG_PORT;
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONNECTIONS;
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_PER_IP;
extern const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_RATE_PER_IP;
extern const tnt_int_config_spec_t TNT_CONFIG_RATE_LIMIT;
extern const tnt_int_config_spec_t TNT_CONFIG_IDLE_TIMEOUT;
extern const tnt_int_config_spec_t TNT_CONFIG_SSH_LOG_LEVEL;
int tnt_config_env_int(const tnt_int_config_spec_t *spec);
bool tnt_config_parse_int(const char *value, const tnt_int_config_spec_t *spec,
int *out);
#endif /* CONFIG_DEFAULTS_H */

View file

@ -9,8 +9,10 @@ typedef enum {
TNT_EXEC_COMMAND_USERS,
TNT_EXEC_COMMAND_STATS,
TNT_EXEC_COMMAND_TAIL,
TNT_EXEC_COMMAND_DUMP,
TNT_EXEC_COMMAND_POST,
TNT_EXEC_COMMAND_EXIT
TNT_EXEC_COMMAND_EXIT,
TNT_EXEC_COMMAND_COUNT
} tnt_exec_command_id_t;
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
@ -18,6 +20,8 @@ bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args);
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void exec_catalog_append_command_list(char *buffer, size_t buf_size,
size_t *pos);
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang);

View file

@ -7,8 +7,12 @@ typedef struct {
const char *text[UI_LANG_COUNT];
} i18n_string_t;
#define I18N_LANG_TEXT(lang, value) [lang] = (value)
#define I18N_EN(value) I18N_LANG_TEXT(UI_LANG_EN, value)
#define I18N_ZH(value) I18N_LANG_TEXT(UI_LANG_ZH, value)
#define I18N_STRING_MAP(...) {{ __VA_ARGS__ }}
#define I18N_STRING(en_text, zh_text) \
{{ [UI_LANG_EN] = (en_text), [UI_LANG_ZH] = (zh_text) }}
I18N_STRING_MAP(I18N_EN(en_text), I18N_ZH(zh_text))
typedef enum {
I18N_USERNAME_PROMPT,
@ -25,6 +29,7 @@ typedef enum {
I18N_HELP_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_TITLE,
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
I18N_MOTD_TITLE,
I18N_MOTD_CONTINUE_HINT,
I18N_TITLE_ONLINE_FORMAT,

View file

@ -26,4 +26,9 @@ void message_format(const message_t *msg, char *buffer, size_t buf_size, int wid
* Returns the last max_results matches in chronological order; caller must free *results. */
int message_search(const char *query, message_t **results, int max_results);
/* Export valid persisted log records in messages.log v1 format. max_records
* 0 exports all valid records; positive values export the last max_records
* valid records. Caller must free *output. */
int message_dump_text(char **output, size_t *output_len, int max_records);
#endif /* MESSAGE_H */

21
include/message_log.h Normal file
View file

@ -0,0 +1,21 @@
#ifndef MESSAGE_LOG_H
#define MESSAGE_LOG_H
#include "message.h"
#define MESSAGE_LOG_MAX_LINE 2048
void message_log_format_timestamp_utc(time_t ts, char *buffer,
size_t buf_size);
/* Parse one complete messages.log v1 record. `now` is used to reject records
* outside TNT's accepted replay window. */
bool message_log_parse_record(const char *line, message_t *out, time_t now);
/* Format one messages.log v1 record. record_len receives the number of bytes
* that would be written, excluding the trailing NUL. Passing NULL/0 for the
* output buffer is allowed when only the length is needed. */
int message_log_format_record(const message_t *msg, char *buffer,
size_t buf_size, size_t *record_len);
#endif /* MESSAGE_LOG_H */

View file

@ -0,0 +1,9 @@
#ifndef MESSAGE_LOG_TOOL_H
#define MESSAGE_LOG_TOOL_H
#include "common.h"
int message_log_tool_check(const char *path);
int message_log_tool_recover(const char *path);
#endif /* MESSAGE_LOG_TOOL_H */

View file

@ -17,6 +17,12 @@ typedef struct {
char content[MAX_MESSAGE_LEN];
} whisper_t;
typedef enum {
TNT_COMMAND_OUTPUT_NONE,
TNT_COMMAND_OUTPUT_GENERIC,
TNT_COMMAND_OUTPUT_INBOX
} tnt_command_output_kind_t;
/* Client connection structure */
typedef struct client {
ssh_session session; /* SSH session */
@ -42,6 +48,7 @@ typedef struct client {
int insert_history_pos;
char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll;
tnt_command_output_kind_t command_output_kind;
bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
@ -67,6 +74,7 @@ typedef struct client {
pthread_mutex_t ref_lock; /* Lock for ref_count */
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
bool channel_callback_ref; /* client.c owns one ref while callbacks are installed */
struct ssh_channel_callbacks_struct *channel_cb;
} client_t;

29
include/tntctl_text.h Normal file
View file

@ -0,0 +1,29 @@
#ifndef TNTCTL_TEXT_H
#define TNTCTL_TEXT_H
#include "common.h"
typedef enum {
TNTCTL_TEXT_INVALID_PORT,
TNTCTL_TEXT_INVALID_LOGIN,
TNTCTL_TEXT_INVALID_HOST_KEY_MODE,
TNTCTL_TEXT_INVALID_KNOWN_HOSTS,
TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
TNTCTL_TEXT_MISSING_HOST,
TNTCTL_TEXT_INVALID_HOST,
TNTCTL_TEXT_LOGIN_HOST_CONFLICT,
TNTCTL_TEXT_UNKNOWN_COMMAND,
TNTCTL_TEXT_INVALID_REMOTE_COMMAND,
TNTCTL_TEXT_DESTINATION_TOO_LONG,
TNTCTL_TEXT_INVALID_DESTINATION,
TNTCTL_TEXT_OUT_OF_MEMORY,
TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG,
TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG,
TNTCTL_TEXT_COUNT
} tntctl_text_id_t;
void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id);
#endif /* TNTCTL_TEXT_H */

View file

@ -27,6 +27,34 @@ sha256_of() {
fi
}
warn_missing_libssh() {
case "$OS" in
linux)
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q 'libssh\.so'; then
return
fi
for path in /usr/lib/libssh.so* /usr/lib64/libssh.so* \
/lib/libssh.so* /lib64/libssh.so*; do
[ -e "$path" ] && return
done
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first, for example:"
echo " Ubuntu/Debian: sudo apt install libssh-4"
echo " Arch: sudo pacman -S libssh"
;;
darwin)
if [ -e /opt/homebrew/opt/libssh/lib/libssh.dylib ] ||
[ -e /usr/local/opt/libssh/lib/libssh.dylib ]; then
return
fi
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first:"
echo " brew install libssh"
;;
esac
}
need_cmd curl
need_cmd awk
@ -53,6 +81,7 @@ echo "OS: $OS"
echo "Arch: $ARCH"
echo "Version: $VERSION"
echo ""
warn_missing_libssh
# Get latest version if not specified
if [ "$VERSION" = "latest" ]; then

View file

@ -17,7 +17,7 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
Also update package versions in Arch, Homebrew, and Debian drafts.
2. Create a GitHub release tag such as `v1.0.1`.
2. Create a GitHub release tag such as `vX.Y.Z`.
3. Build and upload release tarballs or rely on GitHub source archives.
4. Replace placeholder checksums in package drafts.
5. Verify package contents in an isolated directory:
@ -26,13 +26,23 @@ Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
make release-check
```
6. Before submitting package recipes, replace checksum placeholders and run:
6. Assemble a Debian/PPA source tree when preparing Ubuntu packaging:
```sh
make release-check-strict
make debian-source-package
```
7. Submit packages manually:
Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system
with `dpkg-buildpackage` installed to build the unsigned source package.
7. Before submitting package recipes, download the final GitHub source archive,
replace checksum placeholders, and run:
```sh
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
```
8. Submit packages manually:
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.

View file

@ -10,6 +10,8 @@ pkgbase = tnt-chat
makedepends = make
depends = libssh
source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
source = tnt-chat.sysusers
sha256sums = SKIP
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
pkgname = tnt-chat

View file

@ -1,4 +1,4 @@
# Maintainer: M1ng <REPLACE_WITH_EMAIL>
# Maintainer: M1ng <contact@m1ng.space>
pkgname=tnt-chat
pkgver=1.0.1
@ -9,8 +9,10 @@ url='https://github.com/m1ngsama/TNT'
license=('MIT')
depends=('libssh')
makedepends=('gcc' 'make')
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('SKIP')
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
"${pkgname}.sysusers")
sha256sums=('SKIP'
'8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed')
build() {
cd "TNT-${pkgver}"
@ -21,5 +23,7 @@ package() {
cd "TNT-${pkgver}"
make DESTDIR="${pkgdir}" PREFIX=/usr install
make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd
install -Dm644 "${srcdir}/${pkgname}.sysusers" \
"${pkgdir}/usr/lib/sysusers.d/${pkgname}.conf"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -26,11 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`:
makepkg --printsrcinfo > .SRCINFO
```
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
archive checksum, then run the project-level strict check:
Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub
source archive checksum, regenerate `.SRCINFO`, then run the package publish
check:
```sh
make release-check-strict
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
```
## Manual AUR submission
@ -40,7 +41,7 @@ git clone ssh://aur@aur.archlinux.org/tnt-chat.git aur-tnt-chat
cp PKGBUILD .SRCINFO aur-tnt-chat/
cd aur-tnt-chat
git add PKGBUILD .SRCINFO
git commit -m "Update to 1.0.1"
git commit -m "Update to X.Y.Z"
git push
```

View file

@ -0,0 +1 @@
u tnt - "TNT chat server" /var/lib/tnt -

View file

@ -6,18 +6,17 @@ the project has a stable release cadence.
## Draft metadata
The `debian/` directory in this folder is a packaging draft. To test it against
an upstream release tree, copy it to the root of a clean source checkout:
The `debian/` directory in this folder is a packaging draft. To assemble it
against a clean source tree:
```sh
cp -a packaging/debian/debian ./debian
dpkg-buildpackage -us -uc
make debian-source-package
```
For PPA uploads, build a signed source package instead:
For PPA uploads, build a source package on Debian/Ubuntu:
```sh
debuild -S
scripts/package_debian_source.sh --build
```
## Recommended path
@ -47,3 +46,5 @@ debuild -S
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
- Runtime dependency: `libssh`
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
- System user: package maintainer scripts create `tnt:tnt`; the systemd unit
owns `/var/lib/tnt` through `StateDirectory=tnt`

View file

@ -2,4 +2,4 @@ tnt-chat (1.0.1-1) unstable; urgency=medium
* Initial package draft.
-- M1ng <REPLACE_WITH_EMAIL> Thu, 21 May 2026 00:00:00 +0800
-- M1ng <contact@m1ng.space> Thu, 21 May 2026 00:00:00 +0800

View file

@ -1,7 +1,7 @@
Source: tnt-chat
Section: net
Priority: optional
Maintainer: M1ng <REPLACE_WITH_EMAIL>
Maintainer: M1ng <contact@m1ng.space>
Build-Depends:
debhelper-compat (= 13),
libssh-dev,
@ -15,7 +15,8 @@ Package: tnt-chat
Architecture: any
Depends:
${misc:Depends},
${shlibs:Depends}
${shlibs:Depends},
adduser
Description: SSH-native terminal chat server
TNT is a minimalist terminal chat server accessed over SSH. It provides a
Vim-style terminal interface, anonymous access by default, persistent message

View file

@ -0,0 +1,10 @@
#!/bin/sh
set -e
if [ "$1" = "configure" ] && ! getent passwd tnt >/dev/null; then
adduser --system --group --home /var/lib/tnt --no-create-home --gecos "TNT chat server" tnt
fi
#DEBHELPER#
exit 0

View file

@ -6,6 +6,7 @@ project tap first, not Homebrew core:
```sh
brew tap m1ngsama/tnt
brew install tnt-chat
brew services start tnt-chat
```
Homebrew core should wait until TNT has stable releases and broader usage.
@ -18,6 +19,7 @@ From a tap repository:
brew audit --strict --online tnt-chat
brew install --build-from-source ./Formula/tnt-chat.rb
brew test tnt-chat
brew services run tnt-chat
```
For local syntax-only validation from this repository:
@ -28,20 +30,20 @@ ruby -c packaging/homebrew/tnt-chat.rb
## Updating the formula
1. Publish a GitHub release tag such as `v1.0.1`.
1. Publish a GitHub release tag such as `vX.Y.Z`.
2. Download or hash the release source archive:
```sh
curl -L -o tnt-chat-1.0.1.tar.gz \
https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
shasum -a 256 tnt-chat-1.0.1.tar.gz
curl -L -o dist/tnt-chat-vX.Y.Z.tar.gz \
https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz
shasum -a 256 dist/tnt-chat-vX.Y.Z.tar.gz
```
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
4. Run:
```sh
make release-check-strict
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
```
5. Copy the formula into the tap repository and open a normal review PR.

View file

@ -15,6 +15,17 @@ class TntChat < Formula
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
(var/"tnt").mkpath
(var/"log").mkpath
end
service do
run [opt_bin/"tnt", "-d", var/"tnt"]
keep_alive true
working_dir var/"tnt"
log_path var/"log/tnt.log"
error_log_path var/"log/tnt.log"
end
test do

31
scripts/check_release_ref.sh Executable file
View file

@ -0,0 +1,31 @@
#!/bin/sh
# Verify that a release tag matches TNT_VERSION.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
fail() {
echo "release-ref-check: $*" >&2
exit 1
}
ref=${1:-${GITHUB_REF_NAME:-}}
[ -n "$ref" ] || fail "missing release ref; pass vX.Y.Z or set GITHUB_REF_NAME"
case "$ref" in
refs/tags/*) tag=${ref#refs/tags/} ;;
*) tag=$ref ;;
esac
printf '%s\n' "$tag" | grep -Eq '^v[0-9]+\.[0-9]+\.[0-9]+$' ||
fail "release ref must be vMAJOR.MINOR.PATCH, got $tag"
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
[ "$tag" = "v$version" ] ||
fail "release tag $tag does not match TNT_VERSION $version"
echo "release ref matches TNT_VERSION: $tag"

View file

@ -1,44 +1,174 @@
#!/bin/bash
# TNT Log Rotation Script
# Keeps chat history manageable and prevents disk space issues
#!/bin/sh
# Compact and archive a TNT messages.log file.
#
# This is an operator-run maintenance tool. For strict consistency, stop TNT
# or run it during a quiet maintenance window before compacting the active log.
LOG_FILE="${1:-/var/lib/tnt/messages.log}"
MAX_SIZE_MB="${2:-100}"
KEEP_LINES="${3:-10000}"
set -eu
# Check if log file exists
if [ ! -f "$LOG_FILE" ]; then
echo "Log file $LOG_FILE does not exist"
DRY_RUN=0
KEEP_ARCHIVES=5
usage() {
cat <<'USAGE'
Usage: scripts/logrotate.sh [--dry-run] [--keep-archives N] [LOG_FILE [MAX_SIZE_MB [KEEP_LINES]]]
Defaults:
LOG_FILE /var/lib/tnt/messages.log
MAX_SIZE_MB 100
KEEP_LINES 10000
Exit status:
0 success, including missing log file
1 runtime error
64 invalid arguments
USAGE
}
fail_usage() {
echo "logrotate: $*" >&2
usage >&2
exit 64
}
fail() {
echo "logrotate: $*" >&2
exit 1
}
is_uint() {
case "${1:-}" in
''|*[!0-9]*)
return 1
;;
*)
return 0
;;
esac
}
is_positive_uint() {
is_uint "$1" && [ "$1" -gt 0 ]
}
while [ "$#" -gt 0 ]; do
case "$1" in
--dry-run)
DRY_RUN=1
shift
;;
--keep-archives)
[ "$#" -ge 2 ] || fail_usage "missing value for --keep-archives"
is_uint "$2" || fail_usage "invalid archive count: $2"
KEEP_ARCHIVES=$2
shift 2
;;
-h|--help)
usage
exit 0
;;
--)
shift
break
;;
-*)
fail_usage "unknown option: $1"
;;
*)
break
;;
esac
done
[ "$#" -le 3 ] || fail_usage "too many arguments"
LOG_FILE=${1:-/var/lib/tnt/messages.log}
MAX_SIZE_MB=${2:-100}
KEEP_LINES=${3:-10000}
case "$LOG_FILE" in
''|-*)
fail_usage "invalid log path"
;;
esac
is_uint "$MAX_SIZE_MB" || fail_usage "invalid max size: $MAX_SIZE_MB"
is_positive_uint "$KEEP_LINES" || fail_usage "invalid keep lines: $KEEP_LINES"
if [ ! -e "$LOG_FILE" ]; then
echo "logrotate: $LOG_FILE does not exist"
exit 0
fi
[ -f "$LOG_FILE" ] || fail "$LOG_FILE is not a regular file"
# Get file size in MB
FILE_SIZE=$(du -m "$LOG_FILE" | cut -f1)
MAX_BYTES=$((MAX_SIZE_MB * 1024 * 1024))
FILE_SIZE=$(wc -c < "$LOG_FILE" | tr -d ' ')
[ -n "$FILE_SIZE" ] || fail "could not read log size"
# Rotate if file is too large
if [ "$FILE_SIZE" -gt "$MAX_SIZE_MB" ]; then
echo "Log file size: ${FILE_SIZE}MB, rotating..."
compact_log() {
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
backup="${LOG_FILE}.${timestamp}"
suffix=1
# Create backup
BACKUP="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)"
cp "$LOG_FILE" "$BACKUP"
while [ -e "$backup" ] || [ -e "${backup}.gz" ]; do
backup="${LOG_FILE}.${timestamp}.${suffix}"
suffix=$((suffix + 1))
done
# Keep only last N lines
tail -n "$KEEP_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp"
mv "${LOG_FILE}.tmp" "$LOG_FILE"
# Compress old backup
gzip "$BACKUP"
echo "Log rotated. Backup: ${BACKUP}.gz"
echo "Kept last $KEEP_LINES lines"
else
echo "Log file size: ${FILE_SIZE}MB (under ${MAX_SIZE_MB}MB limit)"
if [ "$DRY_RUN" -eq 1 ]; then
echo "logrotate: would archive $LOG_FILE to $backup"
echo "logrotate: would keep last $KEEP_LINES lines"
return 0
fi
# Clean up old compressed logs (keep last 5)
LOG_DIR=$(dirname "$LOG_FILE")
cd "$LOG_DIR" || exit
ls -t messages.log.*.gz 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null
tmp="${LOG_FILE}.tmp.$$"
rm -f "$tmp"
cp -p "$LOG_FILE" "$backup" || fail "failed to create archive"
if ! tail -n "$KEEP_LINES" "$LOG_FILE" > "$tmp"; then
rm -f "$tmp"
fail "failed to compact log"
fi
if ! cat "$tmp" > "$LOG_FILE"; then
rm -f "$tmp"
fail "failed to replace log"
fi
rm -f "$tmp"
echo "Log rotation complete"
if command -v gzip >/dev/null 2>&1; then
gzip -f "$backup" || fail "failed to compress archive"
backup="${backup}.gz"
fi
echo "logrotate: archived $backup"
echo "logrotate: kept last $KEEP_LINES lines"
}
cleanup_archives() {
[ "$KEEP_ARCHIVES" -ge 0 ] || return 0
archives=$(
ls -1t "$LOG_FILE".*.gz "$LOG_FILE".[0-9]* 2>/dev/null || true
)
[ -n "$archives" ] || return 0
printf '%s\n' "$archives" |
awk '!seen[$0]++' |
awk -v keep="$KEEP_ARCHIVES" 'NR > keep' |
while IFS= read -r old; do
[ -n "$old" ] || continue
if [ "$DRY_RUN" -eq 1 ]; then
echo "logrotate: would remove $old"
else
rm -f "$old"
fi
done
}
if [ "$FILE_SIZE" -gt "$MAX_BYTES" ]; then
echo "logrotate: size ${FILE_SIZE} bytes exceeds ${MAX_BYTES} bytes"
compact_log
else
echo "logrotate: size ${FILE_SIZE} bytes is within ${MAX_BYTES} bytes"
fi
cleanup_archives
echo "logrotate: complete"

View file

@ -0,0 +1,79 @@
#!/bin/sh
# Assemble a Debian/Ubuntu source-package tree. This script never uploads.
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/package_debian_source.sh [--build] [OUT_DIR]
Create OUT_DIR/tnt-chat-$TNT_VERSION from tracked source files and copy the
draft Debian metadata to OUT_DIR/tnt-chat-$TNT_VERSION/debian.
Options:
--build run dpkg-buildpackage -S -us -uc after assembly
Default OUT_DIR: dist/debian-source
USAGE
}
fail() {
echo "package-debian-source: $*" >&2
exit 1
}
BUILD=0
OUT_DIR=${TNT_DEBIAN_SOURCE_OUT:-dist/debian-source}
OUT_SET=0
while [ "$#" -gt 0 ]; do
case "$1" in
--build)
BUILD=1
;;
-h|--help)
usage
exit 0
;;
-*)
fail "unknown option: $1"
;;
*)
[ "$OUT_SET" -eq 0 ] || fail "multiple output directories"
OUT_DIR=$1
OUT_SET=1
;;
esac
shift
done
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
[ -n "$VERSION" ] || fail "could not read TNT_VERSION"
SOURCE_NAME="tnt-chat-$VERSION"
SOURCE_ROOT="$OUT_DIR/$SOURCE_NAME"
[ ! -e "$SOURCE_ROOT" ] || fail "$SOURCE_ROOT already exists"
mkdir -p "$OUT_DIR"
mkdir -p "$SOURCE_ROOT"
git ls-files -z | cpio -0 -pdm "$SOURCE_ROOT" >/dev/null 2>&1
cp -R "$ROOT/packaging/debian/debian" "$SOURCE_ROOT/debian"
[ -f "$SOURCE_ROOT/debian/control" ] || fail "missing debian/control"
[ -x "$SOURCE_ROOT/debian/rules" ] || fail "missing executable debian/rules"
[ -x "$SOURCE_ROOT/debian/postinst" ] || fail "missing executable debian/postinst"
echo "Debian source tree assembled: $SOURCE_ROOT"
if [ "$BUILD" -eq 1 ]; then
command -v dpkg-buildpackage >/dev/null 2>&1 ||
fail "dpkg-buildpackage not found"
(
cd "$SOURCE_ROOT"
dpkg-buildpackage -S -us -uc
)
fi

View file

@ -0,0 +1,68 @@
#!/bin/sh
# Verify package-manager recipes against a final release source archive.
set -eu
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
fail() {
echo "package-publish-check: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
fail "sha256sum or shasum is required"
fi
}
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
source_tarball=${SOURCE_TARBALL:-${RELEASE_SOURCE_TARBALL:-}}
[ -n "$source_tarball" ] ||
fail "set SOURCE_TARBALL to the final GitHub source archive"
[ -f "$source_tarball" ] ||
fail "SOURCE_TARBALL does not exist: $source_tarball"
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
fail "replace maintainer email placeholders before package publishing"
arch_sha=$(sed -n "s/^[[:space:]]*sha256sums=('\([^']*\)'.*/\1/p" \
packaging/arch/PKGBUILD | head -n 1)
srcinfo_sha=$(sed -n 's/^[[:space:]]*sha256sums = \([^[:space:]]*\).*/\1/p' \
packaging/arch/.SRCINFO | head -n 1)
brew_sha=$(sed -n 's/^[[:space:]]*sha256 "\([^"]*\)".*/\1/p' \
packaging/homebrew/tnt-chat.rb | head -n 1)
[ -n "$arch_sha" ] || fail "could not read PKGBUILD source checksum"
[ -n "$srcinfo_sha" ] || fail "could not read .SRCINFO source checksum"
[ -n "$brew_sha" ] || fail "could not read Homebrew source checksum"
[ "$arch_sha" != "SKIP" ] || fail "replace PKGBUILD sha256sums before publishing"
[ "$srcinfo_sha" != "SKIP" ] || fail "replace .SRCINFO sha256sums before publishing"
[ "$brew_sha" != "REPLACE_WITH_RELEASE_TARBALL_SHA256" ] ||
fail "replace Homebrew sha256 before publishing"
expected_sha=$(sha256_of "$source_tarball")
[ "$arch_sha" = "$expected_sha" ] ||
fail "PKGBUILD source checksum does not match SOURCE_TARBALL"
[ "$srcinfo_sha" = "$expected_sha" ] ||
fail ".SRCINFO source checksum does not match SOURCE_TARBALL"
[ "$brew_sha" = "$expected_sha" ] ||
fail "Homebrew source checksum does not match SOURCE_TARBALL"
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
fail "PKGBUILD pkgver does not match $version"
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
fail ".SRCINFO pkgver does not match $version"
grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb ||
fail "Homebrew URL does not match v$version"
grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog ||
fail "Debian changelog version does not match $version"
echo "package recipes match SOURCE_TARBALL for $version: $expected_sha"

View file

@ -13,6 +13,7 @@ Default checks:
- version metadata alignment
- clean build
- unit tests
- script tests
- staged install layout with PREFIX=/usr and DESTDIR
- installer shell syntax
- Debian packaging metadata
@ -21,11 +22,13 @@ Default checks:
Environment:
RUN_INTEGRATION=1 also run full make test
RUN_SOAK=1 also run the configurable soak test
RUN_SLOW_CLIENT=1 also run the slow-client backpressure test
PORT=12720 base port for integration tests
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
matching changelog release section, real package checksums, and non-placeholder
maintainer metadata, then build from the tagged source archive.
matching changelog release section, non-placeholder maintainer metadata, and a
build from the tagged source archive. Run `make package-publish-check` after
the final GitHub source archive exists to verify package checksums.
USAGE
}
@ -101,6 +104,9 @@ step "running unit tests"
make -C tests/unit clean
make -C tests/unit run
step "running script tests"
make script-test
step "checking client I/O ownership boundaries"
! grep -R "client_send(target" src include >/dev/null ||
fail "cross-client target writes must be queued through client_queue_bell"
@ -108,6 +114,10 @@ step "checking client I/O ownership boundaries"
fail "cross-client target-array writes must be queued through client_queue_bell"
! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null ||
fail "commands.c must not use SSH io_lock for in-memory command state"
! grep -n "client_addref(client)" src/bootstrap.c >/dev/null ||
fail "bootstrap.c must let client_install_channel_callbacks own callback refs"
grep -q "client_release_session(client)" src/input.c ||
fail "input.c must release session ownership through client_release_session"
if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then
fail "raw SSH channel writes must stay inside src/client.c"
fi
@ -123,6 +133,13 @@ if [ "${RUN_SOAK:-0}" = "1" ]; then
DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}"
fi
if [ "${RUN_SLOW_CLIENT:-0}" = "1" ]; then
step "running slow-client test"
make slow-client-test PORT="$((${PORT:-12720} + 40))" \
DURATION="${SLOW_CLIENT_DURATION:-8}" \
BURST_CHARS="${SLOW_CLIENT_BURST_CHARS:-1600}"
fi
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
cleanup() {
rm -rf "$tmpdir"
@ -142,14 +159,80 @@ make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
step "checking installed log maintenance modes"
log_smoke="$tmpdir/messages.log"
recovered_log="$tmpdir/recovered.messages.log"
recover_report="$tmpdir/recovered.report"
smoke_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > "$log_smoke" <<EOF
$smoke_ts|alice|one
$smoke_ts|mallory|extra|pipe
$smoke_ts|bob|two
EOF
if "$tmpdir/usr/bin/tnt" --log-check "$log_smoke" >"$tmpdir/log-check.out" 2>&1; then
fail "installed tnt --log-check should report invalid records"
fi
grep -q '^valid_records 2$' "$tmpdir/log-check.out" ||
fail "installed tnt --log-check did not report valid records"
grep -q '^invalid_records 1$' "$tmpdir/log-check.out" ||
fail "installed tnt --log-check did not report invalid records"
if "$tmpdir/usr/bin/tnt" --log-recover "$log_smoke" \
>"$recovered_log" 2>"$recover_report"; then
fail "installed tnt --log-recover should report invalid records"
fi
grep -q "$smoke_ts|alice|one" "$recovered_log" ||
fail "installed tnt --log-recover missed alice record"
grep -q "$smoke_ts|bob|two" "$recovered_log" ||
fail "installed tnt --log-recover missed bob record"
! grep -q 'mallory' "$recovered_log" ||
fail "installed tnt --log-recover preserved invalid record"
grep -q '^invalid_records 1$' "$recover_report" ||
fail "installed tnt --log-recover did not report invalid records"
step "checking installer syntax"
sh -n install.sh
sh -n scripts/check_release_ref.sh
sh -n scripts/package_publish_check.sh
scripts/check_release_ref.sh "v$version"
bad_ref=v0.0.0
[ "$version" != "0.0.0" ] || bad_ref=v9.9.9
if scripts/check_release_ref.sh "$bad_ref" >/dev/null 2>&1; then
fail "release ref check accepted a mismatched tag"
fi
step "checking Debian packaging metadata"
[ -x packaging/debian/debian/rules ] ||
fail "packaging/debian/debian/rules must be executable"
[ -x packaging/debian/debian/postinst ] ||
fail "packaging/debian/debian/postinst must be executable"
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
fail "unsupported Debian source format"
grep -q "adduser .* tnt" packaging/debian/debian/postinst ||
fail "Debian postinst must create the tnt system user"
grep -q " adduser" packaging/debian/debian/control ||
fail "Debian package must depend on adduser for postinst user creation"
step "checking Debian source assembly"
sh -n scripts/package_debian_source.sh
scripts/package_debian_source.sh "$tmpdir/debian-source"
[ -f "$tmpdir/debian-source/tnt-chat-$version/debian/control" ] ||
fail "assembled Debian source tree is missing debian/control"
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/rules" ] ||
fail "assembled Debian source tree is missing executable debian/rules"
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/postinst" ] ||
fail "assembled Debian source tree is missing executable debian/postinst"
step "checking packaged system user metadata"
grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers ||
fail "Arch sysusers file must create the tnt system user"
grep -q 'usr/lib/sysusers.d' packaging/arch/PKGBUILD ||
fail "PKGBUILD must install the sysusers.d file"
step "checking Homebrew service metadata"
grep -q "service do" packaging/homebrew/tnt-chat.rb ||
fail "Homebrew formula must define a brew services entry"
grep -q 'opt_bin/"tnt"' packaging/homebrew/tnt-chat.rb ||
fail "Homebrew service must run the installed tnt binary"
step "checking packaging syntax"
if command -v bash >/dev/null 2>&1; then
@ -174,12 +257,6 @@ if [ "$STRICT" -eq 1 ]; then
fail "local tag v$version does not point at HEAD"
grep -q "^## $version " docs/CHANGELOG.md ||
fail "docs/CHANGELOG.md does not contain a release section for $version"
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
fail "replace PKGBUILD sha256sums before strict release"
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
fail "replace .SRCINFO sha256sums before strict release"
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
fail "replace Homebrew sha256 before strict release"
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
fail "replace maintainer email placeholders before strict release"

View file

@ -476,17 +476,12 @@ void *bootstrap_run(void *arg) {
}
client->exec_command_too_long = ctx->exec_command_too_long;
/* Add a ref for the channel callbacks (eof/close/window_change) so the
* client_t outlives any in-flight callback invocation. */
client_addref(client);
if (client_install_channel_callbacks(client) < 0) {
/* Nullify session/channel ownership so client_release won't
* double-free what cleanup_failed_session is about to free. */
client->session = NULL;
client->channel = NULL;
client_release(client); /* drop the callback ref (2 → 1) */
client_release(client); /* drop the main ref (1 → 0, frees client) */
client_release(client);
cleanup_failed_session(session, ctx);
return NULL;
}

View file

@ -1,11 +1,11 @@
#include "chat_room.h"
#include "config_defaults.h"
/* Global chat room instance */
chat_room_t *g_room = NULL;
static int room_capacity_from_env(void) {
return env_int("TNT_MAX_CONNECTIONS", DEFAULT_MAX_CLIENTS, 1,
MAX_CONFIGURED_CLIENTS);
return tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
}
/* Initialize chat room */

View file

@ -1,5 +1,6 @@
#include "cli_text.h"
#include "config_defaults.h"
#include "i18n.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
@ -12,12 +13,14 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
" -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-connections N Global connection limit (default: %d)\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"
" --log-check FILE Check messages.log v1 records\n"
" --log-recover FILE Write valid records to stdout\n"
" -V, --version Show version\n"
" -h, --help Show this help\n"
"\n"
@ -26,9 +29,9 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
" TNT_STATE_DIR State directory\n"
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
" TNT_LANG UI language: en or zh (default: locale)\n"
" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n"
" TNT_MAX_CONNECTIONS Global connection limit (default: %d)\n"
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n",
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: %d)\n",
"tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n"
"选项:\n"
@ -36,12 +39,14 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
" -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-connections N 全局连接数限制 (默认: %d)\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"
" --log-check FILE 检查 messages.log v1 记录\n"
" --log-recover FILE 将有效记录写入 stdout\n"
" -V, --version 显示版本\n"
" -h, --help 显示此帮助\n"
"\n"
@ -50,16 +55,19 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
" TNT_STATE_DIR 状态目录\n"
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: %d)\n"
);
const char *program = (program_name && program_name[0] != '\0')
? program_name
: "tnt";
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
TNT_VERSION, program, DEFAULT_PORT);
TNT_VERSION, program, TNT_DEFAULT_PORT,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_IDLE_TIMEOUT);
}
const char *cli_text_invalid_port_format(ui_lang_t lang) {
@ -74,6 +82,13 @@ const char *cli_text_invalid_value_format(ui_lang_t lang) {
return i18n_string(text, lang);
}
const char *cli_text_option_requires_arg_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Option requires argument: %s\n",
"选项需要参数: %s\n");
return i18n_string(text, lang);
}
const char *cli_text_unknown_option_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");

View file

@ -244,6 +244,25 @@ void client_release(client_t *client) {
}
}
void client_release_session(client_t *client) {
if (!client) return;
if (client->channel && client->channel_cb) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
if (client->channel_cb) {
free(client->channel_cb);
client->channel_cb = NULL;
}
if (client->channel_callback_ref) {
client->channel_callback_ref = false;
client_release(client);
}
client_release(client);
}
/* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...) {
char buffer[2048];
@ -315,8 +334,13 @@ int client_install_channel_callbacks(client_t *client) {
return -1;
}
client_addref(client);
client->channel_callback_ref = true;
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!client->channel_cb) {
client->channel_callback_ref = false;
client_release(client);
return -1;
}
@ -330,6 +354,8 @@ int client_install_channel_callbacks(client_t *client) {
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
free(client->channel_cb);
client->channel_cb = NULL;
client->channel_callback_ref = false;
client_release(client);
return -1;
}

View file

@ -52,12 +52,60 @@ static void append_command_usage(char *output, size_t buf_size, size_t *pos,
command_catalog_append_usage(output, buf_size, pos, id, lang);
}
static void append_inbox_output(client_t *client, char *output,
size_t buf_size, size_t *pos) {
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
client->unread_whispers = 0;
pthread_mutex_unlock(&client->whisper_lock);
buffer_appendf(output, buf_size, pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
snap_count);
if (snap_count == 0) {
buffer_appendf(output, buf_size, pos,
" \033[2;37m%s\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
}
for (int i = 0; i < snap_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&snapshot[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, buf_size, pos,
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
ts, snapshot[i].from, snapshot[i].content);
}
}
bool commands_refresh_active_output(client_t *client) {
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
size_t pos = 0;
if (!client || client->command_output_kind != TNT_COMMAND_OUTPUT_INBOX) {
return false;
}
append_inbox_output(client, output, sizeof(output), &pos);
snprintf(client->command_output, sizeof(client->command_output), "%s",
output);
client->command_output_scroll = 0;
return true;
}
void commands_dispatch(client_t *client) {
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf;
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
size_t pos = 0;
/* Trim whitespace */
@ -70,6 +118,10 @@ void commands_dispatch(client_t *client) {
end--;
}
}
if (cmd[0] == ':') {
cmd++;
while (*cmd == ' ') cmd++;
}
/* Save to command history */
if (cmd[0] != '\0') {
@ -219,9 +271,9 @@ void commands_dispatch(client_t *client) {
snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content),
"%s", rest);
target->unread_whispers++;
pthread_mutex_unlock(&target->whisper_lock);
target->unread_whispers++;
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_queue_bell(target);
@ -242,35 +294,8 @@ void commands_dispatch(client_t *client) {
}
} else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under whisper_lock so a concurrent sender doesn't
* tear what we're rendering. Counter reset happens after copy. */
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
pthread_mutex_unlock(&client->whisper_lock);
client->unread_whispers = 0;
buffer_appendf(output, sizeof(output), &pos,
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
snap_count);
if (snap_count == 0) {
buffer_appendf(output, sizeof(output), &pos,
" \033[2;37m%s\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
}
for (int i = 0; i < snap_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&snapshot[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
ts, snapshot[i].from, snapshot[i].content);
}
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
} else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg;
@ -414,6 +439,7 @@ void commands_dispatch(client_t *client) {
cmd_done:
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_output_scroll = 0;
client->command_output_kind = output_kind;
client->command_input[0] = '\0';
tui_render_command_output(client);
}

80
src/config_defaults.c Normal file
View file

@ -0,0 +1,80 @@
#include "config_defaults.h"
#include "common.h"
#include <stdlib.h>
const tnt_int_config_spec_t TNT_CONFIG_PORT = {
"PORT",
TNT_DEFAULT_PORT,
TNT_MIN_PORT,
TNT_MAX_PORT,
};
const tnt_int_config_spec_t TNT_CONFIG_MAX_CONNECTIONS = {
"TNT_MAX_CONNECTIONS",
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_MIN_CONFIGURED_CLIENTS,
TNT_MAX_CONFIGURED_CLIENTS,
};
const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_PER_IP = {
"TNT_MAX_CONN_PER_IP",
TNT_DEFAULT_MAX_CONN_PER_IP,
TNT_MIN_CONFIGURED_CLIENTS,
TNT_MAX_CONFIGURED_CLIENTS,
};
const tnt_int_config_spec_t TNT_CONFIG_MAX_CONN_RATE_PER_IP = {
"TNT_MAX_CONN_RATE_PER_IP",
TNT_DEFAULT_MAX_CONN_RATE_PER_IP,
TNT_MIN_CONFIGURED_CLIENTS,
TNT_MAX_CONFIGURED_CLIENTS,
};
const tnt_int_config_spec_t TNT_CONFIG_RATE_LIMIT = {
"TNT_RATE_LIMIT",
TNT_DEFAULT_RATE_LIMIT_ENABLED,
TNT_MIN_RATE_LIMIT_ENABLED,
TNT_MAX_RATE_LIMIT_ENABLED,
};
const tnt_int_config_spec_t TNT_CONFIG_IDLE_TIMEOUT = {
"TNT_IDLE_TIMEOUT",
TNT_DEFAULT_IDLE_TIMEOUT,
TNT_MIN_IDLE_TIMEOUT,
TNT_MAX_IDLE_TIMEOUT,
};
const tnt_int_config_spec_t TNT_CONFIG_SSH_LOG_LEVEL = {
"TNT_SSH_LOG_LEVEL",
0,
TNT_MIN_SSH_LOG_LEVEL,
TNT_MAX_SSH_LOG_LEVEL,
};
int tnt_config_env_int(const tnt_int_config_spec_t *spec) {
if (!spec) {
return 0;
}
return env_int(spec->env_name, spec->fallback, spec->min_value,
spec->max_value);
}
bool tnt_config_parse_int(const char *value, const tnt_int_config_spec_t *spec,
int *out) {
char *end = NULL;
long val;
if (!value || value[0] == '\0' || !spec || !out) {
return false;
}
val = strtol(value, &end, 10);
if (!end || *end != '\0' || val < spec->min_value ||
val > spec->max_value) {
return false;
}
*out = (int)val;
return true;
}

View file

@ -291,6 +291,45 @@ static int parse_tail_count(const char *args, int *count) {
return 0;
}
static int parse_dump_count(const char *args, int *count) {
char *end = NULL;
long value;
if (!count) {
return -1;
}
*count = 0;
if (!args || args[0] == '\0') {
return 0;
}
if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
args++;
}
}
value = strtol(args, &end, 10);
if (end == args) {
return -1;
}
while (*end) {
if (!isspace((unsigned char)*end)) {
return -1;
}
end++;
}
if (value < 1 || value > 10000) {
return -1;
}
*count = (int)value;
return 0;
}
static int exec_command_tail(client_t *client, const char *args) {
int requested = 20;
int total_messages;
@ -347,6 +386,27 @@ static int exec_command_tail(client_t *client, const char *args) {
return rc;
}
static int exec_command_dump(client_t *client, const char *args) {
int requested = 0;
char *output = NULL;
size_t output_len = 0;
int rc;
if (parse_dump_count(args, &requested) < 0) {
return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP);
}
if (message_dump_text(&output, &output_len, requested) < 0) {
client_printf(client, "dump: failed to read message log\n");
return TNT_EXIT_ERROR;
}
rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
free(output);
return rc;
}
static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN];
@ -451,10 +511,14 @@ int exec_dispatch(client_t *client) {
return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_DUMP:
return exec_command_dump(client, args);
case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT:
return TNT_EXIT_OK;
case TNT_EXEC_COMMAND_COUNT:
break;
}
}

View file

@ -38,6 +38,14 @@ static const exec_catalog_entry_t entries[] = {
"tail -n N", "tail [N] | tail -n N",
I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump [N]", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump -n N", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "post MESSAGE",
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
@ -147,6 +155,26 @@ void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
}
}
void exec_catalog_append_command_list(char *buffer, size_t buf_size,
size_t *pos) {
bool seen[TNT_EXEC_COMMAND_COUNT] = {0};
size_t count = 0;
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
tnt_exec_command_id_t id = entries[i].id;
if (id < 0 || id >= TNT_EXEC_COMMAND_COUNT || seen[id]) {
continue;
}
if (count > 0) {
buffer_appendf(buffer, buf_size, pos, ", ");
}
buffer_appendf(buffer, buf_size, pos, "%s", entries[i].name);
seen[id] = true;
count++;
}
}
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang) {
const exec_catalog_entry_t *entry = entry_for_id(id);

View file

@ -19,6 +19,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Backspace - Delete character\n"
" Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n"
" Up/Down - Recall sent messages\n"
" Tab - Complete @mention\n"
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
@ -26,6 +28,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" / - Search message history\n"
" j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
@ -49,6 +52,8 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n"
" Up/Down - 调出已发送消息\n"
" Tab - 补全 @mention\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" / - 搜索消息历史\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
@ -71,10 +77,14 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n"
"COMMAND OUTPUT KEYS:\n"
" q, ESC - Close output\n"
" j/k - Scroll down/up\n"
" j/k, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" r - Refresh live output (:inbox)\n"
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
@ -82,18 +92,25 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k - Scroll down/up\n"
" j/k, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" l - Cycle UI language\n",
"\n"
"命令输出按键:\n"
" q, ESC - 关闭输出\n"
" j/k - 向下/上滚动\n"
" j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n"
"\n"
"特殊消息:\n"
" /me <action> - 发送动作 (如 /me waves)\n"
@ -101,9 +118,12 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" l - 切换界面语言\n"
);

View file

@ -26,12 +26,12 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"TNT %s - SSH 匿名聊天室\r\n\r\n"
),
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
"Enter send · Esc browse · :help",
"Enter 发送 · Esc 浏览 · :help"
"Enter send · Esc NORMAL",
"Enter 发送 · Esc NORMAL"
),
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
"Enter · Esc · :help",
"Enter · Esc · :help"
"Enter · Esc",
"Enter · Esc"
),
[I18N_NORMAL_LATEST] = I18N_STRING(
"G latest",
@ -57,6 +57,10 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close",
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭"
),
[I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT] = I18N_STRING(
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom r:refresh q:close",
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 r:刷新 q:关闭"
),
[I18N_MOTD_TITLE] = I18N_STRING(
" NOTICE ",
" 公告 "
@ -138,8 +142,8 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"--- 最近 %d 条消息 ---\n"
),
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
"--- Search: \"%s\" (%d match(es)) ---\n",
"--- 搜索: \"%s\" (%d 条匹配) ---\n"
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
),
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
"Join/leave notifications: %s\n",

View file

@ -2,6 +2,7 @@
#include "chat_room.h"
#include "client.h"
#include "commands.h"
#include "config_defaults.h"
#include "common.h"
#include "exec.h"
#include "history_view.h"
@ -20,11 +21,11 @@
#include <string.h>
#include <time.h>
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
void input_init(void) {
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
g_default_ui_lang = i18n_default_ui_lang();
}
@ -32,10 +33,10 @@ static int read_username(client_t *client) {
char username[MAX_USERNAME_LEN] = {0};
int pos = 0;
char buf[4];
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
tui_render_welcome(client);
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_USERNAME_PROMPT));
client_printf(client, "%s", prompt);
while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -54,6 +55,18 @@ static int read_username(client_t *client) {
if (b == '\r' || b == '\n') {
break;
} else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */
return -1;
} else if (b == 21) { /* Ctrl+U: clear line */
username[0] = '\0';
pos = 0;
client_printf(client, "\r\033[K%s", prompt);
} else if (b == 23) { /* Ctrl+W: delete word */
if (username[0] != '\0') {
utf8_remove_last_word(username);
pos = (int)strlen(username);
client_printf(client, "\r\033[K%s%s", prompt, username);
}
} else if (b == 127 || b == 8) { /* Backspace */
if (pos > 0) {
/* Compute width of the last character before removing it */
@ -221,20 +234,134 @@ static void dismiss_command_output(client_t *client) {
was_motd = client->show_motd;
client->command_output[0] = '\0';
client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = false;
client->mode = MODE_NORMAL;
if (was_motd) {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
normal_scroll_to_latest(client);
} else {
client->mode = MODE_NORMAL;
}
tui_render_screen(client);
}
typedef enum {
PAGER_ACTION_NONE,
PAGER_ACTION_SCROLL,
PAGER_ACTION_CLOSE,
PAGER_ACTION_REFRESH
} pager_action_t;
static int pager_page_height(client_t *client) {
int page = client->height - 2;
if (page < 1) page = 1;
return page;
}
static void pager_scroll_by(int *scroll_pos, int delta) {
*scroll_pos += delta;
if (*scroll_pos < 0) {
*scroll_pos = 0;
}
}
static pager_action_t pager_apply_key(client_t *client, unsigned char key,
int *scroll_pos, bool allow_refresh) {
int page = pager_page_height(client);
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q') {
return PAGER_ACTION_CLOSE;
} else if (key == 'j') {
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (key == 'k') {
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (key == 4) { /* Ctrl+D: half page down */
pager_scroll_by(scroll_pos, half);
return PAGER_ACTION_SCROLL;
} else if (key == 21) { /* Ctrl+U: half page up */
pager_scroll_by(scroll_pos, -half);
return PAGER_ACTION_SCROLL;
} else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (key == 'g') {
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (key == 'G') {
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if ((key == 'r' || key == 'R') && allow_refresh) {
return PAGER_ACTION_REFRESH;
} else if (key == 27) {
char seq[3];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_CLOSE;
}
if (seq[0] != '[') {
return PAGER_ACTION_NONE;
}
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_NONE;
}
if (seq[1] == 'A') { /* Up arrow */
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'B') { /* Down arrow */
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'H') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'F') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if (seq[1] >= '1' && seq[1] <= '6') {
n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50);
if (n == 1 && seq[2] == '~') {
if (seq[1] == '5') { /* PageUp */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '6') { /* PageDown */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '1') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '4') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
}
}
}
}
return PAGER_ACTION_NONE;
}
/* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle Ctrl+C (Exit or switch to NORMAL) */
if (key == 3) {
client_mode_t previous_mode = client->mode;
if (client->show_help) {
client->show_help = false;
tui_render_screen(client);
return true;
}
if (client->command_output[0] != '\0') {
dismiss_command_output(client);
return true;
@ -256,44 +383,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle help screen */
if (client->show_help) {
/* Page size: roughly the visible help body region. */
int page = client->height - 2;
if (page < 1) page = 1;
int half = page / 2;
if (half < 1) half = 1;
pager_action_t action;
if (key == 'q' || key == 27) {
client->show_help = false;
tui_render_screen(client);
} else if (key == 'l' || key == 'L') {
if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'j') {
client->help_scroll_pos++;
tui_render_help(client);
} else if (key == 'k' && client->help_scroll_pos > 0) {
client->help_scroll_pos--;
tui_render_help(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->help_scroll_pos += half;
tui_render_help(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->help_scroll_pos -= half;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->help_scroll_pos += page;
tui_render_help(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->help_scroll_pos -= page;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'g') {
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'G') {
client->help_scroll_pos = 999; /* Large number */
return true;
}
action = pager_apply_key(client, key, &client->help_scroll_pos, false);
if (action == PAGER_ACTION_CLOSE) {
client->show_help = false;
tui_render_screen(client);
} else if (action == PAGER_ACTION_SCROLL) {
tui_render_help(client);
}
return true; /* Key consumed */
@ -302,53 +405,23 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle command output / MOTD display. MOTD remains a simple notice;
* command output behaves like a small pager so long results can be read. */
if (client->command_output[0] != '\0') {
int page = client->height - 2;
int half;
pager_action_t action;
if (client->show_motd) {
dismiss_command_output(client);
return true;
}
if (page < 1) page = 1;
half = page / 2;
if (half < 1) half = 1;
if (key == 'q' || key == 27) {
action = pager_apply_key(client, key, &client->command_output_scroll,
true);
if (action == PAGER_ACTION_CLOSE) {
dismiss_command_output(client);
} else if (key == 'j') {
client->command_output_scroll++;
} else if (action == PAGER_ACTION_SCROLL) {
tui_render_command_output(client);
} else if (action == PAGER_ACTION_REFRESH) {
if (commands_refresh_active_output(client)) {
tui_render_command_output(client);
} else if (key == 'k') {
client->command_output_scroll--;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->command_output_scroll += half;
tui_render_command_output(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->command_output_scroll -= half;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->command_output_scroll += page;
tui_render_command_output(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->command_output_scroll -= page;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 'g') {
client->command_output_scroll = 0;
tui_render_command_output(client);
} else if (key == 'G') {
client->command_output_scroll = 999;
tui_render_command_output(client);
}
return true; /* Key consumed */
}
@ -567,6 +640,12 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_input[0] = '\0';
tui_render_screen(client);
return true;
} else if (key == '/') {
client->mode = MODE_COMMAND;
snprintf(client->command_input, sizeof(client->command_input),
"search ");
tui_render_screen(client);
return true;
} else if (key == 'j') {
normal_scroll_by(client, 1);
tui_render_screen(client);
@ -735,6 +814,7 @@ void input_run_session(client_t *client) {
client->command_history_count = 0;
client->command_history_pos = 0;
client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->connect_time = time(NULL);
client->last_active = time(NULL);
@ -788,6 +868,7 @@ void input_run_session(client_t *client) {
sizeof(client->command_output),
"%s", motd_buf);
client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = true;
tui_render_motd(client);
seen_update_seq = room_get_update_seq(g_room);
@ -836,6 +917,13 @@ main_loop:
room_updated = true;
}
if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX &&
client->command_output[0] != '\0' &&
client->unread_whispers > 0) {
commands_refresh_active_output(client);
client->redraw_pending = true;
}
if (client->redraw_pending ||
(room_updated && !client->show_help &&
client->command_output[0] == '\0')) {
@ -940,6 +1028,8 @@ main_loop:
client->command_input[len] = b;
client->command_input[len + 1] = '\0';
tui_render_screen(client);
} else {
client_send(client, "\a", 1);
}
} else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b);
@ -952,10 +1042,12 @@ main_loop:
}
if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input);
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
if (len + (size_t)char_len <= sizeof(client->command_input) - 1) {
memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0';
tui_render_screen(client);
} else {
client_send(client, "\a", 1);
}
}
}
@ -982,17 +1074,7 @@ cleanup:
ratelimit_release_ip(client->client_ip);
/* Remove channel callbacks before releasing refs to prevent use-after-free
* if a callback fires between the two releases. */
if (client->channel && client->channel_cb) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
/* Release the callback reference (paired with addref before client_install_channel_callbacks) */
client_release(client);
/* Release the main reference - client will be freed when all refs are gone */
client_release(client);
client_release_session(client);
/* Decrement connection count */
ratelimit_decrement_total();

View file

@ -1,8 +1,10 @@
#include "chat_room.h"
#include "cli_text.h"
#include "config_defaults.h"
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "message_log_tool.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -18,24 +20,6 @@ static void signal_handler(int sig) {
_exit(0);
}
static bool parse_int_arg(const char *value, int min_val, int max_val,
int *out) {
char *end = NULL;
long val;
if (!value || value[0] == '\0' || !out) {
return false;
}
val = strtol(value, &end, 10);
if (!end || *end != '\0' || val < min_val || val > max_val) {
return false;
}
*out = (int)val;
return true;
}
static bool is_config_token(const char *value) {
const unsigned char *p = (const unsigned char *)value;
@ -59,59 +43,64 @@ static int set_env_option(const char *name, const char *value) {
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) {
static int set_numeric_env_option(const tnt_int_config_spec_t *spec,
const char *opt_name, const char *value,
ui_lang_t lang) {
int parsed;
if (!parse_int_arg(value, min_val, max_val, &parsed)) {
if (!tnt_config_parse_int(value, spec, &parsed)) {
fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value);
return TNT_EXIT_USAGE;
}
if (set_env_option(env_name, value) != 0) {
if (set_env_option(spec->env_name, value) != 0) {
return TNT_EXIT_ERROR;
}
return TNT_EXIT_OK;
}
int main(int argc, char **argv) {
int port = DEFAULT_PORT;
ui_lang_t lang = i18n_default_ui_lang();
static bool require_option_arg(int argc, char **argv, int index,
ui_lang_t lang) {
if (index + 1 >= argc || argv[index + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[index]);
return false;
}
return true;
}
/* Environment provides defaults; command-line flags override it. */
const char *port_env = getenv("PORT");
if (port_env && port_env[0] != '\0') {
char *end;
long val = strtol(port_env, &end, 10);
if (*end == '\0' && val > 0 && val <= 65535) {
port = (int)val;
}
}
int main(int argc, char **argv) {
int port = tnt_config_env_int(&TNT_CONFIG_PORT);
ui_lang_t lang = i18n_default_ui_lang();
const char *log_check_path = NULL;
const char *log_recover_path = NULL;
/* Parse command line arguments */
for (int i = 1; i < argc; i++) {
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) &&
i + 1 < argc) {
if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) {
int val;
if (!parse_int_arg(argv[i + 1], 1, 65535, &val)) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!tnt_config_parse_int(argv[i + 1], &TNT_CONFIG_PORT, &val)) {
fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]);
return TNT_EXIT_USAGE;
}
port = val;
i++;
} else if ((strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
if (argv[i + 1][0] == '\0') {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
} else if (strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) {
return TNT_EXIT_ERROR;
}
i++;
} else if (strcmp(argv[i], "--bind") == 0 && i + 1 < argc) {
} else if (strcmp(argv[i], "--bind") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
@ -121,7 +110,10 @@ int main(int argc, char **argv) {
return TNT_EXIT_ERROR;
}
i++;
} else if (strcmp(argv[i], "--public-host") == 0 && i + 1 < argc) {
} else if (strcmp(argv[i], "--public-host") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
@ -131,54 +123,80 @@ int main(int argc, char **argv) {
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);
} else if (strcmp(argv[i], "--max-connections") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONNECTIONS,
argv[i], argv[i + 1], 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);
} else if (strcmp(argv[i], "--max-conn-per-ip") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_PER_IP,
argv[i], argv[i + 1], 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);
} else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_RATE_PER_IP,
argv[i], argv[i + 1], 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);
} else if (strcmp(argv[i], "--rate-limit") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_RATE_LIMIT, argv[i],
argv[i + 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);
} else if (strcmp(argv[i], "--idle-timeout") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_IDLE_TIMEOUT, argv[i],
argv[i + 1], 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);
} else if (strcmp(argv[i], "--ssh-log-level") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_SSH_LOG_LEVEL,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--log-check") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[i]);
return TNT_EXIT_USAGE;
}
log_check_path = argv[++i];
} else if (strcmp(argv[i], "--log-recover") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[i]);
return TNT_EXIT_USAGE;
}
log_recover_path = argv[++i];
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
printf("tnt %s\n", TNT_VERSION);
return TNT_EXIT_OK;
@ -196,6 +214,18 @@ int main(int argc, char **argv) {
}
}
if (log_check_path && log_recover_path) {
fprintf(stderr, cli_text_invalid_value_format(lang),
"--log-check", "--log-recover");
return TNT_EXIT_USAGE;
}
if (log_check_path) {
return message_log_tool_check(log_check_path);
}
if (log_recover_path) {
return message_log_tool_recover(log_recover_path);
}
/* Setup signal handlers */
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);

View file

@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
" TNT - SSH terminal chat room\n"
"\n"
"\033[1;37mUse\033[0m\n"
" Type a message and press Enter; Esc browses; G latest; i types\n"
" : runs commands; ? opens the full key reference\n"
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
" Esc browses; / searches; G latest; i types; : commands; ? keys\n"
"\n"
"\033[1;37mCommands\033[0m\n",
"\033[1;36mTNT(1) 帮助\033[0m\n"
@ -22,8 +22,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
" TNT - SSH 终端聊天室\n"
"\n"
"\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n"
" : 运行命令;? 打开完整按键参考\n"
" 输入并 Enter 发送Up/Down 调出消息Tab 补全 @mention\n"
" Esc 浏览;/ 搜索G 最新i 输入;: 命令;? 按键\n"
"\n"
"\033[1;37m命令\033[0m\n"
);

View file

@ -1,29 +1,63 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for timegm() on glibc */
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
#endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
#endif
#include "message.h"
#include "message_log.h"
#include "utf8.h"
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
static time_t parse_rfc3339_utc(const char *timestamp_str) {
struct tm tm = {0};
static void discard_line_remainder(FILE *fp) {
int c;
if (!timestamp_str) {
return (time_t)-1;
while ((c = fgetc(fp)) != '\n' && c != EOF) {
}
}
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm);
if (!result || *result != '\0') {
return (time_t)-1;
static int append_dump_record(char **output, size_t *capacity,
size_t *len, const message_t *msg) {
size_t needed;
size_t available;
if (!output || !capacity || !len || !msg) {
return -1;
}
return timegm(&tm);
if (message_log_format_record(msg, NULL, 0, &needed) < 0) {
return -1;
}
available = *capacity > *len ? *capacity - *len : 0;
if (needed + 1 > available) {
size_t new_capacity = *capacity ? *capacity : 1024;
while (needed + 1 > new_capacity - *len) {
if (new_capacity > SIZE_MAX / 2) {
return -1;
}
new_capacity *= 2;
}
char *grown = realloc(*output, new_capacity);
if (!grown) {
return -1;
}
*output = grown;
*capacity = new_capacity;
}
if (message_log_format_record(msg, *output + *len, *capacity - *len,
NULL) < 0) {
return -1;
}
*len += needed;
return 0;
}
/* Initialize message subsystem */
@ -118,67 +152,25 @@ int message_load(message_t **messages, int max_messages) {
fseek(fp, 0, SEEK_SET);
read_messages:;
char line[2048];
char line[MESSAGE_LOG_MAX_LINE];
int count = 0;
time_t now = time(NULL);
/* Now read forward */
while (fgets(line, sizeof(line), fp) && count < max_messages) {
/* Check for oversized lines */
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) {
/* Skip remainder of line */
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
discard_line_remainder(fp);
continue;
}
/* Format: RFC3339_timestamp|username|content */
char line_copy[2048];
strncpy(line_copy, line, sizeof(line_copy) - 1);
line_copy[sizeof(line_copy) - 1] = '\0';
char *timestamp_str = strtok(line_copy, "|");
char *username = strtok(NULL, "|");
char *content = strtok(NULL, "\n");
/* Validate all fields exist and are non-empty */
if (!timestamp_str || !username || !content) {
continue;
}
if (username[0] == '\0') {
message_t parsed;
if (!message_log_parse_record(line, &parsed, now)) {
continue;
}
/* Validate field lengths */
if (strlen(username) >= MAX_USERNAME_LEN) {
continue;
}
if (strlen(content) >= MAX_MESSAGE_LEN) {
continue;
}
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
continue;
}
/* Parse strict UTC RFC3339 timestamp */
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) {
continue;
}
/* Validate timestamp is reasonable (not in far future or past) */
time_t now = time(NULL);
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
continue;
}
msg_array[count].timestamp = msg_time;
strncpy(msg_array[count].username, username, MAX_USERNAME_LEN - 1);
msg_array[count].username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(msg_array[count].content, content, MAX_MESSAGE_LEN - 1);
msg_array[count].content[MAX_MESSAGE_LEN - 1] = '\0';
count++;
msg_array[count++] = parsed;
}
fclose(fp);
@ -190,6 +182,9 @@ read_messages:;
/* Save a message to log file */
int message_save(const message_t *msg) {
char log_path[PATH_MAX];
message_t safe_msg;
char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48];
size_t record_len = 0;
int rc = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
@ -204,36 +199,29 @@ int message_save(const message_t *msg) {
return -1;
}
/* Format timestamp as RFC3339 */
char timestamp[64];
struct tm tm_info;
gmtime_r(&msg->timestamp, &tm_info);
strftime(timestamp, sizeof(timestamp), "%Y-%m-%dT%H:%M:%SZ", &tm_info);
/* Sanitize username and content to prevent log injection */
char safe_username[MAX_USERNAME_LEN];
char safe_content[MAX_MESSAGE_LEN];
safe_msg.timestamp = msg->timestamp;
strncpy(safe_msg.username, msg->username, sizeof(safe_msg.username) - 1);
safe_msg.username[sizeof(safe_msg.username) - 1] = '\0';
strncpy(safe_username, msg->username, sizeof(safe_username) - 1);
safe_username[sizeof(safe_username) - 1] = '\0';
strncpy(safe_content, msg->content, sizeof(safe_content) - 1);
safe_content[sizeof(safe_content) - 1] = '\0';
strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1);
safe_msg.content[sizeof(safe_msg.content) - 1] = '\0';
/* Replace pipe characters and newlines to prevent log format corruption */
for (char *p = safe_username; *p; p++) {
for (char *p = safe_msg.username; *p; p++) {
if (*p == '|' || *p == '\n' || *p == '\r') {
*p = '_';
}
}
for (char *p = safe_content; *p; p++) {
for (char *p = safe_msg.content; *p; p++) {
if (*p == '|' || *p == '\n' || *p == '\r') {
*p = ' ';
}
}
/* Write to file: timestamp|username|content */
if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 ||
if (message_log_format_record(&safe_msg, record, sizeof(record),
&record_len) < 0 ||
fwrite(record, 1, record_len, fp) != record_len ||
fflush(fp) != 0) {
rc = -1;
}
@ -274,40 +262,21 @@ int message_search(const char *query, message_t **results, int max_results) {
return 0;
}
char line[2048];
char line[MESSAGE_LOG_MAX_LINE];
int count = 0;
time_t now = time(NULL);
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) {
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
discard_line_remainder(fp);
continue;
}
char line_copy[2048];
strncpy(line_copy, line, sizeof(line_copy) - 1);
line_copy[sizeof(line_copy) - 1] = '\0';
char *timestamp_str = strtok(line_copy, "|");
char *username = strtok(NULL, "|");
char *content = strtok(NULL, "\n");
if (!timestamp_str || !username || !content || username[0] == '\0') continue;
if (strlen(username) >= MAX_USERNAME_LEN || strlen(content) >= MAX_MESSAGE_LEN) continue;
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) continue;
if (strcasestr(username, query) == NULL && strcasestr(content, query) == NULL) continue;
time_t msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) continue;
message_t m;
m.timestamp = msg_time;
strncpy(m.username, username, MAX_USERNAME_LEN - 1);
m.username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
m.content[MAX_MESSAGE_LEN - 1] = '\0';
if (!message_log_parse_record(line, &m, now)) continue;
if (strcasestr(m.username, query) == NULL &&
strcasestr(m.content, query) == NULL) continue;
if (count < max_results) {
res[count++] = m;
@ -324,6 +293,118 @@ int message_search(const char *query, message_t **results, int max_results) {
return (count < max_results) ? count : max_results;
}
int message_dump_text(char **output, size_t *output_len, int max_records) {
char log_path[PATH_MAX];
char *buf = NULL;
size_t capacity = 0;
size_t len = 0;
message_t *ring = NULL;
int seen = 0;
int rc = 0;
if (!output || !output_len || max_records < 0) {
return -1;
}
*output = calloc(1, 1);
if (!*output) {
return -1;
}
*output_len = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 0) {
free(*output);
*output = NULL;
return -1;
}
if (max_records > 0) {
ring = calloc((size_t)max_records, sizeof(*ring));
if (!ring) {
free(*output);
*output = NULL;
return -1;
}
}
pthread_mutex_lock(&g_message_file_lock);
FILE *fp = fopen(log_path, "r");
if (!fp) {
int saved_errno = errno;
pthread_mutex_unlock(&g_message_file_lock);
free(ring);
if (saved_errno != ENOENT) {
free(*output);
*output = NULL;
return -1;
}
return 0;
}
char line[MESSAGE_LOG_MAX_LINE];
time_t now = time(NULL);
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
discard_line_remainder(fp);
continue;
}
message_t parsed;
if (!message_log_parse_record(line, &parsed, now)) {
continue;
}
if (max_records > 0) {
ring[seen % max_records] = parsed;
seen++;
} else if (append_dump_record(output, &capacity, output_len,
&parsed) < 0) {
rc = -1;
break;
}
}
fclose(fp);
pthread_mutex_unlock(&g_message_file_lock);
if (rc == 0 && max_records > 0 && seen > 0) {
int count = seen < max_records ? seen : max_records;
int start = seen < max_records ? 0 : seen % max_records;
free(*output);
*output = NULL;
*output_len = 0;
for (int i = 0; i < count; i++) {
message_t *msg = &ring[(start + i) % max_records];
if (append_dump_record(&buf, &capacity, &len, msg) < 0) {
rc = -1;
break;
}
}
if (rc == 0) {
*output = buf ? buf : calloc(1, 1);
*output_len = len;
if (!*output) {
rc = -1;
}
} else {
free(buf);
}
}
free(ring);
if (rc < 0) {
free(*output);
*output = NULL;
*output_len = 0;
return -1;
}
return 0;
}
/* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info;

129
src/message_log.c Normal file
View file

@ -0,0 +1,129 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for timegm() on glibc */
#endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
#endif
#include "message_log.h"
#include "utf8.h"
static time_t parse_rfc3339_utc(const char *timestamp_str) {
struct tm tm = {0};
if (!timestamp_str) {
return (time_t)-1;
}
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm);
if (!result || *result != '\0') {
return (time_t)-1;
}
return timegm(&tm);
}
void message_log_format_timestamp_utc(time_t ts, char *buffer,
size_t buf_size) {
struct tm tm_info;
if (!buffer || buf_size == 0) {
return;
}
gmtime_r(&ts, &tm_info);
strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info);
}
bool message_log_parse_record(const char *line, message_t *out, time_t now) {
char line_copy[MESSAGE_LOG_MAX_LINE];
char *first_sep;
char *second_sep;
char *timestamp_str;
char *username;
char *content;
time_t msg_time;
size_t line_len;
if (!line || !out) {
return false;
}
line_len = strlen(line);
if (line_len == 0 || line[line_len - 1] != '\n') {
return false;
}
if (line_len >= sizeof(line_copy)) {
return false;
}
memcpy(line_copy, line, line_len + 1);
line_copy[line_len - 1] = '\0';
first_sep = strchr(line_copy, '|');
if (!first_sep) {
return false;
}
second_sep = strchr(first_sep + 1, '|');
if (!second_sep || strchr(second_sep + 1, '|')) {
return false;
}
*first_sep = '\0';
*second_sep = '\0';
timestamp_str = line_copy;
username = first_sep + 1;
content = second_sep + 1;
if (timestamp_str[0] == '\0' || username[0] == '\0' ||
content[0] == '\0') {
return false;
}
if (strlen(username) >= MAX_USERNAME_LEN ||
strlen(content) >= MAX_MESSAGE_LEN) {
return false;
}
if (!utf8_is_valid_string(username) || !utf8_is_valid_string(content)) {
return false;
}
msg_time = parse_rfc3339_utc(timestamp_str);
if (msg_time == (time_t)-1) {
return false;
}
if (msg_time > now + 86400 || msg_time < now - 31536000 * 10) {
return false;
}
out->timestamp = msg_time;
strncpy(out->username, username, MAX_USERNAME_LEN - 1);
out->username[MAX_USERNAME_LEN - 1] = '\0';
strncpy(out->content, content, MAX_MESSAGE_LEN - 1);
out->content[MAX_MESSAGE_LEN - 1] = '\0';
return true;
}
int message_log_format_record(const message_t *msg, char *buffer,
size_t buf_size, size_t *record_len) {
char timestamp[64];
int needed;
if (!msg) {
return -1;
}
message_log_format_timestamp_utc(msg->timestamp, timestamp,
sizeof(timestamp));
needed = snprintf(buffer, buf_size, "%s|%s|%s\n", timestamp,
msg->username, msg->content);
if (needed < 0) {
return -1;
}
if (record_len) {
*record_len = (size_t)needed;
}
if (!buffer || buf_size == 0) {
return 0;
}
return (size_t)needed < buf_size ? 0 : -1;
}

111
src/message_log_tool.c Normal file
View file

@ -0,0 +1,111 @@
#include "message_log_tool.h"
#include "message_log.h"
#include <errno.h>
typedef struct {
long records_seen;
long valid_records;
long invalid_records;
long first_invalid_line;
} message_log_report_t;
static void discard_line_remainder(FILE *fp) {
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF) {
}
}
static int print_recovered_record(const message_t *msg) {
char record[MAX_USERNAME_LEN + MAX_MESSAGE_LEN + 48];
size_t record_len = 0;
if (message_log_format_record(msg, record, sizeof(record),
&record_len) < 0) {
return -1;
}
return fwrite(record, 1, record_len, stdout) == record_len ? 0 : -1;
}
static void print_report(FILE *stream, const char *path,
const message_log_report_t *report) {
fprintf(stream,
"path %s\n"
"records_seen %ld\n"
"valid_records %ld\n"
"invalid_records %ld\n"
"first_invalid_line %ld\n",
path,
report->records_seen,
report->valid_records,
report->invalid_records,
report->first_invalid_line);
}
static int scan_log(const char *path, bool recover) {
FILE *fp;
char line[MESSAGE_LOG_MAX_LINE];
long line_no = 0;
time_t now = time(NULL);
message_log_report_t report = {0};
if (!path || path[0] == '\0') {
fprintf(stderr, "log: invalid path\n");
return TNT_EXIT_USAGE;
}
fp = fopen(path, "r");
if (!fp) {
fprintf(stderr, "log: %s: %s\n", path, strerror(errno));
return TNT_EXIT_ERROR;
}
while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line);
message_t parsed;
bool valid = false;
line_no++;
report.records_seen++;
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
discard_line_remainder(fp);
} else {
valid = message_log_parse_record(line, &parsed, now);
}
if (valid) {
report.valid_records++;
if (recover && print_recovered_record(&parsed) < 0) {
fclose(fp);
fprintf(stderr, "log: failed to write recovered output\n");
return TNT_EXIT_ERROR;
}
} else {
report.invalid_records++;
if (report.first_invalid_line == 0) {
report.first_invalid_line = line_no;
}
}
}
if (ferror(fp)) {
fclose(fp);
fprintf(stderr, "log: failed to read %s\n", path);
return TNT_EXIT_ERROR;
}
fclose(fp);
print_report(recover ? stderr : stdout, path, &report);
return report.invalid_records == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
}
int message_log_tool_check(const char *path) {
return scan_log(path, false);
}
int message_log_tool_recover(const char *path) {
return scan_log(path, true);
}

View file

@ -1,4 +1,5 @@
#include "ratelimit.h"
#include "config_defaults.h"
#include "common.h"
#include <arpa/inet.h>
#include <pthread.h>
@ -27,16 +28,20 @@ static pthread_mutex_t g_rate_limit_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_total_connections = 0;
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_max_connections = 64;
static int g_max_conn_per_ip = 5;
static int g_max_conn_rate_per_ip = 10;
static int g_rate_limit_enabled = 1;
static int g_max_connections = TNT_DEFAULT_MAX_CONNECTIONS;
static int g_max_conn_per_ip = TNT_DEFAULT_MAX_CONN_PER_IP;
static int g_max_conn_rate_per_ip = TNT_DEFAULT_MAX_CONN_RATE_PER_IP;
static int g_rate_limit_enabled = TNT_DEFAULT_RATE_LIMIT_ENABLED;
void ratelimit_init(void) {
g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024);
g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024);
g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024);
g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1);
g_max_connections =
tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
g_max_conn_per_ip =
tnt_config_env_int(&TNT_CONFIG_MAX_CONN_PER_IP);
g_max_conn_rate_per_ip =
tnt_config_env_int(&TNT_CONFIG_MAX_CONN_RATE_PER_IP);
g_rate_limit_enabled =
tnt_config_env_int(&TNT_CONFIG_RATE_LIMIT);
}
/* Caller MUST hold g_rate_limit_lock. */

View file

@ -1,6 +1,7 @@
#include "ssh_server.h"
#include "bootstrap.h"
#include "commands.h"
#include "config_defaults.h"
#include "exec.h"
#include "input.h"
#include "ratelimit.h"
@ -23,7 +24,7 @@
/* Global SSH bind instance */
static ssh_bind g_sshbind = NULL;
static int g_listen_port = DEFAULT_PORT;
static int g_listen_port = TNT_DEFAULT_PORT;
static time_t g_server_start_time = 0;

View file

@ -1,4 +1,8 @@
#include "common.h"
#include "config_defaults.h"
#include "exec_catalog.h"
#include "i18n.h"
#include "tntctl_text.h"
#include <ctype.h>
#include <errno.h>
@ -6,21 +10,24 @@
#include <sys/wait.h>
#include <unistd.h>
static void print_usage(FILE *stream) {
fprintf(stream,
"Usage: tntctl [options] host command [args...]\n"
"\n"
"Options:\n"
" -p, --port PORT SSH port (default: 2222)\n"
" -l, --login USER SSH login name for exec identity\n"
" --host-key-checking MODE\n"
" OpenSSH host-key mode: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts file\n"
" -V, --version Print version and exit\n"
" -h, --help Print this help and exit\n"
"\n"
"Commands mirror the TNT SSH exec interface: health, stats, users,\n"
"tail, post, help, and exit.\n");
static void print_usage(FILE *stream, ui_lang_t lang) {
char output[2048];
size_t pos = 0;
output[0] = '\0';
tntctl_text_append_usage(output, sizeof(output), &pos, lang);
fputs(output, stream);
}
static void print_error(ui_lang_t lang, tntctl_text_id_t id) {
fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id));
}
static void print_error_format(ui_lang_t lang, tntctl_text_id_t id,
const char *value) {
fprintf(stderr, "tntctl: ");
fprintf(stderr, tntctl_text(lang, id), value);
fputc('\n', stderr);
}
static bool is_valid_port(const char *value) {
@ -73,14 +80,7 @@ static bool is_host_key_checking_mode(const char *value) {
}
static bool is_known_exec_command(const char *command) {
return command &&
(strcmp(command, "health") == 0 ||
strcmp(command, "stats") == 0 ||
strcmp(command, "users") == 0 ||
strcmp(command, "tail") == 0 ||
strcmp(command, "post") == 0 ||
strcmp(command, "help") == 0 ||
strcmp(command, "exit") == 0);
return exec_catalog_match(command, NULL, NULL);
}
static int build_remote_command(char *buffer, size_t buf_size, int argc,
@ -146,7 +146,7 @@ static int run_ssh(char **ssh_argv) {
}
int main(int argc, char **argv) {
const char *port = "2222";
const char *port = TNT_DEFAULT_PORT_TEXT;
const char *login = NULL;
const char *host_key_checking = NULL;
const char *known_hosts = NULL;
@ -159,6 +159,7 @@ int main(int argc, char **argv) {
char **ssh_argv = NULL;
int ssh_argc = 0;
int rc;
ui_lang_t lang = i18n_default_ui_lang();
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) {
@ -166,7 +167,7 @@ int main(int argc, char **argv) {
break;
} else if (strcmp(argv[i], "-h") == 0 ||
strcmp(argv[i], "--help") == 0) {
print_usage(stdout);
print_usage(stdout, lang);
return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-V") == 0 ||
strcmp(argv[i], "--version") == 0) {
@ -175,7 +176,7 @@ int main(int argc, char **argv) {
} else if (strcmp(argv[i], "-p") == 0 ||
strcmp(argv[i], "--port") == 0) {
if (i + 1 >= argc || !is_valid_port(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid port\n");
print_error(lang, TNTCTL_TEXT_INVALID_PORT);
return TNT_EXIT_USAGE;
}
port = argv[++i];
@ -183,26 +184,27 @@ int main(int argc, char **argv) {
strcmp(argv[i], "--login") == 0) {
if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) ||
strchr(argv[i + 1], '@')) {
fprintf(stderr, "tntctl: invalid login\n");
print_error(lang, TNTCTL_TEXT_INVALID_LOGIN);
return TNT_EXIT_USAGE;
}
login = argv[++i];
} else if (strcmp(argv[i], "--host-key-checking") == 0) {
if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid host-key checking mode\n");
print_error(lang, TNTCTL_TEXT_INVALID_HOST_KEY_MODE);
return TNT_EXIT_USAGE;
}
host_key_checking = argv[++i];
} else if (strcmp(argv[i], "--known-hosts") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0' ||
has_newline(argv[i + 1])) {
fprintf(stderr, "tntctl: invalid known_hosts path\n");
print_error(lang, TNTCTL_TEXT_INVALID_KNOWN_HOSTS);
return TNT_EXIT_USAGE;
}
known_hosts = argv[++i];
} else if (argv[i][0] == '-') {
fprintf(stderr, "tntctl: unknown option: %s\n", argv[i]);
print_usage(stderr);
print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
argv[i]);
print_usage(stderr, lang);
return TNT_EXIT_USAGE;
} else {
break;
@ -210,29 +212,29 @@ int main(int argc, char **argv) {
}
if (i >= argc) {
fprintf(stderr, "tntctl: missing host\n");
print_usage(stderr);
print_error(lang, TNTCTL_TEXT_MISSING_HOST);
print_usage(stderr, lang);
return TNT_EXIT_USAGE;
}
host = argv[i++];
if (is_safe_ssh_token(host)) {
fprintf(stderr, "tntctl: invalid host\n");
print_error(lang, TNTCTL_TEXT_INVALID_HOST);
return TNT_EXIT_USAGE;
}
if (login && strchr(host, '@')) {
fprintf(stderr, "tntctl: use either --login or user@host, not both\n");
print_error(lang, TNTCTL_TEXT_LOGIN_HOST_CONFLICT);
return TNT_EXIT_USAGE;
}
if (i >= argc || !is_known_exec_command(argv[i])) {
fprintf(stderr, "tntctl: unknown or missing command\n");
print_error(lang, TNTCTL_TEXT_UNKNOWN_COMMAND);
return TNT_EXIT_USAGE;
}
if (build_remote_command(remote_command, sizeof(remote_command), argc,
argv, i) < 0) {
fprintf(stderr, "tntctl: invalid or too-long command\n");
print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND);
return TNT_EXIT_USAGE;
}
@ -240,24 +242,24 @@ int main(int argc, char **argv) {
int n = snprintf(destination, sizeof(destination), "%s@%s", login,
host);
if (n < 0 || n >= (int)sizeof(destination)) {
fprintf(stderr, "tntctl: destination too long\n");
print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE;
}
} else {
int n = snprintf(destination, sizeof(destination), "%s", host);
if (n < 0 || n >= (int)sizeof(destination)) {
fprintf(stderr, "tntctl: destination too long\n");
print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE;
}
}
if (destination[0] == '-') {
fprintf(stderr, "tntctl: invalid destination\n");
print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION);
return TNT_EXIT_USAGE;
}
ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv));
if (!ssh_argv) {
fprintf(stderr, "tntctl: out of memory\n");
print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY);
return TNT_EXIT_ERROR;
}
@ -268,7 +270,7 @@ int main(int argc, char **argv) {
int n = snprintf(host_key_option, sizeof(host_key_option),
"StrictHostKeyChecking=%s", host_key_checking);
if (n < 0 || n >= (int)sizeof(host_key_option)) {
fprintf(stderr, "tntctl: host-key option too long\n");
print_error(lang, TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG);
free(ssh_argv);
return TNT_EXIT_USAGE;
}
@ -279,7 +281,7 @@ int main(int argc, char **argv) {
int n = snprintf(known_hosts_option, sizeof(known_hosts_option),
"UserKnownHostsFile=%s", known_hosts);
if (n < 0 || n >= (int)sizeof(known_hosts_option)) {
fprintf(stderr, "tntctl: known_hosts option too long\n");
print_error(lang, TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG);
free(ssh_argv);
return TNT_EXIT_USAGE;
}

101
src/tntctl_text.c Normal file
View file

@ -0,0 +1,101 @@
#include "tntctl_text.h"
#include "config_defaults.h"
#include "exec_catalog.h"
#include "i18n.h"
static const i18n_string_t text_catalog[TNTCTL_TEXT_COUNT] = {
[TNTCTL_TEXT_INVALID_PORT] = I18N_STRING(
"invalid port", "端口无效"
),
[TNTCTL_TEXT_INVALID_LOGIN] = I18N_STRING(
"invalid login", "登录名无效"
),
[TNTCTL_TEXT_INVALID_HOST_KEY_MODE] = I18N_STRING(
"invalid host-key checking mode", "主机密钥检查模式无效"
),
[TNTCTL_TEXT_INVALID_KNOWN_HOSTS] = I18N_STRING(
"invalid known_hosts path", "known_hosts 路径无效"
),
[TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT] = I18N_STRING(
"unknown option: %s", "未知选项: %s"
),
[TNTCTL_TEXT_MISSING_HOST] = I18N_STRING(
"missing host", "缺少 host"
),
[TNTCTL_TEXT_INVALID_HOST] = I18N_STRING(
"invalid host", "host 无效"
),
[TNTCTL_TEXT_LOGIN_HOST_CONFLICT] = I18N_STRING(
"use either --login or user@host, not both",
"只能使用 --login 或 user@host 之一"
),
[TNTCTL_TEXT_UNKNOWN_COMMAND] = I18N_STRING(
"unknown or missing command", "未知命令或缺少命令"
),
[TNTCTL_TEXT_INVALID_REMOTE_COMMAND] = I18N_STRING(
"invalid or too-long command", "命令无效或过长"
),
[TNTCTL_TEXT_DESTINATION_TOO_LONG] = I18N_STRING(
"destination too long", "目标地址过长"
),
[TNTCTL_TEXT_INVALID_DESTINATION] = I18N_STRING(
"invalid destination", "目标地址无效"
),
[TNTCTL_TEXT_OUT_OF_MEMORY] = I18N_STRING(
"out of memory", "内存不足"
),
[TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG] = I18N_STRING(
"host-key option too long", "主机密钥选项过长"
),
[TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG] = I18N_STRING(
"known_hosts option too long", "known_hosts 选项过长"
)
};
typedef char text_catalog_must_cover_enum[
sizeof(text_catalog) / sizeof(text_catalog[0]) == TNTCTL_TEXT_COUNT ? 1 : -1
];
void tntctl_text_append_usage(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
static const i18n_string_t before_commands = I18N_STRING(
"Usage: tntctl [options] host command [args...]\n"
"\n"
"Options:\n"
" -p, --port PORT SSH port (default: " TNT_DEFAULT_PORT_TEXT ")\n"
" -l, --login USER SSH login name for exec identity\n"
" --host-key-checking MODE\n"
" OpenSSH host-key mode: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts file\n"
" -V, --version Print version and exit\n"
" -h, --help Print this help and exit\n"
"\n"
"Commands:\n"
" ",
"用法: tntctl [options] host command [args...]\n"
"\n"
"选项:\n"
" -p, --port PORT SSH 端口 (默认: " TNT_DEFAULT_PORT_TEXT ")\n"
" -l, --login USER SSH 登录名,用作 exec 身份\n"
" --host-key-checking MODE\n"
" OpenSSH 主机密钥模式: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts 文件\n"
" -V, --version 输出版本并退出\n"
" -h, --help 输出此帮助并退出\n"
"\n"
"命令:\n"
" "
);
buffer_appendf(buffer, buf_size, pos, "%s",
i18n_string(before_commands, lang));
exec_catalog_append_command_list(buffer, buf_size, pos);
buffer_appendf(buffer, buf_size, pos, "\n");
}
const char *tntctl_text(ui_lang_t lang, tntctl_text_id_t id) {
if (id < 0 || id >= TNTCTL_TEXT_COUNT) {
return "";
}
return i18n_string(text_catalog[id], lang);
}

View file

@ -373,7 +373,9 @@ void tui_render_screen(client_t *client) {
chips[chip_count].value_color = mode_color;
chip_count++;
const char *hint = i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT);
const char *hint = client->mode == MODE_NORMAL
? i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT)
: "";
int hint_width = utf8_string_width(hint);
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
@ -401,7 +403,7 @@ void tui_render_screen(client_t *client) {
/* Decide what fits. Reserve at least 1 col of gap between left and
* right halves so they never visually touch. */
int show_hint = 1;
int show_hint = hint[0] != '\0';
int show_mute = client->mute_joins ? 1 : 0;
int show_unread = unread_count > 0 ? 1 : 0;
int show_whisper = whisper_count > 0 ? 1 : 0;
@ -677,7 +679,10 @@ void tui_render_command_output(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->ui_lang,
I18N_COMMAND_OUTPUT_STATUS_FORMAT),
client->command_output_kind ==
TNT_COMMAND_OUTPUT_INBOX
? I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT
: I18N_COMMAND_OUTPUT_STATUS_FORMAT),
start + 1, max_scroll + 1);
client_send(client, buffer, pos);

View file

@ -1,6 +1,54 @@
#include "tui_status.h"
#include "i18n.h"
#include "ssh_server.h"
#include "utf8.h"
static void format_command_input_tail(const char *input, int avail_width,
char *display, size_t display_size) {
if (!input || !display || display_size == 0) return;
display[0] = '\0';
if (avail_width < 1) {
return;
}
if (utf8_string_width(input) <= avail_width) {
strncpy(display, input, display_size - 1);
display[display_size - 1] = '\0';
return;
}
const char *marker = "<";
int marker_width = 1;
int tail_width = avail_width - marker_width;
if (tail_width < 1) {
snprintf(display, display_size, "%s", marker);
return;
}
const char *p = input + strlen(input);
const char *tail = p;
int width = 0;
while (p > input && width < tail_width) {
const char *q = p - 1;
while (q > input && ((*q & 0xC0) == 0x80)) {
q--;
}
int bytes_read = 0;
uint32_t cp = utf8_decode(q, &bytes_read);
int char_width = utf8_char_width(cp);
if (width + char_width > tail_width) {
break;
}
width += char_width;
tail = q;
p = q;
}
snprintf(display, display_size, "%s%s", marker, tail);
}
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count,
@ -48,7 +96,12 @@ void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
}
} else if (client->mode == MODE_COMMAND) {
char display[sizeof(client->command_input) + 2];
int avail = client->width - 1;
if (avail < 1) avail = 1;
format_command_input_tail(client->command_input, avail, display,
sizeof(display));
buffer_appendf(buffer, buf_size, pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
"\033[35m:\033[0m%s\033[K", display);
}
}

82
tests/test_cli_options.sh Executable file
View file

@ -0,0 +1,82 @@
#!/bin/sh
# CLI option parsing regression tests.
BIN="../tnt"
PASS=0
FAIL=0
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
if [ -n "$2" ]; then
printf '%s\n' "$2"
fi
FAIL=$((FAIL + 1))
}
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
expect_missing_arg() {
opt="$1"
output=$("$BIN" "$opt" 2>&1)
status=$?
if [ "$status" -eq 64 ] &&
printf '%s\n' "$output" | grep -q "Option requires argument: $opt"; then
pass "$opt reports missing argument"
else
fail "$opt missing argument diagnostic unexpected" "$output"
fi
}
echo "=== TNT CLI Option Tests ==="
for opt in \
-p \
--port \
-d \
--state-dir \
--bind \
--public-host \
--max-connections \
--max-conn-per-ip \
--max-conn-rate-per-ip \
--rate-limit \
--idle-timeout \
--ssh-log-level \
--log-check \
--log-recover
do
expect_missing_arg "$opt"
done
ZH_OUTPUT=$(TNT_LANG=zh "$BIN" --bind 2>&1)
ZH_STATUS=$?
if [ "$ZH_STATUS" -eq 64 ] &&
printf '%s\n' "$ZH_OUTPUT" | grep -q '选项需要参数: --bind'; then
pass "missing argument diagnostic follows TNT_LANG"
else
fail "localized missing argument diagnostic unexpected" "$ZH_OUTPUT"
fi
BAD_PORT_OUTPUT=$("$BIN" --port abc 2>&1)
BAD_PORT_STATUS=$?
if [ "$BAD_PORT_STATUS" -eq 64 ] &&
printf '%s\n' "$BAD_PORT_OUTPUT" | grep -q 'Invalid port: abc'; then
pass "invalid port still reports invalid value"
else
fail "invalid port diagnostic unexpected" "$BAD_PORT_OUTPUT"
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

76
tests/test_docs_help_surface.sh Executable file
View file

@ -0,0 +1,76 @@
#!/bin/sh
# Regression checks for active help/manual surfaces.
PASS=0
FAIL=0
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
REPO_ROOT=$(CDPATH= cd -- "$SCRIPT_DIR/.." && pwd)
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
if [ -n "$2" ]; then
printf '%s\n' "$2"
fi
FAIL=$((FAIL + 1))
}
require_fixed() {
file="$1"
text="$2"
label="$3"
if grep -F -q "$text" "$REPO_ROOT/$file"; then
pass "$label"
else
fail "$label missing" "$file: $text"
fi
}
forbid_fixed() {
file="$1"
text="$2"
label="$3"
if grep -F -q "$text" "$REPO_ROOT/$file"; then
fail "$label still mentions $text" "$file"
else
pass "$label excludes $text"
fi
}
echo "=== TNT Help Surface Tests ==="
require_fixed "tnt.1" "/ Search message history" "manual documents NORMAL search"
require_fixed "tnt.1" "Space/b Scroll full page down/up" "manual documents space/b paging"
require_fixed "tnt.1" "PageDown/PageUp Scroll full page down/up" "manual documents page keys"
require_fixed "tnt.1" "End/Home Jump to bottom/top" "manual documents end/home"
require_fixed "tnt.1" "g/G Jump to top/bottom" "manual documents g/G"
require_fixed "tnt.1" ":lang Show current UI language" "manual documents current language"
require_fixed "tnt.1" ":lang \fIen|zh\fR Switch UI language for this session" "manual documents language codes"
for file in \
README.md \
docs/EASY_SETUP.md \
docs/DEPLOYMENT.md \
docs/INTERFACE.md \
docs/QUICKREF.md \
docs/USER_LIFECYCLE.md \
tnt.1 \
tntctl.1 \
src/command_catalog.c \
src/help_text.c \
src/manual_text.c
do
forbid_fixed "$file" ":support" "$file"
done
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

@ -140,6 +140,19 @@ else
FAIL=$((FAIL + 1))
fi
DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null)
DUMP_USAGE_STATUS=$?
printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$'
if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then
echo "✓ dump usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ dump usage output unexpected"
printf '%s\n' "$DUMP_USAGE"
echo "exit status: $DUMP_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message"
@ -161,6 +174,17 @@ else
FAIL=$((FAIL + 1))
fi
DUMP_OUTPUT=$(ssh $SSH_OPTS localhost "dump -n 1" 2>/dev/null || true)
printf '%s\n' "$DUMP_OUTPUT" | grep -q '|execposter|hello from exec$'
if [ $? -eq 0 ]; then
echo "✓ dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ dump output unexpected"
printf '%s\n' "$DUMP_OUTPUT"
FAIL=$((FAIL + 1))
fi
PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log"
@ -261,6 +285,17 @@ else
FAIL=$((FAIL + 1))
fi
TNTCTL_DUMP=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "dump" "-n" "1" 2>/dev/null || true)
printf '%s\n' "$TNTCTL_DUMP" | grep -q '|ctlposter|hello from tntctl$'
if [ $? -eq 0 ]; then
echo "✓ tntctl dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ tntctl dump output unexpected"
printf '%s\n' "$TNTCTL_DUMP"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF
@ -337,7 +372,7 @@ set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
expect "请输入用户名"
send "sender\r"
expect ":help"
expect "Esc NORMAL"
send "\033"
expect "NORMAL"
send ":"

View file

@ -58,13 +58,58 @@ else
exit 1
fi
USERNAME_CANCEL_SCRIPT="$STATE_DIR/username-cancel.expect"
cat >"$USERNAME_CANCEL_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "\003"
expect eof
EOF
if expect "$USERNAME_CANCEL_SCRIPT" >"$STATE_DIR/username-cancel.log" 2>&1; then
echo "✓ Ctrl+C cancels before username join"
PASS=$((PASS + 1))
else
echo "x Ctrl+C before username failed"
sed -n '1,120p' "$STATE_DIR/username-cancel.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
USERNAME_EDIT_SCRIPT="$STATE_DIR/username-edit.expect"
cat >"$USERNAME_EDIT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "wrong\025editeduser\r"
expect "Esc NORMAL"
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$USERNAME_EDIT_SCRIPT" >"$STATE_DIR/username-edit.log" 2>&1 &&
grep -q 'editeduser' "$STATE_DIR/messages.log" &&
! grep -q 'wrongediteduser' "$STATE_DIR/messages.log"; then
echo "✓ Ctrl+U edits username before join"
PASS=$((PASS + 1))
else
echo "x username line editing failed"
sed -n '1,120p' "$STATE_DIR/username-edit.log" 2>/dev/null || true
cat "$STATE_DIR/messages.log" 2>/dev/null || true
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "tester\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033\[200~"
send -- "line1\nline2\nline3"
send -- "\033\[201~"
@ -139,21 +184,28 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "helper\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "help\r"
send -- ":help\r"
expect "TNT\\(1\\) 帮助"
expect "Tab 补全 @mention"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
expect "Tab - 补全 @mention"
expect "l:语言"
send -- "\003"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
send -- "l"
expect "TNT KEY REFERENCE"
expect "Complete @mention"
expect "l:lang"
send -- "q"
expect "NORMAL"
@ -180,13 +232,52 @@ else
FAIL=$((FAIL + 1))
fi
HELP_PAGER_KEYS_SCRIPT="$STATE_DIR/help-pager-keys.expect"
cat >"$HELP_PAGER_KEYS_SCRIPT" <<EOF
set timeout 10
stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "helppager\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- "?"
expect -re {\(1/[2-9][0-9]*\)}
send -- "\033\[6~"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[5~"
expect -re {\(1/[2-9][0-9]*\)}
send -- "\033\[F"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[H"
expect -re {\(1/[2-9][0-9]*\)}
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$HELP_PAGER_KEYS_SCRIPT" >"$STATE_DIR/help-pager-keys.log" 2>&1; then
echo "✓ help pager accepts terminal paging keys"
PASS=$((PASS + 1))
else
echo "x help pager terminal keys failed"
sed -n '1,220p' "$STATE_DIR/help-pager-keys.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
cat >"$UNKNOWN_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "mistype\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
@ -218,7 +309,7 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "localized\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
@ -268,7 +359,7 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "usageuser\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
@ -304,6 +395,9 @@ expect ":"
send -- "inbox\r"
expect "Private messages"
expect "(empty)"
expect "r:refresh"
send -- "r"
expect "Private messages"
expect "q:close"
send -- "q"
expect "NORMAL"
@ -358,7 +452,7 @@ stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "pageruser\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
@ -368,6 +462,14 @@ expect "j/k:滚动"
expect -re {\(1/[2-9][0-9]*\)}
send -- "j"
expect -re {\(2/[2-9][0-9]*\)}
send -- "\033\[6~"
expect -re {\([3-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[5~"
expect -re {\([1-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[F"
expect -re {\([2-9][0-9]*/[2-9][0-9]*\)}
send -- "\033\[H"
expect -re {\(1/[2-9][0-9]*\)}
send -- "q"
expect "NORMAL"
sleep 0.2
@ -387,13 +489,44 @@ else
FAIL=$((FAIL + 1))
fi
COMMAND_INPUT_WRAP_SCRIPT="$STATE_DIR/command-input-wrap.expect"
cat >"$COMMAND_INPUT_WRAP_SCRIPT" <<EOF
set timeout 10
stty rows 10 columns 40
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "wrapcmd\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaatail"
expect -re {<a+tail}
send -- "\003"
expect "NORMAL"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$COMMAND_INPUT_WRAP_SCRIPT" >"$STATE_DIR/command-input-wrap.log" 2>&1; then
echo "✓ long command input stays on one status line"
PASS=$((PASS + 1))
else
echo "x long command input display failed"
sed -n '1,220p' "$STATE_DIR/command-input-wrap.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "systemuser\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
@ -440,7 +573,7 @@ expect "公告"
expect "维护窗口"
expect "按任意键继续"
send -- "x"
expect "NORMAL"
expect "INSERT"
sleep 0.2
send -- "\003"
sleep 0.2

140
tests/test_logrotate.sh Executable file
View file

@ -0,0 +1,140 @@
#!/bin/sh
# Maintenance-script regression tests for scripts/logrotate.sh.
set -u
PASS=0
FAIL=0
SCRIPT="../scripts/logrotate.sh"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-logrotate-test.XXXXXX")
cleanup() {
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
FAIL=$((FAIL + 1))
}
archive_payload() {
archive=$1
case "$archive" in
*.gz) gzip -cd "$archive" ;;
*) cat "$archive" ;;
esac
}
echo "=== TNT Logrotate Tests ==="
if [ ! -x "$SCRIPT" ]; then
echo "Error: script $SCRIPT not found or not executable."
exit 1
fi
MISSING_OUTPUT=$("$SCRIPT" "$STATE_DIR/missing.log" 100 10 2>&1)
MISSING_STATUS=$?
printf '%s\n' "$MISSING_OUTPUT" | grep -q 'does not exist'
if [ "$MISSING_STATUS" -eq 0 ] && [ $? -eq 0 ]; then
pass "missing log is a successful no-op"
else
fail "missing log handling"
printf '%s\n' "$MISSING_OUTPUT"
fi
LOG="$STATE_DIR/messages.log"
cat > "$LOG" <<'EOF'
2026-01-01T00:00:01Z|alice|one
2026-01-01T00:00:02Z|bob|two
2026-01-01T00:00:03Z|carol|three
EOF
if "$SCRIPT" "$LOG" 100 2 >/dev/null 2>&1 &&
grep -q 'alice|one' "$LOG" &&
[ "$(ls "$LOG".* 2>/dev/null | wc -l | tr -d ' ')" -eq 0 ]; then
pass "small log stays unmodified"
else
fail "small log no-op"
cat "$LOG" 2>/dev/null
fi
ROTATE_OUTPUT=$("$SCRIPT" "$LOG" 0 2 2>&1)
ROTATE_STATUS=$?
ARCHIVE=$(ls "$LOG".*.gz "$LOG".[0-9]* 2>/dev/null | head -n 1)
if [ "$ROTATE_STATUS" -eq 0 ] &&
printf '%s\n' "$ROTATE_OUTPUT" | grep -q 'kept last 2 lines' &&
! grep -q 'alice|one' "$LOG" &&
grep -q 'bob|two' "$LOG" &&
grep -q 'carol|three' "$LOG" &&
[ -n "$ARCHIVE" ] &&
archive_payload "$ARCHIVE" | grep -q 'alice|one'; then
pass "oversize log is archived and compacted"
else
fail "oversize rotation"
printf '%s\n' "$ROTATE_OUTPUT"
cat "$LOG" 2>/dev/null
fi
DRY_LOG="$STATE_DIR/dry.log"
printf 'line1\nline2\nline3\n' > "$DRY_LOG"
DRY_BEFORE=$(cat "$DRY_LOG")
DRY_OUTPUT=$("$SCRIPT" --dry-run "$DRY_LOG" 0 1 2>&1)
DRY_STATUS=$?
if [ "$DRY_STATUS" -eq 0 ] &&
[ "$(cat "$DRY_LOG")" = "$DRY_BEFORE" ] &&
printf '%s\n' "$DRY_OUTPUT" | grep -q 'would archive'; then
pass "dry run does not modify the log"
else
fail "dry run handling"
printf '%s\n' "$DRY_OUTPUT"
fi
INVALID_OUTPUT=$("$SCRIPT" "$LOG" nope 2 2>&1)
INVALID_STATUS=$?
if [ "$INVALID_STATUS" -eq 64 ] &&
printf '%s\n' "$INVALID_OUTPUT" | grep -q 'invalid max size'; then
pass "invalid arguments exit 64"
else
fail "invalid argument status"
printf '%s\n' "$INVALID_OUTPUT"
echo "exit status: $INVALID_STATUS"
fi
DIR_OUTPUT=$("$SCRIPT" "$STATE_DIR" 0 1 2>&1)
DIR_STATUS=$?
if [ "$DIR_STATUS" -eq 1 ] &&
printf '%s\n' "$DIR_OUTPUT" | grep -q 'not a regular file'; then
pass "non-regular log path is rejected"
else
fail "non-regular path handling"
printf '%s\n' "$DIR_OUTPUT"
echo "exit status: $DIR_STATUS"
fi
RET_LOG="$STATE_DIR/retention.log"
printf 'a\nb\nc\n' > "$RET_LOG"
printf old1 > "$RET_LOG.20000101T000000Z.gz"
sleep 1
printf old2 > "$RET_LOG.20010101T000000Z.gz"
sleep 1
printf old3 > "$RET_LOG.20020101T000000Z.gz"
if "$SCRIPT" --keep-archives 2 "$RET_LOG" 100 2 >/dev/null 2>&1 &&
[ "$(ls "$RET_LOG".*.gz 2>/dev/null | wc -l | tr -d ' ')" -eq 2 ]; then
pass "archive retention removes older archives"
else
fail "archive retention"
ls "$RET_LOG".* 2>/dev/null || true
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

134
tests/test_message_log_tool.sh Executable file
View file

@ -0,0 +1,134 @@
#!/bin/sh
# Offline messages.log check/recover regression tests.
set -u
PASS=0
FAIL=0
BIN="../tnt"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-log-tool-test.XXXXXX")
cleanup() {
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
FAIL=$((FAIL + 1))
}
ts_now() {
date -u +%Y-%m-%dT%H:%M:%SZ
}
echo "=== TNT Message Log Tool Tests ==="
if [ ! -x "$BIN" ]; then
echo "Error: binary $BIN not found. Run make first."
exit 1
fi
TS=$(ts_now)
CLEAN_LOG="$STATE_DIR/clean.log"
cat > "$CLEAN_LOG" <<EOF
$TS|alice|one
$TS|bob|two
EOF
CHECK_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" 2>&1)
CHECK_STATUS=$?
printf '%s\n' "$CHECK_OUTPUT" | grep -q '^valid_records 2$' &&
printf '%s\n' "$CHECK_OUTPUT" | grep -q '^invalid_records 0$'
if [ "$CHECK_STATUS" -eq 0 ] && [ $? -eq 0 ]; then
pass "clean log check exits 0"
else
fail "clean log check"
printf '%s\n' "$CHECK_OUTPUT"
echo "exit status: $CHECK_STATUS"
fi
BAD_LOG="$STATE_DIR/bad.log"
cat > "$BAD_LOG" <<EOF
$TS|alice|one
$TS|mallory|extra|pipe
$TS|bob|two
EOF
printf '%s|partial|unterminated' "$TS" >> "$BAD_LOG"
BAD_CHECK_OUTPUT=$("$BIN" --log-check "$BAD_LOG" 2>&1)
BAD_CHECK_STATUS=$?
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^records_seen 4$' &&
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^valid_records 2$' &&
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^invalid_records 2$' &&
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^first_invalid_line 2$'
if [ "$BAD_CHECK_STATUS" -eq 1 ] && [ $? -eq 0 ]; then
pass "bad log check reports skipped records"
else
fail "bad log check"
printf '%s\n' "$BAD_CHECK_OUTPUT"
echo "exit status: $BAD_CHECK_STATUS"
fi
RECOVERED="$STATE_DIR/recovered.log"
RECOVER_REPORT="$STATE_DIR/recover.report"
"$BIN" --log-recover "$BAD_LOG" > "$RECOVERED" 2> "$RECOVER_REPORT"
RECOVER_STATUS=$?
if [ "$RECOVER_STATUS" -eq 1 ] &&
grep -q '^valid_records 2$' "$RECOVER_REPORT" &&
grep -q '^invalid_records 2$' "$RECOVER_REPORT" &&
grep -q "$TS|alice|one" "$RECOVERED" &&
grep -q "$TS|bob|two" "$RECOVERED" &&
! grep -q 'mallory' "$RECOVERED" &&
! grep -q 'partial' "$RECOVERED"; then
pass "recover writes valid records and reports skipped records"
else
fail "bad log recovery"
cat "$RECOVERED" 2>/dev/null
cat "$RECOVER_REPORT" 2>/dev/null
echo "exit status: $RECOVER_STATUS"
fi
MISSING_OUTPUT=$("$BIN" --log-check "$STATE_DIR/missing.log" 2>&1)
MISSING_STATUS=$?
if [ "$MISSING_STATUS" -eq 1 ] &&
printf '%s\n' "$MISSING_OUTPUT" | grep -q 'No such file'; then
pass "missing log exits 1"
else
fail "missing log handling"
printf '%s\n' "$MISSING_OUTPUT"
echo "exit status: $MISSING_STATUS"
fi
USAGE_OUTPUT=$("$BIN" --log-check 2>&1)
USAGE_STATUS=$?
if [ "$USAGE_STATUS" -eq 64 ] &&
printf '%s\n' "$USAGE_OUTPUT" | grep -q 'Option requires argument: --log-check'; then
pass "missing log-check argument exits 64"
else
fail "missing log-check argument"
printf '%s\n' "$USAGE_OUTPUT"
echo "exit status: $USAGE_STATUS"
fi
CONFLICT_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" --log-recover "$CLEAN_LOG" 2>&1)
CONFLICT_STATUS=$?
if [ "$CONFLICT_STATUS" -eq 64 ] &&
printf '%s\n' "$CONFLICT_OUTPUT" | grep -q 'Invalid --log-check: --log-recover'; then
pass "conflicting log modes exit 64"
else
fail "conflicting log modes"
printf '%s\n' "$CONFLICT_OUTPUT"
echo "exit status: $CONFLICT_STATUS"
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

223
tests/test_slow_client.sh Executable file
View file

@ -0,0 +1,223 @@
#!/bin/sh
# Slow interactive-client regression test for TNT.
# Usage: ./test_slow_client.sh [hold_seconds] [burst_chars]
PORT=${PORT:-2222}
HOLD_SECONDS=${1:-8}
BURST_CHARS=${2:-1600}
BIN="../tnt"
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-slow-client-test.XXXXXX")
SERVER_PID=""
SLOW_PID=""
cleanup() {
if [ -n "$SLOW_PID" ]; then
kill "$SLOW_PID" 2>/dev/null || true
wait "$SLOW_PID" 2>/dev/null || true
fi
exec 3>&- 2>/dev/null || true
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 "$HOLD_SECONDS" in
''|*[!0-9]*)
echo "Error: hold_seconds must be a positive integer"
exit 2
;;
esac
case "$BURST_CHARS" in
''|*[!0-9]*)
echo "Error: burst_chars must be a positive integer"
exit 2
;;
esac
if [ "$HOLD_SECONDS" -lt 1 ] || [ "$BURST_CHARS" -lt 1 ]; then
echo "Error: hold_seconds and burst_chars must be positive"
exit 2
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -o LogLevel=ERROR -o ConnectTimeout=5 -p $PORT"
SSH_TTY_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -o ConnectTimeout=5 -p $PORT"
run_ssh_timeout() {
seconds=$1
outfile=$2
shift 2
ssh $SSH_EXEC_OPTS "$@" >"$outfile" 2>&1 &
cmd_pid=$!
elapsed=0
while [ "$elapsed" -lt "$seconds" ]; do
if ! kill -0 "$cmd_pid" 2>/dev/null; then
wait "$cmd_pid"
return $?
fi
sleep 1
elapsed=$((elapsed + 1))
done
if kill -0 "$cmd_pid" 2>/dev/null; then
kill "$cmd_pid" 2>/dev/null || true
wait "$cmd_pid" 2>/dev/null || true
fi
return 124
}
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
}
wait_for_slow_user() {
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 users --json 2>/dev/null || true)
printf '%s\n' "$out" | grep -q '"slow"' && return 0
sleep 1
done
return 1
}
echo "=== TNT Slow Client Test ==="
echo "hold=${HOLD_SECONDS}s burst_chars=$BURST_CHARS port=$PORT"
TNT_LANG=en "$BIN" \
--bind 127.0.0.1 \
--public-host slow.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
SLOW_FIFO="$STATE_DIR/slow.out"
mkfifo "$SLOW_FIFO"
exec 3<>"$SLOW_FIFO"
(
printf 'slow\n'
sleep 2
i=0
while [ "$i" -lt "$BURST_CHARS" ]; do
printf 'x'
i=$((i + 1))
done
sleep "$HOLD_SECONDS"
) | ssh $SSH_TTY_OPTS slow@127.0.0.1 >"$SLOW_FIFO" 2>"$STATE_DIR/slow.err" &
SLOW_PID=$!
if wait_for_slow_user; then
echo "✓ deliberately unread interactive client reached chat"
PASS=$((PASS + 1))
else
echo "✗ slow client did not reach chat"
sed -n '1,120p' "$STATE_DIR/slow.err"
FAIL=$((FAIL + 1))
fi
sleep 3
if run_ssh_timeout 5 "$STATE_DIR/health.out" localhost health &&
grep -qx 'ok' "$STATE_DIR/health.out"; then
echo "✓ health stayed responsive while slow client was pressured"
PASS=$((PASS + 1))
else
echo "✗ health blocked or returned unexpected output"
cat "$STATE_DIR/health.out" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
if run_ssh_timeout 5 "$STATE_DIR/stats.out" localhost stats --json &&
grep -q '"status":"ok"' "$STATE_DIR/stats.out"; then
echo "✓ stats stayed responsive while slow client was pressured"
PASS=$((PASS + 1))
else
echo "✗ stats blocked or returned unexpected output"
cat "$STATE_DIR/stats.out" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
FLOOD_FAIL=0
i=1
while [ "$i" -le 8 ]; do
msg=$(printf 'slow-client responsive post %02d %0900d' "$i" 0)
if ! run_ssh_timeout 5 "$STATE_DIR/post-$i.out" probe@localhost post "$msg" ||
! grep -qx 'posted' "$STATE_DIR/post-$i.out"; then
echo "✗ post blocked or failed during slow-client pressure at $i/8"
cat "$STATE_DIR/post-$i.out" 2>/dev/null || true
FAIL=$((FAIL + 1))
FLOOD_FAIL=1
break
fi
i=$((i + 1))
done
if [ "$FLOOD_FAIL" -eq 0 ]; then
echo "✓ post path stayed responsive during slow-client pressure"
PASS=$((PASS + 1))
fi
if run_ssh_timeout 5 "$STATE_DIR/tail.out" localhost "tail -n 5" &&
grep -q 'slow-client responsive post 08' "$STATE_DIR/tail.out"; then
echo "✓ tail sees messages posted during slow-client pressure"
PASS=$((PASS + 1))
else
echo "✗ tail missing slow-client pressure messages"
cat "$STATE_DIR/tail.out" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server survived slow-client pressure"
PASS=$((PASS + 1))
else
echo "✗ server exited during slow-client pressure"
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

@ -73,6 +73,33 @@ case "$VERSION_OUTPUT" in
*) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;;
esac
HELP_ZH=$(TNT_LANG=zh "$BIN" --help 2>/dev/null || true)
printf '%s\n' "$HELP_ZH" | grep -q '^用法: tntctl \[options\] host command \[args...\]' &&
printf '%s\n' "$HELP_ZH" | grep -q '^选项:$'
if [ $? -eq 0 ]; then
echo "✓ local help follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized help output unexpected"
printf '%s\n' "$HELP_ZH"
FAIL=$((FAIL + 1))
fi
rm -f "$SSH_LOG"
BAD_PORT_ZH=$(PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" TNT_LANG=zh "$BIN" -p nope example.com health 2>&1)
BAD_PORT_STATUS=$?
if [ "$BAD_PORT_STATUS" -eq 64 ] &&
[ ! -f "$SSH_LOG" ] &&
printf '%s\n' "$BAD_PORT_ZH" | grep -q '^tntctl: 端口无效$'; then
echo "✓ local diagnostics follow TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized diagnostic unexpected"
printf '%s\n' "$BAD_PORT_ZH"
[ -f "$SSH_LOG" ] && echo "fake ssh was invoked"
FAIL=$((FAIL + 1))
fi
run_ok "basic argv shape" "$BIN" -p 2222 example.com health
grep -q '^example.com$' "$SSH_LOG" &&
grep -q '^health$' "$SSH_LOG"
@ -108,6 +135,28 @@ else
FAIL=$((FAIL + 1))
fi
run_ok "dump command is accepted" "$BIN" example.com dump -n 1
grep -q '^dump -n 1$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ dump argv is forwarded as one remote command"
PASS=$((PASS + 1))
else
echo "✗ dump argv unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
run_ok "remote help alias is accepted" "$BIN" example.com --help
grep -q '^--help$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ --help after host is forwarded as exec help"
PASS=$((PASS + 1))
else
echo "✗ remote --help command unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1
REMOTE_STATUS=$?
if [ "$REMOTE_STATUS" -eq 64 ]; then

View file

@ -36,7 +36,7 @@ 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"
PRIVATE_SENT="$STATE_DIR/private.sent"
wait_for_health() {
out=""
@ -79,15 +79,18 @@ 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"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox\r"
expect "私信"
expect "(空)"
expect "r:刷新"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
expect "私信"
expect "alice"
expect "private lifecycle ping"
expect "q:关闭"
@ -140,7 +143,7 @@ set timeout 30
spawn ssh $SSH_OPTS alice@127.0.0.1
sleep 1
send -- "alice\r"
expect ":help"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- "?"
@ -157,7 +160,7 @@ expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "i"
expect ":help"
expect "Esc NORMAL"
send -- "hello lifecycle alpha\r"
sleep 1
send -- "\033"
@ -182,6 +185,12 @@ expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "/alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
@ -194,6 +203,7 @@ send -- ":"
expect ":"
send -- "msg bob private lifecycle ping\r"
expect "私信已发送给 bob"
exec touch "$PRIVATE_SENT"
expect "q:关闭"
send -- "q"
expect "NORMAL"
@ -205,10 +215,9 @@ expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "i"
expect ":help"
expect "Esc NORMAL"
send -- "/me ships lifecycle\r"
sleep 1
exec touch "$ALICE_DONE"
send -- "\003"
sleep 0.2
send -- "\003"
@ -222,11 +231,11 @@ else
echo "✗ primary user lifecycle failed"
sed -n '1,240p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1))
touch "$ALICE_DONE"
touch "$PRIVATE_SENT"
fi
if wait "$BOB_PID" 2>/dev/null; then
echo "✓ recipient read private-message inbox"
echo "✓ recipient inbox auto-refreshed after private message"
PASS=$((PASS + 1))
else
echo "✗ recipient inbox journey failed"

View file

@ -12,9 +12,12 @@ endif
# Source files
UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c
MESSAGE_LOG_SRC = ../../src/message_log.c
COMMON_SRC = ../../src/common.c
CONFIG_DEFAULTS_SRC = ../../src/config_defaults.c
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
CLI_TEXT_SRC = ../../src/cli_text.c
TNTCTL_TEXT_SRC = ../../src/tntctl_text.c
CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c
I18N_SRC = ../../src/i18n.c
@ -25,7 +28,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.c
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_ratelimit
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_tntctl_text test_ratelimit test_config_defaults
.PHONY: all clean run
@ -34,10 +37,10 @@ all: $(TESTS)
test_utf8: test_utf8.c $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
test_message: test_message.c $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(MESSAGE_LOG_SRC) $(UTF8_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
@ -64,7 +67,13 @@ test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $
test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC)
test_tntctl_text: test_tntctl_text.c $(TNTCTL_TEXT_SRC) $(EXEC_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC) $(CONFIG_DEFAULTS_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_config_defaults: test_config_defaults.c $(CONFIG_DEFAULTS_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all
@ -101,8 +110,14 @@ run: all
@echo "=== Running CLI Text Tests ==="
./test_cli_text
@echo ""
@echo "=== Running tntctl Text Tests ==="
./test_tntctl_text
@echo ""
@echo "=== Running Rate Limit Tests ==="
./test_ratelimit
@echo ""
@echo "=== Running Config Defaults Tests ==="
./test_config_defaults
clean:
rm -f $(TESTS) *.o test_messages.log

View file

@ -24,6 +24,8 @@ TEST(help_matches_language) {
assert(strstr(output, "Usage: tnt [options]") != NULL);
assert(strstr(output, "--bind ADDR") != NULL);
assert(strstr(output, "--max-connections N") != NULL);
assert(strstr(output, "--log-check FILE") != NULL);
assert(strstr(output, "--log-recover FILE") != NULL);
assert(strstr(output, "TNT_LANG") != NULL);
memset(output, 0, sizeof(output));
@ -39,6 +41,7 @@ TEST(help_matches_language) {
assert(strstr(output, "[选项]") == NULL);
assert(strstr(output, "--public-host HOST") != NULL);
assert(strstr(output, "--idle-timeout SECONDS") != NULL);
assert(strstr(output, "--log-check FILE") != NULL);
assert(strstr(output, "TNT_LANG") != NULL);
}
@ -51,6 +54,10 @@ TEST(error_formats_match_language) {
"Invalid %s: %s\n") == 0);
assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH),
"%s 无效: %s\n") == 0);
assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_EN),
"Option requires argument: %s\n") == 0);
assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_ZH),
"选项需要参数: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
"Unknown option: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),

View file

@ -0,0 +1,66 @@
#include "config_defaults.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.h>
#define TEST(name) static void test_##name(void)
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("ok\n"); \
} while (0)
TEST(specs_expose_runtime_defaults) {
assert(TNT_CONFIG_PORT.fallback == TNT_DEFAULT_PORT);
assert(TNT_CONFIG_MAX_CONNECTIONS.fallback ==
TNT_DEFAULT_MAX_CONNECTIONS);
assert(TNT_CONFIG_MAX_CONN_PER_IP.fallback ==
TNT_DEFAULT_MAX_CONN_PER_IP);
assert(TNT_CONFIG_MAX_CONN_RATE_PER_IP.fallback ==
TNT_DEFAULT_MAX_CONN_RATE_PER_IP);
assert(TNT_CONFIG_RATE_LIMIT.fallback ==
TNT_DEFAULT_RATE_LIMIT_ENABLED);
assert(TNT_CONFIG_IDLE_TIMEOUT.fallback == TNT_DEFAULT_IDLE_TIMEOUT);
assert(TNT_CONFIG_PORT.min_value == TNT_MIN_PORT);
assert(TNT_CONFIG_PORT.max_value == TNT_MAX_PORT);
}
TEST(parse_uses_spec_ranges) {
int out = 0;
assert(tnt_config_parse_int("2222", &TNT_CONFIG_PORT, &out));
assert(out == 2222);
assert(!tnt_config_parse_int("0", &TNT_CONFIG_PORT, &out));
assert(!tnt_config_parse_int("65536", &TNT_CONFIG_PORT, &out));
assert(!tnt_config_parse_int("abc", &TNT_CONFIG_PORT, &out));
assert(!tnt_config_parse_int("", &TNT_CONFIG_PORT, &out));
assert(tnt_config_parse_int("0", &TNT_CONFIG_IDLE_TIMEOUT, &out));
assert(out == 0);
assert(!tnt_config_parse_int("86401", &TNT_CONFIG_IDLE_TIMEOUT, &out));
}
TEST(env_reader_uses_fallback_and_range) {
unsetenv(TNT_CONFIG_MAX_CONNECTIONS.env_name);
assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) ==
TNT_DEFAULT_MAX_CONNECTIONS);
setenv(TNT_CONFIG_MAX_CONNECTIONS.env_name, "128", 1);
assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) == 128);
setenv(TNT_CONFIG_MAX_CONNECTIONS.env_name, "0", 1);
assert(tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS) ==
TNT_DEFAULT_MAX_CONNECTIONS);
unsetenv(TNT_CONFIG_MAX_CONNECTIONS.env_name);
}
int main(void) {
printf("Running config defaults unit tests...\n\n");
RUN_TEST(specs_expose_runtime_defaults);
RUN_TEST(parse_uses_spec_ranges);
RUN_TEST(env_reader_uses_fallback_and_range);
printf("\nAll 3 tests passed!\n");
return 0;
}

View file

@ -28,12 +28,14 @@ TEST(generates_localized_exec_help) {
assert(strstr(en, "TNT exec interface") != NULL);
assert(strstr(en, "Commands:") != NULL);
assert(strstr(en, "users [--json]") != NULL);
assert(strstr(en, "dump [N]") != NULL);
assert(strstr(en, "post MESSAGE") != NULL);
assert(strstr(en, "support") == NULL);
assert(strstr(zh, "TNT exec 接口") != NULL);
assert(strstr(zh, "命令:") != NULL);
assert(strstr(zh, "users [--json]") != NULL);
assert(strstr(zh, "dump [N]") != NULL);
assert(strstr(zh, "post MESSAGE") != NULL);
assert(strstr(zh, "support") == NULL);
assert_ascii_angle_placeholders(zh);
@ -65,6 +67,10 @@ TEST(matches_exec_commands_and_args) {
assert(id == TNT_EXEC_COMMAND_TAIL);
assert(strcmp(args, "-n 20") == 0);
assert(exec_catalog_match("dump -n 20", &id, &args));
assert(id == TNT_EXEC_COMMAND_DUMP);
assert(strcmp(args, "-n 20") == 0);
assert(exec_catalog_match("post hello world", &id, &args));
assert(id == TNT_EXEC_COMMAND_POST);
assert(strcmp(args, "hello world") == 0);
@ -90,6 +96,9 @@ TEST(validates_argument_shapes) {
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_DUMP, "-n 20"));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
}
@ -111,8 +120,18 @@ TEST(generates_localized_usage) {
memset(en, 0, sizeof(en));
en_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99);
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99);
assert(strcmp(en, "dump: usage: dump [N] | dump -n N\n") == 0);
}
TEST(generates_unique_command_list) {
char output[256] = {0};
size_t pos = 0;
exec_catalog_append_command_list(output, sizeof(output), &pos);
assert(strcmp(output,
"help, health, users, stats, tail, dump, post, exit") == 0);
}
int main(void) {
@ -122,6 +141,7 @@ int main(void) {
RUN_TEST(matches_exec_commands_and_args);
RUN_TEST(validates_argument_shapes);
RUN_TEST(generates_localized_usage);
RUN_TEST(generates_unique_command_list);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;

View file

@ -29,6 +29,7 @@ TEST(full_help_matches_language) {
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
assert(strstr(en, ":inbox") != NULL);
assert(strstr(en, "Refresh live output") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL);
assert(strstr(en, "Cycle UI language") != NULL);
@ -38,6 +39,7 @@ TEST(full_help_matches_language) {
assert(strstr(zh, "可用命令") != NULL);
assert(strstr(zh, "命令输出按键") != NULL);
assert(strstr(zh, ":inbox") != NULL);
assert(strstr(zh, "刷新动态输出") != NULL);
assert(strstr(zh, "/me <action>") != NULL);
assert(strstr(zh, "@username") != NULL);
assert(strstr(zh, "<动作>") == NULL);

View file

@ -80,10 +80,21 @@ TEST(default_uses_locale_when_no_tnt_lang) {
TEST(text_lookup_matches_language) {
i18n_string_t sample = I18N_STRING("fallback", "替代");
i18n_string_t mapped = I18N_STRING_MAP(
I18N_EN("mapped fallback"),
I18N_ZH("映射替代")
);
i18n_string_t english_only = I18N_STRING_MAP(
I18N_EN("english only")
);
assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0);
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0);
assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0);
assert(strcmp(i18n_string(mapped, UI_LANG_EN), "mapped fallback") == 0);
assert(strcmp(i18n_string(mapped, UI_LANG_ZH), "映射替代") == 0);
assert(strcmp(i18n_string(english_only, UI_LANG_ZH),
"english only") == 0);
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL);
@ -111,6 +122,12 @@ TEST(text_lookup_matches_language) {
"q:close") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:关闭") != NULL);
assert(strstr(i18n_text(UI_LANG_EN,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT),
"r:refresh") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT),
"r:刷新") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
"Press any key") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),

View file

@ -3,8 +3,10 @@
#include <stdio.h>
#include <string.h>
#include <assert.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/stat.h>
#include <limits.h>
#define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \
@ -16,12 +18,45 @@
static int tests_passed = 0;
static const char *test_log = "test_messages.log";
static char test_state_dir[PATH_MAX];
/* Helper: Clean up test log file */
static void cleanup_test_log(void) {
unlink(test_log);
}
static void cleanup_state_dir(void) {
if (test_state_dir[0] != '\0') {
char log_path[PATH_MAX];
snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir);
unlink(log_path);
rmdir(test_state_dir);
test_state_dir[0] = '\0';
}
unsetenv("TNT_STATE_DIR");
}
static void setup_state_dir(void) {
const char *tmp = getenv("TMPDIR");
cleanup_state_dir();
if (!tmp || tmp[0] == '\0') {
tmp = "/tmp";
}
snprintf(test_state_dir, sizeof(test_state_dir),
"%s/tnt-message-test.XXXXXX", tmp);
assert(mkdtemp(test_state_dir) != NULL);
assert(setenv("TNT_STATE_DIR", test_state_dir, 1) == 0);
}
static void format_rfc3339_now(char *buffer, size_t buf_size) {
time_t now = time(NULL);
struct tm tm_info;
gmtime_r(&now, &tm_info);
strftime(buffer, buf_size, "%Y-%m-%dT%H:%M:%SZ", &tm_info);
}
/* Test message initialization */
TEST(message_init) {
message_init();
@ -122,6 +157,104 @@ TEST(message_save_basic) {
cleanup_test_log();
}
TEST(message_load_skips_malformed_records) {
char ts[64];
char log_path[PATH_MAX];
message_t *messages = NULL;
setup_state_dir();
format_rfc3339_now(ts, sizeof(ts));
snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir);
FILE *fp = fopen(log_path, "wb");
assert(fp != NULL);
fprintf(fp, "%s|alice|valid one\n", ts);
fprintf(fp, "not-a-date|bob|bad date\n");
fprintf(fp, "%s||empty user\n", ts);
fprintf(fp, "%s|mallory|extra|pipe\n", ts);
fprintf(fp, "%s|badutf|bad \xC3\x28\n", ts);
fprintf(fp, "%s|partial|truncated record", ts);
fclose(fp);
int count = message_load(&messages, 10);
assert(count == 1);
assert(strcmp(messages[0].username, "alice") == 0);
assert(strcmp(messages[0].content, "valid one") == 0);
free(messages);
cleanup_state_dir();
}
TEST(message_search_skips_malformed_records) {
char ts[64];
char log_path[PATH_MAX];
message_t *results = NULL;
setup_state_dir();
format_rfc3339_now(ts, sizeof(ts));
snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir);
FILE *fp = fopen(log_path, "wb");
assert(fp != NULL);
fprintf(fp, "%s|alice|needle valid\n", ts);
fprintf(fp, "%s|mallory|needle extra|pipe\n", ts);
fprintf(fp, "%s|partial|needle truncated", ts);
fclose(fp);
int count = message_search("needle", &results, 10);
assert(count == 1);
assert(strcmp(results[0].username, "alice") == 0);
assert(strcmp(results[0].content, "needle valid") == 0);
free(results);
cleanup_state_dir();
}
TEST(message_dump_exports_valid_records) {
char ts[64];
char log_path[PATH_MAX];
char expected_all[512];
char expected_last_two[512];
char *dump = NULL;
size_t dump_len = 0;
setup_state_dir();
format_rfc3339_now(ts, sizeof(ts));
snprintf(log_path, sizeof(log_path), "%s/messages.log", test_state_dir);
FILE *fp = fopen(log_path, "wb");
assert(fp != NULL);
fprintf(fp, "%s|alice|first valid\n", ts);
fprintf(fp, "%s|mallory|extra|pipe\n", ts);
fprintf(fp, "%s|bob|second valid\n", ts);
fprintf(fp, "%s|carol|third valid\n", ts);
fprintf(fp, "%s|partial|truncated record", ts);
fclose(fp);
snprintf(expected_all, sizeof(expected_all),
"%s|alice|first valid\n"
"%s|bob|second valid\n"
"%s|carol|third valid\n",
ts, ts, ts);
assert(message_dump_text(&dump, &dump_len, 0) == 0);
assert(dump != NULL);
assert(dump_len == strlen(expected_all));
assert(strcmp(dump, expected_all) == 0);
free(dump);
dump = NULL;
dump_len = 0;
snprintf(expected_last_two, sizeof(expected_last_two),
"%s|bob|second valid\n"
"%s|carol|third valid\n",
ts, ts);
assert(message_dump_text(&dump, &dump_len, 2) == 0);
assert(dump != NULL);
assert(dump_len == strlen(expected_last_two));
assert(strcmp(dump, expected_last_two) == 0);
free(dump);
cleanup_state_dir();
}
/* Test edge cases */
TEST(message_edge_cases) {
message_t msg;
@ -215,12 +348,16 @@ int main(void) {
RUN_TEST(message_format_unicode);
RUN_TEST(message_format_width_limits);
RUN_TEST(message_save_basic);
RUN_TEST(message_load_skips_malformed_records);
RUN_TEST(message_search_skips_malformed_records);
RUN_TEST(message_dump_exports_valid_records);
RUN_TEST(message_edge_cases);
RUN_TEST(message_special_characters);
RUN_TEST(message_buffer_safety);
RUN_TEST(message_timestamp_formats);
cleanup_test_log();
cleanup_state_dir();
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;

View file

@ -0,0 +1,60 @@
/* Unit tests for tntctl local help and diagnostic text */
#include "../../include/tntctl_text.h"
#include <assert.h>
#include <stdio.h>
#include <string.h>
#define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \
printf("Running %s... ", #name); \
test_##name(); \
printf("\n"); \
tests_passed++; \
} while(0)
static int tests_passed = 0;
TEST(usage_matches_language) {
char en[2048] = {0};
char zh[2048] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
tntctl_text_append_usage(en, sizeof(en), &en_pos, UI_LANG_EN);
tntctl_text_append_usage(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
assert(strstr(en, "Usage: tntctl [options] host command [args...]") != NULL);
assert(strstr(en, "--host-key-checking MODE") != NULL);
assert(strstr(en,
"help, health, users, stats, tail, dump, post, exit") != NULL);
assert(strstr(zh, "用法: tntctl [options] host command [args...]") != NULL);
assert(strstr(zh, "OpenSSH 主机密钥模式") != NULL);
assert(strstr(zh,
"help, health, users, stats, tail, dump, post, exit") != NULL);
}
TEST(errors_match_language) {
assert(strcmp(tntctl_text(UI_LANG_EN, TNTCTL_TEXT_INVALID_PORT),
"invalid port") == 0);
assert(strcmp(tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_INVALID_PORT),
"端口无效") == 0);
assert(strcmp(tntctl_text(UI_LANG_EN, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT),
"unknown option: %s") == 0);
assert(strcmp(tntctl_text(UI_LANG_ZH, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT),
"未知选项: %s") == 0);
assert(strcmp(tntctl_text((ui_lang_t)99, TNTCTL_TEXT_INVALID_PORT),
"invalid port") == 0);
assert(strcmp(tntctl_text(UI_LANG_EN,
(tntctl_text_id_t)TNTCTL_TEXT_COUNT), "") == 0);
}
int main(void) {
printf("Running tntctl text unit tests...\n\n");
RUN_TEST(usage_matches_language);
RUN_TEST(errors_match_language);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

61
tnt.1
View file

@ -26,6 +26,14 @@ tnt \- anonymous SSH chat server with Vim\-style TUI
.IR level ]
.RB [ \-V | \-\-version ]
.RB [ \-h | \-\-help ]
.br
.B tnt
.B \-\-log\-check
.I file
.br
.B tnt
.B \-\-log\-recover
.I file
.SH DESCRIPTION
.B tnt
is a multi\-user anonymous chat server accessed over SSH.
@ -34,6 +42,13 @@ COMMAND modes.
Users connect with any standard SSH client; no account or registration is
needed.
.PP
In the 1.x series,
.B tnt
is the stable server process name.
Use
.BR tntctl (1)
for local control commands against a running server.
.PP
Messages are persisted to a log file and restored on server restart.
The server supports CJK and emoji input, rate limiting, access tokens, and
a non\-interactive exec interface for scripting.
@ -110,6 +125,18 @@ Overrides the
.B TNT_SSH_LOG_LEVEL
environment variable.
.TP
.BR \-\-log\-check " " \fIfile\fR
Check a
.I messages.log
v1 file and print record counts.
Exits non-zero when invalid records are found or the file cannot be read.
.TP
.BR \-\-log\-recover " " \fIfile\fR
Write valid
.I messages.log
v1 records to standard output and print a recovery summary to standard error.
The source file is not modified.
.TP
.BR \-V ", " \-\-version
Print version and exit.
.TP
@ -140,6 +167,8 @@ Press
to return to INSERT,
.B :
to enter COMMAND mode,
.B /
to search message history,
.B ?
to open the full key reference.
.TP
@ -155,6 +184,8 @@ ESC Switch to NORMAL
Ctrl+W Delete last word
Ctrl+U Clear input line
Ctrl+C Switch to NORMAL
Up/Down Browse sent message history
Tab Complete @mention
Paste Keep multi-line paste in the input buffer
/me \fIaction\fR Send action message (e.g. /me waves)
@\fIusername\fR Mention user (bell notification + highlight)
@ -171,6 +202,7 @@ Ctrl+F/Ctrl+B Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
/ Search message history
i Switch to INSERT
: Enter COMMAND mode
? Open full key reference
@ -190,8 +222,9 @@ l l.
:w \fIuser text\fR Short alias for :msg
:inbox Show private messages
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search across full message history
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
:mute\-joins Toggle join/leave system notifications on/off
:lang Show current UI language
:lang \fIen|zh\fR Switch UI language for this session
:help Show concise manual
:clear Clear command output
@ -199,6 +232,25 @@ l l.
Up/Down Browse command history
ESC Cancel and return to NORMAL
.TE
.PP
Command output pages use the same paging keys as the help screen.
.TS
l l.
q, ESC Close output
j/k, arrows Scroll down/up
Ctrl+D/Ctrl+U Scroll half page down/up
Ctrl+F/Ctrl+B Scroll full page down/up
Space/b Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
r Refresh live output (:inbox)
.TE
.PP
The
.B :inbox
page refreshes automatically when a new private message arrives while it is
open.
.SH EXEC INTERFACE
Commands can be run non\-interactively for scripting:
.PP
@ -207,6 +259,7 @@ ssh host \-p 2222 help
ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20
ssh host \-p 2222 dump \-n 100
ssh host \-p 2222 post "Hello from a script"
ssh host \-p 2222 post "/me deploys v2.0"
ssh host \-p 2222 health
@ -287,9 +340,13 @@ libssh log verbosity from 0 to 4 (default: 1).
.SH FILES
.TP
.I messages.log
Chat history in RFC\ 3339 pipe\-delimited format
Chat history in the TNT message log v1 format:
RFC\ 3339 UTC pipe\-delimited records
.RI ( timestamp | username | content ).
Stored in the state directory.
See
.I docs/MESSAGE_LOG.md
in the source distribution for parser and recovery rules.
.TP
.I host_key
RSA 4096\-bit host key, auto\-generated on first run.

View file

@ -73,6 +73,12 @@ Print recent messages.
.B tail -n N
Print recent messages.
.TP
.B dump [N]
Export persisted messages.
.TP
.B dump -n N
Export persisted messages.
.TP
.B post MESSAGE
Post a message non-interactively.
.TP
@ -82,6 +88,7 @@ Print the server exec help.
.nf
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users
.fi