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: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Install dependencies (Ubuntu) - name: Install dependencies (Ubuntu)
if: runner.os == 'Linux' if: runner.os == 'Linux'
run: | run: |
@ -97,6 +100,9 @@ jobs:
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Download all artifacts - name: Download all artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v4
with: with:
@ -122,12 +128,24 @@ jobs:
body: | body: |
## Installation ## 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: Download the binary for your platform:
**Linux AMD64:** **Linux AMD64:**
```bash ```bash
wget 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 }}/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 }}/tntctl-linux-amd64
chmod +x tnt-linux-amd64 chmod +x tnt-linux-amd64
chmod +x tntctl-linux-amd64 chmod +x tntctl-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt sudo mv tnt-linux-amd64 /usr/local/bin/tnt
@ -136,8 +154,8 @@ jobs:
**Linux ARM64:** **Linux ARM64:**
```bash ```bash
wget 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 }}/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 }}/tntctl-linux-arm64
chmod +x tnt-linux-arm64 chmod +x tnt-linux-arm64
chmod +x tntctl-linux-arm64 chmod +x tntctl-linux-arm64
sudo mv tnt-linux-arm64 /usr/local/bin/tnt sudo mv tnt-linux-arm64 /usr/local/bin/tnt
@ -146,8 +164,8 @@ jobs:
**macOS Intel:** **macOS Intel:**
```bash ```bash
wget 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 }}/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 }}/tntctl-darwin-amd64
chmod +x tnt-darwin-amd64 chmod +x tnt-darwin-amd64
chmod +x tntctl-darwin-amd64 chmod +x tntctl-darwin-amd64
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
@ -156,8 +174,8 @@ jobs:
**macOS Apple Silicon:** **macOS Apple Silicon:**
```bash ```bash
wget 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 }}/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 }}/tntctl-darwin-arm64
chmod +x tnt-darwin-arm64 chmod +x tnt-darwin-arm64
chmod +x tntctl-darwin-arm64 chmod +x tntctl-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
@ -166,7 +184,15 @@ jobs:
**Verify checksums:** **Verify checksums:**
```bash ```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 ## 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_manual_text
tests/unit/test_support_text tests/unit/test_support_text
tests/unit/test_cli_text tests/unit/test_cli_text
tests/unit/test_tntctl_text
tests/unit/test_ratelimit tests/unit/test_ratelimit

View file

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

View file

@ -48,9 +48,12 @@ PORT=3333 tnt # via env var
### Connecting ### Connecting
```sh ```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. **Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
## Usage ## Usage
@ -95,7 +98,7 @@ Ctrl+C - Exit chat
:w <user> <text> - Short alias for :msg :w <user> <text> - Short alias for :msg
:inbox - Show private messages :inbox - Show private messages
:last [N] - Show last N messages from history (max 50, default 10) :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 :mute-joins - Toggle join/leave system notifications
:lang <en|zh> - Switch UI language for this session :lang <en|zh> - Switch UI language for this session
:help - Show concise manual :help - Show concise manual
@ -105,6 +108,10 @@ Up/Down - Browse command history
ESC - Return to NORMAL mode 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)** **Special messages (INSERT mode)**
``` ```
/me <action> - Send action (e.g. /me waves) /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 stats --json
ssh -p 2222 chat.example.com users ssh -p 2222 chat.example.com users
ssh -p 2222 chat.example.com "tail -n 20" 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 operator@chat.example.com post "service notice"
ssh -p 2222 chat.example.com post "/me deploys v2.0" ssh -p 2222 chat.example.com post "/me deploys v2.0"
``` ```
@ -208,9 +216,34 @@ around the same SSH exec interface:
```sh ```sh
tntctl chat.example.com health tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json 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 -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 ## Development
### Building ### Building
@ -234,6 +267,7 @@ make connection-limit-test # verify per-IP concurrency and rate limits
make security-test # run security feature checks make security-test # run security feature checks
make stress-test # run configurable concurrent-client stress test make stress-test # run configurable concurrent-client stress test
make soak-test # run idle/reconnect/control-plane soak test make 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 user-lifecycle-test # run a two-user TUI lifecycle test
make ci-test # run the same checks as GitHub Actions make ci-test # run the same checks as GitHub Actions
@ -245,6 +279,7 @@ cd tests
./test_connection_limits.sh # per-IP concurrency and rate limits ./test_connection_limits.sh # per-IP concurrency and rate limits
./test_stress.sh # stress test ./test_stress.sh # stress test
./test_soak.sh # soak test ./test_soak.sh # soak test
./test_slow_client.sh # slow-client backpressure
./test_user_lifecycle.sh # two-user TUI lifecycle ./test_user_lifecycle.sh # two-user TUI lifecycle
``` ```
@ -253,6 +288,8 @@ cd tests
- Anonymous access: 2 tests - Anonymous access: 2 tests
- Security features: 12 tests - Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`) - 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 ### Dependencies
@ -287,6 +324,7 @@ TNT/
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape │ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
│ ├── exec.c # SSH exec command dispatch │ ├── exec.c # SSH exec command dispatch
│ ├── tntctl.c # local wrapper around the SSH exec interface │ ├── tntctl.c # local wrapper around the SSH exec interface
│ ├── tntctl_text.c # tntctl help and option text
│ ├── ssh_server.c # SSH server implementation │ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap │ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic │ ├── chat_room.c # chat room logic
@ -357,10 +395,17 @@ Before preparing a release locally:
make release-check 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 ```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 ## Files
@ -372,6 +417,9 @@ motd.txt - Message of the Day (optional, shown to users on connect)
tnt.service - systemd service unit 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) ### 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. 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 ## Unreleased
### Added ### 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 - Documented the stable SSH exec interface contract, including exit statuses
and JSON field shapes for package tests, scripts, and future `tntctl` work. 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 - Added a public security policy, supported-version guidance, and GitHub issue
templates for bug reports and feature requests. templates for bug reports and feature requests.
- Added `tntctl`, a thin local wrapper around the documented SSH exec - Added `tntctl`, a thin local wrapper around the documented SSH exec
@ -17,8 +34,31 @@
the main onboarding, chat, help, history, search, private-message, nickname, the main onboarding, chat, help, history, search, private-message, nickname,
action-message, and exit paths. action-message, and exit paths.
- Added a VHS tape draft for recording the core TNT terminal-chat experience. - 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 ### 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 - `make install-systemd` now rewrites the installed unit's `ExecStart` to match
the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr` the selected `PREFIX`/`BINDIR`, so package builds that install to `/usr`
produce a unit pointing at `/usr/bin/tnt`. produce a unit pointing at `/usr/bin/tnt`.
@ -30,6 +70,9 @@
- The release guide now documents SemVer expectations, manual release review, - The release guide now documents SemVer expectations, manual release review,
smoke testing, and rollback steps. smoke testing, and rollback steps.
- Package installs now include `tntctl` and its man page alongside `tnt`. - 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 - SSH exec commands longer than the command buffer are now rejected with a
usage error instead of being truncated and executed. usage error instead of being truncated and executed.
- SSH exec `post` now persists the message before broadcasting or returning - 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 - 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 flush against the remote SSH window from that client's session loop. Exec
sessions still write synchronously to preserve script output ordering. 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 - Private-message inbox access now uses its own mutex instead of sharing the
SSH channel write lock, reducing unrelated contention on slow clients. SSH channel write lock, reducing unrelated contention on slow clients.
- Client writes now check the SSH channel's remote window before writing and - Client writes now check the SSH channel's remote window before writing and
@ -47,6 +112,8 @@
direct slow-reader blocking path. direct slow-reader blocking path.
- `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping - `make release-check` can now run the soak test with `RUN_SOAK=1`, keeping
longer runtime checks opt-in for local release validation. 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 - Room capacity and mention notification bookkeeping now follow
`TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit. `TNT_MAX_CONNECTIONS` instead of a hidden fixed 64-client array limit.
- Updated the roadmap to reflect completed `tntctl`, stable exec contract, and - Updated the roadmap to reflect completed `tntctl`, stable exec contract, and
@ -57,6 +124,27 @@
source release. source release.
- Release documentation now creates the local tag before strict release checks, - Release documentation now creates the local tag before strict release checks,
matching the strict gate's tag-at-HEAD requirement. 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 - 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 locale/code parsing, while `src/i18n_text.c` owns the table-driven text
catalog with coverage checks for every message ID. catalog with coverage checks for every message ID.
@ -71,10 +159,15 @@
- Refreshed contributor and development guidance so new commands are added - Refreshed contributor and development guidance so new commands are added
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
`ssh_server.c` / inline-`strcmp` instructions. `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, - `exec_catalog` now owns SSH exec command matching as well as help metadata,
reducing duplicate command knowledge in `src/exec.c`. reducing duplicate command knowledge in `src/exec.c`.
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so - Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
public documentation does not imply a specific production host. 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 - Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
`src/exec.c` no longer duplicates `--json` and required-message validation. `src/exec.c` no longer duplicates `--json` and required-message validation.
- Moved interactive command usage text and first-pass argument-shape checks - Moved interactive command usage text and first-pass argument-shape checks

View file

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

View file

@ -16,6 +16,9 @@ make release-check # release preflight
make test # unit + integration tests make test # unit + integration tests
make ci-test # local CI-equivalent checks make ci-test # local CI-equivalent checks
make stress-test # concurrent-client stress test 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 ## Debug
@ -37,6 +40,7 @@ make check
``` ```
main.c → entry point, signal handling main.c → entry point, signal handling
cli_text.c → startup CLI text 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 command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching, usage, and argument shape 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 ## Common Bugs to Avoid
1. Don't use `strtok()` on client data - use `strtok_r()` or copy first 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) 3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative)
4. UTF-8 chars are multi-byte - use utf8_* functions 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: Specific version:
```bash ```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 ## Manual Install
@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN
```bash ```bash
# Linux AMD64 # 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 chmod +x tnt-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt sudo mv tnt-linux-amd64 /usr/local/bin/tnt
# macOS ARM64 (Apple Silicon) # 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 chmod +x tnt-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt 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. 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 ## Firewall
```bash ```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 ### Key Design Principles
1. **Fixed-size buffers** - No dynamic allocation in hot paths 1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded
2. **Reader-writer locks** - Multiple readers, single writer 2. **Reader-writer locks** - Multiple readers, single writer for room state
3. **Reference counting** - Prevent use-after-free 3. **Per-client output ownership** - Each interactive session writes only to
4. **Ring buffer** - Fixed-size message history (last 100 messages) 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/ src/
├── main.c - CLI entry point and startup option parsing ├── 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 ├── ssh_server.c - SSH listener setup and connection accept loop
├── bootstrap.c - SSH authentication/session bootstrap ├── bootstrap.c - SSH authentication/session bootstrap
├── input.c - Interactive session loop and key handling ├── input.c - Interactive session loop and key handling
@ -76,8 +80,12 @@ src/
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries ├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec_catalog.c - SSH exec command matching and help metadata ├── exec_catalog.c - SSH exec command matching and help metadata
├── exec.c - SSH exec command dispatch ├── 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.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 ├── history_view.c - NORMAL-mode scroll window rules
├── tui.c - Terminal UI rendering (ANSI escape codes) ├── tui.c - Terminal UI rendering (ANSI escape codes)
├── tui_status.c - Mode/status/input-line rendering ├── tui_status.c - Mode/status/input-line rendering
@ -100,13 +108,20 @@ include/
├── bootstrap.h - SSH session bootstrap interface ├── bootstrap.h - SSH session bootstrap interface
├── chat_room.h - Chat room interface ├── chat_room.h - Chat room interface
├── message.h - Message structure and persistence ├── 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 ├── 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 ├── history_view.h - Scroll-state helpers
├── tui.h - TUI rendering functions ├── tui.h - TUI rendering functions
├── tui_status.h - TUI status/input-line rendering interface
├── i18n.h - Language and shared text IDs ├── i18n.h - Language and shared text IDs
├── help_text.h - Key reference text interface ├── help_text.h - Key reference text interface
├── manual.h - Concise manual panel interface ├── manual.h - Concise manual panel interface
├── manual_text.h - Concise manual text interface ├── manual_text.h - Concise manual text interface
├── system_message.h - Localized system message builders
├── ratelimit.h - Connection limit interface ├── ratelimit.h - Connection limit interface
└── utf8.h - UTF-8 utilities └── utf8.h - UTF-8 utilities
``` ```
@ -119,12 +134,16 @@ typedef struct client {
ssh_session session; ssh_session session;
ssh_channel channel; ssh_channel channel;
char username[MAX_USERNAME_LEN]; char username[MAX_USERNAME_LEN];
int width, height; // Terminal dimensions _Atomic int width, height; // Terminal dimensions
client_mode_t mode; // INSERT/NORMAL/COMMAND client_mode_t mode; // INSERT/NORMAL/COMMAND
int scroll_pos; 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 int ref_count; // Reference counting
pthread_mutex_t ref_lock; 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; } client_t;
``` ```
@ -134,6 +153,7 @@ typedef struct {
pthread_rwlock_t lock; // Reader-writer lock pthread_rwlock_t lock; // Reader-writer lock
struct client **clients; // Dynamic array struct client **clients; // Dynamic array
int client_count; int client_count;
uint64_t update_seq; // Bumped when message history changes
message_t *messages; // Ring buffer message_t *messages; // Ring buffer
int message_count; int message_count;
} chat_room_t; } 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 connection-limit-test # Verify per-IP concurrency and rate limits
make security-test # Run security feature checks make security-test # Run security feature checks
make stress-test # Run configurable concurrent-client stress test make stress-test # Run configurable concurrent-client stress test
make soak-test # Run idle/reconnect/control-plane soak test
make 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 make ci-test # Run the same checks as GitHub Actions
# Individual tests # Individual tests
@ -197,6 +220,9 @@ cd tests
./test_security_features.sh # Security checks ./test_security_features.sh # Security checks
./test_anonymous_access.sh # Anonymous access ./test_anonymous_access.sh # Anonymous access
./test_stress.sh # Concurrent connections ./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 ### Test Coverage
@ -205,6 +231,10 @@ cd tests
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection - **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
- **Anonymous**: Passwordless access, any username - **Anonymous**: Passwordless access, any username
- **Stress**: 10 concurrent clients for 30 seconds - **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) ### 2. Chat Room (chat_room.c)
**Thread-safe broadcasting:** **Thread-safe message publication:**
```c ```c
void room_broadcast(chat_room_t *room, const message_t *msg) { void room_broadcast(chat_room_t *room, const message_t *msg) {
pthread_rwlock_wrlock(&room->lock); pthread_rwlock_wrlock(&room->lock);
/* Copy client list with ref counting */ room_add_message(room, msg);
client_t **clients_copy = calloc(...); room->update_seq++;
for (int i = 0; i < count; i++) {
clients_copy[i]->ref_count++;
}
pthread_rwlock_unlock(&room->lock); // Release lock early pthread_rwlock_unlock(&room->lock);
/* Render outside lock (avoid deadlock) */
for (int i = 0; i < count; i++) {
tui_render_screen(clients_copy[i]);
client_release(clients_copy[i]);
}
} }
``` ```
**Why this works:** **Why this works:**
- Copy client list while holding write lock - Broadcast updates shared room state only; it does not render or write to
- Increment reference counts any SSH channel.
- Release lock BEFORE rendering - Each interactive session tracks `room_get_update_seq()` in its own
- Render to all clients outside lock `input_run_session()` loop.
- Decrement reference counts (may free clients) - 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) ### 3. Message Persistence (message.c)
See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record
contract.
**Log format:** **Log format:**
``` ```
2024-01-13T10:30:45Z|username|message content 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): **Optimized loading** (backward scan):
```c ```c
/* Scan backwards from file end */ /* Scan backwards from file end */
@ -380,9 +417,13 @@ void utf8_remove_last_word(char *str) {
```sh ```sh
tests/test_exec_mode.sh # exec command behavior tests/test_exec_mode.sh # exec command behavior
tests/test_interactive_input.sh # COMMAND-mode/TUI 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_i18n.c # localized shared text
tests/unit/test_command_catalog.c # interactive command metadata tests/unit/test_command_catalog.c # interactive command metadata
tests/unit/test_exec_catalog.c # exec command help 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 ### Adding a New Keybinding
@ -449,6 +490,10 @@ keys.
fragments. fragments.
- Keep placeholders visible and stable, for example `%s`, `%d`, - Keep placeholders visible and stable, for example `%s`, `%d`,
`<user>`, and `<message>`. `<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 - Every new user-facing string needs tests for at least English fallback
and Chinese output while this project has two UI languages. 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 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 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 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. storage instead of adding ad hoc branches for every locale.
Relevant conventions: Relevant conventions:

View file

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

View file

@ -3,25 +3,34 @@
This document defines the public surfaces that scripts, package tests, and This document defines the public surfaces that scripts, package tests, and
operators may rely on. operators may rely on.
TNT is still evolving toward a split `tntd` / `tntctl` model. The stable For 1.x, the public binary names are stable:
control surface is the SSH exec interface exposed by the `tnt` daemon.
`tntctl` is a thin wrapper around that same interface. - `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 ## Stability Scope
Stable: Stable:
- public binary names for 1.x: `tnt` and `tntctl`
- documented command-line flags in `tnt(1)` - documented command-line flags in `tnt(1)`
- documented environment variables in `tnt(1)` - documented environment variables in `tnt(1)`
- SSH exec command names and argument shapes listed below - SSH exec command names and argument shapes listed below
- SSH exec exit statuses - SSH exec exit statuses
- JSON field names and value types for documented `--json` commands - 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: Not yet stable:
- exact human-readable diagnostic wording - exact human-readable diagnostic wording
- interactive TUI layout - interactive TUI layout
- on-disk message log format - future storage migration tooling
- internal module names and helper functions - internal module names and helper functions
## Exit Status ## 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 stats --json
ssh -p 2222 chat.example.com users --json ssh -p 2222 chat.example.com users --json
ssh -p 2222 chat.example.com "tail -n 20" 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 operator@chat.example.com post "service notice"
``` ```
@ -55,6 +65,7 @@ The same commands can be run through `tntctl`:
```sh ```sh
tntctl chat.example.com health tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json 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 -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users 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 The current upper bound is `MAX_MESSAGES`. This command reads the live
in-memory room buffer, not the full persisted log. 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` ### `post MESSAGE`
Posts a message as the SSH login name and prints: 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 connection-limit-test per-IP concurrency/rate-limit checks
make security-test security feature checks make security-test security feature checks
make stress-test concurrent-client stress test 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 make ci-test same checks as GitHub Actions
DEBUG DEBUG
@ -43,9 +46,27 @@ INSERT MODE
limit 1023 bytes/message; over-limit input rings bell limit 1023 bytes/message; over-limit input rings bell
normal opens/follows latest; k/PgUp older, j/PgDn newer 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 STRUCTURE
src/main.c entry, signals src/main.c entry, signals
src/cli_text.c startup CLI text 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/command_catalog.c command metadata, usage, argument shape
src/ssh_server.c SSH listener and server setup src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap 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_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch src/exec.c SSH exec command dispatch
src/message.c persistence, search 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/history_view.c message viewport / scroll state
src/help_text.c full-screen key reference text src/help_text.c full-screen key reference text
src/manual.c concise manual panel rendering src/manual.c concise manual panel rendering

View file

@ -25,11 +25,12 @@ Goal: make TNT predictable for operators, scripts, and package maintainers.
- `stats` - `stats`
- `users` - `users`
- `tail` - `tail`
- `dump`
- `post` - `post`
- ✅ support text and JSON output modes where machine use is likely - ✅ support text and JSON output modes where machine use is likely
- ✅ normalize command parsing, help text, and error reporting - ✅ normalize command parsing, help text, and error reporting
- decide whether the server binary should remain `tnt` or split later into a - ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a
separate `tntd` daemon name major-version compatibility plan
- ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`, - ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`,
`--max-connections`, and related long options consistently `--max-connections`, and related long options consistently
- ✅ add man pages for `tnt` and `tntctl` - ✅ 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. 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 - ✅ remove cross-client SSH channel writes from mention and private-message
notifications notifications
- continue replacing ad hoc cross-thread UI mutation with per-client event - continue replacing ad hoc cross-thread UI mutation with per-client event
delivery delivery where new features need cross-client notifications
- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall - ✅ add bounded outbound queues so closed SSH windows cannot immediately stall
interactive output writes interactive output writes
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly - separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling - ✅ make room/client capacity fully runtime-configurable with no hidden
- document hard guarantees and soft limits compile-time ceiling
- ✅ document hard guarantees and soft limits
## Stage 3: Data and Persistence ## Stage 3: Data and Persistence
Goal: make stored history durable, inspectable, and recoverable. Goal: make stored history durable, inspectable, and recoverable.
- formalize the message log format and version it - formalize the message log v1 format
- keep timestamps in a timezone-safe format throughout write and replay - ✅ keep persisted timestamps in UTC throughout write and replay
- validate persisted UTF-8 and record structure before replay - validate persisted UTF-8 and record structure before replay/search
- add log rotation and compaction tooling - ✅ provide an inspection/export command for persisted records
- provide an offline inspection/export command - ✅ add log rotation and compaction tooling
- define recovery behavior for truncated or partially corrupted logs - ✅ define broader recovery tooling for truncated or partially corrupted logs
## Stage 4: Interactive UX ## Stage 4: Interactive UX
Goal: keep the interface efficient for terminal users without sacrificing simplicity. Goal: keep the interface efficient for terminal users without sacrificing simplicity.
- keep the current modal editing model, but make its behavior precise and documented - ✅ keep the current modal editing model precise and documented
- support resize, cursor movement, command history, and predictable paste behavior - ✅ 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: - add useful chat commands with clear semantics:
- ✅ `:nick` / `:name` — nickname change with broadcast - ✅ `:nick` / `:name` — nickname change with broadcast
- ✅ `/me` — action messages - ✅ `/me` — action messages
- ✅ `:last N` — show last N messages from disk history - ✅ `:last N` — show last N messages from disk history
- ✅ `:search <keyword>` — case-insensitive full-text search - ✅ `:search <keyword>` — case-insensitive full-text search
- ✅ `:mute-joins` — per-client join/leave notification toggle - ✅ `:mute-joins` — per-client join/leave notification toggle
- improve discoverability of NORMAL and COMMAND mode actions - improve discoverability of NORMAL and COMMAND mode actions
- make status lines and help output concise enough for small terminals - make status lines and help output concise enough for small terminals
## Stage 5: Operations and Security ## Stage 5: Operations and Security
Goal: make public deployment manageable. 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 - add admin-only controls for read-only mode, mute, and ban
- ✅ expose a minimal health and stats surface suitable for monitoring - ✅ expose a minimal health and stats surface suitable for monitoring
- support systemd-friendly readiness and watchdog behavior - 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 - tighten CI around authentication, limits, and restart behavior
## Stage 6: Release Quality ## 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 sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing
- ✅ add a configurable soak test for idle sessions, reconnects, and control - ✅ add a configurable soak test for idle sessions, reconnects, and control
interface availability interface availability
- add deeper slow-client soak coverage with a deliberately backpressured SSH - add deeper slow-client coverage with a deliberately backpressured SSH
client 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 - keep deployment and test docs aligned with actual runtime behavior
- require every user-visible interface change to update docs and tests in the same change set - require every user-visible interface change to update docs and tests in the same change set
@ -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. 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 1. Replace remaining source-archive checksum placeholders only after the final
introduce `tntd` later with a compatibility plan. GitHub source archive exists, then run `make package-publish-check`.
2. Finish untangling client-state ownership into a clearer release path. 2. Create or move the `vX.Y.Z` tag only when the release commit is final, then
3. Add deeper slow-client soak coverage with a deliberately backpressured SSH run `make release-check-strict` before pushing it.
client. 3. Decide whether admin-only moderation controls belong in 1.0.x or should
4. Replace remaining release placeholders with real maintainer metadata and wait for a later minor release.
source-archive checksums when cutting a public package 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. 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. 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. 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`, 7. User searches from NORMAL with `/term`, or uses commands when needed:
`:search`, `:nick`, `:mute-joins`, and `:q`. `:users`, `:msg`, `:inbox`, `:last`, `:search`, `:nick`, `:mute-joins`,
and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`, 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 ## 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. - 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 - 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. 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 - `:help` is a compact manual, while `?` is a full key reference. Do not add
parallel support commands for the same task. parallel support commands for the same task.
- Command syntax stays ASCII even in localized UI text. Translations explain; - Command syntax stays ASCII even in localized UI text. Translations explain;
they do not change the command language. they do not change the command language.
- Private messages are visible only in the recipient inbox and are not written - Private messages are visible only in the recipient inbox and are not written
to `messages.log`. 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 - Long command output uses a small pager so `:last` and `:search` are readable
on small terminals. on small terminals.
@ -41,7 +49,8 @@ The product path should stay short:
`:last` and `:search` `:last` and `:search`
- first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends - first user toggles `:mute-joins`, sends `:msg`, changes nickname, sends
`/me`, and exits `/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 - exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content - `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 *program_name, ui_lang_t lang);
const char *cli_text_invalid_port_format(ui_lang_t lang); const char *cli_text_invalid_port_format(ui_lang_t lang);
const char *cli_text_invalid_value_format(ui_lang_t lang); const char *cli_text_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_unknown_option_format(ui_lang_t lang);
const char *cli_text_short_usage_format(ui_lang_t lang); const char *cli_text_short_usage_format(ui_lang_t lang);

View file

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

View file

@ -19,4 +19,9 @@
* path; callers must not hold client->io_lock before dispatching. */ * path; callers must not hold client->io_lock before dispatching. */
void commands_dispatch(client_t *client); 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 */ #endif /* COMMANDS_H */

View file

@ -11,6 +11,8 @@
#include <limits.h> #include <limits.h>
#include <pthread.h> #include <pthread.h>
#include "config_defaults.h"
/* Project Metadata */ /* Project Metadata */
#define TNT_VERSION "1.0.1" #define TNT_VERSION "1.0.1"
@ -23,7 +25,6 @@
#define TNT_EXIT_CONFIG 78 #define TNT_EXIT_CONFIG 78
/* Configuration constants */ /* Configuration constants */
#define DEFAULT_PORT 2222
#define MAX_MESSAGES 100 #define MAX_MESSAGES 100
#define MAX_USERNAME_LEN 64 #define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024 #define MAX_MESSAGE_LEN 1024
@ -31,13 +32,17 @@
#define MAX_COMMAND_OUTPUT_LEN 8192 #define MAX_COMMAND_OUTPUT_LEN 8192
#define CLIENT_OUTBOX_CAPACITY (128 * 1024) #define CLIENT_OUTBOX_CAPACITY (128 * 1024)
#define CLIENT_OUTBOX_FLUSH_BUDGET 32768 #define CLIENT_OUTBOX_FLUSH_BUDGET 32768
#define DEFAULT_MAX_CLIENTS 64
#define MAX_CONFIGURED_CLIENTS 1024
#define LOG_FILE "messages.log" #define LOG_FILE "messages.log"
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */ #define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
#define HOST_KEY_FILE "host_key" #define HOST_KEY_FILE "host_key"
#define TNT_DEFAULT_STATE_DIR "." #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 */ /* ANSI color codes */
#define ANSI_RESET "\033[0m" #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_USERS,
TNT_EXEC_COMMAND_STATS, TNT_EXEC_COMMAND_STATS,
TNT_EXEC_COMMAND_TAIL, TNT_EXEC_COMMAND_TAIL,
TNT_EXEC_COMMAND_DUMP,
TNT_EXEC_COMMAND_POST, TNT_EXEC_COMMAND_POST,
TNT_EXEC_COMMAND_EXIT TNT_EXEC_COMMAND_EXIT,
TNT_EXEC_COMMAND_COUNT
} tnt_exec_command_id_t; } tnt_exec_command_id_t;
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id, 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); 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, void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang); 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, void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang); tnt_exec_command_id_t id, ui_lang_t lang);

View file

@ -7,8 +7,12 @@ typedef struct {
const char *text[UI_LANG_COUNT]; const char *text[UI_LANG_COUNT];
} i18n_string_t; } 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) \ #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 { typedef enum {
I18N_USERNAME_PROMPT, I18N_USERNAME_PROMPT,
@ -25,6 +29,7 @@ typedef enum {
I18N_HELP_STATUS_FORMAT, I18N_HELP_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_TITLE, I18N_COMMAND_OUTPUT_TITLE,
I18N_COMMAND_OUTPUT_STATUS_FORMAT, I18N_COMMAND_OUTPUT_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
I18N_MOTD_TITLE, I18N_MOTD_TITLE,
I18N_MOTD_CONTINUE_HINT, I18N_MOTD_CONTINUE_HINT,
I18N_TITLE_ONLINE_FORMAT, 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. */ * 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); 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 */ #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]; char content[MAX_MESSAGE_LEN];
} whisper_t; } whisper_t;
typedef enum {
TNT_COMMAND_OUTPUT_NONE,
TNT_COMMAND_OUTPUT_GENERIC,
TNT_COMMAND_OUTPUT_INBOX
} tnt_command_output_kind_t;
/* Client connection structure */ /* Client connection structure */
typedef struct client { typedef struct client {
ssh_session session; /* SSH session */ ssh_session session; /* SSH session */
@ -42,6 +48,7 @@ typedef struct client {
int insert_history_pos; int insert_history_pos;
char command_output[MAX_COMMAND_OUTPUT_LEN]; char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll; int command_output_scroll;
tnt_command_output_kind_t command_output_kind;
bool show_motd; /* command_output holds MOTD text */ bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN]; char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long; bool exec_command_too_long;
@ -67,6 +74,7 @@ typedef struct client {
pthread_mutex_t ref_lock; /* Lock for ref_count */ pthread_mutex_t ref_lock; /* Lock for ref_count */
pthread_mutex_t io_lock; /* Serialize SSH channel writes */ pthread_mutex_t io_lock; /* Serialize SSH channel writes */
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */ 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; struct ssh_channel_callbacks_struct *channel_cb;
} client_t; } 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 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 curl
need_cmd awk need_cmd awk
@ -53,6 +81,7 @@ echo "OS: $OS"
echo "Arch: $ARCH" echo "Arch: $ARCH"
echo "Version: $VERSION" echo "Version: $VERSION"
echo "" echo ""
warn_missing_libssh
# Get latest version if not specified # Get latest version if not specified
if [ "$VERSION" = "latest" ]; then 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. 1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
Also update package versions in Arch, Homebrew, and Debian drafts. 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. 3. Build and upload release tarballs or rely on GitHub source archives.
4. Replace placeholder checksums in package drafts. 4. Replace placeholder checksums in package drafts.
5. Verify package contents in an isolated directory: 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 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 ```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. - Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible. - 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. - Ubuntu: build Debian source packages and upload to a Launchpad PPA.

View file

@ -10,6 +10,8 @@ pkgbase = tnt-chat
makedepends = make makedepends = make
depends = libssh 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-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 = SKIP
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
pkgname = tnt-chat pkgname = tnt-chat

View file

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

View file

@ -26,11 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`:
makepkg --printsrcinfo > .SRCINFO makepkg --printsrcinfo > .SRCINFO
``` ```
Before AUR submission, replace `sha256sums=('SKIP')` with the real release Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub
archive checksum, then run the project-level strict check: source archive checksum, regenerate `.SRCINFO`, then run the package publish
check:
```sh ```sh
make release-check-strict SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
``` ```
## Manual AUR submission ## 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/ cp PKGBUILD .SRCINFO aur-tnt-chat/
cd aur-tnt-chat cd aur-tnt-chat
git add PKGBUILD .SRCINFO git add PKGBUILD .SRCINFO
git commit -m "Update to 1.0.1" git commit -m "Update to X.Y.Z"
git push 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 ## Draft metadata
The `debian/` directory in this folder is a packaging draft. To test it against The `debian/` directory in this folder is a packaging draft. To assemble it
an upstream release tree, copy it to the root of a clean source checkout: against a clean source tree:
```sh ```sh
cp -a packaging/debian/debian ./debian make debian-source-package
dpkg-buildpackage -us -uc
``` ```
For PPA uploads, build a signed source package instead: For PPA uploads, build a source package on Debian/Ubuntu:
```sh ```sh
debuild -S scripts/package_debian_source.sh --build
``` ```
## Recommended path ## Recommended path
@ -47,3 +46,5 @@ debuild -S
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl` - Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
- Runtime dependency: `libssh` - Runtime dependency: `libssh`
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service` - 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. * 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 Source: tnt-chat
Section: net Section: net
Priority: optional Priority: optional
Maintainer: M1ng <REPLACE_WITH_EMAIL> Maintainer: M1ng <contact@m1ng.space>
Build-Depends: Build-Depends:
debhelper-compat (= 13), debhelper-compat (= 13),
libssh-dev, libssh-dev,
@ -15,7 +15,8 @@ Package: tnt-chat
Architecture: any Architecture: any
Depends: Depends:
${misc:Depends}, ${misc:Depends},
${shlibs:Depends} ${shlibs:Depends},
adduser
Description: SSH-native terminal chat server Description: SSH-native terminal chat server
TNT is a minimalist terminal chat server accessed over SSH. It provides a TNT is a minimalist terminal chat server accessed over SSH. It provides a
Vim-style terminal interface, anonymous access by default, persistent message 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 ```sh
brew tap m1ngsama/tnt brew tap m1ngsama/tnt
brew install tnt-chat brew install tnt-chat
brew services start tnt-chat
``` ```
Homebrew core should wait until TNT has stable releases and broader usage. 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 audit --strict --online tnt-chat
brew install --build-from-source ./Formula/tnt-chat.rb brew install --build-from-source ./Formula/tnt-chat.rb
brew test tnt-chat brew test tnt-chat
brew services run tnt-chat
``` ```
For local syntax-only validation from this repository: For local syntax-only validation from this repository:
@ -28,20 +30,20 @@ ruby -c packaging/homebrew/tnt-chat.rb
## Updating the formula ## 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: 2. Download or hash the release source archive:
```sh ```sh
curl -L -o 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/v1.0.1.tar.gz https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz
shasum -a 256 tnt-chat-1.0.1.tar.gz shasum -a 256 dist/tnt-chat-vX.Y.Z.tar.gz
``` ```
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`. 3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
4. Run: 4. Run:
```sh ```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. 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" 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/tnt.1"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.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 end
test do 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 #!/bin/sh
# TNT Log Rotation Script # Compact and archive a TNT messages.log file.
# Keeps chat history manageable and prevents disk space issues #
# 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}" set -eu
MAX_SIZE_MB="${2:-100}"
KEEP_LINES="${3:-10000}"
# Check if log file exists DRY_RUN=0
if [ ! -f "$LOG_FILE" ]; then KEEP_ARCHIVES=5
echo "Log file $LOG_FILE does not exist"
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 exit 0
fi fi
[ -f "$LOG_FILE" ] || fail "$LOG_FILE is not a regular file"
# Get file size in MB MAX_BYTES=$((MAX_SIZE_MB * 1024 * 1024))
FILE_SIZE=$(du -m "$LOG_FILE" | cut -f1) FILE_SIZE=$(wc -c < "$LOG_FILE" | tr -d ' ')
[ -n "$FILE_SIZE" ] || fail "could not read log size"
# Rotate if file is too large compact_log() {
if [ "$FILE_SIZE" -gt "$MAX_SIZE_MB" ]; then timestamp=$(date -u +%Y%m%dT%H%M%SZ)
echo "Log file size: ${FILE_SIZE}MB, rotating..." backup="${LOG_FILE}.${timestamp}"
suffix=1
# Create backup while [ -e "$backup" ] || [ -e "${backup}.gz" ]; do
BACKUP="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)" backup="${LOG_FILE}.${timestamp}.${suffix}"
cp "$LOG_FILE" "$BACKUP" suffix=$((suffix + 1))
done
# Keep only last N lines if [ "$DRY_RUN" -eq 1 ]; then
tail -n "$KEEP_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp" echo "logrotate: would archive $LOG_FILE to $backup"
mv "${LOG_FILE}.tmp" "$LOG_FILE" echo "logrotate: would keep last $KEEP_LINES lines"
return 0
fi
# Compress old backup tmp="${LOG_FILE}.tmp.$$"
gzip "$BACKUP" 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 rotated. Backup: ${BACKUP}.gz" if command -v gzip >/dev/null 2>&1; then
echo "Kept last $KEEP_LINES lines" 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 else
echo "Log file size: ${FILE_SIZE}MB (under ${MAX_SIZE_MB}MB limit)" echo "logrotate: size ${FILE_SIZE} bytes is within ${MAX_BYTES} bytes"
fi fi
# Clean up old compressed logs (keep last 5) cleanup_archives
LOG_DIR=$(dirname "$LOG_FILE") echo "logrotate: complete"
cd "$LOG_DIR" || exit
ls -t messages.log.*.gz 2>/dev/null | tail -n +6 | xargs rm -f 2>/dev/null
echo "Log rotation 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 - version metadata alignment
- clean build - clean build
- unit tests - unit tests
- script tests
- staged install layout with PREFIX=/usr and DESTDIR - staged install layout with PREFIX=/usr and DESTDIR
- installer shell syntax - installer shell syntax
- Debian packaging metadata - Debian packaging metadata
@ -21,11 +22,13 @@ Default checks:
Environment: Environment:
RUN_INTEGRATION=1 also run full make test RUN_INTEGRATION=1 also run full make test
RUN_SOAK=1 also run the configurable soak test 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 PORT=12720 base port for integration tests
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
matching changelog release section, real package checksums, and non-placeholder matching changelog release section, non-placeholder maintainer metadata, and a
maintainer metadata, then build from the tagged source archive. build from the tagged source archive. Run `make package-publish-check` after
the final GitHub source archive exists to verify package checksums.
USAGE USAGE
} }
@ -101,6 +104,9 @@ step "running unit tests"
make -C tests/unit clean make -C tests/unit clean
make -C tests/unit run make -C tests/unit run
step "running script tests"
make script-test
step "checking client I/O ownership boundaries" step "checking client I/O ownership boundaries"
! grep -R "client_send(target" src include >/dev/null || ! grep -R "client_send(target" src include >/dev/null ||
fail "cross-client target writes must be queued through client_queue_bell" 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" 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 || ! 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" 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 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" fail "raw SSH channel writes must stay inside src/client.c"
fi fi
@ -123,6 +133,13 @@ if [ "${RUN_SOAK:-0}" = "1" ]; then
DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}" DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}"
fi 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") tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
cleanup() { cleanup() {
rm -rf "$tmpdir" 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" || grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
fail "systemd unit ExecStart does not match PREFIX=/usr install path" 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" step "checking installer syntax"
sh -n install.sh 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" step "checking Debian packaging metadata"
[ -x packaging/debian/debian/rules ] || [ -x packaging/debian/debian/rules ] ||
fail "packaging/debian/debian/rules must be executable" 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 || grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
fail "unsupported 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" step "checking packaging syntax"
if command -v bash >/dev/null 2>&1; then 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" fail "local tag v$version does not point at HEAD"
grep -q "^## $version " docs/CHANGELOG.md || grep -q "^## $version " docs/CHANGELOG.md ||
fail "docs/CHANGELOG.md does not contain a release section for $version" 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 || ! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
fail "replace maintainer email placeholders before strict release" 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; 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) { if (client_install_channel_callbacks(client) < 0) {
/* Nullify session/channel ownership so client_release won't /* Nullify session/channel ownership so client_release won't
* double-free what cleanup_failed_session is about to free. */ * double-free what cleanup_failed_session is about to free. */
client->session = NULL; client->session = NULL;
client->channel = NULL; client->channel = NULL;
client_release(client); /* drop the callback ref (2 → 1) */ client_release(client);
client_release(client); /* drop the main ref (1 → 0, frees client) */
cleanup_failed_session(session, ctx); cleanup_failed_session(session, ctx);
return NULL; return NULL;
} }

View file

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

View file

@ -1,5 +1,6 @@
#include "cli_text.h" #include "cli_text.h"
#include "config_defaults.h"
#include "i18n.h" #include "i18n.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos, void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
@ -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" " -d, --state-dir DIR Store host key and logs in DIR\n"
" --bind ADDR Bind to ADDR (default: 0.0.0.0)\n" " --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
" --public-host HOST Show HOST in startup connection hints\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-per-ip N Per-IP concurrent session limit\n"
" --max-conn-rate-per-ip N Per-IP connection-rate limit\n" " --max-conn-rate-per-ip N Per-IP connection-rate limit\n"
" --rate-limit 0|1 Disable/enable rate-based blocking\n" " --rate-limit 0|1 Disable/enable rate-based blocking\n"
" --idle-timeout SECONDS Idle disconnect timeout\n" " --idle-timeout SECONDS Idle disconnect timeout\n"
" --ssh-log-level LEVEL libssh log level 0..4\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" " -V, --version Show version\n"
" -h, --help Show this help\n" " -h, --help Show this help\n"
"\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_STATE_DIR State directory\n"
" TNT_ACCESS_TOKEN Require this password for SSH auth\n" " TNT_ACCESS_TOKEN Require this password for SSH auth\n"
" TNT_LANG UI language: en or zh (default: locale)\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_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" "tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n" "用法: %s [options]\n\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" " -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
" --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n" " --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n"
" --public-host HOST 在启动提示中显示 HOST\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-per-ip N 单 IP 并发会话限制\n"
" --max-conn-rate-per-ip N 单 IP 连接速率限制\n" " --max-conn-rate-per-ip N 单 IP 连接速率限制\n"
" --rate-limit 0|1 禁用/启用速率封禁\n" " --rate-limit 0|1 禁用/启用速率封禁\n"
" --idle-timeout SECONDS 空闲断开时间\n" " --idle-timeout SECONDS 空闲断开时间\n"
" --ssh-log-level LEVEL libssh 日志级别 0..4\n" " --ssh-log-level LEVEL libssh 日志级别 0..4\n"
" --log-check FILE 检查 messages.log v1 记录\n"
" --log-recover FILE 将有效记录写入 stdout\n"
" -V, --version 显示版本\n" " -V, --version 显示版本\n"
" -h, --help 显示此帮助\n" " -h, --help 显示此帮助\n"
"\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_STATE_DIR 状态目录\n"
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n" " TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n" " TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n" " TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\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') const char *program = (program_name && program_name[0] != '\0')
? program_name ? program_name
: "tnt"; : "tnt";
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang), 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) { 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); 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) { const char *cli_text_unknown_option_format(ui_lang_t lang) {
static const i18n_string_t text = static const i18n_string_t text =
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n"); I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");

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 */ /* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...) { int client_printf(client_t *client, const char *fmt, ...) {
char buffer[2048]; char buffer[2048];
@ -315,8 +334,13 @@ int client_install_channel_callbacks(client_t *client) {
return -1; return -1;
} }
client_addref(client);
client->channel_callback_ref = true;
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct)); client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!client->channel_cb) { if (!client->channel_cb) {
client->channel_callback_ref = false;
client_release(client);
return -1; 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) { if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
free(client->channel_cb); free(client->channel_cb);
client->channel_cb = NULL; client->channel_cb = NULL;
client->channel_callback_ref = false;
client_release(client);
return -1; 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); 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) { void commands_dispatch(client_t *client) {
char cmd_buf[256]; char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1); strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0'; cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf; char *cmd = cmd_buf;
char output[MAX_COMMAND_OUTPUT_LEN] = {0}; char output[MAX_COMMAND_OUTPUT_LEN] = {0};
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
size_t pos = 0; size_t pos = 0;
/* Trim whitespace */ /* Trim whitespace */
@ -70,6 +118,10 @@ void commands_dispatch(client_t *client) {
end--; end--;
} }
} }
if (cmd[0] == ':') {
cmd++;
while (*cmd == ' ') cmd++;
}
/* Save to command history */ /* Save to command history */
if (cmd[0] != '\0') { if (cmd[0] != '\0') {
@ -219,9 +271,9 @@ void commands_dispatch(client_t *client) {
snprintf(target->whisper_inbox[slot].content, snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content), sizeof(target->whisper_inbox[slot].content),
"%s", rest); "%s", rest);
target->unread_whispers++;
pthread_mutex_unlock(&target->whisper_lock); pthread_mutex_unlock(&target->whisper_lock);
target->unread_whispers++;
/* Audible nudge — the title bar ✉ counter (UX-11 style) /* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */ * carries the persistent signal. */
client_queue_bell(target); client_queue_bell(target);
@ -242,35 +294,8 @@ void commands_dispatch(client_t *client) {
} }
} else if (command_id == TNT_COMMAND_INBOX) { } else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under whisper_lock so a concurrent sender doesn't output_kind = TNT_COMMAND_OUTPUT_INBOX;
* tear what we're rendering. Counter reset happens after copy. */ append_inbox_output(client, output, sizeof(output), &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));
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);
}
} else if (command_id == TNT_COMMAND_NICK) { } else if (command_id == TNT_COMMAND_NICK) {
const char *new_name = arg; const char *new_name = arg;
@ -414,6 +439,7 @@ void commands_dispatch(client_t *client) {
cmd_done: cmd_done:
snprintf(client->command_output, sizeof(client->command_output), "%s", output); snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = output_kind;
client->command_input[0] = '\0'; client->command_input[0] = '\0';
tui_render_command_output(client); 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; 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) { static int exec_command_tail(client_t *client, const char *args) {
int requested = 20; int requested = 20;
int total_messages; int total_messages;
@ -347,6 +386,27 @@ static int exec_command_tail(client_t *client, const char *args) {
return rc; 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) { static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN]; char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN]; char username[MAX_USERNAME_LEN];
@ -451,10 +511,14 @@ int exec_dispatch(client_t *client) {
return exec_command_stats(client, args != NULL); return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL: case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args); return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_DUMP:
return exec_command_dump(client, args);
case TNT_EXEC_COMMAND_POST: case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args); return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT: case TNT_EXEC_COMMAND_EXIT:
return TNT_EXIT_OK; 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", "tail -n N", "tail [N] | tail -n N",
I18N_STRING("Print recent messages", "输出最近消息"), I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false}, 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, {TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "post MESSAGE", "post MESSAGE", "post MESSAGE",
I18N_STRING("Post a message non-interactively", "非交互发送消息"), 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, void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang) { tnt_exec_command_id_t id, ui_lang_t lang) {
const exec_catalog_entry_t *entry = entry_for_id(id); 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" " Backspace - Delete character\n"
" Ctrl+W - Delete last word\n" " Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n" " Ctrl+U - Delete line\n"
" Up/Down - Recall sent messages\n"
" Tab - Complete @mention\n"
" Ctrl+C - Enter NORMAL mode\n" " Ctrl+C - Enter NORMAL mode\n"
"\n" "\n"
"NORMAL MODE KEYS:\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" " Follows latest until you scroll up\n"
" i - Return to INSERT mode\n" " i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n" " : - Enter COMMAND mode\n"
" / - Search message history\n"
" j/k - Scroll down/up one line\n" " j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n" " Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full 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" " Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n" " Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n" " Ctrl+U - 删除整行\n"
" Up/Down - 调出已发送消息\n"
" Tab - 补全 @mention\n"
" Ctrl+C - 进入 NORMAL 模式\n" " Ctrl+C - 进入 NORMAL 模式\n"
"\n" "\n"
"NORMAL 模式按键:\n" "NORMAL 模式按键:\n"
@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
" 未向上翻阅时自动跟随最新消息\n" " 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n" " i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n" " : - 进入 COMMAND 模式\n"
" / - 搜索消息历史\n"
" j/k - 向下/上滚动一行\n" " j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
@ -71,10 +77,14 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
"\n" "\n"
"COMMAND OUTPUT KEYS:\n" "COMMAND OUTPUT KEYS:\n"
" q, ESC - Close output\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+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full 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" " g/G - Jump to top/bottom\n"
" r - Refresh live output (:inbox)\n"
"\n" "\n"
"SPECIAL MESSAGES:\n" "SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\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" "\n"
"HELP SCREEN KEYS:\n" "HELP SCREEN KEYS:\n"
" q, ESC - Close help\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+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full 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" " g/G - Jump to top/bottom\n"
" l - Cycle UI language\n", " l - Cycle UI language\n",
"\n" "\n"
"命令输出按键:\n" "命令输出按键:\n"
" q, ESC - 关闭输出\n" " q, ESC - 关闭输出\n"
" j/k - 向下/上滚动\n" " j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n" " g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n"
"\n" "\n"
"特殊消息:\n" "特殊消息:\n"
" /me <action> - 发送动作 (如 /me waves)\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"
"帮助界面按键:\n" "帮助界面按键:\n"
" q, ESC - 关闭帮助\n" " q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n" " j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n" " Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n" " Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n" " g/G - 跳到顶部/底部\n"
" l - 切换界面语言\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" "TNT %s - SSH 匿名聊天室\r\n\r\n"
), ),
[I18N_INSERT_HINT_WIDE] = I18N_STRING( [I18N_INSERT_HINT_WIDE] = I18N_STRING(
"Enter send · Esc browse · :help", "Enter send · Esc NORMAL",
"Enter 发送 · Esc 浏览 · :help" "Enter 发送 · Esc NORMAL"
), ),
[I18N_INSERT_HINT_NARROW] = I18N_STRING( [I18N_INSERT_HINT_NARROW] = I18N_STRING(
"Enter · Esc · :help", "Enter · Esc",
"Enter · Esc · :help" "Enter · Esc"
), ),
[I18N_NORMAL_LATEST] = I18N_STRING( [I18N_NORMAL_LATEST] = I18N_STRING(
"G latest", "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", "-- 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:关闭" "-- 命令输出 -- (%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( [I18N_MOTD_TITLE] = I18N_STRING(
" NOTICE ", " NOTICE ",
" 公告 " " 公告 "
@ -138,8 +142,8 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
"--- 最近 %d 条消息 ---\n" "--- 最近 %d 条消息 ---\n"
), ),
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING( [I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
"--- Search: \"%s\" (%d match(es)) ---\n", "--- Search: \"%s\" (showing last %d match(es)) ---\n",
"--- 搜索: \"%s\" (%d 条匹配) ---\n" "--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
), ),
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING( [I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
"Join/leave notifications: %s\n", "Join/leave notifications: %s\n",

View file

@ -2,6 +2,7 @@
#include "chat_room.h" #include "chat_room.h"
#include "client.h" #include "client.h"
#include "commands.h" #include "commands.h"
#include "config_defaults.h"
#include "common.h" #include "common.h"
#include "exec.h" #include "exec.h"
#include "history_view.h" #include "history_view.h"
@ -20,11 +21,11 @@
#include <string.h> #include <string.h>
#include <time.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; static ui_lang_t g_default_ui_lang = UI_LANG_EN;
void input_init(void) { 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(); 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}; char username[MAX_USERNAME_LEN] = {0};
int pos = 0; int pos = 0;
char buf[4]; char buf[4];
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
tui_render_welcome(client); tui_render_welcome(client);
client_printf(client, "%s", i18n_text(client->ui_lang, client_printf(client, "%s", prompt);
I18N_USERNAME_PROMPT));
while (1) { while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */ 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') { if (b == '\r' || b == '\n') {
break; 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 */ } else if (b == 127 || b == 8) { /* Backspace */
if (pos > 0) { if (pos > 0) {
/* Compute width of the last character before removing it */ /* 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; was_motd = client->show_motd;
client->command_output[0] = '\0'; client->command_output[0] = '\0';
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = false; client->show_motd = false;
client->mode = MODE_NORMAL;
if (was_motd) { if (was_motd) {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
normal_scroll_to_latest(client); normal_scroll_to_latest(client);
} else {
client->mode = MODE_NORMAL;
} }
tui_render_screen(client); 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 /* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */ * (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) { static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle Ctrl+C (Exit or switch to NORMAL) */ /* Handle Ctrl+C (Exit or switch to NORMAL) */
if (key == 3) { if (key == 3) {
client_mode_t previous_mode = client->mode; 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') { if (client->command_output[0] != '\0') {
dismiss_command_output(client); dismiss_command_output(client);
return true; return true;
@ -256,44 +383,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle help screen */ /* Handle help screen */
if (client->show_help) { if (client->show_help) {
/* Page size: roughly the visible help body region. */ pager_action_t action;
int page = client->height - 2;
if (page < 1) page = 1;
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q' || key == 27) { if (key == 'l' || key == 'L') {
client->show_help = false;
tui_render_screen(client);
} else if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang); client->ui_lang = i18n_next_ui_lang(client->ui_lang);
client->help_scroll_pos = 0; client->help_scroll_pos = 0;
tui_render_help(client); tui_render_help(client);
} else if (key == 'j') { return true;
client->help_scroll_pos++; }
tui_render_help(client);
} else if (key == 'k' && client->help_scroll_pos > 0) { action = pager_apply_key(client, key, &client->help_scroll_pos, false);
client->help_scroll_pos--; if (action == PAGER_ACTION_CLOSE) {
tui_render_help(client); client->show_help = false;
} else if (key == 4) { /* Ctrl+D: half page down */ tui_render_screen(client);
client->help_scroll_pos += half; } else if (action == PAGER_ACTION_SCROLL) {
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 */
tui_render_help(client); tui_render_help(client);
} }
return true; /* Key consumed */ 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; /* Handle command output / MOTD display. MOTD remains a simple notice;
* command output behaves like a small pager so long results can be read. */ * command output behaves like a small pager so long results can be read. */
if (client->command_output[0] != '\0') { if (client->command_output[0] != '\0') {
int page = client->height - 2; pager_action_t action;
int half;
if (client->show_motd) { if (client->show_motd) {
dismiss_command_output(client); dismiss_command_output(client);
return true; return true;
} }
if (page < 1) page = 1; action = pager_apply_key(client, key, &client->command_output_scroll,
half = page / 2; true);
if (half < 1) half = 1; if (action == PAGER_ACTION_CLOSE) {
if (key == 'q' || key == 27) {
dismiss_command_output(client); dismiss_command_output(client);
} else if (key == 'j') { } else if (action == PAGER_ACTION_SCROLL) {
client->command_output_scroll++;
tui_render_command_output(client); tui_render_command_output(client);
} else if (key == 'k') { } else if (action == PAGER_ACTION_REFRESH) {
client->command_output_scroll--; if (commands_refresh_active_output(client)) {
if (client->command_output_scroll < 0) { tui_render_command_output(client);
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 */ 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'; client->command_input[0] = '\0';
tui_render_screen(client); tui_render_screen(client);
return true; 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') { } else if (key == 'j') {
normal_scroll_by(client, 1); normal_scroll_by(client, 1);
tui_render_screen(client); tui_render_screen(client);
@ -735,6 +814,7 @@ void input_run_session(client_t *client) {
client->command_history_count = 0; client->command_history_count = 0;
client->command_history_pos = 0; client->command_history_pos = 0;
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->connect_time = time(NULL); client->connect_time = time(NULL);
client->last_active = time(NULL); client->last_active = time(NULL);
@ -788,6 +868,7 @@ void input_run_session(client_t *client) {
sizeof(client->command_output), sizeof(client->command_output),
"%s", motd_buf); "%s", motd_buf);
client->command_output_scroll = 0; client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = true; client->show_motd = true;
tui_render_motd(client); tui_render_motd(client);
seen_update_seq = room_get_update_seq(g_room); seen_update_seq = room_get_update_seq(g_room);
@ -836,6 +917,13 @@ main_loop:
room_updated = true; 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 || if (client->redraw_pending ||
(room_updated && !client->show_help && (room_updated && !client->show_help &&
client->command_output[0] == '\0')) { client->command_output[0] == '\0')) {
@ -940,6 +1028,8 @@ main_loop:
client->command_input[len] = b; client->command_input[len] = b;
client->command_input[len + 1] = '\0'; client->command_input[len + 1] = '\0';
tui_render_screen(client); tui_render_screen(client);
} else {
client_send(client, "\a", 1);
} }
} else if (b >= 128) { /* UTF-8 multi-byte */ } else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b); int char_len = utf8_byte_length(b);
@ -952,10 +1042,12 @@ main_loop:
} }
if (!utf8_is_valid_sequence(buf, char_len)) continue; if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input); 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); memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0'; client->command_input[len + char_len] = '\0';
tui_render_screen(client); tui_render_screen(client);
} else {
client_send(client, "\a", 1);
} }
} }
} }
@ -982,17 +1074,7 @@ cleanup:
ratelimit_release_ip(client->client_ip); ratelimit_release_ip(client->client_ip);
/* Remove channel callbacks before releasing refs to prevent use-after-free client_release_session(client);
* 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);
/* Decrement connection count */ /* Decrement connection count */
ratelimit_decrement_total(); ratelimit_decrement_total();

View file

@ -1,8 +1,10 @@
#include "chat_room.h" #include "chat_room.h"
#include "cli_text.h" #include "cli_text.h"
#include "config_defaults.h"
#include "common.h" #include "common.h"
#include "i18n.h" #include "i18n.h"
#include "message.h" #include "message.h"
#include "message_log_tool.h"
#include "ssh_server.h" #include "ssh_server.h"
#include <signal.h> #include <signal.h>
#include <unistd.h> #include <unistd.h>
@ -18,24 +20,6 @@ static void signal_handler(int sig) {
_exit(0); _exit(0);
} }
static bool parse_int_arg(const char *value, int min_val, int max_val,
int *out) {
char *end = NULL;
long val;
if (!value || value[0] == '\0' || !out) {
return false;
}
val = strtol(value, &end, 10);
if (!end || *end != '\0' || val < min_val || val > max_val) {
return false;
}
*out = (int)val;
return true;
}
static bool is_config_token(const char *value) { static bool is_config_token(const char *value) {
const unsigned char *p = (const unsigned 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; return 0;
} }
static int set_numeric_env_option(const char *env_name, const char *opt_name, static int set_numeric_env_option(const tnt_int_config_spec_t *spec,
const char *value, int min_val, const char *opt_name, const char *value,
int max_val, ui_lang_t lang) { ui_lang_t lang) {
int parsed; 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); fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value);
return TNT_EXIT_USAGE; 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_ERROR;
} }
return TNT_EXIT_OK; return TNT_EXIT_OK;
} }
int main(int argc, char **argv) { static bool require_option_arg(int argc, char **argv, int index,
int port = DEFAULT_PORT; ui_lang_t lang) {
ui_lang_t lang = i18n_default_ui_lang(); if (index + 1 >= argc || argv[index + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
/* Environment provides defaults; command-line flags override it. */ argv[index]);
const char *port_env = getenv("PORT"); return false;
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;
}
} }
return true;
}
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 */ /* Parse command line arguments */
for (int i = 1; i < argc; i++) { for (int i = 1; i < argc; i++) {
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) && if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) {
i + 1 < argc) {
int val; 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), fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]); argv[i + 1]);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
port = val; port = val;
i++; i++;
} else if ((strcmp(argv[i], "-d") == 0 || } else if (strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) { strcmp(argv[i], "--state-dir") == 0) {
if (argv[i + 1][0] == '\0') { if (!require_option_arg(argc, argv, i, lang)) {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) { if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) {
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
i++; i++;
} else if (strcmp(argv[i], "--bind") == 0 && i + 1 < argc) { } 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])) { if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang), fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]); argv[i], argv[i + 1]);
@ -121,7 +110,10 @@ int main(int argc, char **argv) {
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
i++; 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])) { if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang), fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]); argv[i], argv[i + 1]);
@ -131,54 +123,80 @@ int main(int argc, char **argv) {
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
i++; i++;
} else if (strcmp(argv[i], "--max-connections") == 0 && } else if (strcmp(argv[i], "--max-connections") == 0) {
i + 1 < argc) { if (!require_option_arg(argc, argv, i, lang)) {
int rc = set_numeric_env_option("TNT_MAX_CONNECTIONS", argv[i], return TNT_EXIT_USAGE;
argv[i + 1], 1, }
MAX_CONFIGURED_CLIENTS, lang); int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONNECTIONS,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; i++;
} else if (strcmp(argv[i], "--max-conn-per-ip") == 0 && } else if (strcmp(argv[i], "--max-conn-per-ip") == 0) {
i + 1 < argc) { if (!require_option_arg(argc, argv, i, lang)) {
int rc = set_numeric_env_option("TNT_MAX_CONN_PER_IP", argv[i], return TNT_EXIT_USAGE;
argv[i + 1], 1, }
MAX_CONFIGURED_CLIENTS, lang); int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_PER_IP,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; i++;
} else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0 && } else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0) {
i + 1 < argc) { if (!require_option_arg(argc, argv, i, lang)) {
int rc = set_numeric_env_option("TNT_MAX_CONN_RATE_PER_IP", return TNT_EXIT_USAGE;
argv[i], argv[i + 1], 1, }
MAX_CONFIGURED_CLIENTS, lang); int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_RATE_PER_IP,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; i++;
} else if (strcmp(argv[i], "--rate-limit") == 0 && i + 1 < argc) { } else if (strcmp(argv[i], "--rate-limit") == 0) {
int rc = set_numeric_env_option("TNT_RATE_LIMIT", argv[i], if (!require_option_arg(argc, argv, i, lang)) {
argv[i + 1], 0, 1, 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) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; i++;
} else if (strcmp(argv[i], "--idle-timeout") == 0 && i + 1 < argc) { } else if (strcmp(argv[i], "--idle-timeout") == 0) {
int rc = set_numeric_env_option("TNT_IDLE_TIMEOUT", argv[i], if (!require_option_arg(argc, argv, i, lang)) {
argv[i + 1], 0, 86400, 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) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; i++;
} else if (strcmp(argv[i], "--ssh-log-level") == 0 && i + 1 < argc) { } else if (strcmp(argv[i], "--ssh-log-level") == 0) {
int rc = set_numeric_env_option("TNT_SSH_LOG_LEVEL", argv[i], if (!require_option_arg(argc, argv, i, lang)) {
argv[i + 1], 0, 4, 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) { if (rc != TNT_EXIT_OK) {
return rc; return rc;
} }
i++; 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) { } else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
printf("tnt %s\n", TNT_VERSION); printf("tnt %s\n", TNT_VERSION);
return TNT_EXIT_OK; return TNT_EXIT_OK;
@ -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 */ /* Setup signal handlers */
signal(SIGINT, signal_handler); signal(SIGINT, signal_handler);
signal(SIGTERM, 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" " TNT - SSH terminal chat room\n"
"\n" "\n"
"\033[1;37mUse\033[0m\n" "\033[1;37mUse\033[0m\n"
" Type a message and press Enter; Esc browses; G latest; i types\n" " Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
" : runs commands; ? opens the full key reference\n" " Esc browses; / searches; G latest; i types; : commands; ? keys\n"
"\n" "\n"
"\033[1;37mCommands\033[0m\n", "\033[1;37mCommands\033[0m\n",
"\033[1;36mTNT(1) 帮助\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" " TNT - SSH 终端聊天室\n"
"\n" "\n"
"\033[1;37m使用\033[0m\n" "\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n" " 输入并 Enter 发送Up/Down 调出消息Tab 补全 @mention\n"
" : 运行命令;? 打开完整按键参考\n" " Esc 浏览;/ 搜索G 最新i 输入;: 命令;? 按键\n"
"\n" "\n"
"\033[1;37m命令\033[0m\n" "\033[1;37m命令\033[0m\n"
); );

View file

@ -1,29 +1,63 @@
#ifndef _DEFAULT_SOURCE #ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for timegm() on glibc */ #define _DEFAULT_SOURCE /* for strcasestr() on glibc */
#endif #endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE) #if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for timegm() on macOS */ #define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
#endif #endif
#include "message.h" #include "message.h"
#include "message_log.h"
#include "utf8.h" #include "utf8.h"
#include <errno.h>
#include <unistd.h> #include <unistd.h>
#include <fcntl.h> #include <fcntl.h>
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
static time_t parse_rfc3339_utc(const char *timestamp_str) { static void discard_line_remainder(FILE *fp) {
struct tm tm = {0}; int c;
if (!timestamp_str) { while ((c = fgetc(fp)) != '\n' && c != EOF) {
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;
} }
char *result = strptime(timestamp_str, "%Y-%m-%dT%H:%M:%SZ", &tm); if (message_log_format_record(msg, NULL, 0, &needed) < 0) {
if (!result || *result != '\0') { return -1;
return (time_t)-1;
} }
return timegm(&tm); 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 */ /* Initialize message subsystem */
@ -118,67 +152,25 @@ int message_load(message_t **messages, int max_messages) {
fseek(fp, 0, SEEK_SET); fseek(fp, 0, SEEK_SET);
read_messages:; read_messages:;
char line[2048]; char line[MESSAGE_LOG_MAX_LINE];
int count = 0; int count = 0;
time_t now = time(NULL);
/* Now read forward */ /* Now read forward */
while (fgets(line, sizeof(line), fp) && count < max_messages) { while (fgets(line, sizeof(line), fp) && count < max_messages) {
/* Check for oversized lines */ /* Check for oversized lines */
size_t line_len = strlen(line); size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) { if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
/* Skip remainder of line */ discard_line_remainder(fp);
int c;
while ((c = fgetc(fp)) != '\n' && c != EOF);
continue; continue;
} }
/* Format: RFC3339_timestamp|username|content */ message_t parsed;
char line_copy[2048]; if (!message_log_parse_record(line, &parsed, now)) {
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') {
continue; continue;
} }
/* Validate field lengths */ msg_array[count++] = parsed;
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++;
} }
fclose(fp); fclose(fp);
@ -190,6 +182,9 @@ read_messages:;
/* Save a message to log file */ /* Save a message to log file */
int message_save(const message_t *msg) { int message_save(const message_t *msg) {
char log_path[PATH_MAX]; 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; int rc = 0;
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 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; 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 */ /* Sanitize username and content to prevent log injection */
char safe_username[MAX_USERNAME_LEN]; safe_msg.timestamp = msg->timestamp;
char safe_content[MAX_MESSAGE_LEN]; 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); strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1);
safe_username[sizeof(safe_username) - 1] = '\0'; safe_msg.content[sizeof(safe_msg.content) - 1] = '\0';
strncpy(safe_content, msg->content, sizeof(safe_content) - 1);
safe_content[sizeof(safe_content) - 1] = '\0';
/* Replace pipe characters and newlines to prevent log format corruption */ /* 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') { if (*p == '|' || *p == '\n' || *p == '\r') {
*p = '_'; *p = '_';
} }
} }
for (char *p = safe_content; *p; p++) { for (char *p = safe_msg.content; *p; p++) {
if (*p == '|' || *p == '\n' || *p == '\r') { if (*p == '|' || *p == '\n' || *p == '\r') {
*p = ' '; *p = ' ';
} }
} }
/* Write to file: timestamp|username|content */ if (message_log_format_record(&safe_msg, record, sizeof(record),
if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 || &record_len) < 0 ||
fwrite(record, 1, record_len, fp) != record_len ||
fflush(fp) != 0) { fflush(fp) != 0) {
rc = -1; rc = -1;
} }
@ -274,40 +262,21 @@ int message_search(const char *query, message_t **results, int max_results) {
return 0; return 0;
} }
char line[2048]; char line[MESSAGE_LOG_MAX_LINE];
int count = 0; int count = 0;
time_t now = time(NULL);
while (fgets(line, sizeof(line), fp)) { while (fgets(line, sizeof(line), fp)) {
size_t line_len = strlen(line); size_t line_len = strlen(line);
if (line_len >= sizeof(line) - 1) { if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
int c; discard_line_remainder(fp);
while ((c = fgetc(fp)) != '\n' && c != EOF);
continue; 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; message_t m;
m.timestamp = msg_time; if (!message_log_parse_record(line, &m, now)) continue;
strncpy(m.username, username, MAX_USERNAME_LEN - 1); if (strcasestr(m.username, query) == NULL &&
m.username[MAX_USERNAME_LEN - 1] = '\0'; strcasestr(m.content, query) == NULL) continue;
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
m.content[MAX_MESSAGE_LEN - 1] = '\0';
if (count < max_results) { if (count < max_results) {
res[count++] = m; 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; 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 */ /* Format a message for display */
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) { void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
struct tm tm_info; 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 "ratelimit.h"
#include "config_defaults.h"
#include "common.h" #include "common.h"
#include <arpa/inet.h> #include <arpa/inet.h>
#include <pthread.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 int g_total_connections = 0;
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER; static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
static int g_max_connections = 64; static int g_max_connections = TNT_DEFAULT_MAX_CONNECTIONS;
static int g_max_conn_per_ip = 5; static int g_max_conn_per_ip = TNT_DEFAULT_MAX_CONN_PER_IP;
static int g_max_conn_rate_per_ip = 10; static int g_max_conn_rate_per_ip = TNT_DEFAULT_MAX_CONN_RATE_PER_IP;
static int g_rate_limit_enabled = 1; static int g_rate_limit_enabled = TNT_DEFAULT_RATE_LIMIT_ENABLED;
void ratelimit_init(void) { void ratelimit_init(void) {
g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024); g_max_connections =
g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024); tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024); g_max_conn_per_ip =
g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1); 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. */ /* Caller MUST hold g_rate_limit_lock. */

View file

@ -1,6 +1,7 @@
#include "ssh_server.h" #include "ssh_server.h"
#include "bootstrap.h" #include "bootstrap.h"
#include "commands.h" #include "commands.h"
#include "config_defaults.h"
#include "exec.h" #include "exec.h"
#include "input.h" #include "input.h"
#include "ratelimit.h" #include "ratelimit.h"
@ -23,7 +24,7 @@
/* Global SSH bind instance */ /* Global SSH bind instance */
static ssh_bind g_sshbind = NULL; 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; static time_t g_server_start_time = 0;

View file

@ -1,4 +1,8 @@
#include "common.h" #include "common.h"
#include "config_defaults.h"
#include "exec_catalog.h"
#include "i18n.h"
#include "tntctl_text.h"
#include <ctype.h> #include <ctype.h>
#include <errno.h> #include <errno.h>
@ -6,21 +10,24 @@
#include <sys/wait.h> #include <sys/wait.h>
#include <unistd.h> #include <unistd.h>
static void print_usage(FILE *stream) { static void print_usage(FILE *stream, ui_lang_t lang) {
fprintf(stream, char output[2048];
"Usage: tntctl [options] host command [args...]\n" size_t pos = 0;
"\n"
"Options:\n" output[0] = '\0';
" -p, --port PORT SSH port (default: 2222)\n" tntctl_text_append_usage(output, sizeof(output), &pos, lang);
" -l, --login USER SSH login name for exec identity\n" fputs(output, stream);
" --host-key-checking MODE\n" }
" OpenSSH host-key mode: yes, accept-new, no\n"
" --known-hosts FILE OpenSSH known_hosts file\n" static void print_error(ui_lang_t lang, tntctl_text_id_t id) {
" -V, --version Print version and exit\n" fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id));
" -h, --help Print this help and exit\n" }
"\n"
"Commands mirror the TNT SSH exec interface: health, stats, users,\n" static void print_error_format(ui_lang_t lang, tntctl_text_id_t id,
"tail, post, help, and exit.\n"); const char *value) {
fprintf(stderr, "tntctl: ");
fprintf(stderr, tntctl_text(lang, id), value);
fputc('\n', stderr);
} }
static bool is_valid_port(const char *value) { 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) { static bool is_known_exec_command(const char *command) {
return command && return exec_catalog_match(command, NULL, NULL);
(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);
} }
static int build_remote_command(char *buffer, size_t buf_size, int argc, 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) { int main(int argc, char **argv) {
const char *port = "2222"; const char *port = TNT_DEFAULT_PORT_TEXT;
const char *login = NULL; const char *login = NULL;
const char *host_key_checking = NULL; const char *host_key_checking = NULL;
const char *known_hosts = NULL; const char *known_hosts = NULL;
@ -159,6 +159,7 @@ int main(int argc, char **argv) {
char **ssh_argv = NULL; char **ssh_argv = NULL;
int ssh_argc = 0; int ssh_argc = 0;
int rc; int rc;
ui_lang_t lang = i18n_default_ui_lang();
for (i = 1; i < argc; i++) { for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) { if (strcmp(argv[i], "--") == 0) {
@ -166,7 +167,7 @@ int main(int argc, char **argv) {
break; break;
} else if (strcmp(argv[i], "-h") == 0 || } else if (strcmp(argv[i], "-h") == 0 ||
strcmp(argv[i], "--help") == 0) { strcmp(argv[i], "--help") == 0) {
print_usage(stdout); print_usage(stdout, lang);
return TNT_EXIT_OK; return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-V") == 0 || } else if (strcmp(argv[i], "-V") == 0 ||
strcmp(argv[i], "--version") == 0) { strcmp(argv[i], "--version") == 0) {
@ -175,7 +176,7 @@ int main(int argc, char **argv) {
} else if (strcmp(argv[i], "-p") == 0 || } else if (strcmp(argv[i], "-p") == 0 ||
strcmp(argv[i], "--port") == 0) { strcmp(argv[i], "--port") == 0) {
if (i + 1 >= argc || !is_valid_port(argv[i + 1])) { 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; return TNT_EXIT_USAGE;
} }
port = argv[++i]; port = argv[++i];
@ -183,26 +184,27 @@ int main(int argc, char **argv) {
strcmp(argv[i], "--login") == 0) { strcmp(argv[i], "--login") == 0) {
if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) || if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) ||
strchr(argv[i + 1], '@')) { strchr(argv[i + 1], '@')) {
fprintf(stderr, "tntctl: invalid login\n"); print_error(lang, TNTCTL_TEXT_INVALID_LOGIN);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
login = argv[++i]; login = argv[++i];
} else if (strcmp(argv[i], "--host-key-checking") == 0) { } else if (strcmp(argv[i], "--host-key-checking") == 0) {
if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) { 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; return TNT_EXIT_USAGE;
} }
host_key_checking = argv[++i]; host_key_checking = argv[++i];
} else if (strcmp(argv[i], "--known-hosts") == 0) { } else if (strcmp(argv[i], "--known-hosts") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0' || if (i + 1 >= argc || argv[i + 1][0] == '\0' ||
has_newline(argv[i + 1])) { 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; return TNT_EXIT_USAGE;
} }
known_hosts = argv[++i]; known_hosts = argv[++i];
} else if (argv[i][0] == '-') { } else if (argv[i][0] == '-') {
fprintf(stderr, "tntctl: unknown option: %s\n", argv[i]); print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
print_usage(stderr); argv[i]);
print_usage(stderr, lang);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} else { } else {
break; break;
@ -210,29 +212,29 @@ int main(int argc, char **argv) {
} }
if (i >= argc) { if (i >= argc) {
fprintf(stderr, "tntctl: missing host\n"); print_error(lang, TNTCTL_TEXT_MISSING_HOST);
print_usage(stderr); print_usage(stderr, lang);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
host = argv[i++]; host = argv[i++];
if (is_safe_ssh_token(host)) { if (is_safe_ssh_token(host)) {
fprintf(stderr, "tntctl: invalid host\n"); print_error(lang, TNTCTL_TEXT_INVALID_HOST);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
if (login && strchr(host, '@')) { 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; return TNT_EXIT_USAGE;
} }
if (i >= argc || !is_known_exec_command(argv[i])) { 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; return TNT_EXIT_USAGE;
} }
if (build_remote_command(remote_command, sizeof(remote_command), argc, if (build_remote_command(remote_command, sizeof(remote_command), argc,
argv, i) < 0) { argv, i) < 0) {
fprintf(stderr, "tntctl: invalid or too-long command\n"); print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
@ -240,24 +242,24 @@ int main(int argc, char **argv) {
int n = snprintf(destination, sizeof(destination), "%s@%s", login, int n = snprintf(destination, sizeof(destination), "%s@%s", login,
host); host);
if (n < 0 || n >= (int)sizeof(destination)) { 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; return TNT_EXIT_USAGE;
} }
} else { } else {
int n = snprintf(destination, sizeof(destination), "%s", host); int n = snprintf(destination, sizeof(destination), "%s", host);
if (n < 0 || n >= (int)sizeof(destination)) { 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; return TNT_EXIT_USAGE;
} }
} }
if (destination[0] == '-') { if (destination[0] == '-') {
fprintf(stderr, "tntctl: invalid destination\n"); print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv)); ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv));
if (!ssh_argv) { if (!ssh_argv) {
fprintf(stderr, "tntctl: out of memory\n"); print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY);
return TNT_EXIT_ERROR; return TNT_EXIT_ERROR;
} }
@ -268,7 +270,7 @@ int main(int argc, char **argv) {
int n = snprintf(host_key_option, sizeof(host_key_option), int n = snprintf(host_key_option, sizeof(host_key_option),
"StrictHostKeyChecking=%s", host_key_checking); "StrictHostKeyChecking=%s", host_key_checking);
if (n < 0 || n >= (int)sizeof(host_key_option)) { 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); free(ssh_argv);
return TNT_EXIT_USAGE; return TNT_EXIT_USAGE;
} }
@ -279,7 +281,7 @@ int main(int argc, char **argv) {
int n = snprintf(known_hosts_option, sizeof(known_hosts_option), int n = snprintf(known_hosts_option, sizeof(known_hosts_option),
"UserKnownHostsFile=%s", known_hosts); "UserKnownHostsFile=%s", known_hosts);
if (n < 0 || n >= (int)sizeof(known_hosts_option)) { 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); free(ssh_argv);
return TNT_EXIT_USAGE; 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; chips[chip_count].value_color = mode_color;
chip_count++; 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); int hint_width = utf8_string_width(hint);
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED); 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; 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 /* Decide what fits. Reserve at least 1 col of gap between left and
* right halves so they never visually touch. */ * 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_mute = client->mute_joins ? 1 : 0;
int show_unread = unread_count > 0 ? 1 : 0; int show_unread = unread_count > 0 ? 1 : 0;
int show_whisper = whisper_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, buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->ui_lang, 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); start + 1, max_scroll + 1);
client_send(client, buffer, pos); client_send(client, buffer, pos);

View file

@ -1,6 +1,54 @@
#include "tui_status.h" #include "tui_status.h"
#include "i18n.h" #include "i18n.h"
#include "ssh_server.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, void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count, 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)); i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
} }
} else if (client->mode == MODE_COMMAND) { } 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, 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)) FAIL=$((FAIL + 1))
fi 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) POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message" echo "✓ post publishes a message"
@ -161,6 +174,17 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi 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" PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log" rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log" mkdir "$STATE_DIR/messages.log"
@ -261,6 +285,17 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi 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" EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready" WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF cat >"$EXPECT_SCRIPT" <<EOF
@ -337,7 +372,7 @@ set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
expect "请输入用户名" expect "请输入用户名"
send "sender\r" send "sender\r"
expect ":help" expect "Esc NORMAL"
send "\033" send "\033"
expect "NORMAL" expect "NORMAL"
send ":" send ":"

View file

@ -58,13 +58,58 @@ else
exit 1 exit 1
fi 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" EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
cat >"$EXPECT_SCRIPT" <<EOF cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10 set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "tester\r" send -- "tester\r"
expect ":help" expect "Esc NORMAL"
send -- "\033\[200~" send -- "\033\[200~"
send -- "line1\nline2\nline3" send -- "line1\nline2\nline3"
send -- "\033\[201~" send -- "\033\[201~"
@ -139,21 +184,28 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "helper\r" send -- "helper\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "help\r" send -- ":help\r"
expect "TNT\\(1\\) 帮助" expect "TNT\\(1\\) 帮助"
expect "Tab 补全 @mention"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "?" send -- "?"
expect "TNT 按键参考" expect "TNT 按键参考"
expect "Tab - 补全 @mention"
expect "l:语言" expect "l:语言"
send -- "\003"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
send -- "l" send -- "l"
expect "TNT KEY REFERENCE" expect "TNT KEY REFERENCE"
expect "Complete @mention"
expect "l:lang" expect "l:lang"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
@ -180,13 +232,52 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi 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" UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
cat >"$UNKNOWN_SCRIPT" <<EOF cat >"$UNKNOWN_SCRIPT" <<EOF
set timeout 10 set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "mistype\r" send -- "mistype\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
@ -218,7 +309,7 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "localized\r" send -- "localized\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
@ -268,7 +359,7 @@ set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "usageuser\r" send -- "usageuser\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
@ -304,6 +395,9 @@ expect ":"
send -- "inbox\r" send -- "inbox\r"
expect "Private messages" expect "Private messages"
expect "(empty)" expect "(empty)"
expect "r:refresh"
send -- "r"
expect "Private messages"
expect "q:close" expect "q:close"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
@ -358,7 +452,7 @@ stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "pageruser\r" send -- "pageruser\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
@ -368,6 +462,14 @@ expect "j/k:滚动"
expect -re {\(1/[2-9][0-9]*\)} expect -re {\(1/[2-9][0-9]*\)}
send -- "j" send -- "j"
expect -re {\(2/[2-9][0-9]*\)} 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" send -- "q"
expect "NORMAL" expect "NORMAL"
sleep 0.2 sleep 0.2
@ -387,13 +489,44 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi 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" SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
set timeout 10 set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1 spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1 sleep 1
send -- "systemuser\r" send -- "systemuser\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
@ -440,7 +573,7 @@ expect "公告"
expect "维护窗口" expect "维护窗口"
expect "按任意键继续" expect "按任意键继续"
send -- "x" send -- "x"
expect "NORMAL" expect "INSERT"
sleep 0.2 sleep 0.2
send -- "\003" send -- "\003"
sleep 0.2 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)) ;; *) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;;
esac 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 run_ok "basic argv shape" "$BIN" -p 2222 example.com health
grep -q '^example.com$' "$SSH_LOG" && grep -q '^example.com$' "$SSH_LOG" &&
grep -q '^health$' "$SSH_LOG" grep -q '^health$' "$SSH_LOG"
@ -108,6 +135,28 @@ else
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
fi 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 PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1
REMOTE_STATUS=$? REMOTE_STATUS=$?
if [ "$REMOTE_STATUS" -eq 64 ]; then 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_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" SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
BOB_READY="$STATE_DIR/bob.ready" BOB_READY="$STATE_DIR/bob.ready"
ALICE_DONE="$STATE_DIR/alice.done" PRIVATE_SENT="$STATE_DIR/private.sent"
wait_for_health() { wait_for_health() {
out="" out=""
@ -79,15 +79,18 @@ set timeout 30
spawn ssh $SSH_OPTS bob@127.0.0.1 spawn ssh $SSH_OPTS bob@127.0.0.1
sleep 1 sleep 1
send -- "bob\r" send -- "bob\r"
expect ":help" expect "Esc NORMAL"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$ALICE_DONE' \]; do sleep 1; done"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "inbox\r" send -- "inbox\r"
expect "私信" expect "私信"
expect "(空)"
expect "r:刷新"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
expect "私信"
expect "alice" expect "alice"
expect "private lifecycle ping" expect "private lifecycle ping"
expect "q:关闭" expect "q:关闭"
@ -140,7 +143,7 @@ set timeout 30
spawn ssh $SSH_OPTS alice@127.0.0.1 spawn ssh $SSH_OPTS alice@127.0.0.1
sleep 1 sleep 1
send -- "alice\r" send -- "alice\r"
expect ":help" expect "Esc NORMAL"
send -- "\033" send -- "\033"
expect "NORMAL" expect "NORMAL"
send -- "?" send -- "?"
@ -157,7 +160,7 @@ expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "i" send -- "i"
expect ":help" expect "Esc NORMAL"
send -- "hello lifecycle alpha\r" send -- "hello lifecycle alpha\r"
sleep 1 sleep 1
send -- "\033" send -- "\033"
@ -182,6 +185,12 @@ expect "alpha"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "/alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":" send -- ":"
expect ":" expect ":"
send -- "mute-joins\r" send -- "mute-joins\r"
@ -194,6 +203,7 @@ send -- ":"
expect ":" expect ":"
send -- "msg bob private lifecycle ping\r" send -- "msg bob private lifecycle ping\r"
expect "私信已发送给 bob" expect "私信已发送给 bob"
exec touch "$PRIVATE_SENT"
expect "q:关闭" expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
@ -205,10 +215,9 @@ expect "q:关闭"
send -- "q" send -- "q"
expect "NORMAL" expect "NORMAL"
send -- "i" send -- "i"
expect ":help" expect "Esc NORMAL"
send -- "/me ships lifecycle\r" send -- "/me ships lifecycle\r"
sleep 1 sleep 1
exec touch "$ALICE_DONE"
send -- "\003" send -- "\003"
sleep 0.2 sleep 0.2
send -- "\003" send -- "\003"
@ -222,11 +231,11 @@ else
echo "✗ primary user lifecycle failed" echo "✗ primary user lifecycle failed"
sed -n '1,240p' "$STATE_DIR/alice.log" sed -n '1,240p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1)) FAIL=$((FAIL + 1))
touch "$ALICE_DONE" touch "$PRIVATE_SENT"
fi fi
if wait "$BOB_PID" 2>/dev/null; then 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)) PASS=$((PASS + 1))
else else
echo "✗ recipient inbox journey failed" echo "✗ recipient inbox journey failed"

View file

@ -12,9 +12,12 @@ endif
# Source files # Source files
UTF8_SRC = ../../src/utf8.c UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c MESSAGE_SRC = ../../src/message.c
MESSAGE_LOG_SRC = ../../src/message_log.c
COMMON_SRC = ../../src/common.c COMMON_SRC = ../../src/common.c
CONFIG_DEFAULTS_SRC = ../../src/config_defaults.c
COMMAND_CATALOG_SRC = ../../src/command_catalog.c COMMAND_CATALOG_SRC = ../../src/command_catalog.c
CLI_TEXT_SRC = ../../src/cli_text.c CLI_TEXT_SRC = ../../src/cli_text.c
TNTCTL_TEXT_SRC = ../../src/tntctl_text.c
CHAT_ROOM_SRC = ../../src/chat_room.c CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c HISTORY_VIEW_SRC = ../../src/history_view.c
I18N_SRC = ../../src/i18n.c I18N_SRC = ../../src/i18n.c
@ -25,7 +28,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.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 .PHONY: all clean run
@ -34,10 +37,10 @@ all: $(TESTS)
test_utf8: test_utf8.c $(UTF8_SRC) test_utf8: test_utf8.c $(UTF8_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(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) $(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) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC) 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) test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS) $(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) $(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all run: all
@ -101,8 +110,14 @@ run: all
@echo "=== Running CLI Text Tests ===" @echo "=== Running CLI Text Tests ==="
./test_cli_text ./test_cli_text
@echo "" @echo ""
@echo "=== Running tntctl Text Tests ==="
./test_tntctl_text
@echo ""
@echo "=== Running Rate Limit Tests ===" @echo "=== Running Rate Limit Tests ==="
./test_ratelimit ./test_ratelimit
@echo ""
@echo "=== Running Config Defaults Tests ==="
./test_config_defaults
clean: clean:
rm -f $(TESTS) *.o test_messages.log 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, "Usage: tnt [options]") != NULL);
assert(strstr(output, "--bind ADDR") != NULL); assert(strstr(output, "--bind ADDR") != NULL);
assert(strstr(output, "--max-connections N") != 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); assert(strstr(output, "TNT_LANG") != NULL);
memset(output, 0, sizeof(output)); memset(output, 0, sizeof(output));
@ -39,6 +41,7 @@ TEST(help_matches_language) {
assert(strstr(output, "[选项]") == NULL); assert(strstr(output, "[选项]") == NULL);
assert(strstr(output, "--public-host HOST") != NULL); assert(strstr(output, "--public-host HOST") != NULL);
assert(strstr(output, "--idle-timeout SECONDS") != NULL); assert(strstr(output, "--idle-timeout SECONDS") != NULL);
assert(strstr(output, "--log-check FILE") != NULL);
assert(strstr(output, "TNT_LANG") != NULL); assert(strstr(output, "TNT_LANG") != NULL);
} }
@ -51,6 +54,10 @@ TEST(error_formats_match_language) {
"Invalid %s: %s\n") == 0); "Invalid %s: %s\n") == 0);
assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH), assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH),
"%s 无效: %s\n") == 0); "%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), assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
"Unknown option: %s\n") == 0); "Unknown option: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH), assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),

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

View file

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

View file

@ -80,10 +80,21 @@ TEST(default_uses_locale_when_no_tnt_lang) {
TEST(text_lookup_matches_language) { TEST(text_lookup_matches_language) {
i18n_string_t sample = I18N_STRING("fallback", "替代"); 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_EN), "fallback") == 0);
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 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(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), assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL); "display name") != NULL);
@ -111,6 +122,12 @@ TEST(text_lookup_matches_language) {
"q:close") != NULL); "q:close") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:关闭") != NULL); "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), assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
"Press any key") != NULL); "Press any key") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT), assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),

View file

@ -3,8 +3,10 @@
#include <stdio.h> #include <stdio.h>
#include <string.h> #include <string.h>
#include <assert.h> #include <assert.h>
#include <stdlib.h>
#include <unistd.h> #include <unistd.h>
#include <sys/stat.h> #include <sys/stat.h>
#include <limits.h>
#define TEST(name) static void test_##name() #define TEST(name) static void test_##name()
#define RUN_TEST(name) do { \ #define RUN_TEST(name) do { \
@ -16,12 +18,45 @@
static int tests_passed = 0; static int tests_passed = 0;
static const char *test_log = "test_messages.log"; static const char *test_log = "test_messages.log";
static char test_state_dir[PATH_MAX];
/* Helper: Clean up test log file */ /* Helper: Clean up test log file */
static void cleanup_test_log(void) { static void cleanup_test_log(void) {
unlink(test_log); 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 initialization */
TEST(message_init) { TEST(message_init) {
message_init(); message_init();
@ -122,6 +157,104 @@ TEST(message_save_basic) {
cleanup_test_log(); 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 edge cases */
TEST(message_edge_cases) { TEST(message_edge_cases) {
message_t msg; message_t msg;
@ -215,12 +348,16 @@ int main(void) {
RUN_TEST(message_format_unicode); RUN_TEST(message_format_unicode);
RUN_TEST(message_format_width_limits); RUN_TEST(message_format_width_limits);
RUN_TEST(message_save_basic); 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_edge_cases);
RUN_TEST(message_special_characters); RUN_TEST(message_special_characters);
RUN_TEST(message_buffer_safety); RUN_TEST(message_buffer_safety);
RUN_TEST(message_timestamp_formats); RUN_TEST(message_timestamp_formats);
cleanup_test_log(); cleanup_test_log();
cleanup_state_dir();
printf("\n✓ All %d tests passed!\n", tests_passed); printf("\n✓ All %d tests passed!\n", tests_passed);
return 0; 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 ] .IR level ]
.RB [ \-V | \-\-version ] .RB [ \-V | \-\-version ]
.RB [ \-h | \-\-help ] .RB [ \-h | \-\-help ]
.br
.B tnt
.B \-\-log\-check
.I file
.br
.B tnt
.B \-\-log\-recover
.I file
.SH DESCRIPTION .SH DESCRIPTION
.B tnt .B tnt
is a multi\-user anonymous chat server accessed over SSH. 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 Users connect with any standard SSH client; no account or registration is
needed. needed.
.PP .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. Messages are persisted to a log file and restored on server restart.
The server supports CJK and emoji input, rate limiting, access tokens, and The server supports CJK and emoji input, rate limiting, access tokens, and
a non\-interactive exec interface for scripting. a non\-interactive exec interface for scripting.
@ -110,6 +125,18 @@ Overrides the
.B TNT_SSH_LOG_LEVEL .B TNT_SSH_LOG_LEVEL
environment variable. environment variable.
.TP .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 .BR \-V ", " \-\-version
Print version and exit. Print version and exit.
.TP .TP
@ -140,6 +167,8 @@ Press
to return to INSERT, to return to INSERT,
.B : .B :
to enter COMMAND mode, to enter COMMAND mode,
.B /
to search message history,
.B ? .B ?
to open the full key reference. to open the full key reference.
.TP .TP
@ -155,6 +184,8 @@ ESC Switch to NORMAL
Ctrl+W Delete last word Ctrl+W Delete last word
Ctrl+U Clear input line Ctrl+U Clear input line
Ctrl+C Switch to NORMAL Ctrl+C Switch to NORMAL
Up/Down Browse sent message history
Tab Complete @mention
Paste Keep multi-line paste in the input buffer Paste Keep multi-line paste in the input buffer
/me \fIaction\fR Send action message (e.g. /me waves) /me \fIaction\fR Send action message (e.g. /me waves)
@\fIusername\fR Mention user (bell notification + highlight) @\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 PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top End/Home Jump to bottom/top
g/G Jump to top/bottom g/G Jump to top/bottom
/ Search message history
i Switch to INSERT i Switch to INSERT
: Enter COMMAND mode : Enter COMMAND mode
? Open full key reference ? Open full key reference
@ -190,8 +222,9 @@ l l.
:w \fIuser text\fR Short alias for :msg :w \fIuser text\fR Short alias for :msg
:inbox Show private messages :inbox Show private messages
:last [\fIN\fR] Show last N messages from history (1\-50, default 10) :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 :mute\-joins Toggle join/leave system notifications on/off
:lang Show current UI language
:lang \fIen|zh\fR Switch UI language for this session :lang \fIen|zh\fR Switch UI language for this session
:help Show concise manual :help Show concise manual
:clear Clear command output :clear Clear command output
@ -199,6 +232,25 @@ l l.
Up/Down Browse command history Up/Down Browse command history
ESC Cancel and return to NORMAL ESC Cancel and return to NORMAL
.TE .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 .SH EXEC INTERFACE
Commands can be run non\-interactively for scripting: Commands can be run non\-interactively for scripting:
.PP .PP
@ -207,6 +259,7 @@ ssh host \-p 2222 help
ssh host \-p 2222 users \-\-json ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20 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 "Hello from a script"
ssh host \-p 2222 post "/me deploys v2.0" ssh host \-p 2222 post "/me deploys v2.0"
ssh host \-p 2222 health ssh host \-p 2222 health
@ -287,9 +340,13 @@ libssh log verbosity from 0 to 4 (default: 1).
.SH FILES .SH FILES
.TP .TP
.I messages.log .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 ). .RI ( timestamp | username | content ).
Stored in the state directory. Stored in the state directory.
See
.I docs/MESSAGE_LOG.md
in the source distribution for parser and recovery rules.
.TP .TP
.I host_key .I host_key
RSA 4096\-bit host key, auto\-generated on first run. RSA 4096\-bit host key, auto\-generated on first run.

View file

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