Compare commits

..

No commits in common. "94b602613f60a21e96a273913668400d3baf361a" and "d3ebe25973e36e20a67ab96235cf4a6501cfc8bf" have entirely different histories.

83 changed files with 1065 additions and 5978 deletions

View file

@ -20,7 +20,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y expect libssh-dev
sudo apt-get install -y libssh-dev
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
@ -34,67 +34,17 @@ jobs:
run: make asan
- name: Run comprehensive tests
run: make ci-test
- name: Run release preflight
run: make release-check
run: |
make test
cd tests
./test_security_features.sh
# Skipping anonymous access test in CI as it requires interactive pty handling which might be flaky
# ./test_anonymous_access.sh
- name: Check for memory leaks
if: runner.os == 'Linux'
run: |
set -eu
sudo apt-get install -y valgrind
STATE_DIR=$(mktemp -d)
SERVER_LOG="$STATE_DIR/server.log"
VALGRIND_LOG="$STATE_DIR/valgrind.log"
PORT=13990
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
ssh-keygen -q -t rsa -b 4096 -m PEM -N "" -f "$STATE_DIR/host_key"
chmod 600 "$STATE_DIR/host_key"
TNT_RATE_LIMIT=0 valgrind --leak-check=full --error-exitcode=99 --log-file="$VALGRIND_LOG" \
./tnt -p "$PORT" -d "$STATE_DIR" >"$SERVER_LOG" 2>&1 &
SERVER_PID=$!
READY=0
for _ in $(seq 1 60); do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
break
fi
OUT=$(ssh -n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o BatchMode=yes -p "$PORT" localhost health 2>/dev/null || true)
if [ "$OUT" = "ok" ]; then
READY=1
break
fi
sleep 1
done
if [ "$READY" -ne 1 ]; then
echo "::group::server log"
cat "$SERVER_LOG" || true
echo "::endgroup::"
echo "::group::valgrind log"
cat "$VALGRIND_LOG" || true
echo "::endgroup::"
exit 1
fi
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
if ! grep -q "ERROR SUMMARY: 0 errors" "$VALGRIND_LOG"; then
cat "$VALGRIND_LOG"
exit 1
fi
timeout 10 valgrind --leak-check=full --error-exitcode=1 ./tnt &
sleep 5
pkill tnt || true

45
.github/workflows/deploy.yml vendored Normal file
View file

@ -0,0 +1,45 @@
name: Deploy
on:
push:
branches: [main]
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y libssh-dev
- name: Build
run: make
- name: Build with AddressSanitizer
run: make asan
- name: Run tests
run: |
make test
cd tests
./test_security_features.sh
deploy:
needs: test
runs-on: ubuntu-latest
steps:
- name: Deploy to production
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.SERVER_HOST }}
username: ${{ secrets.SERVER_USER }}
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
cd /home/admin/repo/tnt
git pull origin main
make clean && make release
cp tnt /home/admin/tnt/tnt
sudo systemctl restart tnt

View file

@ -12,16 +12,16 @@ jobs:
strategy:
matrix:
include:
- os: ubuntu-24.04
- os: ubuntu-latest
target: linux-amd64
artifact: tnt-linux-amd64
- os: ubuntu-24.04-arm
- os: ubuntu-latest
target: linux-arm64
artifact: tnt-linux-arm64
- os: macos-15-intel
- os: macos-latest
target: darwin-amd64
artifact: tnt-darwin-amd64
- os: macos-15
- os: macos-latest
target: darwin-arm64
artifact: tnt-darwin-arm64
@ -34,35 +34,20 @@ jobs:
sudo apt-get update
sudo apt-get install -y libssh-dev
- name: Install cross-compilation tools (Ubuntu ARM64)
if: matrix.target == 'linux-arm64'
run: |
sudo apt-get install -y gcc-aarch64-linux-gnu
sudo dpkg --add-architecture arm64
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
run: |
brew install libssh
- name: Run release preflight
run: make release-check
- name: Build release binary
run: make release
- name: Verify artifact architecture
run: |
file tnt
case "${{ matrix.target }}" in
linux-amd64)
file tnt | grep -E 'ELF 64-bit.*x86-64'
;;
linux-arm64)
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
;;
darwin-amd64)
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
;;
darwin-arm64)
file tnt | grep -E 'Mach-O 64-bit.*arm64'
;;
esac
- name: Rename binary
run: mv tnt ${{ matrix.artifact }}
@ -89,18 +74,19 @@ jobs:
- name: Create checksums
run: |
cd artifacts
: > checksums.txt
for artifact in */tnt-*; do
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
for dir in */; do
cd "$dir"
sha256sum * > checksums.txt
cd ..
done
cat checksums.txt
cd ..
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/*/tnt-*
artifacts/checksums.txt
artifacts/*/checksums.txt
body: |
## Installation
@ -140,8 +126,8 @@ jobs:
```
## What's Changed
See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md)
draft: true
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md)
draft: false
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

10
.gitignore vendored
View file

@ -11,13 +11,3 @@ test.log
tests/unit/test_utf8
tests/unit/test_message
tests/unit/test_chat_room
tests/unit/test_history_view
tests/unit/test_i18n
tests/unit/test_system_message
tests/unit/test_command_catalog
tests/unit/test_exec_catalog
tests/unit/test_help_text
tests/unit/test_manual_text
tests/unit/test_support_text
tests/unit/test_cli_text
tests/unit/test_ratelimit

View file

@ -5,7 +5,6 @@ CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
LDFLAGS = -pthread -lssh
INCLUDES = -Iinclude
DEPFLAGS = -MMD -MP
# Detect libssh location (homebrew on macOS)
ifeq ($(shell uname), Darwin)
@ -22,16 +21,9 @@ OBJ_DIR = obj
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d)
TARGET = tnt
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
.PHONY: all clean install uninstall debug release asan valgrind check info
all: $(TARGET)
@ -40,7 +32,7 @@ $(TARGET): $(OBJECTS)
@echo "Build complete: $(TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(OBJ_DIR):
mkdir -p $(OBJ_DIR)
@ -51,21 +43,14 @@ clean:
@echo "Clean complete"
install: $(TARGET)
install -d $(DESTDIR)$(BINDIR)
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
install -d $(DESTDIR)$(MANDIR)/man1
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
install-systemd:
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
install -d $(DESTDIR)/usr/local/bin
install -m 755 $(TARGET) $(DESTDIR)/usr/local/bin/
install -d $(DESTDIR)/usr/local/share/man/man1
install -m 644 tnt.1 $(DESTDIR)/usr/local/share/man/man1/
uninstall:
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
uninstall-systemd:
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
# Development targets
debug: CFLAGS += -g -DDEBUG
@ -75,12 +60,6 @@ release: CFLAGS += -O3 -DNDEBUG
release: clean $(TARGET)
strip $(TARGET)
release-check:
./scripts/release_check.sh
release-check-strict:
./scripts/release_check.sh --strict
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
asan: LDFLAGS += -fsanitize=address
asan: clean $(TARGET)
@ -95,51 +74,17 @@ check:
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
# Test
test: all unit-test integration-test
test-advisory: all unit-test
test: all unit-test
@echo "Running integration tests..."
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
@cd tests && ./test_basic.sh || echo "(integration tests are advisory)"
unit-test:
@echo "Running unit tests..."
@$(MAKE) -C tests/unit run
integration-test: all
@echo "Running integration tests..."
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh
anonymous-access-test: all
@echo "Running anonymous access tests..."
@cd tests && PORT=$${PORT:-2222} ./test_anonymous_access.sh
connection-limit-test: all
@echo "Running connection limit tests..."
@cd tests && PORT=$${PORT:-2222} ./test_connection_limits.sh
security-test: all
@echo "Running security feature tests..."
@cd tests && PORT=$${PORT:-13600} ./test_security_features.sh
stress-test: all
@echo "Running stress tests..."
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30}
ci-test:
@$(MAKE) test PORT=$(CI_TEST_PORT)
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))
@$(MAKE) connection-limit-test PORT=$$(($(CI_TEST_PORT) + 10))
@$(MAKE) security-test PORT=$$(($(CI_TEST_PORT) + 20))
# Show build info
info:
@echo "Compiler: $(CC)"
@echo "Flags: $(CFLAGS)"
@echo "Sources: $(SOURCES)"
@echo "Objects: $(OBJECTS)"
-include $(DEPS)

110
README.md
View file

@ -21,8 +21,6 @@ A minimalist terminal chat server with Vim-style interface over SSH.
```sh
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
The installer verifies the downloaded release binary against `checksums.txt`
before installing it.
**From source:**
```sh
@ -47,7 +45,7 @@ PORT=3333 tnt # via env var
### Connecting
```sh
ssh -p 2222 chat.example.com
ssh -p 2222 chat.m1ng.space
```
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
@ -64,25 +62,17 @@ Backspace - Delete character
Ctrl+W - Delete last word
Ctrl+U - Delete line
Ctrl+C - Enter NORMAL mode
Paste - Multi-line paste stays in the input buffer
```
The input line shows remaining bytes near the message limit. Extra input
past the limit is ignored with a terminal bell.
**NORMAL mode**
```
Opens at latest messages
Stays pinned to latest until you scroll up
i - Return to INSERT mode
: - Enter COMMAND mode
j/k - Scroll down/up one line
Ctrl+D/U - Scroll half page down/up
Ctrl+F/B - Scroll full page down/up
PgDn/PgUp - Scroll full page down/up
End/Home - Jump to bottom/top
g/G - Jump to top/bottom
? - Show full key reference
? - Show help
Ctrl+C - Exit chat
```
@ -90,14 +80,12 @@ Ctrl+C - Exit chat
```
:list, :users - Show online users
:nick <name> - Change nickname
:msg <user> <message> - Send private message
:msg <user> <text> - Whisper to user
:w <user> <text> - Short alias for :msg
:inbox - Show private messages
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:mute-joins - Toggle join/leave system notifications
:lang <en|zh> - Switch UI language for this session
:help - Show concise manual
:help - Show available commands
:clear - Clear command output
:q, :quit, :exit - Disconnect
Up/Down - Browse command history
@ -127,10 +115,7 @@ TNT_BIND_ADDR=192.168.1.100 tnt
TNT_STATE_DIR=/var/lib/tnt tnt
# Show the public SSH endpoint in startup logs
TNT_PUBLIC_HOST=chat.example.com tnt
# Choose interactive UI language (en or zh; defaults from locale)
TNT_LANG=zh tnt
TNT_PUBLIC_HOST=chat.m1ng.space tnt
```
**Rate limiting:**
@ -173,12 +158,12 @@ tnt -p 2222
TNT also exposes a small non-interactive SSH surface for scripts:
```sh
ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users
ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 operator@chat.example.com post "service notice"
ssh -p 2222 chat.example.com post "/me deploys v2.0"
ssh -p 2222 chat.m1ng.space health
ssh -p 2222 chat.m1ng.space stats --json
ssh -p 2222 chat.m1ng.space users
ssh -p 2222 chat.m1ng.space "tail -n 20"
ssh -p 2222 operator@chat.m1ng.space post "service notice"
ssh -p 2222 chat.m1ng.space post "/me deploys v2.0"
```
**`post` identity**: the message is attributed to the SSH login name (the `user@` part of the URL, falling back to `anonymous`). In the default anonymous-access configuration there is no identity check, so any client can post as any name. Set `TNT_ACCESS_TOKEN` if you need authenticated posting.
@ -191,7 +176,6 @@ ssh -p 2222 chat.example.com post "/me deploys v2.0"
make # standard build
make debug # debug build (with symbols)
make asan # AddressSanitizer build
make release-check # local release/package preflight
make check # static analysis (cppcheck)
make clean # clean build artifacts
```
@ -199,13 +183,7 @@ make clean # clean build artifacts
### Testing
```sh
make test # run comprehensive test suite and fail on regressions
make test-advisory # run integration tests as advisory checks
make anonymous-access-test # verify default anonymous login behavior
make connection-limit-test # verify per-IP concurrency and rate limits
make security-test # run security feature checks
make stress-test # run configurable concurrent-client stress test
make ci-test # run the same checks as GitHub Actions
make test # run comprehensive test suite
# Individual tests
cd tests
@ -219,8 +197,8 @@ cd tests
**Test coverage:**
- Basic functionality: 3 tests
- Anonymous access: 2 tests
- Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
- Security features: 11 tests
- Stress test: concurrent connections
### Dependencies
@ -249,29 +227,14 @@ sudo dnf install libssh-devel
TNT/
├── src/ # source code
│ ├── main.c # entry point
│ ├── cli_text.c # startup CLI help and option text
│ ├── command_catalog.c # command metadata, usage, and argument shape
│ ├── commands.c # COMMAND-mode command dispatch
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
│ ├── exec.c # SSH exec command dispatch
│ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic
│ ├── message.c # message persistence
│ ├── history_view.c # message viewport and scroll state
│ ├── help_text.c # full-screen key reference content
│ ├── manual.c # concise manual panel rendering
│ ├── manual_text.c # concise manual content
│ ├── i18n.c # UI language and locale selection
│ ├── i18n_text.c # shared UI text catalog
│ ├── ratelimit.c # connection limits and rate limiting
│ ├── tui.c # terminal UI rendering
│ ├── tui_status.c # status/input line rendering
│ └── utf8.c # UTF-8 character handling
├── include/ # header files
├── tests/ # test scripts
├── docs/ # documentation
├── packaging/ # package-manager drafts and release checklist
├── scripts/ # operational scripts
├── Makefile # build configuration
└── README.md # this file
@ -297,7 +260,7 @@ TNT_MAX_CONN_PER_IP=30
TNT_MAX_CONN_RATE_PER_IP=60
TNT_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=0
TNT_PUBLIC_HOST=chat.example.com
TNT_PUBLIC_HOST=chat.m1ng.space
EOF
```
@ -313,23 +276,6 @@ CMD ["tnt"]
See [docs/DEPLOYMENT.md](docs/DEPLOYMENT.md) for details.
## Packaging
Package-manager drafts live in [packaging/](packaging/). Current targets are
Arch/AUR (`tnt-chat`), Homebrew tap formula, and Ubuntu PPA notes.
Before preparing a release locally:
```sh
make release-check
```
Before publishing package recipes, replace placeholder checksums and run:
```sh
make release-check-strict
```
## Files
```
@ -371,32 +317,6 @@ Delete `motd.txt` to disable the MOTD.
- **Concurrency**: Supports 100+ concurrent connections
- **Throughput**: 1000+ messages/second
## Troubleshooting
### "Connection closed by remote host" right after `ssh -p 2222 host`
TNT has very little it can say to the SSH client before disconnecting,
so any pre-auth rejection just looks like a generic close. Common
causes, fastest to slowest fix:
| Likely cause | Why | Fix |
|---|---|---|
| Per-IP concurrent limit | `TNT_MAX_CONN_PER_IP` (default 5) | Close other sessions, or raise the env var |
| Per-IP connection rate | More than `TNT_MAX_CONN_RATE_PER_IP` attempts in 60 s | Wait 5 min (block window), or raise the limit |
| Auth-failure ban | 5 wrong passwords / failed kex in a row | Wait 5 min |
| Global cap | `TNT_MAX_CONNECTIONS` (default 64) is full | Wait for someone to leave |
| Firewall | The host's ufw / iptables doesn't open 2222 | Open the port |
The server admin can confirm which by checking the systemd journal
(`sudo journalctl -u tnt -n 50 --no-pager`) — the rejection reason is
logged to stderr with the offending IP.
### Idle disconnect
After `TNT_IDLE_TIMEOUT` seconds (default 1800 = 30 min) of no
keystrokes, TNT prints a localized idle-timeout notice and closes the
channel. Set the env var to `0` to disable.
## Known Limitations
- Single chat room (no multi-room support yet)

View file

@ -47,7 +47,7 @@
**用户体验:**
```bash
# 用户连接(零配置)
ssh -p 2222 chat.example.com
ssh -p 2222 chat.m1ng.space
# 输入任意内容或直接按回车
# 开始聊天!
```
@ -143,7 +143,7 @@ ssh -p 2222 chat.example.com
tnt
# 用户端(任何人)
ssh -p 2222 chat.example.com
ssh -p 2222 chat.m1ng.space
# 输入任何内容作为密码或直接回车
# 选择显示名称(可留空)
# 开始聊天!

View file

@ -1,209 +1,5 @@
# Changelog
## Unreleased
### Changed
- Split UI-language parsing from localized text lookup: `src/i18n.c` now owns
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
catalog with coverage checks for every message ID.
- Kept command placeholders stable across localized output: Chinese help and
usage text now uses ASCII metavariables such as `<user>` and `<message>`.
- Standardized user-facing `:msg` / `:inbox` terminology around "private
message" / "私信" instead of mixing it with "whisper" wording.
- Kept localized startup CLI syntax stable by using `用法: tnt [options]`
instead of localizing the `[options]` metavariable.
- Moved SSH exec help rows into an `exec_catalog` module so command metadata
no longer lives as one large translated blob inside the shared i18n table.
- Refreshed contributor and development guidance so new commands are added
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
`ssh_server.c` / inline-`strcmp` instructions.
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
reducing duplicate command knowledge in `src/exec.c`.
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
public documentation does not imply a specific production host.
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
`src/exec.c` no longer duplicates `--json` and required-message validation.
- Moved interactive command usage text and first-pass argument-shape checks
into `command_catalog`, so known commands with bad arguments now show usage
instead of unknown-command guidance.
- Renamed the internal language state from help-oriented names to
UI-language names (`ui_lang_t`, `client->ui_lang`, and
`i18n_*_ui_lang`) so future i18n work has a correctly named seam.
- Command names, aliases, help summaries, concise-manual command rows, and
unknown-command suggestions now share a dedicated `command_catalog` module.
- COMMAND-mode output is now a small scrollable pager with `j/k`, page
movement, `g/G`, and `q`/Esc close controls, so long `:last` and `:search`
results are readable instead of being cut off by the terminal height.
- Collapsed the interactive help surface around a concise Unix-style `:help`
manual and the `?` full key reference; `:support` is no longer a user-facing
command.
- First-use hints and unknown-command guidance now point users to `:help`
instead of the removed support entry.
- The concise manual module is now named `manual_text`, and the redundant
interactive `:commands` entrypoint was removed.
- The concise `:help` manual now stays within one command-output screen so it
does not truncate on normal terminal sizes.
- Language selection is limited to stable codes (`en`, `zh`) and
locale-shaped environment values; natural-language labels are not accepted
as command arguments.
- Full-screen help now uses `l` to cycle the UI language through the i18n
module instead of hard-coding one key per language.
- Language parsing, language-code output, and help-language cycling now share
one internal language registry.
- Removed the unused `MODE_HELP` enum value and refreshed development-guide
module descriptions for the split between language parsing and text lookup.
- `i18n_text` now indexes localized strings by `UI_LANG_*` instead of storing
English/Chinese as hard-coded struct fields.
- `command_catalog` now uses the shared localized-string helper for help,
manual, and usage text instead of per-field English/Chinese members.
- `exec_catalog` now uses the same localized-string helper for exec help
summaries.
- Startup CLI help and option error formats now use the shared
localized-string helper and English fallback path.
- The concise `:help` manual text now uses the shared localized-string helper
around the command-catalog rows.
- The full-screen key reference now uses the shared localized-string helper
around the command-catalog rows.
- SSH exec help headers and usage prefixes now use the shared
localized-string helper and English fallback path.
- Documented i18n and user-facing text rules for English-first source text,
stable command syntax, concise help copy, and translation-only localization.
- Rewrote the quick setup guide as a concise English-first user lifecycle
document with a short Chinese notes section.
- The shared UI text catalog now uses the same localized-string initializer
as the smaller text modules, avoiding GCC missing-braces warnings.
## 1.0.1 - 2026-05-24 - Release candidate hardening
### Added
- Added a first i18n boundary: `TNT_LANG` / locale detection now chooses the
default interactive UI language (`en` or `zh`) for username prompts, status
hints, help output, and `:support`.
- Added `:lang <en|zh>` so users can switch the interactive UI language for
their current session.
- COMMAND-mode `:help`, unknown-command guidance, language command output, and
continuation prompts now follow the session UI language.
- The full-screen help title and footer now follow the session UI language,
with UTF-8-aware title padding for Chinese.
- Common COMMAND-mode outputs now respect the session language, including
`:users` headers and `:mute-joins` state text.
- Command-output and MOTD screen chrome now use the session UI language.
- Common command usage errors now stay in the session language, and bare
`:search`, `:msg`, and `:nick` show usage instead of falling through to
unknown-command guidance.
- Command output text for common interactive commands is now centralized in
the i18n table instead of being scattered through command flow logic.
- TUI title-bar status labels, including online count, mute marker, and help
hint, now follow the session UI language.
- Join, leave, and nickname-change system messages now use a dedicated
`system_message` module, follow the sender's session language, and keep
`:mute-joins` filtering compatible with both Chinese and English logs.
- The interactive welcome screen now follows the selected UI language,
including the narrow-terminal fallback.
- Full-screen help and COMMAND-mode help now live in a dedicated `help_text`
module, keeping large bilingual help copy out of TUI and command flow code.
- Session language, unknown-command, suggestion, and continuation prompts now
use the shared i18n table instead of inline command-flow conditionals.
- Interactive and exec support guide copy now lives in a dedicated
`support_text` module, with focused language-selection unit coverage.
- Exec-mode help, usage errors, unknown-command feedback, and post validation
messages now follow `TNT_LANG` while preserving stable machine-readable
command output.
- Startup CLI help and option errors now live in a dedicated `cli_text` module
and follow `TNT_LANG` / locale for English and Chinese users.
- Idle-timeout disconnect notices now follow the session UI language.
### Changed
- `make test` now fails on integration-test regressions; constrained local
environments can use `make test-advisory` for the previous advisory behavior.
- Removed the duplicate `deploy.yml` CI workflow so automated checks stay
focused on CI while production deployment remains manual.
- Fixed the per-IP connection-rate limit to allow the configured number of
attempts before blocking, added unit coverage, and exposed
`make connection-limit-test` for the black-box limit regression test.
- Security feature checks now use isolated ports and temporary state
directories, so they no longer require `timeout`/`gtimeout` or write
`host_key` / `messages.log` into the test directory.
- Added `make security-test` and `make ci-test` so local runs can use the same
full verification path as GitHub Actions.
- Anonymous access checks now use isolated state, wait for real SSH health,
avoid external `timeout` helpers, and run through `make anonymous-access-test`
as part of `make ci-test`.
- Stress testing now uses isolated state, waits for real SSH health, avoids
external `timeout` helpers, and is available through `make stress-test`.
- Basic integration tests now wait for real SSH `health` responses instead of
sleeping for a fixed startup delay.
- Connection-limit tests now use shared SSH health readiness checks for both
concurrent-session and connection-rate scenarios.
- CI memory-leak smoke checks now use an isolated state directory, wait for
real SSH readiness, and clean up the exact server PID instead of `pkill`.
- CI memory-leak smoke checks now pre-generate the host key and use a longer
valgrind readiness window, avoiding false failures during slow startup.
- Language parsing now tolerates surrounding whitespace and accepts the
`english` alias, improving `TNT_LANG` and `:lang` ergonomics.
- Refreshed the development guide's command/keybinding instructions so they
point at the current modular `commands`, `exec`, `i18n`, and help text files.
- Refreshed README and quick-reference module maps to match the current
`cli_text`, `help_text`, `support_text`, i18n, exec, and rate-limit modules.
- NORMAL mode now opens at the latest visible messages instead of the oldest
in-memory message. Use `k`/PageUp to browse older history and `G`/End to
return to the latest messages.
- NORMAL mode status now shows the visible message range and points users to
`G latest` when new messages arrive while they are browsing.
- NORMAL mode now keeps following the latest messages while the view is pinned
to the bottom; scrolling upward switches into history browsing.
- NORMAL mode now accepts arrow keys, PageUp/PageDown, and Home/End in addition
to the existing Vim-style keys.
- Message viewport and scroll-state rules now live in a focused
`history_view` module instead of being split across input and rendering code.
- Added unit coverage for `history_view` scroll boundaries, live-follow state,
and date-divider-aware latest windows.
- Status/input line rendering now lives in a focused `tui_status` module,
keeping the main TUI renderer closer to layout orchestration.
- Added `:support` / `support` quick guides so interactive users and SSH exec
clients can discover common actions and troubleshooting paths in-product.
- The GitHub workflow formerly named deploy now runs CI only; production
deployment remains a manual operator action.
- Command output rendering now truncates ANSI-styled UTF-8 text without
counting escape sequences as visible width or cutting color codes.
- Host-key generation now uses the non-deprecated libssh PKI API on libssh
0.12+ while keeping compatibility with older libssh releases.
- INSERT mode now shows a lightweight first-use hint for sending, browsing,
and `:support`.
- `:support` is now task-oriented around common user goals, and mistyped
commands suggest the nearest known command when possible.
- Added a local `make release-check` preflight for release/package validation
without tagging, publishing, or deploying.
- CI now installs `expect` on Ubuntu so interactive integration tests run
instead of being skipped, and runs `make release-check` on every push/PR.
- The tag-triggered release workflow now builds on native x64/arm64 runners,
verifies artifact architecture, emits one checksum file, and creates a draft
release for manual review instead of publishing immediately.
- The one-line installer now downloads `checksums.txt`, verifies the selected
binary before installation, and fails fast on missing release assets.
- Added a Debian packaging metadata draft for the future Ubuntu PPA path, with
lightweight validation in `make release-check`.
- Added an Arch `.SRCINFO` draft and AUR maintainer notes, with version/package
checks in `make release-check`.
- Added Homebrew tap maintainer notes, and expanded `make release-check` to
validate the formula class and `libssh` dependency.
## 2026-05-18 - Interactive input polish
### Added
- Bracketed paste handling keeps multi-line pasted text in the input buffer
until the user presses Enter, then sends it as one message.
- Input and paste overflow now rings the terminal bell when the 1023-byte
message limit is reached.
- Added an interactive `expect` regression test for basic TTY input,
bracketed paste, and overlong paste capping.
- Added the exec-mode regression test to the main `make test` path.
### Fixed
- SSH exec clients now survive stdin EOF long enough to flush stdout, exit
status, EOF, and channel close. This fixes non-interactive commands such as
`ssh localhost health` and `ssh user@host post "message"`.
## 2026-05-16 - Internal cleanup
### Fixed

View file

@ -1,68 +1,43 @@
CI / RELEASE GUIDE
==================
CI/CD USAGE GUIDE
=================
AUTOMATIC TESTING
-----------------
Every push or PR automatically runs:
- Build on Ubuntu
- AddressSanitizer build
- `make ci-test` (strict integration, anonymous access, connection limits,
and security feature checks)
- Release/package preflight (`make release-check`)
- Build on Ubuntu and macOS
- AddressSanitizer checks
- Valgrind memory leak detection
Check status:
https://github.com/m1ngsama/TNT/actions
Production deployment is intentionally manual. The CI workflow must not SSH
into production or restart services on push.
CREATING RELEASES
-----------------
1. Update version metadata:
- include/common.h
- tnt.1
- docs/CHANGELOG.md
- packaging/arch/PKGBUILD
- packaging/homebrew/tnt-chat.rb
1. Update version in CHANGELOG.md
2. Run the local preflight:
make release-check
2. Create and push tag:
git tag v1.0.0
git push origin v1.0.0
3. Replace package checksum placeholders and run:
make release-check-strict
4. Create and push tag:
git tag v1.0.1
git push origin v1.0.1
5. GitHub Actions automatically:
3. GitHub Actions automatically:
- Builds binaries (Linux/macOS, AMD64/ARM64)
- Creates a draft release
- Creates release
- Uploads binaries
- Generates one `checksums.txt` file
- Verifies that artifact architecture matches the asset name
- Generates checksums
6. Review the draft release, smoke-test downloaded assets, then publish it
manually from GitHub.
7. Release appears at:
4. Release appears at:
https://github.com/m1ngsama/TNT/releases
DEPLOYING TO SERVERS
--------------------
Deployments are operator-driven:
1. Build and test locally or in a temporary server directory.
2. Back up the installed binary.
3. Install the new binary.
4. Restart the service.
5. Run black-box checks (`health`, `stats --json`, `users --json`,
and a post/tail smoke test).
The installer can still be used manually on a server:
Single command on any server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Or with specific version:
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
PRODUCTION SETUP (systemd)
---------------------------
@ -83,11 +58,14 @@ PRODUCTION SETUP (systemd)
UPDATING SERVERS
----------------
Manual binary replacement pattern:
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
sudo cp -a /usr/local/bin/tnt "$backup"
sudo install -m 755 ./tnt /usr/local/bin/tnt
sudo systemctl restart tnt
Stop service:
sudo systemctl stop tnt
Run installer again:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Restart:
sudo systemctl start tnt
PLATFORMS SUPPORTED
@ -101,7 +79,7 @@ PLATFORMS SUPPORTED
EXAMPLE WORKFLOW
----------------
# Local development
make && make asan && make release-check
make && make asan
./tnt
# Create release
@ -109,7 +87,8 @@ git tag v1.0.1
git push origin v1.0.1
# Wait 5 minutes for builds
# Deploy to production manually after validation
ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt"
ssh server "sudo systemctl restart tnt"
ssh -p 2222 server health
# Deploy to production servers
for server in server1 server2 server3; do
ssh $server "curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | VERSION=v1.0.1 sh"
ssh $server "sudo systemctl restart tnt"
done

View file

@ -3,19 +3,17 @@
## Build
```sh
make # normal build
make debug # with symbols
make asan # AddressSanitizer
make release # optimized
make release-check # release preflight
make # normal build
make debug # with symbols
make asan # AddressSanitizer
make release # optimized
```
## Test
```sh
make test # unit + integration tests
make ci-test # local CI-equivalent checks
make stress-test # concurrent-client stress test
./test_basic.sh # functional tests
./test_stress.sh 20 60 # 20 clients, 60 seconds
```
## Debug
@ -36,21 +34,10 @@ make check
```
main.c → entry point, signal handling
cli_text.c → startup CLI text
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching, usage, and argument shape
exec.c → SSH exec command dispatch
ssh_server.c → SSH listener setup
bootstrap.c → SSH authentication/session bootstrap
input.c → interactive session loop
ssh_server.c → SSH protocol, client threads
chat_room.c → client list, message broadcast
history_view.c → message viewport and scroll state
i18n.c → UI language and locale selection
i18n_text.c → shared UI text catalog
message.c → persistent storage
tui.c → terminal rendering
tui_status.c → status/input-line rendering
utf8.c → UTF-8 string handling
```
@ -83,13 +70,9 @@ utf8.c → UTF-8 string handling
## Adding Features
1. Add interactive command metadata, usage text, and argument shape in
`src/command_catalog.c`.
2. Add interactive command behavior in `src/commands.c`.
3. Add SSH exec metadata in `src/exec_catalog.c` and dispatch in `src/exec.c`
only when the feature should be scriptable.
4. Put shared localized strings in `src/i18n_text.c`.
5. Add or update the narrowest unit/integration test for the behavior.
1. Add new command in `execute_command()` (ssh_server.c:190)
2. Add new mode in `client_mode_t` enum (common.h:30)
3. Add new vim key in `handle_key()` (ssh_server.c:220)
## Debugging Tips

View file

@ -9,7 +9,7 @@ curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Specific version:
```bash
VERSION=v1.0.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
## Manual Install
@ -54,7 +54,7 @@ TNT_MAX_CONN_PER_IP=30
TNT_MAX_CONN_RATE_PER_IP=60
TNT_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=0
TNT_PUBLIC_HOST=chat.example.com
TNT_PUBLIC_HOST=chat.m1ng.space
EOF
sudo systemctl restart tnt
@ -97,7 +97,7 @@ Place a `motd.txt` file in the state directory. TNT displays it to each user on
# Systemd deployment (state dir is /var/lib/tnt)
sudo tee /var/lib/tnt/motd.txt <<'EOF'
Welcome! Be respectful. No spam.
Type :help for a concise manual, or ? for the full key reference.
Type :help for available commands.
EOF
sudo chown tnt:tnt /var/lib/tnt/motd.txt

View file

@ -9,10 +9,9 @@ Complete guide for TNT developers and contributors.
3. [Building and Testing](#building-and-testing)
4. [Core Components](#core-components)
5. [Adding Features](#adding-features)
6. [User-Facing Text and i18n](#user-facing-text-and-i18n)
7. [Debugging](#debugging)
8. [Performance Optimization](#performance-optimization)
9. [Contributing Guidelines](#contributing-guidelines)
6. [Debugging](#debugging)
7. [Performance Optimization](#performance-optimization)
8. [Contributing Guidelines](#contributing-guidelines)
---
@ -68,26 +67,11 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
```
src/
├── main.c - CLI entry point and startup option parsing
├── ssh_server.c - SSH listener setup and connection accept loop
├── bootstrap.c - SSH authentication/session bootstrap
├── input.c - Interactive session loop and key handling
├── commands.c - COMMAND-mode command dispatch
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
├── exec_catalog.c - SSH exec command matching and help metadata
├── exec.c - SSH exec command dispatch
├── chat_room.c - Chat room logic and message broadcasting
├── main.c - Entry point, signal handling
├── ssh_server.c - SSH server, client threads, authentication
├── chat_room.c - Chat room logic, message broadcasting
├── message.c - Message persistence (RFC3339 format)
├── history_view.c - NORMAL-mode scroll window rules
├── tui.c - Terminal UI rendering (ANSI escape codes)
├── tui_status.c - Mode/status/input-line rendering
├── i18n.c - UI language selection and locale parsing
├── i18n_text.c - Shared UI text catalog
├── help_text.c - Full-screen key reference text
├── manual.c - Concise manual panel rendering
├── manual_text.c - Concise manual text
├── system_message.c - Localized join/leave/nick system messages
├── ratelimit.c - Per-IP and global connection limits
└── utf8.c - UTF-8 character handling
```
@ -97,17 +81,9 @@ src/
include/
├── common.h - Common definitions, constants
├── ssh_server.h - SSH server interface
├── bootstrap.h - SSH session bootstrap interface
├── chat_room.h - Chat room interface
├── message.h - Message structure and persistence
├── command_catalog.h - COMMAND-mode command metadata interface
├── history_view.h - Scroll-state helpers
├── tui.h - TUI rendering functions
├── i18n.h - Language and shared text IDs
├── help_text.h - Key reference text interface
├── manual.h - Concise manual panel interface
├── manual_text.h - Concise manual text interface
├── ratelimit.h - Connection limit interface
└── utf8.h - UTF-8 utilities
```
@ -183,13 +159,7 @@ make install # Install to /usr/local/bin
### Running Tests
```sh
make test # Run all tests and fail on regressions
make test-advisory # Run integration tests as advisory checks
make anonymous-access-test # Verify default anonymous login behavior
make connection-limit-test # Verify per-IP concurrency and rate limits
make security-test # Run security feature checks
make stress-test # Run configurable concurrent-client stress test
make ci-test # Run the same checks as GitHub Actions
make test # Run all tests
# Individual tests
cd tests
@ -358,115 +328,42 @@ void utf8_remove_last_word(char *str) {
### Adding a New Command
1. **For interactive COMMAND mode, add command metadata in `src/command_catalog.c`:**
1. **Add to `execute_command()` in ssh_server.c:**
```c
{
{TNT_COMMAND_NEWCMD, "newcmd", {"newcmd", NULL}, false},
":newcmd", ":newcmd",
"Show new output", "显示新输出",
":newcmd", ":newcmd", 3
if (strcmp(cmd, "newcmd") == 0) {
pos += snprintf(output + pos, sizeof(output) - pos,
"New command output\n");
}
```
2. **Add interactive behavior in `src/commands.c` by switching on the command ID.**
2. **Update help text in tui.c:**
```c
"AVAILABLE COMMANDS:\n"
" newcmd - Description of new command\n"
```
3. **For SSH exec mode, add help metadata in `src/exec_catalog.c` and the stable command path in `src/exec.c` if it should work non-interactively.**
4. **Move shared user-facing strings through `src/i18n_text.c` when they need localization or are reused. Keep command syntax and metavariables ASCII.**
5. **Update user help surfaces through their catalogs. Avoid duplicating command rows by hand.**
6. **Add tests in the narrowest target:**
3. **Add test in tests/test_basic.sh:**
```sh
tests/test_exec_mode.sh # exec command behavior
tests/test_interactive_input.sh # COMMAND-mode/TUI behavior
tests/unit/test_i18n.c # localized shared text
tests/unit/test_command_catalog.c # interactive command metadata
tests/unit/test_exec_catalog.c # exec command help metadata
echo ":newcmd" | timeout 5 ssh -p $PORT localhost
```
### Adding a New Keybinding
1. **Add to the relevant mode handler in `src/input.c`:**
1. **Add to `handle_key()` in ssh_server.c:**
```c
case MODE_INSERT:
if (key == 26) { /* Ctrl+Z */
/* Handle Ctrl+Z */
return true;
}
}
```
2. **Update `src/help_text.c` and status hints in `src/i18n_text.c` /
`src/tui_status.c` if the binding is user-visible.**
2. **Update help text in tui.c**
3. **Document in README.md**
---
## User-Facing Text and i18n
TNT should follow Unix/open-source conventions for user-facing text:
English is the source language, command syntax is stable ASCII, and
translations are presentation only. A localized interface must never create
localized command names, localized option names, or localized configuration
keys.
### Principles
1. **English-first source text**
- Keep code identifiers, comments, command names, option names, and
documentation source in English.
- Treat English text as the canonical source text for future gettext-style
catalogs.
- Do not use translated text as a programmatic key.
2. **Stable language identifiers**
- Interactive `:lang` accepts only stable language codes: `en` and `zh`.
- Code should name this concept `ui_lang`, not `help_lang`; the same value
controls prompts, status text, help, command output, MOTD chrome, and
system messages.
- Locale detection may accept locale-shaped values such as
`en_US.UTF-8`, `zh_CN.UTF-8`, `C`, and `POSIX`.
- Do not accept natural-language labels such as `english`, `chinese`,
`中文`, or `英文` as command arguments.
- If regional variants are added later, add explicit locale identifiers
such as `zh_TW` instead of overloading `zh`.
3. **Concise writing**
- Prefer imperative verbs: "Show", "Switch", "Disconnect".
- Keep command descriptions noun-like or verb-like, not explanatory prose.
- Avoid tutorial language in `:help`; put detailed behavior in `tnt(1)`.
- Keep `:help` within one command-output screen. `?` is the full key
reference.
4. **One behavior, one name**
- Do not create parallel help commands for the same task.
- Keep `:help` for the concise manual and `?` for the full key reference.
- Keep SSH exec commands small, scriptable, and stable.
5. **Translation safety**
- Use whole sentences or whole phrases; do not concatenate translated
fragments.
- Keep placeholders visible and stable, for example `%s`, `%d`,
`<user>`, and `<message>`.
- Every new user-facing string needs tests for at least English fallback
and Chinese output while this project has two UI languages.
### Current Limitations
The current `src/i18n_text.c` implementation is a small-project translation
table implemented in C, not a full gettext catalog. It is acceptable for two
languages because message lookup is already split from language parsing in
`src/i18n.c`, but adding more languages should move toward catalog-like
storage instead of adding ad hoc branches for every locale.
Relevant conventions:
- POSIX locale variables: `LANG`, `LC_ALL`, `LC_MESSAGES`.
- GNU gettext source preparation: decent English, whole sentences, and
format placeholders rather than string concatenation.
---
## Debugging
### Enable Verbose SSH Logging

View file

@ -1,224 +1,278 @@
# TNT Quick Setup
# TNT 匿名聊天室 - 快速部署指南 / TNT Anonymous Chat - Quick Setup Guide
This guide gets a TNT server running and explains the first user session.
For the full reference, see [README.md](../README.md), [tnt(1)](../tnt.1),
and [Deployment](DEPLOYMENT.md).
[中文](#中文) | [English](#english)
## Install
---
```sh
## 中文
### 一键安装
```bash
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
Or build from source:
### 启动服务器
```sh
git clone https://github.com/m1ngsama/TNT.git
cd TNT
make
sudo make install
```bash
tnt # 监听 2222 端口
```
## Start A Server
就这么简单!服务器已经运行了。
```sh
tnt
### 用户如何连接
用户只需要一个SSH客户端即可无需任何配置
```bash
ssh -p 2222 chat.m1ng.space
```
By default TNT listens on port `2222`, stores `host_key` and `messages.log`
in the current directory, and allows anonymous SSH login.
**重要提示**
- ✅ 用户可以使用**任意用户名**连接
- ✅ 用户可以输入**任意密码**(甚至直接按回车跳过)
- ✅ **不需要SSH密钥**
- ✅ 不需要提前注册账号
- ✅ 完全匿名,零门槛
Use explicit state and port settings for a long-running server:
连接后,系统会提示输入显示名称(也可以留空使用默认名称)。
```sh
tnt -p 2222 -d /var/lib/tnt
```
### 生产环境部署
## Connect
使用 systemd 让服务器开机自启:
```sh
ssh -p 2222 chat.example.com
```
Default access rules:
- Any SSH username is accepted.
- Empty or arbitrary passwords are accepted.
- SSH keys are not required.
- TNT asks for a display name after the SSH session starts.
Set `TNT_ACCESS_TOKEN` when you want a shared password:
```sh
TNT_ACCESS_TOKEN="change-this-password" tnt -p 2222 -d /var/lib/tnt
```
## First Session
TNT opens in INSERT mode. Type a message and press `Enter`.
Common keys:
```text
Esc enter NORMAL mode
i return to INSERT mode
: enter COMMAND mode
? open the full key reference
G or End jump to latest messages
Ctrl+C disconnect from NORMAL mode
```
Common commands:
```text
:help concise manual
:users online users
:nick <name> change nickname
:msg <user> <message> send private message
:inbox show private messages
:last [N] recent messages
:search <keyword> search message history
:lang en|zh switch UI language
:q disconnect
```
NORMAL mode opens at the latest messages and follows new messages until the
user scrolls up. Use `G` or `End` to return to the live tail.
## Configure
```sh
# Listening address and port
TNT_BIND_ADDR=0.0.0.0 PORT=2222 tnt
# State directory
TNT_STATE_DIR=/var/lib/tnt tnt
# Shared SSH password
TNT_ACCESS_TOKEN="change-this-password" tnt
# Default UI language; unset means locale detection, then English fallback
TNT_LANG=en tnt
TNT_LANG=zh tnt
# Connection limits
TNT_MAX_CONNECTIONS=200 tnt
TNT_MAX_CONN_PER_IP=30 tnt
TNT_MAX_CONN_RATE_PER_IP=60 tnt
# Idle timeout in seconds; 0 disables it
TNT_IDLE_TIMEOUT=3600 tnt
```
## Run Under systemd
```sh
```bash
# 1. 创建专用用户
sudo useradd -r -s /bin/false tnt
sudo mkdir -p /var/lib/tnt
sudo chown tnt:tnt /var/lib/tnt
# 2. 安装服务
sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tnt
sudo systemctl enable tnt
sudo systemctl start tnt
# 3. 检查状态
sudo systemctl status tnt
```
Put runtime overrides in `/etc/default/tnt`:
### 防火墙设置
```sh
PORT=2222
TNT_BIND_ADDR=0.0.0.0
TNT_STATE_DIR=/var/lib/tnt
TNT_MAX_CONNECTIONS=200
TNT_MAX_CONN_PER_IP=30
TNT_MAX_CONN_RATE_PER_IP=60
TNT_RATE_LIMIT=1
TNT_SSH_LOG_LEVEL=1
TNT_PUBLIC_HOST=chat.example.com
```
记得开放2222端口
Open the listening port in your firewall:
```sh
```bash
# Ubuntu/Debian
sudo ufw allow 2222/tcp
# CentOS/RHEL
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload
```
## Troubleshooting
### 可选配置
### Port Already In Use
通过环境变量进行高级配置:
```sh
tnt -p 3333
```bash
# 修改端口
PORT=3333 tnt
# 限制最大连接数
TNT_MAX_CONNECTIONS=100 tnt
# 限制每个IP的最大连接数
TNT_MAX_CONN_PER_IP=10 tnt
# 只允许本地访问
TNT_BIND_ADDR=127.0.0.1 tnt
# 添加访问密码(所有用户共用一个密码)
TNT_ACCESS_TOKEN="your_secret_password" tnt
```
### Cannot Connect
**注意**:设置 `TNT_ACCESS_TOKEN` 后,所有用户必须使用该密码才能连接,这会提高安全性但也会增加使用门槛。
Check the server process:
### 特性
```sh
systemctl status tnt
sudo journalctl -u tnt -n 50 --no-pager
```
- 🚀 **零配置** - 开箱即用
- 🔓 **完全匿名** - 无需注册,无需密钥
- 🎨 **Vim风格界面** - 支持 INSERT/NORMAL/COMMAND 三种模式
- 📜 **消息历史** - 自动保存聊天记录
- 🌐 **UTF-8支持** - 完美支持中英文及其他语言
- 🔒 **可选安全特性** - 支持限流、访问控制等
Check the listening port:
---
```sh
ss -ltnp | grep 2222
```
## English
Check the firewall:
### One-Line Installation
```sh
sudo ufw status
```
### Connection Closes Immediately
The most common causes are per-IP connection limits, connection-rate limits,
an auth-failure ban, a full room, or a closed firewall port. The server logs
the rejection reason to stderr or the systemd journal.
## Chinese Quick Notes
### 安装
```sh
```bash
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
### 启动
### Start Server
```sh
```bash
tnt # Listen on port 2222
```
That's it! Your server is now running.
### How Users Connect
Users only need an SSH client, no configuration required:
```bash
ssh -p 2222 chat.m1ng.space
```
**Important**:
- ✅ Users can use **ANY username**
- ✅ Users can enter **ANY password** (or just press Enter to skip)
- ✅ **No SSH keys required**
- ✅ No registration needed
- ✅ Completely anonymous, zero barrier
After connecting, the system will prompt for a display name (can be left empty for default name).
### Production Deployment
Use systemd for auto-start on boot:
```bash
# 1. Create dedicated user
sudo useradd -r -s /bin/false tnt
sudo mkdir -p /var/lib/tnt
sudo chown tnt:tnt /var/lib/tnt
# 2. Install service
sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable tnt
sudo systemctl start tnt
# 3. Check status
sudo systemctl status tnt
```
### Firewall Configuration
Remember to open port 2222:
```bash
# Ubuntu/Debian
sudo ufw allow 2222/tcp
# CentOS/RHEL
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload
```
### Optional Configuration
Advanced configuration via environment variables:
```bash
# Change port
PORT=3333 tnt
# Limit max connections
TNT_MAX_CONNECTIONS=100 tnt
# Limit concurrent sessions per IP
TNT_MAX_CONN_PER_IP=10 tnt
# Limit new connections per IP per 60 seconds
TNT_MAX_CONN_RATE_PER_IP=30 tnt
# Bind to localhost only
TNT_BIND_ADDR=127.0.0.1 tnt
# Add password protection (shared password for all users)
TNT_ACCESS_TOKEN="your_secret_password" tnt
```
**Note**: Setting `TNT_ACCESS_TOKEN` requires all users to use that password to connect. This increases security but also raises the barrier to entry.
### Features
- 🚀 **Zero Configuration** - Works out of the box
- 🔓 **Fully Anonymous** - No registration, no keys
- 🎨 **Vim-Style Interface** - Supports INSERT/NORMAL/COMMAND modes
- 📜 **Message History** - Automatic chat log persistence
- 🌐 **UTF-8 Support** - Perfect for all languages
- 🔒 **Optional Security** - Rate limiting, access control, etc.
---
## 使用示例 / Usage Examples
### 基本使用 / Basic Usage
```bash
# 启动服务器
tnt
# 用户连接(从任何机器)
ssh -p 2222 chat.m1ng.space
# 输入任意密码或直接回车
# 输入显示名称或留空
# 开始聊天!
```
默认监听 `2222` 端口,并允许匿名 SSH 登录。
### Vim风格操作 / Vim-Style Operations
### 连接
连接后:
```sh
ssh -p 2222 chat.example.com
- **INSERT 模式**(默认):直接输入消息,按 Enter 发送
- **NORMAL 模式**:按 `ESC` 进入,使用 `j/k` 滚动历史,`g/G` 跳转顶部/底部
- **COMMAND 模式**:按 `:` 进入,输入 `:list` 查看在线用户,`:help` 查看帮助
### 故障排除 / Troubleshooting
#### 问题:端口已被占用
```bash
# 更换端口
tnt -p 3333
```
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
#### 问题:防火墙阻止连接
### 常用操作
```bash
# 检查防火墙状态
sudo ufw status
sudo firewall-cmd --list-ports
```text
Enter 发送消息
Esc 进入 NORMAL 模式
i 回到 INSERT 模式
: 输入命令
? 查看完整按键参考
G 或 End 回到最新消息
:help 查看简明手册
:lang en|zh 切换界面语言
:q 断开连接
# 确保已开放端口
sudo ufw allow 2222/tcp
```
### 常用配置
#### 问题:连接超时
```sh
TNT_ACCESS_TOKEN="change-this-password" tnt
TNT_STATE_DIR=/var/lib/tnt tnt
TNT_LANG=zh tnt
```bash
# 检查服务器是否运行
ps aux | grep tnt
# 检查端口监听
sudo lsof -i:2222
```
---
## 技术细节 / Technical Details
- **语言**: C
- **依赖**: libssh
- **并发**: 多线程,支持数百个同时连接
- **安全**: 可选限流、访问控制、密码保护
- **存储**: 简单的文本日志messages.log
- **配置**: 环境变量,无配置文件
---
## 许可证 / License
MIT License - 自由使用、修改、分发

View file

@ -9,13 +9,8 @@ BUILD
make clean remove artifacts
TEST
make test strict unit + integration tests
make test-advisory unit tests + advisory integration checks
make anonymous-access-test default anonymous login checks
make connection-limit-test per-IP concurrency/rate-limit checks
make security-test security feature checks
make stress-test concurrent-client stress test
make ci-test same checks as GitHub Actions
./test_basic.sh basic functionality
./test_stress.sh 20 60 stress test (20 clients, 60s)
DEBUG
ASAN_OPTIONS=detect_leaks=1 ./tnt
@ -25,44 +20,25 @@ DEBUG
COMMANDS (COMMAND mode, prefix with :)
list, users, who show online users
nick <name> change nickname
msg <user> <message> send private message
msg <user> <text> whisper to user
w <user> <text> alias for msg
inbox show private messages
last [N] last N messages from log (default 10, max 50)
search <keyword> search full history (case-insensitive, 15 results)
mute-joins toggle join/leave notifications
help concise manual
lang [en|zh] show or switch UI language
help show all commands
clear clear output
q / quit / exit disconnect
INSERT MODE
/me <action> action message
@username mention (bell + highlight)
paste multi-line paste stays in the input buffer
limit 1023 bytes/message; over-limit input rings bell
normal opens/follows latest; k/PgUp older, j/PgDn newer
STRUCTURE
src/main.c entry, signals
src/cli_text.c startup CLI text
src/command_catalog.c command metadata, usage, argument shape
src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap
src/chat_room.c broadcast and room state
src/commands.c COMMAND-mode command dispatch
src/exec_catalog.c SSH exec command matching, usage, argument shape
src/exec.c SSH exec command dispatch
src/ssh_server.c SSH, threads, commands
src/chat_room.c broadcast
src/message.c persistence, search
src/history_view.c message viewport / scroll state
src/help_text.c full-screen key reference text
src/manual.c concise manual panel rendering
src/manual_text.c concise manual content
src/i18n.c UI language and locale selection
src/i18n_text.c shared UI text catalog
src/ratelimit.c connection limits and rate limiting
src/tui.c rendering
src/tui_status.c status/input line rendering
src/tui.c rendering, help
src/utf8.c unicode
LIMITS

View file

@ -1,12 +0,0 @@
#ifndef CLI_TEXT_H
#define CLI_TEXT_H
#include "common.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, ui_lang_t lang);
const char *cli_text_invalid_port_format(ui_lang_t lang);
const char *cli_text_unknown_option_format(ui_lang_t lang);
const char *cli_text_short_usage_format(ui_lang_t lang);
#endif /* CLI_TEXT_H */

View file

@ -1,39 +0,0 @@
#ifndef COMMAND_CATALOG_H
#define COMMAND_CATALOG_H
#include "common.h"
typedef enum {
TNT_COMMAND_USERS,
TNT_COMMAND_HELP,
TNT_COMMAND_LANG,
TNT_COMMAND_MSG,
TNT_COMMAND_INBOX,
TNT_COMMAND_NICK,
TNT_COMMAND_LAST,
TNT_COMMAND_SEARCH,
TNT_COMMAND_MUTE_JOINS,
TNT_COMMAND_QUIT,
TNT_COMMAND_CLEAR,
TNT_COMMAND_COUNT
} tnt_command_id_t;
typedef struct {
tnt_command_id_t id;
const char *canonical;
const char *names[4];
} tnt_command_spec_t;
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id);
bool command_catalog_match(const char *line, tnt_command_id_t *id,
const char **args);
bool command_catalog_args_valid(tnt_command_id_t id, const char *args);
const char *command_catalog_suggest(const char *name);
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang);
#endif /* COMMAND_CATALOG_H */

View file

@ -12,7 +12,7 @@
#include <pthread.h>
/* Project Metadata */
#define TNT_VERSION "1.0.1"
#define TNT_VERSION "1.0.0"
/* Configuration constants */
#define DEFAULT_PORT 2222
@ -20,7 +20,6 @@
#define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024
#define MAX_COMMAND_OUTPUT_LEN 8192
#define MAX_CLIENTS 64
#define LOG_FILE "messages.log"
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
@ -40,15 +39,15 @@
typedef enum {
MODE_INSERT,
MODE_NORMAL,
MODE_COMMAND
MODE_COMMAND,
MODE_HELP
} client_mode_t;
/* UI language */
/* Help language */
typedef enum {
UI_LANG_EN,
UI_LANG_ZH,
UI_LANG_COUNT
} ui_lang_t;
LANG_EN,
LANG_ZH
} help_lang_t;
/* Runtime helpers */
const char* tnt_state_dir(void);

View file

@ -1,24 +0,0 @@
#ifndef EXEC_CATALOG_H
#define EXEC_CATALOG_H
#include "common.h"
typedef enum {
TNT_EXEC_COMMAND_HELP,
TNT_EXEC_COMMAND_HEALTH,
TNT_EXEC_COMMAND_USERS,
TNT_EXEC_COMMAND_STATS,
TNT_EXEC_COMMAND_TAIL,
TNT_EXEC_COMMAND_POST,
TNT_EXEC_COMMAND_EXIT
} tnt_exec_command_id_t;
bool exec_catalog_match(const char *line, 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,
ui_lang_t lang);
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang);
#endif /* EXEC_CATALOG_H */

View file

@ -1,9 +0,0 @@
#ifndef HELP_TEXT_H
#define HELP_TEXT_H
#include "common.h"
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
#endif /* HELP_TEXT_H */

View file

@ -1,16 +0,0 @@
#ifndef HISTORY_VIEW_H
#define HISTORY_VIEW_H
#include "message.h"
int history_view_height(int terminal_height);
int history_view_max_scroll(int message_count, int view_height);
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
int message_count, int view_height);
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail);
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
int message_count, int view_height, int delta);
int history_view_latest_start_for_height(const message_t *messages, int count,
int height);
#endif /* HISTORY_VIEW_H */

View file

@ -1,85 +0,0 @@
#ifndef I18N_H
#define I18N_H
#include "common.h"
typedef struct {
const char *text[UI_LANG_COUNT];
} i18n_string_t;
#define I18N_STRING(en_text, zh_text) \
{{ [UI_LANG_EN] = (en_text), [UI_LANG_ZH] = (zh_text) }}
typedef enum {
I18N_USERNAME_PROMPT,
I18N_INVALID_USERNAME,
I18N_ROOM_FULL,
I18N_WELCOME_SUBTITLE,
I18N_WELCOME_TAGLINE,
I18N_WELCOME_FALLBACK_FORMAT,
I18N_INSERT_HINT_WIDE,
I18N_INSERT_HINT_NARROW,
I18N_NORMAL_LATEST,
I18N_NORMAL_NEW_MESSAGES,
I18N_HELP_TITLE,
I18N_HELP_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_TITLE,
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
I18N_MOTD_TITLE,
I18N_MOTD_CONTINUE_HINT,
I18N_TITLE_ONLINE_FORMAT,
I18N_TITLE_MUTED,
I18N_TITLE_HELP_HINT,
I18N_IDLE_TIMEOUT_FORMAT,
I18N_SYSTEM_USERNAME,
I18N_SYSTEM_JOIN_FORMAT,
I18N_SYSTEM_LEAVE_FORMAT,
I18N_SYSTEM_NICK_FORMAT,
I18N_USERS_TITLE,
I18N_MSG_SENT_FORMAT,
I18N_MSG_USER_NOT_FOUND_FORMAT,
I18N_INBOX_TITLE,
I18N_INBOX_EMPTY,
I18N_NICK_INVALID,
I18N_NICK_TAKEN_FORMAT,
I18N_NICK_UNCHANGED,
I18N_NICK_CHANGED_FORMAT,
I18N_LAST_HEADER_FORMAT,
I18N_SEARCH_HEADER_FORMAT,
I18N_MUTE_JOINS_FORMAT,
I18N_MUTE_JOINS_MUTED,
I18N_MUTE_JOINS_UNMUTED,
I18N_CLEAR_DONE,
I18N_LANG_CURRENT_FORMAT,
I18N_LANG_SET_FORMAT,
I18N_LANG_UNSUPPORTED_FORMAT,
I18N_UNKNOWN_COMMAND_FORMAT,
I18N_DID_YOU_MEAN_FORMAT,
I18N_UNKNOWN_GUIDANCE,
I18N_EXEC_POST_EMPTY,
I18N_EXEC_POST_INVALID_UTF8,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
I18N_TEXT_COUNT
} i18n_text_id_t;
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang);
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback);
ui_lang_t i18n_default_ui_lang(void);
ui_lang_t i18n_next_ui_lang(ui_lang_t lang);
const char *i18n_ui_lang_code(ui_lang_t lang);
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id);
static inline const char *i18n_string(i18n_string_t value, ui_lang_t lang) {
if ((int)lang < 0 || lang >= UI_LANG_COUNT) {
lang = UI_LANG_EN;
}
if (value.text[lang]) {
return value.text[lang];
}
if (value.text[UI_LANG_EN]) {
return value.text[UI_LANG_EN];
}
return "";
}
#endif /* I18N_H */

View file

@ -1,9 +0,0 @@
#ifndef MANUAL_H
#define MANUAL_H
#include "common.h"
void manual_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos, ui_lang_t lang);
#endif /* MANUAL_H */

View file

@ -1,9 +0,0 @@
#ifndef MANUAL_TEXT_H
#define MANUAL_TEXT_H
#include "common.h"
void manual_text_append_interactive(char *buffer, size_t buf_size,
size_t *pos, ui_lang_t lang);
#endif /* MANUAL_TEXT_H */

View file

@ -7,16 +7,6 @@
#include <libssh/libssh.h>
#include <libssh/server.h>
/* One stored whisper. Kept per-recipient, not broadcast to the room
* and not persisted to messages.log. Inbox is bounded; oldest slides
* out FIFO. */
#define WHISPER_INBOX_SIZE 16
typedef struct {
time_t timestamp;
char from[MAX_USERNAME_LEN];
char content[MAX_MESSAGE_LEN];
} whisper_t;
/* Client connection structure */
typedef struct client {
ssh_session session; /* SSH session */
@ -26,34 +16,21 @@ typedef struct client {
_Atomic int width;
_Atomic int height;
client_mode_t mode;
ui_lang_t ui_lang;
help_lang_t help_lang;
int scroll_pos;
bool follow_tail; /* NORMAL stays pinned to latest until user scrolls up */
int help_scroll_pos;
bool show_help;
char command_input[256];
char command_history[16][256];
int command_history_count;
int command_history_pos;
/* INSERT mode chat-message history. Last 16 messages this client
* sent, oldest first. Up/Down in INSERT mode walks through it. */
char insert_history[16][MAX_MESSAGE_LEN];
int insert_history_count;
int insert_history_pos;
char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll;
char command_output[2048];
bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN];
char ssh_login[MAX_USERNAME_LEN];
time_t connect_time;
time_t last_active;
atomic_bool redraw_pending;
_Atomic int unread_mentions; /* @-mentions received since last reset */
_Atomic int unread_whispers; /* whispers received since last :inbox view */
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
* the client's own thread inside :inbox handling. */
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
int whisper_inbox_count;
bool mute_joins;
pthread_t thread;
atomic_bool connected;

View file

@ -1,17 +0,0 @@
#ifndef SYSTEM_MESSAGE_H
#define SYSTEM_MESSAGE_H
#include "common.h"
#include "message.h"
void system_message_make_join(message_t *msg, const char *username,
ui_lang_t lang);
void system_message_make_leave(message_t *msg, const char *username,
ui_lang_t lang);
void system_message_make_nick(message_t *msg, const char *old_name,
const char *new_name, ui_lang_t lang);
bool system_message_is_system(const message_t *msg);
bool system_message_is_join_leave(const message_t *msg);
#endif /* SYSTEM_MESSAGE_H */

View file

@ -32,4 +32,7 @@ void tui_clear_screen(struct client *client);
* itself afterwards. */
void tui_render_welcome(struct client *client);
/* Get help text based on language */
const char* tui_get_help_text(help_lang_t lang);
#endif /* TUI_H */

View file

@ -1,12 +0,0 @@
#ifndef TUI_STATUS_H
#define TUI_STATUS_H
#include "common.h"
struct client;
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count,
int start, int end);
#endif /* TUI_STATUS_H */

View file

@ -15,16 +15,9 @@ uint32_t utf8_decode(const char *str, int *bytes_read);
/* Calculate display width of a UTF-8 string (considering CJK double-width) */
int utf8_string_width(const char *str);
/* Calculate display width while treating ANSI escape sequences as zero-width */
int utf8_ansi_string_width(const char *str);
/* Truncate string to fit within max_width display characters */
void utf8_truncate(char *str, int max_width);
/* Truncate ANSI-styled UTF-8 text without cutting escape sequences */
void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size,
int max_width);
/* Count the number of UTF-8 characters in a string */
int utf8_strlen(const char *str);

View file

@ -1,51 +1,24 @@
#!/bin/sh
# TNT installer
# TNT Auto-deploy script
# Usage: curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
set -e
VERSION=${VERSION:-latest}
INSTALL_DIR=${INSTALL_DIR:-/usr/local/bin}
REPO="m1ngsama/TNT"
fail() {
echo "ERROR: $*" >&2
exit 1
}
need_cmd() {
command -v "$1" >/dev/null 2>&1 || fail "$1 is required"
}
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
return 1
fi
}
need_cmd curl
need_cmd awk
# Detect OS and architecture
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
case "$OS" in
linux|darwin) ;;
*) fail "Unsupported OS: $OS" ;;
esac
case "$ARCH" in
x86_64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
*) fail "Unsupported architecture: $ARCH" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
BINARY="tnt-${OS}-${ARCH}"
REPO="m1ngsama/TNT"
echo "=== TNT Installer ==="
echo "OS: $OS"
@ -56,57 +29,34 @@ echo ""
# Get latest version if not specified
if [ "$VERSION" = "latest" ]; then
echo "Fetching latest version..."
VERSION=$(curl -fsSL "https://api.github.com/repos/$REPO/releases/latest" |
sed -n 's/.*"tag_name":[[:space:]]*"\([^"]*\)".*/\1/p' |
head -n 1)
[ -n "$VERSION" ] || fail "Could not determine latest release version"
VERSION=$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
fi
echo "Installing version: $VERSION"
# Download
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
echo "Downloading from: $URL"
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
cleanup() {
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
}
trap cleanup EXIT INT TERM
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
echo "Downloading checksums from: $CHECKSUM_URL"
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
fail "Failed to download checksums.txt"
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
fail "sha256sum or shasum is required for checksum verification"
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
fail "Checksum mismatch for $BINARY"
echo "Checksum verified: $ACTUAL_SHA"
TMP_FILE=$(mktemp)
if ! curl -sSL -o "$TMP_FILE" "$URL"; then
echo "ERROR: Failed to download $BINARY"
rm -f "$TMP_FILE"
exit 1
fi
# Install
chmod +x "$TMP_FILE"
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP_FILE" "$INSTALL_DIR/tnt"
else
echo "Need sudo for installation to $INSTALL_DIR"
need_cmd sudo
sudo mkdir -p "$INSTALL_DIR"
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
sudo mv "$TMP_FILE" "$INSTALL_DIR/tnt"
fi
echo ""
echo "TNT installed successfully to $INSTALL_DIR/tnt"
echo "✓ TNT installed successfully to $INSTALL_DIR/tnt"
echo ""
echo "Run with:"
echo " tnt"

View file

@ -1,37 +0,0 @@
# Packaging
This directory contains package-manager drafts for TNT. They are intentionally
kept out of the root install path and should be reviewed before submission to
any public registry.
## Current targets
- `arch/` - AUR-ready draft for `tnt-chat`.
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
## Release checklist
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
Also update package versions in Arch, Homebrew, and Debian drafts.
2. Create a GitHub release tag such as `v1.0.1`.
3. Build and upload release tarballs or rely on GitHub source archives.
4. Replace placeholder checksums in package drafts.
5. Verify package contents in an isolated directory:
```sh
make release-check
```
6. Before submitting package recipes, replace checksum placeholders and run:
```sh
make release-check-strict
```
7. Submit packages manually:
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.
Do not connect these packaging drafts to automatic production deployment.

View file

@ -1,15 +0,0 @@
pkgbase = tnt-chat
pkgdesc = SSH-native terminal chat server with a Vim-style interface
pkgver = 1.0.1
pkgrel = 1
url = https://github.com/m1ngsama/TNT
arch = x86_64
arch = aarch64
license = MIT
makedepends = gcc
makedepends = make
depends = libssh
source = tnt-chat-1.0.1.tar.gz::https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
sha256sums = SKIP
pkgname = tnt-chat

View file

@ -1,25 +0,0 @@
# Maintainer: M1ng <REPLACE_WITH_EMAIL>
pkgname=tnt-chat
pkgver=1.0.1
pkgrel=1
pkgdesc='SSH-native terminal chat server with a Vim-style interface'
arch=('x86_64' 'aarch64')
url='https://github.com/m1ngsama/TNT'
license=('MIT')
depends=('libssh')
makedepends=('gcc' 'make')
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "TNT-${pkgver}"
make
}
package() {
cd "TNT-${pkgver}"
make DESTDIR="${pkgdir}" PREFIX=/usr install
make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

View file

@ -1,47 +0,0 @@
# Arch / AUR Packaging
The draft package name is `tnt-chat` because `tnt` is already a likely name
collision in Arch/AUR contexts.
## Local validation
From this directory:
```sh
makepkg -si
```
Optional package linting:
```sh
namcap PKGBUILD
namcap tnt-chat-*.pkg.tar.zst
```
## Updating metadata
After editing `PKGBUILD`, regenerate `.SRCINFO`:
```sh
makepkg --printsrcinfo > .SRCINFO
```
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
archive checksum, then run the project-level strict check:
```sh
make release-check-strict
```
## Manual AUR submission
```sh
git clone ssh://aur@aur.archlinux.org/tnt-chat.git aur-tnt-chat
cp PKGBUILD .SRCINFO aur-tnt-chat/
cd aur-tnt-chat
git add PKGBUILD .SRCINFO
git commit -m "Update to 1.0.1"
git push
```
Do not wire this to automatic deployment or release automation.

View file

@ -1,49 +0,0 @@
# Debian and Ubuntu Packaging
Ubuntu distribution should start with a Launchpad PPA. Direct inclusion in
Debian or Ubuntu archives is a separate, slower process and should wait until
the project has a stable release cadence.
## Draft metadata
The `debian/` directory in this folder is a packaging draft. To test it against
an upstream release tree, copy it to the root of a clean source checkout:
```sh
cp -a packaging/debian/debian ./debian
dpkg-buildpackage -us -uc
```
For PPA uploads, build a signed source package instead:
```sh
debuild -S
```
## Recommended path
1. Keep the upstream project installable with:
```sh
make DESTDIR="$pkgdir" PREFIX=/usr install
```
2. Review Debian packaging metadata from a release tarball:
- `debian/control`
- `debian/rules`
- `debian/changelog`
- `debian/copyright`
- `debian/source/format`
3. Build locally with `debuild` or `dpkg-buildpackage`.
4. Upload the signed source package to a Launchpad PPA.
5. Only after repeated stable releases, consider Debian mentors or Ubuntu
archive sponsorship.
## Package shape
- Binary package name: `tnt-chat`
- Installed command: `/usr/bin/tnt`
- Runtime dependency: `libssh`
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`

View file

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

View file

@ -1,22 +0,0 @@
Source: tnt-chat
Section: net
Priority: optional
Maintainer: M1ng <REPLACE_WITH_EMAIL>
Build-Depends:
debhelper-compat (= 13),
libssh-dev,
make,
gcc
Standards-Version: 4.7.0
Homepage: https://github.com/m1ngsama/TNT
Rules-Requires-Root: no
Package: tnt-chat
Architecture: any
Depends:
${misc:Depends},
${shlibs:Depends}
Description: SSH-native terminal chat server
TNT is a minimalist terminal chat server accessed over SSH. It provides a
Vim-style terminal interface, anonymous access by default, persistent message
history, and a small non-interactive SSH exec surface for scripts.

View file

@ -1,26 +0,0 @@
Format: https://www.debian.org/doc/packaging-manuals/copyright-format/1.0/
Upstream-Name: TNT
Source: https://github.com/m1ngsama/TNT
Files: *
Copyright: 2026 M1ng
License: MIT
License: MIT
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
.
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -1,11 +0,0 @@
#!/usr/bin/make -f
%:
dh $@
override_dh_auto_build:
$(MAKE)
override_dh_auto_install:
$(MAKE) DESTDIR=$(CURDIR)/debian/tnt-chat PREFIX=/usr install
$(MAKE) DESTDIR=$(CURDIR)/debian/tnt-chat PREFIX=/usr install-systemd

View file

@ -1 +0,0 @@
3.0 (quilt)

View file

@ -1,49 +0,0 @@
# Homebrew Packaging
The draft formula is `tnt-chat.rb`. The expected install path for users is a
project tap first, not Homebrew core:
```sh
brew tap m1ngsama/tnt
brew install tnt-chat
```
Homebrew core should wait until TNT has stable releases and broader usage.
## Local validation
From a tap repository:
```sh
brew audit --strict --online tnt-chat
brew install --build-from-source ./Formula/tnt-chat.rb
brew test tnt-chat
```
For local syntax-only validation from this repository:
```sh
ruby -c packaging/homebrew/tnt-chat.rb
```
## Updating the formula
1. Publish a GitHub release tag such as `v1.0.1`.
2. Download or hash the release source archive:
```sh
curl -L -o tnt-chat-1.0.1.tar.gz \
https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz
shasum -a 256 tnt-chat-1.0.1.tar.gz
```
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
4. Run:
```sh
make release-check-strict
```
5. Copy the formula into the tap repository and open a normal review PR.
Do not connect this tap update to production deployment.

View file

@ -1,21 +0,0 @@
class TntChat < Formula
desc "SSH-native terminal chat server with a Vim-style interface"
homepage "https://github.com/m1ngsama/TNT"
url "https://github.com/m1ngsama/TNT/archive/refs/tags/v1.0.1.tar.gz"
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
license "MIT"
depends_on "libssh"
def install
system "make"
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
end
test do
assert_match version.to_s, shell_output("#{bin}/tnt --version")
end
end

View file

@ -1,150 +0,0 @@
#!/bin/sh
# Local release preflight. This never tags, pushes, publishes, or deploys.
set -eu
STRICT=0
usage() {
cat <<'USAGE'
Usage: scripts/release_check.sh [--strict]
Default checks:
- version metadata alignment
- clean build
- unit tests
- staged install layout with PREFIX=/usr and DESTDIR
- installer shell syntax
- Debian packaging metadata
- Arch/Homebrew packaging syntax
Environment:
RUN_INTEGRATION=1 also run full make test
PORT=12720 base port for integration tests
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
USAGE
}
while [ "$#" -gt 0 ]; do
case "$1" in
--strict)
STRICT=1
;;
-h|--help)
usage
exit 0
;;
*)
echo "unknown argument: $1" >&2
usage >&2
exit 2
;;
esac
shift
done
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
fail() {
echo "release-check: $*" >&2
exit 1
}
step() {
printf '\n==> %s\n' "$*"
}
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
step "checking version metadata for $version"
grep -q "\"TNT $version\"" tnt.1 ||
fail "tnt.1 does not mention TNT $version"
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
fail "packaging/arch/.SRCINFO pkgver does not match $version"
grep -q "^pkgname=tnt-chat$" packaging/arch/PKGBUILD ||
fail "packaging/arch/PKGBUILD pkgname is not tnt-chat"
grep -q "^pkgname = tnt-chat$" packaging/arch/.SRCINFO ||
fail "packaging/arch/.SRCINFO pkgname is not tnt-chat"
grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb ||
fail "packaging/homebrew/tnt-chat.rb URL does not match v$version"
grep -q "^class TntChat < Formula$" packaging/homebrew/tnt-chat.rb ||
fail "packaging/homebrew/tnt-chat.rb formula class is not TntChat"
grep -q 'depends_on "libssh"' packaging/homebrew/tnt-chat.rb ||
fail "packaging/homebrew/tnt-chat.rb must depend on libssh"
grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog ||
fail "packaging/debian/debian/changelog version does not match $version"
grep -q "^Source: tnt-chat$" packaging/debian/debian/control ||
fail "packaging/debian/debian/control Source is not tnt-chat"
step "building"
make clean
make
actual_version=$(./tnt --version)
[ "$actual_version" = "tnt $version" ] ||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
step "running unit tests"
make -C tests/unit clean
make -C tests/unit run
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
step "running full integration tests"
make test PORT="${PORT:-12720}"
fi
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
cleanup() {
rm -rf "$tmpdir"
}
trap cleanup EXIT INT TERM
step "checking staged install layout"
make DESTDIR="$tmpdir" PREFIX=/usr install
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
step "checking installer syntax"
sh -n install.sh
step "checking Debian packaging metadata"
[ -x packaging/debian/debian/rules ] ||
fail "packaging/debian/debian/rules must be executable"
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
fail "unsupported Debian source format"
step "checking packaging syntax"
if command -v bash >/dev/null 2>&1; then
bash -n packaging/arch/PKGBUILD
else
echo "bash not found; skipping PKGBUILD syntax check"
fi
if command -v ruby >/dev/null 2>&1; then
ruby -c packaging/homebrew/tnt-chat.rb
else
echo "ruby not found; skipping Homebrew formula syntax check"
fi
if [ "$STRICT" -eq 1 ]; then
step "checking strict release gates"
! 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"
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
fail "missing local tag v$version"
fi
step "release preflight passed"

View file

@ -1,66 +0,0 @@
#include "cli_text.h"
#include "i18n.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, ui_lang_t lang) {
static const i18n_string_t help_format = I18N_STRING(
"tnt %s - anonymous SSH chat server\n\n"
"Usage: %s [options]\n\n"
"Options:\n"
" -p, --port PORT Listen on PORT (default: %d)\n"
" -d, --state-dir DIR Store host key and logs in DIR\n"
" -V, --version Show version\n"
" -h, --help Show this help\n"
"\n"
"Environment:\n"
" PORT Default listening port\n"
" TNT_STATE_DIR State directory\n"
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
" TNT_LANG UI language: en or zh (default: locale)\n"
" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n"
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n",
"tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n"
"选项:\n"
" -p, --port PORT 监听 PORT (默认: %d)\n"
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
" -V, --version 显示版本\n"
" -h, --help 显示此帮助\n"
"\n"
"环境变量:\n"
" PORT 默认监听端口\n"
" TNT_STATE_DIR 状态目录\n"
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n"
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n"
);
const char *program = (program_name && program_name[0] != '\0')
? program_name
: "tnt";
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
TNT_VERSION, program, DEFAULT_PORT);
}
const char *cli_text_invalid_port_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Invalid port: %s\n", "端口无效: %s\n");
return i18n_string(text, lang);
}
const char *cli_text_unknown_option_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");
return i18n_string(text, lang);
}
const char *cli_text_short_usage_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n",
"用法: %s [-p PORT] [-d DIR] [-h]\n");
return i18n_string(text, lang);
}

View file

@ -33,10 +33,6 @@ int client_send(client_t *client, const char *data, size_t len) {
total += (size_t)sent;
}
if (client->exec_command[0] != '\0') {
ssh_blocking_flush(client->session, 1000);
}
pthread_mutex_unlock(&client->io_lock);
return 0;
}
@ -62,9 +58,7 @@ void client_release(client_t *client) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
if (client->channel) {
if (ssh_channel_is_open(client->channel)) {
ssh_channel_close(client->channel);
}
ssh_channel_close(client->channel);
ssh_channel_free(client->channel);
}
if (client->session) {
@ -126,12 +120,7 @@ static void client_channel_eof(ssh_session session, ssh_channel channel,
client_t *client = (client_t *)userdata;
if (client) {
/* Exec clients commonly half-close stdin immediately after sending
* the command. Keep stdout usable so the exec handler can return
* output and an exit status. */
if (client->exec_command[0] == '\0') {
client->connected = false;
}
client->connected = false;
}
}

View file

@ -1,311 +0,0 @@
#include "command_catalog.h"
#include "i18n.h"
#include <string.h>
typedef struct {
tnt_command_spec_t spec;
i18n_string_t full_usage;
i18n_string_t summary;
i18n_string_t manual_usage;
i18n_string_t error_usage;
int manual_group;
bool no_args;
bool requires_args;
} command_catalog_entry_t;
static const command_catalog_entry_t entries[] = {
{
{TNT_COMMAND_USERS, "users", {"users", "list", "who", NULL}},
I18N_STRING(":users, :list, :who", ":users, :list, :who"),
I18N_STRING("Show online users", "显示在线用户"),
I18N_STRING(":users", ":users"),
I18N_STRING("Usage: users\n", "用法: users\n"),
1, true, false
},
{
{TNT_COMMAND_MSG, "msg", {"msg", "w", NULL}},
I18N_STRING(":msg <user> <message>, :w <user> <message>",
":msg <user> <message>, :w <user> <message>"),
I18N_STRING("Send private message", "发送私信"),
I18N_STRING(":msg <user> <message>", ":msg <user> <message>"),
I18N_STRING("Usage: msg <user> <message>\n"
" w <user> <message>\n",
"用法: msg <user> <message>\n"
" w <user> <message>\n"),
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Show private messages", "查看私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Usage: inbox\n", "用法: inbox\n"),
2, true, false
},
{
{TNT_COMMAND_NICK, "nick", {"nick", "name", NULL}},
I18N_STRING(":nick <name>, :name <name>",
":nick <name>, :name <name>"),
I18N_STRING("Change nickname", "更改昵称"),
I18N_STRING(":nick <name>", ":nick <name>"),
I18N_STRING("Usage: nick <name>\n", "用法: nick <name>\n"),
2, false, true
},
{
{TNT_COMMAND_LAST, "last", {"last", NULL}},
I18N_STRING(":last [N]", ":last [N]"),
I18N_STRING("Show last N messages (max 50)",
"显示最后 N 条消息(最多50)"),
I18N_STRING(":last [N]", ":last [N]"),
I18N_STRING("Usage: last [N] (N: 1-50, default 10)\n",
"用法: last [N] (N: 1-50默认 10)\n"),
1, false, false
},
{
{TNT_COMMAND_SEARCH, "search", {"search", NULL}},
I18N_STRING(":search <keyword>", ":search <keyword>"),
I18N_STRING("Search message history", "搜索消息历史"),
I18N_STRING(":search <keyword>", ":search <keyword>"),
I18N_STRING("Usage: search <keyword>\n", "用法: search <keyword>\n"),
1, false, true
},
{
{TNT_COMMAND_MUTE_JOINS, "mute-joins", {"mute-joins", "mute", NULL}},
I18N_STRING(":mute-joins, :mute", ":mute-joins, :mute"),
I18N_STRING("Toggle join/leave notices", "切换加入/离开提示"),
I18N_STRING(":mute-joins", ":mute-joins"),
I18N_STRING("Usage: mute-joins\n", "用法: mute-joins\n"),
3, true, false
},
{
{TNT_COMMAND_HELP, "help", {"help", NULL}},
I18N_STRING(":help", ":help"),
I18N_STRING("Show concise manual", "显示简明手册"),
I18N_STRING(NULL, NULL),
I18N_STRING("Usage: help\n", "用法: help\n"),
0, true, false
},
{
{TNT_COMMAND_LANG, "lang", {"lang", "language", NULL}},
I18N_STRING(":lang <en|zh>", ":lang <en|zh>"),
I18N_STRING("Switch UI language", "切换界面语言"),
I18N_STRING(NULL, NULL),
I18N_STRING("Usage: lang <en|zh>\n", "用法: lang <en|zh>\n"),
0, false, false
},
{
{TNT_COMMAND_CLEAR, "clear", {"clear", "cls", NULL}},
I18N_STRING(":clear, :cls", ":clear, :cls"),
I18N_STRING("Clear command output", "清空命令输出"),
I18N_STRING(":clear", ":clear"),
I18N_STRING("Usage: clear\n", "用法: clear\n"),
3, true, false
},
{
{TNT_COMMAND_QUIT, "q", {"q", "quit", "exit", NULL}},
I18N_STRING(":q, :quit, :exit", ":q, :quit, :exit"),
I18N_STRING("Disconnect", "断开连接"),
I18N_STRING(":q", ":q"),
I18N_STRING("Usage: q\n", "用法: q\n"),
3, true, false
}
};
static const command_catalog_entry_t *entry_for_id(tnt_command_id_t id) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
if (entries[i].spec.id == id) {
return &entries[i];
}
}
return NULL;
}
static const char *skip_spaces(const char *value) {
while (value && *value == ' ') {
value++;
}
return value;
}
static bool name_matches(const char *line, const char *name,
const char **args) {
size_t len;
if (!line || !name) {
return false;
}
len = strlen(name);
if (strncmp(line, name, len) != 0) {
return false;
}
if (line[len] != '\0' && line[len] != ' ') {
return false;
}
if (args) {
*args = skip_spaces(line + len);
}
return true;
}
static int min3(int a, int b, int c) {
int m = a < b ? a : b;
return m < c ? m : c;
}
static int edit_distance(const char *a, const char *b) {
size_t la = strlen(a);
size_t lb = strlen(b);
int prev[32];
int curr[32];
if (la >= 32 || lb >= 32) {
return 99;
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = (int)j;
}
for (size_t i = 1; i <= la; i++) {
curr[0] = (int)i;
for (size_t j = 1; j <= lb; j++) {
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = min3(prev[j] + 1, curr[j - 1] + 1,
prev[j - 1] + cost);
}
for (size_t j = 0; j <= lb; j++) {
prev[j] = curr[j];
}
}
return prev[lb];
}
const tnt_command_spec_t *command_catalog_get(tnt_command_id_t id) {
const command_catalog_entry_t *entry = entry_for_id(id);
return entry ? &entry->spec : NULL;
}
bool command_catalog_match(const char *line, tnt_command_id_t *id,
const char **args) {
line = skip_spaces(line);
if (!line || line[0] == '\0') {
return false;
}
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const tnt_command_spec_t *spec = &entries[i].spec;
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
const char *candidate_args = NULL;
if (!spec->names[n]) {
break;
}
if (!name_matches(line, spec->names[n], &candidate_args)) {
continue;
}
if (id) {
*id = spec->id;
}
if (args) {
*args = candidate_args ? candidate_args : "";
}
return true;
}
}
return false;
}
bool command_catalog_args_valid(tnt_command_id_t id, const char *args) {
const command_catalog_entry_t *entry = entry_for_id(id);
args = skip_spaces(args);
if (!entry) {
return false;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}
if (entry->requires_args) {
return args && args[0] != '\0';
}
return true;
}
const char *command_catalog_suggest(const char *name) {
const char *best = NULL;
int best_distance = 99;
if (!name || !*name) {
return NULL;
}
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const tnt_command_spec_t *spec = &entries[i].spec;
for (size_t n = 0; n < sizeof(spec->names) / sizeof(spec->names[0]); n++) {
int distance;
if (!spec->names[n]) {
break;
}
distance = edit_distance(name, spec->names[n]);
if (distance < best_distance) {
best_distance = distance;
best = spec->canonical;
}
}
}
return best_distance <= 2 ? best : NULL;
}
void command_catalog_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *usage = i18n_string(entries[i].full_usage, lang);
const char *summary = i18n_string(entries[i].summary, lang);
buffer_appendf(buffer, buf_size, pos, " %-40s - %s\n",
usage, summary);
}
}
void command_catalog_append_manual(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
for (int group = 1; group <= 3; group++) {
bool first = true;
buffer_appendf(buffer, buf_size, pos, " ");
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *usage;
if (entries[i].manual_group != group) {
continue;
}
usage = i18n_string(entries[i].manual_usage, lang);
if (!usage || usage[0] == '\0') {
continue;
}
if (!first) {
buffer_appendf(buffer, buf_size, pos, ", ");
}
buffer_appendf(buffer, buf_size, pos, "%s", usage);
first = false;
}
buffer_appendf(buffer, buf_size, pos, "\n");
}
}
void command_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang) {
const command_catalog_entry_t *entry = entry_for_id(id);
const char *usage;
if (!entry) {
return;
}
usage = i18n_string(entry->error_usage, lang);
buffer_appendf(buffer, buf_size, pos, "%s", usage);
}

View file

@ -1,18 +1,8 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
#endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
#endif
#include "commands.h"
#include "chat_room.h"
#include "client.h"
#include "command_catalog.h"
#include "common.h"
#include "i18n.h"
#include "manual.h"
#include "message.h"
#include "system_message.h"
#include "tui.h"
#include "utf8.h"
#include <stdio.h>
@ -20,44 +10,12 @@
#include <string.h>
#include <time.h>
/* Append `text` to the output buffer with every case-insensitive match of
* `needle` wrapped in a reverse-yellow ANSI chip. Preserves the original
* casing of the matched substring. needle == NULL or empty appends raw. */
static void append_highlighted(char *output, size_t buf_size, size_t *pos,
const char *text, const char *needle) {
if (!needle || !*needle) {
buffer_appendf(output, buf_size, pos, "%s", text);
return;
}
size_t nlen = strlen(needle);
const char *p = text;
while (*p) {
const char *hit = strcasestr(p, needle);
if (!hit) {
buffer_appendf(output, buf_size, pos, "%s", p);
return;
}
if (hit > p) {
buffer_append_bytes(output, buf_size, pos, p, (size_t)(hit - p));
}
buffer_append_bytes(output, buf_size, pos, "\033[7;33m", 7);
buffer_append_bytes(output, buf_size, pos, hit, nlen);
buffer_append_bytes(output, buf_size, pos, "\033[0m", 4);
p = hit + nlen;
}
}
static void append_command_usage(char *output, size_t buf_size, size_t *pos,
tnt_command_id_t id, ui_lang_t lang) {
command_catalog_append_usage(output, buf_size, pos, id, lang);
}
void commands_dispatch(client_t *client) {
char cmd_buf[256];
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
char *cmd = cmd_buf;
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
char output[2048] = {0};
size_t pos = 0;
/* Trim whitespace */
@ -85,49 +43,22 @@ void commands_dispatch(client_t *client) {
client->command_history_pos = client->command_history_count;
}
if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
}
tnt_command_id_t command_id;
const char *arg = "";
if (!command_catalog_match(cmd, &command_id, &arg)) {
const char *suggestion = command_catalog_suggest(cmd);
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_UNKNOWN_GUIDANCE));
goto cmd_done;
}
"========================================\n"
" Online Users / 在线用户\n"
"========================================\n");
if (!command_catalog_args_valid(command_id, arg)) {
append_command_usage(output, sizeof(output), &pos, command_id,
client->ui_lang);
goto cmd_done;
}
if (command_id == TNT_COMMAND_USERS) {
pthread_rwlock_rdlock(&g_room->lock);
int total = g_room->client_count;
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_USERS_TITLE), total);
"Total / 总数: %d\n"
"----------------------------------------\n",
g_room->client_count);
time_t now = time(NULL);
for (int i = 0; i < total; i++) {
bool is_self = (g_room->clients[i] == client);
for (int i = 0; i < g_room->client_count; i++) {
char marker = (g_room->clients[i] == client) ? '*' : ' ';
int dur = (int)(now - g_room->clients[i]->connect_time);
char dur_str[32];
if (dur < 60) {
@ -135,44 +66,42 @@ void commands_dispatch(client_t *client) {
} else if (dur < 3600) {
snprintf(dur_str, sizeof(dur_str), "%dm", dur / 60);
} else {
snprintf(dur_str, sizeof(dur_str), "%dh%dm",
dur / 3600, (dur % 3600) / 60);
snprintf(dur_str, sizeof(dur_str), "%dh%dm", dur / 3600, (dur % 3600) / 60);
}
/* 1-column gutter: ▎ for you, blank for others */
buffer_appendf(output, sizeof(output), &pos,
"%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n",
is_self ? "\033[36m▎\033[0m" : " ",
"%c %d. %s (%s)\n", marker, i + 1,
g_room->clients[i]->username, dur_str);
}
pthread_rwlock_unlock(&g_room->lock);
} else if (command_id == TNT_COMMAND_HELP) {
manual_append_interactive_panel(output, sizeof(output), &pos,
client->ui_lang);
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
"* = you / 你\n");
} else if (command_id == TNT_COMMAND_LANG) {
ui_lang_t next_lang;
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
" Available Commands / 可用命令\n"
"========================================\n"
"list, users, who - Show online users\n"
"nick/name <name> - Change nickname\n"
"msg/w <user> <text> - Whisper to user\n"
"last [N] - Show last N messages\n"
"search <keyword> - Search message history\n"
"mute-joins - Toggle join/leave notices\n"
"help, commands - Show this help\n"
"clear, cls - Clear command output\n"
"q, quit, exit - Disconnect\n"
"Up/Down arrows - Command history\n"
"========================================\n"
"In INSERT mode:\n"
" /me <action> - Send action message\n"
" @username - Mention (bell notify)\n"
"========================================\n");
if (!arg || arg[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_LANG_CURRENT_FORMAT),
i18n_ui_lang_code(client->ui_lang));
} else if (i18n_try_parse_ui_lang(arg, &next_lang)) {
client->ui_lang = next_lang;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_LANG_SET_FORMAT),
i18n_ui_lang_code(client->ui_lang));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_LANG_UNSUPPORTED_FORMAT),
arg);
}
} else if (command_id == TNT_COMMAND_MSG) {
const char *rest = arg;
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
@ -182,8 +111,9 @@ void commands_dispatch(client_t *client) {
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
buffer_appendf(output, sizeof(output), &pos,
"Usage: msg <username> <message>\n"
" w <username> <message>\n");
} else {
bool found = false;
client_t *target = NULL;
@ -199,90 +129,34 @@ void commands_dispatch(client_t *client) {
pthread_rwlock_unlock(&g_room->lock);
if (target) {
/* Push into recipient's inbox. io_lock serialises so two
* senders to the same recipient don't tear the ring. */
pthread_mutex_lock(&target->io_lock);
int slot;
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = target->whisper_inbox_count++;
} else {
/* FIFO evict the oldest */
memmove(&target->whisper_inbox[0],
&target->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
target->whisper_inbox[slot].timestamp = time(NULL);
snprintf(target->whisper_inbox[slot].from,
sizeof(target->whisper_inbox[slot].from),
"%s", client->username);
snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content),
"%s", rest);
pthread_mutex_unlock(&target->io_lock);
target->unread_whispers++;
char whisper[MAX_MESSAGE_LEN];
snprintf(whisper, sizeof(whisper),
"\r\n\033[35m[whisper from %s]: %s\033[0m\r\n",
client->username, rest);
client_send(target, whisper, strlen(whisper));
target->redraw_pending = true;
/* Audible nudge — the title bar ✉ counter (UX-11 style)
* carries the persistent signal. */
client_send(target, "\a", 1);
client_release(target);
}
if (found) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_MSG_SENT_FORMAT),
target_name);
"Whisper sent to %s\n", target_name);
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
"User '%s' not found\n", target_name);
}
}
} else if (command_id == TNT_COMMAND_INBOX) {
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
* tear what we're rendering. Counter reset happens after copy. */
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
pthread_mutex_lock(&client->io_lock);
snap_count = client->whisper_inbox_count;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
pthread_mutex_unlock(&client->io_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) {
const char *new_name = arg;
} else if (strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 5;
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_NICK, client->ui_lang);
buffer_appendf(output, sizeof(output), &pos,
"Usage: nick <new_username>\n");
} else if (!is_valid_username(new_name)) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_NICK_INVALID));
buffer_appendf(output, sizeof(output), &pos,
"Invalid username\n");
} else {
char validated_name[MAX_USERNAME_LEN];
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
@ -290,60 +164,33 @@ void commands_dispatch(client_t *client) {
utf8_truncate(validated_name, 20);
}
/* Reject collisions with active room members. Held under
* wrlock so the username swap below races neither read nor
* concurrent :nick from another client. */
char old_name[MAX_USERNAME_LEN];
bool taken = false;
pthread_rwlock_wrlock(&g_room->lock);
snprintf(old_name, sizeof(old_name), "%s", client->username);
if (strcmp(validated_name, old_name) != 0) {
for (int i = 0; i < g_room->client_count; i++) {
if (g_room->clients[i] == client) continue;
if (strcmp(g_room->clients[i]->username,
validated_name) == 0) {
taken = true;
break;
}
}
}
if (!taken) {
snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name);
}
snprintf(client->username, MAX_USERNAME_LEN, "%s", validated_name);
pthread_rwlock_unlock(&g_room->lock);
if (taken) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_NICK_TAKEN_FORMAT),
validated_name);
} else if (strcmp(validated_name, old_name) == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_NICK_UNCHANGED));
} else {
message_t nick_msg;
system_message_make_nick(&nick_msg, old_name,
client->username, client->ui_lang);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
message_t nick_msg = { .timestamp = time(NULL) };
snprintf(nick_msg.username, MAX_USERNAME_LEN, "系统");
snprintf(nick_msg.content, MAX_MESSAGE_LEN,
"%s 更名为 %s", old_name, client->username);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_NICK_CHANGED_FORMAT),
old_name, client->username);
}
buffer_appendf(output, sizeof(output), &pos,
"Nickname changed: %s -> %s\n", old_name, client->username);
}
} else if (command_id == TNT_COMMAND_LAST) {
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
char *arg = cmd + 4;
while (*arg == ' ') arg++;
int n = 10;
if (*arg != '\0') {
char *endp;
long val = strtol(arg, &endp, 10);
if (*endp != '\0' || val < 1 || val > 50) {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_LAST, client->ui_lang);
buffer_appendf(output, sizeof(output), &pos,
"Usage: last [N] (N: 1-50, default 10)\n");
goto cmd_done;
}
n = (int)val;
@ -352,8 +199,7 @@ void commands_dispatch(client_t *client) {
message_t *last_msgs = NULL;
int last_count = message_load(&last_msgs, n);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
last_count);
"--- Last %d message(s) ---\n", last_count);
for (int i = 0; i < last_count; i++) {
char ts[20];
struct tm tmi;
@ -364,57 +210,60 @@ void commands_dispatch(client_t *client) {
}
free(last_msgs);
} else if (command_id == TNT_COMMAND_SEARCH) {
const char *query = arg;
} else if (strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 7;
while (*query == ' ') query++;
if (*query == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_SEARCH, client->ui_lang);
buffer_appendf(output, sizeof(output), &pos,
"Usage: search <keyword>\n");
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_SEARCH_HEADER_FORMAT),
query, found_count);
"--- Search: \"%s\" (%d match(es)) ---\n", query, found_count);
for (int i = 0; i < found_count; i++) {
char ts[20];
struct tm tmi;
localtime_r(&found[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] ", ts);
append_highlighted(output, sizeof(output), &pos,
found[i].username, query);
buffer_appendf(output, sizeof(output), &pos, ": ");
append_highlighted(output, sizeof(output), &pos,
found[i].content, query);
buffer_appendf(output, sizeof(output), &pos, "\n");
"[%s] %s: %s\n", ts, found[i].username, found[i].content);
}
free(found);
}
} else if (command_id == TNT_COMMAND_MUTE_JOINS) {
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
client->mute_joins = !client->mute_joins;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang, I18N_MUTE_JOINS_FORMAT),
i18n_text(client->ui_lang,
client->mute_joins ?
I18N_MUTE_JOINS_MUTED :
I18N_MUTE_JOINS_UNMUTED));
"Join/leave notifications: %s\n",
client->mute_joins ? "muted" : "unmuted");
} else if (command_id == TNT_COMMAND_QUIT) {
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
client->connected = false;
return;
} else if (command_id == TNT_COMMAND_CLEAR) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_CLEAR_DONE));
} else if (strcmp(cmd, "clear") == 0 || strcmp(cmd, "cls") == 0) {
buffer_appendf(output, sizeof(output), &pos, "Command output cleared\n");
} else if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
} else {
buffer_appendf(output, sizeof(output), &pos,
"Unknown command: %s\n"
"Type 'help' for available commands\n", cmd);
}
cmd_done:
buffer_appendf(output, sizeof(output), &pos,
"\nPress any key to continue...");
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_output_scroll = 0;
client->command_input[0] = '\0';
tui_render_command_output(client);
}

View file

@ -2,8 +2,6 @@
#include "chat_room.h"
#include "client.h"
#include "common.h"
#include "exec_catalog.h"
#include "i18n.h"
#include "input.h"
#include "message.h"
#include "ratelimit.h"
@ -117,24 +115,20 @@ static void resolve_exec_username(const client_t *client, char *buffer,
}
static int exec_command_help(client_t *client) {
char help_text[1024];
size_t pos = 0;
static const char help_text[] =
"TNT exec interface\n"
"Commands:\n"
" help Show this help\n"
" health Print service health\n"
" users [--json] List online users\n"
" stats [--json] Print room statistics\n"
" tail [N] Print recent messages\n"
" tail -n N Print recent messages\n"
" post MESSAGE Post a message non-interactively\n"
" post \"/me act\" Post an action message\n"
" exit Exit successfully\n";
help_text[0] = '\0';
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
client->ui_lang);
return client_send(client, help_text, pos) == 0 ? 0 : 1;
}
static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
char usage[128];
size_t pos = 0;
usage[0] = '\0';
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
client->ui_lang);
client_printf(client, "%s", usage);
return 64;
return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1;
}
static int exec_command_health(client_t *client) {
@ -300,7 +294,8 @@ static int exec_command_tail(client_t *client, const char *args) {
int rc;
if (parse_tail_count(args, &requested) < 0) {
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
client_printf(client, "tail: usage: tail [N] | tail -n N\n");
return 64;
}
pthread_rwlock_rdlock(&g_room->lock);
@ -352,7 +347,8 @@ static int exec_command_post(client_t *client, const char *args) {
};
if (!args || args[0] == '\0') {
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
client_printf(client, "post: usage: post MESSAGE\n");
return 64;
}
strncpy(content, args, sizeof(content) - 1);
@ -360,15 +356,12 @@ static int exec_command_post(client_t *client, const char *args) {
trim_ascii_whitespace(content);
if (content[0] == '\0') {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
client_printf(client, "post: message cannot be empty\n");
return 64;
}
if (!utf8_is_valid_string(content)) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_POST_INVALID_UTF8));
client_printf(client, "post: invalid UTF-8 input\n");
return 1;
}
@ -389,64 +382,72 @@ static int exec_command_post(client_t *client, const char *args) {
}
room_broadcast(g_room, &msg);
if (client_send(client, "posted\n", 7) != 0) {
return 1;
}
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
fprintf(stderr, "post: failed to persist message\n");
client_printf(client, "post: failed to persist message\n");
return 1;
}
return 0;
return client_send(client, "posted\n", 7) == 0 ? 0 : 1;
}
int exec_dispatch(client_t *client) {
char command_copy[MAX_EXEC_COMMAND_LEN];
tnt_exec_command_id_t command_id;
const char *args = NULL;
char *cmd;
char *args;
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
command_copy[sizeof(command_copy) - 1] = '\0';
trim_ascii_whitespace(command_copy);
if (command_copy[0] == '\0') {
cmd = command_copy;
if (*cmd == '\0') {
return exec_command_help(client);
}
if (exec_catalog_match(command_copy, &command_id, &args)) {
if (!exec_catalog_args_valid(command_id, args)) {
return exec_command_usage(client, command_id);
}
switch (command_id) {
case TNT_EXEC_COMMAND_HELP:
return exec_command_help(client);
case TNT_EXEC_COMMAND_HEALTH:
return exec_command_health(client);
case TNT_EXEC_COMMAND_USERS:
return exec_command_users(client, args != NULL);
case TNT_EXEC_COMMAND_STATS:
return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT:
return 0;
args = cmd;
while (*args && !isspace((unsigned char)*args)) {
args++;
}
if (*args) {
*args++ = '\0';
while (*args && isspace((unsigned char)*args)) {
args++;
}
} else {
args = NULL;
}
for (char *p = command_copy; *p; p++) {
if (isspace((unsigned char)*p)) {
*p = '\0';
break;
}
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
return exec_command_help(client);
}
client_printf(client,
i18n_text(client->ui_lang,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
command_copy);
if (strcmp(cmd, "health") == 0) {
return exec_command_health(client);
}
if (strcmp(cmd, "users") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "users: usage: users [--json]\n");
return 64;
}
return exec_command_users(client, args != NULL);
}
if (strcmp(cmd, "stats") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "stats: usage: stats [--json]\n");
return 64;
}
return exec_command_stats(client, args != NULL);
}
if (strcmp(cmd, "tail") == 0) {
return exec_command_tail(client, args);
}
if (strcmp(cmd, "post") == 0) {
return exec_command_post(client, args);
}
if (strcmp(cmd, "exit") == 0) {
return 0;
}
client_printf(client, "Unknown command: %s\n", cmd);
return 64;
}

View file

@ -1,161 +0,0 @@
#include "exec_catalog.h"
#include "i18n.h"
typedef struct {
tnt_exec_command_id_t id;
const char *name;
const char *alias;
const char *usage;
const char *usage_syntax;
i18n_string_t summary;
bool no_args;
bool optional_json;
bool requires_args;
} exec_catalog_entry_t;
static const exec_catalog_entry_t entries[] = {
{TNT_EXEC_COMMAND_HELP, "help", "--help",
"help", "help", I18N_STRING("Show this help", "显示此帮助"),
true, false, false},
{TNT_EXEC_COMMAND_HEALTH, "health", NULL,
"health", "health",
I18N_STRING("Print service health", "输出服务健康状态"),
true, false, false},
{TNT_EXEC_COMMAND_USERS, "users", NULL,
"users [--json]", "users [--json]",
I18N_STRING("List online users", "列出在线用户"),
false, true, false},
{TNT_EXEC_COMMAND_STATS, "stats", NULL,
"stats [--json]", "stats [--json]",
I18N_STRING("Print room statistics", "输出房间统计"),
false, true, false},
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
"tail [N]", "tail [N] | tail -n N",
I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false},
{TNT_EXEC_COMMAND_TAIL, "tail", NULL,
"tail -n N", "tail [N] | tail -n N",
I18N_STRING("Print recent messages", "输出最近消息"),
false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "post MESSAGE",
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
false, false, true},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post \"/me act\"", "post MESSAGE",
I18N_STRING("Post an action message", "发送动作消息"),
false, false, true},
{TNT_EXEC_COMMAND_EXIT, "exit", NULL,
"exit", "exit", I18N_STRING("Exit successfully", "成功退出"),
true, false, false}
};
static const exec_catalog_entry_t *entry_for_id(tnt_exec_command_id_t id) {
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
if (entries[i].id == id) {
return &entries[i];
}
}
return NULL;
}
static const char *skip_spaces(const char *value) {
while (value && *value && (*value == ' ' || *value == '\t')) {
value++;
}
return value;
}
static bool name_matches(const char *line, const char *name,
const char **args) {
size_t len;
if (!line || !name) {
return false;
}
len = strlen(name);
if (strncmp(line, name, len) != 0) {
return false;
}
if (line[len] != '\0' && line[len] != ' ' && line[len] != '\t') {
return false;
}
if (args) {
const char *candidate_args = skip_spaces(line + len);
*args = candidate_args && candidate_args[0] != '\0'
? candidate_args
: NULL;
}
return true;
}
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
const char **args) {
line = skip_spaces(line);
if (!line || line[0] == '\0') {
return false;
}
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
if (!name_matches(line, entries[i].name, args) &&
!name_matches(line, entries[i].alias, args)) {
continue;
}
if (id) {
*id = entries[i].id;
}
return true;
}
return false;
}
bool exec_catalog_args_valid(tnt_exec_command_id_t id, const char *args) {
const exec_catalog_entry_t *entry = entry_for_id(id);
if (!entry) {
return false;
}
if (entry->no_args) {
return !args || args[0] == '\0';
}
if (entry->optional_json) {
return !args || strcmp(args, "--json") == 0;
}
if (entry->requires_args) {
return args && args[0] != '\0';
}
return true;
}
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
static const i18n_string_t header =
I18N_STRING("TNT exec interface\nCommands:\n",
"TNT exec 接口\n命令:\n");
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(header, lang));
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
const char *summary = i18n_string(entries[i].summary, lang);
buffer_appendf(buffer, buf_size, pos, " %-15s %s\n",
entries[i].usage, summary);
}
}
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang) {
const exec_catalog_entry_t *entry = entry_for_id(id);
static const i18n_string_t usage_format =
I18N_STRING("%s: usage: %s\n", "%s: 用法: %s\n");
if (!entry) {
return;
}
buffer_appendf(buffer, buf_size, pos, i18n_string(usage_format, lang),
entry->name, entry->usage_syntax);
}

View file

@ -1,116 +0,0 @@
#include "help_text.h"
#include "command_catalog.h"
#include "i18n.h"
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang) {
static const i18n_string_t before_commands = I18N_STRING(
"TNT KEY REFERENCE\n"
"\n"
"OPERATING MODES:\n"
" INSERT - Type and send messages (default)\n"
" NORMAL - Browse message history\n"
" COMMAND - Execute commands\n"
"\n"
"INSERT MODE KEYS:\n"
" ESC - Enter NORMAL mode\n"
" Enter - Send message\n"
" Backspace - Delete character\n"
" Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n"
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
" Opens at latest messages\n"
" Follows latest until you scroll up\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" ? - Show full key reference\n"
" Ctrl+C - Exit chat\n"
"\n"
"AVAILABLE COMMANDS:\n",
"TNT 按键参考\n"
"\n"
"操作模式:\n"
" INSERT - 输入和发送消息(默认)\n"
" NORMAL - 浏览消息历史\n"
" COMMAND - 执行命令\n"
"\n"
"INSERT 模式按键:\n"
" ESC - 进入 NORMAL 模式\n"
" Enter - 发送消息\n"
" Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" ? - 显示完整按键参考\n"
" Ctrl+C - 退出聊天\n"
"\n"
"可用命令:\n"
);
static const i18n_string_t after_commands = I18N_STRING(
"\n"
"COMMAND OUTPUT KEYS:\n"
" q, ESC - Close output\n"
" j/k - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" g/G - Jump to top/bottom\n"
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
" @username - Mention user (bell + highlight)\n"
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" g/G - Jump to top/bottom\n"
" l - Cycle UI language\n",
"\n"
"命令输出按键:\n"
" q, ESC - 关闭输出\n"
" j/k - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
"\n"
"特殊消息:\n"
" /me <action> - 发送动作 (如 /me waves)\n"
" @username - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" l - 切换界面语言\n"
);
buffer_appendf(buffer, buf_size, pos, "%s",
i18n_string(before_commands, lang));
command_catalog_append_full(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos, "%s",
i18n_string(after_commands, lang));
}

View file

@ -1,84 +0,0 @@
#include "history_view.h"
static void message_date_key(const message_t *msg, char out[11]) {
struct tm tmi;
localtime_r(&msg->timestamp, &tmi);
strftime(out, 11, "%Y-%m-%d", &tmi);
}
static int rendered_rows_for_slice(const message_t *messages, int start,
int end) {
int rows = 0;
char last_date[11] = "";
for (int i = start; i < end; i++) {
char this_date[11];
message_date_key(&messages[i], this_date);
if (strcmp(this_date, last_date) != 0) {
rows++;
memcpy(last_date, this_date, sizeof(last_date));
}
rows++;
}
return rows;
}
int history_view_height(int terminal_height) {
int height = terminal_height - 3;
return height < 1 ? 1 : height;
}
int history_view_max_scroll(int message_count, int view_height) {
int max_scroll = message_count - view_height;
return max_scroll < 0 ? 0 : max_scroll;
}
void history_view_scroll_to_latest(int *scroll_pos, bool *follow_tail,
int message_count, int view_height) {
if (!scroll_pos || !follow_tail) return;
*scroll_pos = history_view_max_scroll(message_count, view_height);
*follow_tail = true;
}
void history_view_scroll_to_oldest(int *scroll_pos, bool *follow_tail) {
if (!scroll_pos || !follow_tail) return;
*scroll_pos = 0;
*follow_tail = false;
}
void history_view_scroll_by(int *scroll_pos, bool *follow_tail,
int message_count, int view_height, int delta) {
if (!scroll_pos || !follow_tail) return;
int max_scroll = history_view_max_scroll(message_count, view_height);
if (*follow_tail && delta < 0) {
*scroll_pos = max_scroll;
}
*scroll_pos += delta;
if (*scroll_pos < 0) {
*scroll_pos = 0;
} else if (*scroll_pos > max_scroll) {
*scroll_pos = max_scroll;
}
*follow_tail = *scroll_pos >= max_scroll;
}
int history_view_latest_start_for_height(const message_t *messages, int count,
int height) {
int start = count;
for (int candidate = count - 1; candidate >= 0; candidate--) {
int rows = rendered_rows_for_slice(messages, candidate, count);
if (rows > height) {
break;
}
start = candidate;
}
if (start == count && count > 0) {
start = count - 1;
}
return start;
}

View file

@ -1,116 +0,0 @@
#include "i18n.h"
#include <ctype.h>
typedef struct {
ui_lang_t lang;
const char *code;
const char *prefixes[4];
} ui_lang_definition_t;
static const ui_lang_definition_t ui_lang_defs[] = {
{UI_LANG_EN, "en", {"en", "c", "posix", NULL}},
{UI_LANG_ZH, "zh", {"zh", NULL}}
};
typedef char ui_lang_defs_must_cover_enum[
sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]) == UI_LANG_COUNT ? 1 : -1];
static const char *skip_space(const char *value) {
while (value && *value &&
isspace((unsigned char)*value)) {
value++;
}
return value;
}
static bool is_lang_boundary(const char *value) {
if (*value == '\0' || *value == '_' || *value == '-' || *value == '.') {
return true;
}
if (!isspace((unsigned char)*value)) {
return false;
}
return *skip_space(value) == '\0';
}
static bool starts_with_lang(const char *value, const char *prefix) {
if (!value || !prefix) return false;
value = skip_space(value);
while (*prefix) {
if (tolower((unsigned char)*value) !=
tolower((unsigned char)*prefix)) {
return false;
}
value++;
prefix++;
}
return is_lang_boundary(value);
}
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang) {
if (!value || value[0] == '\0') {
return false;
}
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
for (size_t j = 0; ui_lang_defs[i].prefixes[j]; j++) {
if (starts_with_lang(value, ui_lang_defs[i].prefixes[j])) {
if (lang) {
*lang = ui_lang_defs[i].lang;
}
return true;
}
}
}
return false;
}
ui_lang_t i18n_parse_ui_lang(const char *value, ui_lang_t fallback) {
ui_lang_t lang;
if (i18n_try_parse_ui_lang(value, &lang)) {
return lang;
}
return fallback;
}
ui_lang_t i18n_default_ui_lang(void) {
const char *explicit_lang = getenv("TNT_LANG");
if (explicit_lang && explicit_lang[0] != '\0') {
return i18n_parse_ui_lang(explicit_lang, UI_LANG_EN);
}
const char *locale = getenv("LC_ALL");
if (!locale || locale[0] == '\0') {
locale = getenv("LC_MESSAGES");
}
if (!locale || locale[0] == '\0') {
locale = getenv("LANG");
}
return i18n_parse_ui_lang(locale, UI_LANG_EN);
}
ui_lang_t i18n_next_ui_lang(ui_lang_t lang) {
size_t count = sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]);
for (size_t i = 0; i < count; i++) {
if (ui_lang_defs[i].lang == lang) {
return ui_lang_defs[(i + 1) % count].lang;
}
}
return ui_lang_defs[0].lang;
}
const char *i18n_ui_lang_code(ui_lang_t lang) {
for (size_t i = 0; i < sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]); i++) {
if (ui_lang_defs[i].lang == lang) {
return ui_lang_defs[i].code;
}
}
return ui_lang_defs[0].code;
}

View file

@ -1,209 +0,0 @@
#include "i18n.h"
static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
[I18N_USERNAME_PROMPT] = I18N_STRING(
" Enter display name (blank for anonymous): ",
" 请输入用户名 (留空 anonymous): "
),
[I18N_INVALID_USERNAME] = I18N_STRING(
"Invalid username. Using 'anonymous' instead.\r\n",
"用户名无效,已改用 anonymous。\r\n"
),
[I18N_ROOM_FULL] = I18N_STRING(
"Room is full\r\n",
"房间已满\r\n"
),
[I18N_WELCOME_SUBTITLE] = I18N_STRING(
"anonymous chat · SSH",
"匿名聊天室 · SSH"
),
[I18N_WELCOME_TAGLINE] = I18N_STRING(
"keyboard-first terminal chat",
"键盘友好的终端交流"
),
[I18N_WELCOME_FALLBACK_FORMAT] = I18N_STRING(
"TNT %s - anonymous chat over SSH\r\n\r\n",
"TNT %s - SSH 匿名聊天室\r\n\r\n"
),
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
"Enter send · Esc browse · :help",
"Enter 发送 · Esc 浏览 · :help"
),
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
"Enter · Esc · :help",
"Enter · Esc · :help"
),
[I18N_NORMAL_LATEST] = I18N_STRING(
"G latest",
"G 最新"
),
[I18N_NORMAL_NEW_MESSAGES] = I18N_STRING(
"new",
"新消息"
),
[I18N_HELP_TITLE] = I18N_STRING(
" KEYS ",
" 按键 "
),
[I18N_HELP_STATUS_FORMAT] = I18N_STRING(
"-- KEY REFERENCE -- (%d/%d) j/k:scroll g/G:top/bottom l:lang q:close",
"-- 按键参考 -- (%d/%d) j/k:滚动 g/G:首尾 l:语言 q:关闭"
),
[I18N_COMMAND_OUTPUT_TITLE] = I18N_STRING(
" COMMAND OUTPUT ",
" 命令输出 "
),
[I18N_COMMAND_OUTPUT_STATUS_FORMAT] = I18N_STRING(
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom q:close",
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 q:关闭"
),
[I18N_MOTD_TITLE] = I18N_STRING(
" NOTICE ",
" 公告 "
),
[I18N_MOTD_CONTINUE_HINT] = I18N_STRING(
" Press any key ",
" 按任意键继续 "
),
[I18N_TITLE_ONLINE_FORMAT] = I18N_STRING(
"online %d",
"在线 %d"
),
[I18N_TITLE_MUTED] = I18N_STRING(
"muted",
"静音"
),
[I18N_TITLE_HELP_HINT] = I18N_STRING(
"? keys",
"? 按键"
),
[I18N_IDLE_TIMEOUT_FORMAT] = I18N_STRING(
"\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
"\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n"
),
[I18N_SYSTEM_USERNAME] = I18N_STRING(
"system",
"系统"
),
[I18N_SYSTEM_JOIN_FORMAT] = I18N_STRING(
"%s joined the room",
"%s 加入了聊天室"
),
[I18N_SYSTEM_LEAVE_FORMAT] = I18N_STRING(
"%s left the room",
"%s 离开了聊天室"
),
[I18N_SYSTEM_NICK_FORMAT] = I18N_STRING(
"%s renamed to %s",
"%s 更名为 %s"
),
[I18N_USERS_TITLE] = I18N_STRING(
"Online users",
"在线用户"
),
[I18N_MSG_SENT_FORMAT] = I18N_STRING(
"Private message sent to %s\n",
"私信已发送给 %s\n"
),
[I18N_MSG_USER_NOT_FOUND_FORMAT] = I18N_STRING(
"User '%s' not found\n",
"未找到用户 '%s'\n"
),
[I18N_INBOX_TITLE] = I18N_STRING(
"Private messages",
"私信"
),
[I18N_INBOX_EMPTY] = I18N_STRING(
"(empty)",
"(空)"
),
[I18N_NICK_INVALID] = I18N_STRING(
"Invalid username\n",
"用户名无效\n"
),
[I18N_NICK_TAKEN_FORMAT] = I18N_STRING(
"Nickname '%s' is already taken\n",
"昵称 '%s' 已被使用\n"
),
[I18N_NICK_UNCHANGED] = I18N_STRING(
"Nickname unchanged\n",
"昵称未变化\n"
),
[I18N_NICK_CHANGED_FORMAT] = I18N_STRING(
"Nickname changed: %s -> %s\n",
"昵称已修改: %s -> %s\n"
),
[I18N_LAST_HEADER_FORMAT] = I18N_STRING(
"--- Last %d message(s) ---\n",
"--- 最近 %d 条消息 ---\n"
),
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
"--- Search: \"%s\" (%d match(es)) ---\n",
"--- 搜索: \"%s\" (%d 条匹配) ---\n"
),
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
"Join/leave notifications: %s\n",
"加入/离开提示: %s\n"
),
[I18N_MUTE_JOINS_MUTED] = I18N_STRING(
"muted",
"已静音"
),
[I18N_MUTE_JOINS_UNMUTED] = I18N_STRING(
"unmuted",
"已开启"
),
[I18N_CLEAR_DONE] = I18N_STRING(
"Command output cleared\n",
"命令输出已清空\n"
),
[I18N_LANG_CURRENT_FORMAT] = I18N_STRING(
"Current language: %s\n"
"Usage: lang <en|zh>\n",
"当前语言: %s\n"
"用法: lang <en|zh>\n"
),
[I18N_LANG_SET_FORMAT] = I18N_STRING(
"Language set to: %s\n",
"语言已切换为: %s\n"
),
[I18N_LANG_UNSUPPORTED_FORMAT] = I18N_STRING(
"Unsupported language: %s\n"
"Usage: lang <en|zh>\n",
"不支持的语言: %s\n"
"用法: lang <en|zh>\n"
),
[I18N_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
"Unknown command: %s\n",
"未知命令: %s\n"
),
[I18N_DID_YOU_MEAN_FORMAT] = I18N_STRING(
"Did you mean :%s?\n",
"你是想输入 :%s 吗?\n"
),
[I18N_UNKNOWN_GUIDANCE] = I18N_STRING(
"Type :help for help\n",
"输入 :help 查看帮助\n"
),
[I18N_EXEC_POST_EMPTY] = I18N_STRING(
"post: message cannot be empty\n",
"post: 消息不能为空\n"
),
[I18N_EXEC_POST_INVALID_UTF8] = I18N_STRING(
"post: invalid UTF-8 input\n",
"post: 输入不是有效 UTF-8\n"
),
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
"Unknown command: %s\n",
"未知命令: %s\n"
)
};
const char *i18n_text(ui_lang_t lang, i18n_text_id_t id) {
if (id < 0 || id >= I18N_TEXT_COUNT) {
return "";
}
const i18n_string_t *entry = &text_catalog[id];
return i18n_string(*entry, lang);
}

View file

@ -4,28 +4,22 @@
#include "commands.h"
#include "common.h"
#include "exec.h"
#include "history_view.h"
#include "i18n.h"
#include "message.h"
#include "ratelimit.h"
#include "system_message.h"
#include "tui.h"
#include "utf8.h"
#include <libssh/callbacks.h>
#include <libssh/libssh.h>
#include <libssh/server.h>
#include <strings.h> /* strncasecmp */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
void input_init(void) {
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
g_default_ui_lang = i18n_default_ui_lang();
}
static int read_username(client_t *client) {
@ -34,8 +28,7 @@ static int read_username(client_t *client) {
char buf[4];
tui_render_welcome(client);
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_USERNAME_PROMPT));
client_printf(client, " 请输入用户名 (留空 anonymous): ");
while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -117,8 +110,7 @@ static int read_username(client_t *client) {
/* Validate username for security */
if (!is_valid_username(client->username)) {
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_INVALID_USERNAME));
client_printf(client, "Invalid username. Using 'anonymous' instead.\r\n");
strcpy(client->username, "anonymous");
} else {
/* Truncate to 20 characters */
@ -151,93 +143,20 @@ void notify_mentions(const char *content, const client_t *sender) {
for (int i = 0; i < target_count; i++) {
client_send(targets[i], "\a", 1);
targets[i]->unread_mentions++;
targets[i]->redraw_pending = true;
client_release(targets[i]);
}
}
static int read_channel_exact(client_t *client, char *buf, size_t len,
int timeout_ms) {
size_t got = 0;
while (got < len) {
int n = ssh_channel_read_timeout(client->channel, buf + got,
len - got, 0, timeout_ms);
if (n == SSH_AGAIN || n <= 0) {
break;
}
got += (size_t)n;
}
return (int)got;
}
static bool append_paste_byte(char *input, unsigned char b) {
if (b == '\r' || b == '\n' || b == '\t') {
b = ' ';
}
if (b < 32) {
return true;
}
size_t cur = strlen(input);
if (cur < MAX_MESSAGE_LEN - 1) {
input[cur] = (char)b;
input[cur + 1] = '\0';
return true;
}
return false;
}
static void normal_scroll_to_latest(client_t *client) {
if (!client) return;
history_view_scroll_to_latest(&client->scroll_pos, &client->follow_tail,
room_get_message_count(g_room),
history_view_height(client->height));
}
static void normal_scroll_by(client_t *client, int delta) {
if (!client) return;
history_view_scroll_by(&client->scroll_pos, &client->follow_tail,
room_get_message_count(g_room),
history_view_height(client->height), delta);
}
static void dismiss_command_output(client_t *client) {
bool was_motd;
if (!client) return;
was_motd = client->show_motd;
client->command_output[0] = '\0';
client->command_output_scroll = 0;
client->show_motd = false;
client->mode = MODE_NORMAL;
if (was_motd) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
}
/* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle Ctrl+C (Exit or switch to NORMAL) */
if (key == 3) {
client_mode_t previous_mode = client->mode;
if (client->command_output[0] != '\0') {
dismiss_command_output(client);
return true;
}
if (previous_mode != MODE_NORMAL) {
if (client->mode != MODE_NORMAL) {
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
client->show_help = false;
if (previous_mode == MODE_INSERT) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
} else {
/* In NORMAL mode, Ctrl+C exits */
@ -248,17 +167,15 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle help screen */
if (client->show_help) {
/* Page size: roughly the visible help body region. */
int page = client->height - 2;
if (page < 1) page = 1;
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q' || key == 27) {
client->show_help = false;
tui_render_screen(client);
} else if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
} else if (key == 'e' || key == 'E') {
client->help_lang = LANG_EN;
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'z' || key == 'Z') {
client->help_lang = LANG_ZH;
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'j') {
@ -267,20 +184,6 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
} else if (key == 'k' && client->help_scroll_pos > 0) {
client->help_scroll_pos--;
tui_render_help(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->help_scroll_pos += half;
tui_render_help(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->help_scroll_pos -= half;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->help_scroll_pos += page;
tui_render_help(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->help_scroll_pos -= page;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'g') {
client->help_scroll_pos = 0;
tui_render_help(client);
@ -291,171 +194,25 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
return true; /* Key consumed */
}
/* Handle command output / MOTD display. MOTD remains a simple notice;
* command output behaves like a small pager so long results can be read. */
/* Handle command output / MOTD display: any key dismisses */
if (client->command_output[0] != '\0') {
int page = client->height - 2;
int half;
if (client->show_motd) {
dismiss_command_output(client);
return true;
}
if (page < 1) page = 1;
half = page / 2;
if (half < 1) half = 1;
if (key == 'q' || key == 27) {
dismiss_command_output(client);
} else if (key == 'j') {
client->command_output_scroll++;
tui_render_command_output(client);
} else if (key == 'k') {
client->command_output_scroll--;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->command_output_scroll += half;
tui_render_command_output(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->command_output_scroll -= half;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->command_output_scroll += page;
tui_render_command_output(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->command_output_scroll -= page;
if (client->command_output_scroll < 0) {
client->command_output_scroll = 0;
}
tui_render_command_output(client);
} else if (key == 'g') {
client->command_output_scroll = 0;
tui_render_command_output(client);
} else if (key == 'G') {
client->command_output_scroll = 999;
tui_render_command_output(client);
}
client->command_output[0] = '\0';
client->show_motd = false;
client->mode = MODE_NORMAL;
tui_render_screen(client);
return true; /* Key consumed */
}
/* Mode-specific handling */
switch (client->mode) {
case MODE_INSERT:
if (key == 27) { /* ESC — may also be the start of an arrow seq */
char seq[2];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n == 1 && seq[0] == '[') {
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n == 1) {
if (seq[1] == 'A') { /* Up — walk back through sent history */
if (client->insert_history_count > 0 &&
client->insert_history_pos > 0) {
client->insert_history_pos--;
strncpy(input,
client->insert_history[client->insert_history_pos],
MAX_MESSAGE_LEN - 1);
input[MAX_MESSAGE_LEN - 1] = '\0';
tui_render_input(client, input);
}
return true;
} else if (seq[1] == 'B') { /* Down — walk forward */
if (client->insert_history_pos <
client->insert_history_count - 1) {
client->insert_history_pos++;
strncpy(input,
client->insert_history[client->insert_history_pos],
MAX_MESSAGE_LEN - 1);
input[MAX_MESSAGE_LEN - 1] = '\0';
} else {
client->insert_history_pos =
client->insert_history_count;
input[0] = '\0';
}
tui_render_input(client, input);
return true;
} else if (seq[1] == '2') {
/* Could be bracketed-paste start "ESC[200~".
* Read the next 3 bytes and confirm. */
char rest[3];
int m = read_channel_exact(client, rest,
sizeof(rest), 500);
if (m == 3 && rest[0] == '0' && rest[1] == '0'
&& rest[2] == '~') {
/* Drain bytes into `input` until we see
* the end marker ESC[201~. Newlines become
* spaces so a multi-line paste stays a
* single message instead of N sends. */
bool overflow = false;
while (1) {
char b;
int k = ssh_channel_read_timeout(
client->channel, &b, 1, 0, 5000);
if (k != 1) break;
if (b == '\033') {
char tail[5];
int t = read_channel_exact(
client, tail, sizeof(tail), 500);
if (t == 5 && tail[0] == '['
&& tail[1] == '2'
&& tail[2] == '0'
&& tail[3] == '1'
&& tail[4] == '~') {
break; /* end of paste */
}
/* Stray ESC inside paste: drop the ESC
* but keep printable bytes that
* followed it. */
for (int i = 0; i < t; i++) {
if (!append_paste_byte(
input,
(unsigned char)tail[i])) {
overflow = true;
}
}
continue;
}
if (!append_paste_byte(input,
(unsigned char)b)) {
overflow = true;
}
}
tui_render_input(client, input);
if (overflow) {
client_send(client, "\a", 1);
}
}
return true;
}
}
}
/* Plain ESC — fall through to NORMAL mode */
if (key == 27) { /* ESC */
client->mode = MODE_NORMAL;
normal_scroll_to_latest(client);
client->scroll_pos = 0;
tui_render_screen(client);
return true;
return true; /* Key consumed */
} else if (key == '\r' || key == '\n') { /* Enter */
if (input[0] != '\0') {
/* Record into the per-client INSERT history ring */
int max_hist = (int)(sizeof(client->insert_history) /
sizeof(client->insert_history[0]));
if (client->insert_history_count >= max_hist) {
memmove(&client->insert_history[0],
&client->insert_history[1],
(max_hist - 1) * sizeof(client->insert_history[0]));
client->insert_history_count = max_hist - 1;
}
snprintf(client->insert_history[client->insert_history_count],
sizeof(client->insert_history[0]), "%s", input);
client->insert_history_count++;
client->insert_history_pos = client->insert_history_count;
message_t msg = {
.timestamp = time(NULL),
};
@ -496,62 +253,18 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
tui_render_input(client, input);
}
return true;
} else if (key == 9) { /* Tab: complete @mention */
/* Walk back from end to find the start of the trailing
* "@…" token (an '@' not preceded by an alphanumeric).
* If found, scan g_room for the first case-insensitive
* username prefix-match (cycling past self) and replace
* the token. */
size_t in_len = strlen(input);
ssize_t at_idx = -1;
for (ssize_t i = (ssize_t)in_len - 1; i >= 0; i--) {
unsigned char c = (unsigned char)input[i];
if (c == '@') {
if (i == 0 || input[i - 1] == ' ') at_idx = i;
break;
}
if (c == ' ') break;
}
if (at_idx >= 0) {
const char *prefix = input + at_idx + 1;
size_t plen = strlen(prefix);
char match[MAX_USERNAME_LEN] = "";
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
const char *uname = g_room->clients[i]->username;
if (plen == 0
? strcmp(uname, client->username) != 0
: strncasecmp(uname, prefix, plen) == 0) {
snprintf(match, sizeof(match), "%s", uname);
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
if (match[0] != '\0') {
/* Replace "@<prefix>" with "@<match> " (trailing
* space so the next word starts cleanly). */
size_t avail = MAX_MESSAGE_LEN - 1
- (size_t)at_idx - 1;
size_t mlen = strlen(match);
if (mlen + 1 <= avail) {
input[at_idx + 1] = '\0';
strncat(input, match, avail);
strncat(input, " ", 1);
tui_render_input(client, input);
}
}
}
return true;
}
break;
case MODE_NORMAL: {
int nm_msg_height = history_view_height(client->height);
int nm_msg_count = room_get_message_count(g_room);
int nm_msg_height = client->height - 3;
if (nm_msg_height < 1) nm_msg_height = 1;
int nm_max_scroll = nm_msg_count - nm_msg_height;
if (nm_max_scroll < 0) nm_max_scroll = 0;
if (key == 'i') {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
tui_render_screen(client);
return true;
} else if (key == ':') {
@ -560,79 +273,47 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
tui_render_screen(client);
return true;
} else if (key == 'j') {
normal_scroll_by(client, 1);
tui_render_screen(client);
if (client->scroll_pos < nm_max_scroll) {
client->scroll_pos++;
tui_render_screen(client);
}
return true;
} else if (key == 'k' && client->scroll_pos > 0) {
normal_scroll_by(client, -1);
client->scroll_pos--;
tui_render_screen(client);
return true;
} else if (key == 4) { /* Ctrl+D: half page down */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
normal_scroll_by(client, half);
client->scroll_pos += half;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 21) { /* Ctrl+U: half page up */
int half = nm_msg_height / 2;
if (half < 1) half = 1;
normal_scroll_by(client, -half);
client->scroll_pos -= half;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 6) { /* Ctrl+F: full page down */
normal_scroll_by(client, nm_msg_height);
client->scroll_pos += nm_msg_height;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 2) { /* Ctrl+B: full page up */
normal_scroll_by(client, -nm_msg_height);
client->scroll_pos -= nm_msg_height;
if (client->scroll_pos < 0) client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 'g') {
history_view_scroll_to_oldest(&client->scroll_pos,
&client->follow_tail);
client->scroll_pos = 0;
tui_render_screen(client);
return true;
} else if (key == 'G') {
normal_scroll_to_latest(client);
client->unread_mentions = 0;
client->scroll_pos = nm_max_scroll;
tui_render_screen(client);
return true;
} else if (key == 27) {
char seq[4];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n == 1 && seq[0] == '[') {
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n == 1) {
if (seq[1] == 'A') { /* Up arrow */
normal_scroll_by(client, -1);
} else if (seq[1] == 'B') { /* Down arrow */
normal_scroll_by(client, 1);
} else if (seq[1] == 'H') { /* Home */
history_view_scroll_to_oldest(&client->scroll_pos,
&client->follow_tail);
} else if (seq[1] == 'F') { /* End */
normal_scroll_to_latest(client);
} 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 */
normal_scroll_by(client, -nm_msg_height);
} else if (seq[1] == '6') { /* PageDown */
normal_scroll_by(client, nm_msg_height);
} else if (seq[1] == '1') { /* Home */
history_view_scroll_to_oldest(
&client->scroll_pos,
&client->follow_tail);
} else if (seq[1] == '4') { /* End */
normal_scroll_to_latest(client);
}
}
}
tui_render_screen(client);
}
}
return true;
} else if (key == '?') {
client->show_help = true;
client->help_scroll_pos = 0;
@ -715,18 +396,15 @@ void input_run_session(client_t *client) {
char input[MAX_MESSAGE_LEN] = {0};
char buf[4];
bool joined_room = false;
bool bracketed_paste_enabled = false;
uint64_t seen_update_seq;
time_t last_keepalive = time(NULL);
/* Terminal size already set from PTY request */
client->mode = MODE_INSERT;
client->follow_tail = true;
client->ui_lang = g_default_ui_lang;
client->help_lang = LANG_ZH;
client->connected = true;
client->command_history_count = 0;
client->command_history_pos = 0;
client->command_output_scroll = 0;
client->connect_time = time(NULL);
client->last_active = time(NULL);
@ -734,9 +412,6 @@ void input_run_session(client_t *client) {
if (client->exec_command[0] != '\0') {
int exit_status = exec_dispatch(client);
ssh_channel_request_send_exit_status(client->channel, exit_status);
ssh_channel_send_eof(client->channel);
ssh_blocking_flush(client->session, 1000);
ssh_channel_close(client->channel);
goto cleanup;
}
@ -747,21 +422,18 @@ void input_run_session(client_t *client) {
/* Add to room */
if (room_add_client(g_room, client) < 0) {
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_ROOM_FULL));
client_printf(client, "Room is full\n");
goto cleanup;
}
joined_room = true;
/* Enable xterm bracketed-paste mode only for interactive chat, so
* multi-line pastes arrive framed by ESC[200~...ESC[201~ instead of
* as a stream of Enters. Terminals that don't recognise it ignore it. */
client_send(client, "\033[?2004h", 8);
bracketed_paste_enabled = true;
/* Broadcast join message */
message_t join_msg;
system_message_make_join(&join_msg, client->username, client->ui_lang);
message_t join_msg = {
.timestamp = time(NULL),
};
strncpy(join_msg.username, "系统", MAX_USERNAME_LEN - 1);
join_msg.username[MAX_USERNAME_LEN - 1] = '\0';
snprintf(join_msg.content, MAX_MESSAGE_LEN, "%s 加入了聊天室", client->username);
room_broadcast(g_room, &join_msg);
message_save(&join_msg);
@ -779,7 +451,6 @@ void input_run_session(client_t *client) {
snprintf(client->command_output,
sizeof(client->command_output),
"%s", motd_buf);
client->command_output_scroll = 0;
client->show_motd = true;
tui_render_motd(client);
seen_update_seq = room_get_update_seq(g_room);
@ -828,10 +499,6 @@ main_loop:
} else if (client->command_output[0] != '\0') {
tui_render_command_output(client);
} else {
if (room_updated && client->mode == MODE_NORMAL &&
client->follow_tail) {
normal_scroll_to_latest(client);
}
tui_render_screen(client);
if (client->mode == MODE_INSERT && input[0] != '\0') {
tui_render_input(client, input);
@ -846,9 +513,7 @@ main_loop:
if (g_idle_timeout > 0 && joined_room &&
time(NULL) - client->last_active >= g_idle_timeout) {
client_printf(client,
i18n_text(client->ui_lang,
I18N_IDLE_TIMEOUT_FORMAT),
client_printf(client, "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
g_idle_timeout / 60);
break;
}
@ -881,8 +546,6 @@ main_loop:
input[len] = b;
input[len + 1] = '\0';
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
}
} else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b);
@ -904,12 +567,10 @@ main_loop:
continue;
}
int len = strlen(input);
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
if (len + char_len < MAX_MESSAGE_LEN - 1) {
memcpy(input + len, buf, char_len);
input[len + char_len] = '\0';
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
}
}
} else if (client->mode == MODE_COMMAND && !client->show_help &&
@ -943,16 +604,14 @@ main_loop:
}
cleanup:
if (bracketed_paste_enabled && client->channel &&
ssh_channel_is_open(client->channel)) {
client_send(client, "\033[?2004l", 8);
}
/* Broadcast leave message */
if (joined_room) {
message_t leave_msg;
system_message_make_leave(&leave_msg, client->username,
client->ui_lang);
message_t leave_msg = {
.timestamp = time(NULL),
};
strncpy(leave_msg.username, "系统", MAX_USERNAME_LEN - 1);
leave_msg.username[MAX_USERNAME_LEN - 1] = '\0';
snprintf(leave_msg.content, MAX_MESSAGE_LEN, "%s 离开了聊天室", client->username);
client->connected = false;
room_remove_client(g_room, client);

View file

@ -1,9 +1,7 @@
#include "chat_room.h"
#include "cli_text.h"
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "ssh_server.h"
#include "chat_room.h"
#include "message.h"
#include <signal.h>
#include <unistd.h>
@ -20,7 +18,6 @@ static void signal_handler(int sig) {
int main(int argc, char **argv) {
int port = DEFAULT_PORT;
ui_lang_t lang = i18n_default_ui_lang();
/* Environment provides defaults; command-line flags override it. */
const char *port_env = getenv("PORT");
@ -39,8 +36,7 @@ int main(int argc, char **argv) {
char *end;
long val = strtol(argv[i + 1], &end, 10);
if (*end != '\0' || val <= 0 || val > 65535) {
fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]);
fprintf(stderr, "Invalid port: %s\n", argv[i + 1]);
return 1;
}
port = (int)val;
@ -56,15 +52,24 @@ int main(int argc, char **argv) {
printf("tnt %s\n", TNT_VERSION);
return 0;
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
char output[2048] = {0};
size_t pos = 0;
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
fputs(output, stdout);
printf("tnt %s - anonymous SSH chat server\n\n", TNT_VERSION);
printf("Usage: %s [options]\n\n", argv[0]);
printf("Options:\n");
printf(" -p, --port PORT Listen on PORT (default: %d)\n", DEFAULT_PORT);
printf(" -d, --state-dir DIR Store host key and logs in DIR\n");
printf(" -V, --version Show version\n");
printf(" -h, --help Show this help\n");
printf("\nEnvironment:\n");
printf(" PORT Default listening port\n");
printf(" TNT_STATE_DIR State directory\n");
printf(" TNT_ACCESS_TOKEN Require this password for SSH auth\n");
printf(" TNT_MAX_CONNECTIONS Global connection limit (default: 64)\n");
printf(" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n");
printf(" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n");
return 0;
} else {
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
fprintf(stderr, "Unknown option: %s\n", argv[i]);
fprintf(stderr, "Usage: %s [-p PORT] [-d DIR] [-h]\n", argv[0]);
return 1;
}
}

View file

@ -1,9 +0,0 @@
#include "manual.h"
#include "manual_text.h"
void manual_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos, ui_lang_t lang) {
if (!buffer || !pos) return;
manual_text_append_interactive(buffer, buf_size, pos, lang);
}

View file

@ -1,50 +0,0 @@
#include "manual_text.h"
#include "command_catalog.h"
#include "i18n.h"
void manual_text_append_interactive(char *buffer, size_t buf_size,
size_t *pos, ui_lang_t lang) {
static const i18n_string_t intro = I18N_STRING(
"\033[1;36mTNT(1) help\033[0m\n"
"\n"
"\033[1;37mName\033[0m\n"
" TNT - SSH terminal chat room\n"
"\n"
"\033[1;37mUse\033[0m\n"
" Type a message and press Enter; Esc browses; G latest; i types\n"
" : runs commands; ? opens the full key reference\n"
"\n"
"\033[1;37mCommands\033[0m\n",
"\033[1;36mTNT(1) 帮助\033[0m\n"
"\n"
"\033[1;37m名称\033[0m\n"
" TNT - SSH 终端聊天室\n"
"\n"
"\033[1;37m使用\033[0m\n"
" 输入消息并 Enter 发送Esc 浏览历史G 最新i 输入\n"
" : 运行命令;? 打开完整按键参考\n"
"\n"
"\033[1;37m命令\033[0m\n"
);
static const i18n_string_t outro = I18N_STRING(
"\n"
"\033[1;37mLanguage\033[0m\n"
" :lang show current language\n"
" :lang en|zh switch language\n"
"\n"
"\033[1;37mSee also\033[0m\n"
" ? full key reference\n",
"\n"
"\033[1;37m语言\033[0m\n"
" :lang 显示当前语言\n"
" :lang en|zh 切换语言\n"
"\n"
"\033[1;37m参见\033[0m\n"
" ? 完整按键参考\n"
);
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(intro, lang));
command_catalog_append_manual(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos, "%s", i18n_string(outro, lang));
}

View file

@ -134,7 +134,7 @@ bool ratelimit_check_ip(const char *ip) {
}
entry->recent_connection_count++;
if (entry->recent_connection_count > g_max_conn_rate_per_ip) {
if (entry->recent_connection_count >= g_max_conn_rate_per_ip) {
entry->is_blocked = true;
entry->block_until = now + BLOCK_DURATION;
pthread_mutex_unlock(&g_rate_limit_lock);

View file

@ -7,7 +7,6 @@
#include "tui.h"
#include "utf8.h"
#include <libssh/libssh.h>
#include <libssh/libssh_version.h>
#include <libssh/server.h>
#include <libssh/callbacks.h>
#include <sys/socket.h>
@ -34,29 +33,6 @@ time_t ssh_server_start_time(void) {
/* Configuration from environment variables. Rate-limiting moved to ratelimit.{c,h},
* the access token to bootstrap.{c,h}, and the idle timeout to input.{c,h}. */
static int generate_rsa_host_key(ssh_key *key) {
#if defined(LIBSSH_VERSION_INT) && LIBSSH_VERSION_INT >= SSH_VERSION_INT(0, 12, 0)
ssh_pki_ctx pki_ctx = ssh_pki_ctx_new();
int rsa_bits = 4096;
int rc;
if (!pki_ctx) {
return -1;
}
if (ssh_pki_ctx_options_set(pki_ctx, SSH_PKI_OPTION_RSA_KEY_SIZE,
&rsa_bits) < 0) {
ssh_pki_ctx_free(pki_ctx);
return -1;
}
rc = ssh_pki_generate_key(SSH_KEYTYPE_RSA, pki_ctx, key);
ssh_pki_ctx_free(pki_ctx);
return rc;
#else
return ssh_pki_generate(SSH_KEYTYPE_RSA, 4096, key);
#endif
}
/* Generate or load SSH host key */
static int setup_host_key(ssh_bind sshbind) {
struct stat st;
@ -97,7 +73,7 @@ static int setup_host_key(ssh_bind sshbind) {
/* Generate new key */
printf("Generating new RSA 4096-bit host key...\n");
ssh_key key;
if (generate_rsa_host_key(&key) < 0) {
if (ssh_pki_generate(SSH_KEYTYPE_RSA, 4096, &key) < 0) {
fprintf(stderr, "Failed to generate RSA key\n");
return -1;
}

View file

@ -1,72 +0,0 @@
#include "system_message.h"
#include "i18n.h"
#include <stdio.h>
#include <string.h>
#include <time.h>
static void system_message_init(message_t *msg, ui_lang_t lang) {
if (!msg) {
return;
}
memset(msg, 0, sizeof(*msg));
msg->timestamp = time(NULL);
snprintf(msg->username, sizeof(msg->username), "%s",
i18n_text(lang, I18N_SYSTEM_USERNAME));
}
void system_message_make_join(message_t *msg, const char *username,
ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;
}
snprintf(msg->content, sizeof(msg->content),
i18n_text(lang, I18N_SYSTEM_JOIN_FORMAT),
username ? username : "");
}
void system_message_make_leave(message_t *msg, const char *username,
ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;
}
snprintf(msg->content, sizeof(msg->content),
i18n_text(lang, I18N_SYSTEM_LEAVE_FORMAT),
username ? username : "");
}
void system_message_make_nick(message_t *msg, const char *old_name,
const char *new_name, ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;
}
snprintf(msg->content, sizeof(msg->content),
i18n_text(lang, I18N_SYSTEM_NICK_FORMAT),
old_name ? old_name : "", new_name ? new_name : "");
}
bool system_message_is_system(const message_t *msg) {
if (!msg) {
return false;
}
return strcmp(msg->username, "系统") == 0 ||
strcmp(msg->username, "system") == 0;
}
bool system_message_is_join_leave(const message_t *msg) {
if (!system_message_is_system(msg)) {
return false;
}
return strstr(msg->content, "加入了聊天室") != NULL ||
strstr(msg->content, "离开了聊天室") != NULL ||
strstr(msg->content, "joined the room") != NULL ||
strstr(msg->content, "left the room") != NULL;
}

477
src/tui.c
View file

@ -2,14 +2,15 @@
#include "client.h"
#include "ssh_server.h"
#include "chat_room.h"
#include "help_text.h"
#include "history_view.h"
#include "i18n.h"
#include "system_message.h"
#include "tui_status.h"
#include "utf8.h"
#include <unistd.h>
static bool is_join_leave_msg(const message_t *msg) {
if (strcmp(msg->username, "系统") != 0) return false;
return strstr(msg->content, "加入了聊天室") != NULL ||
strstr(msg->content, "离开了聊天室") != NULL;
}
static const char *username_color(const char *name) {
static const char *colors[] = {
"\033[31m", "\033[32m", "\033[33m",
@ -29,28 +30,9 @@ static void format_message_colored(const message_t *msg, char *buffer,
char time_str[32];
strftime(time_str, sizeof(time_str), "%H:%M", &tm_info);
/* Is this message from the local user? Used to draw a 1-column gutter
* marker so they can scan their own contributions when scrolling. */
bool is_self = false;
if (my_username && my_username[0] != '\0' &&
!system_message_is_system(msg)) {
if (strcmp(msg->username, "*") == 0) {
/* /me message: content starts with the actor's username */
size_t un_len = strlen(my_username);
if (strncmp(msg->content, my_username, un_len) == 0 &&
(msg->content[un_len] == ' ' || msg->content[un_len] == '\0')) {
is_self = true;
}
} else if (strcmp(msg->username, my_username) == 0) {
is_self = true;
}
}
/* Always 1 column wide so all messages align vertically. */
const char *gutter = is_self ? "\033[36m▎\033[0m" : " ";
bool mentioned = false;
if (my_username && my_username[0] != '\0' &&
!system_message_is_system(msg)) {
strcmp(msg->username, "系统") != 0) {
char mention[MAX_USERNAME_LEN + 2];
snprintf(mention, sizeof(mention), "@%s", my_username);
if (strstr(msg->content, mention) != NULL) {
@ -60,42 +42,41 @@ static void format_message_colored(const message_t *msg, char *buffer,
const char *hl_start = mentioned ? "\033[1;33m" : "";
const char *hl_end = mentioned ? "\033[0m" : "";
if (system_message_is_system(msg)) {
if (strcmp(msg->username, "系统") == 0) {
snprintf(buffer, buf_size,
"%s\033[90m--> %s\033[0m", gutter, msg->content);
"\033[90m--> %s\033[0m", msg->content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(buffer, buf_size,
"%s\033[90m%s\033[0m \033[3;36m* %s\033[0m",
gutter, time_str, msg->content);
"\033[90m%s\033[0m \033[3;36m* %s\033[0m",
time_str, msg->content);
} else {
snprintf(buffer, buf_size,
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
gutter, time_str, username_color(msg->username),
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
time_str, username_color(msg->username),
msg->username, hl_start, msg->content, hl_end);
}
/* Plain-text version for width calculation — gutter is 1 column. */
/* Plain-text version for width calculation */
char plain[MAX_MESSAGE_LEN + 128];
if (system_message_is_system(msg)) {
snprintf(plain, sizeof(plain), " --> %s", msg->content);
if (strcmp(msg->username, "系统") == 0) {
snprintf(plain, sizeof(plain), "--> %s", msg->content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(plain, sizeof(plain), " %s * %s", time_str, msg->content);
snprintf(plain, sizeof(plain), "%s * %s", time_str, msg->content);
} else {
snprintf(plain, sizeof(plain), " %s %s: %s",
snprintf(plain, sizeof(plain), "%s %s: %s",
time_str, msg->username, msg->content);
}
if (utf8_string_width(plain) > width) {
/* Rebuild with truncated content — prefix_plain also includes the
* 1-column gutter so the budget math comes out right. */
/* Rebuild with truncated content */
int prefix_width;
char prefix_plain[256];
if (system_message_is_system(msg)) {
snprintf(prefix_plain, sizeof(prefix_plain), " --> ");
if (strcmp(msg->username, "系统") == 0) {
snprintf(prefix_plain, sizeof(prefix_plain), "--> ");
} else if (strcmp(msg->username, "*") == 0) {
snprintf(prefix_plain, sizeof(prefix_plain), " %s * ", time_str);
snprintf(prefix_plain, sizeof(prefix_plain), "%s * ", time_str);
} else {
snprintf(prefix_plain, sizeof(prefix_plain), " %s %s: ",
snprintf(prefix_plain, sizeof(prefix_plain), "%s %s: ",
time_str, msg->username);
}
prefix_width = utf8_string_width(prefix_plain);
@ -103,7 +84,7 @@ static void format_message_colored(const message_t *msg, char *buffer,
if (content_width < 4) content_width = 4;
char truncated_content[MAX_MESSAGE_LEN];
if (system_message_is_system(msg)) {
if (strcmp(msg->username, "系统") == 0) {
strncpy(truncated_content, msg->content, sizeof(truncated_content) - 1);
truncated_content[sizeof(truncated_content) - 1] = '\0';
} else if (strcmp(msg->username, "*") == 0) {
@ -114,17 +95,17 @@ static void format_message_colored(const message_t *msg, char *buffer,
}
utf8_truncate(truncated_content, content_width);
if (system_message_is_system(msg)) {
if (strcmp(msg->username, "系统") == 0) {
snprintf(buffer, buf_size,
"%s\033[90m--> %s\033[0m", gutter, truncated_content);
"\033[90m--> %s\033[0m", truncated_content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(buffer, buf_size,
"%s\033[90m%s\033[0m \033[3;36m%s\033[0m",
gutter, time_str, truncated_content);
"\033[90m%s\033[0m \033[3;36m%s\033[0m",
time_str, truncated_content);
} else {
snprintf(buffer, buf_size,
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
gutter, time_str, username_color(msg->username),
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
time_str, username_color(msg->username),
msg->username, hl_start, truncated_content, hl_end);
}
}
@ -154,8 +135,8 @@ void tui_render_welcome(client_t *client) {
/* Lines, in display order. Width is computed in display columns. */
const char *line1 = "TNT · " TNT_VERSION;
const char *line2 = i18n_text(client->ui_lang, I18N_WELCOME_SUBTITLE);
const char *line3 = i18n_text(client->ui_lang, I18N_WELCOME_TAGLINE);
const char *line2 = "匿名聊天室 · SSH";
const char *line3 = "Anonymous chat over SSH";
int inner_w = utf8_string_width(line1);
int w2 = utf8_string_width(line2);
@ -166,13 +147,11 @@ void tui_render_welcome(client_t *client) {
/* Fall back to plain prompt if the terminal is too narrow for the frame. */
if (inner_w + 2 > rw) {
char fallback_text[96];
char fallback[128];
snprintf(fallback_text, sizeof(fallback_text),
i18n_text(client->ui_lang, I18N_WELCOME_FALLBACK_FORMAT),
TNT_VERSION);
int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "%s",
fallback_text);
int n = snprintf(fallback, sizeof(fallback),
ANSI_CLEAR ANSI_HOME
"TNT %s — anonymous chat over SSH\r\n\r\n",
TNT_VERSION);
if (n > 0) client_send(client, fallback, (size_t)n);
return;
}
@ -256,25 +235,22 @@ void tui_render_screen(client_t *client) {
int msg_count = g_room->message_count;
pthread_rwlock_unlock(&g_room->lock);
/* Calculate which messages to show. The initial slice is capped by
* message count; the lock-held copy below tightens "latest" slices so
* date dividers cannot push the newest messages off-screen. */
int msg_height = history_view_height(render_height);
/* Calculate which messages to show */
int msg_height = render_height - 3;
if (msg_height < 1) msg_height = 1;
int start = 0;
int latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
bool anchor_latest = client->mode != MODE_NORMAL ||
client->follow_tail ||
client->scroll_pos >= latest_scroll_start;
if (client->mode == MODE_NORMAL) {
start = client->scroll_pos;
if (start > latest_scroll_start) {
start = latest_scroll_start;
if (start > msg_count - msg_height) {
start = msg_count - msg_height;
}
if (start < 0) start = 0;
} else {
/* INSERT mode: show latest */
start = latest_scroll_start;
if (msg_count > msg_height) {
start = msg_count - msg_height;
}
}
int end = start + msg_height;
@ -282,11 +258,10 @@ void tui_render_screen(client_t *client) {
/* Allocate snapshot outside the lock to avoid blocking writers */
message_t *msg_snapshot = NULL;
int snapshot_capacity = msg_height;
int snapshot_count = end - start;
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
if (snapshot_count > 0) {
msg_snapshot = calloc(snapshot_count, sizeof(message_t));
}
/* Second pass under lock: copy messages */
@ -294,22 +269,12 @@ void tui_render_screen(client_t *client) {
pthread_rwlock_rdlock(&g_room->lock);
/* Re-clamp in case msg_count changed */
int actual_count = g_room->message_count;
int actual_start = start;
int actual_end = end;
if (anchor_latest) {
actual_end = actual_count;
actual_start = history_view_latest_start_for_height(
g_room->messages, actual_count, msg_height);
} else {
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
}
int actual_end = (end <= actual_count) ? end : actual_count;
int actual_start = (start < actual_end) ? start : actual_end;
int actual_snapshot = actual_end - actual_start;
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
if (actual_snapshot > 0 && actual_snapshot <= snapshot_count) {
memcpy(msg_snapshot, &g_room->messages[actual_start],
actual_snapshot * sizeof(message_t));
start = actual_start;
end = actual_end;
snapshot_count = actual_snapshot;
} else {
snapshot_count = 0;
@ -323,7 +288,7 @@ void tui_render_screen(client_t *client) {
if (client->mute_joins && msg_snapshot) {
int filtered = 0;
for (int i = 0; i < snapshot_count; i++) {
if (!system_message_is_join_leave(&msg_snapshot[i])) {
if (!is_join_leave_msg(&msg_snapshot[i])) {
msg_snapshot[filtered++] = msg_snapshot[i];
}
}
@ -335,16 +300,15 @@ void tui_render_screen(client_t *client) {
/* Title bar — segmented chips on a single line, no full-line reverse.
*
* Segments (left to right), each followed by a dim middle-dot:
* Segments (left to right):
* bold username
* online count
* mode name (colour matches the mode itself: cyan/yellow/magenta)
* mute marker, only when active
* right-aligned hint
*
* When the terminal is narrow, drop the optional segments in
* reverse priority: hint mute mode chip online count, until
* what's left fits. The bold username is always shown. */
* `· ` separators are dim grey so the eye groups segments without
* mistaking them for content. */
struct title_chip { const char *value; const char *value_color; };
struct title_chip chips[3];
int chip_count = 0;
@ -354,9 +318,7 @@ void tui_render_screen(client_t *client) {
chip_count++;
char online_buf[32];
snprintf(online_buf, sizeof(online_buf),
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
online);
snprintf(online_buf, sizeof(online_buf), "在线 %d", online);
chips[chip_count].value = online_buf;
chips[chip_count].value_color = "\033[37m";
chip_count++;
@ -373,67 +335,11 @@ void tui_render_screen(client_t *client) {
chips[chip_count].value_color = mode_color;
chip_count++;
const char *hint = i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT);
int hint_width = utf8_string_width(hint);
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
/* Unread @-mentions chip — high-priority, gets a bright yellow star.
* Sits between mode and hint when present, and survives degradation
* longer than the hint / mute / mode chips. */
int unread_count = client->unread_mentions;
char unread_buf[32] = "";
int unread_width = 0;
if (unread_count > 0) {
snprintf(unread_buf, sizeof(unread_buf), "★ %d", unread_count);
unread_width = utf8_string_width(unread_buf) + 2; /* leading " · " minus initial space accounted later */
}
/* Unread whispers chip — bright magenta envelope. Same priority as
* the mentions chip; both signal "you missed something". */
int whisper_count = client->unread_whispers;
char whisper_buf[32] = "";
int whisper_width = 0;
if (whisper_count > 0) {
snprintf(whisper_buf, sizeof(whisper_buf), "✉ %d", whisper_count);
whisper_width = utf8_string_width(whisper_buf) + 2;
}
/* Decide what fits. Reserve at least 1 col of gap between left and
* right halves so they never visually touch. */
int show_hint = 1;
int show_mute = client->mute_joins ? 1 : 0;
int show_unread = unread_count > 0 ? 1 : 0;
int show_whisper = whisper_count > 0 ? 1 : 0;
int show_chips = chip_count;
while (show_chips > 1) {
int left_w = 1 /*leading space*/;
for (int i = 0; i < show_chips; i++) {
if (i > 0) left_w += 3; /* " · " */
left_w += utf8_string_width(chips[i].value);
}
if (show_mute) left_w += mute_width;
if (show_unread) left_w += unread_width + 1;
if (show_whisper) left_w += whisper_width + 1;
int right_w = (show_hint ? hint_width + 1 /*trailing space*/ : 0);
int needed = left_w + 1 /*min gap*/ + right_w;
if (needed <= render_width) break;
/* Drop priority: hint → mute → mode → online → whispers → mentions. */
if (show_hint) { show_hint = 0; continue; }
if (show_mute) { show_mute = 0; continue; }
if (show_chips > 1) { show_chips--; continue; }
if (show_whisper) { show_whisper = 0; continue; }
if (show_unread) { show_unread = 0; continue; }
break;
}
/* Compose left half. */
char left[256];
size_t lpos = 0;
int left_width = 0;
for (int i = 0; i < show_chips; i++) {
for (int i = 0; i < chip_count; i++) {
if (i > 0) {
buffer_appendf(left, sizeof(left), &lpos, "\033[2;37m · \033[0m");
left_width += 3;
@ -442,35 +348,21 @@ void tui_render_screen(client_t *client) {
chips[i].value_color, chips[i].value);
left_width += utf8_string_width(chips[i].value);
}
if (show_mute) {
buffer_appendf(left, sizeof(left), &lpos,
" \033[2;37m%s\033[0m", mute_label);
left_width += mute_width;
}
if (show_unread) {
buffer_appendf(left, sizeof(left), &lpos,
" \033[1;33m%s\033[0m", unread_buf);
left_width += unread_width + 1;
}
if (show_whisper) {
buffer_appendf(left, sizeof(left), &lpos,
" \033[1;35m%s\033[0m", whisper_buf);
left_width += whisper_width + 1;
if (client->mute_joins) {
buffer_appendf(left, sizeof(left), &lpos, " \033[2;37m静音\033[0m");
left_width += 4;
}
int gap = render_width - left_width - (show_hint ? hint_width + 2 : 1);
const char *hint = "? 帮助";
int hint_width = utf8_string_width(hint);
int gap = render_width - left_width - hint_width - 2;
if (gap < 1) gap = 1;
buffer_appendf(buffer, buf_size, &pos, " %s", left);
for (int i = 0; i < gap; i++) {
buffer_append_bytes(buffer, buf_size, &pos, " ", 1);
}
if (show_hint) {
buffer_appendf(buffer, buf_size, &pos,
"\033[2;37m%s\033[0m \033[K\r\n", hint);
} else {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
}
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m%s\033[0m \033[K\r\n", hint);
/* Render messages from snapshot. Insert a dim "── YYYY-MM-DD ──" divider
* before the first message of each new day so the eye can land on dates
@ -526,17 +418,36 @@ void tui_render_screen(client_t *client) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
/* Status/Input line */
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
if (client->mode == MODE_INSERT) {
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m\033[0m \033[K");
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int scroll_pos = client->scroll_pos + 1;
if (total == 0) scroll_pos = 0;
int unseen = msg_count - end;
/* mode reverse-video chip + dim position + optional unseen marker */
if (unseen > 0) {
buffer_appendf(buffer, buf_size, &pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m"
" \033[33m▼ %d new\033[0m\033[K",
scroll_pos, total, unseen);
} else {
buffer_appendf(buffer, buf_size, &pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d / %d\033[0m\033[K",
scroll_pos, total);
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, &pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
}
client_send(client, buffer, pos);
free(buffer);
}
/* Render the input line.
*
* Format: " <input>" with optional right-aligned length indicator
* once the buffer is past 80% full. The indicator turns bold-yellow
* past 95% so users can see further keystrokes will be dropped. */
/* Render the input line */
void tui_render_input(client_t *client, const char *input) {
if (!client || !client->connected) return;
@ -547,25 +458,7 @@ void tui_render_input(client_t *client, const char *input) {
char buffer[2048];
int input_width = utf8_string_width(input);
size_t input_bytes = strlen(input);
/* Decide whether to show the length gauge and how loud. */
int gauge_width = 0;
char gauge[64] = "";
if (input_bytes > (MAX_MESSAGE_LEN * 8) / 10) { /* > 80 % */
size_t remaining = (input_bytes < MAX_MESSAGE_LEN)
? (MAX_MESSAGE_LEN - 1 - input_bytes) : 0;
const char *color =
(input_bytes > (MAX_MESSAGE_LEN * 95) / 100) ? "\033[1;33m"
: "\033[2;37m";
snprintf(gauge, sizeof(gauge), "%s… %zu B\033[0m", color, remaining);
/* Plain-text width: " … 1234 B" → 4 + len(digits) + 2 */
char digits[12];
snprintf(digits, sizeof(digits), "%zu", remaining);
gauge_width = 4 + (int)strlen(digits) + 2; /* "… ", digits, " B" + leading space */
}
int avail = rw - 3 - (gauge_width > 0 ? gauge_width + 1 : 0);
int avail = rw - 3;
if (avail < 1) avail = 1;
/* Truncate from start if too long */
@ -588,20 +481,10 @@ void tui_render_input(client_t *client, const char *input) {
strncpy(display, p, sizeof(display) - 1);
}
/* Compose: cursor to input row, clear line, " " prompt, input.
* If a gauge is active, append it right-aligned. */
if (gauge_width > 0) {
int displayed_width = utf8_string_width(display);
int padding = rw - 2 - displayed_width - gauge_width;
if (padding < 1) padding = 1;
snprintf(buffer, sizeof(buffer),
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m\033[0m %s%*s%s",
rh, display, padding, "", gauge);
} else {
snprintf(buffer, sizeof(buffer),
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m\033[0m %s",
rh, display);
}
/* Move to input line and clear it, then write input */
snprintf(buffer, sizeof(buffer),
"\033[%d;1H" ANSI_CLEAR_LINE "\033[2;37m\033[0m %s",
rh, display);
client_send(client, buffer, strlen(buffer));
}
@ -615,7 +498,7 @@ void tui_render_command_output(client_t *client) {
if (rw < 10) rw = 10;
if (rh < 4) rh = 4;
char buffer[MAX_COMMAND_OUTPUT_LEN + 1024];
char buffer[4096];
size_t pos = 0;
buffer[0] = '\0';
@ -623,63 +506,40 @@ void tui_render_command_output(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = i18n_text(client->ui_lang,
I18N_COMMAND_OUTPUT_TITLE);
char title_display[64];
utf8_ansi_truncate(title, title_display, sizeof(title_display), rw);
int title_width = utf8_ansi_string_width(title_display);
const char *title = " COMMAND OUTPUT ";
int title_width = strlen(title);
int padding = rw - title_width;
if (padding < 0) padding = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s",
title_display);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title);
for (int i = 0; i < padding; i++) {
buffer_append_bytes(buffer, sizeof(buffer), &pos, " ", 1);
}
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Command output - use a copy to avoid strtok corruption */
char output_copy[MAX_COMMAND_OUTPUT_LEN];
char output_copy[2048];
strncpy(output_copy, client->command_output, sizeof(output_copy) - 1);
output_copy[sizeof(output_copy) - 1] = '\0';
char *lines[256];
int line_count = 0;
char *line = strtok(output_copy, "\n");
while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) {
lines[line_count++] = line;
line = strtok(NULL, "\n");
}
int line_count = 0;
int max_lines = rh - 2;
int content_height = rh - 2;
if (content_height < 1) content_height = 1;
int max_scroll = line_count - content_height;
if (max_scroll < 0) max_scroll = 0;
if (client->command_output_scroll < 0) client->command_output_scroll = 0;
if (client->command_output_scroll > max_scroll) {
client->command_output_scroll = max_scroll;
}
int start = client->command_output_scroll;
int end = start + content_height;
if (end > line_count) end = line_count;
for (int i = start; i < end; i++) {
while (line && line_count < max_lines) {
char truncated[1024];
utf8_ansi_truncate(lines[i], truncated, sizeof(truncated), rw);
strncpy(truncated, line, sizeof(truncated) - 1);
truncated[sizeof(truncated) - 1] = '\0';
if (utf8_string_width(truncated) > rw) {
utf8_truncate(truncated, rw);
}
buffer_appendf(buffer, sizeof(buffer), &pos, "%s\r\n", truncated);
line = strtok(NULL, "\n");
line_count++;
}
for (int i = end - start; i < content_height; i++) {
buffer_appendf(buffer, sizeof(buffer), &pos, "\033[K\r\n");
}
buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->ui_lang,
I18N_COMMAND_OUTPUT_STATUS_FORMAT),
start + 1, max_scroll + 1);
client_send(client, buffer, pos);
}
@ -705,8 +565,8 @@ void tui_render_motd(client_t *client) {
size_t pos = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Top border with a localized title chip. */
const char *title = i18n_text(client->ui_lang, I18N_MOTD_TITLE);
/* Top border: ╭─ 公告 / MOTD ──...──╮ */
const char *title = " 公告 / MOTD ";
int title_w = utf8_string_width(title);
int top_dash_fill = rw - 2 - title_w - 1; /* 2 corners, 1 leading ─ */
if (top_dash_fill < 0) top_dash_fill = 0;
@ -757,9 +617,8 @@ void tui_render_motd(client_t *client) {
/* Bottom breathing-room line */
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
/* Bottom border with a localized continue hint. */
const char *footer = i18n_text(client->ui_lang,
I18N_MOTD_CONTINUE_HINT);
/* Bottom border: ╰─ 按任意键继续 ─...─╯ */
const char *footer = " 按任意键继续 / press any key ";
int footer_w = utf8_string_width(footer);
int bot_dash_fill = rw - 2 - footer_w - 1;
if (bot_dash_fill < 0) bot_dash_fill = 0;
@ -773,6 +632,104 @@ void tui_render_motd(client_t *client) {
client_send(client, buffer, pos);
}
const char* tui_get_help_text(help_lang_t lang) {
if (lang == LANG_EN) {
return "TERMINAL CHAT ROOM - HELP\n"
"\n"
"OPERATING MODES:\n"
" INSERT - Type and send messages (default)\n"
" NORMAL - Browse message history\n"
" COMMAND - Execute commands\n"
"\n"
"INSERT MODE KEYS:\n"
" ESC - Enter NORMAL mode\n"
" Enter - Send message\n"
" Backspace - Delete character\n"
" Ctrl+W - Delete last word\n"
" Ctrl+U - Delete line\n"
" Ctrl+C - Enter NORMAL mode\n"
"\n"
"NORMAL MODE KEYS:\n"
" i - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" g/G - Jump to top/bottom\n"
" ? - Show this help\n"
" Ctrl+C - Exit chat\n"
"\n"
"AVAILABLE COMMANDS:\n"
" :list, :users - Show online users\n"
" :nick <name> - Change nickname\n"
" :msg <user> <text> - Whisper to user\n"
" :w <user> <text> - Short alias for :msg\n"
" :last [N] - Show last N messages (max 50)\n"
" :search <keyword> - Search message history\n"
" :mute-joins - Toggle join/leave notices\n"
" :help - Show available commands\n"
" :clear - Clear command output\n"
" :q, :quit, :exit - Disconnect\n"
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
" @username - Mention user (bell + highlight)\n"
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k - Scroll down/up\n"
" g/G - Jump to top/bottom\n"
" e/z - Switch English/Chinese\n";
} else {
return "终端聊天室 - 帮助\n"
"\n"
"操作模式:\n"
" INSERT - 输入和发送消息(默认)\n"
" NORMAL - 浏览消息历史\n"
" COMMAND - 执行命令\n"
"\n"
"INSERT 模式按键:\n"
" ESC - 进入 NORMAL 模式\n"
" Enter - 发送消息\n"
" Backspace - 删除字符\n"
" Ctrl+W - 删除上个单词\n"
" Ctrl+U - 删除整行\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" i - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" j/k - 向下/上滚动一行\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" ? - 显示此帮助\n"
" Ctrl+C - 退出聊天\n"
"\n"
"可用命令:\n"
" :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n"
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
"\n"
"特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n"
" @用户名 - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" g/G - 跳到顶部/底部\n"
" e/z - 切换英文/中文\n";
}
}
/* Render the help screen */
void tui_render_help(client_t *client) {
if (!client || !client->connected) return;
@ -790,8 +747,8 @@ void tui_render_help(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = i18n_text(client->ui_lang, I18N_HELP_TITLE);
int title_width = utf8_string_width(title);
const char *title = " HELP ";
int title_width = strlen(title);
int padding = rw - title_width;
if (padding < 0) padding = 0;
@ -801,11 +758,11 @@ void tui_render_help(client_t *client) {
}
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Help content */
const char *help_text = tui_get_help_text(client->help_lang);
char help_copy[8192];
size_t help_pos = 0;
help_copy[0] = '\0';
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
client->ui_lang);
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
help_copy[sizeof(help_copy) - 1] = '\0';
/* Split into lines and display with scrolling */
char *lines[100];
@ -836,7 +793,7 @@ void tui_render_help(client_t *client) {
/* Status line */
buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
"-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close",
start + 1, max_scroll + 1);
client_send(client, buffer, pos);

View file

@ -1,54 +0,0 @@
#include "tui_status.h"
#include "i18n.h"
#include "ssh_server.h"
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
const struct client *client, int msg_count,
int start, int end) {
if (!buffer || !pos || !client) return;
if (client->mode == MODE_INSERT) {
if (client->width >= 58) {
buffer_appendf(buffer, buf_size, pos,
"\033[2;37m\033[0m "
"\033[2;37m%s\033[0m"
"\033[K",
i18n_text(client->ui_lang,
I18N_INSERT_HINT_WIDE));
} else if (client->width >= 36) {
buffer_appendf(buffer, buf_size, pos,
"\033[2;37m\033[0m "
"\033[2;37m%s\033[0m\033[K",
i18n_text(client->ui_lang,
I18N_INSERT_HINT_NARROW));
} else {
buffer_appendf(buffer, buf_size, pos, "\033[2;37m\033[0m \033[K");
}
} else if (client->mode == MODE_NORMAL) {
int total = msg_count;
int range_start = total == 0 ? 0 : start + 1;
int range_end = total == 0 ? 0 : end;
int unseen = msg_count - end;
if (unseen > 0) {
buffer_appendf(buffer, buf_size, pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d-%d / %d\033[0m"
" \033[33m▼ %d %s · %s\033[0m\033[K",
range_start, range_end, total, unseen,
i18n_text(client->ui_lang,
I18N_NORMAL_NEW_MESSAGES),
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
} else {
buffer_appendf(buffer, buf_size, pos,
"\033[7;33m NORMAL \033[0m"
" \033[2;37m%d-%d / %d\033[0m"
" \033[2;37m%s\033[0m\033[K",
range_start, range_end, total,
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
}
} else if (client->mode == MODE_COMMAND) {
buffer_appendf(buffer, buf_size, pos,
"\033[35m:\033[0m%s\033[K", client->command_input);
}
}

View file

@ -96,49 +96,6 @@ int utf8_string_width(const char *str) {
return width;
}
static const char *skip_ansi_sequence(const char *p) {
if (!p || *p != '\033') {
return p;
}
if (p[1] == '[') {
const char *q = p + 2;
while (*q) {
unsigned char c = (unsigned char)*q;
if (c >= 0x40 && c <= 0x7E) {
return q + 1;
}
q++;
}
return p + 1;
}
return p[1] ? p + 2 : p + 1;
}
int utf8_ansi_string_width(const char *str) {
int width = 0;
int bytes_read;
const char *p = str;
if (!str) {
return 0;
}
while (*p != '\0') {
if (*p == '\033') {
p = skip_ansi_sequence(p);
continue;
}
uint32_t codepoint = utf8_decode(p, &bytes_read);
width += utf8_char_width(codepoint);
p += bytes_read;
}
return width;
}
/* Count the number of UTF-8 characters in a string */
int utf8_strlen(const char *str) {
int count = 0;
@ -177,72 +134,6 @@ void utf8_truncate(char *str, int max_width) {
*last_valid = '\0';
}
void utf8_ansi_truncate(const char *src, char *dst, size_t dst_size,
int max_width) {
int width = 0;
int bytes_read;
size_t pos = 0;
bool copied_ansi = false;
bool last_ansi_was_reset = false;
bool truncated = false;
const char *p = src;
if (!dst || dst_size == 0) {
return;
}
dst[0] = '\0';
if (!src || max_width <= 0) {
return;
}
while (*p != '\0') {
if (*p == '\033') {
const char *end = skip_ansi_sequence(p);
size_t len = (size_t)(end - p);
if (pos + len >= dst_size) {
truncated = true;
break;
}
memcpy(dst + pos, p, len);
pos += len;
copied_ansi = true;
last_ansi_was_reset =
len == strlen(ANSI_RESET) && memcmp(p, ANSI_RESET, len) == 0;
p = end;
continue;
}
uint32_t codepoint = utf8_decode(p, &bytes_read);
int char_width = utf8_char_width(codepoint);
if (width + char_width > max_width) {
truncated = true;
break;
}
if (pos + (size_t)bytes_read >= dst_size) {
truncated = true;
break;
}
memcpy(dst + pos, p, (size_t)bytes_read);
pos += (size_t)bytes_read;
width += char_width;
p += bytes_read;
}
if (truncated && copied_ansi && !last_ansi_was_reset) {
size_t reset_len = strlen(ANSI_RESET);
if (pos + reset_len < dst_size) {
memcpy(dst + pos, ANSI_RESET, reset_len);
pos += reset_len;
}
}
dst[pos] = '\0';
}
/* Remove last UTF-8 character from string */
void utf8_remove_last_char(char *str) {
int len = strlen(str);

View file

@ -1,112 +1,90 @@
#!/bin/bash
# Anonymous SSH access regression tests for TNT
# Test anonymous SSH access
BIN="../tnt"
PORT=${PORT:-2222}
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-anonymous-test.XXXXXX")
SERVER_PID=""
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found."
exit 1
fi
SSH_BASE="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAuthentication=no -p $PORT"
echo "Starting TNT server on port $PORT..."
$BIN -p $PORT > /dev/null 2>&1 &
SERVER_PID=$!
sleep 2
wait_for_health() {
local 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 -n $SSH_BASE localhost health 2>/dev/null || true)
[ "$out" = "ok" ] && return 0
sleep 1
done
return 1
cleanup() {
kill $SERVER_PID 2>/dev/null
wait 2>/dev/null
}
trap cleanup EXIT
run_password_test() {
local name="$1"
local user="$2"
local password="$3"
local display_name="$4"
local script="$STATE_DIR/$name.expect"
local log="$STATE_DIR/$name.log"
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
cat >"$script" <<EOF
set timeout 10
spawn ssh $SSH_BASE -o PreferredAuthentications=password $user@localhost
echo "Testing anonymous SSH access to TNT server..."
echo ""
# Test 1: Connection with any username and password
echo "Test 1: Connection with any username (should succeed)"
$TIMEOUT_CMD 10 expect -c "
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT testuser@localhost
expect {
-re "(?i)password:" {
send -- "$password\r"
exp_continue
}
"请输入用户名" {
send -- "$display_name\r"
send -- "\003"
expect eof
exit 0
\"password:\" {
send \"anypassword\r\"
expect {
\"请输入用户名\" {
send \"TestUser\r\"
send \"\003\"
exit 0
}
timeout { exit 1 }
}
}
timeout { exit 1 }
eof { exit 1 }
}
EOF
" 2>&1 | grep -q "请输入用户名"
if expect "$script" >"$log" 2>&1; then
return 0
fi
sed -n '1,120p' "$log"
return 1
}
echo "=== TNT Anonymous Access Tests ==="
TNT_LANG=zh TNT_RATE_LIMIT=0 "$BIN" -p "$PORT" -d "$STATE_DIR" \
>"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
if wait_for_health; then
echo "✓ server started"
PASS=$((PASS + 1))
if [ $? -eq 0 ]; then
echo "✓ Test 1 PASSED: Can connect with any password"
else
echo "✗ server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
echo "✗ Test 1 FAILED: Cannot connect with any password"
exit 1
fi
if run_password_test "any-password" "testuser" "anypassword" "TestUser"; then
echo "✓ accepts any password when no access token is configured"
PASS=$((PASS + 1))
else
echo "✗ any-password login failed"
FAIL=$((FAIL + 1))
fi
echo ""
if run_password_test "empty-password" "anonymous" "" ""; then
echo "✓ accepts empty password when no access token is configured"
PASS=$((PASS + 1))
# Test 2: Connection should work with empty password
echo "Test 2: Simple connection (standard SSH command)"
$TIMEOUT_CMD 10 expect -c "
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT anonymous@localhost
expect {
\"password:\" {
send \"\r\"
expect {
\"请输入用户名\" {
send \"\r\"
send \"\003\"
exit 0
}
timeout { exit 1 }
}
}
timeout { exit 1 }
}
" 2>&1 | grep -q "请输入用户名"
if [ $? -eq 0 ]; then
echo "✓ Test 2 PASSED: Can connect with empty password"
else
echo "✗ empty-password login failed"
FAIL=$((FAIL + 1))
echo "✗ Test 2 FAILED: Cannot connect with empty password"
exit 1
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"
echo "Anonymous access test completed."
exit 0

View file

@ -5,108 +5,64 @@
PORT=${PORT:-2222}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-basic-test.XXXXXX")
SSH_HEALTH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
kill $SERVER_PID 2>/dev/null
rm -f test.log
}
trap cleanup EXIT
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_HEALTH_OPTS localhost health 2>/dev/null || true)
[ "$out" = "ok" ] && return 0
sleep 1
done
return 1
}
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
echo "=== TNT Basic Tests ==="
# Path to binary
BIN="../tnt"
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping basic interactive tests"
exit 0
fi
# Start server
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
$BIN -p $PORT >test.log 2>&1 &
SERVER_PID=$!
sleep 5
# Test 1: Server started and accepts exec health checks
if wait_for_health; then
# Test 1: Server started
if kill -0 $SERVER_PID 2>/dev/null; then
echo "✓ Server started"
PASS=$((PASS + 1))
else
echo "✗ Server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
exit 1
fi
# Test 2: SSH connection
CONNECT_SCRIPT="$STATE_DIR/connect.expect"
cat >"$CONNECT_SCRIPT" <<EOF
set timeout 10
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
sleep 1
send -- "basic\r"
expect ""
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$CONNECT_SCRIPT" >"$STATE_DIR/connect.log" 2>&1; then
if $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o BatchMode=yes -p $PORT localhost exit 2>/dev/null; then
echo "✓ SSH connection works"
PASS=$((PASS + 1))
else
echo "✗ SSH connection failed"
sed -n '1,120p' "$STATE_DIR/connect.log"
FAIL=$((FAIL + 1))
fi
# Test 3: Message logging
MESSAGE_SCRIPT="$STATE_DIR/message.expect"
cat >"$MESSAGE_SCRIPT" <<EOF
set timeout 10
spawn ssh -e none -p $PORT -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR anonymous@127.0.0.1
sleep 1
send -- "testuser\r"
expect ""
send -- "test message\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$MESSAGE_SCRIPT" >"$STATE_DIR/message.log.out" 2>&1 &&
grep -q 'testuser|test message' "$STATE_DIR/messages.log"; then
(echo "testuser"; echo "test message"; sleep 1) | $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -p $PORT localhost >/dev/null 2>&1 &
sleep 3
if [ -f messages.log ]; then
echo "✓ Message logging works"
PASS=$((PASS + 1))
else
echo "✗ Message logging failed"
sed -n '1,120p' "$STATE_DIR/message.log.out"
cat "$STATE_DIR/messages.log" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi

View file

@ -28,16 +28,14 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
SSH_COMMON_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes"
SSH_OPTS="$SSH_COMMON_OPTS -p $PORT"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
wait_for_health_on_port() {
health_port=$1
wait_for_health() {
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_COMMON_OPTS -p "$health_port" localhost health 2>/dev/null || true)
OUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
[ "$OUT" = "ok" ] && return 0
sleep 1
done
@ -46,11 +44,11 @@ wait_for_health_on_port() {
echo "=== TNT Connection Limit Tests ==="
TNT_LANG=zh TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
>"$STATE_DIR/concurrent.log" 2>&1 &
SERVER_PID=$!
if wait_for_health_on_port "$PORT"; then
if wait_for_health; then
echo "✓ server started for concurrent limit test"
PASS=$((PASS + 1))
else
@ -99,16 +97,14 @@ wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
RATE_PORT=$((PORT + 1))
SSH_RATE_OPTS="$SSH_COMMON_OPTS -p $RATE_PORT"
SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT"
# The health readiness probe is a real SSH connection and counts toward the
# per-IP rate window. Use a burst of 3 so readiness consumes one slot, then the
# test can still assert two successful client connections before the block.
TNT_LANG=zh TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=3 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
>"$STATE_DIR/rate.log" 2>&1 &
SERVER_PID=$!
if wait_for_health_on_port "$RATE_PORT"; then
sleep 2
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server started for rate limit test"
PASS=$((PASS + 1))
else

View file

@ -25,11 +25,11 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
echo "=== TNT Exec Mode Tests ==="
TNT_LANG=zh TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
SERVER_PID=$!
HEALTH_OUTPUT=""
@ -51,17 +51,6 @@ else
FAIL=$((FAIL + 1))
fi
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
if [ $? -eq 0 ]; then
echo "✓ no-arg exec usage follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ no-arg exec usage output unexpected"
printf '%s\n' "$HEALTH_USAGE"
FAIL=$((FAIL + 1))
fi
STATS_OUTPUT=$(ssh $SSH_OPTS localhost stats 2>/dev/null || true)
printf '%s\n' "$STATS_OUTPUT" | grep -q '^status ok$' &&
printf '%s\n' "$STATS_OUTPUT" | grep -q '^online_users 0$'
@ -86,51 +75,6 @@ else
FAIL=$((FAIL + 1))
fi
HELP_OUTPUT=$(ssh $SSH_OPTS localhost help 2>/dev/null || true)
printf '%s\n' "$HELP_OUTPUT" | grep -q '^TNT exec 接口$' &&
printf '%s\n' "$HELP_OUTPUT" | grep -q '^命令:$'
if [ $? -eq 0 ]; then
echo "✓ help follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ help output unexpected"
printf '%s\n' "$HELP_OUTPUT"
FAIL=$((FAIL + 1))
fi
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true)
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
if [ $? -eq 0 ]; then
echo "✓ unknown command follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ unknown command output unexpected"
printf '%s\n' "$UNKNOWN_OUTPUT"
FAIL=$((FAIL + 1))
fi
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true)
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
if [ $? -eq 0 ]; then
echo "✓ post usage follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ post usage output unexpected"
printf '%s\n' "$POST_USAGE"
FAIL=$((FAIL + 1))
fi
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ]; then
echo "✓ users usage follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ users usage output unexpected"
printf '%s\n' "$USERS_USAGE"
FAIL=$((FAIL + 1))
fi
POST_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "hello from exec" 2>/dev/null || true)
if [ "$POST_OUTPUT" = "posted" ]; then
echo "✓ post publishes a message"
@ -168,7 +112,7 @@ EOF
expect "$EXPECT_SCRIPT" >"${STATE_DIR}/expect.log" 2>&1 &
INTERACTIVE_PID=$!
for _ in 1 2 3 4 5; do
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -f "$WATCHER_READY" ] && break
sleep 1
done
@ -194,7 +138,7 @@ else
fi
USERS_JSON=""
for _ in 1 2 3 4 5 6 7 8 9 10; do
for _ in 1 2 3 4 5; do
USERS_JSON=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"' && break
sleep 1

View file

@ -1,465 +0,0 @@
#!/bin/sh
# Interactive input regression tests for TNT.
PORT=${PORT:-12347}
PASS=0
FAIL=0
BIN="../tnt"
SERVER_PID=""
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-input-test.XXXXXX")
cleanup() {
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping interactive input tests"
exit 0
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-e none -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
echo "=== TNT Interactive Input Tests ==="
TNT_LANG=zh TNT_RATE_LIMIT=0 "$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
SERVER_READY=0
for _ in 1 2 3 4 5; do
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
echo "x Server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
if grep -q "TNT chat server listening" "$STATE_DIR/server.log"; then
SERVER_READY=1
break
fi
sleep 1
done
if [ "$SERVER_READY" -eq 1 ]; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "x Server did not become ready"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
cat >"$EXPECT_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "tester\r"
expect ":help"
send -- "\033\[200~"
send -- "line1\nline2\nline3"
send -- "\033\[201~"
sleep 1
send -- "\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$EXPECT_SCRIPT" >"$STATE_DIR/expect.log" 2>&1; then
if grep -q 'tester|line1 line2 line3' "$STATE_DIR/messages.log" &&
! grep -q 'tester|line1$' "$STATE_DIR/messages.log"; then
echo "✓ bracketed paste becomes one message"
PASS=$((PASS + 1))
else
echo "x bracketed paste message log unexpected"
cat "$STATE_DIR/messages.log" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
else
echo "x bracketed paste client failed"
sed -n '1,120p' "$STATE_DIR/expect.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
LONG_SCRIPT="$STATE_DIR/long-paste.expect"
cat >"$LONG_SCRIPT" <<EOF
set timeout 10
set payload [string repeat a 1100]
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "longer\r"
expect ""
send -- "\033\[200~"
send -- \$payload
send -- "\033\[201~"
sleep 1
send -- "\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$LONG_SCRIPT" >"$STATE_DIR/long-paste.log" 2>&1; then
long_line=$(grep 'longer|' "$STATE_DIR/messages.log" | tail -1)
content=${long_line#*|}
content=${content#*|}
content_len=$(printf '%s' "$content" | wc -c | tr -d ' ')
if [ "$content_len" -eq 1023 ]; then
echo "✓ overlong paste is capped at message limit"
PASS=$((PASS + 1))
else
echo "x overlong paste length unexpected: $content_len"
FAIL=$((FAIL + 1))
fi
else
echo "x overlong paste client failed"
sed -n '1,120p' "$STATE_DIR/long-paste.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
HELP_SCRIPT="$STATE_DIR/help.expect"
cat >"$HELP_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "helper\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "help\r"
expect "TNT\\(1\\) 帮助"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
expect "l:语言"
send -- "l"
expect "TNT KEY REFERENCE"
expect "l:lang"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "lang en\r"
expect "Language set to: en"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$HELP_SCRIPT" >"$STATE_DIR/help.log" 2>&1; then
echo "✓ :help renders concise manual"
PASS=$((PASS + 1))
else
echo "x :help command failed"
sed -n '1,160p' "$STATE_DIR/help.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
cat >"$UNKNOWN_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "mistype\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "hlep\r"
expect "你是想输入 :help 吗?"
expect "q:关闭"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$UNKNOWN_SCRIPT" >"$STATE_DIR/unknown-command.log" 2>&1; then
echo "✓ mistyped command suggests nearest command"
PASS=$((PASS + 1))
else
echo "x mistyped command suggestion failed"
sed -n '1,160p' "$STATE_DIR/unknown-command.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
LOCALIZED_COMMANDS_SCRIPT="$STATE_DIR/localized-commands.expect"
cat >"$LOCALIZED_COMMANDS_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "localized\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "命令输出"
expect "加入/离开提示"
expect "已静音"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "lang en\r"
expect "Language set to: en"
expect "q:close"
send -- "q"
expect "NORMAL"
expect "online"
send -- ":"
expect ":"
send -- "users\r"
expect "COMMAND OUTPUT"
expect "Online users"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$LOCALIZED_COMMANDS_SCRIPT" >"$STATE_DIR/localized-commands.log" 2>&1; then
echo "✓ common command output follows session language"
PASS=$((PASS + 1))
else
echo "x localized command output failed"
sed -n '1,200p' "$STATE_DIR/localized-commands.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
COMMAND_USAGE_SCRIPT="$STATE_DIR/command-usage.expect"
cat >"$COMMAND_USAGE_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "usageuser\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search\r"
expect "用法: search <keyword>"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "msg\r"
expect "用法: msg <user> <message>"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick\r"
expect "用法: nick <name>"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "lang en\r"
expect "Language set to: en"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox\r"
expect "Private messages"
expect "(empty)"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "last 999\r"
expect "Usage: last \\[N\\]"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "users extra\r"
expect "Usage: users"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "help now\r"
expect "Usage: help"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$COMMAND_USAGE_SCRIPT" >"$STATE_DIR/command-usage.log" 2>&1; then
echo "✓ command usage errors follow session language"
PASS=$((PASS + 1))
else
echo "x localized command usage failed"
sed -n '1,220p' "$STATE_DIR/command-usage.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
scroll_ts=$(date -u '+%Y-%m-%dT%H:%M:%SZ')
scroll_i=1
while [ "$scroll_i" -le 30 ]; do
printf '%s|fixture|scroll fixture %02d\n' "$scroll_ts" "$scroll_i" >>"$STATE_DIR/messages.log"
scroll_i=$((scroll_i + 1))
done
COMMAND_OUTPUT_SCROLL_SCRIPT="$STATE_DIR/command-output-scroll.expect"
cat >"$COMMAND_OUTPUT_SCROLL_SCRIPT" <<EOF
set timeout 10
stty rows 8 columns 80
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "pageruser\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "last 50\r"
expect "j/k:滚动"
expect -re {\(1/[2-9][0-9]*\)}
send -- "j"
expect -re {\(2/[2-9][0-9]*\)}
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$COMMAND_OUTPUT_SCROLL_SCRIPT" >"$STATE_DIR/command-output-scroll.log" 2>&1; then
echo "✓ command output can scroll before closing"
PASS=$((PASS + 1))
else
echo "x command output scrolling failed"
sed -n '1,220p' "$STATE_DIR/command-output-scroll.log"
sed -n '1,120p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "systemuser\r"
expect ":help"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "lang en\r"
expect "Language set to: en"
expect "q:close"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick systemuser2\r"
expect "Nickname changed: systemuser -> systemuser2"
expect "q:close"
send -- "q"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$SYSTEM_MESSAGES_SCRIPT" >"$STATE_DIR/system-messages.log" 2>&1 &&
grep -q 'system|systemuser renamed to systemuser2' "$STATE_DIR/messages.log" &&
grep -q 'system|systemuser2 left the room' "$STATE_DIR/messages.log"; then
echo "✓ system messages follow session language"
PASS=$((PASS + 1))
else
echo "x localized system messages failed"
sed -n '1,220p' "$STATE_DIR/system-messages.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
printf '维护窗口\n' >"$STATE_DIR/motd.txt"
MOTD_SCRIPT="$STATE_DIR/motd.expect"
cat >"$MOTD_SCRIPT" <<EOF
set timeout 10
spawn ssh $SSH_OPTS anonymous@127.0.0.1
sleep 1
send -- "motduser\r"
expect "公告"
expect "维护窗口"
expect "按任意键继续"
send -- "x"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$MOTD_SCRIPT" >"$STATE_DIR/motd.log" 2>&1; then
echo "✓ MOTD chrome follows session language"
PASS=$((PASS + 1))
else
echo "x localized MOTD chrome failed"
sed -n '1,200p' "$STATE_DIR/motd.log"
sed -n '1,120p' "$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

@ -11,9 +11,6 @@ NC='\033[0m'
PASS=0
FAIL=0
STATE_ROOT=$(mktemp -d "${TMPDIR:-/tmp}/tnt-security-test.XXXXXX")
SERVER_PIDS=""
NEXT_PORT=${PORT:-13600}
print_test() {
echo -e "\n${YELLOW}[TEST]${NC} $1"
@ -30,11 +27,8 @@ fail() {
}
cleanup() {
for pid in $SERVER_PIDS; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
rm -rf "$STATE_ROOT"
pkill -f "^\.\./tnt" 2>/dev/null || true
sleep 1
}
trap cleanup EXIT
@ -49,47 +43,23 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
run_server_probe() {
local name="$1"
local port="$NEXT_PORT"
local pid
local state_dir
local log_file
shift
NEXT_PORT=$((NEXT_PORT + 1))
state_dir="$STATE_ROOT/$name"
log_file="$state_dir/server.log"
mkdir -p "$state_dir"
"$@" "$BIN" -p "$port" -d "$state_dir" >"$log_file" 2>&1 &
pid=$!
SERVER_PIDS="$SERVER_PIDS $pid"
for _ in 1 2 3 4 5 6 7 8; do
if grep -q "TNT chat server listening" "$log_file"; then
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
return 0
fi
if ! kill -0 "$pid" 2>/dev/null; then
break
fi
sleep 1
done
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
sed -n '1,120p' "$log_file"
return 1
}
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
# Test 1: 4096-bit RSA Key Generation
print_test "1. RSA 4096-bit Key Generation"
KEY_DIR="$STATE_ROOT/host-key"
rm -f host_key
$BIN &
PID=$!
sleep 8 # Wait for key generation
kill $PID 2>/dev/null || true
sleep 1
if run_server_probe host-key env && [ -f "$KEY_DIR/host_key" ]; then
KEY_SIZE=$(ssh-keygen -l -f "$KEY_DIR/host_key" 2>/dev/null | awk '{print $1}')
if [ -f host_key ]; then
KEY_SIZE=$(ssh-keygen -l -f host_key 2>/dev/null | awk '{print $1}')
if [ "$KEY_SIZE" = "4096" ]; then
pass "RSA key upgraded to 4096 bits (was 2048)"
else
@ -98,9 +68,9 @@ if run_server_probe host-key env && [ -f "$KEY_DIR/host_key" ]; then
# Check permissions
if [[ "$OSTYPE" == "darwin"* ]]; then
PERMS=$(stat -f "%OLp" "$KEY_DIR/host_key")
PERMS=$(stat -f "%OLp" host_key)
else
PERMS=$(stat -c "%a" "$KEY_DIR/host_key")
PERMS=$(stat -c "%a" host_key)
fi
if [ "$PERMS" = "600" ]; then
@ -116,34 +86,33 @@ fi
print_test "2. Environment Variable Configuration"
# Test bind address
run_server_probe bind-addr env TNT_BIND_ADDR=127.0.0.1 && \
TNT_BIND_ADDR=127.0.0.1 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_BIND_ADDR configuration works" || fail "TNT_BIND_ADDR not working"
# Test with access token set (just verify it starts)
run_server_probe access-token env TNT_ACCESS_TOKEN="test123" && \
TNT_ACCESS_TOKEN="test123" $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_ACCESS_TOKEN configuration accepted" || fail "TNT_ACCESS_TOKEN not working"
# Test max connections configuration
run_server_probe max-connections env TNT_MAX_CONNECTIONS=10 && \
TNT_MAX_CONNECTIONS=10 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_MAX_CONNECTIONS configuration accepted" || fail "TNT_MAX_CONNECTIONS not working"
# Test per-IP connection rate configuration
run_server_probe conn-rate env TNT_MAX_CONN_RATE_PER_IP=20 && \
TNT_MAX_CONN_RATE_PER_IP=20 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_MAX_CONN_RATE_PER_IP configuration accepted" || fail "TNT_MAX_CONN_RATE_PER_IP not working"
# Test rate limit toggle
run_server_probe rate-toggle env TNT_RATE_LIMIT=0 && \
TNT_RATE_LIMIT=0 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
pass "TNT_RATE_LIMIT configuration accepted" || fail "TNT_RATE_LIMIT not working"
sleep 1
# Test 3: Input Validation in Message Log
print_test "3. Message Log Sanitization"
MESSAGE_DIR="$STATE_ROOT/message-log"
mkdir -p "$MESSAGE_DIR"
rm -f messages.log
# Create a test message log with potentially dangerous content
cat > "$MESSAGE_DIR/messages.log" <<EOF
cat > messages.log <<EOF
2026-01-22T10:00:00Z|testuser|normal message
2026-01-22T10:01:00Z|user|with|pipes|attempt to break format
2026-01-22T10:02:00Z|user
@ -152,9 +121,15 @@ newline
2026-01-22T10:03:00Z|validuser|valid content
EOF
# Start server and let it load messages, then verify it kept valid entries.
if run_server_probe message-log env >/dev/null &&
grep -q "validuser" "$MESSAGE_DIR/messages.log"; then
# Start server and let it load messages
$BIN &
PID=$!
sleep 3
kill $PID 2>/dev/null || true
sleep 1
# Check if server handled malformed log entries safely
if grep -q "validuser" messages.log; then
pass "Server loads messages from log file"
else
fail "Server message loading issue"
@ -240,16 +215,21 @@ fi
# Test 7: Resource Management (Dynamic Allocation)
print_test "7. Resource Management (Large Log Files)"
LARGE_DIR="$STATE_ROOT/large-log"
mkdir -p "$LARGE_DIR"
rm -f messages.log
# Create a large message log (2000 entries, more than old fixed 1000 limit)
for i in $(seq 1 2000); do
echo "2026-01-22T$(printf "%02d" $((i/100))):$(printf "%02d" $((i%60))):00Z|user$i|message $i" >> "$LARGE_DIR/messages.log"
echo "2026-01-22T$(printf "%02d" $((i/100))):$(printf "%02d" $((i%60))):00Z|user$i|message $i" >> messages.log
done
$BIN &
PID=$!
sleep 4
kill $PID 2>/dev/null || true
sleep 1
# Check if server started successfully with large log
if run_server_probe large-log env >/dev/null && [ -f "$LARGE_DIR/messages.log" ]; then
LINE_COUNT=$(wc -l < "$LARGE_DIR/messages.log")
if [ -f messages.log ]; then
LINE_COUNT=$(wc -l < messages.log)
if [ "$LINE_COUNT" -ge 2000 ]; then
pass "Server handles large message log (${LINE_COUNT} messages)"
else

View file

@ -1,142 +1,56 @@
#!/bin/sh
# Lightweight concurrent-client stress test for TNT.
# Usage: ./test_stress.sh [num_clients] [duration_seconds]
# Stress test for TNT server
# Usage: ./test_stress.sh [num_clients]
PORT=${PORT:-2222}
CLIENTS=${1:-10}
DURATION=${2:-30}
BIN="../tnt"
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-stress-test.XXXXXX")
SERVER_PID=""
CLIENT_PIDS=""
cleanup() {
for pid in $CLIENT_PIDS; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
echo "Error: Binary $BIN not found."
exit 1
fi
case "$CLIENTS" in
''|*[!0-9]*)
echo "Error: num_clients must be a positive integer"
exit 2
;;
esac
case "$DURATION" in
''|*[!0-9]*)
echo "Error: duration_seconds must be a positive integer"
exit 2
;;
esac
if [ "$CLIENTS" -lt 1 ] || [ "$DURATION" -lt 1 ]; then
echo "Error: num_clients and duration_seconds must be positive"
exit 2
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
wait_for_health() {
out=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 1
fi
out=$(ssh -n $SSH_OPTS localhost health 2>/dev/null || true)
[ "$out" = "ok" ] && return 0
sleep 1
done
return 1
}
echo "=== TNT Stress Test ==="
echo "clients=$CLIENTS duration=${DURATION}s port=$PORT"
MAX_CONN_PER_IP=$((CLIENTS + 5))
TNT_LANG=zh TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$MAX_CONN_PER_IP \
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
echo "Starting TNT server on port $PORT..."
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$CLIENTS $BIN -p $PORT &
SERVER_PID=$!
sleep 2
if wait_for_health; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "✗ server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server failed to start"
exit 1
fi
for i in $(seq 1 "$CLIENTS"); do
script="$STATE_DIR/client-$i.expect"
log="$STATE_DIR/client-$i.log"
ready="$STATE_DIR/client-$i.ready"
echo "Spawning $CLIENTS clients for ${DURATION}s..."
cat >"$script" <<EOF
set timeout [expr {$DURATION + 15}]
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT stress$i@localhost
expect "请输入用户名"
send -- "stress$i\r"
exec touch "$ready"
for i in $(seq 1 $CLIENTS); do
(
sleep $((i % 5))
echo "test user $i" | $TIMEOUT_CMD $DURATION ssh -o StrictHostKeyChecking=no \
-o UserKnownHostsFile=/dev/null -p $PORT localhost \
>/dev/null 2>&1
) &
done
echo "Running stress test..."
sleep $DURATION
send -- "\003"
expect eof
EOF
expect "$script" >"$log" 2>&1 &
CLIENT_PIDS="$CLIENT_PIDS $!"
done
echo "Cleaning up..."
kill $SERVER_PID 2>/dev/null
wait
ready_count=0
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
ready_count=$(find "$STATE_DIR" -name 'client-*.ready' -type f | wc -l | tr -d ' ')
[ "$ready_count" = "$CLIENTS" ] && break
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
break
fi
sleep 1
done
if [ "$ready_count" = "$CLIENTS" ]; then
echo "✓ all clients reached chat"
PASS=$((PASS + 1))
echo "Stress test complete"
if kill -0 $SERVER_PID 2>/dev/null; then
echo "WARNING: tnt process still running"
else
echo "✗ only $ready_count/$CLIENTS clients reached chat"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
echo "Server shutdown confirmed."
fi
for pid in $CLIENT_PIDS; do
wait "$pid" 2>/dev/null || FAIL=$((FAIL + 1))
done
CLIENT_PIDS=""
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server survived concurrent clients"
PASS=$((PASS + 1))
else
echo "✗ server exited during stress test"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"
exit 0

View file

@ -13,19 +13,9 @@ endif
UTF8_SRC = ../../src/utf8.c
MESSAGE_SRC = ../../src/message.c
COMMON_SRC = ../../src/common.c
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
CLI_TEXT_SRC = ../../src/cli_text.c
CHAT_ROOM_SRC = ../../src/chat_room.c
HISTORY_VIEW_SRC = ../../src/history_view.c
I18N_SRC = ../../src/i18n.c
I18N_TEXT_SRC = ../../src/i18n_text.c
EXEC_CATALOG_SRC = ../../src/exec_catalog.c
SYSTEM_MESSAGE_SRC = ../../src/system_message.c
HELP_TEXT_SRC = ../../src/help_text.c
MANUAL_TEXT_SRC = ../../src/manual_text.c
RATELIMIT_SRC = ../../src/ratelimit.c
TESTS = test_utf8 test_message test_chat_room test_history_view test_i18n test_system_message test_command_catalog test_exec_catalog test_help_text test_manual_text test_cli_text test_ratelimit
TESTS = test_utf8 test_message test_chat_room
.PHONY: all clean run
@ -40,33 +30,6 @@ test_message: test_message.c $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
test_chat_room: test_chat_room.c $(CHAT_ROOM_SRC) $(MESSAGE_SRC) $(UTF8_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_i18n: test_i18n.c $(I18N_SRC) $(I18N_TEXT_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_system_message: test_system_message.c $(SYSTEM_MESSAGE_SRC) $(I18N_SRC) $(I18N_TEXT_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_command_catalog: test_command_catalog.c $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_exec_catalog: test_exec_catalog.c $(EXEC_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_help_text: test_help_text.c $(HELP_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_manual_text: test_manual_text.c $(MANUAL_TEXT_SRC) $(COMMAND_CATALOG_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
test_ratelimit: test_ratelimit.c $(RATELIMIT_SRC) $(COMMON_SRC)
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
run: all
@echo "=== Running UTF-8 Tests ==="
./test_utf8
@ -76,33 +39,6 @@ run: all
@echo ""
@echo "=== Running Chat Room Tests ==="
./test_chat_room
@echo ""
@echo "=== Running History View Tests ==="
./test_history_view
@echo ""
@echo "=== Running i18n Tests ==="
./test_i18n
@echo ""
@echo "=== Running System Message Tests ==="
./test_system_message
@echo ""
@echo "=== Running Command Catalog Tests ==="
./test_command_catalog
@echo ""
@echo "=== Running Exec Catalog Tests ==="
./test_exec_catalog
@echo ""
@echo "=== Running Help Text Tests ==="
./test_help_text
@echo ""
@echo "=== Running Manual Text Tests ==="
./test_manual_text
@echo ""
@echo "=== Running CLI Text Tests ==="
./test_cli_text
@echo ""
@echo "=== Running Rate Limit Tests ==="
./test_ratelimit
clean:
rm -f $(TESTS) *.o test_messages.log

View file

@ -1,66 +0,0 @@
/* Unit tests for command-line help and error text */
#include "../../include/cli_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(help_matches_language) {
char output[2048] = {0};
size_t pos = 0;
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN);
assert(strstr(output, "anonymous SSH chat server") != NULL);
assert(strstr(output, "Usage: tnt [options]") != NULL);
assert(strstr(output, "TNT_LANG") != NULL);
memset(output, 0, sizeof(output));
pos = 0;
cli_text_append_help(output, sizeof(output), &pos, "", (ui_lang_t)99);
assert(strstr(output, "Usage: tnt [options]") != NULL);
memset(output, 0, sizeof(output));
pos = 0;
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_ZH);
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
assert(strstr(output, "用法: tnt [options]") != NULL);
assert(strstr(output, "[选项]") == NULL);
assert(strstr(output, "TNT_LANG") != NULL);
}
TEST(error_formats_match_language) {
assert(strcmp(cli_text_invalid_port_format(UI_LANG_EN),
"Invalid port: %s\n") == 0);
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
"端口无效: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
"Unknown option: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
"未知选项: %s\n") == 0);
assert(strcmp(cli_text_short_usage_format(UI_LANG_EN),
"Usage: %s [-p PORT] [-d DIR] [-h]\n") == 0);
assert(strcmp(cli_text_short_usage_format(UI_LANG_ZH),
"用法: %s [-p PORT] [-d DIR] [-h]\n") == 0);
assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99),
"Invalid port: %s\n") == 0);
}
int main(void) {
printf("Running CLI text unit tests...\n\n");
RUN_TEST(help_matches_language);
RUN_TEST(error_formats_match_language);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,142 +0,0 @@
/* Unit tests for command catalog names, aliases, and generated help text */
#include "../../include/command_catalog.h"
#include "text_assert.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(matches_canonical_names_and_aliases) {
tnt_command_id_t id;
const char *args;
assert(command_catalog_match("users", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(strcmp(args, "") == 0);
assert(command_catalog_match("list", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(command_catalog_match("msg alice hello", &id, &args));
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("w alice hello", &id, &args));
assert(id == TNT_COMMAND_MSG);
assert(strcmp(args, "alice hello") == 0);
assert(command_catalog_match("language zh", &id, &args));
assert(id == TNT_COMMAND_LANG);
assert(strcmp(args, "zh") == 0);
}
TEST(matches_known_commands_before_argument_validation) {
tnt_command_id_t id;
const char *args;
assert(command_catalog_match("users extra", &id, &args));
assert(id == TNT_COMMAND_USERS);
assert(strcmp(args, "extra") == 0);
assert(command_catalog_match("help now", &id, &args));
assert(id == TNT_COMMAND_HELP);
assert(strcmp(args, "now") == 0);
assert(command_catalog_match("q now", &id, &args));
assert(id == TNT_COMMAND_QUIT);
assert(strcmp(args, "now") == 0);
}
TEST(validates_argument_shapes) {
assert(command_catalog_args_valid(TNT_COMMAND_USERS, NULL));
assert(!command_catalog_args_valid(TNT_COMMAND_USERS, "extra"));
assert(command_catalog_args_valid(TNT_COMMAND_HELP, NULL));
assert(!command_catalog_args_valid(TNT_COMMAND_HELP, "now"));
assert(!command_catalog_args_valid(TNT_COMMAND_MSG, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_MSG, "alice hello"));
assert(!command_catalog_args_valid(TNT_COMMAND_SEARCH, ""));
assert(command_catalog_args_valid(TNT_COMMAND_SEARCH, "needle"));
assert(command_catalog_args_valid(TNT_COMMAND_LAST, NULL));
assert(command_catalog_args_valid(TNT_COMMAND_LAST, "999"));
assert(command_catalog_args_valid(TNT_COMMAND_LANG, "fr"));
}
TEST(suggests_from_catalog_aliases) {
assert(strcmp(command_catalog_suggest("hlep"), "help") == 0);
assert(strcmp(command_catalog_suggest("usres"), "users") == 0);
assert(strcmp(command_catalog_suggest("laguage"), "lang") == 0);
assert(command_catalog_suggest("not-even-close") == NULL);
}
TEST(generates_localized_help_sections) {
char en[4096] = {0};
char zh[4096] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
command_catalog_append_full(en, sizeof(en), &en_pos, UI_LANG_EN);
command_catalog_append_full(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
assert(strstr(en, ":users, :list, :who") != NULL);
assert(strstr(en, "Show online users") != NULL);
assert(strstr(en, ":msg <user> <message>") != NULL);
assert(strstr(en, "Show private messages") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(zh, ":users, :list, :who") != NULL);
assert(strstr(zh, "显示在线用户") != NULL);
assert(strstr(zh, "查看私信") != NULL);
assert(strstr(zh, ":msg <user> <message>") != NULL);
assert(strstr(zh, "<用户>") == NULL);
assert(strstr(zh, "<消息>") == NULL);
assert(strstr(zh, ":support") == NULL);
assert_ascii_angle_placeholders(zh);
}
TEST(generates_localized_usage) {
char en[256] = {0};
char zh[256] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_LAST, UI_LANG_EN);
command_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_COMMAND_MSG, UI_LANG_ZH);
assert(strcmp(en, "Usage: last [N] (N: 1-50, default 10)\n") == 0);
assert(strcmp(zh, "用法: msg <user> <message>\n"
" w <user> <message>\n") == 0);
en[0] = '\0';
en_pos = 0;
command_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_COMMAND_USERS, (ui_lang_t)99);
assert(strcmp(en, "Usage: users\n") == 0);
}
int main(void) {
printf("Running command catalog unit tests...\n\n");
RUN_TEST(matches_canonical_names_and_aliases);
RUN_TEST(matches_known_commands_before_argument_validation);
RUN_TEST(validates_argument_shapes);
RUN_TEST(suggests_from_catalog_aliases);
RUN_TEST(generates_localized_help_sections);
RUN_TEST(generates_localized_usage);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,128 +0,0 @@
/* Unit tests for SSH exec command help catalog */
#include "../../include/exec_catalog.h"
#include "text_assert.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(generates_localized_exec_help) {
char en[2048] = {0};
char zh[2048] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
exec_catalog_append_help(en, sizeof(en), &en_pos, UI_LANG_EN);
exec_catalog_append_help(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
assert(strstr(en, "TNT exec interface") != NULL);
assert(strstr(en, "Commands:") != NULL);
assert(strstr(en, "users [--json]") != NULL);
assert(strstr(en, "post MESSAGE") != NULL);
assert(strstr(en, "support") == NULL);
assert(strstr(zh, "TNT exec 接口") != NULL);
assert(strstr(zh, "命令:") != NULL);
assert(strstr(zh, "users [--json]") != NULL);
assert(strstr(zh, "post MESSAGE") != NULL);
assert(strstr(zh, "support") == NULL);
assert_ascii_angle_placeholders(zh);
en[0] = '\0';
en_pos = 0;
exec_catalog_append_help(en, sizeof(en), &en_pos, (ui_lang_t)99);
assert(strstr(en, "TNT exec interface") != NULL);
assert(strstr(en, "Show this help") != NULL);
}
TEST(matches_exec_commands_and_args) {
tnt_exec_command_id_t id;
const char *args;
assert(exec_catalog_match("help", &id, &args));
assert(id == TNT_EXEC_COMMAND_HELP);
assert(args == NULL);
assert(exec_catalog_match("--help", &id, &args));
assert(id == TNT_EXEC_COMMAND_HELP);
assert(args == NULL);
assert(exec_catalog_match("users --json", &id, &args));
assert(id == TNT_EXEC_COMMAND_USERS);
assert(strcmp(args, "--json") == 0);
assert(exec_catalog_match("tail -n 20", &id, &args));
assert(id == TNT_EXEC_COMMAND_TAIL);
assert(strcmp(args, "-n 20") == 0);
assert(exec_catalog_match("post hello world", &id, &args));
assert(id == TNT_EXEC_COMMAND_POST);
assert(strcmp(args, "hello world") == 0);
assert(exec_catalog_match("exit", &id, &args));
assert(id == TNT_EXEC_COMMAND_EXIT);
assert(args == NULL);
assert(!exec_catalog_match("usersx", &id, &args));
assert(!exec_catalog_match("nope", &id, &args));
}
TEST(validates_argument_shapes) {
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, NULL));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HELP, "now"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, NULL));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_HEALTH, "now"));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--json"));
assert(!exec_catalog_args_valid(TNT_EXEC_COMMAND_USERS, "--xml"));
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_POST, NULL));
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
}
TEST(generates_localized_usage) {
char en[256] = {0};
char zh[256] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_TAIL, UI_LANG_EN);
exec_catalog_append_usage(zh, sizeof(zh), &zh_pos,
TNT_EXEC_COMMAND_POST, UI_LANG_ZH);
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
assert(strcmp(zh, "post: 用法: post MESSAGE\n") == 0);
memset(en, 0, sizeof(en));
en_pos = 0;
exec_catalog_append_usage(en, sizeof(en), &en_pos,
TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99);
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
}
int main(void) {
printf("Running exec catalog unit tests...\n\n");
RUN_TEST(generates_localized_exec_help);
RUN_TEST(matches_exec_commands_and_args);
RUN_TEST(validates_argument_shapes);
RUN_TEST(generates_localized_usage);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,74 +0,0 @@
/* Unit tests for help text ownership and language selection */
#include "../../include/help_text.h"
#include "text_assert.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(full_help_matches_language) {
char en[8192] = {0};
char zh[8192] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
help_text_append_full(en, sizeof(en), &en_pos, UI_LANG_EN);
help_text_append_full(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
assert(strstr(en, "TNT KEY REFERENCE") != NULL);
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
assert(strstr(en, ":inbox") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL);
assert(strstr(en, "Cycle UI language") != NULL);
assert(strstr(en, "Switch English/Chinese") == NULL);
assert(strstr(zh, "TNT 按键参考") != NULL);
assert(strstr(zh, "可用命令") != NULL);
assert(strstr(zh, "命令输出按键") != NULL);
assert(strstr(zh, ":inbox") != NULL);
assert(strstr(zh, "/me <action>") != NULL);
assert(strstr(zh, "@username") != NULL);
assert(strstr(zh, "<动作>") == NULL);
assert(strstr(zh, "@用户名") == NULL);
assert(strstr(zh, ":support") == NULL);
assert(strstr(zh, ":commands") == NULL);
assert(strstr(zh, "切换界面语言") != NULL);
assert(strstr(zh, "切换英文/中文") == NULL);
assert_ascii_angle_placeholders(zh);
}
TEST(full_help_falls_back_to_english) {
char text[8192] = {0};
size_t pos = 0;
help_text_append_full(text, sizeof(text), &pos, (ui_lang_t)99);
assert(strstr(text, "TNT KEY REFERENCE") != NULL);
assert(strstr(text, "AVAILABLE COMMANDS") != NULL);
assert(strstr(text, "COMMAND OUTPUT KEYS") != NULL);
assert(strstr(text, "Cycle UI language") != NULL);
assert(strstr(text, "TNT 按键参考") == NULL);
assert(strstr(text, "切换界面语言") == NULL);
}
int main(void) {
printf("Running help text unit tests...\n\n");
RUN_TEST(full_help_matches_language);
RUN_TEST(full_help_falls_back_to_english);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,110 +0,0 @@
/* Unit tests for history_view viewport and scroll rules */
#include "../../include/history_view.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;
static message_t make_msg(time_t timestamp, const char *content) {
message_t msg = { .timestamp = timestamp };
snprintf(msg.username, sizeof(msg.username), "user");
snprintf(msg.content, sizeof(msg.content), "%s", content);
return msg;
}
TEST(height_clamps_to_message_area) {
assert(history_view_height(24) == 21);
assert(history_view_height(4) == 1);
assert(history_view_height(1) == 1);
assert(history_view_height(0) == 1);
}
TEST(max_scroll_clamps_to_zero) {
assert(history_view_max_scroll(0, 20) == 0);
assert(history_view_max_scroll(10, 20) == 0);
assert(history_view_max_scroll(20, 20) == 0);
assert(history_view_max_scroll(25, 20) == 5);
}
TEST(scroll_to_latest_enables_follow_tail) {
int scroll = 0;
bool follow = false;
history_view_scroll_to_latest(&scroll, &follow, 30, 10);
assert(scroll == 20);
assert(follow == true);
}
TEST(scroll_to_oldest_disables_follow_tail) {
int scroll = 12;
bool follow = true;
history_view_scroll_to_oldest(&scroll, &follow);
assert(scroll == 0);
assert(follow == false);
}
TEST(scroll_by_clamps_and_toggles_follow) {
int scroll = 20;
bool follow = true;
history_view_scroll_by(&scroll, &follow, 30, 10, -3);
assert(scroll == 17);
assert(follow == false);
history_view_scroll_by(&scroll, &follow, 30, 10, 100);
assert(scroll == 20);
assert(follow == true);
history_view_scroll_by(&scroll, &follow, 30, 10, -100);
assert(scroll == 0);
assert(follow == false);
}
TEST(latest_start_counts_date_dividers) {
message_t messages[6];
messages[0] = make_msg(1704067200, "day1-1"); /* 2024-01-01 */
messages[1] = make_msg(1704067260, "day1-2");
messages[2] = make_msg(1704153600, "day2-1"); /* 2024-01-02 */
messages[3] = make_msg(1704153660, "day2-2");
messages[4] = make_msg(1704240000, "day3-1"); /* 2024-01-03 */
messages[5] = make_msg(1704240060, "day3-2");
assert(history_view_latest_start_for_height(messages, 6, 3) == 4);
assert(history_view_latest_start_for_height(messages, 6, 4) == 4);
assert(history_view_latest_start_for_height(messages, 6, 5) == 3);
assert(history_view_latest_start_for_height(messages, 6, 6) == 2);
}
TEST(latest_start_handles_empty_and_tiny_view) {
message_t messages[1];
messages[0] = make_msg(1704067200, "only");
assert(history_view_latest_start_for_height(messages, 0, 3) == 0);
assert(history_view_latest_start_for_height(messages, 1, 1) == 0);
}
int main(void) {
printf("=== History View Unit Tests ===\n");
RUN_TEST(height_clamps_to_message_area);
RUN_TEST(max_scroll_clamps_to_zero);
RUN_TEST(scroll_to_latest_enables_follow_tail);
RUN_TEST(scroll_to_oldest_disables_follow_tail);
RUN_TEST(scroll_by_clamps_and_toggles_follow);
RUN_TEST(latest_start_counts_date_dividers);
RUN_TEST(latest_start_handles_empty_and_tiny_view);
printf("\nAll %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,189 +0,0 @@
/* Unit tests for i18n language selection and text lookup */
#include "../../include/i18n.h"
#include "text_assert.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.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(parse_explicit_languages) {
ui_lang_t lang;
assert(i18n_parse_ui_lang("zh", UI_LANG_EN) == UI_LANG_ZH);
assert(i18n_parse_ui_lang("zh_CN.UTF-8", UI_LANG_EN) == UI_LANG_ZH);
assert(i18n_parse_ui_lang("en", UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_parse_ui_lang("en_US.UTF-8", UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_parse_ui_lang("C", UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_parse_ui_lang("POSIX", UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_try_parse_ui_lang("zh", &lang) == true);
assert(lang == UI_LANG_ZH);
assert(i18n_try_parse_ui_lang("en", &lang) == true);
assert(lang == UI_LANG_EN);
assert(i18n_try_parse_ui_lang("cn", &lang) == false);
assert(i18n_try_parse_ui_lang("english", &lang) == false);
assert(i18n_try_parse_ui_lang("chinese", &lang) == false);
assert(i18n_try_parse_ui_lang("中文", &lang) == false);
assert(i18n_try_parse_ui_lang("英文", &lang) == false);
assert(i18n_try_parse_ui_lang("fr", &lang) == false);
}
TEST(parse_unknown_uses_fallback) {
assert(i18n_parse_ui_lang(NULL, UI_LANG_ZH) == UI_LANG_ZH);
assert(i18n_parse_ui_lang("", UI_LANG_EN) == UI_LANG_EN);
assert(i18n_parse_ui_lang("fr_FR.UTF-8", UI_LANG_ZH) == UI_LANG_ZH);
}
TEST(parse_ignores_surrounding_whitespace) {
ui_lang_t lang;
assert(i18n_try_parse_ui_lang(" zh ", &lang) == true);
assert(lang == UI_LANG_ZH);
assert(i18n_parse_ui_lang("\ten_US.UTF-8\n", UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_try_parse_ui_lang(" english ", &lang) == false);
assert(i18n_try_parse_ui_lang("zh CN", &lang) == false);
setenv("TNT_LANG", " zh ", 1);
setenv("LC_ALL", "en_US.UTF-8", 1);
assert(i18n_default_ui_lang() == UI_LANG_ZH);
}
TEST(default_prefers_tnt_lang) {
setenv("TNT_LANG", "zh_CN.UTF-8", 1);
setenv("LC_ALL", "en_US.UTF-8", 1);
assert(i18n_default_ui_lang() == UI_LANG_ZH);
setenv("TNT_LANG", "en", 1);
setenv("LC_ALL", "zh_CN.UTF-8", 1);
assert(i18n_default_ui_lang() == UI_LANG_EN);
}
TEST(default_uses_locale_when_no_tnt_lang) {
unsetenv("TNT_LANG");
setenv("LC_ALL", "zh_CN.UTF-8", 1);
assert(i18n_default_ui_lang() == UI_LANG_ZH);
setenv("LC_ALL", "C", 1);
assert(i18n_default_ui_lang() == UI_LANG_EN);
}
TEST(text_lookup_matches_language) {
i18n_string_t sample = I18N_STRING("fallback", "替代");
assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0);
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0);
assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0);
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_USERNAME_PROMPT),
"用户名") != NULL);
assert(strstr(i18n_text((ui_lang_t)99, I18N_USERNAME_PROMPT),
"display name") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_WELCOME_SUBTITLE),
"anonymous chat") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_WELCOME_SUBTITLE),
"匿名聊天室") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_HELP_STATUS_FORMAT),
"KEY REFERENCE") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_HELP_STATUS_FORMAT),
"l:lang") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_HELP_STATUS_FORMAT),
"按键参考") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_HELP_STATUS_FORMAT),
"l:语言") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_COMMAND_OUTPUT_TITLE),
"COMMAND") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_TITLE),
"命令输出") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:close") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:关闭") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
"Press any key") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),
"按任意键") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_TITLE_ONLINE_FORMAT),
"online") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_TITLE_ONLINE_FORMAT),
"在线") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_IDLE_TIMEOUT_FORMAT),
"idle timeout") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_IDLE_TIMEOUT_FORMAT),
"空闲超时") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MSG_SENT_FORMAT),
"Private message sent") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MSG_SENT_FORMAT),
"私信已发送") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_INBOX_TITLE),
"Private messages") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_INBOX_TITLE),
"私信") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_SEARCH_HEADER_FORMAT),
"Search") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_SEARCH_HEADER_FORMAT),
"搜索") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_LANG_CURRENT_FORMAT),
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_LANG_CURRENT_FORMAT),
"lang <en|zh>") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_UNKNOWN_COMMAND_FORMAT),
"Unknown command") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_UNKNOWN_COMMAND_FORMAT),
"未知命令") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_POST_EMPTY),
"message cannot be empty") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
"消息不能为空") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
"Unknown command") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
"未知命令") != NULL);
assert(strcmp(i18n_ui_lang_code(UI_LANG_EN), "en") == 0);
assert(strcmp(i18n_ui_lang_code(UI_LANG_ZH), "zh") == 0);
assert(strcmp(i18n_ui_lang_code((ui_lang_t)99), "en") == 0);
assert(i18n_next_ui_lang(UI_LANG_EN) == UI_LANG_ZH);
assert(i18n_next_ui_lang(UI_LANG_ZH) == UI_LANG_EN);
assert(i18n_next_ui_lang((ui_lang_t)99) == UI_LANG_EN);
}
TEST(text_catalog_is_complete) {
for (int id = 0; id < I18N_TEXT_COUNT; id++) {
assert(i18n_text(UI_LANG_EN, (i18n_text_id_t)id)[0] != '\0');
assert(i18n_text(UI_LANG_ZH, (i18n_text_id_t)id)[0] != '\0');
assert_ascii_angle_placeholders(
i18n_text(UI_LANG_ZH, (i18n_text_id_t)id));
}
assert(strcmp(i18n_text(UI_LANG_EN,
(i18n_text_id_t)I18N_TEXT_COUNT), "") == 0);
assert(strcmp(i18n_text(UI_LANG_ZH,
(i18n_text_id_t)I18N_TEXT_COUNT), "") == 0);
}
int main(void) {
printf("Running i18n unit tests...\n\n");
RUN_TEST(parse_explicit_languages);
RUN_TEST(parse_unknown_uses_fallback);
RUN_TEST(parse_ignores_surrounding_whitespace);
RUN_TEST(default_prefers_tnt_lang);
RUN_TEST(default_uses_locale_when_no_tnt_lang);
RUN_TEST(text_lookup_matches_language);
RUN_TEST(text_catalog_is_complete);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,77 +0,0 @@
/* Unit tests for concise manual text language selection */
#include "../../include/manual_text.h"
#include "text_assert.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;
static int count_lines(const char *text) {
int lines = 0;
while (text && *text) {
if (*text == '\n') {
lines++;
}
text++;
}
return lines;
}
TEST(interactive_manual_matches_language) {
char en[4096] = {0};
char zh[4096] = {0};
size_t en_pos = 0;
size_t zh_pos = 0;
manual_text_append_interactive(en, sizeof(en), &en_pos, UI_LANG_EN);
manual_text_append_interactive(zh, sizeof(zh), &zh_pos, UI_LANG_ZH);
assert(strstr(en, "TNT(1) help") != NULL);
assert(strstr(en, "Use") != NULL);
assert(strstr(en, "Commands") != NULL);
assert(strstr(en, ":lang en|zh") != NULL);
assert(strstr(en, ":mute-joins") != NULL);
assert(strstr(en, ":mute-joins, :clear, :q") != NULL);
assert(strstr(en, ":support") == NULL);
assert(strstr(en, ":commands") == NULL);
assert(count_lines(en) <= 20);
memset(en, 0, sizeof(en));
en_pos = 0;
manual_text_append_interactive(en, sizeof(en), &en_pos, (ui_lang_t)99);
assert(strstr(en, "TNT(1) help") != NULL);
assert(strstr(zh, "TNT(1) 帮助") != NULL);
assert(strstr(zh, "使用") != NULL);
assert(strstr(zh, "命令") != NULL);
assert(strstr(zh, ":lang en|zh") != NULL);
assert(strstr(zh, ":mute-joins") != NULL);
assert(strstr(zh, ":msg <user> <message>") != NULL);
assert(strstr(zh, "<用户>") == NULL);
assert(strstr(zh, ":mute-joins, :clear, :q") != NULL);
assert(strstr(zh, ":support") == NULL);
assert(strstr(zh, ":commands") == NULL);
assert(count_lines(zh) <= 20);
assert_ascii_angle_placeholders(zh);
}
int main(void) {
printf("Running manual text unit tests...\n\n");
RUN_TEST(interactive_manual_matches_language);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -22,6 +22,18 @@ static void cleanup_test_log(void) {
unlink(test_log);
}
/* Helper: Create test log with N messages */
static void create_test_log(int count) {
FILE *fp = fopen(test_log, "w");
assert(fp != NULL);
for (int i = 0; i < count; i++) {
fprintf(fp, "2026-02-08T10:00:%02d+08:00|user%d|Test message %d\n",
i, i, i);
}
fclose(fp);
}
/* Test message initialization */
TEST(message_init) {
message_init();
@ -36,6 +48,7 @@ TEST(message_load_empty) {
FILE *fp = fopen(test_log, "w");
fclose(fp);
message_t *messages = NULL;
/* Can't easily override LOG_FILE constant, so this is a documentation test */
cleanup_test_log();

View file

@ -1,71 +0,0 @@
/* Unit tests for connection and rate-limit accounting */
#include "../../include/ratelimit.h"
#include <assert.h>
#include <stdio.h>
#include <stdlib.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(per_ip_concurrent_limit_blocks_second_active_connection) {
const char *ip = "203.0.113.10";
setenv("TNT_RATE_LIMIT", "0", 1);
setenv("TNT_MAX_CONN_PER_IP", "1", 1);
ratelimit_init();
assert(ratelimit_check_ip(ip) == true);
assert(ratelimit_check_ip(ip) == false);
ratelimit_release_ip(ip);
assert(ratelimit_check_ip(ip) == true);
ratelimit_release_ip(ip);
}
TEST(rate_limit_allows_configured_burst_then_blocks) {
const char *ip = "203.0.113.20";
setenv("TNT_RATE_LIMIT", "1", 1);
setenv("TNT_MAX_CONN_PER_IP", "10", 1);
setenv("TNT_MAX_CONN_RATE_PER_IP", "2", 1);
ratelimit_init();
assert(ratelimit_check_ip(ip) == true);
ratelimit_release_ip(ip);
assert(ratelimit_check_ip(ip) == true);
ratelimit_release_ip(ip);
assert(ratelimit_check_ip(ip) == false);
}
TEST(global_limit_tracks_active_total) {
setenv("TNT_MAX_CONNECTIONS", "1", 1);
ratelimit_init();
assert(ratelimit_check_and_increment_total() == true);
assert(ratelimit_get_active_total() == 1);
assert(ratelimit_check_and_increment_total() == false);
ratelimit_decrement_total();
assert(ratelimit_get_active_total() == 0);
assert(ratelimit_check_and_increment_total() == true);
ratelimit_decrement_total();
}
int main(void) {
printf("Running rate-limit unit tests...\n\n");
RUN_TEST(per_ip_concurrent_limit_blocks_second_active_connection);
RUN_TEST(rate_limit_allows_configured_burst_then_blocks);
RUN_TEST(global_limit_tracks_active_total);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -1,77 +0,0 @@
/* Unit tests for localized system event messages */
#include "../../include/system_message.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(join_leave_follow_language) {
message_t msg;
system_message_make_join(&msg, "alice", UI_LANG_ZH);
assert(strcmp(msg.username, "系统") == 0);
assert(strstr(msg.content, "alice") != NULL);
assert(strstr(msg.content, "加入了聊天室") != NULL);
assert(system_message_is_system(&msg));
assert(system_message_is_join_leave(&msg));
system_message_make_leave(&msg, "bob", UI_LANG_EN);
assert(strcmp(msg.username, "system") == 0);
assert(strstr(msg.content, "bob") != NULL);
assert(strstr(msg.content, "left the room") != NULL);
assert(system_message_is_system(&msg));
assert(system_message_is_join_leave(&msg));
}
TEST(nick_messages_are_system_events_not_join_leave) {
message_t msg;
system_message_make_nick(&msg, "old", "new", UI_LANG_EN);
assert(strcmp(msg.username, "system") == 0);
assert(strstr(msg.content, "old") != NULL);
assert(strstr(msg.content, "new") != NULL);
assert(strstr(msg.content, "renamed") != NULL);
assert(system_message_is_system(&msg));
assert(!system_message_is_join_leave(&msg));
system_message_make_nick(&msg, "", "", UI_LANG_ZH);
assert(strcmp(msg.username, "系统") == 0);
assert(strstr(msg.content, "更名为") != NULL);
assert(system_message_is_system(&msg));
assert(!system_message_is_join_leave(&msg));
}
TEST(legacy_system_names_are_recognized) {
message_t msg = {0};
snprintf(msg.username, sizeof(msg.username), "系统");
snprintf(msg.content, sizeof(msg.content), "alice 离开了聊天室");
assert(system_message_is_system(&msg));
assert(system_message_is_join_leave(&msg));
snprintf(msg.username, sizeof(msg.username), "system");
snprintf(msg.content, sizeof(msg.content), "alice joined the room");
assert(system_message_is_system(&msg));
assert(system_message_is_join_leave(&msg));
}
int main(void) {
printf("Running system message unit tests...\n\n");
RUN_TEST(join_leave_follow_language);
RUN_TEST(nick_messages_are_system_events_not_join_leave);
RUN_TEST(legacy_system_names_are_recognized);
printf("\n✓ All %d tests passed!\n", tests_passed);
return 0;
}

View file

@ -114,22 +114,6 @@ TEST(utf8_string_width_cjk_only) {
assert(utf8_string_width("中文字符") == 8);
}
TEST(utf8_ansi_string_width_ignores_escape_sequences) {
assert(utf8_ansi_string_width("\033[1;36mHello\033[0m") == 5);
assert(utf8_ansi_string_width("\033[31m支持\033[0m") == 4);
assert(utf8_ansi_string_width("A\033[7;33m中\033[0mB") == 4);
}
TEST(utf8_ansi_truncate_preserves_escape_sequences) {
char out[64];
utf8_ansi_truncate("\033[31mHello世界\033[0m", out, sizeof(out), 7);
assert(strcmp(out, "\033[31mHello世\033[0m") == 0);
utf8_ansi_truncate("A\033[7;33m中文\033[0mB", out, sizeof(out), 5);
assert(strcmp(out, "A\033[7;33m中文\033[0m") == 0);
}
/* Test backspace handling */
TEST(utf8_remove_last_char) {
char buffer[256];
@ -244,8 +228,6 @@ int main(void) {
RUN_TEST(utf8_string_width_ascii);
RUN_TEST(utf8_string_width_mixed);
RUN_TEST(utf8_string_width_cjk_only);
RUN_TEST(utf8_ansi_string_width_ignores_escape_sequences);
RUN_TEST(utf8_ansi_truncate_preserves_escape_sequences);
RUN_TEST(utf8_remove_last_char);
RUN_TEST(utf8_remove_last_char_multibyte);
RUN_TEST(utf8_remove_last_word);

View file

@ -1,24 +0,0 @@
#ifndef TEST_TEXT_ASSERT_H
#define TEST_TEXT_ASSERT_H
#include <assert.h>
static void assert_ascii_angle_placeholders(const char *text) {
int in_placeholder = 0;
while (text && *text) {
unsigned char ch = (unsigned char)*text;
if (ch == '<') {
in_placeholder = 1;
} else if (ch == '>') {
in_placeholder = 0;
} else if (in_placeholder) {
assert(ch < 128);
}
text++;
}
}
#endif /* TEST_TEXT_ASSERT_H */

35
tnt.1
View file

@ -1,5 +1,5 @@
.\" tnt(1) - Terminal Network Talk
.TH TNT 1 "2026-05-24" "TNT 1.0.1" "User Commands"
.TH TNT 1 "April 2026" "TNT 1.0.0" "User Commands"
.SH NAME
tnt \- anonymous SSH chat server with Vim\-style TUI
.SH SYNOPSIS
@ -15,8 +15,7 @@ tnt \- anonymous SSH chat server with Vim\-style TUI
is a multi\-user anonymous chat server accessed over SSH.
It provides a Vim\-style terminal user interface with INSERT, NORMAL, and
COMMAND modes.
Users connect with any standard SSH client; no account or registration is
needed.
Users connect with any standard SSH client; no account or registration is needed.
.PP
Messages are persisted to a log file and restored on server restart.
The server supports CJK and emoji input, rate limiting, access tokens, and
@ -45,6 +44,7 @@ Print version and exit.
.BR \-h ", " \-\-help
Print a short usage summary and exit.
.SH CONNECTING
.PP
.nf
ssh any\-username@hostname \-p 2222
.fi
@ -70,7 +70,7 @@ to return to INSERT,
.B :
to enter COMMAND mode,
.B ?
to open the full key reference.
to open the help screen.
.TP
.B COMMAND
Execute commands prefixed with
@ -84,45 +84,33 @@ ESC Switch to NORMAL
Ctrl+W Delete last word
Ctrl+U Clear input line
Ctrl+C Switch to NORMAL
Paste Keep multi-line paste in the input buffer
/me \fIaction\fR Send action message (e.g. /me waves)
@\fIusername\fR Mention user (bell notification + highlight)
.TE
.PP
The input line shows remaining bytes near the message limit. Extra input
past the limit is ignored with a terminal bell.
.SS NORMAL mode
.TS
l l.
j/k Scroll down/up one line
Ctrl+D/Ctrl+U Scroll half page down/up
Ctrl+F/Ctrl+B Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
i Switch to INSERT
: Enter COMMAND mode
? Open full key reference
? Open help screen
Ctrl+C Disconnect
.TE
.PP
NORMAL mode opens on the latest visible messages and stays pinned there
until you scroll up. Use k, Ctrl+U, Ctrl+B, or PageUp to move toward
older history; use G or End to return to the latest messages.
.SS COMMAND mode
.TS
l l.
:list Show online users
:nick \fIname\fR Change nickname
:name \fIname\fR Alias for :nick
:msg \fIuser message\fR Send private message
:msg \fIuser text\fR Send private whisper
:w \fIuser text\fR Short alias for :msg
:inbox Show private messages
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search across full message history
:mute\-joins Toggle join/leave system notifications on/off
:lang \fIen|zh\fR Switch UI language for this session
:help Show concise manual
:help Show available commands
:clear Clear command output
:q, :quit, :exit Disconnect
Up/Down Browse command history
@ -156,14 +144,6 @@ Directory for host key and message log (default: current directory).
If set, clients must supply this string as their SSH password.
Compared in constant time.
.TP
.B TNT_LANG
Default interactive UI language.
Accepts
.B en
or
.BR zh .
When unset, TNT detects the process locale and falls back to English.
.TP
.B TNT_MAX_CONNECTIONS
Global connection limit (default: 64, max: 1024).
.TP
@ -245,7 +225,6 @@ m1ngsama <contact@m1ng.space>
.SH BUGS
Report bugs at
.UR https://github.com/m1ngsama/TNT/issues
the project issue tracker
.UE .
.SH SEE ALSO
.BR ssh (1),