Compare commits

...

81 commits

Author SHA1 Message Date
94b602613f i18n: use shared initializer for text catalog
Some checks failed
CI / build-and-test (macos-latest) (push) Has been cancelled
CI / build-and-test (ubuntu-latest) (push) Has been cancelled
2026-05-24 20:15:12 +08:00
139715efb5 docs: tighten quick setup guide 2026-05-24 16:32:37 +08:00
cd49519058 exec: use localized strings for catalog chrome 2026-05-24 16:28:56 +08:00
6c8ea56e8d help: use shared localized string helper 2026-05-24 16:22:23 +08:00
ed92aeb1e6 manual: use shared localized string helper 2026-05-24 16:15:27 +08:00
f196bfaf6d cli: use shared localized string helper 2026-05-24 16:10:44 +08:00
aa2b8b1b23 exec: use shared localized string helper 2026-05-24 15:32:20 +08:00
d1d44d0914 i18n: share localized string helper 2026-05-24 15:27:19 +08:00
46f5780057 i18n: index text catalog by language 2026-05-24 15:20:01 +08:00
69d3b76512 cleanup: remove unused help mode 2026-05-24 15:15:13 +08:00
f99103ede6 i18n: centralize language definitions 2026-05-24 15:10:54 +08:00
155e535b8a i18n: cycle help language with one key 2026-05-24 15:06:34 +08:00
f2942e9c9e commands: centralize usage validation in catalog 2026-05-24 15:00:41 +08:00
0aaba8e1f9 exec: centralize usage validation in catalog 2026-05-24 14:33:48 +08:00
1391ddca07 docs: use neutral host examples 2026-05-24 13:15:10 +08:00
bfaafb4b35 exec: centralize command matching in catalog 2026-05-24 13:12:47 +08:00
da0170d2c0 docs: refresh command contribution guidance 2026-05-24 12:43:28 +08:00
e911a2d469 exec: extract help text into catalog 2026-05-24 12:41:05 +08:00
5eda6ed127 tests: guard localized command placeholders 2026-05-24 12:34:23 +08:00
00fc944da8 ux: standardize private message terminology 2026-05-24 12:30:08 +08:00
01439507d5 i18n: keep command placeholders locale neutral 2026-05-24 12:26:16 +08:00
8fbd789dfb i18n: split text catalog from language parsing 2026-05-24 12:18:21 +08:00
06a10e2df8 i18n: rename help language state to ui language 2026-05-24 12:11:54 +08:00
1f1c2398b6 tui: make command output scrollable 2026-05-24 11:55:26 +08:00
57bf3cfc67 commands: centralize interactive command catalog 2026-05-24 11:25:46 +08:00
8eb311e54b i18n: restore code-based language syntax 2026-05-24 11:09:17 +08:00
a693d281f8 ux: collapse help surface around manual 2026-05-24 10:17:25 +08:00
15aac7134f ci: preseed valgrind smoke host key 2026-05-24 09:22:10 +08:00
e78989c7ce release: prepare 1.0.1 2026-05-24 09:16:07 +08:00
782d21eaae docs: align development guide with modules 2026-05-24 09:01:00 +08:00
86e1ec8e32 i18n: tolerate whitespace in language parsing 2026-05-24 08:58:51 +08:00
1897a980d5 ci: harden valgrind smoke check 2026-05-24 08:55:34 +08:00
ddf1242b17 test: wait for connection limit readiness 2026-05-24 08:53:08 +08:00
84e26e3f74 test: wait for basic health readiness 2026-05-24 08:47:36 +08:00
998da4288f test: stabilize stress test runner 2026-05-24 08:42:39 +08:00
fa16beb7a6 test: stabilize anonymous access checks 2026-05-23 23:30:43 +08:00
cd170d3245 docs: refresh module quick reference 2026-05-23 22:12:22 +08:00
f39f07b205 ci: add local ci-test target 2026-05-23 22:10:28 +08:00
095491927a test: cover connection limit regressions 2026-05-23 21:38:27 +08:00
6d5c77b850 ci: make integration tests strict 2026-05-23 21:26:19 +08:00
6ec86eb016 i18n: localize idle timeout notice 2026-05-23 20:10:51 +08:00
73655d0e70 i18n: localize startup cli text 2026-05-23 20:08:18 +08:00
fd6cdbf627 i18n: localize exec guidance text 2026-05-23 20:03:31 +08:00
81c3f45864 support: move guide copy into text module 2026-05-23 19:55:44 +08:00
0cf8ac6759 i18n: centralize command guidance text 2026-05-23 19:45:53 +08:00
4fb531771b help: move bilingual help text into module 2026-05-23 19:41:38 +08:00
8009887be9 i18n: localize welcome screen 2026-05-23 19:33:21 +08:00
07e47e65c8 i18n: module system event messages 2026-05-23 19:30:11 +08:00
1d8fcea3fa i18n: localize title bar status 2026-05-23 19:21:01 +08:00
aca68824ac i18n: centralize command output text 2026-05-23 19:11:29 +08:00
9159586716 i18n: localize command usage errors 2026-05-23 18:36:44 +08:00
4c8ef99880 i18n: localize modal screen chrome 2026-05-23 18:32:26 +08:00
22ab85acef i18n: localize common command outputs 2026-05-23 18:29:30 +08:00
92123d208d i18n: localize help screen chrome 2026-05-23 18:25:30 +08:00
f535b928d1 i18n: localize command mode guidance 2026-05-23 18:17:53 +08:00
2e69283e5c i18n: add session language command 2026-05-23 18:10:54 +08:00
0c27976763 i18n: select interactive language from locale 2026-05-23 18:06:39 +08:00
39f7f1c7c4 packaging: document homebrew tap path 2026-05-23 17:57:19 +08:00
599cd690b8 packaging: document aur submission path 2026-05-23 17:56:40 +08:00
4c7b72e7a0 packaging: add debian package draft 2026-05-23 17:55:21 +08:00
2490262332 install: verify release binary checksums 2026-05-21 12:58:24 +08:00
7da33951b0 release: harden binary artifact workflow 2026-05-21 12:55:39 +08:00
d819fd5324 ci: run release preflight 2026-05-21 12:52:16 +08:00
a4748cd902 release: add local preflight checks 2026-05-21 12:51:10 +08:00
b4e714ed44 packaging: prepare package manager installs 2026-05-21 12:36:21 +08:00
36dbe8d549 tui: guide first-time users 2026-05-21 12:36:06 +08:00
69ddcd2d95 ssh: use non-deprecated host key generation api 2026-05-21 12:20:41 +08:00
169ba1a150 tui: preserve ansi styling when truncating output 2026-05-21 12:12:14 +08:00
67d21ad0e9 tui: improve history browsing and support guide 2026-05-21 11:57:59 +08:00
87d6572156 chat: whisper inbox with :inbox view + ✉ unread chip (UX-12)
Whispers used to flash on the recipient's terminal and disappear with
the next redraw.  No history, no record, no signal if you weren't
looking.

Now whispers are stored per-recipient in a bounded inbox (16 slots,
FIFO eviction):

  typedef struct { time_t timestamp; char from[]; char content[]; } whisper_t;
  whisper_t whisper_inbox[16];
  int       whisper_inbox_count;
  _Atomic int unread_whispers;

Sender side (:msg / :w):
  - resolves target as before
  - pushes the whisper into target->whisper_inbox under target->io_lock
    (so two simultaneous senders to the same recipient don't tear the
    ring)
  - bumps target->unread_whispers atomically
  - sends a single \a bell + triggers redraw_pending
  - no longer writes whisper text directly to the channel (which used
    to get clobbered by the next redraw)

Recipient side:
  - new :inbox command in COMMAND mode prints the snapshot under
    io_lock, in M7 chat-list style:

      悄悄话 · whispers  · 3
        05-17 13:42  alice: 一会儿要不要喝咖啡
        ...

  - viewing :inbox resets unread_whispers to 0

Title bar (extends UX-11):
  - bright magenta "✉ N" chip alongside the yellow "★ N" mention chip
  - same priority / degradation rules as ★

Whispers remain private — they're never broadcast to the room and
never persisted to messages.log.  The inbox lives only in client_t,
so disconnecting drops it.
2026-05-17 14:35:16 +08:00
ddcecbea81 tui: persistent @mention unread counter in title bar (UX-11)
The bell + brief yellow highlight on the chat line meant that if you
weren't looking at the screen the moment someone @-mentioned you, you
had no way to know.

Now the title bar carries a sticky chip:

    tester · 在线 3 · NORMAL  ★ 2                ? 帮助

- bright yellow "★ N" appears whenever client->unread_mentions > 0
- count is bumped atomically in notify_mentions() for each target
- cleared automatically when the user returns to attention:
  * pressing 'i' in NORMAL to re-enter INSERT mode
  * pressing 'G' in NORMAL to jump to the live tail
- never dropped by the narrow-terminal degradation (UX-6) unless
  every other optional chip has already been shed — it's the highest
  priority signal in the bar

Counter is _Atomic int so the cross-thread bump in notify_mentions
doesn't tear against the local thread's reads / resets.
2026-05-17 14:27:46 +08:00
70718482f3 docs: troubleshooting section for "Connection closed by remote host" (UX-10)
The original UX-10 was "give the client a readable reason on
disconnect" — turns out the libssh server API doesn't let us send
SSH_MSG_USERAUTH_BANNER, ssh_set_banner is GET-only on the linked
versions (0.9.6 on oss, 0.10.6 on ali), and pre-auth rejections
(max_connections / ratelimit / firewall) happen before any SSH
exchange the client could parse.

The realistic improvement is documentation: README now has a
troubleshooting table mapping the generic close to the actual cause
and how to verify (journalctl) + fix.  Also documents the idle
timeout disconnect for completeness.

Server-side stderr already prints rejection reason with the
offending IP, so journalctl gives the admin enough to debug.
2026-05-17 14:24:35 +08:00
6a36cbcb82 input: Tab completes @mentions in INSERT mode (UX-9)
Typing @al<Tab> in INSERT mode now resolves to @alice and appends a
trailing space so the next word starts cleanly.

Algorithm:
1. walk back from end-of-input until '@' or ' ' is seen
2. '@' counts as a mention start only when at start-of-input or
   preceded by a space (avoids matching e.g. email@host)
3. case-insensitive strncasecmp against current g_room usernames
4. first hit wins; the search ignores the local user when the prefix
   is empty (so a lone "@<Tab>" defaults to the first *other* member,
   matching the typical "ping someone" intent)

If the buffer is too short to hold "@<match> ", the completion is a
no-op rather than silently truncating the match.

Standard chat-client behaviour — much less typing for @mentions.
2026-05-17 14:21:34 +08:00
585262fe4f commands: refresh :list output to match M7 aesthetic (UX-8)
:list used to look like an ASCII printout from the 90s:

    ========================================
         Online Users / 在线用户
    ========================================
    Total / 总数: 3
    ----------------------------------------
    * 1. alice (5m)
      2. bob (12s)
      3. carol (1h2m)
    ========================================
    * = you / 你

Now it matches the rest of the TUI:

    在线用户 · online  · 3
    ▎  alice  · 5m
       bob    · 12s
       carol  · 1h2m

- bold cyan title chip, dim grey total count
- 1-column ▎ gutter on your own row (same vocabulary as UX-1
  message gutter)
- dim grey "· duration" separator instead of parentheses + ASCII rule
- no trailing rule, no legend explaining "* = you" because the
  gutter speaks for itself
2026-05-17 14:17:02 +08:00
0e03c4d216 commands: highlight matched keyword in :search results (UX-7)
:search dumps the matching lines with username and content, but the
query word itself was just rendered as-is.  In a result set with 15
matches you had to eye-scan each line for the keyword.

Now each occurrence of the (case-insensitively matched) needle is
wrapped in a reverse-yellow ANSI chip both in the username column and
in the content column.  Original casing of the matched substring is
preserved.

Helper:
    append_highlighted(output, buf_size, &pos, text, needle)
emits text into the output buffer with every case-insensitive hit
wrapped in `\033[7;33m … \033[0m`.

strcasestr() needs _DEFAULT_SOURCE / _DARWIN_C_SOURCE feature macros;
the same dance message.c already does is now mirrored in commands.c.
2026-05-17 14:13:21 +08:00
0a013ed40f tui: title bar gracefully degrades on narrow terminals (UX-6)
When the terminal is too narrow to hold

    username · 在线 N · MODE  [静音]                ? 帮助

the chips and hint would visually collide.  Now the renderer measures
required width against render_width and drops optional segments in
reverse priority until what's left fits:

    1. drop the "? 帮助" hint
    2. drop the "静音" marker (if shown)
    3. drop the mode chip
    4. drop the online-count chip

The bold username is always shown.  A minimum 1-column gap is kept
between left and right halves so they never touch.

Mostly cosmetic on a regular terminal, but matters on phones /
tmux split panes / narrow side windows.
2026-05-17 13:51:25 +08:00
ae1bc2f166 input: vim-style paging keys in the help screen (UX-5)
The NORMAL chat mode has Ctrl+D/U (half page) and Ctrl+F/B (full page)
scrolling, which is what vim users reach for.  The help screen had
none of these — only j/k single-line and g/G top/bottom — so reaching
the bottom of a help dump meant mashing j.

Now the help screen accepts the same four shortcuts.  The page size
is computed from client->height (matching what NORMAL mode does), so
half/full page scroll size scales with the terminal.

Both the EN and ZH help text have been updated to advertise the new
shortcuts.
2026-05-17 13:49:00 +08:00
c66491d4f8 tui: byte-budget gauge in INSERT input line (UX-4)
The chat input is bounded at MAX_MESSAGE_LEN (1024 bytes).  Past that
limit the input loop silently drops further keystrokes — which is fine
mechanically but leaves the user typing into the void.

tui_render_input() now appends a right-aligned gauge to the input line
once the buffer crosses 80 % full:

    › some long message that goes on and on … 187 B    (dim grey)
    › this one is almost at the limit       … 12 B    (bold yellow > 95 %)

Below 80 % the prompt is unchanged.  The gauge eats display width from
the available content area so the existing horizontal scroll-truncate
logic keeps working — long input still scrolls cleanly past the gauge.

(Paste handling, which is the other half of the long-input UX gap,
lands separately as UX-13.)
2026-05-17 13:47:00 +08:00
b1353d904b commands: reject :nick collisions with active clients (UX-3)
:nick used to swap client->username unconditionally, so two clients
could both end up named "alice" — and the subsequent :msg / :w
disambiguation would just hit whichever came first in g_room->clients.

Now the rename happens under wrlock with an explicit scan: if any
*other* client already owns that username, the change is refused with

    Nickname 'alice' is already taken

If the requested name equals the current one, return a short
"Nickname unchanged" instead of broadcasting a no-op system message.

The room-lock-held scan is O(n) over current clients (n ≤ 64 by
default) and folds naturally into the existing wrlock, so there's no
new lock acquisition.
2026-05-17 13:44:25 +08:00
f3217de36b input: Up/Down in INSERT mode walks sent-message history (UX-2)
ESC in INSERT mode used to switch straight to NORMAL.  Now it follows
the same arrow-key probe COMMAND mode already does: if the next two
bytes are "[A" or "[B", treat as Up / Down and walk through the last
16 messages this client has sent.  A plain ESC still falls through to
NORMAL — the 50 ms probe timeout keeps that path responsive.

Storage: new client_t fields
  char insert_history[16][MAX_MESSAGE_LEN];
  int  insert_history_count;
  int  insert_history_pos;

Recording: every Enter that broadcasts a message pushes the input
buffer onto the ring (with FIFO eviction at 16) and resets pos.
Down at the bottom of the ring returns to an empty input.

This is the standard chat-client recall that vim users (and anyone
who's ever used a shell) expect.
2026-05-17 13:43:15 +08:00
94f3d28562 tui: cyan ▎ gutter on the user's own messages (UX-1)
Self-messages were rendered identically to anyone else's, so scrolling
back to find "what did I just say?" meant scanning every author name.

Now every rendered chat line carries a 1-column left gutter:
- ▎ (U+258E, cyan) when the message is from the local user
- single space otherwise — keeps the rest of the line aligned

Detection handles both regular messages (username == my_username) and
/me action messages (which use "*" as the author and prefix the actor
into the content).  System messages ("系统") always render with the
space gutter regardless.

Gutter width is folded into the existing truncation budget so long
messages still fit the terminal.
2026-05-17 13:35:57 +08:00
83 changed files with 6013 additions and 1100 deletions

View file

@ -20,7 +20,7 @@ jobs:
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libssh-dev
sudo apt-get install -y expect libssh-dev
- name: Install dependencies (macOS)
if: runner.os == 'macOS'
@ -34,17 +34,67 @@ jobs:
run: make asan
- name: Run comprehensive tests
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
run: make ci-test
- name: Run release preflight
run: make release-check
- name: Check for memory leaks
if: runner.os == 'Linux'
run: |
set -eu
sudo apt-get install -y valgrind
timeout 10 valgrind --leak-check=full --error-exitcode=1 ./tnt &
sleep 5
pkill tnt || true
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

View file

@ -1,45 +0,0 @@
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-latest
- os: ubuntu-24.04
target: linux-amd64
artifact: tnt-linux-amd64
- os: ubuntu-latest
- os: ubuntu-24.04-arm
target: linux-arm64
artifact: tnt-linux-arm64
- os: macos-latest
- os: macos-15-intel
target: darwin-amd64
artifact: tnt-darwin-amd64
- os: macos-latest
- os: macos-15
target: darwin-arm64
artifact: tnt-darwin-arm64
@ -34,20 +34,35 @@ 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 }}
@ -74,19 +89,18 @@ jobs:
- name: Create checksums
run: |
cd artifacts
for dir in */; do
cd "$dir"
sha256sum * > checksums.txt
cd ..
: > checksums.txt
for artifact in */tnt-*; do
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
done
cd ..
cat checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/*/tnt-*
artifacts/*/checksums.txt
artifacts/checksums.txt
body: |
## Installation
@ -126,8 +140,8 @@ jobs:
```
## What's Changed
See [CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/CHANGELOG.md)
draft: false
See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md)
draft: true
prerelease: false
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}

10
.gitignore vendored
View file

@ -11,3 +11,13 @@ 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,6 +5,7 @@ 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)
@ -21,9 +22,16 @@ OBJ_DIR = obj
SOURCES = $(wildcard $(SRC_DIR)/*.c)
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d)
TARGET = tnt
.PHONY: all clean install uninstall debug release asan valgrind check info
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
all: $(TARGET)
@ -32,7 +40,7 @@ $(TARGET): $(OBJECTS)
@echo "Build complete: $(TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) $(INCLUDES) -c $< -o $@
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
$(OBJ_DIR):
mkdir -p $(OBJ_DIR)
@ -43,14 +51,21 @@ clean:
@echo "Clean complete"
install: $(TARGET)
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/
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)/
uninstall:
rm -f $(DESTDIR)/usr/local/bin/$(TARGET)
rm -f $(DESTDIR)/usr/local/share/man/man1/tnt.1
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
uninstall-systemd:
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
# Development targets
debug: CFLAGS += -g -DDEBUG
@ -60,6 +75,12 @@ 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)
@ -74,17 +95,51 @@ 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
test: all unit-test integration-test
test-advisory: all unit-test
@echo "Running integration tests..."
@cd tests && ./test_basic.sh || echo "(integration tests are advisory)"
@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)"
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,6 +21,8 @@ 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
@ -45,7 +47,7 @@ PORT=3333 tnt # via env var
### Connecting
```sh
ssh -p 2222 chat.m1ng.space
ssh -p 2222 chat.example.com
```
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
@ -62,17 +64,25 @@ 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 help
? - Show full key reference
Ctrl+C - Exit chat
```
@ -80,12 +90,14 @@ Ctrl+C - Exit chat
```
:list, :users - Show online users
:nick <name> - Change nickname
:msg <user> <text> - Whisper to user
:msg <user> <message> - Send private message
: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
:help - Show available commands
:lang <en|zh> - Switch UI language for this session
:help - Show concise manual
:clear - Clear command output
:q, :quit, :exit - Disconnect
Up/Down - Browse command history
@ -115,7 +127,10 @@ 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.m1ng.space tnt
TNT_PUBLIC_HOST=chat.example.com tnt
# Choose interactive UI language (en or zh; defaults from locale)
TNT_LANG=zh tnt
```
**Rate limiting:**
@ -158,12 +173,12 @@ tnt -p 2222
TNT also exposes a small non-interactive SSH surface for scripts:
```sh
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"
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"
```
**`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.
@ -176,6 +191,7 @@ ssh -p 2222 chat.m1ng.space 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
```
@ -183,7 +199,13 @@ make clean # clean build artifacts
### Testing
```sh
make test # run comprehensive test suite
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
# Individual tests
cd tests
@ -197,8 +219,8 @@ cd tests
**Test coverage:**
- Basic functionality: 3 tests
- Anonymous access: 2 tests
- Security features: 11 tests
- Stress test: concurrent connections
- Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
### Dependencies
@ -227,14 +249,29 @@ 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
@ -260,7 +297,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.m1ng.space
TNT_PUBLIC_HOST=chat.example.com
EOF
```
@ -276,6 +313,23 @@ 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
```
@ -317,6 +371,32 @@ 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.m1ng.space
ssh -p 2222 chat.example.com
# 输入任意内容或直接按回车
# 开始聊天!
```
@ -143,7 +143,7 @@ ssh -p 2222 chat.m1ng.space
tnt
# 用户端(任何人)
ssh -p 2222 chat.m1ng.space
ssh -p 2222 chat.example.com
# 输入任何内容作为密码或直接回车
# 选择显示名称(可留空)
# 开始聊天!

View file

@ -1,5 +1,209 @@
# 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,42 +1,67 @@
CI/CD USAGE GUIDE
=================
CI / RELEASE GUIDE
==================
AUTOMATIC TESTING
-----------------
Every push or PR automatically runs:
- Build on Ubuntu and macOS
- AddressSanitizer checks
- Valgrind memory leak detection
- Build on Ubuntu
- AddressSanitizer build
- `make ci-test` (strict integration, anonymous access, connection limits,
and security feature checks)
- Release/package preflight (`make release-check`)
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 in CHANGELOG.md
1. Update version metadata:
- include/common.h
- tnt.1
- docs/CHANGELOG.md
- packaging/arch/PKGBUILD
- packaging/homebrew/tnt-chat.rb
2. Create and push tag:
git tag v1.0.0
git push origin v1.0.0
2. Run the local preflight:
make release-check
3. GitHub Actions automatically:
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:
- Builds binaries (Linux/macOS, AMD64/ARM64)
- Creates release
- Creates a draft release
- Uploads binaries
- Generates checksums
- Generates one `checksums.txt` file
- Verifies that artifact architecture matches the asset name
4. Release appears at:
6. Review the draft release, smoke-test downloaded assets, then publish it
manually from GitHub.
7. Release appears at:
https://github.com/m1ngsama/TNT/releases
DEPLOYING TO SERVERS
--------------------
Single command on any server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
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).
Or with specific version:
VERSION=v1.0.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
The installer can still be used manually on a server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
PRODUCTION SETUP (systemd)
@ -58,14 +83,11 @@ PRODUCTION SETUP (systemd)
UPDATING SERVERS
----------------
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
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
PLATFORMS SUPPORTED
@ -79,7 +101,7 @@ PLATFORMS SUPPORTED
EXAMPLE WORKFLOW
----------------
# Local development
make && make asan
make && make asan && make release-check
./tnt
# Create release
@ -87,8 +109,7 @@ git tag v1.0.1
git push origin v1.0.1
# Wait 5 minutes for builds
# 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
# 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

View file

@ -7,13 +7,15 @@ make # normal build
make debug # with symbols
make asan # AddressSanitizer
make release # optimized
make release-check # release preflight
```
## Test
```sh
./test_basic.sh # functional tests
./test_stress.sh 20 60 # 20 clients, 60 seconds
make test # unit + integration tests
make ci-test # local CI-equivalent checks
make stress-test # concurrent-client stress test
```
## Debug
@ -34,10 +36,21 @@ make check
```
main.c → entry point, signal handling
ssh_server.c → SSH protocol, client threads
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
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
```
@ -70,9 +83,13 @@ utf8.c → UTF-8 string handling
## Adding Features
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)
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.
## 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.0 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
VERSION=v1.0.1 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.m1ng.space
TNT_PUBLIC_HOST=chat.example.com
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 available commands.
Type :help for a concise manual, or ? for the full key reference.
EOF
sudo chown tnt:tnt /var/lib/tnt/motd.txt

View file

@ -9,9 +9,10 @@ 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. [Debugging](#debugging)
7. [Performance Optimization](#performance-optimization)
8. [Contributing Guidelines](#contributing-guidelines)
6. [User-Facing Text and i18n](#user-facing-text-and-i18n)
7. [Debugging](#debugging)
8. [Performance Optimization](#performance-optimization)
9. [Contributing Guidelines](#contributing-guidelines)
---
@ -67,11 +68,26 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
```
src/
├── main.c - Entry point, signal handling
├── ssh_server.c - SSH server, client threads, authentication
├── chat_room.c - Chat room logic, message broadcasting
├── 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
├── 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
```
@ -81,9 +97,17 @@ 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
```
@ -159,7 +183,13 @@ make install # Install to /usr/local/bin
### Running Tests
```sh
make test # Run all tests
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
# Individual tests
cd tests
@ -328,28 +358,36 @@ void utf8_remove_last_word(char *str) {
### Adding a New Command
1. **Add to `execute_command()` in ssh_server.c:**
1. **For interactive COMMAND mode, add command metadata in `src/command_catalog.c`:**
```c
if (strcmp(cmd, "newcmd") == 0) {
pos += snprintf(output + pos, sizeof(output) - pos,
"New command output\n");
{
{TNT_COMMAND_NEWCMD, "newcmd", {"newcmd", NULL}, false},
":newcmd", ":newcmd",
"Show new output", "显示新输出",
":newcmd", ":newcmd", 3
}
```
2. **Update help text in tui.c:**
```c
"AVAILABLE COMMANDS:\n"
" newcmd - Description of new command\n"
```
2. **Add interactive behavior in `src/commands.c` by switching on the command ID.**
3. **Add test in tests/test_basic.sh:**
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:**
```sh
echo ":newcmd" | timeout 5 ssh -p $PORT localhost
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
```
### Adding a New Keybinding
1. **Add to `handle_key()` in ssh_server.c:**
1. **Add to the relevant mode handler in `src/input.c`:**
```c
case MODE_INSERT:
if (key == 26) { /* Ctrl+Z */
@ -358,12 +396,77 @@ case MODE_INSERT:
}
```
2. **Update help text in tui.c**
2. **Update `src/help_text.c` and status hints in `src/i18n_text.c` /
`src/tui_status.c` if the binding is user-visible.**
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,278 +1,224 @@
# TNT 匿名聊天室 - 快速部署指南 / TNT Anonymous Chat - Quick Setup Guide
# TNT Quick Setup
[中文](#中文) | [English](#english)
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).
---
## Install
## 中文
### 一键安装
```bash
```sh
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
### 启动服务器
Or build from source:
```bash
tnt # 监听 2222 端口
```sh
git clone https://github.com/m1ngsama/TNT.git
cd TNT
make
sudo make install
```
就这么简单!服务器已经运行了。
## Start A Server
### 用户如何连接
用户只需要一个SSH客户端即可无需任何配置
```bash
ssh -p 2222 chat.m1ng.space
```
**重要提示**
- ✅ 用户可以使用**任意用户名**连接
- ✅ 用户可以输入**任意密码**(甚至直接按回车跳过)
- ✅ **不需要SSH密钥**
- ✅ 不需要提前注册账号
- ✅ 完全匿名,零门槛
连接后,系统会提示输入显示名称(也可以留空使用默认名称)。
### 生产环境部署
使用 systemd 让服务器开机自启:
```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 tnt
sudo systemctl start tnt
# 3. 检查状态
sudo systemctl status tnt
```
### 防火墙设置
记得开放2222端口
```bash
# Ubuntu/Debian
sudo ufw allow 2222/tcp
# CentOS/RHEL
sudo firewall-cmd --permanent --add-port=2222/tcp
sudo firewall-cmd --reload
```
### 可选配置
通过环境变量进行高级配置:
```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
```
**注意**:设置 `TNT_ACCESS_TOKEN` 后,所有用户必须使用该密码才能连接,这会提高安全性但也会增加使用门槛。
### 特性
- 🚀 **零配置** - 开箱即用
- 🔓 **完全匿名** - 无需注册,无需密钥
- 🎨 **Vim风格界面** - 支持 INSERT/NORMAL/COMMAND 三种模式
- 📜 **消息历史** - 自动保存聊天记录
- 🌐 **UTF-8支持** - 完美支持中英文及其他语言
- 🔒 **可选安全特性** - 支持限流、访问控制等
---
## English
### One-Line Installation
```bash
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
### Start Server
```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
# 启动服务器
```sh
tnt
# 用户连接(从任何机器)
ssh -p 2222 chat.m1ng.space
# 输入任意密码或直接回车
# 输入显示名称或留空
# 开始聊天!
```
### Vim风格操作 / Vim-Style Operations
By default TNT listens on port `2222`, stores `host_key` and `messages.log`
in the current directory, and allows anonymous SSH login.
连接后:
Use explicit state and port settings for a long-running server:
- **INSERT 模式**(默认):直接输入消息,按 Enter 发送
- **NORMAL 模式**:按 `ESC` 进入,使用 `j/k` 滚动历史,`g/G` 跳转顶部/底部
- **COMMAND 模式**:按 `:` 进入,输入 `:list` 查看在线用户,`:help` 查看帮助
```sh
tnt -p 2222 -d /var/lib/tnt
```
### 故障排除 / Troubleshooting
## Connect
#### 问题:端口已被占用
```sh
ssh -p 2222 chat.example.com
```
```bash
# 更换端口
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
sudo useradd -r -s /bin/false tnt
sudo mkdir -p /var/lib/tnt
sudo chown tnt:tnt /var/lib/tnt
sudo cp tnt.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now tnt
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
```
Open the listening port in your firewall:
```sh
sudo ufw allow 2222/tcp
```
## Troubleshooting
### Port Already In Use
```sh
tnt -p 3333
```
#### 问题:防火墙阻止连接
### Cannot Connect
```bash
# 检查防火墙状态
Check the server process:
```sh
systemctl status tnt
sudo journalctl -u tnt -n 50 --no-pager
```
Check the listening port:
```sh
ss -ltnp | grep 2222
```
Check the firewall:
```sh
sudo ufw status
sudo firewall-cmd --list-ports
# 确保已开放端口
sudo ufw allow 2222/tcp
```
#### 问题:连接超时
### Connection Closes Immediately
```bash
# 检查服务器是否运行
ps aux | grep tnt
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.
# 检查端口监听
sudo lsof -i:2222
## Chinese Quick Notes
### 安装
```sh
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
---
### 启动
## 技术细节 / Technical Details
```sh
tnt
```
- **语言**: C
- **依赖**: libssh
- **并发**: 多线程,支持数百个同时连接
- **安全**: 可选限流、访问控制、密码保护
- **存储**: 简单的文本日志messages.log
- **配置**: 环境变量,无配置文件
默认监听 `2222` 端口,并允许匿名 SSH 登录。
---
### 连接
## 许可证 / License
```sh
ssh -p 2222 chat.example.com
```
MIT License - 自由使用、修改、分发
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
### 常用操作
```text
Enter 发送消息
Esc 进入 NORMAL 模式
i 回到 INSERT 模式
: 输入命令
? 查看完整按键参考
G 或 End 回到最新消息
:help 查看简明手册
:lang en|zh 切换界面语言
:q 断开连接
```
### 常用配置
```sh
TNT_ACCESS_TOKEN="change-this-password" tnt
TNT_STATE_DIR=/var/lib/tnt tnt
TNT_LANG=zh tnt
```

View file

@ -9,8 +9,13 @@ BUILD
make clean remove artifacts
TEST
./test_basic.sh basic functionality
./test_stress.sh 20 60 stress test (20 clients, 60s)
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
DEBUG
ASAN_OPTIONS=detect_leaks=1 ./tnt
@ -20,25 +25,44 @@ DEBUG
COMMANDS (COMMAND mode, prefix with :)
list, users, who show online users
nick <name> change nickname
msg <user> <text> whisper to user
msg <user> <message> send private message
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 show all commands
help concise manual
lang [en|zh] show or switch UI language
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/ssh_server.c SSH, threads, commands
src/chat_room.c broadcast
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/message.c persistence, search
src/tui.c rendering, help
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/utf8.c unicode
LIMITS

12
include/cli_text.h Normal file
View file

@ -0,0 +1,12 @@
#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 */

39
include/command_catalog.h Normal file
View file

@ -0,0 +1,39 @@
#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.0"
#define TNT_VERSION "1.0.1"
/* Configuration constants */
#define DEFAULT_PORT 2222
@ -20,6 +20,7 @@
#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 */
@ -39,15 +40,15 @@
typedef enum {
MODE_INSERT,
MODE_NORMAL,
MODE_COMMAND,
MODE_HELP
MODE_COMMAND
} client_mode_t;
/* Help language */
/* UI language */
typedef enum {
LANG_EN,
LANG_ZH
} help_lang_t;
UI_LANG_EN,
UI_LANG_ZH,
UI_LANG_COUNT
} ui_lang_t;
/* Runtime helpers */
const char* tnt_state_dir(void);

24
include/exec_catalog.h Normal file
View file

@ -0,0 +1,24 @@
#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 */

9
include/help_text.h Normal file
View file

@ -0,0 +1,9 @@
#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 */

16
include/history_view.h Normal file
View file

@ -0,0 +1,16 @@
#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 */

85
include/i18n.h Normal file
View file

@ -0,0 +1,85 @@
#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 */

9
include/manual.h Normal file
View file

@ -0,0 +1,9 @@
#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 */

9
include/manual_text.h Normal file
View file

@ -0,0 +1,9 @@
#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,6 +7,16 @@
#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 */
@ -16,21 +26,34 @@ typedef struct client {
_Atomic int width;
_Atomic int height;
client_mode_t mode;
help_lang_t help_lang;
ui_lang_t ui_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;
char command_output[2048];
/* 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;
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;

17
include/system_message.h Normal file
View file

@ -0,0 +1,17 @@
#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,7 +32,4 @@ 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 */

12
include/tui_status.h Normal file
View file

@ -0,0 +1,12 @@
#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,9 +15,16 @@ 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,24 +1,51 @@
#!/bin/sh
# TNT Auto-deploy script
# TNT installer
# 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" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
*) fail "Unsupported architecture: $ARCH" ;;
esac
BINARY="tnt-${OS}-${ARCH}"
REPO="m1ngsama/TNT"
echo "=== TNT Installer ==="
echo "OS: $OS"
@ -29,34 +56,57 @@ echo ""
# Get latest version if not specified
if [ "$VERSION" = "latest" ]; then
echo "Fetching latest version..."
VERSION=$(curl -sSL "https://api.github.com/repos/$REPO/releases/latest" | grep '"tag_name":' | sed -E 's/.*"([^"]+)".*/\1/')
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"
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)
if ! curl -sSL -o "$TMP_FILE" "$URL"; then
echo "ERROR: Failed to download $BINARY"
rm -f "$TMP_FILE"
exit 1
fi
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"
# Install
chmod +x "$TMP_FILE"
if [ -w "$INSTALL_DIR" ]; then
mv "$TMP_FILE" "$INSTALL_DIR/tnt"
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
else
echo "Need sudo for installation to $INSTALL_DIR"
sudo mv "$TMP_FILE" "$INSTALL_DIR/tnt"
need_cmd sudo
sudo mkdir -p "$INSTALL_DIR"
sudo install -m 755 "$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"

37
packaging/README.md Normal file
View file

@ -0,0 +1,37 @@
# 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.

15
packaging/arch/.SRCINFO Normal file
View file

@ -0,0 +1,15 @@
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

25
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,25 @@
# 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"
}

47
packaging/arch/README.md Normal file
View file

@ -0,0 +1,47 @@
# 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

@ -0,0 +1,49 @@
# 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

@ -0,0 +1,5 @@
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

@ -0,0 +1,22 @@
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

@ -0,0 +1,26 @@
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.

11
packaging/debian/debian/rules Executable file
View file

@ -0,0 +1,11 @@
#!/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

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

View file

@ -0,0 +1,49 @@
# 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

@ -0,0 +1,21 @@
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

150
scripts/release_check.sh Executable file
View file

@ -0,0 +1,150 @@
#!/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"

66
src/cli_text.c Normal file
View file

@ -0,0 +1,66 @@
#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,6 +33,10 @@ 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;
}
@ -58,7 +62,9 @@ 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_free(client->channel);
}
if (client->session) {
@ -120,9 +126,14 @@ 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;
}
}
}
static void client_channel_close(ssh_session session, ssh_channel channel,
void *userdata) {

311
src/command_catalog.c Normal file
View file

@ -0,0 +1,311 @@
#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,8 +1,18 @@
#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>
@ -10,12 +20,44 @@
#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[2048] = {0};
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
size_t pos = 0;
/* Trim whitespace */
@ -43,22 +85,49 @@ void commands_dispatch(client_t *client) {
client->command_history_pos = client->command_history_count;
}
if (strcmp(cmd, "list") == 0 || strcmp(cmd, "users") == 0 ||
strcmp(cmd, "who") == 0) {
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
" Online Users / 在线用户\n"
"========================================\n");
if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
}
pthread_rwlock_rdlock(&g_room->lock);
tnt_command_id_t command_id;
const char *arg = "";
if (!command_catalog_match(cmd, &command_id, &arg)) {
const char *suggestion = command_catalog_suggest(cmd);
buffer_appendf(output, sizeof(output), &pos,
"Total / 总数: %d\n"
"----------------------------------------\n",
g_room->client_count);
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;
}
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);
time_t now = time(NULL);
for (int i = 0; i < g_room->client_count; i++) {
char marker = (g_room->clients[i] == client) ? '*' : ' ';
for (int i = 0; i < total; i++) {
bool is_self = (g_room->clients[i] == client);
int dur = (int)(now - g_room->clients[i]->connect_time);
char dur_str[32];
if (dur < 60) {
@ -66,42 +135,44 @@ 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,
"%c %d. %s (%s)\n", marker, i + 1,
"%s \033[37m%s\033[0m \033[2;37m· %s\033[0m\n",
is_self ? "\033[36m▎\033[0m" : " ",
g_room->clients[i]->username, dur_str);
}
pthread_rwlock_unlock(&g_room->lock);
buffer_appendf(output, sizeof(output), &pos,
"========================================\n"
"* = you / 你\n");
} else if (command_id == TNT_COMMAND_HELP) {
manual_append_interactive_panel(output, sizeof(output), &pos,
client->ui_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");
} else if (command_id == TNT_COMMAND_LANG) {
ui_lang_t next_lang;
} else if (strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 2 : cmd + 4;
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;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
@ -111,9 +182,8 @@ void commands_dispatch(client_t *client) {
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: msg <username> <message>\n"
" w <username> <message>\n");
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
} else {
bool found = false;
client_t *target = NULL;
@ -129,34 +199,90 @@ void commands_dispatch(client_t *client) {
pthread_rwlock_unlock(&g_room->lock);
if (target) {
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));
/* 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++;
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,
"Whisper sent to %s\n", target_name);
i18n_text(client->ui_lang,
I18N_MSG_SENT_FORMAT),
target_name);
} else {
buffer_appendf(output, sizeof(output), &pos,
"User '%s' not found\n", target_name);
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
}
}
} else if (strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 5;
} 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;
while (*new_name == ' ') new_name++;
if (new_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: nick <new_username>\n");
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_NICK, client->ui_lang);
} else if (!is_valid_username(new_name)) {
buffer_appendf(output, sizeof(output), &pos,
"Invalid username\n");
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_NICK_INVALID));
} else {
char validated_name[MAX_USERNAME_LEN];
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
@ -164,33 +290,60 @@ 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);
}
pthread_rwlock_unlock(&g_room->lock);
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);
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);
buffer_appendf(output, sizeof(output), &pos,
"Nickname changed: %s -> %s\n", old_name, client->username);
i18n_text(client->ui_lang,
I18N_NICK_CHANGED_FORMAT),
old_name, client->username);
}
}
} else if (strncmp(cmd, "last", 4) == 0 && (cmd[4] == ' ' || cmd[4] == '\0')) {
char *arg = cmd + 4;
} else if (command_id == TNT_COMMAND_LAST) {
while (*arg == ' ') arg++;
int n = 10;
if (*arg != '\0') {
char *endp;
long val = strtol(arg, &endp, 10);
if (*endp != '\0' || val < 1 || val > 50) {
buffer_appendf(output, sizeof(output), &pos,
"Usage: last [N] (N: 1-50, default 10)\n");
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_LAST, client->ui_lang);
goto cmd_done;
}
n = (int)val;
@ -199,7 +352,8 @@ 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,
"--- Last %d message(s) ---\n", last_count);
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
last_count);
for (int i = 0; i < last_count; i++) {
char ts[20];
struct tm tmi;
@ -210,60 +364,57 @@ void commands_dispatch(client_t *client) {
}
free(last_msgs);
} else if (strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 7;
} else if (command_id == TNT_COMMAND_SEARCH) {
const char *query = arg;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos,
"Usage: search <keyword>\n");
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_SEARCH, client->ui_lang);
} else {
message_t *found = NULL;
int found_count = message_search(query, &found, 15);
buffer_appendf(output, sizeof(output), &pos,
"--- Search: \"%s\" (%d match(es)) ---\n", query, found_count);
i18n_text(client->ui_lang,
I18N_SEARCH_HEADER_FORMAT),
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] %s: %s\n", ts, found[i].username, found[i].content);
"[%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");
}
free(found);
}
} else if (strcmp(cmd, "mute-joins") == 0 || strcmp(cmd, "mute") == 0) {
} else if (command_id == TNT_COMMAND_MUTE_JOINS) {
client->mute_joins = !client->mute_joins;
buffer_appendf(output, sizeof(output), &pos,
"Join/leave notifications: %s\n",
client->mute_joins ? "muted" : "unmuted");
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));
} else if (strcmp(cmd, "q") == 0 || strcmp(cmd, "quit") == 0 ||
strcmp(cmd, "exit") == 0) {
} else if (command_id == TNT_COMMAND_QUIT) {
client->connected = false;
return;
} 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);
} else if (command_id == TNT_COMMAND_CLEAR) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_CLEAR_DONE));
}
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,6 +2,8 @@
#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"
@ -115,20 +117,24 @@ static void resolve_exec_username(const client_t *client, char *buffer,
}
static int exec_command_help(client_t *client) {
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";
char help_text[1024];
size_t pos = 0;
return client_send(client, help_text, sizeof(help_text) - 1) == 0 ? 0 : 1;
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;
}
static int exec_command_health(client_t *client) {
@ -294,8 +300,7 @@ static int exec_command_tail(client_t *client, const char *args) {
int rc;
if (parse_tail_count(args, &requested) < 0) {
client_printf(client, "tail: usage: tail [N] | tail -n N\n");
return 64;
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
}
pthread_rwlock_rdlock(&g_room->lock);
@ -347,8 +352,7 @@ static int exec_command_post(client_t *client, const char *args) {
};
if (!args || args[0] == '\0') {
client_printf(client, "post: usage: post MESSAGE\n");
return 64;
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
}
strncpy(content, args, sizeof(content) - 1);
@ -356,12 +360,15 @@ static int exec_command_post(client_t *client, const char *args) {
trim_ascii_whitespace(content);
if (content[0] == '\0') {
client_printf(client, "post: message cannot be empty\n");
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
return 64;
}
if (!utf8_is_valid_string(content)) {
client_printf(client, "post: invalid UTF-8 input\n");
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_POST_INVALID_UTF8));
return 1;
}
@ -382,72 +389,64 @@ static int exec_command_post(client_t *client, const char *args) {
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
client_printf(client, "post: failed to persist message\n");
if (client_send(client, "posted\n", 7) != 0) {
return 1;
}
return client_send(client, "posted\n", 7) == 0 ? 0 : 1;
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
fprintf(stderr, "post: failed to persist message\n");
return 1;
}
return 0;
}
int exec_dispatch(client_t *client) {
char command_copy[MAX_EXEC_COMMAND_LEN];
char *cmd;
char *args;
tnt_exec_command_id_t command_id;
const char *args = NULL;
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
command_copy[sizeof(command_copy) - 1] = '\0';
trim_ascii_whitespace(command_copy);
cmd = command_copy;
if (*cmd == '\0') {
if (command_copy[0] == '\0') {
return exec_command_help(client);
}
args = cmd;
while (*args && !isspace((unsigned char)*args)) {
args++;
}
if (*args) {
*args++ = '\0';
while (*args && isspace((unsigned char)*args)) {
args++;
}
} else {
args = NULL;
if (exec_catalog_match(command_copy, &command_id, &args)) {
if (!exec_catalog_args_valid(command_id, args)) {
return exec_command_usage(client, command_id);
}
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
switch (command_id) {
case TNT_EXEC_COMMAND_HELP:
return exec_command_help(client);
}
if (strcmp(cmd, "health") == 0) {
case TNT_EXEC_COMMAND_HEALTH:
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;
}
case TNT_EXEC_COMMAND_USERS:
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;
}
case TNT_EXEC_COMMAND_STATS:
return exec_command_stats(client, args != NULL);
}
if (strcmp(cmd, "tail") == 0) {
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);
}
if (strcmp(cmd, "post") == 0) {
case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args);
}
if (strcmp(cmd, "exit") == 0) {
case TNT_EXEC_COMMAND_EXIT:
return 0;
}
}
client_printf(client, "Unknown command: %s\n", cmd);
for (char *p = command_copy; *p; p++) {
if (isspace((unsigned char)*p)) {
*p = '\0';
break;
}
}
client_printf(client,
i18n_text(client->ui_lang,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
command_copy);
return 64;
}

161
src/exec_catalog.c Normal file
View file

@ -0,0 +1,161 @@
#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);
}

116
src/help_text.c Normal file
View file

@ -0,0 +1,116 @@
#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));
}

84
src/history_view.c Normal file
View file

@ -0,0 +1,84 @@
#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;
}

116
src/i18n.c Normal file
View file

@ -0,0 +1,116 @@
#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;
}

209
src/i18n_text.c Normal file
View file

@ -0,0 +1,209 @@
#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,22 +4,28 @@
#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) {
@ -28,7 +34,8 @@ static int read_username(client_t *client) {
char buf[4];
tui_render_welcome(client);
client_printf(client, " 请输入用户名 (留空 anonymous): ");
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_USERNAME_PROMPT));
while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -110,7 +117,8 @@ static int read_username(client_t *client) {
/* Validate username for security */
if (!is_valid_username(client->username)) {
client_printf(client, "Invalid username. Using 'anonymous' instead.\r\n");
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_INVALID_USERNAME));
strcpy(client->username, "anonymous");
} else {
/* Truncate to 20 characters */
@ -143,20 +151,93 @@ 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) {
if (client->mode != MODE_NORMAL) {
client_mode_t previous_mode = client->mode;
if (client->command_output[0] != '\0') {
dismiss_command_output(client);
return true;
}
if (previous_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 */
@ -167,15 +248,17 @@ 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 == '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;
} else if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'j') {
@ -184,6 +267,20 @@ 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);
@ -194,25 +291,171 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
return true; /* Key consumed */
}
/* Handle command output / MOTD display: any key dismisses */
/* Handle command output / MOTD display. MOTD remains a simple notice;
* command output behaves like a small pager so long results can be read. */
if (client->command_output[0] != '\0') {
client->command_output[0] = '\0';
client->show_motd = false;
client->mode = MODE_NORMAL;
tui_render_screen(client);
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);
}
return true; /* Key consumed */
}
/* Mode-specific handling */
switch (client->mode) {
case MODE_INSERT:
if (key == 27) { /* ESC */
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 */
client->mode = MODE_NORMAL;
client->scroll_pos = 0;
normal_scroll_to_latest(client);
tui_render_screen(client);
return true; /* Key consumed */
return true;
} 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),
};
@ -253,18 +496,62 @@ 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_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;
int nm_msg_height = history_view_height(client->height);
if (key == 'i') {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
tui_render_screen(client);
return true;
} else if (key == ':') {
@ -273,47 +560,79 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
tui_render_screen(client);
return true;
} else if (key == 'j') {
if (client->scroll_pos < nm_max_scroll) {
client->scroll_pos++;
normal_scroll_by(client, 1);
tui_render_screen(client);
}
return true;
} else if (key == 'k' && client->scroll_pos > 0) {
client->scroll_pos--;
normal_scroll_by(client, -1);
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;
client->scroll_pos += half;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
normal_scroll_by(client, half);
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;
client->scroll_pos -= half;
if (client->scroll_pos < 0) client->scroll_pos = 0;
normal_scroll_by(client, -half);
tui_render_screen(client);
return true;
} else if (key == 6) { /* Ctrl+F: full page down */
client->scroll_pos += nm_msg_height;
if (client->scroll_pos > nm_max_scroll) client->scroll_pos = nm_max_scroll;
normal_scroll_by(client, nm_msg_height);
tui_render_screen(client);
return true;
} else if (key == 2) { /* Ctrl+B: full page up */
client->scroll_pos -= nm_msg_height;
if (client->scroll_pos < 0) client->scroll_pos = 0;
normal_scroll_by(client, -nm_msg_height);
tui_render_screen(client);
return true;
} else if (key == 'g') {
client->scroll_pos = 0;
history_view_scroll_to_oldest(&client->scroll_pos,
&client->follow_tail);
tui_render_screen(client);
return true;
} else if (key == 'G') {
client->scroll_pos = nm_max_scroll;
normal_scroll_to_latest(client);
client->unread_mentions = 0;
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;
@ -396,15 +715,18 @@ 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->help_lang = LANG_ZH;
client->follow_tail = true;
client->ui_lang = g_default_ui_lang;
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);
@ -412,6 +734,9 @@ 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;
}
@ -422,18 +747,21 @@ void input_run_session(client_t *client) {
/* Add to room */
if (room_add_client(g_room, client) < 0) {
client_printf(client, "Room is full\n");
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_ROOM_FULL));
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 = {
.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);
message_t join_msg;
system_message_make_join(&join_msg, client->username, client->ui_lang);
room_broadcast(g_room, &join_msg);
message_save(&join_msg);
@ -451,6 +779,7 @@ 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);
@ -499,6 +828,10 @@ 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);
@ -513,7 +846,9 @@ main_loop:
if (g_idle_timeout > 0 && joined_room &&
time(NULL) - client->last_active >= g_idle_timeout) {
client_printf(client, "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n",
client_printf(client,
i18n_text(client->ui_lang,
I18N_IDLE_TIMEOUT_FORMAT),
g_idle_timeout / 60);
break;
}
@ -546,6 +881,8 @@ 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);
@ -567,10 +904,12 @@ 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 &&
@ -604,14 +943,16 @@ 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 = {
.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);
message_t leave_msg;
system_message_make_leave(&leave_msg, client->username,
client->ui_lang);
client->connected = false;
room_remove_client(g_room, client);

View file

@ -1,7 +1,9 @@
#include "common.h"
#include "ssh_server.h"
#include "chat_room.h"
#include "cli_text.h"
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -18,6 +20,7 @@ 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");
@ -36,7 +39,8 @@ 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, "Invalid port: %s\n", argv[i + 1]);
fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]);
return 1;
}
port = (int)val;
@ -52,24 +56,15 @@ 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) {
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");
char output[2048] = {0};
size_t pos = 0;
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
fputs(output, stdout);
return 0;
} else {
fprintf(stderr, "Unknown option: %s\n", argv[i]);
fprintf(stderr, "Usage: %s [-p PORT] [-d DIR] [-h]\n", argv[0]);
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
return 1;
}
}

9
src/manual.c Normal file
View file

@ -0,0 +1,9 @@
#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);
}

50
src/manual_text.c Normal file
View file

@ -0,0 +1,50 @@
#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,6 +7,7 @@
#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>
@ -33,6 +34,29 @@ 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;
@ -73,7 +97,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 (ssh_pki_generate(SSH_KEYTYPE_RSA, 4096, &key) < 0) {
if (generate_rsa_host_key(&key) < 0) {
fprintf(stderr, "Failed to generate RSA key\n");
return -1;
}

72
src/system_message.c Normal file
View file

@ -0,0 +1,72 @@
#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;
}

459
src/tui.c
View file

@ -2,15 +2,14 @@
#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",
@ -30,9 +29,28 @@ 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' &&
strcmp(msg->username, "系统") != 0) {
!system_message_is_system(msg)) {
char mention[MAX_USERNAME_LEN + 2];
snprintf(mention, sizeof(mention), "@%s", my_username);
if (strstr(msg->content, mention) != NULL) {
@ -42,23 +60,23 @@ 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 (strcmp(msg->username, "系统") == 0) {
if (system_message_is_system(msg)) {
snprintf(buffer, buf_size,
"\033[90m--> %s\033[0m", msg->content);
"%s\033[90m--> %s\033[0m", gutter, msg->content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(buffer, buf_size,
"\033[90m%s\033[0m \033[3;36m* %s\033[0m",
time_str, msg->content);
"%s\033[90m%s\033[0m \033[3;36m* %s\033[0m",
gutter, time_str, msg->content);
} else {
snprintf(buffer, buf_size,
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
time_str, username_color(msg->username),
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
gutter, time_str, username_color(msg->username),
msg->username, hl_start, msg->content, hl_end);
}
/* Plain-text version for width calculation */
/* Plain-text version for width calculation — gutter is 1 column. */
char plain[MAX_MESSAGE_LEN + 128];
if (strcmp(msg->username, "系统") == 0) {
if (system_message_is_system(msg)) {
snprintf(plain, sizeof(plain), " --> %s", msg->content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(plain, sizeof(plain), " %s * %s", time_str, msg->content);
@ -68,10 +86,11 @@ static void format_message_colored(const message_t *msg, char *buffer,
}
if (utf8_string_width(plain) > width) {
/* Rebuild with truncated content */
/* Rebuild with truncated content — prefix_plain also includes the
* 1-column gutter so the budget math comes out right. */
int prefix_width;
char prefix_plain[256];
if (strcmp(msg->username, "系统") == 0) {
if (system_message_is_system(msg)) {
snprintf(prefix_plain, sizeof(prefix_plain), " --> ");
} else if (strcmp(msg->username, "*") == 0) {
snprintf(prefix_plain, sizeof(prefix_plain), " %s * ", time_str);
@ -84,7 +103,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 (strcmp(msg->username, "系统") == 0) {
if (system_message_is_system(msg)) {
strncpy(truncated_content, msg->content, sizeof(truncated_content) - 1);
truncated_content[sizeof(truncated_content) - 1] = '\0';
} else if (strcmp(msg->username, "*") == 0) {
@ -95,17 +114,17 @@ static void format_message_colored(const message_t *msg, char *buffer,
}
utf8_truncate(truncated_content, content_width);
if (strcmp(msg->username, "系统") == 0) {
if (system_message_is_system(msg)) {
snprintf(buffer, buf_size,
"\033[90m--> %s\033[0m", truncated_content);
"%s\033[90m--> %s\033[0m", gutter, truncated_content);
} else if (strcmp(msg->username, "*") == 0) {
snprintf(buffer, buf_size,
"\033[90m%s\033[0m \033[3;36m%s\033[0m",
time_str, truncated_content);
"%s\033[90m%s\033[0m \033[3;36m%s\033[0m",
gutter, time_str, truncated_content);
} else {
snprintf(buffer, buf_size,
"\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
time_str, username_color(msg->username),
"%s\033[90m%s\033[0m %s%s\033[0m: %s%s%s",
gutter, time_str, username_color(msg->username),
msg->username, hl_start, truncated_content, hl_end);
}
}
@ -135,8 +154,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 = "匿名聊天室 · SSH";
const char *line3 = "Anonymous chat over SSH";
const char *line2 = i18n_text(client->ui_lang, I18N_WELCOME_SUBTITLE);
const char *line3 = i18n_text(client->ui_lang, I18N_WELCOME_TAGLINE);
int inner_w = utf8_string_width(line1);
int w2 = utf8_string_width(line2);
@ -147,11 +166,13 @@ 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];
int n = snprintf(fallback, sizeof(fallback),
ANSI_CLEAR ANSI_HOME
"TNT %s — anonymous chat over SSH\r\n\r\n",
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);
if (n > 0) client_send(client, fallback, (size_t)n);
return;
}
@ -235,22 +256,25 @@ 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 */
int msg_height = render_height - 3;
if (msg_height < 1) msg_height = 1;
/* 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);
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 > msg_count - msg_height) {
start = msg_count - msg_height;
if (start > latest_scroll_start) {
start = latest_scroll_start;
}
if (start < 0) start = 0;
} else {
/* INSERT mode: show latest */
if (msg_count > msg_height) {
start = msg_count - msg_height;
}
start = latest_scroll_start;
}
int end = start + msg_height;
@ -258,10 +282,11 @@ 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) {
msg_snapshot = calloc(snapshot_count, sizeof(message_t));
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
}
/* Second pass under lock: copy messages */
@ -269,12 +294,22 @@ 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_end = (end <= actual_count) ? end : actual_count;
int actual_start = (start < actual_end) ? start : actual_end;
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_snapshot = actual_end - actual_start;
if (actual_snapshot > 0 && actual_snapshot <= snapshot_count) {
if (actual_snapshot > 0 && actual_snapshot <= snapshot_capacity) {
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;
@ -288,7 +323,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 (!is_join_leave_msg(&msg_snapshot[i])) {
if (!system_message_is_join_leave(&msg_snapshot[i])) {
msg_snapshot[filtered++] = msg_snapshot[i];
}
}
@ -300,15 +335,16 @@ void tui_render_screen(client_t *client) {
/* Title bar — segmented chips on a single line, no full-line reverse.
*
* Segments (left to right):
* Segments (left to right), each followed by a dim middle-dot:
* bold username
* online count
* mode name (colour matches the mode itself: cyan/yellow/magenta)
* mute marker, only when active
* right-aligned hint
*
* `· ` separators are dim grey so the eye groups segments without
* mistaking them for content. */
* 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. */
struct title_chip { const char *value; const char *value_color; };
struct title_chip chips[3];
int chip_count = 0;
@ -318,7 +354,9 @@ void tui_render_screen(client_t *client) {
chip_count++;
char online_buf[32];
snprintf(online_buf, sizeof(online_buf), "在线 %d", online);
snprintf(online_buf, sizeof(online_buf),
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
online);
chips[chip_count].value = online_buf;
chips[chip_count].value_color = "\033[37m";
chip_count++;
@ -335,11 +373,67 @@ 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 < chip_count; i++) {
for (int i = 0; i < show_chips; i++) {
if (i > 0) {
buffer_appendf(left, sizeof(left), &lpos, "\033[2;37m · \033[0m");
left_width += 3;
@ -348,21 +442,35 @@ void tui_render_screen(client_t *client) {
chips[i].value_color, chips[i].value);
left_width += utf8_string_width(chips[i].value);
}
if (client->mute_joins) {
buffer_appendf(left, sizeof(left), &lpos, " \033[2;37m静音\033[0m");
left_width += 4;
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;
}
const char *hint = "? 帮助";
int hint_width = utf8_string_width(hint);
int gap = render_width - left_width - hint_width - 2;
int gap = render_width - left_width - (show_hint ? hint_width + 2 : 1);
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);
}
buffer_appendf(buffer, buf_size, &pos, "\033[2;37m%s\033[0m \033[K\r\n", hint);
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");
}
/* 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
@ -418,36 +526,17 @@ void tui_render_screen(client_t *client) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
/* Status/Input line */
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);
}
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
client_send(client, buffer, pos);
free(buffer);
}
/* Render the input line */
/* 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. */
void tui_render_input(client_t *client, const char *input) {
if (!client || !client->connected) return;
@ -458,7 +547,25 @@ void tui_render_input(client_t *client, const char *input) {
char buffer[2048];
int input_width = utf8_string_width(input);
int avail = rw - 3;
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);
if (avail < 1) avail = 1;
/* Truncate from start if too long */
@ -481,10 +588,20 @@ void tui_render_input(client_t *client, const char *input) {
strncpy(display, p, sizeof(display) - 1);
}
/* Move to input line and clear it, then write input */
/* 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);
}
client_send(client, buffer, strlen(buffer));
}
@ -498,7 +615,7 @@ void tui_render_command_output(client_t *client) {
if (rw < 10) rw = 10;
if (rh < 4) rh = 4;
char buffer[4096];
char buffer[MAX_COMMAND_OUTPUT_LEN + 1024];
size_t pos = 0;
buffer[0] = '\0';
@ -506,40 +623,63 @@ void tui_render_command_output(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = " COMMAND OUTPUT ";
int title_width = strlen(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);
int padding = rw - title_width;
if (padding < 0) padding = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s", title);
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_REVERSE "%s",
title_display);
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[2048];
char output_copy[MAX_COMMAND_OUTPUT_LEN];
strncpy(output_copy, client->command_output, sizeof(output_copy) - 1);
output_copy[sizeof(output_copy) - 1] = '\0';
char *line = strtok(output_copy, "\n");
char *lines[256];
int line_count = 0;
int max_lines = rh - 2;
while (line && line_count < max_lines) {
char truncated[1024];
strncpy(truncated, line, sizeof(truncated) - 1);
truncated[sizeof(truncated) - 1] = '\0';
if (utf8_string_width(truncated) > rw) {
utf8_truncate(truncated, rw);
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 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++) {
char truncated[1024];
utf8_ansi_truncate(lines[i], truncated, sizeof(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);
}
@ -565,8 +705,8 @@ void tui_render_motd(client_t *client) {
size_t pos = 0;
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Top border: ╭─ 公告 / MOTD ──...──╮ */
const char *title = " 公告 / MOTD ";
/* Top border with a localized title chip. */
const char *title = i18n_text(client->ui_lang, I18N_MOTD_TITLE);
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;
@ -617,8 +757,9 @@ void tui_render_motd(client_t *client) {
/* Bottom breathing-room line */
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
/* Bottom border: ╰─ 按任意键继续 ─...─╯ */
const char *footer = " 按任意键继续 / press any key ";
/* Bottom border with a localized continue hint. */
const char *footer = i18n_text(client->ui_lang,
I18N_MOTD_CONTINUE_HINT);
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;
@ -632,104 +773,6 @@ 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;
@ -747,8 +790,8 @@ void tui_render_help(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = " HELP ";
int title_width = strlen(title);
const char *title = i18n_text(client->ui_lang, I18N_HELP_TITLE);
int title_width = utf8_string_width(title);
int padding = rw - title_width;
if (padding < 0) padding = 0;
@ -758,11 +801,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];
strncpy(help_copy, help_text, sizeof(help_copy) - 1);
help_copy[sizeof(help_copy) - 1] = '\0';
size_t help_pos = 0;
help_copy[0] = '\0';
help_text_append_full(help_copy, sizeof(help_copy), &help_pos,
client->ui_lang);
/* Split into lines and display with scrolling */
char *lines[100];
@ -793,7 +836,7 @@ void tui_render_help(client_t *client) {
/* Status line */
buffer_appendf(buffer, sizeof(buffer), &pos,
"-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close",
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
start + 1, max_scroll + 1);
client_send(client, buffer, pos);

54
src/tui_status.c Normal file
View file

@ -0,0 +1,54 @@
#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,6 +96,49 @@ 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;
@ -134,6 +177,72 @@ 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,90 +1,112 @@
#!/bin/bash
# Test anonymous SSH access
# Anonymous SSH access regression tests for TNT
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
echo "Starting TNT server on port $PORT..."
$BIN -p $PORT > /dev/null 2>&1 &
SSH_BASE="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o PubkeyAuthentication=no -p $PORT"
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
}
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"
cat >"$script" <<EOF
set timeout 10
spawn ssh $SSH_BASE -o PreferredAuthentications=password $user@localhost
expect {
-re "(?i)password:" {
send -- "$password\r"
exp_continue
}
"请输入用户名" {
send -- "$display_name\r"
send -- "\003"
expect eof
exit 0
}
timeout { exit 1 }
eof { exit 1 }
}
EOF
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=$!
sleep 2
cleanup() {
kill $SERVER_PID 2>/dev/null
wait 2>/dev/null
}
trap cleanup EXIT
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
fi
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 {
\"password:\" {
send \"anypassword\r\"
expect {
\"请输入用户名\" {
send \"TestUser\r\"
send \"\003\"
exit 0
}
timeout { exit 1 }
}
}
timeout { exit 1 }
}
" 2>&1 | grep -q "请输入用户名"
if [ $? -eq 0 ]; then
echo "✓ Test 1 PASSED: Can connect with any password"
if wait_for_health; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "✗ Test 1 FAILED: Cannot connect with any password"
echo "✗ server failed to start"
sed -n '1,120p' "$STATE_DIR/server.log"
exit 1
fi
echo ""
# 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"
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 "✗ Test 2 FAILED: Cannot connect with empty password"
exit 1
echo "✗ any-password login failed"
FAIL=$((FAIL + 1))
fi
if run_password_test "empty-password" "anonymous" "" ""; then
echo "✓ accepts empty password when no access token is configured"
PASS=$((PASS + 1))
else
echo "✗ empty-password login failed"
FAIL=$((FAIL + 1))
fi
echo ""
echo "Anonymous access test completed."
exit 0
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

@ -5,64 +5,108 @@
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() {
kill $SERVER_PID 2>/dev/null
rm -f test.log
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
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
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
}
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
# Start server
$BIN -p $PORT >test.log 2>&1 &
SERVER_PID=$!
sleep 5
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping basic interactive tests"
exit 0
fi
# Test 1: Server started
if kill -0 $SERVER_PID 2>/dev/null; then
# Start server
"$BIN" -p "$PORT" -d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
# Test 1: Server started and accepts exec health checks
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"
FAIL=$((FAIL + 1))
exit 1
fi
# Test 2: SSH connection
if $TIMEOUT_CMD 5 ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
-o BatchMode=yes -p $PORT localhost exit 2>/dev/null; then
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
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
(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
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 "✓ 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,14 +28,16 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
SSH_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
SSH_COMMON_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes"
SSH_OPTS="$SSH_COMMON_OPTS -p $PORT"
wait_for_health() {
wait_for_health_on_port() {
health_port=$1
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 1
fi
OUT=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
OUT=$(ssh $SSH_COMMON_OPTS -p "$health_port" localhost health 2>/dev/null || true)
[ "$OUT" = "ok" ] && return 0
sleep 1
done
@ -44,11 +46,11 @@ wait_for_health() {
echo "=== TNT Connection Limit Tests ==="
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=1 "$BIN" -p "$PORT" -d "$STATE_DIR" \
TNT_LANG=zh 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; then
if wait_for_health_on_port "$PORT"; then
echo "✓ server started for concurrent limit test"
PASS=$((PASS + 1))
else
@ -97,14 +99,16 @@ wait "$SERVER_PID" 2>/dev/null || true
SERVER_PID=""
RATE_PORT=$((PORT + 1))
SSH_RATE_OPTS="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $RATE_PORT"
SSH_RATE_OPTS="$SSH_COMMON_OPTS -p $RATE_PORT"
TNT_MAX_CONN_PER_IP=10 TNT_MAX_CONN_RATE_PER_IP=2 "$BIN" -p "$RATE_PORT" -d "$STATE_DIR" \
# 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" \
>"$STATE_DIR/rate.log" 2>&1 &
SERVER_PID=$!
sleep 2
if kill -0 "$SERVER_PID" 2>/dev/null; then
if wait_for_health_on_port "$RATE_PORT"; 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="-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
echo "=== TNT Exec Mode Tests ==="
TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
TNT_LANG=zh TNT_RATE_LIMIT=0 $BIN -p "$PORT" -d "$STATE_DIR" >"${STATE_DIR}/server.log" 2>&1 &
SERVER_PID=$!
HEALTH_OUTPUT=""
@ -51,6 +51,17 @@ 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$'
@ -75,6 +86,51 @@ 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"
@ -112,7 +168,7 @@ EOF
expect "$EXPECT_SCRIPT" >"${STATE_DIR}/expect.log" 2>&1 &
INTERACTIVE_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
for _ in 1 2 3 4 5; do
[ -f "$WATCHER_READY" ] && break
sleep 1
done
@ -138,7 +194,7 @@ else
fi
USERS_JSON=""
for _ in 1 2 3 4 5; do
for _ in 1 2 3 4 5 6 7 8 9 10; do
USERS_JSON=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
printf '%s\n' "$USERS_JSON" | grep -q '"watcher"' && break
sleep 1

465
tests/test_interactive_input.sh Executable file
View file

@ -0,0 +1,465 @@
#!/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,6 +11,9 @@ 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"
@ -27,8 +30,11 @@ fail() {
}
cleanup() {
pkill -f "^\.\./tnt" 2>/dev/null || true
sleep 1
for pid in $SERVER_PIDS; do
kill "$pid" 2>/dev/null || true
wait "$pid" 2>/dev/null || true
done
rm -rf "$STATE_ROOT"
}
trap cleanup EXIT
@ -43,23 +49,47 @@ if [ ! -f "$BIN" ]; then
exit 1
fi
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
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
}
# Test 1: 4096-bit RSA Key Generation
print_test "1. RSA 4096-bit Key Generation"
rm -f host_key
$BIN &
PID=$!
sleep 8 # Wait for key generation
kill $PID 2>/dev/null || true
sleep 1
KEY_DIR="$STATE_ROOT/host-key"
if [ -f host_key ]; then
KEY_SIZE=$(ssh-keygen -l -f host_key 2>/dev/null | awk '{print $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 [ "$KEY_SIZE" = "4096" ]; then
pass "RSA key upgraded to 4096 bits (was 2048)"
else
@ -68,9 +98,9 @@ if [ -f host_key ]; then
# Check permissions
if [[ "$OSTYPE" == "darwin"* ]]; then
PERMS=$(stat -f "%OLp" host_key)
PERMS=$(stat -f "%OLp" "$KEY_DIR/host_key")
else
PERMS=$(stat -c "%a" host_key)
PERMS=$(stat -c "%a" "$KEY_DIR/host_key")
fi
if [ "$PERMS" = "600" ]; then
@ -86,33 +116,34 @@ fi
print_test "2. Environment Variable Configuration"
# Test bind address
TNT_BIND_ADDR=127.0.0.1 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
run_server_probe bind-addr env TNT_BIND_ADDR=127.0.0.1 && \
pass "TNT_BIND_ADDR configuration works" || fail "TNT_BIND_ADDR not working"
# Test with access token set (just verify it starts)
TNT_ACCESS_TOKEN="test123" $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
run_server_probe access-token env TNT_ACCESS_TOKEN="test123" && \
pass "TNT_ACCESS_TOKEN configuration accepted" || fail "TNT_ACCESS_TOKEN not working"
# Test max connections configuration
TNT_MAX_CONNECTIONS=10 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
run_server_probe max-connections env TNT_MAX_CONNECTIONS=10 && \
pass "TNT_MAX_CONNECTIONS configuration accepted" || fail "TNT_MAX_CONNECTIONS not working"
# Test per-IP connection rate configuration
TNT_MAX_CONN_RATE_PER_IP=20 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
run_server_probe conn-rate env TNT_MAX_CONN_RATE_PER_IP=20 && \
pass "TNT_MAX_CONN_RATE_PER_IP configuration accepted" || fail "TNT_MAX_CONN_RATE_PER_IP not working"
# Test rate limit toggle
TNT_RATE_LIMIT=0 $TIMEOUT_CMD 3 $BIN 2>&1 | grep -q "TNT chat server" && \
run_server_probe rate-toggle env TNT_RATE_LIMIT=0 && \
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"
rm -f messages.log
MESSAGE_DIR="$STATE_ROOT/message-log"
mkdir -p "$MESSAGE_DIR"
# Create a test message log with potentially dangerous content
cat > messages.log <<EOF
cat > "$MESSAGE_DIR/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
@ -121,15 +152,9 @@ newline
2026-01-22T10:03:00Z|validuser|valid content
EOF
# 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
# 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
pass "Server loads messages from log file"
else
fail "Server message loading issue"
@ -215,21 +240,16 @@ fi
# Test 7: Resource Management (Dynamic Allocation)
print_test "7. Resource Management (Large Log Files)"
rm -f messages.log
LARGE_DIR="$STATE_ROOT/large-log"
mkdir -p "$LARGE_DIR"
# 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" >> messages.log
echo "2026-01-22T$(printf "%02d" $((i/100))):$(printf "%02d" $((i%60))):00Z|user$i|message $i" >> "$LARGE_DIR/messages.log"
done
$BIN &
PID=$!
sleep 4
kill $PID 2>/dev/null || true
sleep 1
# Check if server started successfully with large log
if [ -f messages.log ]; then
LINE_COUNT=$(wc -l < messages.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 [ "$LINE_COUNT" -ge 2000 ]; then
pass "Server handles large message log (${LINE_COUNT} messages)"
else

View file

@ -1,56 +1,142 @@
#!/bin/sh
# Stress test for TNT server
# Usage: ./test_stress.sh [num_clients]
# Lightweight concurrent-client stress test for TNT.
# Usage: ./test_stress.sh [num_clients] [duration_seconds]
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."
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
# Detect timeout command
TIMEOUT_CMD="timeout"
if command -v gtimeout >/dev/null 2>&1; then
TIMEOUT_CMD="gtimeout"
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
fi
echo "Starting TNT server on port $PORT..."
TNT_RATE_LIMIT=0 TNT_MAX_CONN_PER_IP=$CLIENTS $BIN -p $PORT &
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 &
SERVER_PID=$!
sleep 2
if ! kill -0 $SERVER_PID 2>/dev/null; then
echo "Server failed to start"
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"
exit 1
fi
echo "Spawning $CLIENTS clients for ${DURATION}s..."
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"
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
) &
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"
sleep $DURATION
send -- "\003"
expect eof
EOF
expect "$script" >"$log" 2>&1 &
CLIENT_PIDS="$CLIENT_PIDS $!"
done
echo "Running stress test..."
sleep $DURATION
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
echo "Cleaning up..."
kill $SERVER_PID 2>/dev/null
wait
echo "Stress test complete"
if kill -0 $SERVER_PID 2>/dev/null; then
echo "WARNING: tnt process still running"
if [ "$ready_count" = "$CLIENTS" ]; then
echo "✓ all clients reached chat"
PASS=$((PASS + 1))
else
echo "Server shutdown confirmed."
echo "✗ only $ready_count/$CLIENTS clients reached chat"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
exit 0
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"

View file

@ -13,9 +13,19 @@ 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
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
.PHONY: all clean run
@ -30,6 +40,33 @@ 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
@ -39,6 +76,33 @@ 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

@ -0,0 +1,66 @@
/* 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

@ -0,0 +1,142 @@
/* 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

@ -0,0 +1,128 @@
/* 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

@ -0,0 +1,74 @@
/* 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

@ -0,0 +1,110 @@
/* 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;
}

189
tests/unit/test_i18n.c Normal file
View file

@ -0,0 +1,189 @@
/* 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

@ -0,0 +1,77 @@
/* 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,18 +22,6 @@ 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();
@ -48,7 +36,6 @@ 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

@ -0,0 +1,71 @@
/* 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

@ -0,0 +1,77 @@
/* 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,6 +114,22 @@ 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];
@ -228,6 +244,8 @@ 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);

24
tests/unit/text_assert.h Normal file
View file

@ -0,0 +1,24 @@
#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 "April 2026" "TNT 1.0.0" "User Commands"
.TH TNT 1 "2026-05-24" "TNT 1.0.1" "User Commands"
.SH NAME
tnt \- anonymous SSH chat server with Vim\-style TUI
.SH SYNOPSIS
@ -15,7 +15,8 @@ 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
@ -44,7 +45,6 @@ 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 help screen.
to open the full key reference.
.TP
.B COMMAND
Execute commands prefixed with
@ -84,33 +84,45 @@ 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 help screen
? Open full key reference
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 text\fR Send private whisper
:msg \fIuser message\fR Send private message
: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
:help Show available commands
:lang \fIen|zh\fR Switch UI language for this session
:help Show concise manual
:clear Clear command output
:q, :quit, :exit Disconnect
Up/Down Browse command history
@ -144,6 +156,14 @@ 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
@ -225,6 +245,7 @@ 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),