Compare commits

...

74 commits
v1.0.1 ... main

Author SHA1 Message Date
1284d5d052
Merge pull request #54 from m1ngsama/feat/module-foundation
Some checks failed
CI / PR gate (macos-latest) (push) Has been cancelled
CI / PR gate (ubuntu-24.04) (push) Has been cancelled
CI / Extended Linux runtime (push) Has been cancelled
CI / Portable build (alpine-musl) (push) Has been cancelled
CI / Portable build (debian-stable-glibc) (push) Has been cancelled
CI / Portable build (ubuntu-24.04-glibc) (push) Has been cancelled
CI / Package recipe gate (push) Has been cancelled
Add module foundation and optional runtime
2026-06-04 23:02:09 +08:00
2402c70d6f feat: add module foundation runtime
Add validated input buffering, shared JSON helpers, the tnt.module.v1 protocol helpers, and an opt-in external-process module runtime behind TNT_MODULE_PATHS.

Closes #52
2026-06-04 22:48:21 +08:00
2fcfcad613
Merge pull request #53 from m1ngsama/feat/private-message-flow
Improve private message inbox and reply flow
2026-06-04 22:47:20 +08:00
bacfe1ef4b Show empty inbox after clearing 2026-05-31 20:04:22 +08:00
7ff9474a5d Relax private inbox unread count test 2026-05-31 20:02:09 +08:00
d7531f9305 Show unread count in private inbox 2026-05-29 18:06:45 +08:00
845657e3c2 Allow clearing private message inbox 2026-05-29 18:04:34 +08:00
2fca031362 Mark unread private messages in inbox 2026-05-29 18:01:05 +08:00
1f8fb7acf4 Add private message reply command 2026-05-29 17:40:09 +08:00
5ae02054ee Improve terminal UX and private message flow 2026-05-29 17:05:22 +08:00
5ac760d196 Stabilize exec CI and disable scheduled runs
Some checks failed
CI / PR gate (macos-latest) (push) Has been cancelled
CI / PR gate (ubuntu-24.04) (push) Has been cancelled
CI / Extended Linux runtime (push) Has been cancelled
CI / Portable build (alpine-musl) (push) Has been cancelled
CI / Portable build (debian-stable-glibc) (push) Has been cancelled
CI / Portable build (ubuntu-24.04-glibc) (push) Has been cancelled
CI / Package recipe gate (push) Has been cancelled
2026-05-29 15:28:39 +08:00
c04be0b263
Merge pull request #50 from m1ngsama/release/source-archive-dry-run
Some checks are pending
CI / PR gate (macos-latest) (push) Waiting to run
CI / PR gate (ubuntu-24.04) (push) Waiting to run
CI / Extended Linux runtime (push) Waiting to run
CI / Portable build (alpine-musl) (push) Waiting to run
CI / Portable build (debian-stable-glibc) (push) Waiting to run
CI / Portable build (ubuntu-24.04-glibc) (push) Waiting to run
CI / Package recipe gate (push) Waiting to run
Extract release source archive packaging
2026-05-28 16:47:34 +08:00
a51d0fb605 Extract release source archive packaging 2026-05-28 16:40:27 +08:00
885ba9f6f9
Merge pull request #49 from m1ngsama/cicd-multiplatform-governance
Multiplatform CI governance foundation
2026-05-28 15:02:12 +08:00
c7ee5cf0df
Merge pull request #48 from m1ngsama/release/public-readiness-foundation
Public readiness foundation
2026-05-28 14:58:24 +08:00
b6f92968d0 Add multiplatform CI governance gates 2026-05-28 14:48:36 +08:00
d4b260c160 Centralize runtime config defaults 2026-05-28 11:42:31 +08:00
0da5f51e2e Split release and package publish gates 2026-05-28 11:29:25 +08:00
fe7419709e Polish interactive help lifecycle 2026-05-28 11:19:25 +08:00
a800b026b3 Fix tntctl ASAN link flags 2026-05-28 11:07:44 +08:00
f6d5765d81 Refresh development module map 2026-05-28 10:38:27 +08:00
8affea2508 Generate tntctl command list from exec catalog 2026-05-28 10:36:22 +08:00
f2be702a15 Guard active help surfaces 2026-05-28 10:28:02 +08:00
fab8b315a5 Split tntctl local text catalog 2026-05-28 09:40:55 +08:00
4175bd520f Refresh release readiness roadmap 2026-05-28 09:26:27 +08:00
b71aa89a45 Smoke-test installed log maintenance modes 2026-05-28 09:25:12 +08:00
d22d5160d7 Add Debian source package assembly 2026-05-28 09:23:43 +08:00
d893351c5a Add language-keyed i18n string initializers 2026-05-28 09:14:36 +08:00
57d0f931b5 Add Homebrew service metadata 2026-05-28 09:11:25 +08:00
51f264bca2 Add package system user metadata 2026-05-28 09:09:02 +08:00
b23b1ba194 Localize tntctl help and diagnostics 2026-05-28 09:04:24 +08:00
f0499c32f6 Tighten CLI option diagnostics 2026-05-28 08:59:54 +08:00
797ecbb992 Improve TUI pager and search ergonomics 2026-05-27 19:24:55 +08:00
1c451b7722 Add offline message log recovery modes 2026-05-27 10:26:50 +08:00
3252e4583c Split message log record module 2026-05-27 10:08:32 +08:00
5240756f96 Harden message log maintenance tooling 2026-05-27 09:58:56 +08:00
8b55a3d9ab Add persisted message dump command 2026-05-27 09:37:51 +08:00
7b5a683557 Document message log v1 contract 2026-05-27 09:21:59 +08:00
ceffe59234 Harden message log replay parsing 2026-05-27 09:18:23 +08:00
ec507965b2 Centralize client session ownership release 2026-05-27 09:11:07 +08:00
2b43ce6a3e Refresh client ownership developer docs 2026-05-26 20:19:43 +08:00
cbaf02c769 Document stable 1.x binary naming 2026-05-26 20:16:36 +08:00
13b671cc9f Add slow-client backpressure regression 2026-05-26 14:20:07 +08:00
e603a55cb3 Polish live inbox command output 2026-05-26 12:22:33 +08:00
f3e2762f30 Stabilize short soak lifecycle window 2026-05-26 11:26:09 +08:00
d3002dbfde Deepen TUI lifecycle and runtime readiness 2026-05-26 11:15:55 +08:00
33e2dc4f13 Build public release readiness foundation 2026-05-26 09:42:14 +08:00
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
134 changed files with 12560 additions and 2226 deletions

64
.github/ISSUE_TEMPLATE/bug_report.yml vendored Normal file
View file

@ -0,0 +1,64 @@
name: Bug Report
description: Report a reproducible problem in TNT.
labels: ["bug"]
body:
- type: markdown
attributes:
value: |
For security vulnerabilities, do not open a public issue. See SECURITY.md.
- type: input
id: version
attributes:
label: Version
description: Run `tnt --version`, or provide the commit hash.
placeholder: "tnt 1.0.1"
validations:
required: true
- type: dropdown
id: install_method
attributes:
label: Installation Method
options:
- GitHub release binary
- Source build
- install.sh
- Package manager draft
- Other
validations:
required: true
- type: input
id: platform
attributes:
label: Platform
placeholder: "Ubuntu 24.04 x86_64, Arch Linux, macOS 15 arm64"
validations:
required: true
- type: textarea
id: repro
attributes:
label: Reproduction Steps
description: Keep this as small and concrete as possible.
placeholder: |
1. Start TNT with ...
2. Connect with ...
3. Run ...
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected Behavior
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual Behavior
validations:
required: true
- type: textarea
id: logs
attributes:
label: Relevant Logs
description: Remove secrets, access tokens, and private hostnames.
render: text

5
.github/ISSUE_TEMPLATE/config.yml vendored Normal file
View file

@ -0,0 +1,5 @@
blank_issues_enabled: true
contact_links:
- name: Security vulnerability
url: https://github.com/m1ngsama/TNT/security
about: Do not open public issues for vulnerabilities. See SECURITY.md for private reporting paths.

View file

@ -0,0 +1,37 @@
name: Feature Request
description: Suggest a focused improvement to TNT.
labels: ["enhancement"]
body:
- type: textarea
id: problem
attributes:
label: Problem
description: What workflow or limitation should this improve?
validations:
required: true
- type: textarea
id: proposal
attributes:
label: Proposal
description: Describe the smallest useful behavior change.
validations:
required: true
- type: dropdown
id: area
attributes:
label: Area
options:
- Interactive TUI
- SSH exec / scripting
- Packaging / release
- Operations / systemd
- Security
- Documentation
- Other
validations:
required: true
- type: textarea
id: alternatives
attributes:
label: Alternatives Considered
description: Optional. Existing commands, scripts, or workflows you tried.

View file

@ -2,19 +2,32 @@ name: CI
on:
push:
branches: [ main ]
branches: [ main, 'release/**' ]
pull_request:
branches: [ main ]
branches: [ main, 'release/**' ]
workflow_dispatch:
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
build-and-test:
pr-gate:
name: PR gate (${{ matrix.os }})
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, macos-latest]
os: [ubuntu-24.04, macos-latest]
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Install dependencies (Ubuntu)
if: runner.os == 'Linux'
@ -39,11 +52,26 @@ jobs:
- name: Run release preflight
run: make release-check
extended-linux-runtime:
name: Extended Linux runtime
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install dependencies
run: |
sudo apt-get update
sudo apt-get install -y expect libssh-dev valgrind
- name: Run extended release preflight
run: |
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
- name: Check for memory leaks
if: runner.os == 'Linux'
run: |
set -eu
sudo apt-get install -y valgrind
STATE_DIR=$(mktemp -d)
SERVER_LOG="$STATE_DIR/server.log"
VALGRIND_LOG="$STATE_DIR/valgrind.log"
@ -98,3 +126,60 @@ jobs:
cat "$VALGRIND_LOG"
exit 1
fi
portable-container-builds:
name: Portable build (${{ matrix.name }})
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: ubuntu-24.04
strategy:
fail-fast: false
matrix:
include:
- name: debian-stable-glibc
image: debian:stable-slim
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates
- name: ubuntu-24.04-glibc
image: ubuntu:24.04
setup: apt-get update && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends build-essential libssh-dev ca-certificates
- name: alpine-musl
image: alpine:3.20
setup: apk add --no-cache build-base libssh-dev ca-certificates
steps:
- uses: actions/checkout@v6
- name: Build in container
run: |
docker run --rm -v "$PWD:/src:ro" "${{ matrix.image }}" sh -c '
set -eu
${{ matrix.setup }}
mkdir /work
cp -R /src/. /work/
cd /work
make clean
make
./tnt --version
./tntctl --version
'
package-recipe-gate:
name: Package recipe gate
if: github.event_name == 'workflow_dispatch' || github.event_name == 'push'
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Install packaging tools
run: |
sudo apt-get update
sudo apt-get install -y ruby cpio
- name: Validate packaging metadata
run: |
for script in scripts/*.sh; do
sh -n "$script"
done
bash -n packaging/arch/PKGBUILD
ruby -c packaging/homebrew/tnt-chat.rb
scripts/package_debian_source.sh "$RUNNER_TEMP/debian-source"

View file

@ -5,28 +5,42 @@ on:
tags:
- 'v*'
permissions:
contents: read
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: true
jobs:
build:
name: Build ${{ matrix.target }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
include:
- os: ubuntu-24.04
target: linux-amd64
artifact: tnt-linux-amd64
ctl_artifact: tntctl-linux-amd64
- os: ubuntu-24.04-arm
target: linux-arm64
artifact: tnt-linux-arm64
ctl_artifact: tntctl-linux-arm64
- os: macos-15-intel
target: darwin-amd64
artifact: tnt-darwin-amd64
ctl_artifact: tntctl-darwin-amd64
- os: macos-15
target: darwin-arm64
artifact: tnt-darwin-arm64
ctl_artifact: tntctl-darwin-arm64
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Install dependencies (Ubuntu)
if: runner.os == 'Linux'
@ -48,97 +62,191 @@ jobs:
- name: Verify artifact architecture
run: |
file tnt
file tntctl
case "${{ matrix.target }}" in
linux-amd64)
file tnt | grep -E 'ELF 64-bit.*x86-64'
file tntctl | grep -E 'ELF 64-bit.*x86-64'
;;
linux-arm64)
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
;;
darwin-amd64)
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
;;
darwin-arm64)
file tnt | grep -E 'Mach-O 64-bit.*arm64'
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
;;
esac
- name: Rename binary
run: mv tnt ${{ matrix.artifact }}
run: |
mv tnt ${{ matrix.artifact }}
mv tntctl ${{ matrix.ctl_artifact }}
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: ${{ matrix.artifact }}
path: ${{ matrix.artifact }}
path: |
${{ matrix.artifact }}
${{ matrix.ctl_artifact }}
source-archive:
name: Source archive
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Build source archive
run: |
archive=$(scripts/package_source_archive.sh "${GITHUB_REF_NAME}" dist)
sha256sum "$archive"
- name: Upload source archive
uses: actions/upload-artifact@v4
with:
name: tnt-chat-source
path: dist/tnt-chat-v*-source.tar.gz
artifact-gate:
name: Release artifact gate
needs: [build, source-archive]
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v6
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Download build artifacts
uses: actions/download-artifact@v4
with:
path: ./artifacts
- name: Verify and package release assets
run: scripts/package_release_assets.sh ./artifacts ./dist/release-assets
- name: Upload release asset bundle
uses: actions/upload-artifact@v4
with:
name: release-assets
path: dist/release-assets/*
release:
needs: build
needs: [artifact-gate, source-archive]
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
- uses: actions/checkout@v6
- name: Download all artifacts
- name: Verify release tag matches source version
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
- name: Download release asset bundle
uses: actions/download-artifact@v4
with:
path: ./artifacts
name: release-assets
path: ./release-assets
- name: Create checksums
- name: Verify release checksums
run: |
cd artifacts
: > checksums.txt
for artifact in */tnt-*; do
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
done
cd release-assets
sha256sum -c checksums.txt
cat checksums.txt
- name: Create Release
uses: softprops/action-gh-release@v1
with:
files: |
artifacts/*/tnt-*
artifacts/checksums.txt
release-assets/*
body: |
## Installation
Install the libssh runtime before running TNT:
```bash
# Ubuntu/Debian
sudo apt install libssh-4
# Arch
sudo pacman -S libssh
# macOS
brew install libssh
```
Download the binary for your platform:
**Linux AMD64:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-amd64
chmod +x tnt-linux-amd64
chmod +x tntctl-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
```
**Linux ARM64:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-linux-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-linux-arm64
chmod +x tnt-linux-arm64
chmod +x tntctl-linux-arm64
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
```
**macOS Intel:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-amd64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-amd64
chmod +x tnt-darwin-amd64
chmod +x tntctl-darwin-amd64
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
```
**macOS Apple Silicon:**
```bash
wget https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tnt-darwin-arm64
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/tntctl-darwin-arm64
chmod +x tnt-darwin-arm64
chmod +x tntctl-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
```
**Verify checksums:**
```bash
sha256sum -c checksums.txt
curl -LO https://github.com/${{ github.repository }}/releases/download/${{ github.ref_name }}/checksums.txt
# Linux
sha256sum -c checksums.txt --ignore-missing
# macOS
for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do
grep " $f$" checksums.txt | shasum -a 256 -c -
done
```
The release also includes `tnt-chat-${{ github.ref_name }}-source.tar.gz`
for package-manager recipes. Verify it with the same `checksums.txt`
before updating Arch, Homebrew, Debian, Ubuntu, or container package
metadata.
## What's Changed
See [docs/CHANGELOG.md](https://github.com/${{ github.repository }}/blob/${{ github.ref_name }}/docs/CHANGELOG.md)
draft: true

8
.gitignore vendored
View file

@ -1,6 +1,7 @@
*.o
obj/
tnt
tntctl
messages.log
host_key
host_key.pub
@ -8,13 +9,20 @@ host_key.pub
.DS_Store
test.log
*.dSYM/
demos/*.gif
demos/*.mp4
demos/*.webm
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_tntctl_text
tests/unit/test_ratelimit

View file

@ -4,6 +4,7 @@
CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
LDFLAGS = -pthread -lssh
CTL_LDFLAGS =
INCLUDES = -Iinclude
DEPFLAGS = -MMD -MP
@ -20,10 +21,13 @@ SRC_DIR = src
INC_DIR = include
OBJ_DIR = obj
SOURCES = $(wildcard $(SRC_DIR)/*.c)
SOURCES = $(filter-out $(SRC_DIR)/tntctl.c $(SRC_DIR)/tntctl_text.c,$(wildcard $(SRC_DIR)/*.c))
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
DEPS = $(OBJECTS:.o=.d)
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
TARGET = tnt
CTL_TARGET = tntctl
CTL_OBJECTS = $(OBJ_DIR)/tntctl.o $(OBJ_DIR)/tntctl_text.o $(OBJ_DIR)/exec_catalog.o $(OBJ_DIR)/common.o $(OBJ_DIR)/config_defaults.o $(OBJ_DIR)/i18n.o
TARGETS = $(TARGET) $(CTL_TARGET)
PREFIX ?= /usr/local
BINDIR ?= $(PREFIX)/bin
@ -31,14 +35,18 @@ MANDIR ?= $(PREFIX)/share/man
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict asan valgrind check test test-advisory ci-test unit-test integration-test anonymous-access-test connection-limit-test security-test stress-test info
.PHONY: all clean install install-systemd uninstall uninstall-systemd debug release release-check release-check-strict package-publish-check debian-source-package asan valgrind check test test-advisory ci-test unit-test script-test integration-test module-runtime-test anonymous-access-test connection-limit-test security-test stress-test soak-test slow-client-test user-lifecycle-test info
all: $(TARGET)
all: $(TARGETS)
$(TARGET): $(OBJECTS)
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
@echo "Build complete: $(TARGET)"
$(CTL_TARGET): $(CTL_OBJECTS)
$(CC) $(CTL_OBJECTS) -o $@ $(CTL_LDFLAGS)
@echo "Build complete: $(CTL_TARGET)"
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
@ -46,34 +54,40 @@ $(OBJ_DIR):
mkdir -p $(OBJ_DIR)
clean:
rm -rf $(OBJ_DIR) $(TARGET)
rm -rf $(OBJ_DIR) $(TARGETS)
rm -f tests/*.log tests/host_key* tests/messages.log
@echo "Clean complete"
install: $(TARGET)
install: $(TARGETS)
install -d $(DESTDIR)$(BINDIR)
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
install -d $(DESTDIR)$(MANDIR)/man1
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
install-systemd:
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
install -m 644 tnt.service $(DESTDIR)$(SYSTEMD_UNIT_DIR)/
sed 's#^ExecStart=.*#ExecStart=$(BINDIR)/$(TARGET)#' tnt.service > "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
chmod 644 "$(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service"
uninstall:
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
uninstall-systemd:
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
# Development targets
debug: CFLAGS += -g -DDEBUG
debug: clean $(TARGET)
debug: clean $(TARGETS)
release: CFLAGS += -O3 -DNDEBUG
release: clean $(TARGET)
release: clean $(TARGETS)
strip $(TARGET)
strip $(CTL_TARGET)
release-check:
./scripts/release_check.sh
@ -81,9 +95,16 @@ release-check:
release-check-strict:
./scripts/release_check.sh --strict
package-publish-check:
./scripts/package_publish_check.sh
debian-source-package:
./scripts/package_debian_source.sh $${OUT_DIR:-dist/debian-source}
asan: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
asan: LDFLAGS += -fsanitize=address
asan: clean $(TARGET)
asan: CTL_LDFLAGS += -fsanitize=address
asan: clean $(TARGETS)
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
valgrind: debug
@ -95,23 +116,42 @@ check:
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
# Test
test: all unit-test integration-test
test: all unit-test script-test integration-test
test-advisory: all unit-test
@echo "Running integration tests..."
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh || echo "(basic integration tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.sh || echo "(exec mode tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 2)) ./test_interactive_input.sh || echo "(interactive input tests are advisory)"
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh || echo "(module runtime tests are advisory)"
unit-test:
@echo "Running unit tests..."
@$(MAKE) -C tests/unit run
script-test: all
@echo "Running script tests..."
@cd tests && ./test_cli_options.sh
@cd tests && ./test_docs_help_surface.sh
@cd tests && ./test_logrotate.sh
@cd tests && ./test_message_log_tool.sh
@cd tests && ./test_source_archive.sh
@cd tests && ./test_release_artifact_gate.sh
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
@cd tests && PORT=$$(($${PORT:-2222} + 3)) ./test_user_lifecycle.sh
@cd tests && PORT=$$(($${PORT:-2222} + 4)) ./test_mute_joins_view.sh
@cd tests && PORT=$$(($${PORT:-2222} + 5)) ./test_empty_view.sh
@cd tests && PORT=$$(($${PORT:-2222} + 6)) ./test_module_runtime.sh
@cd tests && ./test_tntctl_cli.sh
module-runtime-test: all
@echo "Running module runtime tests..."
@cd tests && PORT=$${PORT:-2222} ./test_module_runtime.sh
anonymous-access-test: all
@echo "Running anonymous access tests..."
@ -129,6 +169,18 @@ stress-test: all
@echo "Running stress tests..."
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30}
soak-test: all
@echo "Running soak tests..."
@cd tests && PORT=$${PORT:-2222} ./test_soak.sh $${DURATION:-8} $${RECONNECTS:-5}
slow-client-test: all
@echo "Running slow-client tests..."
@cd tests && PORT=$${PORT:-2222} ./test_slow_client.sh $${DURATION:-8} $${BURST_CHARS:-1600}
user-lifecycle-test: all
@echo "Running user lifecycle tests..."
@cd tests && PORT=$${PORT:-2222} ./test_user_lifecycle.sh
ci-test:
@$(MAKE) test PORT=$(CI_TEST_PORT)
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))

152
README.md
View file

@ -21,8 +21,9 @@ 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.
The installer verifies downloaded release binaries against `checksums.txt`
before installing them. Older releases may provide only `tnt`; newer releases
also install `tntctl`.
**From source:**
```sh
@ -47,9 +48,12 @@ PORT=3333 tnt # via env var
### Connecting
```sh
ssh -p 2222 chat.m1ng.space
ssh -p 2222 localhost
```
For a deployed server, replace `localhost` with your public host, for example
`chat.example.com`.
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
## Usage
@ -74,7 +78,7 @@ past the limit is ignored with a terminal bell.
```
Opens at latest messages
Stays pinned to latest until you scroll up
i - Return to INSERT mode
i/a/o - Return to INSERT mode
: - Enter COMMAND mode
j/k - Scroll down/up one line
Ctrl+D/U - Scroll half page down/up
@ -82,7 +86,7 @@ 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
```
@ -90,20 +94,33 @@ 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
:reply <text> - Reply to latest private message
:r <text> - Short alias for :reply
:inbox - Show private messages
:inbox clear - Clear private messages for this session
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
:support - Show quick support guide
:lang <en|zh> - Switch UI language for this session
:help - Show available commands
:help - Show concise manual
:clear - Clear command output
:q, :quit, :exit - Disconnect
Up/Down - Browse command history
ESC - Return to NORMAL mode
```
Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox`
shows incoming and sent private messages newest-first; press `r` to refresh it
manually, and it refreshes when a new private message arrives while the inbox
is open. `:reply text` and `:r text` send to the latest private-message peer.
Unread incoming private messages are marked with `*` until `:inbox` renders.
The inbox title shows a transient unread count when new private messages are
present.
`:inbox clear` removes private messages and the reply target for this session.
Private messages are per-session only and are not written to `messages.log`.
**Special messages (INSERT mode)**
```
/me <action> - Send action (e.g. /me waves)
@ -127,12 +144,27 @@ 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
```
The same operational settings can be passed explicitly, which is often
clearer in package scripts and one-off test deployments:
```sh
tnt \
--bind 127.0.0.1 \
--public-host chat.example.com \
--max-connections 100 \
--max-conn-per-ip 10 \
--max-conn-rate-per-ip 30 \
--idle-timeout 3600 \
-p 2222 \
-d /var/lib/tnt
```
**Rate limiting:**
```sh
# Max total connections (default 64)
@ -173,17 +205,55 @@ 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 support
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 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice"
ssh -p 2222 chat.example.com post "/me deploys v2.0"
```
**`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.
See [docs/INTERFACE.md](docs/INTERFACE.md) for the stable exec command
contract, exit statuses, and JSON field definitions.
Source and package-manager installs also include `tntctl`, a thin wrapper
around the same SSH exec interface:
```sh
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
```
### Log Maintenance
Persisted public history is stored as `messages.log` in the TNT state
directory. Private messages and local inbox state are intentionally excluded.
For manual maintenance, archive and compact it with:
```sh
scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
```
The script archives the full log, keeps the last `KEEP_LINES` records in the
active file, compresses the archive when `gzip` is available, and can be
previewed with `--dry-run`.
Installed binaries also include offline checks for the v1 log format:
```sh
tnt --log-check /var/lib/tnt/messages.log
tnt --log-recover /var/lib/tnt/messages.log > messages.recovered.log
```
`--log-check` prints record counts and exits non-zero when invalid records are
found. `--log-recover` writes valid records to stdout and reports skipped
records to stderr; it never edits the source log in place.
## Development
### Building
@ -206,6 +276,9 @@ make anonymous-access-test # verify default anonymous login behavior
make connection-limit-test # verify per-IP concurrency and rate limits
make security-test # run security feature checks
make stress-test # run configurable concurrent-client stress test
make soak-test # run idle/reconnect/control-plane soak test
make slow-client-test # run slow interactive-client backpressure test
make user-lifecycle-test # run a two-user TUI lifecycle test
make ci-test # run the same checks as GitHub Actions
# Individual tests
@ -215,6 +288,9 @@ cd tests
./test_anonymous_access.sh # anonymous access
./test_connection_limits.sh # per-IP concurrency and rate limits
./test_stress.sh # stress test
./test_soak.sh # soak test
./test_slow_client.sh # slow-client backpressure
./test_user_lifecycle.sh # two-user TUI lifecycle
```
**Test coverage:**
@ -222,6 +298,8 @@ cd tests
- Anonymous access: 2 tests
- Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
- Slow-client test: an unread interactive SSH client cannot block health,
stats, post, tail, or server survival checks
### Dependencies
@ -251,14 +329,26 @@ 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
│ ├── tntctl.c # local wrapper around the SSH exec interface
│ ├── tntctl_text.c # tntctl help and option text
│ ├── ssh_server.c # SSH server implementation
│ ├── bootstrap.c # SSH authentication and session bootstrap
│ ├── chat_room.c # chat room logic
│ ├── message.c # message persistence
│ ├── module_protocol.c # external module JSONL protocol helpers
│ ├── module_runtime.c # optional external module supervisor
│ ├── json_text.c # small JSON string helpers
│ ├── input_buffer.c # validated terminal input buffer helpers
│ ├── history_view.c # message viewport and scroll state
│ ├── help_text.c # full-screen and command help content
│ ├── support_text.c # quick support guide content
│ ├── i18n.c # language selection and shared UI text
│ ├── 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
@ -292,7 +382,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
```
@ -319,10 +409,17 @@ Before preparing a release locally:
make release-check
```
Before publishing package recipes, replace placeholder checksums and run:
Longer local preflight can opt into runtime soak and slow-client coverage:
```sh
make release-check-strict
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
```
Before publishing package recipes, download the explicit release source archive,
replace placeholder checksums, and run:
```sh
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
```
## Files
@ -334,6 +431,13 @@ motd.txt - Message of the Day (optional, shown to users on connect)
tnt.service - systemd service unit
```
The persisted chat-history format is documented in
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md). Experimental community modules
should follow the external-process protocol in
[docs/MODULE_PROTOCOL.md](docs/MODULE_PROTOCOL.md). Module-generated content
must always include a plain-text fallback so TNT can keep working on basic
terminal clients and preserve the stable `messages.log` v1 history contract.
### MOTD (Message of the Day)
Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue.
@ -353,6 +457,8 @@ Delete `motd.txt` to disable the MOTD.
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
- [Interface Contract](docs/INTERFACE.md) - Scriptable commands, exit statuses, and JSON fields
- [Module Protocol](docs/MODULE_PROTOCOL.md) - External-process module contract
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
- [Changelog](docs/CHANGELOG.md) - Version history

61
SECURITY.md Normal file
View file

@ -0,0 +1,61 @@
# Security Policy
## Supported Versions
TNT currently supports security fixes for the latest published release and the
current `main` branch.
| Version | Supported |
|---|---|
| latest release | yes |
| `main` | best effort |
| older releases | no |
This policy will become stricter after TNT has a longer stable release history.
## Reporting a Vulnerability
Do not open a public issue for a security vulnerability.
Report privately through one of these paths:
- GitHub private vulnerability reporting, when available on the repository
- email: `contact@m1ng.space`
Include:
- affected version or commit
- operating system and deployment shape
- reproduction steps or proof of concept
- expected impact
- whether the issue is already public
## Response
The maintainer will try to acknowledge valid reports within 7 days. Fixes may
land on `main` before a release is published. For serious issues, the release
notes will mention the security impact after users have a reasonable upgrade
path.
## Scope
In scope:
- remote crashes or memory-safety bugs
- authentication or access-token bypass
- unintended file writes outside `TNT_STATE_DIR`
- privilege escalation in packaged service configuration
- release artifact tampering or installer verification bypass
Out of scope:
- denial of service from an operator intentionally disabling rate limits
- identity spoofing in the documented anonymous-access mode
- vulnerabilities requiring local administrator access to the host
## Release Integrity
Release binaries are published with `checksums.txt`. The installer verifies
the selected binary against that file before installation. Future releases
should add a detached signature for `checksums.txt` before package recipes are
submitted to public registries.

59
demos/tnt-lifecycle.tape Normal file
View file

@ -0,0 +1,59 @@
# TNT lifecycle demo.
#
# Run from the repository root after building:
#
# make
# vhs demos/tnt-lifecycle.tape
#
# The generated GIF is intentionally ignored by git; commit the tape, not the
# rendered artifact.
Output demos/tnt-lifecycle.gif
Require ssh
Set Shell "bash"
Set FontSize 28
Set Width 1200
Set Height 720
Set Theme "Catppuccin Mocha"
Set TypingSpeed 35ms
Set Padding 16
Set WindowBar Colorful
Hide
Type "STATE_DIR=$(mktemp -d /tmp/tnt-vhs.XXXXXX); PORT=22333; TNT_LANG=en ./tnt --bind 127.0.0.1 --public-host demo.local --rate-limit 0 --idle-timeout 0 -p $PORT -d $STATE_DIR >/tmp/tnt-vhs.log 2>&1 & TNT_PID=$!; sleep 1; clear" Enter
Show
Type "ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT demo@127.0.0.1" Enter
Sleep 1s
Type "demo" Enter
Sleep 1s
Type "hello from TNT" Enter
Sleep 800ms
Escape
Sleep 500ms
Type ":help" Enter
Sleep 2s
Type "q"
Sleep 600ms
Type ":last 5" Enter
Sleep 2s
Type "q"
Sleep 600ms
Type ":search TNT" Enter
Sleep 2s
Type "q"
Sleep 600ms
Type "i"
Sleep 300ms
Type "/me ships terminal chat over SSH" Enter
Sleep 2s
Ctrl+C
Sleep 300ms
Ctrl+C
Sleep 1s
Hide
Type "kill $TNT_PID >/dev/null 2>&1; rm -rf $STATE_DIR; clear" Enter
Show

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

View file

@ -1,115 +1,269 @@
CI / RELEASE GUIDE
==================
# CI/CD and Release Governance
AUTOMATIC TESTING
-----------------
Every push or PR automatically runs:
- Build on Ubuntu
- AddressSanitizer build
- `make ci-test` (strict integration, anonymous access, connection limits,
and security feature checks)
- Release/package preflight (`make release-check`)
TNT is a C SSH terminal chat server. The CI/CD system is designed for a public
open-source project: fast feedback on pull requests, broader push and manual
validation across target environments, reproducible release artifacts, and a
manual production deployment boundary.
Check status:
https://github.com/m1ngsama/TNT/actions
Production deployment is intentionally manual. Workflows must not SSH into
production, restart services, upload to OSS buckets, publish package-manager
recipes, or mutate running servers.
Production deployment is intentionally manual. The CI workflow must not SSH
into production or restart services on push.
## Pipeline Layers
### PR Fast Gate
CREATING RELEASES
-----------------
1. Update version metadata:
- include/common.h
- tnt.1
- docs/CHANGELOG.md
- packaging/arch/PKGBUILD
- packaging/homebrew/tnt-chat.rb
Workflow: `.github/workflows/ci.yml`
2. Run the local preflight:
make release-check
Runs on pull requests targeting `main` or `release/**`, and pushes to `main`
or `release/**`:
3. Replace package checksum placeholders and run:
make release-check-strict
- Ubuntu 24.04 and macOS latest builds.
- Normal build with `make`.
- AddressSanitizer build with `make asan`.
- Integration/security gate with `make ci-test`.
- Local release/package preflight with `make release-check`.
4. Create and push tag:
git tag v1.0.1
git push origin v1.0.1
Purpose:
5. GitHub Actions automatically:
- Builds binaries (Linux/macOS, AMD64/ARM64)
- Creates a draft release
- Uploads binaries
- Generates one `checksums.txt` file
- Verifies that artifact architecture matches the asset name
- Keep contributor feedback fast enough for normal review.
- Catch build, integration, packaging metadata, and release-preflight regressions
before merge.
- Avoid slow soak, valgrind, and container matrix jobs on every PR.
6. Review the draft release, smoke-test downloaded assets, then publish it
manually from GitHub.
### Extended Validation
7. Release appears at:
https://github.com/m1ngsama/TNT/releases
Workflow: `.github/workflows/ci.yml`
Runs on `main` or `release/**` pushes and manual dispatch:
DEPLOYING TO SERVERS
--------------------
Deployments are operator-driven:
1. Build and test locally or in a temporary server directory.
2. Back up the installed binary.
3. Install the new binary.
4. Restart the service.
5. Run black-box checks (`health`, `stats --json`, `users --json`,
`support`, and a post/tail smoke test).
- `extended-linux-runtime`
- Runs `RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check`.
- Runs a valgrind smoke test against a temporary server.
- `portable-container-builds`
- Builds in Debian stable glibc.
- Builds in Ubuntu 24.04 glibc.
- Builds in Alpine musl.
- `package-recipe-gate`
- Syntax-checks shell scripts.
- Syntax-checks the Arch `PKGBUILD`.
- Syntax-checks the Homebrew formula.
- Assembles the Debian source tree.
The installer can still be used manually on a server:
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
Purpose:
- Broaden platform confidence without making every PR wait for the full matrix.
- Detect musl/glibc portability issues early.
- Keep package metadata reviewable before public registry submission.
PRODUCTION SETUP (systemd)
---------------------------
1. Install binary (see above)
### Release Artifact Gates
2. Setup service:
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
Workflow: `.github/workflows/release.yml`
3. Check status:
sudo systemctl status tnt
sudo journalctl -u tnt -f
Runs only for SemVer tags matching `vMAJOR.MINOR.PATCH`:
- Verifies the tag matches `TNT_VERSION` through `scripts/check_release_ref.sh`.
- Builds Linux glibc AMD64 and ARM64 binaries.
- Builds macOS Intel and Apple Silicon binaries.
- Verifies binary architecture labels.
- Builds an explicit source archive with `scripts/package_source_archive.sh`:
`tnt-chat-vX.Y.Z-source.tar.gz`.
- Runs `scripts/package_release_assets.sh` to collect release assets, verify
expected asset names, verify binary architecture labels again after artifact
download, verify source archive contents, generate `checksums.txt`, and verify
the checksum file.
- Creates a GitHub draft release only. Publishing stays manual.
The release workflow does not publish package-manager recipes or deploy
production servers.
## Platform Policy
Current release assets:
- Linux glibc AMD64: `tnt-linux-amd64`, `tntctl-linux-amd64`
- Linux glibc ARM64: `tnt-linux-arm64`, `tntctl-linux-arm64`
- macOS Intel: `tnt-darwin-amd64`, `tntctl-darwin-amd64`
- macOS Apple Silicon: `tnt-darwin-arm64`, `tntctl-darwin-arm64`
- Source archive: `tnt-chat-vX.Y.Z-source.tar.gz`
Current CI validation:
- Ubuntu 24.04
- macOS latest
- Debian stable glibc container build
- Ubuntu 24.04 glibc container build
- Alpine musl container build
Package-manager routes:
- Debian/Ubuntu: maintain draft Debian metadata and start with a Launchpad PPA.
- Arch/AUR: maintain `packaging/arch/PKGBUILD` and `.SRCINFO`; submit manually.
- Homebrew/macOS: maintain a tap formula first; Homebrew core can wait for a
stable release cadence and broader adoption.
- Source archive: every public package recipe must pin the final GitHub release
source archive checksum.
- Containers: first stage is Docker-based build validation in CI. Publishing
images should wait until image labels, SBOM, provenance, CVE scanning, and
registry ownership are defined.
## Release Policy
- Use SemVer-style tags: `vMAJOR.MINOR.PATCH`.
- Bump PATCH for compatible bug fixes and release hardening.
- Bump MINOR for new commands, new documented flags, JSON field additions, or
visible user-interface behavior changes.
- Bump MAJOR for incompatible command, config, storage, or package behavior.
- Keep GitHub release publishing manual by using draft releases.
- Keep production deployment manual.
Update version metadata before tagging:
- `include/common.h`
- `tnt.1`
- `tntctl.1`
- `docs/CHANGELOG.md`
- `packaging/arch/PKGBUILD`
- `packaging/arch/.SRCINFO`
- `packaging/homebrew/tnt-chat.rb`
- `packaging/debian/debian/changelog`
Local preflight:
```sh
make release-check
```
Longer local runtime gate:
```sh
RUN_INTEGRATION=1 RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
```
Strict local release gate before pushing a tag:
```sh
git tag vX.Y.Z
make release-check-strict
```
Strict mode requires the local `vX.Y.Z` tag to point at `HEAD` and builds from
the tagged source archive, so it catches files that were left untracked and
would be missing from the release source archive.
After strict checks pass:
```sh
git push origin vX.Y.Z
```
GitHub Actions then builds artifacts and opens a draft release. Review and
publish that draft manually.
## Release Review Checklist
Before publishing a draft release:
- Confirm the Git tag points at the intended commit.
- Confirm the release workflow passed.
- Download every release asset from GitHub, not from the local workspace.
- Verify downloaded assets against `checksums.txt`.
- Run downloaded `tnt --version` and `tntctl --version`.
- Start a temporary server and check:
```sh
ssh -p 2222 server health
ssh -p 2222 server stats --json
ssh -p 2222 server users --json
ssh -p 2222 operator@server post "release smoke"
ssh -p 2222 server "tail -n 1"
```
- Check runtime dynamic links with `ldd` on Linux or `otool -L` on macOS.
- Confirm `libssh` runtime installation is documented for the target install
path.
- Verify the explicit source archive checksum before updating Arch, Homebrew,
Debian, Ubuntu, or container package metadata.
- Run package publication preflight after package recipes pin final source
checksums:
```sh
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
```
## Checksums
Release assets include `checksums.txt`.
Linux:
```sh
sha256sum -c checksums.txt --ignore-missing
```
macOS:
```sh
for f in tnt-* tntctl-* tnt-chat-*-source.tar.gz; do
grep " $f$" checksums.txt | shasum -a 256 -c -
done
```
## Supply Chain Roadmap
Stage 1, implemented now:
- Tag/version gate.
- Draft release, manual publish.
- Binary architecture validation.
- Source archive validation.
- Local source-archive dry-run coverage.
- SHA-256 checksums for every release asset.
- Package recipe checksum preflight.
Stage 2, next:
- Generate an SBOM for release artifacts, preferably CycloneDX or SPDX.
- Attach SBOM files to draft releases.
- Add package lint jobs for Debian source packages, Arch packages, Homebrew
audit, and container image metadata.
Stage 3, later:
- Sign release checksums and/or artifacts with a documented maintainer key or
Sigstore flow.
- Add SLSA provenance for GitHub Actions builds.
- Define container image ownership, tag policy, vulnerability scan policy, and
rollback behavior before publishing images.
## Manual Production Deployment
Deployment remains 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 only the intended `tnt` service.
5. Run black-box checks: `health`, `stats --json`, `users --json`, and one
post/tail smoke test.
UPDATING SERVERS
----------------
Manual binary replacement pattern:
backup=/usr/local/bin/tnt.bak-$(date +%Y%m%d%H%M%S)
sudo cp -a /usr/local/bin/tnt "$backup"
sudo install -m 755 ./tnt /usr/local/bin/tnt
sudo systemctl restart tnt
```sh
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
-------------------
✓ Linux AMD64 (x86_64)
✓ Linux ARM64 (aarch64)
✓ macOS Intel (x86_64)
✓ macOS Apple Silicon (arm64)
## Rollback
Production rollback stays manual:
EXAMPLE WORKFLOW
----------------
# Local development
make && make asan && make release-check
./tnt
1. Keep the previous binary before replacing it.
2. Stop or restart only the intended `tnt` service.
3. Restore the previous binary if smoke checks fail.
4. Re-run `health`, `stats --json`, and one post/tail smoke test.
# Create release
git tag v1.0.1
git push origin v1.0.1
# Wait 5 minutes for builds
# Deploy to production manually after validation
ssh server "sudo install -m 755 /tmp/tnt-build/tnt /usr/local/bin/tnt"
ssh server "sudo systemctl restart tnt"
ssh -p 2222 server health
Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes
the message log format, its release notes must include downgrade behavior.

View file

@ -3,17 +3,22 @@
## Build
```sh
make # normal build
make debug # with symbols
make asan # AddressSanitizer
make release # optimized
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
make soak-test # idle/reconnect/control-plane soak
make slow-client-test # slow interactive-client backpressure
make user-lifecycle-test # two-user TUI lifecycle
```
## Debug
@ -34,10 +39,23 @@ make check
```
main.c → entry point, signal handling
ssh_server.c → SSH protocol, client threads
cli_text.c → startup CLI text
tntctl_text.c → tntctl local help and diagnostics
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
commands.c → COMMAND-mode command dispatch
exec_catalog.c → SSH exec command matching, usage, and argument shape
exec.c → SSH exec command dispatch
tntctl.c → local wrapper around the SSH exec interface
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
```
@ -56,7 +74,7 @@ utf8.c → UTF-8 string handling
## Known Limits
- Max 64 clients (MAX_CLIENTS)
- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
- Max 100 messages in memory (MAX_MESSAGES)
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
- Max 64 bytes username (MAX_USERNAME_LEN)
@ -64,15 +82,20 @@ utf8.c → UTF-8 string handling
## Common Bugs to Avoid
1. Don't use `strtok()` on client data - use `strtok_r()` or copy first
2. Always increment ref_count before using client outside lock
2. Always use `client_addref()` / `client_release()` before using a client
outside `g_room->lock`; never modify `ref_count` directly
3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative)
4. UTF-8 chars are multi-byte - use utf8_* functions
## 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.1 curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
VERSION=vX.Y.Z curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
```
## Manual Install
@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN
```bash
# Linux AMD64
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-linux-amd64
chmod +x tnt-linux-amd64
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
# macOS ARM64 (Apple Silicon)
wget https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
curl -LO https://github.com/m1ngsama/TNT/releases/latest/download/tnt-darwin-arm64
chmod +x tnt-darwin-arm64
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
```
@ -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
@ -89,6 +89,37 @@ Recommended interpretation:
- `TNT_MAX_CONN_RATE_PER_IP`: new connection attempts allowed per IP per 60 seconds
- `TNT_RATE_LIMIT=0`: disables rate-based blocking and auth-failure IP blocking, but not the explicit capacity limits
## Edge Module Production Profile
Some deployments intentionally track the newest TNT builds and newest module
integrations to exercise the full product surface. Treat these as edge
production environments: user-facing, but optimized for fast integration and
fast rollback.
For that profile:
- Deploy TNT and modules as separate artifacts so a module can be disabled
without replacing the core server.
- Keep module permissions explicit and minimal. Do not grant private-message
access unless the module exists for that purpose.
- Keep a known-good TNT binary and module manifest set on disk for immediate
rollback.
- Log module startup failures, invalid JSONL, protocol errors, and timeouts
separately from chat history.
- Prefer plain-text fallbacks for every module-created message, even when the
module also targets richer terminal renderers.
- Before promoting a module, test its manifest and JSONL handshake against the
protocol in `docs/MODULE_PROTOCOL.md`.
Enable modules explicitly with `TNT_MODULE_PATHS`, using a colon-separated
list of module directories:
```bash
TNT_MODULE_PATHS=/opt/tnt-modules/echo-module:/opt/tnt-modules/other-module
```
Unset `TNT_MODULE_PATHS` and restart TNT to return to the plain core server.
## MOTD (Message of the Day)
Place a `motd.txt` file in the state directory. TNT displays it to each user on connect; they press any key to enter the chat.
@ -97,7 +128,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
@ -107,6 +138,34 @@ sudo rm /var/lib/tnt/motd.txt
No restart required — TNT reads the file on each new connection.
## Manual Log Maintenance
TNT stores public chat history in `messages.log` under the state directory.
Use the maintenance script from a source checkout when the service is stopped
or during a quiet maintenance window:
```bash
sudo systemctl stop tnt
sudo scripts/logrotate.sh /var/lib/tnt/messages.log 100 10000
sudo systemctl start tnt
```
The arguments are `LOG_FILE MAX_SIZE_MB KEEP_LINES`. The script archives the
full log, compacts the active log to the last `KEEP_LINES` records, compresses
the archive when `gzip` is available, and keeps the newest five archives by
default. Use `--dry-run` to preview actions, or `--keep-archives N` to change
archive retention.
Before replacing a suspicious log, inspect and recover it offline:
```bash
tnt --log-check /var/lib/tnt/messages.log
tnt --log-recover /var/lib/tnt/messages.log > /var/lib/tnt/messages.recovered.log
```
`--log-recover` writes valid records to stdout and reports skipped records to
stderr. Review the recovered file before replacing the active log.
## Firewall
```bash

View file

@ -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)
---
@ -54,10 +55,13 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
### Key Design Principles
1. **Fixed-size buffers** - No dynamic allocation in hot paths
2. **Reader-writer locks** - Multiple readers, single writer
3. **Reference counting** - Prevent use-after-free
4. **Ring buffer** - Fixed-size message history (last 100 messages)
1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded
2. **Reader-writer locks** - Multiple readers, single writer for room state
3. **Per-client output ownership** - Each interactive session writes only to
its own SSH channel
4. **Reference counting** - Keep client objects alive across callbacks and
cross-thread lookups
5. **Ring buffer** - Fixed-size in-memory message history (last 100 messages)
---
@ -68,19 +72,32 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
```
src/
├── main.c - CLI entry point and startup option parsing
├── cli_text.c - Server CLI help and option diagnostics
├── ssh_server.c - SSH listener setup and connection accept loop
├── bootstrap.c - SSH authentication/session bootstrap
├── input.c - Interactive session loop and key handling
├── 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
├── json_text.c - JSON string escaping and top-level string extraction
├── input_buffer.c - Validated INSERT/COMMAND/paste buffer helpers
├── module_protocol.c - External module JSONL protocol helpers
├── module_runtime.c - Optional external module supervisor
├── tntctl.c - Local wrapper around the SSH exec interface
├── tntctl_text.c - tntctl local help and diagnostics
├── chat_room.c - Chat room state, message ring, and update sequence
├── message.c - Message persistence (RFC3339 format)
├── message_log.c - messages.log v1 parsing and formatting
├── message_log_tool.c - Offline messages.log check/recover CLI
├── history_view.c - NORMAL-mode scroll window rules
├── tui.c - Terminal UI rendering (ANSI escape codes)
├── tui_status.c - Mode/status/input-line rendering
├── i18n.c - Language selection and shared UI text
├── help_text.c - Full-screen and command help text
├── support_text.c - Quick support guide text
├── 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
@ -95,11 +112,24 @@ include/
├── bootstrap.h - SSH session bootstrap interface
├── chat_room.h - Chat room interface
├── message.h - Message structure and persistence
├── message_log.h - messages.log v1 parser/formatter interface
├── message_log_tool.h - Offline log check/recover interface
├── command_catalog.h - COMMAND-mode command metadata interface
├── exec_catalog.h - SSH exec command metadata interface
├── json_text.h - JSON text helper interface
├── input_buffer.h - Terminal input buffer helper interface
├── module_protocol.h - External module protocol helper interface
├── module_runtime.h - External module supervisor interface
├── cli_text.h - Server CLI text interface
├── tntctl_text.h - tntctl text interface
├── history_view.h - Scroll-state helpers
├── tui.h - TUI rendering functions
├── tui_status.h - TUI status/input-line rendering interface
├── i18n.h - Language and shared text IDs
├── help_text.h - Help text interface
├── support_text.h - Support guide text interface
├── help_text.h - Key reference text interface
├── manual.h - Concise manual panel interface
├── manual_text.h - Concise manual text interface
├── system_message.h - Localized system message builders
├── ratelimit.h - Connection limit interface
└── utf8.h - UTF-8 utilities
```
@ -112,12 +142,16 @@ typedef struct client {
ssh_session session;
ssh_channel channel;
char username[MAX_USERNAME_LEN];
int width, height; // Terminal dimensions
_Atomic int width, height; // Terminal dimensions
client_mode_t mode; // INSERT/NORMAL/COMMAND
int scroll_pos;
bool connected;
atomic_bool connected;
char *outbox; // Bounded queued interactive output
size_t outbox_len, outbox_pos;
int ref_count; // Reference counting
pthread_mutex_t ref_lock;
pthread_mutex_t io_lock; // Own SSH channel writes only
bool channel_callback_ref; // Ref held while callbacks are installed
} client_t;
```
@ -127,6 +161,7 @@ typedef struct {
pthread_rwlock_t lock; // Reader-writer lock
struct client **clients; // Dynamic array
int client_count;
uint64_t update_seq; // Bumped when message history changes
message_t *messages; // Ring buffer
int message_count;
} chat_room_t;
@ -182,6 +217,9 @@ make anonymous-access-test # Verify default anonymous login behavior
make connection-limit-test # Verify per-IP concurrency and rate limits
make security-test # Run security feature checks
make stress-test # Run configurable concurrent-client stress test
make soak-test # Run idle/reconnect/control-plane soak test
make slow-client-test # Run slow interactive-client backpressure test
make user-lifecycle-test # Run a two-user TUI lifecycle test
make ci-test # Run the same checks as GitHub Actions
# Individual tests
@ -190,6 +228,9 @@ cd tests
./test_security_features.sh # Security checks
./test_anonymous_access.sh # Anonymous access
./test_stress.sh # Concurrent connections
./test_soak.sh # Idle/reconnect soak
./test_slow_client.sh # Slow-client backpressure
./test_user_lifecycle.sh # Two-user TUI lifecycle
```
### Test Coverage
@ -198,6 +239,10 @@ cd tests
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
- **Anonymous**: Passwordless access, any username
- **Stress**: 10 concurrent clients for 30 seconds
- **Soak**: idle session, reconnect churn, health/stats/users/post/tail
- **Slow client**: unread interactive SSH client cannot block control paths
- **Lifecycle**: two-user TUI story covering help, history, search, private
messages, nickname, action messages, and persistence boundaries
---
@ -237,41 +282,48 @@ while ((!ctx->auth_success || ctx->channel == NULL || !ctx->channel_ready) && !t
### 2. Chat Room (chat_room.c)
**Thread-safe broadcasting:**
**Thread-safe message publication:**
```c
void room_broadcast(chat_room_t *room, const message_t *msg) {
pthread_rwlock_wrlock(&room->lock);
/* Copy client list with ref counting */
client_t **clients_copy = calloc(...);
for (int i = 0; i < count; i++) {
clients_copy[i]->ref_count++;
}
room_add_message(room, msg);
room->update_seq++;
pthread_rwlock_unlock(&room->lock); // Release lock early
/* Render outside lock (avoid deadlock) */
for (int i = 0; i < count; i++) {
tui_render_screen(clients_copy[i]);
client_release(clients_copy[i]);
}
pthread_rwlock_unlock(&room->lock);
}
```
**Why this works:**
- Copy client list while holding write lock
- Increment reference counts
- Release lock BEFORE rendering
- Render to all clients outside lock
- Decrement reference counts (may free clients)
- Broadcast updates shared room state only; it does not render or write to
any SSH channel.
- Each interactive session tracks `room_get_update_seq()` in its own
`input_run_session()` loop.
- When the sequence changes, the client renders and flushes its own output.
- This keeps slow SSH windows local to that client and prevents one recipient
from blocking a sender or the whole room.
- Cross-client lookups, such as mentions and private messages, must call
`client_addref()` before using a client pointer outside `g_room->lock`, then
`client_release()` when done. Do not increment `ref_count` directly.
- Session callback lifetime is owned by `client.c`: `client_install_channel_callbacks()`
takes the callback ref, and `client_release_session()` removes callbacks and
releases both the callback ref and the session main ref.
### 3. Message Persistence (message.c)
See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record
contract.
**Log format:**
```
2024-01-13T10:30:45Z|username|message content
```
Log replay and search use the same strict parser. A record is accepted only
when it has exactly three fields, a strict UTC RFC3339 timestamp, valid UTF-8
username/content, bounded field lengths, and a trailing newline. Unterminated
last lines are treated as partial writes and skipped.
**Optimized loading** (backward scan):
```c
/* Scan backwards from file end */
@ -351,29 +403,35 @@ void utf8_remove_last_word(char *str) {
### Adding a New Command
1. **For interactive COMMAND mode, add to `commands_dispatch()` in `src/commands.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. **For SSH exec mode, add the stable command path in `src/exec.c` if it should work non-interactively.**
2. **Add interactive behavior in `src/commands.c` by switching on the command ID.**
3. **Move user-facing strings through `src/i18n.c` when they need localization or are reused.**
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. **Update help text in `src/help_text.c`:**
```c
"AVAILABLE COMMANDS:\n"
" newcmd - Description of new command\n"
```
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. **Add tests in the narrowest target:**
5. **Update user help surfaces through their catalogs. Avoid duplicating command rows by hand.**
6. **Add tests in the narrowest target:**
```sh
tests/test_exec_mode.sh # exec command behavior
tests/test_interactive_input.sh # COMMAND-mode/TUI behavior
tests/test_user_lifecycle.sh # end-to-end two-user TUI behavior
tests/test_slow_client.sh # slow SSH reader/backpressure behavior
tests/unit/test_i18n.c # localized shared text
tests/unit/test_command_catalog.c # interactive command metadata
tests/unit/test_exec_catalog.c # exec command help metadata
tests/unit/test_tntctl_text.c # tntctl local help/diagnostic text
tests/test_docs_help_surface.sh # active help/manual drift checks
```
### Adding a New Keybinding
@ -387,12 +445,82 @@ case MODE_INSERT:
}
```
2. **Update `src/help_text.c` and status hints in `src/i18n.c` / `src/tui_status.c` if the binding is user-visible.**
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>`.
- Use `I18N_STRING(en, zh)` for ordinary two-language entries. Use
`I18N_STRING_MAP(I18N_EN(...), I18N_ZH(...))` when an entry needs
language-keyed initialization so future languages can be added without
changing every existing initializer.
- Every new user-facing string needs tests for at least English fallback
and Chinese output while this project has two UI languages.
### 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`, and localized strings can now be initialized by language key.
Adding many more languages should still move toward external catalog-like
storage instead of adding ad hoc branches for every locale.
Relevant conventions:
- 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,236 @@
# 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 localhost
```
```bash
# 更换端口
For a deployed server, replace `localhost` with your public host.
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
/ search message history
G or End jump to latest messages
Up/Down recall sent messages in INSERT mode
Tab complete @mention in INSERT mode
Ctrl+C disconnect from NORMAL mode
```
Common commands:
```text
:help concise manual
:users online users
:nick <name> change nickname
:msg <user> <message> send private message
:reply <message> reply to latest private message
:inbox show private messages
:inbox clear clear 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 localhost
```
MIT License - 自由使用、修改、分发
部署到公网后,将 `localhost` 替换为你的域名。
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
### 常用操作
```text
Enter 发送消息
Esc 进入 NORMAL 模式
i 回到 INSERT 模式
: 输入命令
? 查看完整按键参考
/ 搜索消息历史
G 或 End 回到最新消息
Up/Down 在 INSERT 模式调出已发送消息
Tab 在 INSERT 模式补全 @mention
:help 查看简明手册
:lang en|zh 切换界面语言
:q 断开连接
```
### 常用配置
```sh
TNT_ACCESS_TOKEN="change-this-password" tnt
TNT_STATE_DIR=/var/lib/tnt tnt
TNT_LANG=zh tnt
```

194
docs/INTERFACE.md Normal file
View file

@ -0,0 +1,194 @@
# Interface Contract
This document defines the public surfaces that scripts, package tests, and
operators may rely on.
For 1.x, the public binary names are stable:
- `tnt` is the server process and daemon entrypoint.
- `tntctl` is a thin local wrapper around the SSH exec interface.
TNT will not introduce a separate `tntd` binary during 1.x. If the project
ever splits the server into `tntd`, that change must ship with a major-version
compatibility plan, package migration notes, and a transition period for the
`tnt` command.
## Stability Scope
Stable:
- public binary names for 1.x: `tnt` and `tntctl`
- documented command-line flags in `tnt(1)`
- documented environment variables in `tnt(1)`
- SSH exec command names and argument shapes listed below
- SSH exec exit statuses
- JSON field names and value types for documented `--json` commands
- `messages.log` v1 record format documented in
[MESSAGE_LOG.md](MESSAGE_LOG.md)
Not yet stable:
- exact human-readable diagnostic wording
- interactive TUI layout
- future storage migration tooling
- internal module names and helper functions
## Exit Status
TNT process startup and SSH exec commands use these exit statuses:
| Code | Name | Meaning |
|---:|---|---|
| 0 | `TNT_EXIT_OK` | Success |
| 1 | `TNT_EXIT_ERROR` | Runtime error, I/O error, allocation failure, persistence failure |
| 64 | `TNT_EXIT_USAGE` | Unknown command, invalid option, invalid argument shape |
| 69 | `TNT_EXIT_UNAVAILABLE` | Local `tntctl` SSH transport unavailable |
| 78 | `TNT_EXIT_CONFIG` | Reserved for future local `tntctl` configuration errors |
`64` follows the common `sysexits(3)` usage-error convention.
## SSH Exec Commands
Exec commands are run through a standard SSH client:
```sh
ssh -p 2222 chat.example.com health
ssh -p 2222 chat.example.com stats --json
ssh -p 2222 chat.example.com users --json
ssh -p 2222 chat.example.com "tail -n 20"
ssh -p 2222 chat.example.com "dump -n 100"
ssh -p 2222 operator@chat.example.com post "service notice"
```
The same commands can be run through `tntctl`:
```sh
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users
```
### `health`
Prints:
```text
ok
```
Exit status: `0` when the daemon can accept and handle exec requests.
### `stats [--json]`
Text output is line-oriented key/value data:
```text
status ok
online_users 0
message_count 0
client_capacity 64
active_connections 1
uptime_seconds 12
```
JSON output:
```json
{
"status": "ok",
"online_users": 0,
"message_count": 0,
"client_capacity": 64,
"active_connections": 1,
"uptime_seconds": 12
}
```
Field names and scalar types are stable. New fields may be added in a minor
release.
### `users [--json]`
Text output prints one username per line.
JSON output is an array of strings:
```json
["alice", "bob"]
```
### `tail [N]` / `tail -n N`
Prints recent in-memory messages as tab-separated lines:
```text
2026-05-25T12:00:00Z alice hello
```
The current upper bound is `MAX_MESSAGES`. This command reads the live
in-memory room buffer, not the full persisted log.
### `dump [N]` / `dump -n N`
Exports valid persisted `messages.log` v1 records in chronological order:
```text
2026-05-25T12:00:00Z|alice|hello
```
Without `N`, `dump` exports all valid persisted records. With `N`, it exports
the last `N` valid persisted records. Malformed, invalid UTF-8, oversized, or
truncated records are skipped by the same strict parser used for replay and
search.
This command reads the on-disk log, not the live in-memory room buffer. A
missing log produces empty output and exit status `0`.
### `post MESSAGE`
Posts a message as the SSH login name and prints:
```text
posted
```
In anonymous-access mode, the SSH login name is not authenticated. Operators
should configure `TNT_ACCESS_TOKEN` before relying on exec-post identity.
## Interactive Private Messages
`:msg user message` and its `:w` alias deliver private messages only to online
interactive clients. `:reply message` and its `:r` alias send to the latest
private-message peer in the current session. Private messages are not
persisted to `messages.log` and are not included in exec `tail`, exec `dump`,
`:last`, or `:search`.
Each participant keeps a bounded in-memory `:inbox` for the current session.
Recipients see incoming private messages; senders see local sent-message
copies. Unread incoming messages are marked with `*` until `:inbox` renders.
`:inbox` displays newest messages first, shows a transient unread count, can
be refreshed with `r`, and refreshes automatically while open when a new
private message arrives.
`:inbox clear` removes the current session's private messages, unread count,
and reply target.
### `help`
Prints a localized human-readable command summary. It is intended for people,
not parsers.
## `tntctl`
`tntctl` preserves the command names, exit statuses, and JSON schemas above.
It invokes the local `ssh(1)` client without a local shell. OpenSSH transport
failures are mapped to `TNT_EXIT_UNAVAILABLE` (`69`); remote TNT exec statuses
are otherwise returned unchanged.
The wrapper intentionally does not accept arbitrary SSH options or a password
option. It exposes only bounded host-key options:
`--host-key-checking yes|accept-new|no` and `--known-hosts FILE`. Use normal
SSH configuration for jump hosts, identity files, and authentication. If the
server requires `TNT_ACCESS_TOKEN`, enter it through the normal SSH password
prompt or use an SSH setup appropriate for the deployment.

109
docs/MESSAGE_LOG.md Normal file
View file

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

143
docs/MODULE_PROTOCOL.md Normal file
View file

@ -0,0 +1,143 @@
# TNT Module Protocol
This document defines the compatibility contract for external TNT modules.
The first implementation target is external-process modules that exchange
JSON Lines with TNT over stdin/stdout. Keeping modules out of the server
address space makes the community extension surface easier to audit, restart,
rate-limit, and disable.
The protocol is intentionally separate from `messages.log` v1. TNT 1.x keeps
the persisted public history format stable. Module-generated content must
always provide a plain-text fallback that can be stored and rendered by older
or less capable clients.
TNT core should stay conservative: text-first, terminal-compatible, and easy
to deploy over plain SSH. Modules are the extension surface for personalized
workflow features, rich rendering, terminal-specific visuals, and other
experience experiments. Integrating a module with TNT must not make plain
terminal users lose the basic chat path.
## Compatibility
- Protocol version: `tnt.module.v1`
- Transport: UTF-8 JSON Lines
- Framing: one complete JSON object per line
- Direction: TNT sends events to module stdin; modules write responses to
stdout
- Error stream: modules should write diagnostics to stderr
- License: module protocol examples and official community modules should use
the same license as TNT unless a module states stricter terms
Modules are disabled unless `TNT_MODULE_PATHS` is set. The value is a
colon-separated list of module directories, each containing `tnt-module.json`
and the declared executable entrypoint.
TNT may add optional fields to existing messages. Modules must ignore unknown
fields. TNT must ignore unknown response fields unless the response type
explicitly requires them.
## Manifest
Each module directory should include `tnt-module.json`:
```json
{
"protocol": "tnt.module.v1",
"name": "echo",
"version": "0.1.0",
"description": "Echoes public messages for testing",
"entrypoint": "./echo-module.sh",
"permissions": ["message:read", "message:create"],
"events": ["message.created"]
}
```
Required fields:
- `protocol`: protocol compatibility string
- `name`: stable module id, lowercase ASCII, `a-z`, `0-9`, and `-`
- `version`: module version
- `entrypoint`: executable path relative to the manifest directory
- `permissions`: explicit capabilities requested by the module
- `events`: event names the module wants to receive
## Handshake
TNT starts a module process and writes a handshake event:
```json
{"type":"handshake","protocol":"tnt.module.v1","server":{"name":"tnt","version":"1.0.1"}}
```
The module should answer:
```json
{"type":"handshake.ok","protocol":"tnt.module.v1","module":{"name":"echo","version":"0.1.0"}}
```
If the module cannot run, it should answer:
```json
{"type":"error","code":"unsupported_protocol","message":"requires tnt.module.v2"}
```
## Events
Message-created event:
```json
{
"type": "message.created",
"message": {
"id": "local-00000001",
"timestamp": "2026-06-04T12:00:00Z",
"sender": "alice",
"kind": "text",
"plain_text": "hello",
"metadata": {}
}
}
```
The `plain_text` field is mandatory for every user-visible message. Future
rich content, images, and terminal-specific render hints must be represented
as optional metadata or attachment records with a plain-text fallback.
## Responses
Create a public message:
```json
{"type":"message.create","plain_text":"echo: hello"}
```
No-op acknowledgement:
```json
{"type":"event.ok"}
```
Module error:
```json
{"type":"error","code":"bad_request","message":"missing plain_text"}
```
## Security Rules
- Modules are untrusted external processes.
- TNT should enforce per-module permissions before delivering events or
accepting responses.
- TNT should cap stdout line length, startup time, event handling time, and
total queued output.
- TNT should disable a module after repeated invalid JSON, protocol errors, or
timeout failures.
- Modules must never receive private messages unless they request and are
granted an explicit private-message permission.
## Rendering Rules
Every module-created message must be renderable as plain text. Terminal image
protocols such as Kitty graphics or Sixel are optional renderer capabilities,
not message requirements. A module may provide attachment metadata later, but
TNT must be able to fall back to a link, filename, digest, or short label.

View file

@ -15,6 +15,9 @@ TEST
make connection-limit-test per-IP concurrency/rate-limit checks
make security-test security feature checks
make stress-test concurrent-client stress test
make soak-test idle/reconnect/control-plane soak test
make slow-client-test slow interactive-client backpressure test
make user-lifecycle-test two-user TUI lifecycle test
make ci-test same checks as GitHub Actions
DEBUG
@ -25,13 +28,17 @@ 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
reply <text> reply to latest private message
r <text> alias for reply
inbox show private messages, newest first
inbox clear clear private messages for this session
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
support quick support guide
help show all commands
help concise manual
lang [en|zh] show or switch UI language
clear clear output
q / quit / exit disconnect
@ -41,20 +48,49 @@ INSERT MODE
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
insert aliases i/a/o enter INSERT mode from NORMAL
EXEC COMMANDS
health print service health
stats [--json] print room statistics
users [--json] list online users
tail [N] / tail -n N recent in-memory room messages
dump [N] / dump -n N persisted messages.log v1 records
post <message> post as the SSH login name
MAINTENANCE
scripts/logrotate.sh LOG_FILE MAX_SIZE_MB KEEP_LINES
archive and compact messages.log
scripts/logrotate.sh --dry-run ...
preview log maintenance actions
tnt --log-check LOG_FILE audit messages.log v1 records
tnt --log-recover LOG_FILE > OUT
write valid records to stdout
STRUCTURE
src/main.c entry, signals
src/cli_text.c startup CLI text
src/tntctl_text.c tntctl local help and diagnostics
src/command_catalog.c command metadata, usage, argument shape
src/ssh_server.c SSH listener and server setup
src/bootstrap.c SSH auth/session bootstrap
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/json_text.c JSON string escape/extract helpers
src/input_buffer.c validated INSERT/COMMAND/paste buffer helpers
src/message.c persistence, search
src/message_log.c messages.log v1 parsing and formatting
src/message_log_tool.c offline messages.log check/recover CLI
src/module_protocol.c external module JSONL protocol helpers
src/module_runtime.c optional external module supervisor
src/history_view.c message viewport / scroll state
src/help_text.c full-screen and command help text
src/support_text.c quick support guide content
src/i18n.c language selection and shared text
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
@ -66,7 +102,7 @@ LIMITS
1024 bytes/message
FILES
messages.log chat log (RFC3339)
messages.log public chat log (RFC3339; excludes private messages)
host_key SSH key (auto-generated)
motd.txt message of the day (optional)
CHANGELOG.md version history

View file

@ -17,65 +17,100 @@ This roadmap is intentionally strict. Each stage should leave the project easier
Goal: make TNT predictable for operators, scripts, and package maintainers.
- split the current surface into `tntd` (daemon) and `tntctl` (control client)
- keep SSH exec support, but treat it as a transport for stable commands rather than the primary API shape
- define stable subcommands and exit codes for:
- ✅ introduce `tntctl` as a thin control client over the stable SSH exec surface
- keep SSH exec support, but treat it as a transport for stable commands rather
than an ad hoc command surface
- ✅ define stable subcommands and exit codes for:
- `health`
- `stats`
- `users`
- `tail`
- `dump`
- `post`
- support text and JSON output modes where machine use is likely
- normalize command parsing, help text, and error reporting
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
- add a man page for `tntd` and `tntctl`
- ✅ support text and JSON output modes where machine use is likely
- ✅ normalize command parsing, help text, and error reporting
- ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a
major-version compatibility plan
- ✅ add `--bind`, `--port`, `--state-dir`, `--public-host`,
`--max-connections`, and related long options consistently
- ✅ add man pages for `tnt` and `tntctl`
## Stage 2: Runtime Model
Goal: make long-running operation boring and reliable.
- move client state to a clearer ownership model with one release path
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery
- add bounded outbound queues so slow clients cannot stall other users
- ✅ move session callback ownership into `client.c` and release sessions
through one `client_release_session()` path
- ✅ remove cross-client SSH channel writes from mention and private-message
notifications
- continue replacing ad hoc cross-thread UI mutation with per-client event
delivery where new features need cross-client notifications
- ✅ add bounded outbound queues so closed SSH windows cannot immediately stall
interactive output writes
- separate accept, session bootstrap, interactive I/O, and persistence concerns more cleanly
- make room/client capacity fully runtime-configurable with no hidden compile-time ceiling
- document hard guarantees and soft limits
- ✅ make room/client capacity fully runtime-configurable with no hidden
compile-time ceiling
- ✅ document hard guarantees and soft limits
## Stage 3: Data and Persistence
Goal: make stored history durable, inspectable, and recoverable.
- formalize the message log format and version it
- keep timestamps in a timezone-safe format throughout write and replay
- validate persisted UTF-8 and record structure before replay
- add log rotation and compaction tooling
- provide an offline inspection/export command
- define recovery behavior for truncated or partially corrupted logs
- formalize the message log v1 format
- ✅ keep persisted timestamps in UTC throughout write and replay
- validate persisted UTF-8 and record structure before replay/search
- ✅ provide an inspection/export command for persisted records
- ✅ add log rotation and compaction tooling
- ✅ define broader recovery tooling for truncated or partially corrupted logs
## Stage 4: Interactive UX
Goal: keep the interface efficient for terminal users without sacrificing simplicity.
- keep the current modal editing model, but make its behavior precise and documented
- support resize, cursor movement, command history, and predictable paste behavior
- ✅ keep the current modal editing model precise and documented
- ✅ support resize, command history, pager navigation, and predictable paste
behavior
- add in-line cursor movement/editing only if it can stay simple and testable
- add useful chat commands with clear semantics:
- ✅ `:nick` / `:name` — nickname change with broadcast
- ✅ `/me` — action messages
- ✅ `:last N` — show last N messages from disk history
- ✅ `:search <keyword>` — case-insensitive full-text search
- ✅ `:mute-joins` — per-client join/leave notification toggle
- improve discoverability of NORMAL and COMMAND mode actions
- make status lines and help output concise enough for small terminals
- ✅ improve discoverability of NORMAL and COMMAND mode actions
- ✅ make status lines and help output concise enough for small terminals
## Stage 4.5: Module Foundation
Goal: let community features plug into TNT without coupling every user request
to the core server binary.
- keep TNT core basic and broadly compatible; route personalized workflows,
rich visuals, and terminal-specific experience upgrades through modules
- define the external-process module protocol before loading any third-party
code into production rooms
- keep module messages compatible with plain terminal clients by requiring
plain-text fallbacks for rich content and attachments
- treat terminal image protocols as optional renderer capabilities, not as the
core message format
- prefer JSON Lines over stdin/stdout for early modules so TNT can supervise,
restart, rate-limit, and disable modules independently
- keep module permissions explicit: message read/create, command registration,
private-message access, and future attachment access must be separate grants
- publish official examples in a companion community repository that tracks
TNT protocol versions and license terms
## Stage 5: Operations and Security
Goal: make public deployment manageable.
- provide clear distinction between concurrent session limits and connection-rate limits
- ✅ provide clear distinction between concurrent session limits and
connection-rate limits
- add admin-only controls for read-only mode, mute, and ban
- expose a minimal health and stats surface suitable for monitoring
- expose a minimal health and stats surface suitable for monitoring
- support systemd-friendly readiness and watchdog behavior
- document recommended production defaults for public, private, and localhost-only deployments
- ✅ document recommended production defaults for public, private, and
localhost-only deployments
- tighten CI around authentication, limits, and restart behavior
## Stage 6: Release Quality
@ -84,7 +119,13 @@ Goal: make regressions harder to introduce.
- expand CI coverage across Linux and macOS for build and smoke tests
- add sanitizer jobs and targeted fuzzing for UTF-8, log parsing, and command parsing
- add soak tests for long-lived sessions and slow-client behavior
- ✅ add a configurable soak test for idle sessions, reconnects, and control
interface availability
- ✅ add deeper slow-client coverage with a deliberately backpressured SSH
client
- ✅ verify staged package installs, systemd unit paths, packaging metadata,
Debian source assembly, Homebrew service metadata, and installed log
maintenance modes in release preflight
- keep deployment and test docs aligned with actual runtime behavior
- require every user-visible interface change to update docs and tests in the same change set
@ -92,8 +133,9 @@ Goal: make regressions harder to introduce.
These are the next changes that should happen before new feature work expands the surface area.
1. Introduce `tntctl` and move stable command handling behind it.
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`.
3. Add per-client outbound queues and finish untangling client-state ownership.
4. Remove the remaining hidden runtime limits and make them explicit configuration.
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers.
1. Replace remaining source-archive checksum placeholders only after the
explicit release source archive exists, then run `make package-publish-check`.
2. Create or move the `vX.Y.Z` tag only when the release commit is final, then
run `make release-check-strict` before pushing it.
3. Decide whether admin-only moderation controls belong in 1.0.x or should
wait for a later minor release.

67
docs/USER_LIFECYCLE.md Normal file
View file

@ -0,0 +1,67 @@
# User Lifecycle
TNT solves one narrow problem: create a keyboard-first chat room that anyone
with an SSH client can join without installing a custom client.
The product path should stay short:
1. Operator installs `tnt`, chooses a state directory, and starts the server.
2. User connects with `ssh -p 2222 host`.
3. User picks a display name or presses Enter for `anonymous`.
4. User lands in INSERT mode at the live tail and can type immediately.
5. User presses Esc to browse history with Vim-style movement.
6. User uses `:help` for the concise manual or `?` for the full key reference.
7. User searches from NORMAL with `/term`, or uses commands when needed:
`:users`, `:msg`, `:reply`, `:inbox`, `:last`, `:search`, `:nick`,
`:mute-joins`, and `:q`.
8. Scripts and operators use `tntctl` or SSH exec commands for `health`,
`stats`, `users`, `tail`, `dump`, and `post`.
## TUI Experience Notes
- The first screen should make the product legible without reading external
docs: this is an SSH chat room, not a shell.
- INSERT mode is the default because most users arrive to send a message.
- NORMAL mode opens at the latest messages, not the oldest history. Users can
move upward for older context and use `G` or End to return to live chat.
- NORMAL mode accepts `/` as the fast path for history search, matching a
common terminal-reader habit while reusing the existing `:search` command.
- INSERT mode keeps a small per-session sent-message history on Up/Down and
completes trailing `@mention` prefixes with Tab.
- `:help` is a compact manual, while `?` is a full key reference. Do not add
parallel support commands for the same task.
- Command syntax stays ASCII even in localized UI text. Translations explain;
they do not change the command language.
- Private messages are visible in each participant's in-memory `:inbox`:
recipients see incoming messages, senders see local sent-message copies,
newest first. They are not written to `messages.log` and do not survive a
reconnect.
- `:inbox` is live enough for normal chat use: it can be refreshed with `r`
and refreshes automatically when a new private message arrives while the
inbox is open. Incoming unread messages are marked with `*` and counted in
the inbox title until the inbox renders them. `:inbox clear` removes private
messages and the reply target for the current session.
- `:reply` / `:r` keeps the private-message path keyboard-short: it answers
the latest private-message peer in the current session without retyping a
username.
- Long command output uses a small pager so `:last` and `:search` are readable
on small terminals.
## Regression Coverage
`make user-lifecycle-test` runs a two-user SSH TUI journey:
- second user joins and is visible through `users --json`
- first user opens `?`, checks `:users`, sends a public message, scrolls, uses
`:last` and `:search`
- first user toggles `:mute-joins`, sends two `:msg` messages, receives a
`:reply`, confirms private-message copies in `:inbox`, clears the inbox,
changes nickname, sends `/me`, and exits
- second user opens `:inbox` before the private messages arrive, sees it
auto-refresh after delivery, newest first, and replies without retyping the
sender's username
- exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content
This test is intentionally closer to a user story than a unit regression. Keep
it focused on lifecycle guarantees, not every keybinding.

View file

@ -4,9 +4,11 @@
#include "common.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, help_lang_t lang);
const char *cli_text_invalid_port_format(help_lang_t lang);
const char *cli_text_unknown_option_format(help_lang_t lang);
const char *cli_text_short_usage_format(help_lang_t lang);
const char *program_name, ui_lang_t lang);
const char *cli_text_invalid_port_format(ui_lang_t lang);
const char *cli_text_invalid_value_format(ui_lang_t lang);
const char *cli_text_option_requires_arg_format(ui_lang_t lang);
const char *cli_text_unknown_option_format(ui_lang_t lang);
const char *cli_text_short_usage_format(ui_lang_t lang);
#endif /* CLI_TEXT_H */

View file

@ -3,11 +3,28 @@
#include "ssh_server.h" /* for client_t */
/* Send `len` bytes to the client over its SSH channel. Serialised on
* client->io_lock so concurrent senders don't interleave. Returns 0 on
* success, -1 if the channel is gone or a partial write fails. */
/* Send `len` bytes to the client over its SSH channel.
*
* Exec sessions write synchronously so command output and exit status remain
* ordered. Interactive sessions enqueue into a bounded per-client outbox and
* flush opportunistically from the same client's session loop, so a closed SSH
* window cannot block unrelated room activity. Returns -1 if the channel is
* gone, a write fails, or the bounded outbox is full. */
int client_send(client_t *client, const char *data, size_t len);
/* Flush queued interactive output for this client. Returns 0 when all
* possible progress was made; queued bytes may remain if the remote SSH window
* is currently closed. */
int client_flush_output(client_t *client);
/* Queue an audible bell for the client's own session loop to send. This
* avoids writing to another client's SSH channel from the sender's thread. */
void client_queue_bell(client_t *client);
/* Send one queued bell, if present, from the client's own session loop.
* Returns 0 when no bell was pending or it was written successfully. */
int client_flush_pending_bells(client_t *client);
/* printf-style wrapper around client_send(). The formatted string must
* fit in 2048 bytes; truncation or encoding errors return -1. */
int client_printf(client_t *client, const char *fmt, ...);
@ -15,20 +32,18 @@ int client_printf(client_t *client, const char *fmt, ...);
/* Reference counting for safe cross-thread cleanup.
*
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
* (the "main" ref), then adds a second ref before installing the channel
* callbacks (the "callback" ref) so the client outlives any in-flight
* eof / close / window-change callback invocation. The interactive
* session releases both refs in its cleanup path; the final release
* frees the SSH session, channel, callback struct, and the client_t. */
* (the "main" ref). client_install_channel_callbacks() takes a second
* ref owned by client.c while channel callbacks are installed, so the
* client outlives in-flight eof / close / window-change callbacks.
* input_run_session() ends ownership with client_release_session(). */
void client_addref(client_t *client);
void client_release(client_t *client);
void client_release_session(client_t *client);
/* Install the post-bootstrap channel callbacks (window-change, eof, close)
* that target this client_t. Caller MUST have already added one
* client_addref() to keep the client alive across in-flight callback
* invocations; the matching client_release() happens during cleanup in
* input_run_session(). Returns 0 on success, -1 on failure (in which
* case the caller still owns both refs and must release them). */
/* Install the post-bootstrap channel callbacks (window-change, eof, close).
* On success this function takes the callback reference described above.
* On failure no callback reference remains and the caller still owns only
* its original main reference. */
int client_install_channel_callbacks(client_t *client);
#endif /* CLIENT_H */

40
include/command_catalog.h Normal file
View file

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

@ -15,9 +15,13 @@
* - Toggles client->mute_joins on `:mute-joins`
* - May broadcast a system rename message on `:nick`
*
* Reads g_room. Caller must already hold the channel I/O serialisation
* established by handle_key() this function calls back into client_send
* (via tui_render_command_output) which acquires client->io_lock. */
* Reads g_room. Renders command output through the normal client_send()
* path; callers must not hold client->io_lock before dispatching. */
void commands_dispatch(client_t *client);
/* Rebuild the currently visible command output when it is backed by live
* client state, such as :inbox. Returns true if output changed and the caller
* should render it again. */
bool commands_refresh_active_output(client_t *client);
#endif /* COMMANDS_H */

View file

@ -11,21 +11,38 @@
#include <limits.h>
#include <pthread.h>
#include "config_defaults.h"
/* Project Metadata */
#define TNT_VERSION "1.0.1"
/* Public process/exec exit statuses. TNT follows the common sysexits(3)
* convention for usage errors while keeping runtime failures portable. */
#define TNT_EXIT_OK 0
#define TNT_EXIT_ERROR 1
#define TNT_EXIT_USAGE 64
#define TNT_EXIT_UNAVAILABLE 69
#define TNT_EXIT_CONFIG 78
/* Configuration constants */
#define DEFAULT_PORT 2222
#define MAX_MESSAGES 100
#define MAX_USERNAME_LEN 64
#define MAX_MESSAGE_LEN 1024
#define MAX_EXEC_COMMAND_LEN 1024
#define MAX_CLIENTS 64
#define MAX_COMMAND_OUTPUT_LEN 8192
#define CLIENT_OUTBOX_CAPACITY (128 * 1024)
#define CLIENT_OUTBOX_FLUSH_BUDGET 32768
#define LOG_FILE "messages.log"
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
#define HOST_KEY_FILE "host_key"
#define TNT_DEFAULT_STATE_DIR "."
#define DEFAULT_IDLE_TIMEOUT 1800 /* 30 minutes */
/* Backward-compatible names for older modules while config_defaults owns the
* actual runtime defaults and accepted ranges. */
#define DEFAULT_PORT TNT_DEFAULT_PORT
#define DEFAULT_MAX_CLIENTS TNT_DEFAULT_MAX_CONNECTIONS
#define MAX_CONFIGURED_CLIENTS TNT_MAX_CONFIGURED_CLIENTS
#define DEFAULT_IDLE_TIMEOUT TNT_DEFAULT_IDLE_TIMEOUT
/* ANSI color codes */
#define ANSI_RESET "\033[0m"
@ -39,15 +56,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);

47
include/config_defaults.h Normal file
View file

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

View file

@ -6,9 +6,9 @@
/* Dispatch the non-interactive SSH exec command stored in
* client->exec_command. Returns the exit status to send back to the
* SSH client:
* 0 = success
* 1 = runtime error (I/O, OOM, persistence failure)
* 64 = usage error (unknown command, bad args)
* TNT_EXIT_OK = success
* TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
* TNT_EXIT_USAGE = usage error (unknown command, bad args)
*
* Reads g_room and shared client state. Safe to call once per
* exec-mode session before the channel is closed. */

28
include/exec_catalog.h Normal file
View file

@ -0,0 +1,28 @@
#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_DUMP,
TNT_EXEC_COMMAND_POST,
TNT_EXEC_COMMAND_EXIT,
TNT_EXEC_COMMAND_COUNT
} tnt_exec_command_id_t;
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
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_command_list(char *buffer, size_t buf_size,
size_t *pos);
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang);
#endif /* EXEC_CATALOG_H */

View file

@ -3,8 +3,7 @@
#include "common.h"
const char *help_text_full(help_lang_t lang);
void help_text_append_commands(char *output, size_t buf_size, size_t *pos,
help_lang_t lang);
void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
ui_lang_t lang);
#endif /* HELP_TEXT_H */

View file

@ -3,6 +3,17 @@
#include "common.h"
typedef struct {
const char *text[UI_LANG_COUNT];
} i18n_string_t;
#define I18N_LANG_TEXT(lang, value) [lang] = (value)
#define I18N_EN(value) I18N_LANG_TEXT(UI_LANG_EN, value)
#define I18N_ZH(value) I18N_LANG_TEXT(UI_LANG_ZH, value)
#define I18N_STRING_MAP(...) {{ __VA_ARGS__ }}
#define I18N_STRING(en_text, zh_text) \
I18N_STRING_MAP(I18N_EN(en_text), I18N_ZH(zh_text))
typedef enum {
I18N_USERNAME_PROMPT,
I18N_INVALID_USERNAME,
@ -17,31 +28,37 @@ typedef enum {
I18N_HELP_TITLE,
I18N_HELP_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_TITLE,
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
I18N_MOTD_TITLE,
I18N_MOTD_CONTINUE_HINT,
I18N_TITLE_ONLINE_FORMAT,
I18N_TITLE_MUTED,
I18N_TITLE_HELP_HINT,
I18N_EMPTY_ROOM,
I18N_EMPTY_FILTERED,
I18N_IDLE_TIMEOUT_FORMAT,
I18N_SYSTEM_USERNAME,
I18N_SYSTEM_JOIN_FORMAT,
I18N_SYSTEM_LEAVE_FORMAT,
I18N_SYSTEM_NICK_FORMAT,
I18N_USERS_TITLE,
I18N_MSG_USAGE,
I18N_MSG_SENT_FORMAT,
I18N_MSG_USER_NOT_FOUND_FORMAT,
I18N_REPLY_NO_TARGET,
I18N_INBOX_TITLE,
I18N_INBOX_EMPTY,
I18N_NICK_USAGE,
I18N_INBOX_SENT_TO_FORMAT,
I18N_INBOX_CLEARED,
I18N_INBOX_UNREAD_FORMAT,
I18N_NICK_INVALID,
I18N_NICK_TAKEN_FORMAT,
I18N_NICK_UNCHANGED,
I18N_NICK_CHANGED_FORMAT,
I18N_LAST_USAGE,
I18N_LAST_HEADER_FORMAT,
I18N_SEARCH_USAGE,
I18N_LAST_EMPTY,
I18N_SEARCH_HEADER_FORMAT,
I18N_SEARCH_EMPTY,
I18N_MUTE_JOINS_FORMAT,
I18N_MUTE_JOINS_MUTED,
I18N_MUTE_JOINS_UNMUTED,
@ -52,21 +69,33 @@ typedef enum {
I18N_UNKNOWN_COMMAND_FORMAT,
I18N_DID_YOU_MEAN_FORMAT,
I18N_UNKNOWN_GUIDANCE,
I18N_EXEC_HELP,
I18N_EXEC_USERS_USAGE,
I18N_EXEC_STATS_USAGE,
I18N_EXEC_TAIL_USAGE,
I18N_EXEC_POST_USAGE,
I18N_EXEC_POST_EMPTY,
I18N_EXEC_POST_INVALID_UTF8,
I18N_EXEC_POST_TOO_LONG,
I18N_EXEC_POST_PERSIST_FAILED,
I18N_EXEC_COMMAND_TOO_LONG,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT,
I18N_CONTINUE_PROMPT
I18N_TEXT_COUNT
} i18n_text_id_t;
bool i18n_try_parse_lang(const char *value, help_lang_t *lang);
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback);
help_lang_t i18n_default_lang(void);
const char *i18n_lang_code(help_lang_t lang);
const char *i18n_text(help_lang_t lang, i18n_text_id_t id);
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 */

35
include/input_buffer.h Normal file
View file

@ -0,0 +1,35 @@
#ifndef INPUT_BUFFER_H
#define INPUT_BUFFER_H
#include "common.h"
typedef enum {
TNT_INPUT_APPEND_OK = 0,
TNT_INPUT_APPEND_IGNORED = 1 << 0,
TNT_INPUT_APPEND_OVERFLOW = 1 << 1,
TNT_INPUT_APPEND_INVALID_UTF8 = 1 << 2
} tnt_input_append_status_t;
typedef struct {
char bytes[4];
int len;
int expected_len;
} tnt_input_utf8_state_t;
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state);
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b);
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
const char *bytes, int len);
/* Append one byte from a terminal stream, validating UTF-8 across calls.
* In paste mode CR/LF/TAB are normalized to spaces so existing TNT 1.x
* single-line message semantics are preserved. */
int tnt_input_append_stream_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode);
/* Returns TNT_INPUT_APPEND_INVALID_UTF8 when the stream ended mid-codepoint. */
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state);
#endif /* INPUT_BUFFER_H */

15
include/json_text.h Normal file
View file

@ -0,0 +1,15 @@
#ifndef JSON_TEXT_H
#define JSON_TEXT_H
#include "common.h"
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text);
/* Extract a top-level JSON string field from a single JSON object.
* Returns false for malformed JSON, missing key, non-string value, or output
* overflow. Unknown nested objects and arrays are skipped. */
bool tnt_json_get_string_field(const char *json, const char *key,
char *out, size_t out_size);
#endif /* JSON_TEXT_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

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

21
include/message_log.h Normal file
View file

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

View file

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

24
include/module_protocol.h Normal file
View file

@ -0,0 +1,24 @@
#ifndef MODULE_PROTOCOL_H
#define MODULE_PROTOCOL_H
#include "message.h"
#define TNT_MODULE_PROTOCOL_VERSION "tnt.module.v1"
#define TNT_MODULE_EVENT_MESSAGE_CREATED "message.created"
#define TNT_MODULE_RESPONSE_MESSAGE_CREATE "message.create"
typedef struct {
char plain_text[MAX_MESSAGE_LEN];
} tnt_module_message_create_t;
int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,
const char *server_version);
int tnt_module_append_message_created(char *buffer, size_t buf_size,
size_t *pos, const char *message_id,
const message_t *msg);
bool tnt_module_parse_message_create(const char *line,
tnt_module_message_create_t *out);
#endif /* MODULE_PROTOCOL_H */

27
include/module_runtime.h Normal file
View file

@ -0,0 +1,27 @@
#ifndef MODULE_RUNTIME_H
#define MODULE_RUNTIME_H
#include "message.h"
#define TNT_MAX_MODULES 8
#define TNT_MODULE_QUEUE_LIMIT 128
typedef struct {
char name[64];
char entrypoint[PATH_MAX];
bool wants_message_created;
bool can_read_messages;
bool can_create_messages;
} tnt_module_manifest_t;
int tnt_module_manifest_load(const char *module_dir,
tnt_module_manifest_t *out);
int tnt_module_runtime_init(void);
void tnt_module_runtime_shutdown(void);
/* Queue a user/core-created public message for enabled modules. This is
* intentionally fire-and-forget so basic chat never depends on module health. */
void tnt_module_runtime_publish_message_created(const message_t *msg);
#endif /* MODULE_RUNTIME_H */

View file

@ -14,9 +14,18 @@
typedef struct {
time_t timestamp;
char from[MAX_USERNAME_LEN];
char to[MAX_USERNAME_LEN];
char content[MAX_MESSAGE_LEN];
bool outgoing;
bool unread;
} whisper_t;
typedef enum {
TNT_COMMAND_OUTPUT_NONE,
TNT_COMMAND_OUTPUT_GENERIC,
TNT_COMMAND_OUTPUT_INBOX
} tnt_command_output_kind_t;
/* Client connection structure */
typedef struct client {
ssh_session session; /* SSH session */
@ -26,7 +35,7 @@ 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;
@ -40,17 +49,28 @@ typedef struct client {
char insert_history[16][MAX_MESSAGE_LEN];
int insert_history_count;
int insert_history_pos;
char command_output[2048];
char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll;
tnt_command_output_kind_t command_output_kind;
bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
char ssh_login[MAX_USERNAME_LEN];
time_t connect_time;
time_t last_active;
atomic_bool redraw_pending;
_Atomic int pending_bells; /* Bell nudges for this client's loop */
_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. */
char last_whisper_peer[MAX_USERNAME_LEN]; /* Most recent private-message peer */
char *outbox; /* Bounded queued output for interactive writes */
size_t outbox_len;
size_t outbox_pos;
size_t outbox_capacity;
char *render_buffer; /* Reused main-screen render buffer */
size_t render_buffer_capacity;
/* Per-client whisper inbox. Protected separately from SSH channel I/O
* so slow writes do not block in-memory private-message delivery. */
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
int whisper_inbox_count;
bool mute_joins;
@ -59,6 +79,8 @@ typedef struct client {
int ref_count; /* Reference count for safe cleanup */
pthread_mutex_t ref_lock; /* Lock for ref_count */
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
pthread_mutex_t whisper_lock; /* Serialize whisper inbox access */
bool channel_callback_ref; /* client.c owns one ref while callbacks are installed */
struct ssh_channel_callbacks_struct *channel_cb;
} client_t;

View file

@ -1,11 +0,0 @@
#ifndef SUPPORT_H
#define SUPPORT_H
#include "common.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos, help_lang_t lang);
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang);
#endif /* SUPPORT_H */

View file

@ -1,9 +0,0 @@
#ifndef SUPPORT_TEXT_H
#define SUPPORT_TEXT_H
#include "common.h"
const char *support_text_interactive(help_lang_t lang);
const char *support_text_exec(help_lang_t lang);
#endif /* SUPPORT_TEXT_H */

View file

@ -5,11 +5,11 @@
#include "message.h"
void system_message_make_join(message_t *msg, const char *username,
help_lang_t lang);
ui_lang_t lang);
void system_message_make_leave(message_t *msg, const char *username,
help_lang_t lang);
ui_lang_t lang);
void system_message_make_nick(message_t *msg, const char *old_name,
const char *new_name, help_lang_t lang);
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);

29
include/tntctl_text.h Normal file
View file

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

View file

@ -24,6 +24,9 @@ void tui_render_motd(struct client *client);
/* Render the input line */
void tui_render_input(struct client *client, const char *input);
/* Render only the command input/status line */
void tui_render_command_input(struct client *client);
/* Clear the screen */
void tui_clear_screen(struct client *client);
@ -32,5 +35,4 @@ void tui_clear_screen(struct client *client);
* itself afterwards. */
void tui_render_welcome(struct client *client);
/* Get help text based on language */
#endif /* TUI_H */

View file

@ -27,6 +27,34 @@ sha256_of() {
fi
}
warn_missing_libssh() {
case "$OS" in
linux)
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q 'libssh\.so'; then
return
fi
for path in /usr/lib/libssh.so* /usr/lib64/libssh.so* \
/lib/libssh.so* /lib64/libssh.so*; do
[ -e "$path" ] && return
done
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first, for example:"
echo " Ubuntu/Debian: sudo apt install libssh-4"
echo " Arch: sudo pacman -S libssh"
;;
darwin)
if [ -e /opt/homebrew/opt/libssh/lib/libssh.dylib ] ||
[ -e /usr/local/opt/libssh/lib/libssh.dylib ]; then
return
fi
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first:"
echo " brew install libssh"
;;
esac
}
need_cmd curl
need_cmd awk
@ -45,13 +73,15 @@ case "$ARCH" in
*) fail "Unsupported architecture: $ARCH" ;;
esac
BINARY="tnt-${OS}-${ARCH}"
SERVER_BINARY="tnt-${OS}-${ARCH}"
CTL_BINARY="tntctl-${OS}-${ARCH}"
echo "=== TNT Installer ==="
echo "OS: $OS"
echo "Arch: $ARCH"
echo "Version: $VERSION"
echo ""
warn_missing_libssh
# Get latest version if not specified
if [ "$VERSION" = "latest" ]; then
@ -65,51 +95,81 @@ fi
echo "Installing version: $VERSION"
# Download
URL="https://github.com/$REPO/releases/download/$VERSION/$BINARY"
SERVER_URL="https://github.com/$REPO/releases/download/$VERSION/$SERVER_BINARY"
CTL_URL="https://github.com/$REPO/releases/download/$VERSION/$CTL_BINARY"
CHECKSUM_URL="https://github.com/$REPO/releases/download/$VERSION/checksums.txt"
echo "Downloading from: $URL"
echo "Downloading from: $SERVER_URL"
TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
SERVER_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt.XXXXXX")
CTL_TMP_FILE=$(mktemp "${TMPDIR:-/tmp}/tntctl.XXXXXX")
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
INSTALL_CTL=0
cleanup() {
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
}
trap cleanup EXIT INT TERM
curl -fsSL -o "$TMP_FILE" "$URL" || fail "Failed to download $BINARY"
curl -fsSL -o "$SERVER_TMP_FILE" "$SERVER_URL" ||
fail "Failed to download $SERVER_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"
EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
[ -n "$EXPECTED_SERVER_SHA" ] || fail "No checksum entry found for $SERVER_BINARY"
EXPECTED_CTL_SHA=$(awk -v name="$CTL_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
ACTUAL_SHA=$(sha256_of "$TMP_FILE") ||
ACTUAL_SERVER_SHA=$(sha256_of "$SERVER_TMP_FILE") ||
fail "sha256sum or shasum is required for checksum verification"
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
fail "Checksum mismatch for $BINARY"
[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
fail "Checksum mismatch for $SERVER_BINARY"
echo "Checksum verified: $ACTUAL_SHA"
echo "Checksum verified: $SERVER_BINARY $ACTUAL_SERVER_SHA"
if [ -n "$EXPECTED_CTL_SHA" ]; then
echo "Downloading from: $CTL_URL"
curl -fsSL -o "$CTL_TMP_FILE" "$CTL_URL" ||
fail "Failed to download $CTL_BINARY"
ACTUAL_CTL_SHA=$(sha256_of "$CTL_TMP_FILE") ||
fail "sha256sum or shasum is required for checksum verification"
[ "$ACTUAL_CTL_SHA" = "$EXPECTED_CTL_SHA" ] ||
fail "Checksum mismatch for $CTL_BINARY"
echo "Checksum verified: $CTL_BINARY $ACTUAL_CTL_SHA"
INSTALL_CTL=1
else
echo "No checksum entry found for $CTL_BINARY; skipping tntctl for this release"
fi
# Install
chmod +x "$TMP_FILE"
chmod +x "$SERVER_TMP_FILE"
[ "$INSTALL_CTL" -eq 0 ] || chmod +x "$CTL_TMP_FILE"
if [ -d "$INSTALL_DIR" ] && [ -w "$INSTALL_DIR" ]; then
install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
[ "$INSTALL_CTL" -eq 0 ] || install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
else
echo "Need sudo for installation to $INSTALL_DIR"
need_cmd sudo
sudo mkdir -p "$INSTALL_DIR"
sudo install -m 755 "$TMP_FILE" "$INSTALL_DIR/tnt"
sudo install -m 755 "$SERVER_TMP_FILE" "$INSTALL_DIR/tnt"
[ "$INSTALL_CTL" -eq 0 ] || sudo install -m 755 "$CTL_TMP_FILE" "$INSTALL_DIR/tntctl"
fi
echo ""
echo "TNT installed successfully to $INSTALL_DIR/tnt"
if [ "$INSTALL_CTL" -eq 1 ]; then
echo "TNT installed successfully to $INSTALL_DIR/tnt and $INSTALL_DIR/tntctl"
else
echo "TNT installed successfully to $INSTALL_DIR/tnt"
fi
echo ""
echo "Run with:"
echo " tnt"
echo ""
echo "Or specify port:"
echo " PORT=3333 tnt"
if [ "$INSTALL_CTL" -eq 1 ]; then
echo ""
echo "Control a server with:"
echo " tntctl localhost health"
fi

View file

@ -10,12 +10,33 @@ any public registry.
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
Package installs include both `tnt` and `tntctl`. `tnt` is the server process;
`tntctl` is a thin wrapper around the documented SSH exec interface.
## CI governance
Package recipes are validated in stages:
- PR fast gate: `make release-check` verifies package metadata stays aligned
with `TNT_VERSION`.
- Extended CI: package syntax and Debian source-tree assembly run on `main` and
`release/**` pushes, nightly, and manual workflow dispatch.
- Release gate: the workflow builds an explicit release source archive, verifies
it, and includes it in `checksums.txt`.
- Publishing gate: after final source checksums are pinned, run
`SOURCE_TARBALL=... make package-publish-check`.
All package-manager submissions remain manual. CI must not push to AUR, open or
merge Homebrew tap updates, upload Debian/PPA packages, publish container
images, or deploy production servers.
## 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.
2. Create a GitHub release tag such as `vX.Y.Z`.
3. Let the release workflow build the explicit release source archive and draft
release assets.
4. Replace placeholder checksums in package drafts.
5. Verify package contents in an isolated directory:
@ -23,13 +44,23 @@ any public registry.
make release-check
```
6. Before submitting package recipes, replace checksum placeholders and run:
6. Assemble a Debian/PPA source tree when preparing Ubuntu packaging:
```sh
make release-check-strict
make debian-source-package
```
7. Submit packages manually:
Use `scripts/package_debian_source.sh --build` on a Debian/Ubuntu system
with `dpkg-buildpackage` installed to build the unsigned source package.
7. Before submitting package recipes, download the explicit release source archive,
replace checksum placeholders, and run:
```sh
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z-source.tar.gz make package-publish-check
```
8. Submit packages manually:
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.

View file

@ -9,7 +9,9 @@ pkgbase = tnt-chat
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
source = tnt-chat-v1.0.1-source.tar.gz::https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz
source = tnt-chat.sysusers
sha256sums = SKIP
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
pkgname = tnt-chat

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,7 @@
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"
url "https://github.com/m1ngsama/TNT/releases/download/v1.0.1/tnt-chat-v1.0.1-source.tar.gz"
sha256 "REPLACE_WITH_RELEASE_TARBALL_SHA256"
license "MIT"
@ -12,10 +12,24 @@ class TntChat < Formula
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
bin.install "#{buildpath}/stage#{prefix}/bin/tntctl"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tnt.1"
man1.install "#{buildpath}/stage#{prefix}/share/man/man1/tntctl.1"
(var/"tnt").mkpath
(var/"log").mkpath
end
service do
run [opt_bin/"tnt", "-d", var/"tnt"]
keep_alive true
working_dir var/"tnt"
log_path var/"log/tnt.log"
error_log_path var/"log/tnt.log"
end
test do
assert_match version.to_s, shell_output("#{bin}/tnt --version")
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
end
end

31
scripts/check_release_ref.sh Executable file
View file

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

View file

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

View file

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

View file

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

165
scripts/package_release_assets.sh Executable file
View file

@ -0,0 +1,165 @@
#!/bin/sh
# Collect release workflow artifacts into one flat, checksum-verified bundle.
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/package_release_assets.sh ARTIFACT_ROOT [OUT_DIR]
ARTIFACT_ROOT is the directory produced by actions/download-artifact.
OUT_DIR defaults to dist/release-assets.
The script validates the expected release asset names, verifies binary
architecture labels, verifies the source archive shape, writes checksums.txt,
and verifies that checksums.txt matches the assembled bundle. It never
publishes a release.
USAGE
}
fail() {
echo "release-artifact-gate: $*" >&2
exit 1
}
sha256_of() {
if command -v sha256sum >/dev/null 2>&1; then
sha256sum "$1" | awk '{print $1}'
elif command -v shasum >/dev/null 2>&1; then
shasum -a 256 "$1" | awk '{print $1}'
else
fail "sha256sum or shasum is required"
fi
}
find_unique() {
name=$1
matches=$(find "$ARTIFACT_ROOT" -type f -name "$name" -print)
count=$(printf '%s\n' "$matches" | sed '/^$/d' | wc -l | tr -d ' ')
[ "$count" = "1" ] ||
fail "expected exactly one artifact named $name, found $count"
printf '%s\n' "$matches"
}
require_file_label() {
path=$1
pattern=$2
label=$(file "$path")
printf '%s\n' "$label" | grep -E "$pattern" >/dev/null ||
fail "unexpected file type for $(basename "$path"): $label"
}
archive_has_entry() {
entry=$1
printf '%s\n' "$archive_listing" |
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }'
}
require_archive_entry() {
entry=$1
label=$2
archive_has_entry "$entry" ||
fail "source archive is missing $label"
}
verify_asset() {
name=$1
path=$2
[ -s "$path" ] || fail "empty artifact: $name"
case "$name" in
tnt-linux-amd64|tntctl-linux-amd64)
require_file_label "$path" 'ELF 64-bit.*x86-64'
;;
tnt-linux-arm64|tntctl-linux-arm64)
require_file_label "$path" 'ELF 64-bit.*(aarch64|ARM aarch64)'
;;
tnt-darwin-amd64|tntctl-darwin-amd64)
require_file_label "$path" 'Mach-O 64-bit.*x86_64'
;;
tnt-darwin-arm64|tntctl-darwin-arm64)
require_file_label "$path" 'Mach-O 64-bit.*arm64'
;;
tnt-chat-v*-source.tar.gz)
archive_listing=$(tar -tzf "$path") ||
fail "source archive is not a readable tar.gz: $name"
require_archive_entry "TNT-$VERSION/LICENSE" "LICENSE"
require_archive_entry "TNT-$VERSION/src/tntctl.c" "src/tntctl.c"
require_archive_entry "TNT-$VERSION/packaging/README.md" "packaging/README.md"
require_archive_entry "TNT-$VERSION/tnt.1" "tnt.1"
require_archive_entry "TNT-$VERSION/tntctl.1" "tntctl.1"
;;
*)
fail "unexpected release artifact: $name"
;;
esac
}
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || {
usage
exit 0
}
ARTIFACT_ROOT=${1:-}
OUT_DIR=${2:-dist/release-assets}
[ -n "$ARTIFACT_ROOT" ] || {
usage >&2
exit 2
}
[ -d "$ARTIFACT_ROOT" ] || fail "ARTIFACT_ROOT does not exist: $ARTIFACT_ROOT"
ARTIFACT_ROOT=$(CDPATH= cd -- "$ARTIFACT_ROOT" && pwd)
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$ROOT/$OUT_DIR" ;;
esac
VERSION=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
[ -n "$VERSION" ] || fail "could not read TNT_VERSION"
SOURCE_ASSET="tnt-chat-v$VERSION-source.tar.gz"
EXPECTED_ASSETS="
tnt-linux-amd64
tntctl-linux-amd64
tnt-linux-arm64
tntctl-linux-arm64
tnt-darwin-amd64
tntctl-darwin-amd64
tnt-darwin-arm64
tntctl-darwin-arm64
$SOURCE_ASSET
"
mkdir -p "$OUT_DIR"
for name in $EXPECTED_ASSETS; do
dst="$OUT_DIR/$name"
[ ! -e "$dst" ] || fail "output already exists: $dst"
src=$(find_unique "$name")
verify_asset "$name" "$src"
cp "$src" "$dst"
done
(
cd "$OUT_DIR"
: > checksums.txt
for name in $EXPECTED_ASSETS; do
printf '%s %s\n' "$(sha256_of "$name")" "$name" >> checksums.txt
done
while read -r expected name; do
[ -n "$expected" ] || continue
actual=$(sha256_of "$name")
[ "$actual" = "$expected" ] ||
fail "checksum mismatch for $name"
done < checksums.txt
)
echo "release artifact bundle ready: $OUT_DIR"

View file

@ -0,0 +1,89 @@
#!/bin/sh
# Build and validate the explicit release source archive.
set -eu
usage() {
cat <<'USAGE'
Usage: scripts/package_source_archive.sh REF [OUT_DIR]
REF is a git ref, commit, or release tag to archive. OUT_DIR defaults to dist.
The archive name is tnt-chat-v$TNT_VERSION-source.tar.gz, and its top-level
directory is TNT-$TNT_VERSION/.
When REF is a SemVer tag such as v1.2.3 or refs/tags/v1.2.3, the tag must match
TNT_VERSION from that ref. This script only builds and validates the archive; it
does not tag, publish, upload, or deploy.
USAGE
}
fail() {
echo "source-archive: $*" >&2
exit 1
}
require_archive_entry() {
entry=$1
label=$2
printf '%s\n' "$archive_listing" |
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }' ||
fail "source archive is missing $label"
}
[ "${1:-}" != "-h" ] && [ "${1:-}" != "--help" ] || {
usage
exit 0
}
REF=${1:-}
OUT_DIR=${2:-dist}
[ -n "$REF" ] || {
usage >&2
exit 2
}
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
cd "$ROOT"
commit=$(git rev-parse --verify "$REF^{commit}") ||
fail "could not resolve git ref: $REF"
case "$OUT_DIR" in
/*) ;;
*) OUT_DIR="$ROOT/$OUT_DIR" ;;
esac
version=$(git show "$commit:include/common.h" |
sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p')
[ -n "$version" ] || fail "could not read TNT_VERSION from $REF"
case "$REF" in
refs/tags/v[0-9]*.[0-9]*.[0-9]*)
tag=${REF#refs/tags/}
[ "$tag" = "v$version" ] ||
fail "release tag $tag does not match TNT_VERSION $version"
;;
v[0-9]*.[0-9]*.[0-9]*)
[ "$REF" = "v$version" ] ||
fail "release tag $REF does not match TNT_VERSION $version"
;;
esac
archive="$OUT_DIR/tnt-chat-v$version-source.tar.gz"
mkdir -p "$OUT_DIR"
[ ! -e "$archive" ] || fail "output already exists: $archive"
git archive --format=tar.gz --prefix="TNT-$version/" -o "$archive" "$commit"
archive_listing=$(tar -tzf "$archive") ||
fail "archive is not a readable tar.gz: $archive"
require_archive_entry "TNT-$version/LICENSE" "LICENSE"
require_archive_entry "TNT-$version/src/tntctl.c" "src/tntctl.c"
require_archive_entry "TNT-$version/packaging/README.md" "packaging/README.md"
require_archive_entry "TNT-$version/tnt.1" "tnt.1"
require_archive_entry "TNT-$version/tntctl.1" "tntctl.1"
echo "$archive"

View file

@ -13,6 +13,7 @@ Default checks:
- version metadata alignment
- clean build
- unit tests
- script tests
- staged install layout with PREFIX=/usr and DESTDIR
- installer shell syntax
- Debian packaging metadata
@ -20,9 +21,14 @@ Default checks:
Environment:
RUN_INTEGRATION=1 also run full make test
RUN_SOAK=1 also run the configurable soak test
RUN_SLOW_CLIENT=1 also run the slow-client backpressure test
PORT=12720 base port for integration tests
Strict checks additionally require real package checksums and a local vX.Y.Z tag.
Strict checks additionally require a clean tree, a vX.Y.Z tag at HEAD, a
matching changelog release section, non-placeholder maintainer metadata, and a
build from the tagged source archive. Run `make package-publish-check` after
the explicit release source archive exists to verify package checksums.
USAGE
}
@ -62,6 +68,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' 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 "\"TNT $version\"" tntctl.1 ||
fail "tntctl.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 ||
@ -70,8 +78,12 @@ 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 '${pkgname}-v${pkgver}-source.tar.gz' packaging/arch/PKGBUILD ||
fail "packaging/arch/PKGBUILD source must use the release source archive"
grep -q "tnt-chat-v${version}-source.tar.gz" packaging/arch/.SRCINFO ||
fail "packaging/arch/.SRCINFO source does not match v$version release archive"
grep -q "tnt-chat-v${version}-source.tar.gz" packaging/homebrew/tnt-chat.rb ||
fail "packaging/homebrew/tnt-chat.rb URL does not match v$version release archive"
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 ||
@ -88,16 +100,50 @@ make
actual_version=$(./tnt --version)
[ "$actual_version" = "tnt $version" ] ||
fail "binary version mismatch: expected 'tnt $version', got '$actual_version'"
tntctl_version=$(./tntctl --version)
[ "$tntctl_version" = "tntctl $version" ] ||
fail "control binary version mismatch: expected 'tntctl $version', got '$tntctl_version'"
step "running unit tests"
make -C tests/unit clean
make -C tests/unit run
step "running script tests"
make script-test
step "checking client I/O ownership boundaries"
! grep -R "client_send(target" src include >/dev/null ||
fail "cross-client target writes must be queued through client_queue_bell"
! grep -R "client_send(targets" src include >/dev/null ||
fail "cross-client target-array writes must be queued through client_queue_bell"
! grep -n "pthread_mutex_lock(&.*->io_lock)" src/commands.c >/dev/null ||
fail "commands.c must not use SSH io_lock for in-memory command state"
! grep -n "client_addref(client)" src/bootstrap.c >/dev/null ||
fail "bootstrap.c must let client_install_channel_callbacks own callback refs"
grep -q "client_release_session(client)" src/input.c ||
fail "input.c must release session ownership through client_release_session"
if grep -R "ssh_channel_write" src include | grep -v "^src/client.c:" >/dev/null; then
fail "raw SSH channel writes must stay inside src/client.c"
fi
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
step "running full integration tests"
make test PORT="${PORT:-12720}"
fi
if [ "${RUN_SOAK:-0}" = "1" ]; then
step "running soak test"
make soak-test PORT="$((${PORT:-12720} + 30))" \
DURATION="${SOAK_DURATION:-8}" RECONNECTS="${SOAK_RECONNECTS:-5}"
fi
if [ "${RUN_SLOW_CLIENT:-0}" = "1" ]; then
step "running slow-client test"
make slow-client-test PORT="$((${PORT:-12720} + 40))" \
DURATION="${SLOW_CLIENT_DURATION:-8}" \
BURST_CHARS="${SLOW_CLIENT_BURST_CHARS:-1600}"
fi
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
cleanup() {
rm -rf "$tmpdir"
@ -109,18 +155,90 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
[ -x "$tmpdir/usr/bin/tntctl" ] || fail "missing executable: /usr/bin/tntctl"
[ -f "$tmpdir/usr/share/man/man1/tnt.1" ] || fail "missing manpage: /usr/share/man/man1/tnt.1"
[ -f "$tmpdir/usr/share/man/man1/tntctl.1" ] || fail "missing manpage: /usr/share/man/man1/tntctl.1"
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
fail "missing systemd unit: /usr/lib/systemd/system/tnt.service"
grep -q "^ExecStart=/usr/bin/tnt$" "$tmpdir/usr/lib/systemd/system/tnt.service" ||
fail "systemd unit ExecStart does not match PREFIX=/usr install path"
step "checking installed log maintenance modes"
log_smoke="$tmpdir/messages.log"
recovered_log="$tmpdir/recovered.messages.log"
recover_report="$tmpdir/recovered.report"
smoke_ts=$(date -u +%Y-%m-%dT%H:%M:%SZ)
cat > "$log_smoke" <<EOF
$smoke_ts|alice|one
$smoke_ts|mallory|extra|pipe
$smoke_ts|bob|two
EOF
if "$tmpdir/usr/bin/tnt" --log-check "$log_smoke" >"$tmpdir/log-check.out" 2>&1; then
fail "installed tnt --log-check should report invalid records"
fi
grep -q '^valid_records 2$' "$tmpdir/log-check.out" ||
fail "installed tnt --log-check did not report valid records"
grep -q '^invalid_records 1$' "$tmpdir/log-check.out" ||
fail "installed tnt --log-check did not report invalid records"
if "$tmpdir/usr/bin/tnt" --log-recover "$log_smoke" \
>"$recovered_log" 2>"$recover_report"; then
fail "installed tnt --log-recover should report invalid records"
fi
grep -q "$smoke_ts|alice|one" "$recovered_log" ||
fail "installed tnt --log-recover missed alice record"
grep -q "$smoke_ts|bob|two" "$recovered_log" ||
fail "installed tnt --log-recover missed bob record"
! grep -q 'mallory' "$recovered_log" ||
fail "installed tnt --log-recover preserved invalid record"
grep -q '^invalid_records 1$' "$recover_report" ||
fail "installed tnt --log-recover did not report invalid records"
step "checking installer syntax"
sh -n install.sh
sh -n scripts/check_release_ref.sh
sh -n scripts/package_publish_check.sh
sh -n scripts/package_release_assets.sh
sh -n scripts/package_source_archive.sh
scripts/check_release_ref.sh "v$version"
bad_ref=v0.0.0
[ "$version" != "0.0.0" ] || bad_ref=v9.9.9
if scripts/check_release_ref.sh "$bad_ref" >/dev/null 2>&1; then
fail "release ref check accepted a mismatched tag"
fi
step "checking Debian packaging metadata"
[ -x packaging/debian/debian/rules ] ||
fail "packaging/debian/debian/rules must be executable"
[ -x packaging/debian/debian/postinst ] ||
fail "packaging/debian/debian/postinst must be executable"
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
fail "unsupported Debian source format"
grep -q "adduser .* tnt" packaging/debian/debian/postinst ||
fail "Debian postinst must create the tnt system user"
grep -q " adduser" packaging/debian/debian/control ||
fail "Debian package must depend on adduser for postinst user creation"
step "checking Debian source assembly"
sh -n scripts/package_debian_source.sh
scripts/package_debian_source.sh "$tmpdir/debian-source"
[ -f "$tmpdir/debian-source/tnt-chat-$version/debian/control" ] ||
fail "assembled Debian source tree is missing debian/control"
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/rules" ] ||
fail "assembled Debian source tree is missing executable debian/rules"
[ -x "$tmpdir/debian-source/tnt-chat-$version/debian/postinst" ] ||
fail "assembled Debian source tree is missing executable debian/postinst"
step "checking packaged system user metadata"
grep -q '^u tnt ' packaging/arch/tnt-chat.sysusers ||
fail "Arch sysusers file must create the tnt system user"
grep -q 'usr/lib/sysusers.d' packaging/arch/PKGBUILD ||
fail "PKGBUILD must install the sysusers.d file"
step "checking Homebrew service metadata"
grep -q "service do" packaging/homebrew/tnt-chat.rb ||
fail "Homebrew formula must define a brew services entry"
grep -q 'opt_bin/"tnt"' packaging/homebrew/tnt-chat.rb ||
fail "Homebrew service must run the installed tnt binary"
step "checking packaging syntax"
if command -v bash >/dev/null 2>&1; then
@ -137,14 +255,54 @@ 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"
[ -z "$(git status --short)" ] ||
fail "working tree must be clean for strict release"
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
fail "missing local tag v$version"
[ "$(git rev-parse "refs/tags/v$version^{}")" = "$(git rev-parse HEAD)" ] ||
fail "local tag v$version does not point at HEAD"
grep -q "^## $version " docs/CHANGELOG.md ||
fail "docs/CHANGELOG.md does not contain a release section for $version"
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
fail "replace maintainer email placeholders before strict release"
step "checking tagged source archive"
archive="$tmpdir/tnt-chat-v$version-source.tar.gz"
archive_extract="$tmpdir/source"
archive_install="$tmpdir/source-install"
archive_root="$archive_extract/TNT-$version"
scripts/package_source_archive.sh "refs/tags/v$version" "$tmpdir" >/dev/null
mkdir -p "$archive_extract"
tar -xzf "$archive" -C "$archive_extract"
[ -f "$archive_root/src/tntctl.c" ] ||
fail "tagged source archive is missing src/tntctl.c"
[ -f "$archive_root/tnt.1" ] ||
fail "tagged source archive is missing tnt.1"
[ -f "$archive_root/tntctl.1" ] ||
fail "tagged source archive is missing tntctl.1"
[ -f "$archive_root/LICENSE" ] ||
fail "tagged source archive is missing LICENSE"
(
cd "$archive_root"
make
make DESTDIR="$archive_install" PREFIX=/usr install
make DESTDIR="$archive_install" PREFIX=/usr install-systemd
)
[ -x "$archive_install/usr/bin/tnt" ] ||
fail "tagged source install is missing /usr/bin/tnt"
[ -x "$archive_install/usr/bin/tntctl" ] ||
fail "tagged source install is missing /usr/bin/tntctl"
[ -f "$archive_install/usr/share/man/man1/tnt.1" ] ||
fail "tagged source install is missing tnt.1"
[ -f "$archive_install/usr/share/man/man1/tntctl.1" ] ||
fail "tagged source install is missing tntctl.1"
grep -q "^ExecStart=/usr/bin/tnt$" \
"$archive_install/usr/lib/systemd/system/tnt.service" ||
fail "tagged source systemd unit ExecStart does not match /usr/bin/tnt"
fi
step "release preflight passed"

View file

@ -25,6 +25,7 @@ typedef struct {
int pty_width;
int pty_height;
char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
bool auth_success;
int auth_attempts;
bool channel_ready; /* Set when shell/exec request received */
@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
/* Store exec command */
if (command) {
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
if (strlen(command) >= sizeof(ctx->exec_command)) {
ctx->exec_command_too_long = true;
ctx->exec_command[0] = '\0';
} else {
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
}
}
/* Mark channel as ready */
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
ctx->pty_width = 80;
ctx->pty_height = 24;
ctx->exec_command[0] = '\0';
ctx->exec_command_too_long = false;
ctx->requested_user[0] = '\0';
ctx->auth_success = false;
ctx->auth_attempts = 0;
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
client->ref_count = 1;
pthread_mutex_init(&client->ref_lock, NULL);
pthread_mutex_init(&client->io_lock, NULL);
pthread_mutex_init(&client->whisper_lock, NULL);
if (ctx->requested_user[0] != '\0') {
strncpy(client->ssh_login, ctx->requested_user,
@ -466,18 +474,14 @@ void *bootstrap_run(void *arg) {
sizeof(client->exec_command) - 1);
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
}
/* Add a ref for the channel callbacks (eof/close/window_change) so the
* client_t outlives any in-flight callback invocation. */
client_addref(client);
client->exec_command_too_long = ctx->exec_command_too_long;
if (client_install_channel_callbacks(client) < 0) {
/* Nullify session/channel ownership so client_release won't
* double-free what cleanup_failed_session is about to free. */
client->session = NULL;
client->channel = NULL;
client_release(client); /* drop the callback ref (2 → 1) */
client_release(client); /* drop the main ref (1 → 0, frees client) */
client_release(client);
cleanup_failed_session(session, ctx);
return NULL;
}

View file

@ -1,22 +1,11 @@
#include "chat_room.h"
#include "config_defaults.h"
/* Global chat room instance */
chat_room_t *g_room = NULL;
static int room_capacity_from_env(void) {
const char *env = getenv("TNT_MAX_CONNECTIONS");
if (!env || env[0] == '\0') {
return MAX_CLIENTS;
}
char *end;
long capacity = strtol(env, &end, 10);
if (*end != '\0' || capacity < 1 || capacity > 1024) {
return MAX_CLIENTS;
}
return (int)capacity;
return tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
}
/* Initialize chat room */

View file

@ -1,62 +1,103 @@
#include "cli_text.h"
#include "config_defaults.h"
#include "i18n.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, help_lang_t lang) {
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"
" --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
" --public-host HOST Show HOST in startup connection hints\n"
" --max-connections N Global connection limit (default: %d)\n"
" --max-conn-per-ip N Per-IP concurrent session limit\n"
" --max-conn-rate-per-ip N Per-IP connection-rate limit\n"
" --rate-limit 0|1 Disable/enable rate-based blocking\n"
" --idle-timeout SECONDS Idle disconnect timeout\n"
" --ssh-log-level LEVEL libssh log level 0..4\n"
" --log-check FILE Check messages.log v1 records\n"
" --log-recover FILE Write valid records to stdout\n"
" -V, --version Show version\n"
" -h, --help Show this help\n"
"\n"
"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: %d)\n"
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: %d)\n",
"tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n"
"选项:\n"
" -p, --port PORT 监听 PORT (默认: %d)\n"
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
" --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n"
" --public-host HOST 在启动提示中显示 HOST\n"
" --max-connections N 全局连接数限制 (默认: %d)\n"
" --max-conn-per-ip N 单 IP 并发会话限制\n"
" --max-conn-rate-per-ip N 单 IP 连接速率限制\n"
" --rate-limit 0|1 禁用/启用速率封禁\n"
" --idle-timeout SECONDS 空闲断开时间\n"
" --ssh-log-level LEVEL libssh 日志级别 0..4\n"
" --log-check FILE 检查 messages.log v1 记录\n"
" --log-recover FILE 将有效记录写入 stdout\n"
" -V, --version 显示版本\n"
" -h, --help 显示此帮助\n"
"\n"
"环境变量:\n"
" PORT 默认监听端口\n"
" TNT_STATE_DIR 状态目录\n"
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: %d)\n"
);
const char *program = (program_name && program_name[0] != '\0')
? program_name
: "tnt";
if (lang == LANG_ZH) {
buffer_appendf(buffer, buf_size, pos,
"tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [选项]\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",
TNT_VERSION, program, DEFAULT_PORT);
return;
}
buffer_appendf(buffer, buf_size, pos,
"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_VERSION, program, DEFAULT_PORT);
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
TNT_VERSION, program, TNT_DEFAULT_PORT,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_IDLE_TIMEOUT);
}
const char *cli_text_invalid_port_format(help_lang_t lang) {
return lang == LANG_ZH ? "端口无效: %s\n" : "Invalid port: %s\n";
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(help_lang_t lang) {
return lang == LANG_ZH ? "未知选项: %s\n" : "Unknown option: %s\n";
const char *cli_text_invalid_value_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Invalid %s: %s\n", "%s 无效: %s\n");
return i18n_string(text, lang);
}
const char *cli_text_short_usage_format(help_lang_t lang) {
return lang == LANG_ZH ? "用法: %s [-p PORT] [-d DIR] [-h]\n"
: "Usage: %s [-p PORT] [-d DIR] [-h]\n";
const char *cli_text_option_requires_arg_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Option requires argument: %s\n",
"选项需要参数: %s\n");
return i18n_string(text, lang);
}
const char *cli_text_unknown_option_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");
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 [options]\n",
"用法: %s [options]\n");
return i18n_string(text, lang);
}

View file

@ -9,11 +9,139 @@
#include <stdio.h>
#include <stdlib.h>
/* Send data to client via SSH channel */
int client_send(client_t *client, const char *data, size_t len) {
static int client_send_fail(client_t *client) {
if (client) {
client->connected = false;
}
return -1;
}
static bool client_is_exec(const client_t *client) {
return client && (client->exec_command[0] != '\0' ||
client->exec_command_too_long);
}
static int client_write_direct_locked(client_t *client, const char *data,
size_t len, size_t budget,
bool fail_on_closed_window) {
size_t total = 0;
while (total < len) {
size_t remaining = len - total;
uint32_t window = ssh_channel_window_size(client->channel);
if (window == 0) {
if (!fail_on_closed_window) {
break;
}
return client_send_fail(client);
}
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
if (chunk > window) {
chunk = window;
}
if (budget > 0 && chunk > budget) {
chunk = (uint32_t)budget;
}
int sent = ssh_channel_write(client->channel, data + total, chunk);
if (sent <= 0) {
return client_send_fail(client);
}
total += (size_t)sent;
if (budget > 0) {
budget -= (size_t)sent;
if (budget == 0) {
break;
}
}
}
return (int)total;
}
static int client_flush_output_locked(client_t *client, size_t budget) {
size_t pending;
int sent;
if (!client->outbox || client->outbox_pos >= client->outbox_len) {
if (client->outbox) {
client->outbox_pos = 0;
client->outbox_len = 0;
}
return 0;
}
pending = client->outbox_len - client->outbox_pos;
sent = client_write_direct_locked(client, client->outbox + client->outbox_pos,
pending, budget, false);
if (sent < 0) {
return -1;
}
client->outbox_pos += (size_t)sent;
if (client->outbox_pos >= client->outbox_len) {
client->outbox_pos = 0;
client->outbox_len = 0;
}
return 0;
}
static int client_compact_outbox(client_t *client) {
if (!client->outbox || client->outbox_pos == 0) {
return 0;
}
if (client->outbox_pos < client->outbox_len) {
memmove(client->outbox, client->outbox + client->outbox_pos,
client->outbox_len - client->outbox_pos);
client->outbox_len -= client->outbox_pos;
} else {
client->outbox_len = 0;
}
client->outbox_pos = 0;
return 0;
}
static int client_enqueue_output_locked(client_t *client, const char *data,
size_t len) {
if (len == 0) {
return 0;
}
if (len > CLIENT_OUTBOX_CAPACITY) {
return client_send_fail(client);
}
if (!client->outbox) {
client->outbox = malloc(CLIENT_OUTBOX_CAPACITY);
if (!client->outbox) {
return client_send_fail(client);
}
client->outbox_capacity = CLIENT_OUTBOX_CAPACITY;
client->outbox_len = 0;
client->outbox_pos = 0;
}
client_compact_outbox(client);
if (client->outbox_len + len > client->outbox_capacity) {
return client_send_fail(client);
}
memcpy(client->outbox + client->outbox_len, data, len);
client->outbox_len += len;
return 0;
}
/* Send data to client via SSH channel */
int client_send(client_t *client, const char *data, size_t len) {
int rc = 0;
if (!client || !data) return -1;
if (len == 0) return 0;
pthread_mutex_lock(&client->io_lock);
@ -22,23 +150,57 @@ int client_send(client_t *client, const char *data, size_t len) {
return -1;
}
while (total < len) {
size_t remaining = len - total;
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
int sent = ssh_channel_write(client->channel, data + total, chunk);
if (sent <= 0) {
pthread_mutex_unlock(&client->io_lock);
return -1;
if (client_is_exec(client)) {
rc = client_write_direct_locked(client, data, len, 0, true);
if (rc >= 0 && (size_t)rc == len) {
rc = 0;
} else if (rc >= 0) {
rc = client_send_fail(client);
}
total += (size_t)sent;
}
if (client->exec_command[0] != '\0') {
ssh_blocking_flush(client->session, 1000);
} else {
rc = client_enqueue_output_locked(client, data, len);
if (rc == 0) {
rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET);
}
}
pthread_mutex_unlock(&client->io_lock);
return 0;
return rc;
}
int client_flush_output(client_t *client) {
int rc;
if (!client) return 0;
pthread_mutex_lock(&client->io_lock);
if (!client->connected || !client->channel) {
pthread_mutex_unlock(&client->io_lock);
return -1;
}
rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET);
pthread_mutex_unlock(&client->io_lock);
return rc;
}
void client_queue_bell(client_t *client) {
if (!client) return;
atomic_store(&client->pending_bells, 1);
client->redraw_pending = true;
}
int client_flush_pending_bells(client_t *client) {
if (!client) return 0;
if (atomic_exchange(&client->pending_bells, 0) <= 0) {
return 0;
}
return client_send(client, "\a", 1);
}
void client_addref(client_t *client) {
@ -74,12 +236,34 @@ void client_release(client_t *client) {
if (client->channel_cb) {
free(client->channel_cb);
}
free(client->outbox);
free(client->render_buffer);
pthread_mutex_destroy(&client->io_lock);
pthread_mutex_destroy(&client->whisper_lock);
pthread_mutex_destroy(&client->ref_lock);
free(client);
}
}
void client_release_session(client_t *client) {
if (!client) return;
if (client->channel && client->channel_cb) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
if (client->channel_cb) {
free(client->channel_cb);
client->channel_cb = NULL;
}
if (client->channel_callback_ref) {
client->channel_callback_ref = false;
client_release(client);
}
client_release(client);
}
/* Send formatted string to client */
int client_printf(client_t *client, const char *fmt, ...) {
char buffer[2048];
@ -151,8 +335,13 @@ int client_install_channel_callbacks(client_t *client) {
return -1;
}
client_addref(client);
client->channel_callback_ref = true;
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!client->channel_cb) {
client->channel_callback_ref = false;
client_release(client);
return -1;
}
@ -166,6 +355,8 @@ int client_install_channel_callbacks(client_t *client) {
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
free(client->channel_cb);
client->channel_cb = NULL;
client->channel_callback_ref = false;
client_release(client);
return -1;
}

326
src/command_catalog.c Normal file
View file

@ -0,0 +1,326 @@
#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_REPLY, "reply", {"reply", "r", NULL}},
I18N_STRING(":reply <message>, :r <message>",
":reply <message>, :r <message>"),
I18N_STRING("Reply to latest private message", "回复最近私信"),
I18N_STRING(":reply <message>", ":reply <message>"),
I18N_STRING("Usage: reply <message>\n"
" r <message>\n",
"用法: reply <message>\n"
" r <message>\n"),
2, false, true
},
{
{TNT_COMMAND_INBOX, "inbox", {"inbox", NULL}},
I18N_STRING(":inbox, :inbox clear", ":inbox, :inbox clear"),
I18N_STRING("Show or clear private messages", "查看或清空私信"),
I18N_STRING(":inbox", ":inbox"),
I18N_STRING("Usage: inbox [clear]\n", "用法: inbox [clear]\n"),
2, false, 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 (id == TNT_COMMAND_INBOX) {
return !args || args[0] == '\0' || strcmp(args, "clear") == 0;
}
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

@ -7,11 +7,11 @@
#include "commands.h"
#include "chat_room.h"
#include "client.h"
#include "command_catalog.h"
#include "common.h"
#include "help_text.h"
#include "i18n.h"
#include "manual.h"
#include "message.h"
#include "support.h"
#include "system_message.h"
#include "tui.h"
#include "utf8.h"
@ -47,63 +47,171 @@ static void append_highlighted(char *output, size_t buf_size, size_t *pos,
}
}
static int min3(int a, int b, int c) {
int m = a < b ? a : b;
return m < c ? m : c;
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);
}
static int command_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];
static bool message_visible_for_client(const client_t *client,
const message_t *msg) {
return !client || !client->mute_joins ||
!system_message_is_join_leave(msg);
}
static const char *suggest_command(const char *cmd) {
static const char *commands[] = {
"list", "users", "who", "nick", "name", "msg", "w", "inbox",
"last", "search", "mute-joins", "mute", "support", "guide",
"lang", "language", "help", "commands", "clear", "cls",
"q", "quit", "exit"
};
const char *best = NULL;
int best_distance = 99;
static void client_append_whisper(client_t *owner, const char *from,
const char *to, const char *content,
bool outgoing, bool count_unread) {
if (!owner || !from || !to || !content) return;
if (!cmd || !*cmd) {
return NULL;
pthread_mutex_lock(&owner->whisper_lock);
int slot;
if (owner->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = owner->whisper_inbox_count++;
} else {
memmove(&owner->whisper_inbox[0],
&owner->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
for (size_t i = 0; i < sizeof(commands) / sizeof(commands[0]); i++) {
int distance = command_edit_distance(cmd, commands[i]);
if (distance < best_distance) {
best_distance = distance;
best = commands[i];
owner->whisper_inbox[slot].timestamp = time(NULL);
snprintf(owner->whisper_inbox[slot].from,
sizeof(owner->whisper_inbox[slot].from), "%s", from);
snprintf(owner->whisper_inbox[slot].to,
sizeof(owner->whisper_inbox[slot].to), "%s", to);
snprintf(owner->whisper_inbox[slot].content,
sizeof(owner->whisper_inbox[slot].content), "%s", content);
owner->whisper_inbox[slot].outgoing = outgoing;
owner->whisper_inbox[slot].unread = count_unread;
snprintf(owner->last_whisper_peer, sizeof(owner->last_whisper_peer), "%s",
outgoing ? to : from);
if (count_unread) {
owner->unread_whispers++;
}
pthread_mutex_unlock(&owner->whisper_lock);
}
static void send_private_message(client_t *client, const char *target_name,
const char *content, char *output,
size_t buf_size, size_t *pos) {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
return best_distance <= 2 ? best : NULL;
if (target) {
client_append_whisper(target, client->username, target_name,
content, false, true);
if (target != client) {
client_append_whisper(client, client->username, target_name,
content, true, false);
}
/* Audible nudge: the title bar whisper counter carries the
* persistent signal without cross-client SSH writes. */
client_queue_bell(target);
client_release(target);
}
if (found) {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang, I18N_MSG_SENT_FORMAT),
target_name);
} else {
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
}
}
static void append_inbox_output(client_t *client, char *output,
size_t buf_size, size_t *pos) {
whisper_t snapshot[WHISPER_INBOX_SIZE];
int snap_count;
int unread_count;
pthread_mutex_lock(&client->whisper_lock);
snap_count = client->whisper_inbox_count;
unread_count = client->unread_whispers;
memcpy(snapshot, client->whisper_inbox,
snap_count * sizeof(whisper_t));
for (int i = 0; i < snap_count; i++) {
client->whisper_inbox[i].unread = false;
}
client->unread_whispers = 0;
pthread_mutex_unlock(&client->whisper_lock);
buffer_appendf(output, buf_size, pos,
"\033[1;36m%s\033[0m \033[2;37m· %d",
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
snap_count);
if (unread_count > 0) {
buffer_appendf(output, buf_size, pos,
" · ");
buffer_appendf(output, buf_size, pos,
i18n_text(client->ui_lang,
I18N_INBOX_UNREAD_FORMAT),
unread_count);
}
buffer_appendf(output, buf_size, pos, "\033[0m\n");
if (snap_count == 0) {
buffer_appendf(output, buf_size, pos,
" \033[2;37m%s\033[0m\n",
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
}
for (int i = snap_count - 1; i >= 0; i--) {
char ts[20];
char peer[MAX_USERNAME_LEN + 16];
const char *marker = snapshot[i].unread ? "\033[1;35m*\033[0m" : " ";
struct tm tmi;
localtime_r(&snapshot[i].timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
if (snapshot[i].outgoing) {
snprintf(peer, sizeof(peer),
i18n_text(client->ui_lang,
I18N_INBOX_SENT_TO_FORMAT),
snapshot[i].to);
} else {
snprintf(peer, sizeof(peer), "%s", snapshot[i].from);
}
buffer_appendf(output, buf_size, pos,
" %s \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
marker, ts, peer, snapshot[i].content);
}
}
static void clear_inbox(client_t *client) {
pthread_mutex_lock(&client->whisper_lock);
memset(client->whisper_inbox, 0, sizeof(client->whisper_inbox));
client->whisper_inbox_count = 0;
client->unread_whispers = 0;
client->last_whisper_peer[0] = '\0';
pthread_mutex_unlock(&client->whisper_lock);
}
bool commands_refresh_active_output(client_t *client) {
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
size_t pos = 0;
if (!client || client->command_output_kind != TNT_COMMAND_OUTPUT_INBOX) {
return false;
}
append_inbox_output(client, output, sizeof(output), &pos);
snprintf(client->command_output, sizeof(client->command_output), "%s",
output);
client->command_output_scroll = 0;
return true;
}
void commands_dispatch(client_t *client) {
@ -111,7 +219,8 @@ void commands_dispatch(client_t *client) {
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};
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
size_t pos = 0;
/* Trim whitespace */
@ -124,6 +233,10 @@ void commands_dispatch(client_t *client) {
end--;
}
}
if (cmd[0] == ':') {
cmd++;
while (*cmd == ' ') cmd++;
}
/* Save to command history */
if (cmd[0] != '\0') {
@ -139,13 +252,45 @@ 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) {
if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
}
tnt_command_id_t command_id;
const char *arg = "";
if (!command_catalog_match(cmd, &command_id, &arg)) {
const char *suggestion = command_catalog_suggest(cmd);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_UNKNOWN_GUIDANCE));
goto cmd_done;
}
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->help_lang, I18N_USERS_TITLE), total);
i18n_text(client->ui_lang, I18N_USERS_TITLE), total);
time_t now = time(NULL);
for (int i = 0; i < total; i++) {
@ -168,47 +313,33 @@ void commands_dispatch(client_t *client) {
}
pthread_rwlock_unlock(&g_room->lock);
} else if (strcmp(cmd, "help") == 0 || strcmp(cmd, "commands") == 0) {
help_text_append_commands(output, sizeof(output), &pos,
client->help_lang);
} else if (command_id == TNT_COMMAND_HELP) {
manual_append_interactive_panel(output, sizeof(output), &pos,
client->ui_lang);
} else if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
support_append_interactive_panel(output, sizeof(output), &pos,
client->help_lang);
} else if (strcmp(cmd, "lang") == 0 || strcmp(cmd, "language") == 0 ||
strncmp(cmd, "lang ", 5) == 0 ||
strncmp(cmd, "language ", 9) == 0) {
char *arg = NULL;
help_lang_t next_lang;
if (strncmp(cmd, "lang ", 5) == 0) {
arg = cmd + 5;
} else if (strncmp(cmd, "language ", 9) == 0) {
arg = cmd + 9;
}
} else if (command_id == TNT_COMMAND_LANG) {
ui_lang_t next_lang;
if (!arg || arg[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
i18n_text(client->ui_lang,
I18N_LANG_CURRENT_FORMAT),
i18n_lang_code(client->help_lang));
} else if (i18n_try_parse_lang(arg, &next_lang)) {
client->help_lang = next_lang;
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->help_lang,
i18n_text(client->ui_lang,
I18N_LANG_SET_FORMAT),
i18n_lang_code(client->help_lang));
i18n_ui_lang_code(client->ui_lang));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
i18n_text(client->ui_lang,
I18N_LANG_UNSUPPORTED_FORMAT),
arg);
}
} else if (strcmp(cmd, "msg") == 0 || strcmp(cmd, "w") == 0 ||
strncmp(cmd, "msg ", 4) == 0 || strncmp(cmd, "w ", 2) == 0) {
char *rest = (cmd[0] == 'w') ? cmd + 1 : cmd + 3;
} else if (command_id == TNT_COMMAND_MSG) {
const char *rest = arg;
while (*rest == ' ') rest++;
char target_name[MAX_USERNAME_LEN] = {0};
int ti = 0;
@ -218,108 +349,63 @@ void commands_dispatch(client_t *client) {
while (*rest == ' ') rest++;
if (target_name[0] == '\0' || rest[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_MSG_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_MSG, client->ui_lang);
} else {
bool found = false;
client_t *target = NULL;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->client_count; i++) {
if (strcmp(g_room->clients[i]->username, target_name) == 0) {
target = g_room->clients[i];
client_addref(target);
found = true;
break;
}
}
pthread_rwlock_unlock(&g_room->lock);
send_private_message(client, target_name, rest, output,
sizeof(output), &pos);
}
if (target) {
/* Push into recipient's inbox. io_lock serialises so two
* senders to the same recipient don't tear the ring. */
pthread_mutex_lock(&target->io_lock);
int slot;
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
slot = target->whisper_inbox_count++;
} else {
/* FIFO evict the oldest */
memmove(&target->whisper_inbox[0],
&target->whisper_inbox[1],
(WHISPER_INBOX_SIZE - 1) * sizeof(whisper_t));
slot = WHISPER_INBOX_SIZE - 1;
}
target->whisper_inbox[slot].timestamp = time(NULL);
snprintf(target->whisper_inbox[slot].from,
sizeof(target->whisper_inbox[slot].from),
"%s", client->username);
snprintf(target->whisper_inbox[slot].content,
sizeof(target->whisper_inbox[slot].content),
"%s", rest);
pthread_mutex_unlock(&target->io_lock);
} else if (command_id == TNT_COMMAND_REPLY) {
const char *message = arg;
char target_name[MAX_USERNAME_LEN] = {0};
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);
}
while (*message == ' ') message++;
if (message[0] == '\0') {
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_REPLY, client->ui_lang);
} else {
pthread_mutex_lock(&client->whisper_lock);
snprintf(target_name, sizeof(target_name), "%s",
client->last_whisper_peer);
pthread_mutex_unlock(&client->whisper_lock);
if (found) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_MSG_SENT_FORMAT),
target_name);
if (target_name[0] == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_REPLY_NO_TARGET));
} else {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_MSG_USER_NOT_FOUND_FORMAT),
target_name);
send_private_message(client, target_name, message, output,
sizeof(output), &pos);
}
}
} else if (strcmp(cmd, "inbox") == 0) {
/* 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->help_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->help_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_INBOX) {
const char *inbox_arg = arg;
while (*inbox_arg == ' ') inbox_arg++;
if (strcmp(inbox_arg, "clear") == 0) {
clear_inbox(client);
output_kind = TNT_COMMAND_OUTPUT_INBOX;
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_INBOX_CLEARED));
buffer_appendf(output, sizeof(output), &pos, "\n");
append_inbox_output(client, output, sizeof(output), &pos);
} else {
output_kind = TNT_COMMAND_OUTPUT_INBOX;
append_inbox_output(client, output, sizeof(output), &pos);
}
} else if (strcmp(cmd, "nick") == 0 || strcmp(cmd, "name") == 0 ||
strncmp(cmd, "nick ", 5) == 0 || strncmp(cmd, "name ", 5) == 0) {
char *new_name = cmd + 4;
} 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, "%s",
i18n_text(client->help_lang, I18N_NICK_USAGE));
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, "%s",
i18n_text(client->help_lang, I18N_NICK_INVALID));
i18n_text(client->ui_lang, I18N_NICK_INVALID));
} else {
char validated_name[MAX_USERNAME_LEN];
snprintf(validated_name, sizeof(validated_name), "%s", new_name);
@ -351,133 +437,137 @@ void commands_dispatch(client_t *client) {
if (taken) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
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->help_lang,
i18n_text(client->ui_lang,
I18N_NICK_UNCHANGED));
} else {
message_t nick_msg;
system_message_make_nick(&nick_msg, old_name,
client->username, client->help_lang);
client->username, client->ui_lang);
room_broadcast(g_room, &nick_msg);
message_save(&nick_msg);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
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, "%s",
i18n_text(client->help_lang, I18N_LAST_USAGE));
append_command_usage(output, sizeof(output), &pos,
TNT_COMMAND_LAST, client->ui_lang);
goto cmd_done;
}
n = (int)val;
}
message_t *last_msgs = NULL;
int last_count = message_load(&last_msgs, n);
int load_count = message_load(&last_msgs,
client->mute_joins ? MAX_MESSAGES : n);
int visible_count = 0;
for (int i = 0; i < load_count; i++) {
if (message_visible_for_client(client, &last_msgs[i])) {
last_msgs[visible_count++] = last_msgs[i];
}
}
int start = visible_count > n ? visible_count - n : 0;
int last_count = visible_count - start;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang, I18N_LAST_HEADER_FORMAT),
i18n_text(client->ui_lang, I18N_LAST_HEADER_FORMAT),
last_count);
if (last_count == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang, I18N_LAST_EMPTY));
}
for (int i = 0; i < last_count; i++) {
message_t *msg = &last_msgs[start + i];
char ts[20];
struct tm tmi;
localtime_r(&last_msgs[i].timestamp, &tmi);
localtime_r(&msg->timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] %s: %s\n", ts, last_msgs[i].username, last_msgs[i].content);
"[%s] %s: %s\n", ts, msg->username, msg->content);
}
free(last_msgs);
} else if (strcmp(cmd, "search") == 0 || strncmp(cmd, "search ", 7) == 0) {
char *query = cmd + 6;
} else if (command_id == TNT_COMMAND_SEARCH) {
const char *query = arg;
while (*query == ' ') query++;
if (*query == '\0') {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_SEARCH_USAGE));
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,
i18n_text(client->help_lang,
I18N_SEARCH_HEADER_FORMAT),
query, found_count);
int search_limit = client->mute_joins ? MAX_MESSAGES : 15;
int found_count = message_search(query, &found, search_limit);
int visible_count = 0;
for (int i = 0; i < found_count; i++) {
if (message_visible_for_client(client, &found[i])) {
found[visible_count++] = found[i];
}
}
int start = visible_count > 15 ? visible_count - 15 : 0;
int display_count = visible_count - start;
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->ui_lang,
I18N_SEARCH_HEADER_FORMAT),
query, display_count);
if (display_count == 0) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->ui_lang,
I18N_SEARCH_EMPTY));
}
for (int i = 0; i < display_count; i++) {
message_t *msg = &found[start + i];
char ts[20];
struct tm tmi;
localtime_r(&found[i].timestamp, &tmi);
localtime_r(&msg->timestamp, &tmi);
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
buffer_appendf(output, sizeof(output), &pos,
"[%s] ", ts);
append_highlighted(output, sizeof(output), &pos,
found[i].username, query);
msg->username, query);
buffer_appendf(output, sizeof(output), &pos, ": ");
append_highlighted(output, sizeof(output), &pos,
found[i].content, query);
msg->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,
i18n_text(client->help_lang, I18N_MUTE_JOINS_FORMAT),
i18n_text(client->help_lang,
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) {
} else if (command_id == TNT_COMMAND_CLEAR) {
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_CLEAR_DONE));
} else if (cmd[0] == '\0') {
/* Empty command */
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
tui_render_screen(client);
return;
} else {
const char *suggestion = suggest_command(cmd);
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_UNKNOWN_COMMAND_FORMAT),
cmd);
if (suggestion) {
buffer_appendf(output, sizeof(output), &pos,
i18n_text(client->help_lang,
I18N_DID_YOU_MEAN_FORMAT),
suggestion);
}
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_UNKNOWN_GUIDANCE));
i18n_text(client->ui_lang, I18N_CLEAR_DONE));
}
cmd_done:
buffer_appendf(output, sizeof(output), &pos, "%s",
i18n_text(client->help_lang, I18N_CONTINUE_PROMPT));
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
client->command_output_scroll = 0;
client->command_output_kind = output_kind;
client->command_input[0] = '\0';
tui_render_command_output(client);
}

80
src/config_defaults.c Normal file
View file

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

View file

@ -2,11 +2,13 @@
#include "chat_room.h"
#include "client.h"
#include "common.h"
#include "exec_catalog.h"
#include "i18n.h"
#include "input.h"
#include "json_text.h"
#include "message.h"
#include "module_runtime.h"
#include "ratelimit.h"
#include "support.h"
#include "utf8.h"
#include <ctype.h>
#include <stdio.h>
@ -56,48 +58,6 @@ static void trim_ascii_whitespace(char *text) {
}
}
static void json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text) {
const unsigned char *p = (const unsigned char *)(text ? text : "");
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
while (*p && *pos < buf_size - 1) {
char escaped[7];
switch (*p) {
case '\\':
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
break;
case '"':
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
break;
case '\n':
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
break;
case '\r':
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
break;
case '\t':
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
break;
default:
if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
buffer_append_bytes(buffer, buf_size, pos,
escaped, strlen(escaped));
} else {
buffer_append_bytes(buffer, buf_size, pos,
(const char *)p, 1);
}
break;
}
p++;
}
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
}
static void resolve_exec_username(const client_t *client, char *buffer,
size_t buf_size) {
if (!buffer || buf_size == 0) {
@ -117,22 +77,31 @@ static void resolve_exec_username(const client_t *client, char *buffer,
}
static int exec_command_help(client_t *client) {
const char *help_text = i18n_text(client->help_lang, I18N_EXEC_HELP);
return client_send(client, help_text, strlen(help_text)) == 0 ? 0 : 1;
}
static int exec_command_support(client_t *client) {
char output[2048] = {0};
char help_text[1024];
size_t pos = 0;
support_append_exec_panel(output, sizeof(output), &pos, client->help_lang);
return client_send(client, output, pos) == 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 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
}
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 TNT_EXIT_USAGE;
}
static int exec_command_health(client_t *client) {
static const char ok[] = "ok\n";
return client_send(client, ok, sizeof(ok) - 1) == 0 ? 0 : 1;
return client_send(client, ok, sizeof(ok) - 1) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
}
static int exec_command_users(client_t *client, bool json) {
@ -150,7 +119,7 @@ static int exec_command_users(client_t *client, bool json) {
if (!usernames) {
pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "users: out of memory\n");
return 1;
return TNT_EXIT_ERROR;
}
for (int i = 0; i < count; i++) {
@ -170,7 +139,7 @@ static int exec_command_users(client_t *client, bool json) {
if (!output) {
free(usernames);
client_printf(client, "users: out of memory\n");
return 1;
return TNT_EXIT_ERROR;
}
if (json) {
@ -179,7 +148,7 @@ static int exec_command_users(client_t *client, bool json) {
if (i > 0) {
buffer_append_bytes(output, output_size, &pos, ",", 1);
}
json_append_string(output, output_size, &pos, usernames[i]);
tnt_json_append_string(output, output_size, &pos, usernames[i]);
}
buffer_append_bytes(output, output_size, &pos, "]\n", 2);
} else {
@ -188,7 +157,7 @@ static int exec_command_users(client_t *client, bool json) {
}
}
rc = client_send(client, output, pos) == 0 ? 0 : 1;
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
free(output);
free(usernames);
return rc;
@ -236,10 +205,11 @@ static int exec_command_stats(client_t *client, bool json) {
if (len < 0 || len >= (int)sizeof(buffer)) {
client_printf(client, "stats: output overflow\n");
return 1;
return TNT_EXIT_ERROR;
}
return client_send(client, buffer, (size_t)len) == 0 ? 0 : 1;
return client_send(client, buffer, (size_t)len) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
}
static int parse_tail_count(const char *args, int *count) {
@ -281,6 +251,45 @@ static int parse_tail_count(const char *args, int *count) {
return 0;
}
static int parse_dump_count(const char *args, int *count) {
char *end = NULL;
long value;
if (!count) {
return -1;
}
*count = 0;
if (!args || args[0] == '\0') {
return 0;
}
if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
args++;
}
}
value = strtol(args, &end, 10);
if (end == args) {
return -1;
}
while (*end) {
if (!isspace((unsigned char)*end)) {
return -1;
}
end++;
}
if (value < 1 || value > 10000) {
return -1;
}
*count = (int)value;
return 0;
}
static int exec_command_tail(client_t *client, const char *args) {
int requested = 20;
int total_messages;
@ -293,9 +302,7 @@ static int exec_command_tail(client_t *client, const char *args) {
int rc;
if (parse_tail_count(args, &requested) < 0) {
client_printf(client, "%s",
i18n_text(client->help_lang, I18N_EXEC_TAIL_USAGE));
return 64;
return exec_command_usage(client, TNT_EXEC_COMMAND_TAIL);
}
pthread_rwlock_rdlock(&g_room->lock);
@ -311,7 +318,7 @@ static int exec_command_tail(client_t *client, const char *args) {
if (!snapshot) {
pthread_rwlock_unlock(&g_room->lock);
client_printf(client, "tail: out of memory\n");
return 1;
return TNT_EXIT_ERROR;
}
memcpy(snapshot, &g_room->messages[start], (size_t)count * sizeof(message_t));
}
@ -323,7 +330,7 @@ static int exec_command_tail(client_t *client, const char *args) {
if (!output) {
free(snapshot);
client_printf(client, "tail: out of memory\n");
return 1;
return TNT_EXIT_ERROR;
}
for (int i = 0; i < count; i++) {
@ -333,12 +340,33 @@ static int exec_command_tail(client_t *client, const char *args) {
timestamp, snapshot[i].username, snapshot[i].content);
}
rc = client_send(client, output, pos) == 0 ? 0 : 1;
rc = client_send(client, output, pos) == 0 ? TNT_EXIT_OK : TNT_EXIT_ERROR;
free(output);
free(snapshot);
return rc;
}
static int exec_command_dump(client_t *client, const char *args) {
int requested = 0;
char *output = NULL;
size_t output_len = 0;
int rc;
if (parse_dump_count(args, &requested) < 0) {
return exec_command_usage(client, TNT_EXEC_COMMAND_DUMP);
}
if (message_dump_text(&output, &output_len, requested) < 0) {
client_printf(client, "dump: failed to read message log\n");
return TNT_EXIT_ERROR;
}
rc = client_send(client, output, output_len) == 0 ? TNT_EXIT_OK
: TNT_EXIT_ERROR;
free(output);
return rc;
}
static int exec_command_post(client_t *client, const char *args) {
char content[MAX_MESSAGE_LEN];
char username[MAX_USERNAME_LEN];
@ -347,9 +375,13 @@ static int exec_command_post(client_t *client, const char *args) {
};
if (!args || args[0] == '\0') {
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
}
if (strlen(args) >= sizeof(content)) {
client_printf(client, "%s",
i18n_text(client->help_lang, I18N_EXEC_POST_USAGE));
return 64;
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
return TNT_EXIT_USAGE;
}
strncpy(content, args, sizeof(content) - 1);
@ -358,15 +390,15 @@ static int exec_command_post(client_t *client, const char *args) {
if (content[0] == '\0') {
client_printf(client, "%s",
i18n_text(client->help_lang, I18N_EXEC_POST_EMPTY));
return 64;
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
return TNT_EXIT_USAGE;
}
if (!utf8_is_valid_string(content)) {
client_printf(client, "%s",
i18n_text(client->help_lang,
i18n_text(client->ui_lang,
I18N_EXEC_POST_INVALID_UTF8));
return 1;
return TNT_EXIT_ERROR;
}
resolve_exec_username(client, username, sizeof(username));
@ -385,87 +417,81 @@ static int exec_command_post(client_t *client, const char *args) {
msg.content[sizeof(msg.content) - 1] = '\0';
}
room_broadcast(g_room, &msg);
if (client_send(client, "posted\n", 7) != 0) {
return 1;
}
notify_mentions(msg.content, client);
if (message_save(&msg) < 0) {
fprintf(stderr, "post: failed to persist message\n");
return 1;
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_POST_PERSIST_FAILED));
return TNT_EXIT_ERROR;
}
return 0;
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
tnt_module_runtime_publish_message_created(&msg);
if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR;
}
return TNT_EXIT_OK;
}
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;
if (client->exec_command_too_long) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_COMMAND_TOO_LONG));
return TNT_EXIT_USAGE;
}
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++;
if (exec_catalog_match(command_copy, &command_id, &args)) {
if (!exec_catalog_args_valid(command_id, args)) {
return exec_command_usage(client, command_id);
}
switch (command_id) {
case TNT_EXEC_COMMAND_HELP:
return exec_command_help(client);
case TNT_EXEC_COMMAND_HEALTH:
return exec_command_health(client);
case TNT_EXEC_COMMAND_USERS:
return exec_command_users(client, args != NULL);
case TNT_EXEC_COMMAND_STATS:
return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_DUMP:
return exec_command_dump(client, args);
case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT:
return TNT_EXIT_OK;
case TNT_EXEC_COMMAND_COUNT:
break;
}
} else {
args = NULL;
}
if (strcmp(cmd, "help") == 0 || strcmp(cmd, "--help") == 0) {
return exec_command_help(client);
}
if (strcmp(cmd, "support") == 0 || strcmp(cmd, "guide") == 0) {
return exec_command_support(client);
}
if (strcmp(cmd, "health") == 0) {
return exec_command_health(client);
}
if (strcmp(cmd, "users") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "%s",
i18n_text(client->help_lang,
I18N_EXEC_USERS_USAGE));
return 64;
for (char *p = command_copy; *p; p++) {
if (isspace((unsigned char)*p)) {
*p = '\0';
break;
}
return exec_command_users(client, args != NULL);
}
if (strcmp(cmd, "stats") == 0) {
if (args && strcmp(args, "--json") != 0) {
client_printf(client, "%s",
i18n_text(client->help_lang,
I18N_EXEC_STATS_USAGE));
return 64;
}
return exec_command_stats(client, args != NULL);
}
if (strcmp(cmd, "tail") == 0) {
return exec_command_tail(client, args);
}
if (strcmp(cmd, "post") == 0) {
return exec_command_post(client, args);
}
if (strcmp(cmd, "exit") == 0) {
return 0;
}
client_printf(client,
i18n_text(client->help_lang,
i18n_text(client->ui_lang,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
cmd);
return 64;
command_copy);
return TNT_EXIT_USAGE;
}

189
src/exec_catalog.c Normal file
View file

@ -0,0 +1,189 @@
#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_DUMP, "dump", NULL,
"dump [N]", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_DUMP, "dump", NULL,
"dump -n N", "dump [N] | dump -n N",
I18N_STRING("Export persisted messages", "导出持久化消息"),
false, false, false},
{TNT_EXEC_COMMAND_POST, "post", NULL,
"post MESSAGE", "post MESSAGE",
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
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_command_list(char *buffer, size_t buf_size,
size_t *pos) {
bool seen[TNT_EXEC_COMMAND_COUNT] = {0};
size_t count = 0;
for (size_t i = 0; i < sizeof(entries) / sizeof(entries[0]); i++) {
tnt_exec_command_id_t id = entries[i].id;
if (id < 0 || id >= TNT_EXEC_COMMAND_COUNT || seen[id]) {
continue;
}
if (count > 0) {
buffer_appendf(buffer, buf_size, pos, ", ");
}
buffer_appendf(buffer, buf_size, pos, "%s", entries[i].name);
seen[id] = true;
count++;
}
}
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
tnt_exec_command_id_t id, ui_lang_t lang) {
const exec_catalog_entry_t *entry = entry_for_id(id);
static const i18n_string_t usage_format =
I18N_STRING("%s: usage: %s\n", "%s: 用法: %s\n");
if (!entry) {
return;
}
buffer_appendf(buffer, buf_size, pos, i18n_string(usage_format, lang),
entry->name, entry->usage_syntax);
}

View file

@ -1,167 +1,136 @@
#include "help_text.h"
void help_text_append_commands(char *output, size_t buf_size, size_t *pos,
help_lang_t lang) {
if (lang == LANG_ZH) {
buffer_appendf(output, buf_size, pos,
"========================================\n"
" 可用命令\n"
"========================================\n"
"list, users, who - 显示在线用户\n"
"nick/name <name> - 修改昵称\n"
"msg/w <user> <text> - 私聊用户\n"
"inbox - 查看私聊历史\n"
"last [N] - 查看最近 N 条消息\n"
"search <keyword> - 搜索消息历史\n"
"mute-joins - 切换加入/离开提示\n"
"support - 显示快速支持指南\n"
"lang [en|zh] - 查看或切换界面语言\n"
"help, commands - 显示此帮助\n"
"clear, cls - 清空命令输出\n"
"q, quit, exit - 断开连接\n"
"上/下方向键 - 命令历史\n"
"========================================\n"
"INSERT 模式:\n"
" /me <action> - 发送动作消息\n"
" @username - 提及用户并响铃提示\n"
"========================================\n");
return;
}
#include "command_catalog.h"
#include "i18n.h"
buffer_appendf(output, buf_size, 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 (private)\n"
"inbox - Show whisper history\n"
"last [N] - Show last N messages\n"
"search <keyword> - Search message history\n"
"mute-joins - Toggle join/leave notices\n"
"support - Show quick support guide\n"
"lang [en|zh] - Show or switch UI language\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");
}
const char *help_text_full(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"
" 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 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"
" :support - Show quick support guide\n"
" :lang <en|zh> - Switch UI language\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"
" 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"
" e/z - Switch English/Chinese\n";
}
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"
" 默认停在最新消息\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"
" :list, :users - 显示在线用户\n"
" :nick <名字> - 更改昵称\n"
" :msg <用户> <文本> - 私聊\n"
" :w <用户> <文本> - :msg 的简写\n"
" :last [N] - 显示最后 N 条消息(最多50)\n"
" :search <关键词> - 搜索消息历史\n"
" :mute-joins - 切换加入/离开提示\n"
" :support - 显示快速支持指南\n"
" :lang <en|zh> - 切换界面语言\n"
" :help - 显示可用命令\n"
" :clear - 清空命令输出\n"
" :q, :quit, :exit - 断开连接\n"
"\n"
"特殊消息:\n"
" /me <动作> - 发送动作 (如 /me 挥手)\n"
" @用户名 - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" g/G - 跳到顶部/底部\n"
" e/z - 切换英文/中文\n";
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"
" Up/Down - Recall sent messages\n"
" Tab - Complete @mention\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/a/o - Return to INSERT mode\n"
" : - Enter COMMAND mode\n"
" / - Search message history\n"
" j/k - Scroll down/up one line\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" 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"
" Up/Down - 调出已发送消息\n"
" Tab - 补全 @mention\n"
" Ctrl+C - 进入 NORMAL 模式\n"
"\n"
"NORMAL 模式按键:\n"
" 默认停在最新消息\n"
" 未向上翻阅时自动跟随最新消息\n"
" i/a/o - 返回 INSERT 模式\n"
" : - 进入 COMMAND 模式\n"
" / - 搜索消息历史\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, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" r - Refresh live output (:inbox)\n"
"\n"
"SPECIAL MESSAGES:\n"
" /me <action> - Send action (e.g. /me waves)\n"
" @username - Mention user (bell + highlight)\n"
"\n"
"HELP SCREEN KEYS:\n"
" q, ESC - Close help\n"
" j/k, arrows - Scroll down/up\n"
" Ctrl+D/U - Scroll half page down/up\n"
" Ctrl+F/B - Scroll full page down/up\n"
" Space/b - Scroll full page down/up\n"
" PgDn/PgUp - Scroll full page down/up\n"
" End/Home - Jump to bottom/top\n"
" g/G - Jump to top/bottom\n"
" l - Cycle UI language\n",
"\n"
"命令输出按键:\n"
" q, ESC - 关闭输出\n"
" j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" r - 刷新动态输出 (:inbox)\n"
"\n"
"特殊消息:\n"
" /me <action> - 发送动作 (如 /me waves)\n"
" @username - 提及用户 (响铃+高亮)\n"
"\n"
"帮助界面按键:\n"
" q, ESC - 关闭帮助\n"
" j/k, arrows - 向下/上滚动\n"
" Ctrl+D/U - 向下/上滚动半页\n"
" Ctrl+F/B - 向下/上滚动整页\n"
" Space/b - 向下/上滚动整页\n"
" PgDn/PgUp - 向下/上滚动整页\n"
" End/Home - 跳到底部/顶部\n"
" g/G - 跳到顶部/底部\n"
" l - 切换界面语言\n"
);
buffer_appendf(buffer, buf_size, pos, "%s",
i18n_string(before_commands, lang));
command_catalog_append_full(buffer, buf_size, pos, lang);
buffer_appendf(buffer, buf_size, pos, "%s",
i18n_string(after_commands, lang));
}

View file

@ -2,6 +2,19 @@
#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)) {
@ -36,41 +49,37 @@ static bool starts_with_lang(const char *value, const char *prefix) {
return is_lang_boundary(value);
}
bool i18n_try_parse_lang(const char *value, help_lang_t *lang) {
bool i18n_try_parse_ui_lang(const char *value, ui_lang_t *lang) {
if (!value || value[0] == '\0') {
return false;
}
if (starts_with_lang(value, "zh") ||
starts_with_lang(value, "cn") ||
starts_with_lang(value, "chinese")) {
if (lang) *lang = LANG_ZH;
return true;
}
if (starts_with_lang(value, "en") ||
starts_with_lang(value, "english") ||
starts_with_lang(value, "c") ||
starts_with_lang(value, "posix")) {
if (lang) *lang = LANG_EN;
return true;
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;
}
help_lang_t i18n_parse_lang(const char *value, help_lang_t fallback) {
help_lang_t lang;
if (i18n_try_parse_lang(value, &lang)) {
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;
}
help_lang_t i18n_default_lang(void) {
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_lang(explicit_lang, LANG_EN);
return i18n_parse_ui_lang(explicit_lang, UI_LANG_EN);
}
const char *locale = getenv("LC_ALL");
@ -81,277 +90,27 @@ help_lang_t i18n_default_lang(void) {
locale = getenv("LANG");
}
return i18n_parse_lang(locale, LANG_EN);
return i18n_parse_ui_lang(locale, UI_LANG_EN);
}
const char *i18n_lang_code(help_lang_t lang) {
return lang == LANG_ZH ? "zh" : "en";
}
ui_lang_t i18n_next_ui_lang(ui_lang_t lang) {
size_t count = sizeof(ui_lang_defs) / sizeof(ui_lang_defs[0]);
const char *i18n_text(help_lang_t lang, i18n_text_id_t id) {
if (lang == LANG_ZH) {
switch (id) {
case I18N_USERNAME_PROMPT:
return " 请输入用户名 (留空 anonymous): ";
case I18N_INVALID_USERNAME:
return "用户名无效,已改用 anonymous。\r\n";
case I18N_ROOM_FULL:
return "房间已满\r\n";
case I18N_WELCOME_SUBTITLE:
return "匿名聊天室 · SSH";
case I18N_WELCOME_TAGLINE:
return "键盘友好的终端交流";
case I18N_WELCOME_FALLBACK_FORMAT:
return "TNT %s - SSH 匿名聊天室\r\n\r\n";
case I18N_INSERT_HINT_WIDE:
return "Enter 发送 · Esc 浏览 · :support";
case I18N_INSERT_HINT_NARROW:
return "Enter · Esc · :support";
case I18N_NORMAL_LATEST:
return "G 最新";
case I18N_NORMAL_NEW_MESSAGES:
return "新消息";
case I18N_HELP_TITLE:
return " 帮助 ";
case I18N_HELP_STATUS_FORMAT:
return "-- 帮助 -- (%d/%d) j/k:滚动 g/G:首尾 e/z:语言 q:关闭";
case I18N_COMMAND_OUTPUT_TITLE:
return " 命令输出 ";
case I18N_MOTD_TITLE:
return " 公告 ";
case I18N_MOTD_CONTINUE_HINT:
return " 按任意键继续 ";
case I18N_TITLE_ONLINE_FORMAT:
return "在线 %d";
case I18N_TITLE_MUTED:
return "静音";
case I18N_TITLE_HELP_HINT:
return "? 帮助";
case I18N_IDLE_TIMEOUT_FORMAT:
return "\r\n\033[33m已断开: 空闲超时 (%d 分钟)\033[0m\r\n";
case I18N_SYSTEM_USERNAME:
return "系统";
case I18N_SYSTEM_JOIN_FORMAT:
return "%s 加入了聊天室";
case I18N_SYSTEM_LEAVE_FORMAT:
return "%s 离开了聊天室";
case I18N_SYSTEM_NICK_FORMAT:
return "%s 更名为 %s";
case I18N_USERS_TITLE:
return "在线用户";
case I18N_MSG_USAGE:
return "用法: msg <用户名> <消息>\n"
" w <用户名> <消息>\n";
case I18N_MSG_SENT_FORMAT:
return "悄悄话已发送给 %s\n";
case I18N_MSG_USER_NOT_FOUND_FORMAT:
return "未找到用户 '%s'\n";
case I18N_INBOX_TITLE:
return "悄悄话";
case I18N_INBOX_EMPTY:
return "(空)";
case I18N_NICK_USAGE:
return "用法: nick <新用户名>\n";
case I18N_NICK_INVALID:
return "用户名无效\n";
case I18N_NICK_TAKEN_FORMAT:
return "昵称 '%s' 已被使用\n";
case I18N_NICK_UNCHANGED:
return "昵称未变化\n";
case I18N_NICK_CHANGED_FORMAT:
return "昵称已修改: %s -> %s\n";
case I18N_LAST_USAGE:
return "用法: last [N] (N: 1-50默认 10)\n";
case I18N_LAST_HEADER_FORMAT:
return "--- 最近 %d 条消息 ---\n";
case I18N_SEARCH_USAGE:
return "用法: search <关键词>\n";
case I18N_SEARCH_HEADER_FORMAT:
return "--- 搜索: \"%s\" (%d 条匹配) ---\n";
case I18N_MUTE_JOINS_FORMAT:
return "加入/离开提示: %s\n";
case I18N_MUTE_JOINS_MUTED:
return "已静音";
case I18N_MUTE_JOINS_UNMUTED:
return "已开启";
case I18N_CLEAR_DONE:
return "命令输出已清空\n";
case I18N_LANG_CURRENT_FORMAT:
return "当前语言: %s\n"
"用法: lang <en|zh>\n";
case I18N_LANG_SET_FORMAT:
return "语言已切换为: %s\n";
case I18N_LANG_UNSUPPORTED_FORMAT:
return "不支持的语言: %s\n"
"用法: lang <en|zh>\n";
case I18N_UNKNOWN_COMMAND_FORMAT:
return "未知命令: %s\n";
case I18N_DID_YOU_MEAN_FORMAT:
return "你是想输入 :%s 吗?\n";
case I18N_UNKNOWN_GUIDANCE:
return "输入 :support 查看引导,或 :help 查看命令\n";
case I18N_EXEC_HELP:
return "TNT exec 接口\n"
"命令:\n"
" help 显示此帮助\n"
" health 输出服务健康状态\n"
" users [--json] 列出在线用户\n"
" stats [--json] 输出房间统计\n"
" tail [N] 输出最近消息\n"
" tail -n N 输出最近消息\n"
" post MESSAGE 非交互发送消息\n"
" post \"/me act\" 发送动作消息\n"
" support 显示快速支持指南\n"
" exit 成功退出\n";
case I18N_EXEC_USERS_USAGE:
return "users: 用法: users [--json]\n";
case I18N_EXEC_STATS_USAGE:
return "stats: 用法: stats [--json]\n";
case I18N_EXEC_TAIL_USAGE:
return "tail: 用法: tail [N] | tail -n N\n";
case I18N_EXEC_POST_USAGE:
return "post: 用法: post MESSAGE\n";
case I18N_EXEC_POST_EMPTY:
return "post: 消息不能为空\n";
case I18N_EXEC_POST_INVALID_UTF8:
return "post: 输入不是有效 UTF-8\n";
case I18N_EXEC_UNKNOWN_COMMAND_FORMAT:
return "未知命令: %s\n";
case I18N_CONTINUE_PROMPT:
return "\n按任意键继续...";
for (size_t i = 0; i < count; i++) {
if (ui_lang_defs[i].lang == lang) {
return ui_lang_defs[(i + 1) % count].lang;
}
}
switch (id) {
case I18N_USERNAME_PROMPT:
return " Enter display name (blank for anonymous): ";
case I18N_INVALID_USERNAME:
return "Invalid username. Using 'anonymous' instead.\r\n";
case I18N_ROOM_FULL:
return "Room is full\r\n";
case I18N_WELCOME_SUBTITLE:
return "anonymous chat · SSH";
case I18N_WELCOME_TAGLINE:
return "keyboard-first terminal chat";
case I18N_WELCOME_FALLBACK_FORMAT:
return "TNT %s - anonymous chat over SSH\r\n\r\n";
case I18N_INSERT_HINT_WIDE:
return "Enter send · Esc browse · :support";
case I18N_INSERT_HINT_NARROW:
return "Enter · Esc · :support";
case I18N_NORMAL_LATEST:
return "G latest";
case I18N_NORMAL_NEW_MESSAGES:
return "new";
case I18N_HELP_TITLE:
return " HELP ";
case I18N_HELP_STATUS_FORMAT:
return "-- HELP -- (%d/%d) j/k:scroll g/G:top/bottom e/z:lang q:close";
case I18N_COMMAND_OUTPUT_TITLE:
return " COMMAND OUTPUT ";
case I18N_MOTD_TITLE:
return " NOTICE ";
case I18N_MOTD_CONTINUE_HINT:
return " Press any key ";
case I18N_TITLE_ONLINE_FORMAT:
return "online %d";
case I18N_TITLE_MUTED:
return "muted";
case I18N_TITLE_HELP_HINT:
return "? help";
case I18N_IDLE_TIMEOUT_FORMAT:
return "\r\n\033[33mDisconnected: idle timeout (%d min)\033[0m\r\n";
case I18N_SYSTEM_USERNAME:
return "system";
case I18N_SYSTEM_JOIN_FORMAT:
return "%s joined the room";
case I18N_SYSTEM_LEAVE_FORMAT:
return "%s left the room";
case I18N_SYSTEM_NICK_FORMAT:
return "%s renamed to %s";
case I18N_USERS_TITLE:
return "Online users";
case I18N_MSG_USAGE:
return "Usage: msg <username> <message>\n"
" w <username> <message>\n";
case I18N_MSG_SENT_FORMAT:
return "Whisper sent to %s\n";
case I18N_MSG_USER_NOT_FOUND_FORMAT:
return "User '%s' not found\n";
case I18N_INBOX_TITLE:
return "Whispers";
case I18N_INBOX_EMPTY:
return "(empty)";
case I18N_NICK_USAGE:
return "Usage: nick <new_username>\n";
case I18N_NICK_INVALID:
return "Invalid username\n";
case I18N_NICK_TAKEN_FORMAT:
return "Nickname '%s' is already taken\n";
case I18N_NICK_UNCHANGED:
return "Nickname unchanged\n";
case I18N_NICK_CHANGED_FORMAT:
return "Nickname changed: %s -> %s\n";
case I18N_LAST_USAGE:
return "Usage: last [N] (N: 1-50, default 10)\n";
case I18N_LAST_HEADER_FORMAT:
return "--- Last %d message(s) ---\n";
case I18N_SEARCH_USAGE:
return "Usage: search <keyword>\n";
case I18N_SEARCH_HEADER_FORMAT:
return "--- Search: \"%s\" (%d match(es)) ---\n";
case I18N_MUTE_JOINS_FORMAT:
return "Join/leave notifications: %s\n";
case I18N_MUTE_JOINS_MUTED:
return "muted";
case I18N_MUTE_JOINS_UNMUTED:
return "unmuted";
case I18N_CLEAR_DONE:
return "Command output cleared\n";
case I18N_LANG_CURRENT_FORMAT:
return "Current language: %s\n"
"Usage: lang <en|zh>\n";
case I18N_LANG_SET_FORMAT:
return "Language set to: %s\n";
case I18N_LANG_UNSUPPORTED_FORMAT:
return "Unsupported language: %s\n"
"Usage: lang <en|zh>\n";
case I18N_UNKNOWN_COMMAND_FORMAT:
return "Unknown command: %s\n";
case I18N_DID_YOU_MEAN_FORMAT:
return "Did you mean :%s?\n";
case I18N_UNKNOWN_GUIDANCE:
return "Type :support for guidance or :help for commands\n";
case I18N_EXEC_HELP:
return "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"
" support Show quick support guide\n"
" exit Exit successfully\n";
case I18N_EXEC_USERS_USAGE:
return "users: usage: users [--json]\n";
case I18N_EXEC_STATS_USAGE:
return "stats: usage: stats [--json]\n";
case I18N_EXEC_TAIL_USAGE:
return "tail: usage: tail [N] | tail -n N\n";
case I18N_EXEC_POST_USAGE:
return "post: usage: post MESSAGE\n";
case I18N_EXEC_POST_EMPTY:
return "post: message cannot be empty\n";
case I18N_EXEC_POST_INVALID_UTF8:
return "post: invalid UTF-8 input\n";
case I18N_EXEC_UNKNOWN_COMMAND_FORMAT:
return "Unknown command: %s\n";
case I18N_CONTINUE_PROMPT:
return "\nPress any key to continue...";
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 "";
return ui_lang_defs[0].code;
}

257
src/i18n_text.c Normal file
View file

@ -0,0 +1,257 @@
#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 NORMAL",
"Enter 发送 · Esc NORMAL"
),
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
"Enter · Esc",
"Enter · Esc"
),
[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_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT] = I18N_STRING(
"-- COMMAND OUTPUT -- (%d/%d) j/k:scroll Ctrl-D/U:half g/G:top/bottom r:refresh q:close",
"-- 命令输出 -- (%d/%d) j/k:滚动 Ctrl-D/U:半页 g/G:首尾 r:刷新 q:关闭"
),
[I18N_MOTD_TITLE] = I18N_STRING(
" NOTICE ",
" 公告 "
),
[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_EMPTY_ROOM] = I18N_STRING(
"No messages yet",
"暂无消息"
),
[I18N_EMPTY_FILTERED] = I18N_STRING(
"No visible messages",
"暂无可见消息"
),
[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_REPLY_NO_TARGET] = I18N_STRING(
"No private message to reply to\n",
"没有可回复的私信\n"
),
[I18N_INBOX_TITLE] = I18N_STRING(
"Private messages",
"私信"
),
[I18N_INBOX_EMPTY] = I18N_STRING(
"(empty)",
"(空)"
),
[I18N_INBOX_SENT_TO_FORMAT] = I18N_STRING(
"you -> %s",
"你 -> %s"
),
[I18N_INBOX_CLEARED] = I18N_STRING(
"Private messages cleared\n",
"私信已清空\n"
),
[I18N_INBOX_UNREAD_FORMAT] = I18N_STRING(
"%d new",
"%d 新"
),
[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_LAST_EMPTY] = I18N_STRING(
"No messages to show\n",
"没有可显示的消息\n"
),
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
),
[I18N_SEARCH_EMPTY] = I18N_STRING(
"No matches\n",
"没有匹配结果\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_POST_TOO_LONG] = I18N_STRING(
"post: message too long\n",
"post: 消息过长\n"
),
[I18N_EXEC_POST_PERSIST_FAILED] = I18N_STRING(
"post: failed to persist message\n",
"post: 消息持久化失败\n"
),
[I18N_EXEC_COMMAND_TOO_LONG] = I18N_STRING(
"exec: command too long\n",
"exec: 命令过长\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

@ -2,11 +2,14 @@
#include "chat_room.h"
#include "client.h"
#include "commands.h"
#include "config_defaults.h"
#include "common.h"
#include "exec.h"
#include "history_view.h"
#include "i18n.h"
#include "input_buffer.h"
#include "message.h"
#include "module_runtime.h"
#include "ratelimit.h"
#include "system_message.h"
#include "tui.h"
@ -20,22 +23,24 @@
#include <string.h>
#include <time.h>
static int g_idle_timeout = DEFAULT_IDLE_TIMEOUT;
static help_lang_t g_default_lang = LANG_EN;
static int g_idle_timeout = TNT_DEFAULT_IDLE_TIMEOUT;
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
#define MAIN_LOOP_POLL_TIMEOUT_MS 250
void input_init(void) {
g_idle_timeout = env_int("TNT_IDLE_TIMEOUT", DEFAULT_IDLE_TIMEOUT, 0, 86400);
g_default_lang = i18n_default_lang();
g_idle_timeout = tnt_config_env_int(&TNT_CONFIG_IDLE_TIMEOUT);
g_default_ui_lang = i18n_default_ui_lang();
}
static int read_username(client_t *client) {
char username[MAX_USERNAME_LEN] = {0};
int pos = 0;
char buf[4];
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
tui_render_welcome(client);
client_printf(client, "%s", i18n_text(client->help_lang,
I18N_USERNAME_PROMPT));
client_printf(client, "%s", prompt);
while (1) {
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
@ -54,6 +59,18 @@ static int read_username(client_t *client) {
if (b == '\r' || b == '\n') {
break;
} else if (b == 3 || b == 4) { /* Ctrl+C / Ctrl+D */
return -1;
} else if (b == 21) { /* Ctrl+U: clear line */
username[0] = '\0';
pos = 0;
client_printf(client, "\r\033[K%s", prompt);
} else if (b == 23) { /* Ctrl+W: delete word */
if (username[0] != '\0') {
utf8_remove_last_word(username);
pos = (int)strlen(username);
client_printf(client, "\r\033[K%s%s", prompt, username);
}
} else if (b == 127 || b == 8) { /* Backspace */
if (pos > 0) {
/* Compute width of the last character before removing it */
@ -117,7 +134,7 @@ static int read_username(client_t *client) {
/* Validate username for security */
if (!is_valid_username(client->username)) {
client_printf(client, "%s", i18n_text(client->help_lang,
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_INVALID_USERNAME));
strcpy(client->username, "anonymous");
} else {
@ -134,9 +151,17 @@ static int read_username(client_t *client) {
void notify_mentions(const char *content, const client_t *sender) {
pthread_rwlock_rdlock(&g_room->lock);
int count = g_room->client_count;
client_t *targets[MAX_CLIENTS];
client_t **targets = NULL;
int target_count = 0;
if (count > 0) {
targets = calloc((size_t)count, sizeof(*targets));
if (!targets) {
pthread_rwlock_unlock(&g_room->lock);
return;
}
}
for (int i = 0; i < count; i++) {
client_t *c = g_room->clients[i];
if (c == sender) continue;
@ -150,11 +175,11 @@ void notify_mentions(const char *content, const client_t *sender) {
pthread_rwlock_unlock(&g_room->lock);
for (int i = 0; i < target_count; i++) {
client_send(targets[i], "\a", 1);
targets[i]->unread_mentions++;
targets[i]->redraw_pending = true;
client_queue_bell(targets[i]);
client_release(targets[i]);
}
free(targets);
}
static int read_channel_exact(client_t *client, char *buf, size_t len,
@ -173,44 +198,184 @@ static int read_channel_exact(client_t *client, char *buf, size_t len,
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;
static int normal_visible_message_count(const client_t *client) {
if (!client || !client->mute_joins) {
return room_get_message_count(g_room);
}
size_t cur = strlen(input);
if (cur < MAX_MESSAGE_LEN - 1) {
input[cur] = (char)b;
input[cur + 1] = '\0';
return true;
int count = 0;
pthread_rwlock_rdlock(&g_room->lock);
for (int i = 0; i < g_room->message_count; i++) {
if (!system_message_is_join_leave(&g_room->messages[i])) {
count++;
}
}
return false;
pthread_rwlock_unlock(&g_room->lock);
return count;
}
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),
normal_visible_message_count(client),
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),
normal_visible_message_count(client),
history_view_height(client->height), delta);
}
static void normal_enter_insert(client_t *client) {
if (!client) return;
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
tui_render_screen(client);
}
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->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = false;
if (was_motd) {
client->mode = MODE_INSERT;
client->follow_tail = true;
client->unread_mentions = 0;
normal_scroll_to_latest(client);
} else {
client->mode = MODE_NORMAL;
}
tui_render_screen(client);
}
typedef enum {
PAGER_ACTION_NONE,
PAGER_ACTION_SCROLL,
PAGER_ACTION_CLOSE,
PAGER_ACTION_REFRESH
} pager_action_t;
static int pager_page_height(client_t *client) {
int page = client->height - 2;
if (page < 1) page = 1;
return page;
}
static void pager_scroll_by(int *scroll_pos, int delta) {
*scroll_pos += delta;
if (*scroll_pos < 0) {
*scroll_pos = 0;
}
}
static pager_action_t pager_apply_key(client_t *client, unsigned char key,
int *scroll_pos, bool allow_refresh) {
int page = pager_page_height(client);
int half = page / 2;
if (half < 1) half = 1;
if (key == 'q') {
return PAGER_ACTION_CLOSE;
} else if (key == 'j') {
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (key == 'k') {
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (key == 4) { /* Ctrl+D: half page down */
pager_scroll_by(scroll_pos, half);
return PAGER_ACTION_SCROLL;
} else if (key == 21) { /* Ctrl+U: half page up */
pager_scroll_by(scroll_pos, -half);
return PAGER_ACTION_SCROLL;
} else if (key == 6 || key == ' ') { /* Ctrl+F / Space: page down */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (key == 2 || key == 'b') { /* Ctrl+B / b: page up */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (key == 'g') {
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (key == 'G') {
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if ((key == 'r' || key == 'R') && allow_refresh) {
return PAGER_ACTION_REFRESH;
} else if (key == 27) {
char seq[3];
int n = ssh_channel_read_timeout(client->channel, seq, 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_CLOSE;
}
if (seq[0] != '[') {
return PAGER_ACTION_NONE;
}
n = ssh_channel_read_timeout(client->channel, &seq[1], 1, 0, 50);
if (n != 1) {
return PAGER_ACTION_NONE;
}
if (seq[1] == 'A') { /* Up arrow */
pager_scroll_by(scroll_pos, -1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'B') { /* Down arrow */
pager_scroll_by(scroll_pos, 1);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'H') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == 'F') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
} else if (seq[1] >= '1' && seq[1] <= '6') {
n = ssh_channel_read_timeout(client->channel, &seq[2], 1, 0, 50);
if (n == 1 && seq[2] == '~') {
if (seq[1] == '5') { /* PageUp */
pager_scroll_by(scroll_pos, -page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '6') { /* PageDown */
pager_scroll_by(scroll_pos, page);
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '1') { /* Home */
*scroll_pos = 0;
return PAGER_ACTION_SCROLL;
} else if (seq[1] == '4') { /* End */
*scroll_pos = 999;
return PAGER_ACTION_SCROLL;
}
}
}
}
return PAGER_ACTION_NONE;
}
/* Handle a single key press. Returns true if the key was fully consumed
* (no further character buffering needed). */
static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle Ctrl+C (Exit or switch to NORMAL) */
if (key == 3) {
client_mode_t previous_mode = client->mode;
if (client->show_help) {
client->show_help = false;
tui_render_screen(client);
return true;
}
if (client->command_output[0] != '\0') {
dismiss_command_output(client);
return true;
}
if (previous_mode != MODE_NORMAL) {
client->mode = MODE_NORMAL;
client->command_input[0] = '\0';
@ -228,63 +393,46 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
/* Handle help screen */
if (client->show_help) {
/* Page size: roughly the visible help body region. */
int page = client->height - 2;
if (page < 1) page = 1;
int half = page / 2;
if (half < 1) half = 1;
pager_action_t action;
if (key == 'q' || key == 27) {
if (key == 'l' || key == 'L') {
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
client->help_scroll_pos = 0;
tui_render_help(client);
return true;
}
action = pager_apply_key(client, key, &client->help_scroll_pos, false);
if (action == PAGER_ACTION_CLOSE) {
client->show_help = false;
tui_render_screen(client);
} else if (key == 'e' || key == 'E') {
client->help_lang = LANG_EN;
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'z' || key == 'Z') {
client->help_lang = LANG_ZH;
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'j') {
client->help_scroll_pos++;
tui_render_help(client);
} else if (key == 'k' && client->help_scroll_pos > 0) {
client->help_scroll_pos--;
tui_render_help(client);
} else if (key == 4) { /* Ctrl+D: half page down */
client->help_scroll_pos += half;
tui_render_help(client);
} else if (key == 21) { /* Ctrl+U: half page up */
client->help_scroll_pos -= half;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 6) { /* Ctrl+F: full page down */
client->help_scroll_pos += page;
tui_render_help(client);
} else if (key == 2) { /* Ctrl+B: full page up */
client->help_scroll_pos -= page;
if (client->help_scroll_pos < 0) client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'g') {
client->help_scroll_pos = 0;
tui_render_help(client);
} else if (key == 'G') {
client->help_scroll_pos = 999; /* Large number */
} else if (action == PAGER_ACTION_SCROLL) {
tui_render_help(client);
}
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') {
bool was_motd = client->show_motd;
client->command_output[0] = '\0';
client->show_motd = false;
client->mode = MODE_NORMAL;
if (was_motd) {
normal_scroll_to_latest(client);
pager_action_t action;
if (client->show_motd) {
dismiss_command_output(client);
return true;
}
action = pager_apply_key(client, key, &client->command_output_scroll,
true);
if (action == PAGER_ACTION_CLOSE) {
dismiss_command_output(client);
} else if (action == PAGER_ACTION_SCROLL) {
tui_render_command_output(client);
} else if (action == PAGER_ACTION_REFRESH) {
if (commands_refresh_active_output(client)) {
tui_render_command_output(client);
}
}
tui_render_screen(client);
return true; /* Key consumed */
}
@ -336,6 +484,8 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
* spaces so a multi-line paste stays a
* single message instead of N sends. */
bool overflow = false;
bool invalid_utf8 = false;
tnt_input_utf8_state_t paste_utf8 = {0};
while (1) {
char b;
int k = ssh_channel_read_timeout(
@ -356,21 +506,40 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
* but keep printable bytes that
* followed it. */
for (int i = 0; i < t; i++) {
if (!append_paste_byte(
input,
(unsigned char)tail[i])) {
int status =
tnt_input_append_stream_byte(
input, MAX_MESSAGE_LEN,
&paste_utf8,
(unsigned char)tail[i],
true);
if (status &
TNT_INPUT_APPEND_OVERFLOW) {
overflow = true;
}
if (status &
TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
}
continue;
}
if (!append_paste_byte(input,
(unsigned char)b)) {
int status = tnt_input_append_stream_byte(
input, MAX_MESSAGE_LEN, &paste_utf8,
(unsigned char)b, true);
if (status & TNT_INPUT_APPEND_OVERFLOW) {
overflow = true;
}
if (status & TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
}
if (tnt_input_utf8_state_finish(
&paste_utf8) &
TNT_INPUT_APPEND_INVALID_UTF8) {
invalid_utf8 = true;
}
tui_render_input(client, input);
if (overflow) {
if (overflow || invalid_utf8) {
client_send(client, "\a", 1);
}
}
@ -416,7 +585,11 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, client);
message_save(&msg);
if (message_save(&msg) == 0) {
tnt_module_runtime_publish_message_created(&msg);
} else {
fprintf(stderr, "interactive: failed to persist message\n");
}
input[0] = '\0';
}
tui_render_screen(client);
@ -491,16 +664,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
case MODE_NORMAL: {
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);
if (key == 'i' || key == 'a' || key == 'A' ||
key == 'o' || key == 'O') {
normal_enter_insert(client);
return true;
} else if (key == ':') {
client->mode = MODE_COMMAND;
client->command_input[0] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
return true;
} else if (key == '/') {
client->mode = MODE_COMMAND;
snprintf(client->command_input, sizeof(client->command_input),
"search ");
tui_render_command_input(client);
return true;
} else if (key == 'j') {
normal_scroll_by(client, 1);
@ -600,7 +777,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_history[client->command_history_pos],
sizeof(client->command_input) - 1);
client->command_input[sizeof(client->command_input) - 1] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
} else if (seq[1] == 'B') { /* Down arrow */
@ -614,7 +791,7 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
client->command_history_pos = client->command_history_count;
client->command_input[0] = '\0';
}
tui_render_screen(client);
tui_render_command_input(client);
return true;
}
}
@ -629,19 +806,19 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
} else if (key == 127 || key == 8) { /* Backspace */
if (client->command_input[0] != '\0') {
utf8_remove_last_char(client->command_input);
tui_render_screen(client);
tui_render_command_input(client);
}
return true; /* Key consumed */
} else if (key == 23) { /* Ctrl+W (Delete Word) */
if (client->command_input[0] != '\0') {
utf8_remove_last_word(client->command_input);
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
} else if (key == 21) { /* Ctrl+U (Delete Line) */
if (client->command_input[0] != '\0') {
client->command_input[0] = '\0';
tui_render_screen(client);
tui_render_command_input(client);
}
return true;
}
@ -665,20 +842,24 @@ void input_run_session(client_t *client) {
/* Terminal size already set from PTY request */
client->mode = MODE_INSERT;
client->follow_tail = true;
client->help_lang = g_default_lang;
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->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->connect_time = time(NULL);
client->last_active = time(NULL);
/* Check for exec command */
if (client->exec_command[0] != '\0') {
if (client->exec_command[0] != '\0' || client->exec_command_too_long) {
int exit_status = exec_dispatch(client);
ssh_channel_request_send_exit_status(client->channel, exit_status);
ssh_blocking_flush(client->session, 1000);
ssh_channel_send_eof(client->channel);
ssh_blocking_flush(client->session, 1000);
ssh_channel_close(client->channel);
ssh_blocking_flush(client->session, 1000);
goto cleanup;
}
@ -689,7 +870,7 @@ void input_run_session(client_t *client) {
/* Add to room */
if (room_add_client(g_room, client) < 0) {
client_printf(client, "%s", i18n_text(client->help_lang,
client_printf(client, "%s", i18n_text(client->ui_lang,
I18N_ROOM_FULL));
goto cleanup;
}
@ -703,7 +884,7 @@ void input_run_session(client_t *client) {
/* Broadcast join message */
message_t join_msg;
system_message_make_join(&join_msg, client->username, client->help_lang);
system_message_make_join(&join_msg, client->username, client->ui_lang);
room_broadcast(g_room, &join_msg);
message_save(&join_msg);
@ -721,6 +902,8 @@ void input_run_session(client_t *client) {
snprintf(client->command_output,
sizeof(client->command_output),
"%s", motd_buf);
client->command_output_scroll = 0;
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
client->show_motd = true;
tui_render_motd(client);
seen_update_seq = room_get_update_seq(g_room);
@ -738,7 +921,12 @@ main_loop:
/* Main input loop */
while (client->connected && ssh_channel_is_open(client->channel)) {
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);
if (client_flush_output(client) != 0) {
break;
}
int ready = ssh_channel_poll_timeout(client->channel,
MAIN_LOOP_POLL_TIMEOUT_MS, 0);
if (ready == SSH_ERROR) {
break;
@ -752,11 +940,26 @@ main_loop:
break;
}
if (client_flush_output(client) != 0) {
break;
}
if (client_flush_pending_bells(client) != 0) {
break;
}
if (current_update_seq != seen_update_seq) {
seen_update_seq = current_update_seq;
room_updated = true;
}
if (client->command_output_kind == TNT_COMMAND_OUTPUT_INBOX &&
client->command_output[0] != '\0' &&
client->unread_whispers > 0) {
commands_refresh_active_output(client);
client->redraw_pending = true;
}
if (client->redraw_pending ||
(room_updated && !client->show_help &&
client->command_output[0] == '\0')) {
@ -788,7 +991,7 @@ main_loop:
if (g_idle_timeout > 0 && joined_room &&
time(NULL) - client->last_active >= g_idle_timeout) {
client_printf(client,
i18n_text(client->help_lang,
i18n_text(client->ui_lang,
I18N_IDLE_TIMEOUT_FORMAT),
g_idle_timeout / 60);
break;
@ -817,10 +1020,9 @@ main_loop:
if (client->mode == MODE_INSERT && !client->show_help &&
client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */
int len = strlen(input);
if (len < MAX_MESSAGE_LEN - 1) {
input[len] = b;
input[len + 1] = '\0';
int status = tnt_input_append_ascii(input,
MAX_MESSAGE_LEN, b);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
@ -844,10 +1046,9 @@ main_loop:
/* Invalid UTF-8 sequence */
continue;
}
int len = strlen(input);
if (len + char_len <= MAX_MESSAGE_LEN - 1) {
memcpy(input + len, buf, char_len);
input[len + char_len] = '\0';
int status = tnt_input_append_utf8_sequence(
input, MAX_MESSAGE_LEN, buf, char_len);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_input(client, input);
} else {
client_send(client, "\a", 1);
@ -856,11 +1057,13 @@ main_loop:
} else if (client->mode == MODE_COMMAND && !client->show_help &&
client->command_output[0] == '\0') {
if (b >= 32 && b < 127) { /* ASCII printable */
size_t len = strlen(client->command_input);
if (len < sizeof(client->command_input) - 1) {
client->command_input[len] = b;
client->command_input[len + 1] = '\0';
tui_render_screen(client);
int status = tnt_input_append_ascii(
client->command_input, sizeof(client->command_input),
b);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client);
} else {
client_send(client, "\a", 1);
}
} else if (b >= 128) { /* UTF-8 multi-byte */
int char_len = utf8_byte_length(b);
@ -872,11 +1075,13 @@ main_loop:
if (read_bytes != char_len - 1) continue;
}
if (!utf8_is_valid_sequence(buf, char_len)) continue;
size_t len = strlen(client->command_input);
if (len + (size_t)char_len < sizeof(client->command_input) - 1) {
memcpy(client->command_input + len, buf, char_len);
client->command_input[len + char_len] = '\0';
tui_render_screen(client);
int status = tnt_input_append_utf8_sequence(
client->command_input, sizeof(client->command_input),
buf, char_len);
if (status == TNT_INPUT_APPEND_OK) {
tui_render_command_input(client);
} else {
client_send(client, "\a", 1);
}
}
}
@ -893,7 +1098,7 @@ cleanup:
if (joined_room) {
message_t leave_msg;
system_message_make_leave(&leave_msg, client->username,
client->help_lang);
client->ui_lang);
client->connected = false;
room_remove_client(g_room, client);
@ -903,17 +1108,7 @@ cleanup:
ratelimit_release_ip(client->client_ip);
/* Remove channel callbacks before releasing refs to prevent use-after-free
* if a callback fires between the two releases. */
if (client->channel && client->channel_cb) {
ssh_remove_channel_callbacks(client->channel, client->channel_cb);
}
/* Release the callback reference (paired with addref before client_install_channel_callbacks) */
client_release(client);
/* Release the main reference - client will be freed when all refs are gone */
client_release(client);
client_release_session(client);
/* Decrement connection count */
ratelimit_decrement_total();

147
src/input_buffer.c Normal file
View file

@ -0,0 +1,147 @@
#include "input_buffer.h"
#include "utf8.h"
void tnt_input_utf8_state_reset(tnt_input_utf8_state_t *state) {
if (!state) return;
state->len = 0;
state->expected_len = 0;
memset(state->bytes, 0, sizeof(state->bytes));
}
static int append_bytes(char *input, size_t input_size, const char *bytes,
size_t len) {
size_t cur;
if (!input || !bytes || input_size == 0 || len == 0) {
return TNT_INPUT_APPEND_IGNORED;
}
cur = strlen(input);
if (cur + len >= input_size) {
return TNT_INPUT_APPEND_OVERFLOW;
}
memcpy(input + cur, bytes, len);
input[cur + len] = '\0';
return TNT_INPUT_APPEND_OK;
}
int tnt_input_append_ascii(char *input, size_t input_size, unsigned char b) {
char c = (char)b;
if (b < 32 || b >= 127) {
return TNT_INPUT_APPEND_IGNORED;
}
return append_bytes(input, input_size, &c, 1);
}
int tnt_input_append_utf8_sequence(char *input, size_t input_size,
const char *bytes, int len) {
if (!bytes || len <= 0 || len > 4 ||
!utf8_is_valid_sequence(bytes, len)) {
return TNT_INPUT_APPEND_INVALID_UTF8;
}
return append_bytes(input, input_size, bytes, (size_t)len);
}
static int append_printable_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode);
static int start_utf8_sequence(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int expected = utf8_byte_length(b);
if (expected <= 1 || expected > 4) {
tnt_input_utf8_state_reset(state);
return TNT_INPUT_APPEND_INVALID_UTF8;
}
state->bytes[0] = (char)b;
state->len = 1;
state->expected_len = expected;
if (expected == 1) {
int status = tnt_input_append_utf8_sequence(input, input_size,
state->bytes, 1);
tnt_input_utf8_state_reset(state);
return status;
}
(void)paste_mode;
return TNT_INPUT_APPEND_OK;
}
static int append_printable_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int status = TNT_INPUT_APPEND_OK;
if (b < 128) {
if (state && state->len > 0) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
}
status |= tnt_input_append_ascii(input, input_size, b);
return status;
}
if (!state) {
return TNT_INPUT_APPEND_INVALID_UTF8;
}
if (state->len == 0) {
return start_utf8_sequence(input, input_size, state, b, paste_mode);
}
if ((b & 0xC0) != 0x80) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
status |= append_printable_byte(input, input_size, state, b,
paste_mode);
return status;
}
state->bytes[state->len++] = (char)b;
if (state->len == state->expected_len) {
status |= tnt_input_append_utf8_sequence(input, input_size,
state->bytes, state->len);
tnt_input_utf8_state_reset(state);
}
return status;
}
int tnt_input_append_stream_byte(char *input, size_t input_size,
tnt_input_utf8_state_t *state,
unsigned char b, bool paste_mode) {
int status = TNT_INPUT_APPEND_OK;
if (paste_mode && (b == '\r' || b == '\n' || b == '\t')) {
b = ' ';
}
if (b < 32) {
if (state && state->len > 0) {
tnt_input_utf8_state_reset(state);
status |= TNT_INPUT_APPEND_INVALID_UTF8;
}
return status | TNT_INPUT_APPEND_IGNORED;
}
return status | append_printable_byte(input, input_size, state, b,
paste_mode);
}
int tnt_input_utf8_state_finish(tnt_input_utf8_state_t *state) {
if (!state || state->len == 0) {
return TNT_INPUT_APPEND_OK;
}
tnt_input_utf8_state_reset(state);
return TNT_INPUT_APPEND_INVALID_UTF8;
}

343
src/json_text.c Normal file
View file

@ -0,0 +1,343 @@
#include "json_text.h"
#include <ctype.h>
void tnt_json_append_string(char *buffer, size_t buf_size, size_t *pos,
const char *text) {
const unsigned char *p = (const unsigned char *)(text ? text : "");
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
while (*p && pos && *pos < buf_size - 1) {
char escaped[7];
switch (*p) {
case '\\':
buffer_append_bytes(buffer, buf_size, pos, "\\\\", 2);
break;
case '"':
buffer_append_bytes(buffer, buf_size, pos, "\\\"", 2);
break;
case '\b':
buffer_append_bytes(buffer, buf_size, pos, "\\b", 2);
break;
case '\f':
buffer_append_bytes(buffer, buf_size, pos, "\\f", 2);
break;
case '\n':
buffer_append_bytes(buffer, buf_size, pos, "\\n", 2);
break;
case '\r':
buffer_append_bytes(buffer, buf_size, pos, "\\r", 2);
break;
case '\t':
buffer_append_bytes(buffer, buf_size, pos, "\\t", 2);
break;
default:
if (*p < 0x20) {
snprintf(escaped, sizeof(escaped), "\\u%04x", *p);
buffer_append_bytes(buffer, buf_size, pos,
escaped, strlen(escaped));
} else {
buffer_append_bytes(buffer, buf_size, pos,
(const char *)p, 1);
}
break;
}
p++;
}
buffer_append_bytes(buffer, buf_size, pos, "\"", 1);
}
static const char *skip_ws(const char *p) {
while (p && isspace((unsigned char)*p)) {
p++;
}
return p;
}
static int hex_value(char c) {
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
return -1;
}
static bool parse_hex4(const char *p, uint32_t *out) {
uint32_t cp = 0;
if (!p || !out) return false;
for (int i = 0; i < 4; i++) {
int v = hex_value(p[i]);
if (v < 0) return false;
cp = (cp << 4) | (uint32_t)v;
}
*out = cp;
return true;
}
static bool append_decoded_codepoint(char *out, size_t out_size,
size_t *pos, uint32_t cp) {
char bytes[4];
size_t len;
if (!out || !pos || out_size == 0 || cp == 0 ||
cp > 0x10FFFF || (cp >= 0xD800 && cp <= 0xDFFF)) {
return false;
}
if (cp <= 0x7F) {
bytes[0] = (char)cp;
len = 1;
} else if (cp <= 0x7FF) {
bytes[0] = (char)(0xC0 | (cp >> 6));
bytes[1] = (char)(0x80 | (cp & 0x3F));
len = 2;
} else if (cp <= 0xFFFF) {
bytes[0] = (char)(0xE0 | (cp >> 12));
bytes[1] = (char)(0x80 | ((cp >> 6) & 0x3F));
bytes[2] = (char)(0x80 | (cp & 0x3F));
len = 3;
} else {
bytes[0] = (char)(0xF0 | (cp >> 18));
bytes[1] = (char)(0x80 | ((cp >> 12) & 0x3F));
bytes[2] = (char)(0x80 | ((cp >> 6) & 0x3F));
bytes[3] = (char)(0x80 | (cp & 0x3F));
len = 4;
}
if (*pos + len >= out_size) {
return false;
}
memcpy(out + *pos, bytes, len);
*pos += len;
out[*pos] = '\0';
return true;
}
static bool append_byte(char *out, size_t out_size, size_t *pos, char c) {
if (!out || !pos || out_size == 0 || *pos + 1 >= out_size) {
return false;
}
out[(*pos)++] = c;
out[*pos] = '\0';
return true;
}
static bool parse_json_string(const char **cursor, char *out,
size_t out_size) {
const char *p;
size_t pos = 0;
if (!cursor || !*cursor || **cursor != '"' || !out || out_size == 0) {
return false;
}
out[0] = '\0';
p = *cursor + 1;
while (*p) {
unsigned char c = (unsigned char)*p++;
if (c == '"') {
*cursor = p;
return true;
}
if (c < 0x20) {
return false;
}
if (c != '\\') {
if (!append_byte(out, out_size, &pos, (char)c)) {
return false;
}
continue;
}
char esc = *p++;
switch (esc) {
case '"':
case '\\':
case '/':
if (!append_byte(out, out_size, &pos, esc)) return false;
break;
case 'b':
if (!append_byte(out, out_size, &pos, '\b')) return false;
break;
case 'f':
if (!append_byte(out, out_size, &pos, '\f')) return false;
break;
case 'n':
if (!append_byte(out, out_size, &pos, '\n')) return false;
break;
case 'r':
if (!append_byte(out, out_size, &pos, '\r')) return false;
break;
case 't':
if (!append_byte(out, out_size, &pos, '\t')) return false;
break;
case 'u': {
uint32_t cp;
if (!parse_hex4(p, &cp)) return false;
p += 4;
if (cp >= 0xD800 && cp <= 0xDBFF) {
uint32_t low;
if (p[0] != '\\' || p[1] != 'u' ||
!parse_hex4(p + 2, &low) ||
low < 0xDC00 || low > 0xDFFF) {
return false;
}
p += 6;
cp = 0x10000 + ((cp - 0xD800) << 10) + (low - 0xDC00);
} else if (cp >= 0xDC00 && cp <= 0xDFFF) {
return false;
}
if (!append_decoded_codepoint(out, out_size, &pos, cp)) {
return false;
}
break;
}
default:
return false;
}
}
return false;
}
static bool skip_json_string(const char **cursor) {
const char *p;
if (!cursor || !*cursor || **cursor != '"') {
return false;
}
p = *cursor + 1;
while (*p) {
unsigned char c = (unsigned char)*p++;
if (c == '"') {
*cursor = p;
return true;
}
if (c < 0x20) return false;
if (c == '\\') {
if (*p == 'u') {
uint32_t ignored;
if (!parse_hex4(p + 1, &ignored)) return false;
p += 5;
} else if (*p) {
p++;
} else {
return false;
}
}
}
return false;
}
static bool skip_json_value(const char **cursor) {
const char *p;
if (!cursor || !*cursor) return false;
p = skip_ws(*cursor);
if (*p == '"') {
if (!skip_json_string(&p)) return false;
*cursor = p;
return true;
}
if (*p == '{' || *p == '[') {
int depth = 0;
do {
if (*p == '"' && !skip_json_string(&p)) {
return false;
}
if (*p == '{' || *p == '[') {
depth++;
p++;
} else if (*p == '}' || *p == ']') {
depth--;
p++;
} else if (*p) {
p++;
} else {
return false;
}
} while (depth > 0);
*cursor = p;
return true;
}
while (*p && *p != ',' && *p != '}' && *p != ']') {
p++;
}
*cursor = p;
return true;
}
bool tnt_json_get_string_field(const char *json, const char *key,
char *out, size_t out_size) {
const char *p;
bool found = false;
if (!json || !key || key[0] == '\0' || !out || out_size == 0) {
return false;
}
out[0] = '\0';
p = skip_ws(json);
if (*p != '{') {
return false;
}
p++;
while (1) {
char parsed_key[128];
p = skip_ws(p);
if (*p == '}') {
return false;
}
if (*p != '"' || !parse_json_string(&p, parsed_key,
sizeof(parsed_key))) {
return false;
}
p = skip_ws(p);
if (*p != ':') {
return false;
}
p = skip_ws(p + 1);
if (strcmp(parsed_key, key) == 0) {
if (*p != '"' || !parse_json_string(&p, out, out_size)) {
return false;
}
found = true;
} else if (!skip_json_value(&p)) {
return false;
}
p = skip_ws(p);
if (*p == ',') {
p++;
continue;
}
if (*p == '}') {
return found;
}
return false;
}
}

View file

@ -1,8 +1,11 @@
#include "chat_room.h"
#include "cli_text.h"
#include "config_defaults.h"
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "message_log_tool.h"
#include "module_runtime.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -18,57 +21,212 @@ static void signal_handler(int sig) {
_exit(0);
}
int main(int argc, char **argv) {
int port = DEFAULT_PORT;
help_lang_t lang = i18n_default_lang();
static bool is_config_token(const char *value) {
const unsigned char *p = (const unsigned char *)value;
/* Environment provides defaults; command-line flags override it. */
const char *port_env = getenv("PORT");
if (port_env && port_env[0] != '\0') {
char *end;
long val = strtol(port_env, &end, 10);
if (*end == '\0' && val > 0 && val <= 65535) {
port = (int)val;
}
if (!value || value[0] == '\0') {
return false;
}
while (*p) {
if (*p <= 32 || *p == 127) {
return false;
}
p++;
}
return true;
}
static int set_env_option(const char *name, const char *value) {
if (setenv(name, value, 1) != 0) {
perror(name);
return -1;
}
return 0;
}
static int set_numeric_env_option(const tnt_int_config_spec_t *spec,
const char *opt_name, const char *value,
ui_lang_t lang) {
int parsed;
if (!tnt_config_parse_int(value, spec, &parsed)) {
fprintf(stderr, cli_text_invalid_value_format(lang), opt_name, value);
return TNT_EXIT_USAGE;
}
if (set_env_option(spec->env_name, value) != 0) {
return TNT_EXIT_ERROR;
}
return TNT_EXIT_OK;
}
static bool require_option_arg(int argc, char **argv, int index,
ui_lang_t lang) {
if (index + 1 >= argc || argv[index + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[index]);
return false;
}
return true;
}
int main(int argc, char **argv) {
int port = tnt_config_env_int(&TNT_CONFIG_PORT);
ui_lang_t lang = i18n_default_ui_lang();
const char *log_check_path = NULL;
const char *log_recover_path = NULL;
/* Parse command line arguments */
for (int i = 1; i < argc; i++) {
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) &&
i + 1 < argc) {
char *end;
long val = strtol(argv[i + 1], &end, 10);
if (*end != '\0' || val <= 0 || val > 65535) {
if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) {
int val;
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!tnt_config_parse_int(argv[i + 1], &TNT_CONFIG_PORT, &val)) {
fprintf(stderr, cli_text_invalid_port_format(lang),
argv[i + 1]);
return 1;
return TNT_EXIT_USAGE;
}
port = (int)val;
port = val;
i++;
} else if ((strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
perror("setenv TNT_STATE_DIR");
return 1;
} else if (strcmp(argv[i], "-d") == 0 ||
strcmp(argv[i], "--state-dir") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) {
return TNT_EXIT_ERROR;
}
i++;
} else if (strcmp(argv[i], "--bind") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
return TNT_EXIT_USAGE;
}
if (set_env_option("TNT_BIND_ADDR", argv[i + 1]) != 0) {
return TNT_EXIT_ERROR;
}
i++;
} else if (strcmp(argv[i], "--public-host") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
if (!is_config_token(argv[i + 1])) {
fprintf(stderr, cli_text_invalid_value_format(lang),
argv[i], argv[i + 1]);
return TNT_EXIT_USAGE;
}
if (set_env_option("TNT_PUBLIC_HOST", argv[i + 1]) != 0) {
return TNT_EXIT_ERROR;
}
i++;
} else if (strcmp(argv[i], "--max-connections") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONNECTIONS,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--max-conn-per-ip") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_PER_IP,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--max-conn-rate-per-ip") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_MAX_CONN_RATE_PER_IP,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--rate-limit") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_RATE_LIMIT, argv[i],
argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--idle-timeout") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_IDLE_TIMEOUT, argv[i],
argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--ssh-log-level") == 0) {
if (!require_option_arg(argc, argv, i, lang)) {
return TNT_EXIT_USAGE;
}
int rc = set_numeric_env_option(&TNT_CONFIG_SSH_LOG_LEVEL,
argv[i], argv[i + 1], lang);
if (rc != TNT_EXIT_OK) {
return rc;
}
i++;
} else if (strcmp(argv[i], "--log-check") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[i]);
return TNT_EXIT_USAGE;
}
log_check_path = argv[++i];
} else if (strcmp(argv[i], "--log-recover") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0') {
fprintf(stderr, cli_text_option_requires_arg_format(lang),
argv[i]);
return TNT_EXIT_USAGE;
}
log_recover_path = argv[++i];
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
printf("tnt %s\n", TNT_VERSION);
return 0;
return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
char output[2048] = {0};
size_t pos = 0;
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
fputs(output, stdout);
return 0;
return TNT_EXIT_OK;
} else {
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
return 1;
return TNT_EXIT_USAGE;
}
}
if (log_check_path && log_recover_path) {
fprintf(stderr, cli_text_invalid_value_format(lang),
"--log-check", "--log-recover");
return TNT_EXIT_USAGE;
}
if (log_check_path) {
return message_log_tool_check(log_check_path);
}
if (log_recover_path) {
return message_log_tool_recover(log_recover_path);
}
/* Setup signal handlers */
signal(SIGINT, signal_handler);
signal(SIGTERM, signal_handler);
@ -77,28 +235,35 @@ int main(int argc, char **argv) {
/* Initialize subsystems */
if (tnt_ensure_state_dir() < 0) {
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
return 1;
return TNT_EXIT_ERROR;
}
message_init();
if (tnt_module_runtime_init() < 0) {
fprintf(stderr, "Failed to initialize module runtime\n");
return TNT_EXIT_ERROR;
}
/* Create chat room */
g_room = room_create();
if (!g_room) {
fprintf(stderr, "Failed to create chat room\n");
return 1;
tnt_module_runtime_shutdown();
return TNT_EXIT_ERROR;
}
/* Initialize server */
if (ssh_server_init(port) < 0) {
fprintf(stderr, "Failed to initialize server\n");
tnt_module_runtime_shutdown();
room_destroy(g_room);
return 1;
return TNT_EXIT_ERROR;
}
/* Start server (blocking) */
int ret = ssh_server_start(0);
tnt_module_runtime_shutdown();
room_destroy(g_room);
return ret;
}

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, Enter sends; Up/Down recalls; Tab completes @mentions\n"
" Esc browses; / searches; G latest; i/a/o types; : commands; ? keys\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 发送Up/Down 调出消息Tab 补全 @mention\n"
" Esc 浏览;/ 搜索G 最新i/a/o 输入;: 命令;? 按键\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

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

129
src/message_log.c Normal file
View file

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

111
src/message_log_tool.c Normal file
View file

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

112
src/module_protocol.c Normal file
View file

@ -0,0 +1,112 @@
#include "module_protocol.h"
#include "common.h"
#include "json_text.h"
#include "message_log.h"
#include "utf8.h"
static bool append_was_truncated(size_t pos, size_t buf_size) {
return buf_size == 0 || pos >= buf_size - 1;
}
static bool has_plain_text_controls(const char *text) {
const unsigned char *p = (const unsigned char *)text;
while (p && *p) {
if (*p < 32 || *p == 127) {
return true;
}
p++;
}
return false;
}
int tnt_module_append_handshake(char *buffer, size_t buf_size, size_t *pos,
const char *server_version) {
const char *version = server_version ? server_version : TNT_VERSION;
size_t before;
if (!buffer || !pos || buf_size == 0) {
return -1;
}
before = *pos;
buffer_appendf(buffer, buf_size, pos,
"{\"type\":\"handshake\",\"protocol\":");
tnt_json_append_string(buffer, buf_size, pos, TNT_MODULE_PROTOCOL_VERSION);
buffer_appendf(buffer, buf_size, pos,
",\"server\":{\"name\":\"tnt\",\"version\":");
tnt_json_append_string(buffer, buf_size, pos, version);
buffer_appendf(buffer, buf_size, pos, "}}\n");
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
before != *pos
? -1
: 0;
}
int tnt_module_append_message_created(char *buffer, size_t buf_size,
size_t *pos, const char *message_id,
const message_t *msg) {
char timestamp[64];
size_t before;
if (!buffer || !pos || buf_size == 0 || !message_id || !msg ||
message_id[0] == '\0' || !utf8_is_valid_string(msg->username) ||
!utf8_is_valid_string(msg->content)) {
return -1;
}
before = *pos;
message_log_format_timestamp_utc(msg->timestamp, timestamp,
sizeof(timestamp));
buffer_appendf(buffer, buf_size, pos,
"{\"type\":\"%s\",\"message\":{\"id\":",
TNT_MODULE_EVENT_MESSAGE_CREATED);
tnt_json_append_string(buffer, buf_size, pos, message_id);
buffer_appendf(buffer, buf_size, pos, ",\"timestamp\":");
tnt_json_append_string(buffer, buf_size, pos, timestamp);
buffer_appendf(buffer, buf_size, pos, ",\"sender\":");
tnt_json_append_string(buffer, buf_size, pos, msg->username);
buffer_appendf(buffer, buf_size, pos, ",\"kind\":\"text\","
"\"plain_text\":");
tnt_json_append_string(buffer, buf_size, pos, msg->content);
buffer_appendf(buffer, buf_size, pos, ",\"metadata\":{}}}\n");
return append_was_truncated(*pos, buf_size) && *pos == buf_size - 1 &&
before != *pos
? -1
: 0;
}
bool tnt_module_parse_message_create(const char *line,
tnt_module_message_create_t *out) {
char type[64];
char plain_text[MAX_MESSAGE_LEN];
if (!line || !out) {
return false;
}
memset(out, 0, sizeof(*out));
if (!tnt_json_get_string_field(line, "type", type, sizeof(type)) ||
strcmp(type, TNT_MODULE_RESPONSE_MESSAGE_CREATE) != 0) {
return false;
}
if (!tnt_json_get_string_field(line, "plain_text", plain_text,
sizeof(plain_text))) {
return false;
}
if (plain_text[0] == '\0' ||
strlen(plain_text) >= sizeof(out->plain_text) ||
!utf8_is_valid_string(plain_text) ||
has_plain_text_controls(plain_text)) {
return false;
}
snprintf(out->plain_text, sizeof(out->plain_text), "%s", plain_text);
return true;
}

537
src/module_runtime.c Normal file
View file

@ -0,0 +1,537 @@
#include "module_runtime.h"
#include "chat_room.h"
#include "common.h"
#include "json_text.h"
#include "module_protocol.h"
#include "utf8.h"
#include <errno.h>
#include <fcntl.h>
#include <signal.h>
#include <sys/select.h>
#include <sys/wait.h>
#include <unistd.h>
#define TNT_MODULE_LINE_MAX 4096
#define TNT_MODULE_HANDSHAKE_TIMEOUT_MS 2000
#define TNT_MODULE_RESPONSE_TIMEOUT_MS 100
struct client;
void notify_mentions(const char *content, const struct client *sender);
typedef struct module_process {
tnt_module_manifest_t manifest;
pid_t pid;
int stdin_fd;
int stdout_fd;
bool active;
} module_process_t;
typedef struct module_event_node {
message_t msg;
struct module_event_node *next;
} module_event_node_t;
static module_process_t g_modules[TNT_MAX_MODULES];
static int g_module_count = 0;
static pthread_t g_module_thread;
static bool g_thread_started = false;
static bool g_running = false;
static pthread_mutex_t g_queue_lock = PTHREAD_MUTEX_INITIALIZER;
static pthread_cond_t g_queue_cond = PTHREAD_COND_INITIALIZER;
static module_event_node_t *g_queue_head = NULL;
static module_event_node_t *g_queue_tail = NULL;
static int g_queue_len = 0;
static bool is_safe_relative_entrypoint(const char *entrypoint) {
if (!entrypoint || entrypoint[0] == '\0' || entrypoint[0] == '/') {
return false;
}
if (strstr(entrypoint, "..") != NULL) {
return false;
}
for (const unsigned char *p = (const unsigned char *)entrypoint; *p; p++) {
if (*p <= 32 || *p == 127 || *p == '|' || *p == ';' ||
*p == '&' || *p == '`' || *p == '$' || *p == '<' ||
*p == '>' || *p == '\\') {
return false;
}
}
return true;
}
static bool json_array_contains_string(const char *json, const char *key,
const char *value) {
char needle[128];
const char *p;
if (!json || !key || !value ||
snprintf(needle, sizeof(needle), "\"%s\"", key) >=
(int)sizeof(needle)) {
return false;
}
p = strstr(json, needle);
if (!p) return false;
p = strchr(p + strlen(needle), ':');
if (!p) return false;
p++;
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n') p++;
if (*p != '[') return false;
p++;
while (*p) {
char item[128];
while (*p == ' ' || *p == '\t' || *p == '\r' || *p == '\n' ||
*p == ',') {
p++;
}
if (*p == ']') return false;
if (*p != '"') return false;
const char *cursor = p;
size_t pos = 0;
item[0] = '\0';
cursor++;
while (*cursor && *cursor != '"') {
if (*cursor == '\\') {
cursor++;
if (!*cursor) return false;
}
if (pos + 1 >= sizeof(item)) return false;
item[pos++] = *cursor++;
}
if (*cursor != '"') return false;
item[pos] = '\0';
if (strcmp(item, value) == 0) return true;
p = cursor + 1;
}
return false;
}
int tnt_module_manifest_load(const char *module_dir,
tnt_module_manifest_t *out) {
char manifest_path[PATH_MAX];
char manifest[TNT_MODULE_LINE_MAX];
char protocol[64];
FILE *fp;
size_t n;
if (!module_dir || module_dir[0] == '\0' || !out) {
return -1;
}
memset(out, 0, sizeof(*out));
if (snprintf(manifest_path, sizeof(manifest_path), "%s/tnt-module.json",
module_dir) >= (int)sizeof(manifest_path)) {
return -1;
}
fp = fopen(manifest_path, "rb");
if (!fp) {
return -1;
}
n = fread(manifest, 1, sizeof(manifest) - 1, fp);
fclose(fp);
manifest[n] = '\0';
if (n == 0 || n >= sizeof(manifest) - 1 ||
!tnt_json_get_string_field(manifest, "protocol", protocol,
sizeof(protocol)) ||
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) != 0 ||
!tnt_json_get_string_field(manifest, "name", out->name,
sizeof(out->name)) ||
!tnt_json_get_string_field(manifest, "entrypoint", out->entrypoint,
sizeof(out->entrypoint)) ||
!is_valid_username(out->name) ||
!is_safe_relative_entrypoint(out->entrypoint)) {
return -1;
}
out->wants_message_created = json_array_contains_string(
manifest, "events", TNT_MODULE_EVENT_MESSAGE_CREATED);
out->can_read_messages = json_array_contains_string(
manifest, "permissions", "message:read");
out->can_create_messages = json_array_contains_string(
manifest, "permissions", "message:create");
if (!out->wants_message_created || !out->can_read_messages ||
!out->can_create_messages) {
return -1;
}
return 0;
}
static int wait_fd_readable(int fd, int timeout_ms) {
fd_set readfds;
struct timeval tv;
FD_ZERO(&readfds);
FD_SET(fd, &readfds);
tv.tv_sec = timeout_ms / 1000;
tv.tv_usec = (timeout_ms % 1000) * 1000;
return select(fd + 1, &readfds, NULL, NULL, &tv);
}
static int read_line_timeout(int fd, char *line, size_t line_size,
int timeout_ms) {
size_t pos = 0;
if (!line || line_size == 0) return -1;
line[0] = '\0';
while (pos + 1 < line_size) {
char c;
int ready = wait_fd_readable(fd, timeout_ms);
if (ready <= 0) {
break;
}
ssize_t n = read(fd, &c, 1);
if (n <= 0) {
return -1;
}
if (c == '\n') {
line[pos] = '\0';
return (int)pos;
}
if ((unsigned char)c < 32 && c != '\t' && c != '\r') {
return -1;
}
line[pos++] = c;
}
line[pos] = '\0';
return pos > 0 ? (int)pos : 0;
}
static void close_module_process(module_process_t *module) {
if (!module || !module->active) return;
close(module->stdin_fd);
close(module->stdout_fd);
kill(module->pid, SIGTERM);
waitpid(module->pid, NULL, WNOHANG);
module->active = false;
}
static bool handshake_ok(const char *line) {
char type[64];
char protocol[64];
return tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
strcmp(type, "handshake.ok") == 0 &&
tnt_json_get_string_field(line, "protocol", protocol,
sizeof(protocol)) &&
strcmp(protocol, TNT_MODULE_PROTOCOL_VERSION) == 0;
}
static int start_module_process(const char *module_dir,
module_process_t *module) {
int in_pipe[2] = {-1, -1};
int out_pipe[2] = {-1, -1};
char handshake[512] = "";
char line[TNT_MODULE_LINE_MAX];
size_t pos = 0;
if (!module) {
return -1;
}
if (pipe(in_pipe) < 0) {
return -1;
}
if (pipe(out_pipe) < 0) {
close(in_pipe[0]);
close(in_pipe[1]);
return -1;
}
pid_t pid = fork();
if (pid < 0) {
close(in_pipe[0]);
close(in_pipe[1]);
close(out_pipe[0]);
close(out_pipe[1]);
return -1;
}
if (pid == 0) {
close(in_pipe[1]);
close(out_pipe[0]);
if (dup2(in_pipe[0], STDIN_FILENO) < 0 ||
dup2(out_pipe[1], STDOUT_FILENO) < 0 ||
chdir(module_dir) < 0) {
_exit(127);
}
close(in_pipe[0]);
close(out_pipe[1]);
execl(module->manifest.entrypoint, module->manifest.entrypoint,
(char *)NULL);
_exit(127);
}
close(in_pipe[0]);
close(out_pipe[1]);
module->pid = pid;
module->stdin_fd = in_pipe[1];
module->stdout_fd = out_pipe[0];
module->active = true;
if (tnt_module_append_handshake(handshake, sizeof(handshake), &pos,
TNT_VERSION) < 0 ||
write(module->stdin_fd, handshake, strlen(handshake)) !=
(ssize_t)strlen(handshake) ||
read_line_timeout(module->stdout_fd, line, sizeof(line),
TNT_MODULE_HANDSHAKE_TIMEOUT_MS) <= 0 ||
!handshake_ok(line)) {
close_module_process(module);
return -1;
}
return 0;
}
static void enqueue_message(const message_t *msg) {
module_event_node_t *node;
if (!msg) return;
pthread_mutex_lock(&g_queue_lock);
if (!g_running || g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
pthread_mutex_unlock(&g_queue_lock);
if (g_queue_len >= TNT_MODULE_QUEUE_LIMIT) {
fprintf(stderr, "module runtime: event queue full, dropping\n");
}
return;
}
node = calloc(1, sizeof(*node));
if (!node) {
pthread_mutex_unlock(&g_queue_lock);
return;
}
node->msg = *msg;
if (g_queue_tail) {
g_queue_tail->next = node;
} else {
g_queue_head = node;
}
g_queue_tail = node;
g_queue_len++;
pthread_cond_signal(&g_queue_cond);
pthread_mutex_unlock(&g_queue_lock);
}
static module_event_node_t *dequeue_message(void) {
module_event_node_t *node;
pthread_mutex_lock(&g_queue_lock);
while (g_running && !g_queue_head) {
pthread_cond_wait(&g_queue_cond, &g_queue_lock);
}
node = g_queue_head;
if (node) {
g_queue_head = node->next;
if (!g_queue_head) g_queue_tail = NULL;
g_queue_len--;
}
pthread_mutex_unlock(&g_queue_lock);
return node;
}
static void publish_module_message(const module_process_t *module,
const char *plain_text) {
message_t msg = {
.timestamp = time(NULL),
};
if (!module || !plain_text || plain_text[0] == '\0') return;
snprintf(msg.username, sizeof(msg.username), "module:%s",
module->manifest.name);
snprintf(msg.content, sizeof(msg.content), "%s", plain_text);
if (message_save(&msg) < 0) {
fprintf(stderr, "module runtime: failed to persist module message\n");
return;
}
room_broadcast(g_room, &msg);
notify_mentions(msg.content, NULL);
}
static void handle_module_response(module_process_t *module, const char *line) {
tnt_module_message_create_t create;
char type[64];
if (!module || !line || line[0] == '\0') return;
if (tnt_module_parse_message_create(line, &create)) {
publish_module_message(module, create.plain_text);
return;
}
if (tnt_json_get_string_field(line, "type", type, sizeof(type)) &&
strcmp(type, "event.ok") == 0) {
return;
}
fprintf(stderr, "module runtime: ignored invalid response from %s\n",
module->manifest.name);
}
static void deliver_message_to_module(module_process_t *module,
const message_t *msg,
uint64_t event_id) {
char event[TNT_MODULE_LINE_MAX] = "";
char line[TNT_MODULE_LINE_MAX];
char message_id[64];
size_t pos = 0;
if (!module || !module->active || !msg) return;
snprintf(message_id, sizeof(message_id), "local-%llu",
(unsigned long long)event_id);
if (tnt_module_append_message_created(event, sizeof(event), &pos,
message_id, msg) < 0 ||
write(module->stdin_fd, event, strlen(event)) !=
(ssize_t)strlen(event)) {
fprintf(stderr, "module runtime: disabling %s after write failure\n",
module->manifest.name);
close_module_process(module);
return;
}
while (1) {
int n = read_line_timeout(module->stdout_fd, line, sizeof(line),
TNT_MODULE_RESPONSE_TIMEOUT_MS);
if (n == 0) {
return;
}
if (n < 0) {
fprintf(stderr, "module runtime: disabling %s after read failure\n",
module->manifest.name);
close_module_process(module);
return;
}
handle_module_response(module, line);
}
}
static void *module_worker_main(void *arg) {
uint64_t event_id = 0;
(void)arg;
while (g_running) {
module_event_node_t *node = dequeue_message();
if (!node) {
continue;
}
event_id++;
for (int i = 0; i < g_module_count; i++) {
deliver_message_to_module(&g_modules[i], &node->msg, event_id);
}
free(node);
}
return NULL;
}
static int load_modules_from_env(void) {
const char *paths = getenv("TNT_MODULE_PATHS");
char copy[4096];
char *saveptr = NULL;
char *token;
if (!paths || paths[0] == '\0') {
return 0;
}
if (strlen(paths) >= sizeof(copy)) {
fprintf(stderr, "module runtime: TNT_MODULE_PATHS too long\n");
return -1;
}
snprintf(copy, sizeof(copy), "%s", paths);
token = strtok_r(copy, ":", &saveptr);
while (token && g_module_count < TNT_MAX_MODULES) {
module_process_t *module = &g_modules[g_module_count];
memset(module, 0, sizeof(*module));
module->stdin_fd = -1;
module->stdout_fd = -1;
if (tnt_module_manifest_load(token, &module->manifest) == 0 &&
start_module_process(token, module) == 0) {
fprintf(stderr, "module runtime: enabled %s\n",
module->manifest.name);
g_module_count++;
} else {
fprintf(stderr, "module runtime: failed to enable module at %s\n",
token);
}
token = strtok_r(NULL, ":", &saveptr);
}
return 0;
}
int tnt_module_runtime_init(void) {
g_module_count = 0;
g_running = false;
if (load_modules_from_env() < 0) {
return -1;
}
if (g_module_count == 0) {
return 0;
}
g_running = true;
if (pthread_create(&g_module_thread, NULL, module_worker_main, NULL) != 0) {
g_running = false;
for (int i = 0; i < g_module_count; i++) {
close_module_process(&g_modules[i]);
}
g_module_count = 0;
return -1;
}
g_thread_started = true;
return 0;
}
void tnt_module_runtime_shutdown(void) {
module_event_node_t *node;
pthread_mutex_lock(&g_queue_lock);
g_running = false;
pthread_cond_broadcast(&g_queue_cond);
pthread_mutex_unlock(&g_queue_lock);
if (g_thread_started) {
pthread_join(g_module_thread, NULL);
g_thread_started = false;
}
while ((node = g_queue_head) != NULL) {
g_queue_head = node->next;
free(node);
}
g_queue_tail = NULL;
g_queue_len = 0;
for (int i = 0; i < g_module_count; i++) {
close_module_process(&g_modules[i]);
}
g_module_count = 0;
}
void tnt_module_runtime_publish_message_created(const message_t *msg) {
if (!msg || g_module_count == 0) {
return;
}
enqueue_message(msg);
}

View file

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

View file

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

View file

@ -1,17 +0,0 @@
#include "support.h"
#include "support_text.h"
void support_append_interactive_panel(char *buffer, size_t buf_size,
size_t *pos, help_lang_t lang) {
if (!buffer || !pos) return;
buffer_appendf(buffer, buf_size, pos, "%s",
support_text_interactive(lang));
}
void support_append_exec_panel(char *buffer, size_t buf_size, size_t *pos,
help_lang_t lang) {
if (!buffer || !pos) return;
buffer_appendf(buffer, buf_size, pos, "%s", support_text_exec(lang));
}

View file

@ -1,97 +0,0 @@
#include "support_text.h"
const char *support_text_interactive(help_lang_t lang) {
if (lang == LANG_ZH) {
return "\033[1;36m支持 · support\033[0m\n"
"\n"
"\033[1;37m第一次进来\033[0m\n"
" INSERT 输入消息Enter 发送ESC 进入 NORMAL\n"
" NORMAL 浏览消息G 回到最新i 继续输入\n"
" COMMAND 按 : 输入命令q/ESC 关闭当前面板\n"
"\n"
"\033[1;37m我想...\033[0m\n"
" 看谁在线 :users\n"
" 看最近历史 :last 20\n"
" 搜索聊天记录 :search <keyword>\n"
" 回到最新消息 G 或 End\n"
" 私聊某个人 :msg <user> <text>\n"
" 查看私聊收件箱 :inbox\n"
" 静音进出提示 :mute-joins\n"
"\n"
"\033[1;37m遇到问题\033[0m\n"
" 看不到新消息: 在 NORMAL 按 G 或 End 回到最新\n"
" 粘贴多行文本: 直接粘贴TNT 会等 Enter 后一次发送\n"
" 输入太长: 状态行接近限制时会提示,超出会响铃\n"
" 命令不记得: 输入 :help 看列表,输入 :support 回到这里\n"
" 连接断开: 可能是空闲超时、连接数限制或网络重连\n"
"\n"
"\033[2;37m更多: ? 打开完整按键帮助,:help 查看命令列表\033[0m\n";
}
return "\033[1;36mSupport\033[0m\n"
"\n"
"\033[1;37mFirst minute\033[0m\n"
" INSERT Type messages, Enter sends, ESC enters NORMAL\n"
" NORMAL Browse history, G jumps latest, i continues typing\n"
" COMMAND Press : for commands, q/ESC closes this panel\n"
"\n"
"\033[1;37mI want to...\033[0m\n"
" See who is online :users\n"
" See recent history :last 20\n"
" Search history :search <keyword>\n"
" Return to latest G or End\n"
" Whisper someone :msg <user> <text>\n"
" Read whispers :inbox\n"
" Mute join notices :mute-joins\n"
"\n"
"\033[1;37mTroubleshooting\033[0m\n"
" Missing new messages: press G or End in NORMAL\n"
" Pasting many lines: paste normally, then Enter sends once\n"
" Message too long: the status line warns near the limit\n"
" Forgot a command: type :help or return here with :support\n"
" Disconnected: check idle timeout, limits, or reconnect\n"
"\n"
"\033[2;37mMore: ? opens full key help, :help lists commands\033[0m\n";
}
const char *support_text_exec(help_lang_t lang) {
if (lang == LANG_ZH) {
return "TNT 支持\n"
"\n"
"交互使用:\n"
" ssh -p 2222 HOST\n"
" INSERT: 输入消息并按 Enter 发送\n"
" NORMAL: G 回到最新k/PageUp 查看更早消息\n"
" COMMAND: 按 : 后可运行 users, last, search, msg, inbox\n"
"\n"
"非交互检查:\n"
" ssh -p 2222 HOST health\n"
" ssh -p 2222 HOST stats --json\n"
" ssh -p 2222 HOST users --json\n"
" ssh -p 2222 HOST 'tail -n 20'\n"
" ssh -p 2222 USER@HOST post 'message'\n"
"\n"
"排查:\n"
" 连接过早关闭: 检查限流、空闲超时、连接容量、\n"
" 单 IP 限制和防火墙规则。\n";
}
return "TNT support\n"
"\n"
"Interactive use:\n"
" ssh -p 2222 HOST\n"
" INSERT: type and press Enter to send\n"
" NORMAL: press G for latest, k/PageUp for older messages\n"
" COMMAND: press : then run users, last, search, msg, inbox\n"
"\n"
"Non-interactive checks:\n"
" ssh -p 2222 HOST health\n"
" ssh -p 2222 HOST stats --json\n"
" ssh -p 2222 HOST users --json\n"
" ssh -p 2222 HOST 'tail -n 20'\n"
" ssh -p 2222 USER@HOST post 'message'\n"
"\n"
"Troubleshooting:\n"
" Connection closes early: check rate limits, idle timeout,\n"
" global connection capacity, per-IP limits, and firewall rules.\n";
}

View file

@ -4,7 +4,7 @@
#include <string.h>
#include <time.h>
static void system_message_init(message_t *msg, help_lang_t lang) {
static void system_message_init(message_t *msg, ui_lang_t lang) {
if (!msg) {
return;
}
@ -16,7 +16,7 @@ static void system_message_init(message_t *msg, help_lang_t lang) {
}
void system_message_make_join(message_t *msg, const char *username,
help_lang_t lang) {
ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;
@ -28,7 +28,7 @@ void system_message_make_join(message_t *msg, const char *username,
}
void system_message_make_leave(message_t *msg, const char *username,
help_lang_t lang) {
ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;
@ -40,7 +40,7 @@ void system_message_make_leave(message_t *msg, const char *username,
}
void system_message_make_nick(message_t *msg, const char *old_name,
const char *new_name, help_lang_t lang) {
const char *new_name, ui_lang_t lang) {
system_message_init(msg, lang);
if (!msg) {
return;

298
src/tntctl.c Normal file
View file

@ -0,0 +1,298 @@
#include "common.h"
#include "config_defaults.h"
#include "exec_catalog.h"
#include "i18n.h"
#include "tntctl_text.h"
#include <ctype.h>
#include <errno.h>
#include <stdio.h>
#include <sys/wait.h>
#include <unistd.h>
static void print_usage(FILE *stream, ui_lang_t lang) {
char output[2048];
size_t pos = 0;
output[0] = '\0';
tntctl_text_append_usage(output, sizeof(output), &pos, lang);
fputs(output, stream);
}
static void print_error(ui_lang_t lang, tntctl_text_id_t id) {
fprintf(stderr, "tntctl: %s\n", tntctl_text(lang, id));
}
static void print_error_format(ui_lang_t lang, tntctl_text_id_t id,
const char *value) {
fprintf(stderr, "tntctl: ");
fprintf(stderr, tntctl_text(lang, id), value);
fputc('\n', stderr);
}
static bool is_valid_port(const char *value) {
char *end = NULL;
long port;
if (!value || value[0] == '\0') {
return false;
}
errno = 0;
port = strtol(value, &end, 10);
return errno == 0 && end && *end == '\0' && port > 0 && port <= 65535;
}
static bool is_safe_ssh_token(const char *value) {
const unsigned char *p = (const unsigned char *)value;
if (!value || value[0] == '\0' || value[0] == '-') {
return true;
}
while (*p) {
if (isspace(*p) || iscntrl(*p) || *p == ';' || *p == '&' ||
*p == '|' || *p == '`' || *p == '$' || *p == '<' ||
*p == '>' || *p == '\\') {
return true;
}
p++;
}
return false;
}
static bool has_newline(const char *value) {
const char *p = value;
while (p && *p) {
if (*p == '\n' || *p == '\r') {
return true;
}
p++;
}
return false;
}
static bool is_host_key_checking_mode(const char *value) {
return value &&
(strcmp(value, "yes") == 0 ||
strcmp(value, "accept-new") == 0 ||
strcmp(value, "no") == 0);
}
static bool is_known_exec_command(const char *command) {
return exec_catalog_match(command, NULL, NULL);
}
static int build_remote_command(char *buffer, size_t buf_size, int argc,
char **argv, int first_arg) {
size_t pos = 0;
if (first_arg >= argc) {
return -1;
}
buffer[0] = '\0';
for (int i = first_arg; i < argc; i++) {
size_t len;
if (has_newline(argv[i])) {
return -1;
}
len = strlen(argv[i]);
if (pos + len + (i > first_arg ? 1u : 0u) >= buf_size) {
return -1;
}
if (i > first_arg) {
buffer[pos++] = ' ';
}
memcpy(buffer + pos, argv[i], len);
pos += len;
buffer[pos] = '\0';
}
return 0;
}
static int run_ssh(char **ssh_argv) {
pid_t pid = fork();
int status;
if (pid < 0) {
perror("tntctl: fork");
return TNT_EXIT_ERROR;
}
if (pid == 0) {
execvp("ssh", ssh_argv);
perror("tntctl: ssh");
_exit(TNT_EXIT_UNAVAILABLE);
}
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) {
perror("tntctl: waitpid");
return TNT_EXIT_ERROR;
}
}
if (WIFEXITED(status)) {
int rc = WEXITSTATUS(status);
return rc == 255 ? TNT_EXIT_UNAVAILABLE : rc;
}
if (WIFSIGNALED(status)) {
return 128 + WTERMSIG(status);
}
return TNT_EXIT_ERROR;
}
int main(int argc, char **argv) {
const char *port = TNT_DEFAULT_PORT_TEXT;
const char *login = NULL;
const char *host_key_checking = NULL;
const char *known_hosts = NULL;
char host_key_option[64];
char known_hosts_option[1024];
int i;
const char *host;
char destination[512];
char remote_command[MAX_EXEC_COMMAND_LEN];
char **ssh_argv = NULL;
int ssh_argc = 0;
int rc;
ui_lang_t lang = i18n_default_ui_lang();
for (i = 1; i < argc; i++) {
if (strcmp(argv[i], "--") == 0) {
i++;
break;
} else if (strcmp(argv[i], "-h") == 0 ||
strcmp(argv[i], "--help") == 0) {
print_usage(stdout, lang);
return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-V") == 0 ||
strcmp(argv[i], "--version") == 0) {
printf("tntctl %s\n", TNT_VERSION);
return TNT_EXIT_OK;
} else if (strcmp(argv[i], "-p") == 0 ||
strcmp(argv[i], "--port") == 0) {
if (i + 1 >= argc || !is_valid_port(argv[i + 1])) {
print_error(lang, TNTCTL_TEXT_INVALID_PORT);
return TNT_EXIT_USAGE;
}
port = argv[++i];
} else if (strcmp(argv[i], "-l") == 0 ||
strcmp(argv[i], "--login") == 0) {
if (i + 1 >= argc || is_safe_ssh_token(argv[i + 1]) ||
strchr(argv[i + 1], '@')) {
print_error(lang, TNTCTL_TEXT_INVALID_LOGIN);
return TNT_EXIT_USAGE;
}
login = argv[++i];
} else if (strcmp(argv[i], "--host-key-checking") == 0) {
if (i + 1 >= argc || !is_host_key_checking_mode(argv[i + 1])) {
print_error(lang, TNTCTL_TEXT_INVALID_HOST_KEY_MODE);
return TNT_EXIT_USAGE;
}
host_key_checking = argv[++i];
} else if (strcmp(argv[i], "--known-hosts") == 0) {
if (i + 1 >= argc || argv[i + 1][0] == '\0' ||
has_newline(argv[i + 1])) {
print_error(lang, TNTCTL_TEXT_INVALID_KNOWN_HOSTS);
return TNT_EXIT_USAGE;
}
known_hosts = argv[++i];
} else if (argv[i][0] == '-') {
print_error_format(lang, TNTCTL_TEXT_UNKNOWN_OPTION_FORMAT,
argv[i]);
print_usage(stderr, lang);
return TNT_EXIT_USAGE;
} else {
break;
}
}
if (i >= argc) {
print_error(lang, TNTCTL_TEXT_MISSING_HOST);
print_usage(stderr, lang);
return TNT_EXIT_USAGE;
}
host = argv[i++];
if (is_safe_ssh_token(host)) {
print_error(lang, TNTCTL_TEXT_INVALID_HOST);
return TNT_EXIT_USAGE;
}
if (login && strchr(host, '@')) {
print_error(lang, TNTCTL_TEXT_LOGIN_HOST_CONFLICT);
return TNT_EXIT_USAGE;
}
if (i >= argc || !is_known_exec_command(argv[i])) {
print_error(lang, TNTCTL_TEXT_UNKNOWN_COMMAND);
return TNT_EXIT_USAGE;
}
if (build_remote_command(remote_command, sizeof(remote_command), argc,
argv, i) < 0) {
print_error(lang, TNTCTL_TEXT_INVALID_REMOTE_COMMAND);
return TNT_EXIT_USAGE;
}
if (login) {
int n = snprintf(destination, sizeof(destination), "%s@%s", login,
host);
if (n < 0 || n >= (int)sizeof(destination)) {
print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE;
}
} else {
int n = snprintf(destination, sizeof(destination), "%s", host);
if (n < 0 || n >= (int)sizeof(destination)) {
print_error(lang, TNTCTL_TEXT_DESTINATION_TOO_LONG);
return TNT_EXIT_USAGE;
}
}
if (destination[0] == '-') {
print_error(lang, TNTCTL_TEXT_INVALID_DESTINATION);
return TNT_EXIT_USAGE;
}
ssh_argv = calloc((size_t)argc * 2u + 8u, sizeof(*ssh_argv));
if (!ssh_argv) {
print_error(lang, TNTCTL_TEXT_OUT_OF_MEMORY);
return TNT_EXIT_ERROR;
}
ssh_argv[ssh_argc++] = "ssh";
ssh_argv[ssh_argc++] = "-p";
ssh_argv[ssh_argc++] = (char *)port;
if (host_key_checking) {
int n = snprintf(host_key_option, sizeof(host_key_option),
"StrictHostKeyChecking=%s", host_key_checking);
if (n < 0 || n >= (int)sizeof(host_key_option)) {
print_error(lang, TNTCTL_TEXT_HOST_KEY_OPTION_TOO_LONG);
free(ssh_argv);
return TNT_EXIT_USAGE;
}
ssh_argv[ssh_argc++] = "-o";
ssh_argv[ssh_argc++] = host_key_option;
}
if (known_hosts) {
int n = snprintf(known_hosts_option, sizeof(known_hosts_option),
"UserKnownHostsFile=%s", known_hosts);
if (n < 0 || n >= (int)sizeof(known_hosts_option)) {
print_error(lang, TNTCTL_TEXT_KNOWN_HOSTS_OPTION_TOO_LONG);
free(ssh_argv);
return TNT_EXIT_USAGE;
}
ssh_argv[ssh_argc++] = "-o";
ssh_argv[ssh_argc++] = known_hosts_option;
}
ssh_argv[ssh_argc++] = destination;
ssh_argv[ssh_argc++] = remote_command;
ssh_argv[ssh_argc] = NULL;
rc = run_ssh(ssh_argv);
free(ssh_argv);
return rc;
}

101
src/tntctl_text.c Normal file
View file

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

244
src/tui.c
View file

@ -21,6 +21,25 @@ static const char *username_color(const char *name) {
return colors[h % 6];
}
static char *client_render_buffer(client_t *client, size_t min_size) {
if (!client || min_size == 0) {
return NULL;
}
if (client->render_buffer_capacity >= min_size) {
return client->render_buffer;
}
char *grown = realloc(client->render_buffer, min_size);
if (!grown) {
return NULL;
}
client->render_buffer = grown;
client->render_buffer_capacity = min_size;
return client->render_buffer;
}
static void format_message_colored(const message_t *msg, char *buffer,
size_t buf_size, int width,
const char *my_username) {
@ -154,8 +173,8 @@ void tui_render_welcome(client_t *client) {
/* Lines, in display order. Width is computed in display columns. */
const char *line1 = "TNT · " TNT_VERSION;
const char *line2 = i18n_text(client->help_lang, I18N_WELCOME_SUBTITLE);
const char *line3 = i18n_text(client->help_lang, I18N_WELCOME_TAGLINE);
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);
@ -169,7 +188,7 @@ void tui_render_welcome(client_t *client) {
char fallback_text[96];
char fallback[128];
snprintf(fallback_text, sizeof(fallback_text),
i18n_text(client->help_lang, I18N_WELCOME_FALLBACK_FORMAT),
i18n_text(client->ui_lang, I18N_WELCOME_FALLBACK_FORMAT),
TNT_VERSION);
int n = snprintf(fallback, sizeof(fallback), ANSI_CLEAR ANSI_HOME "%s",
fallback_text);
@ -245,7 +264,7 @@ void tui_render_screen(client_t *client) {
if (render_height < 4) render_height = 4;
const size_t buf_size = (size_t)(render_height + 10) * (MAX_MESSAGE_LEN + 64) + 2048;
char *buffer = malloc(buf_size);
char *buffer = client_render_buffer(client, buf_size);
if (!buffer) return;
size_t pos = 0;
buffer[0] = '\0';
@ -255,6 +274,7 @@ void tui_render_screen(client_t *client) {
int online = g_room->client_count;
int msg_count = g_room->message_count;
pthread_rwlock_unlock(&g_room->lock);
int raw_msg_count = msg_count;
/* Calculate which messages to show. The initial slice is capped by
* message count; the lock-held copy below tightens "latest" slices so
@ -280,47 +300,95 @@ void tui_render_screen(client_t *client) {
int end = start + msg_height;
if (end > msg_count) end = msg_count;
/* Allocate snapshot outside the lock to avoid blocking writers */
message_t *visible_messages = NULL;
message_t *msg_snapshot = NULL;
int snapshot_capacity = msg_height;
int snapshot_count = end - start;
int snapshot_count = 0;
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
if (client->mute_joins && msg_count > 0) {
visible_messages = calloc(MAX_MESSAGES, sizeof(message_t));
if (visible_messages) {
int visible_count = 0;
pthread_rwlock_rdlock(&g_room->lock);
online = g_room->client_count;
raw_msg_count = g_room->message_count;
for (int i = 0; i < g_room->message_count; i++) {
if (!system_message_is_join_leave(&g_room->messages[i])) {
visible_messages[visible_count++] = g_room->messages[i];
}
}
pthread_rwlock_unlock(&g_room->lock);
msg_count = visible_count;
latest_scroll_start = history_view_max_scroll(msg_count, msg_height);
anchor_latest = client->mode != MODE_NORMAL ||
client->follow_tail ||
client->scroll_pos >= latest_scroll_start;
if (client->mode == MODE_NORMAL) {
start = client->scroll_pos;
if (start > latest_scroll_start) {
start = latest_scroll_start;
}
if (start < 0) start = 0;
} else {
start = latest_scroll_start;
}
end = start + msg_height;
if (end > msg_count) end = msg_count;
if (anchor_latest) {
start = history_view_latest_start_for_height(
visible_messages, msg_count, msg_height);
end = msg_count;
}
snapshot_count = end - start;
if (snapshot_count > 0) {
msg_snapshot = visible_messages + start;
}
}
}
/* Second pass under lock: copy messages */
if (msg_snapshot) {
pthread_rwlock_rdlock(&g_room->lock);
/* Re-clamp in case msg_count changed */
int actual_count = g_room->message_count;
int actual_start = start;
int actual_end = end;
if (anchor_latest) {
actual_end = actual_count;
actual_start = history_view_latest_start_for_height(
g_room->messages, actual_count, msg_height);
} else {
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
if (!visible_messages) {
/* Allocate snapshot outside the lock to avoid blocking writers */
int snapshot_capacity = msg_height;
snapshot_count = end - start;
if (snapshot_count > 0 && snapshot_capacity > 0) {
msg_snapshot = calloc(snapshot_capacity, sizeof(message_t));
}
int actual_snapshot = actual_end - actual_start;
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;
/* Second pass under lock: copy messages */
if (msg_snapshot) {
pthread_rwlock_rdlock(&g_room->lock);
/* Re-clamp in case msg_count changed */
int actual_count = g_room->message_count;
int actual_start = start;
int actual_end = end;
if (anchor_latest) {
actual_end = actual_count;
actual_start = history_view_latest_start_for_height(
g_room->messages, actual_count, msg_height);
} else {
actual_end = (actual_end <= actual_count) ? actual_end : actual_count;
actual_start = (actual_start < actual_end) ? actual_start : actual_end;
}
int actual_snapshot = actual_end - actual_start;
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;
}
pthread_rwlock_unlock(&g_room->lock);
}
pthread_rwlock_unlock(&g_room->lock);
}
/* Now render using snapshot (no lock held) */
/* If mute_joins is set, remove join/leave messages from snapshot in place */
if (client->mute_joins && msg_snapshot) {
if (client->mute_joins && msg_snapshot && !anchor_latest) {
int filtered = 0;
for (int i = 0; i < snapshot_count; i++) {
if (!system_message_is_join_leave(&msg_snapshot[i])) {
@ -355,7 +423,7 @@ void tui_render_screen(client_t *client) {
char online_buf[32];
snprintf(online_buf, sizeof(online_buf),
i18n_text(client->help_lang, I18N_TITLE_ONLINE_FORMAT),
i18n_text(client->ui_lang, I18N_TITLE_ONLINE_FORMAT),
online);
chips[chip_count].value = online_buf;
chips[chip_count].value_color = "\033[37m";
@ -373,9 +441,11 @@ void tui_render_screen(client_t *client) {
chips[chip_count].value_color = mode_color;
chip_count++;
const char *hint = i18n_text(client->help_lang, I18N_TITLE_HELP_HINT);
const char *hint = client->mode == MODE_NORMAL
? i18n_text(client->ui_lang, I18N_TITLE_HELP_HINT)
: "";
int hint_width = utf8_string_width(hint);
const char *mute_label = i18n_text(client->help_lang, I18N_TITLE_MUTED);
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
/* Unread @-mentions chip — high-priority, gets a bright yellow star.
@ -401,7 +471,7 @@ void tui_render_screen(client_t *client) {
/* Decide what fits. Reserve at least 1 col of gap between left and
* right halves so they never visually touch. */
int show_hint = 1;
int show_hint = hint[0] != '\0';
int show_mute = client->mute_joins ? 1 : 0;
int show_unread = unread_count > 0 ? 1 : 0;
int show_whisper = whisper_count > 0 ? 1 : 0;
@ -511,9 +581,26 @@ void tui_render_screen(client_t *client) {
buffer_appendf(buffer, buf_size, &pos, "%s\033[K\r\n", msg_line);
rows_written++;
}
free(msg_snapshot);
}
if (rows_written == 0) {
const char *empty_text =
client->mute_joins && raw_msg_count > 0
? i18n_text(client->ui_lang, I18N_EMPTY_FILTERED)
: i18n_text(client->ui_lang, I18N_EMPTY_ROOM);
int empty_width = utf8_string_width(empty_text);
int empty_pad = (render_width - empty_width) / 2;
if (empty_pad < 0) empty_pad = 0;
for (int i = 0; i < empty_pad; 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", empty_text);
rows_written++;
}
free(visible_messages ? visible_messages : msg_snapshot);
/* Fill empty lines and clear them */
for (int i = rows_written; i < msg_height; i++) {
buffer_appendf(buffer, buf_size, &pos, "\033[K\r\n");
@ -529,7 +616,6 @@ void tui_render_screen(client_t *client) {
tui_status_append(buffer, buf_size, &pos, client, msg_count, start, end);
client_send(client, buffer, pos);
free(buffer);
}
/* Render the input line.
@ -606,6 +692,23 @@ void tui_render_input(client_t *client, const char *input) {
client_send(client, buffer, strlen(buffer));
}
void tui_render_command_input(client_t *client) {
if (!client || !client->connected) return;
int rh = client->height;
if (rh < 4) rh = 4;
char buffer[sizeof(client->command_input) + 64];
size_t pos = 0;
buffer[0] = '\0';
buffer_appendf(buffer, sizeof(buffer), &pos,
"\033[%d;1H" ANSI_CLEAR_LINE, rh);
tui_status_append(buffer, sizeof(buffer), &pos, client, 0, 0, 0);
client_send(client, buffer, pos);
}
/* Render the command output screen */
void tui_render_command_output(client_t *client) {
if (!client || !client->connected) return;
@ -615,7 +718,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';
@ -623,7 +726,7 @@ void tui_render_command_output(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = i18n_text(client->help_lang,
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);
@ -639,23 +742,50 @@ void tui_render_command_output(client_t *client) {
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;
char *line = strtok(output_copy, "\n");
while (line && line_count < (int)(sizeof(lines) / sizeof(lines[0]))) {
lines[line_count++] = line;
line = strtok(NULL, "\n");
}
while (line && line_count < max_lines) {
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(line, truncated, sizeof(truncated), rw);
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,
client->command_output_kind ==
TNT_COMMAND_OUTPUT_INBOX
? I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT
: I18N_COMMAND_OUTPUT_STATUS_FORMAT),
start + 1, max_scroll + 1);
client_send(client, buffer, pos);
}
@ -682,7 +812,7 @@ void tui_render_motd(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Top border with a localized title chip. */
const char *title = i18n_text(client->help_lang, I18N_MOTD_TITLE);
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;
@ -734,7 +864,7 @@ void tui_render_motd(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, "\r\n");
/* Bottom border with a localized continue hint. */
const char *footer = i18n_text(client->help_lang,
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;
@ -766,7 +896,7 @@ void tui_render_help(client_t *client) {
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_CLEAR ANSI_HOME);
/* Title */
const char *title = i18n_text(client->help_lang, I18N_HELP_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;
@ -777,11 +907,11 @@ void tui_render_help(client_t *client) {
}
buffer_appendf(buffer, sizeof(buffer), &pos, ANSI_RESET "\r\n");
/* Help content */
const char *help_text = help_text_full(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];
@ -812,7 +942,7 @@ void tui_render_help(client_t *client) {
/* Status line */
buffer_appendf(buffer, sizeof(buffer), &pos,
i18n_text(client->help_lang, I18N_HELP_STATUS_FORMAT),
i18n_text(client->ui_lang, I18N_HELP_STATUS_FORMAT),
start + 1, max_scroll + 1);
client_send(client, buffer, pos);

View file

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

82
tests/test_cli_options.sh Executable file
View file

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

Some files were not shown because too many files have changed in this diff Show more