Compare commits

..

36 commits

Author SHA1 Message Date
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
98 changed files with 7162 additions and 702 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,34 @@ name: CI
on:
push:
branches: [ main ]
branches: [ main, 'release/**' ]
pull_request:
branches: [ main ]
branches: [ main, 'release/**' ]
workflow_dispatch:
schedule:
- cron: '17 3 * * *'
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 +54,26 @@ jobs:
- name: Run release preflight
run: make release-check
extended-linux-runtime:
name: Extended Linux runtime
if: github.event_name == 'schedule' || 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 +128,60 @@ jobs:
cat "$VALGRIND_LOG"
exit 1
fi
portable-container-builds:
name: Portable build (${{ matrix.name }})
if: github.event_name == 'schedule' || 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 == 'schedule' || 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

5
.gitignore vendored
View file

@ -1,6 +1,7 @@
*.o
obj/
tnt
tntctl
messages.log
host_key
host_key.pub
@ -8,6 +9,9 @@ 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
@ -20,4 +24,5 @@ tests/unit/test_help_text
tests/unit/test_manual_text
tests/unit/test_support_text
tests/unit/test_cli_text
tests/unit/test_tntctl_text
tests/unit/test_ratelimit

View file

@ -4,6 +4,7 @@
CC = gcc
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
LDFLAGS = -pthread -lssh
CTL_LDFLAGS =
INCLUDES = -Iinclude
DEPFLAGS = -MMD -MP
@ -20,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 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,7 +116,7 @@ check:
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
# Test
test: all unit-test integration-test
test: all unit-test script-test integration-test
test-advisory: all unit-test
@echo "Running integration tests..."
@ -107,11 +128,22 @@ 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 && ./test_tntctl_cli.sh
anonymous-access-test: all
@echo "Running anonymous access tests..."
@ -129,6 +161,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))

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.example.com
ssh -p 2222 localhost
```
For a deployed server, replace `localhost` with your public host, for example
`chat.example.com`.
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
## Usage
@ -94,7 +98,7 @@ Ctrl+C - Exit chat
:w <user> <text> - Short alias for :msg
:inbox - Show private messages
:last [N] - Show last N messages from history (max 50, default 10)
:search <keyword> - Search full message history (case-insensitive)
:search <keyword> - Search message history (shows last 15 matches)
:mute-joins - Toggle join/leave system notifications
:lang <en|zh> - Switch UI language for this session
:help - Show concise manual
@ -104,6 +108,10 @@ Up/Down - Browse command history
ESC - Return to NORMAL mode
```
Command output pages use `j/k`, `Ctrl+D/U`, and `g/G` for paging. `:inbox`
is live: press `r` to refresh it manually, and it refreshes when a new private
message arrives while the inbox is open.
**Special messages (INSERT mode)**
```
/me <action> - Send action (e.g. /me waves)
@ -133,6 +141,21 @@ TNT_PUBLIC_HOST=chat.example.com tnt
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)
@ -177,12 +200,50 @@ 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. 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
@ -205,6 +266,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
@ -214,6 +278,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:**
@ -221,6 +288,8 @@ cd tests
- Anonymous access: 2 tests
- Security features: 12 tests
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
- Slow-client test: an unread interactive SSH client cannot block health,
stats, post, tail, or server survival checks
### Dependencies
@ -254,6 +323,8 @@ TNT/
│ ├── 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
@ -324,10 +395,17 @@ Before preparing a release locally:
make release-check
```
Before publishing package recipes, replace placeholder checksums and run:
Longer local preflight can opt into runtime soak and slow-client coverage:
```sh
make release-check-strict
RUN_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
@ -339,6 +417,9 @@ motd.txt - Message of the Day (optional, shown to users on connect)
tnt.service - systemd service unit
```
The persisted chat-history format is documented in
[docs/MESSAGE_LOG.md](docs/MESSAGE_LOG.md).
### MOTD (Message of the Day)
Place a `motd.txt` file in the state directory to show a welcome message to every user on connect. Users see the MOTD before entering the chat and press any key to continue.
@ -358,6 +439,7 @@ 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
- [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

@ -2,7 +2,157 @@
## 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.
@ -17,10 +167,15 @@
- Refreshed contributor and development guidance so new commands are added
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
`ssh_server.c` / inline-`strcmp` instructions.
- Refreshed developer ownership guidance to match the current update-sequence
model: room broadcasts update shared state only, while each interactive
client renders and flushes its own SSH channel.
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
reducing duplicate command knowledge in `src/exec.c`.
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
public documentation does not imply a specific production host.
- First-run connection examples now use `localhost`, keeping
`chat.example.com` for deployed public-host examples.
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
`src/exec.c` no longer duplicates `--json` and required-message validation.
- Moved interactive command usage text and first-pass argument-shape checks

View file

@ -1,115 +1,270 @@
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 scheduled
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 and Nightly Validation
7. Release appears at:
https://github.com/m1ngsama/TNT/releases
Workflow: `.github/workflows/ci.yml`
Runs on `main` or `release/**` pushes, manual dispatch, and the nightly
schedule:
DEPLOYING TO SERVERS
--------------------
Deployments are operator-driven:
1. Build and test locally or in a temporary server directory.
2. Back up the installed binary.
3. Install the new binary.
4. Restart the service.
5. Run black-box checks (`health`, `stats --json`, `users --json`,
and a post/tail smoke test).
- `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

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

View file

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

View file

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

View file

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

177
docs/INTERFACE.md Normal file
View file

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

106
docs/MESSAGE_LOG.md Normal file
View file

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

View file

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

View file

@ -17,65 +17,80 @@ 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 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 +99,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 +113,9 @@ Goal: make regressions harder to introduce.
These are the next changes that should happen before new feature work expands the surface area.
1. 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.

58
docs/USER_LIFECYCLE.md Normal file
View file

@ -0,0 +1,58 @@
# 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`, `: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 only in the recipient inbox and are not written
to `messages.log`.
- `:inbox` is live enough for normal chat use: it can be refreshed with `r`
and refreshes automatically when a new private message arrives while the
inbox is open.
- Long command output uses a small pager so `:last` and `:search` are readable
on small terminals.
## 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 `:msg`, changes nickname, sends
`/me`, and exits
- second user opens `:inbox` before the private message arrives and sees it
auto-refresh after delivery
- exec `tail` sees public messages
- `messages.log` contains public history and excludes private-message content
This test is intentionally closer to a user story than a unit regression. Keep
it focused on lifecycle guarantees, not every keybinding.

View file

@ -6,6 +6,8 @@
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
const char *program_name, ui_lang_t lang);
const char *cli_text_invalid_port_format(ui_lang_t lang);
const char *cli_text_invalid_value_format(ui_lang_t lang);
const char *cli_text_option_requires_arg_format(ui_lang_t lang);
const char *cli_text_unknown_option_format(ui_lang_t lang);
const char *cli_text_short_usage_format(ui_lang_t lang);

View file

@ -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 */

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,22 +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_COMMAND_OUTPUT_LEN 8192
#define MAX_CLIENTS 64
#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"

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. */

View file

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

View file

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

View file

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

21
include/message_log.h Normal file
View file

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

View file

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

View file

@ -17,6 +17,12 @@ typedef struct {
char content[MAX_MESSAGE_LEN];
} whisper_t;
typedef enum {
TNT_COMMAND_OUTPUT_NONE,
TNT_COMMAND_OUTPUT_GENERIC,
TNT_COMMAND_OUTPUT_INBOX
} tnt_command_output_kind_t;
/* Client connection structure */
typedef struct client {
ssh_session session; /* SSH session */
@ -42,16 +48,23 @@ typedef struct client {
int insert_history_pos;
char command_output[MAX_COMMAND_OUTPUT_LEN];
int command_output_scroll;
tnt_command_output_kind_t command_output_kind;
bool show_motd; /* command_output holds MOTD text */
char exec_command[MAX_EXEC_COMMAND_LEN];
bool exec_command_too_long;
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 *outbox; /* Bounded queued output for interactive writes */
size_t outbox_len;
size_t outbox_pos;
size_t outbox_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;
@ -60,6 +73,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;

29
include/tntctl_text.h Normal file
View file

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

View file

@ -27,6 +27,34 @@ sha256_of() {
fi
}
warn_missing_libssh() {
case "$OS" in
linux)
if command -v ldconfig >/dev/null 2>&1 &&
ldconfig -p 2>/dev/null | grep -q 'libssh\.so'; then
return
fi
for path in /usr/lib/libssh.so* /usr/lib64/libssh.so* \
/lib/libssh.so* /lib64/libssh.so*; do
[ -e "$path" ] && return
done
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first, for example:"
echo " Ubuntu/Debian: sudo apt install libssh-4"
echo " Arch: sudo pacman -S libssh"
;;
darwin)
if [ -e /opt/homebrew/opt/libssh/lib/libssh.dylib ] ||
[ -e /usr/local/opt/libssh/lib/libssh.dylib ]; then
return
fi
echo "WARNING: TNT requires the libssh runtime library."
echo "Install it first:"
echo " brew install libssh"
;;
esac
}
need_cmd curl
need_cmd awk
@ -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,5 +1,6 @@
#include "cli_text.h"
#include "config_defaults.h"
#include "i18n.h"
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
@ -8,42 +9,65 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *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"
" -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: 64)\n"
" TNT_MAX_CONNECTIONS Global connection limit (default: %d)\n"
" TNT_RATE_LIMIT Set to 0 to disable rate limiting\n"
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: 1800)\n",
" TNT_IDLE_TIMEOUT Idle disconnect timeout in seconds (default: %d)\n",
"tnt %s - 匿名 SSH 聊天服务器\n\n"
"用法: %s [options]\n\n"
"选项:\n"
" -p, --port PORT 监听 PORT (默认: %d)\n"
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
" -V, --version 显示版本\n"
" -h, --help 显示此帮助\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 全局连接数限制 (默认: 64)\n"
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: 1800)\n"
" TNT_IDLE_TIMEOUT 空闲断开时间,单位秒 (默认: %d)\n"
);
const char *program = (program_name && program_name[0] != '\0')
? program_name
: "tnt";
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
TNT_VERSION, program, DEFAULT_PORT);
TNT_VERSION, program, TNT_DEFAULT_PORT,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_MAX_CONNECTIONS,
TNT_DEFAULT_IDLE_TIMEOUT);
}
const char *cli_text_invalid_port_format(ui_lang_t lang) {
@ -52,6 +76,19 @@ const char *cli_text_invalid_port_format(ui_lang_t lang) {
return i18n_string(text, lang);
}
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_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");
@ -60,7 +97,7 @@ const char *cli_text_unknown_option_format(ui_lang_t lang) {
const char *cli_text_short_usage_format(ui_lang_t lang) {
static const i18n_string_t text =
I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n",
"用法: %s [-p PORT] [-d DIR] [-h]\n");
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,33 @@ void client_release(client_t *client) {
if (client->channel_cb) {
free(client->channel_cb);
}
free(client->outbox);
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 +334,13 @@ int client_install_channel_callbacks(client_t *client) {
return -1;
}
client_addref(client);
client->channel_callback_ref = true;
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
if (!client->channel_cb) {
client->channel_callback_ref = false;
client_release(client);
return -1;
}
@ -166,6 +354,8 @@ int client_install_channel_callbacks(client_t *client) {
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
free(client->channel_cb);
client->channel_cb = NULL;
client->channel_callback_ref = false;
client_release(client);
return -1;
}

View file

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

80
src/config_defaults.c Normal file
View file

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

View file

@ -123,7 +123,8 @@ static int exec_command_help(client_t *client) {
help_text[0] = '\0';
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
client->ui_lang);
return client_send(client, help_text, pos) == 0 ? 0 : 1;
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) {
@ -134,12 +135,13 @@ static int exec_command_usage(client_t *client, tnt_exec_command_id_t id) {
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
client->ui_lang);
client_printf(client, "%s", usage);
return 64;
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) {
@ -157,7 +159,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++) {
@ -177,7 +179,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) {
@ -195,7 +197,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;
@ -243,10 +245,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) {
@ -288,6 +291,45 @@ static int parse_tail_count(const char *args, int *count) {
return 0;
}
static int parse_dump_count(const char *args, int *count) {
char *end = NULL;
long value;
if (!count) {
return -1;
}
*count = 0;
if (!args || args[0] == '\0') {
return 0;
}
if (strncmp(args, "-n", 2) == 0) {
args += 2;
while (*args && isspace((unsigned char)*args)) {
args++;
}
}
value = strtol(args, &end, 10);
if (end == args) {
return -1;
}
while (*end) {
if (!isspace((unsigned char)*end)) {
return -1;
}
end++;
}
if (value < 1 || value > 10000) {
return -1;
}
*count = (int)value;
return 0;
}
static int exec_command_tail(client_t *client, const char *args) {
int requested = 20;
int total_messages;
@ -316,7 +358,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));
}
@ -328,7 +370,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++) {
@ -338,12 +380,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];
@ -355,6 +418,12 @@ static int exec_command_post(client_t *client, const char *args) {
return exec_command_usage(client, TNT_EXEC_COMMAND_POST);
}
if (strlen(args) >= sizeof(content)) {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_TOO_LONG));
return TNT_EXIT_USAGE;
}
strncpy(content, args, sizeof(content) - 1);
content[sizeof(content) - 1] = '\0';
trim_ascii_whitespace(content);
@ -362,14 +431,14 @@ static int exec_command_post(client_t *client, const char *args) {
if (content[0] == '\0') {
client_printf(client, "%s",
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
return 64;
return TNT_EXIT_USAGE;
}
if (!utf8_is_valid_string(content)) {
client_printf(client, "%s",
i18n_text(client->ui_lang,
I18N_EXEC_POST_INVALID_UTF8));
return 1;
return TNT_EXIT_ERROR;
}
resolve_exec_username(client, username, sizeof(username));
@ -388,18 +457,22 @@ 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);
if (client_send(client, "posted\n", 7) != 0) {
return TNT_EXIT_ERROR;
}
return TNT_EXIT_OK;
}
int exec_dispatch(client_t *client) {
@ -407,6 +480,13 @@ int exec_dispatch(client_t *client) {
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);
@ -431,10 +511,14 @@ int exec_dispatch(client_t *client) {
return exec_command_stats(client, args != NULL);
case TNT_EXEC_COMMAND_TAIL:
return exec_command_tail(client, args);
case TNT_EXEC_COMMAND_DUMP:
return exec_command_dump(client, args);
case TNT_EXEC_COMMAND_POST:
return exec_command_post(client, args);
case TNT_EXEC_COMMAND_EXIT:
return 0;
return TNT_EXIT_OK;
case TNT_EXEC_COMMAND_COUNT:
break;
}
}
@ -448,5 +532,5 @@ int exec_dispatch(client_t *client) {
i18n_text(client->ui_lang,
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
command_copy);
return 64;
return TNT_EXIT_USAGE;
}

View file

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

View file

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

View file

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

View file

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

View file

@ -1,8 +1,10 @@
#include "chat_room.h"
#include "cli_text.h"
#include "config_defaults.h"
#include "common.h"
#include "i18n.h"
#include "message.h"
#include "message_log_tool.h"
#include "ssh_server.h"
#include <signal.h>
#include <unistd.h>
@ -18,57 +20,212 @@ static void signal_handler(int sig) {
_exit(0);
}
int main(int argc, char **argv) {
int port = DEFAULT_PORT;
ui_lang_t lang = i18n_default_ui_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,7 +234,7 @@ 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();
@ -86,14 +243,14 @@ int main(int argc, char **argv) {
g_room = room_create();
if (!g_room) {
fprintf(stderr, "Failed to create chat room\n");
return 1;
return TNT_EXIT_ERROR;
}
/* Initialize server */
if (ssh_server_init(port) < 0) {
fprintf(stderr, "Failed to initialize server\n");
room_destroy(g_room);
return 1;
return TNT_EXIT_ERROR;
}
/* Start server (blocking) */

View file

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

View file

@ -1,29 +1,63 @@
#ifndef _DEFAULT_SOURCE
#define _DEFAULT_SOURCE /* for timegm() on glibc */
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
#endif
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
#endif
#include "message.h"
#include "message_log.h"
#include "utf8.h"
#include <errno.h>
#include <unistd.h>
#include <fcntl.h>
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
static time_t parse_rfc3339_utc(const char *timestamp_str) {
struct tm tm = {0};
static void discard_line_remainder(FILE *fp) {
int c;
if (!timestamp_str) {
return (time_t)-1;
while ((c = fgetc(fp)) != '\n' && c != EOF) {
}
}
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);
}

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;

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);
}

View file

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

View file

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

82
tests/test_cli_options.sh Executable file
View file

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

76
tests/test_docs_help_surface.sh Executable file
View file

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

View file

@ -26,6 +26,7 @@ if [ ! -f "$BIN" ]; then
fi
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null"
echo "=== TNT Exec Mode Tests ==="
@ -51,14 +52,16 @@ else
FAIL=$((FAIL + 1))
fi
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null || true)
HEALTH_USAGE=$(ssh $SSH_OPTS localhost health extra 2>/dev/null)
HEALTH_USAGE_STATUS=$?
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
if [ $? -eq 0 ]; then
echo "✓ no-arg exec usage follows TNT_LANG"
if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then
echo "✓ no-arg exec usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ no-arg exec usage output unexpected"
printf '%s\n' "$HEALTH_USAGE"
echo "exit status: $HEALTH_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
@ -98,36 +101,55 @@ else
FAIL=$((FAIL + 1))
fi
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null || true)
UNKNOWN_OUTPUT=$(ssh $SSH_OPTS localhost nope 2>/dev/null)
UNKNOWN_STATUS=$?
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
if [ $? -eq 0 ]; then
echo "✓ unknown command follows TNT_LANG"
if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then
echo "✓ unknown command follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ unknown command output unexpected"
printf '%s\n' "$UNKNOWN_OUTPUT"
echo "exit status: $UNKNOWN_STATUS"
FAIL=$((FAIL + 1))
fi
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null || true)
POST_USAGE=$(ssh $SSH_OPTS localhost post 2>/dev/null)
POST_USAGE_STATUS=$?
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
if [ $? -eq 0 ]; then
echo "✓ post usage follows TNT_LANG"
if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then
echo "✓ post usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ post usage output unexpected"
printf '%s\n' "$POST_USAGE"
echo "exit status: $POST_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null || true)
USERS_USAGE=$(ssh $SSH_OPTS localhost users --xml 2>/dev/null)
USERS_USAGE_STATUS=$?
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ]; then
echo "✓ users usage follows TNT_LANG"
if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then
echo "✓ users usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ users usage output unexpected"
printf '%s\n' "$USERS_USAGE"
echo "exit status: $USERS_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
DUMP_USAGE=$(ssh $SSH_OPTS localhost "dump -n nope" 2>/dev/null)
DUMP_USAGE_STATUS=$?
printf '%s\n' "$DUMP_USAGE" | grep -q '^dump: 用法: dump \[N\] | dump -n N$'
if [ $? -eq 0 ] && [ "$DUMP_USAGE_STATUS" -eq 64 ]; then
echo "✓ dump usage follows TNT_LANG and exits 64"
PASS=$((PASS + 1))
else
echo "✗ dump usage output unexpected"
printf '%s\n' "$DUMP_USAGE"
echo "exit status: $DUMP_USAGE_STATUS"
FAIL=$((FAIL + 1))
fi
@ -152,6 +174,128 @@ else
FAIL=$((FAIL + 1))
fi
DUMP_OUTPUT=$(ssh $SSH_OPTS localhost "dump -n 1" 2>/dev/null || true)
printf '%s\n' "$DUMP_OUTPUT" | grep -q '|execposter|hello from exec$'
if [ $? -eq 0 ]; then
echo "✓ dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ dump output unexpected"
printf '%s\n' "$DUMP_OUTPUT"
FAIL=$((FAIL + 1))
fi
PERSIST_FAIL_MARKER="persist-fail-marker"
rm -f "$STATE_DIR/messages.log"
mkdir "$STATE_DIR/messages.log"
PERSIST_FAIL_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "$PERSIST_FAIL_MARKER" 2>/dev/null)
PERSIST_FAIL_STATUS=$?
rmdir "$STATE_DIR/messages.log"
printf '%s\n' "$PERSIST_FAIL_OUTPUT" | grep -q 'posted'
PERSIST_FAIL_POSTED=$?
PERSIST_FAIL_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
printf '%s\n' "$PERSIST_FAIL_TAIL" | grep -q "$PERSIST_FAIL_MARKER"
PERSIST_FAIL_VISIBLE=$?
if [ "$PERSIST_FAIL_STATUS" -eq 1 ] &&
[ "$PERSIST_FAIL_POSTED" -ne 0 ] &&
[ "$PERSIST_FAIL_VISIBLE" -ne 0 ]; then
echo "✓ post persistence failure is not broadcast or acknowledged"
PASS=$((PASS + 1))
else
echo "✗ post persistence failure handling unexpected"
printf '%s\n' "$PERSIST_FAIL_OUTPUT"
printf '%s\n' "$PERSIST_FAIL_TAIL"
echo "exit status: $PERSIST_FAIL_STATUS"
FAIL=$((FAIL + 1))
fi
LONG_MARKER="too-long-exec-marker"
LONG_COMMAND=$(printf 'post %s %01020d' "$LONG_MARKER" 0)
LONG_OUTPUT=$(ssh $SSH_OPTS localhost "$LONG_COMMAND" 2>/dev/null)
LONG_STATUS=$?
printf '%s\n' "$LONG_OUTPUT" | grep -q '命令过长'
LONG_ERROR=$?
LONG_TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
printf '%s\n' "$LONG_TAIL" | grep -q "$LONG_MARKER"
LONG_VISIBLE=$?
if [ "$LONG_STATUS" -eq 64 ] &&
[ "$LONG_ERROR" -eq 0 ] &&
[ "$LONG_VISIBLE" -ne 0 ]; then
echo "✓ overlong exec command is rejected without truncation"
PASS=$((PASS + 1))
else
echo "✗ overlong exec command handling unexpected"
printf '%s\n' "$LONG_OUTPUT"
printf '%s\n' "$LONG_TAIL"
echo "exit status: $LONG_STATUS"
FAIL=$((FAIL + 1))
fi
TNTCTL_HEALTH=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost health 2>/dev/null || true)
if [ "$TNTCTL_HEALTH" = "ok" ]; then
echo "✓ tntctl health uses exec interface"
PASS=$((PASS + 1))
else
echo "✗ tntctl health failed: $TNTCTL_HEALTH"
FAIL=$((FAIL + 1))
fi
TNTCTL_STATS=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost stats --json 2>/dev/null || true)
printf '%s\n' "$TNTCTL_STATS" | grep -q '"status":"ok"'
if [ $? -eq 0 ]; then
echo "✓ tntctl stats --json returns JSON"
PASS=$((PASS + 1))
else
echo "✗ tntctl stats --json output unexpected"
printf '%s\n' "$TNTCTL_STATS"
FAIL=$((FAIL + 1))
fi
TNTCTL_USERS_USAGE=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost users --xml 2>/dev/null)
TNTCTL_USERS_STATUS=$?
printf '%s\n' "$TNTCTL_USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
if [ $? -eq 0 ] && [ "$TNTCTL_USERS_STATUS" -eq 64 ]; then
echo "✓ tntctl preserves remote usage exit 64"
PASS=$((PASS + 1))
else
echo "✗ tntctl users usage output unexpected"
printf '%s\n' "$TNTCTL_USERS_USAGE"
echo "exit status: $TNTCTL_USERS_STATUS"
FAIL=$((FAIL + 1))
fi
TNTCTL_POST=$("../tntctl" -p "$PORT" $TNTCTL_OPTS -l ctlposter localhost post "hello from tntctl" 2>/dev/null || true)
if [ "$TNTCTL_POST" = "posted" ]; then
echo "✓ tntctl post publishes a message"
PASS=$((PASS + 1))
else
echo "✗ tntctl post failed: $TNTCTL_POST"
FAIL=$((FAIL + 1))
fi
TNTCTL_TAIL=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "tail" "-n" "1" 2>/dev/null || true)
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'ctlposter' &&
printf '%s\n' "$TNTCTL_TAIL" | grep -q 'hello from tntctl'
if [ $? -eq 0 ]; then
echo "✓ tntctl tail returns recent messages"
PASS=$((PASS + 1))
else
echo "✗ tntctl tail output unexpected"
printf '%s\n' "$TNTCTL_TAIL"
FAIL=$((FAIL + 1))
fi
TNTCTL_DUMP=$("../tntctl" -p "$PORT" $TNTCTL_OPTS localhost "dump" "-n" "1" 2>/dev/null || true)
printf '%s\n' "$TNTCTL_DUMP" | grep -q '|ctlposter|hello from tntctl$'
if [ $? -eq 0 ]; then
echo "✓ tntctl dump returns persisted message log records"
PASS=$((PASS + 1))
else
echo "✗ tntctl dump output unexpected"
printf '%s\n' "$TNTCTL_DUMP"
FAIL=$((FAIL + 1))
fi
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
WATCHER_READY="${STATE_DIR}/watcher.ready"
cat >"$EXPECT_SCRIPT" <<EOF
@ -160,7 +304,7 @@ spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT w
expect "请输入用户名"
send "watcher\r"
exec touch "$WATCHER_READY"
sleep 8
sleep 12
send "\003"
expect eof
EOF
@ -213,6 +357,45 @@ else
FAIL=$((FAIL + 1))
fi
MENTION_OUTPUT=$(ssh $SSH_OPTS execposter@localhost post "@watcher hello from exec mention" 2>/dev/null || true)
if [ "$MENTION_OUTPUT" = "posted" ]; then
echo "✓ post returns while notifying an interactive mention target"
PASS=$((PASS + 1))
else
echo "✗ mention post failed: $MENTION_OUTPUT"
FAIL=$((FAIL + 1))
fi
MSG_SCRIPT="${STATE_DIR}/private-message.expect"
cat >"$MSG_SCRIPT" <<EOF
set timeout 10
spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT sender@localhost
expect "请输入用户名"
send "sender\r"
expect "Esc NORMAL"
send "\033"
expect "NORMAL"
send ":"
expect ":"
send "msg watcher hello from private message\r"
expect "私信已发送给 watcher"
expect "q:关闭"
send "q"
sleep 0.2
send "\003"
expect eof
EOF
if expect "$MSG_SCRIPT" >"${STATE_DIR}/private-message.log" 2>&1; then
echo "✓ :msg returns while queuing recipient notification"
PASS=$((PASS + 1))
else
echo "✗ :msg notification path failed"
sed -n '1,120p' "${STATE_DIR}/private-message.log"
sed -n '1,120p' "${STATE_DIR}/server.log"
FAIL=$((FAIL + 1))
fi
wait "${INTERACTIVE_PID}" 2>/dev/null || true
INTERACTIVE_PID=""

View file

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

140
tests/test_logrotate.sh Executable file
View file

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

134
tests/test_message_log_tool.sh Executable file
View file

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

View file

@ -0,0 +1,154 @@
#!/bin/sh
# Release-artifact gate regression tests.
set -u
PASS=0
FAIL=0
SCRIPT="../scripts/package_release_assets.sh"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-artifact-test.XXXXXX")
cleanup() {
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
if [ "${2:-}" ]; then
printf '%s\n' "$2"
fi
FAIL=$((FAIL + 1))
}
version() {
sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' ../include/common.h
}
write_elf_x86_64() {
printf '\177ELF\002\001\001\000\000\000\000\000\000\000\000\000\002\000\076\000\001\000\000\000' > "$1"
}
write_elf_aarch64() {
printf '\177ELF\002\001\001\000\000\000\000\000\000\000\000\000\002\000\267\000\001\000\000\000' > "$1"
}
write_macho_x86_64() {
printf '\317\372\355\376\007\000\000\001\003\000\000\000\002\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' > "$1"
}
write_macho_arm64() {
printf '\317\372\355\376\014\000\000\001\000\000\000\000\002\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000\000' > "$1"
}
build_artifact_tree() {
artifact_root=$1
include_source=$2
ver=$(version)
mkdir -p \
"$artifact_root/linux-amd64" \
"$artifact_root/linux-arm64" \
"$artifact_root/darwin-amd64" \
"$artifact_root/darwin-arm64"
write_elf_x86_64 "$artifact_root/linux-amd64/tnt-linux-amd64"
write_elf_x86_64 "$artifact_root/linux-amd64/tntctl-linux-amd64"
write_elf_aarch64 "$artifact_root/linux-arm64/tnt-linux-arm64"
write_elf_aarch64 "$artifact_root/linux-arm64/tntctl-linux-arm64"
write_macho_x86_64 "$artifact_root/darwin-amd64/tnt-darwin-amd64"
write_macho_x86_64 "$artifact_root/darwin-amd64/tntctl-darwin-amd64"
write_macho_arm64 "$artifact_root/darwin-arm64/tnt-darwin-arm64"
write_macho_arm64 "$artifact_root/darwin-arm64/tntctl-darwin-arm64"
if [ "$include_source" = "yes" ]; then
mkdir -p "$artifact_root/source"
../scripts/package_source_archive.sh HEAD "$artifact_root/source" >/dev/null
fi
}
expect_file() {
path=$1
name=$2
[ -f "$path" ] && pass "$name" || fail "$name missing"
}
echo "=== TNT Release Artifact Gate Tests ==="
if [ ! -x "$SCRIPT" ]; then
echo "Error: script $SCRIPT not found or not executable."
exit 1
fi
VER=$(version)
ARTIFACT_ROOT="$STATE_DIR/artifacts"
OUT_DIR="$STATE_DIR/out"
build_artifact_tree "$ARTIFACT_ROOT" yes
OUTPUT=$("$SCRIPT" "$ARTIFACT_ROOT" "$OUT_DIR" 2>&1)
STATUS=$?
if [ "$STATUS" -eq 0 ] &&
printf '%s\n' "$OUTPUT" | grep -q 'release artifact bundle ready'; then
pass "complete artifact set is accepted"
else
fail "complete artifact set failed" "$OUTPUT"
fi
for asset in \
tnt-linux-amd64 \
tntctl-linux-amd64 \
tnt-linux-arm64 \
tntctl-linux-arm64 \
tnt-darwin-amd64 \
tntctl-darwin-amd64 \
tnt-darwin-arm64 \
tntctl-darwin-arm64 \
"tnt-chat-v$VER-source.tar.gz" \
checksums.txt
do
expect_file "$OUT_DIR/$asset" "bundles $asset"
done
if grep -q " tnt-linux-amd64$" "$OUT_DIR/checksums.txt" &&
grep -q " tnt-chat-v$VER-source.tar.gz$" "$OUT_DIR/checksums.txt"; then
pass "checksums include binaries and source archive"
else
fail "checksums are incomplete" "$(cat "$OUT_DIR/checksums.txt" 2>/dev/null)"
fi
DUP_ROOT="$STATE_DIR/duplicate"
DUP_OUT="$STATE_DIR/duplicate-out"
build_artifact_tree "$DUP_ROOT" yes
mkdir -p "$DUP_ROOT/duplicate"
cp "$DUP_ROOT/linux-amd64/tnt-linux-amd64" "$DUP_ROOT/duplicate/tnt-linux-amd64"
DUP_OUTPUT=$("$SCRIPT" "$DUP_ROOT" "$DUP_OUT" 2>&1)
DUP_STATUS=$?
if [ "$DUP_STATUS" -ne 0 ] &&
printf '%s\n' "$DUP_OUTPUT" | grep -q 'expected exactly one artifact named tnt-linux-amd64'; then
pass "duplicate artifact is rejected"
else
fail "duplicate artifact handling" "$DUP_OUTPUT"
fi
MISSING_ROOT="$STATE_DIR/missing"
MISSING_OUT="$STATE_DIR/missing-out"
build_artifact_tree "$MISSING_ROOT" no
MISSING_OUTPUT=$("$SCRIPT" "$MISSING_ROOT" "$MISSING_OUT" 2>&1)
MISSING_STATUS=$?
if [ "$MISSING_STATUS" -ne 0 ] &&
printf '%s\n' "$MISSING_OUTPUT" | grep -q "expected exactly one artifact named tnt-chat-v$VER-source.tar.gz"; then
pass "missing source archive is rejected"
else
fail "missing source archive handling" "$MISSING_OUTPUT"
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

223
tests/test_slow_client.sh Executable file
View file

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

227
tests/test_soak.sh Executable file
View file

@ -0,0 +1,227 @@
#!/bin/sh
# Lightweight soak test for TNT.
# Usage: ./test_soak.sh [duration_seconds] [reconnect_count]
PORT=${PORT:-2222}
DURATION=${1:-8}
RECONNECTS=${2:-5}
BIN="../tnt"
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-soak-test.XXXXXX")
SERVER_PID=""
IDLE_PID=""
cleanup() {
if [ -n "$IDLE_PID" ]; then
kill "$IDLE_PID" 2>/dev/null || true
wait "$IDLE_PID" 2>/dev/null || true
fi
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
case "$DURATION" in
''|*[!0-9]*)
echo "Error: duration_seconds must be a positive integer"
exit 2
;;
esac
case "$RECONNECTS" in
''|*[!0-9]*)
echo "Error: reconnect_count must be a positive integer"
exit 2
;;
esac
if [ "$DURATION" -lt 1 ] || [ "$RECONNECTS" -lt 1 ]; then
echo "Error: duration_seconds and reconnect_count must be positive"
exit 2
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping soak test"
exit 0
fi
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
SSH_TTY_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
wait_for_health() {
out=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 1
fi
out=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
[ "$out" = "ok" ] && return 0
sleep 1
done
return 1
}
echo "=== TNT Soak Test ==="
echo "duration=${DURATION}s reconnects=$RECONNECTS port=$PORT"
TNT_LANG=zh "$BIN" \
--bind 127.0.0.1 \
--public-host soak.local \
--max-connections 32 \
--max-conn-per-ip 32 \
--max-conn-rate-per-ip 64 \
--rate-limit 0 \
--idle-timeout 0 \
--ssh-log-level 1 \
-p "$PORT" \
-d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
if wait_for_health; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "✗ server failed to start"
sed -n '1,160p' "$STATE_DIR/server.log"
exit 1
fi
if grep -q 'ssh -p '"$PORT"' soak.local' "$STATE_DIR/server.log"; then
echo "✓ explicit public host appears in startup hint"
PASS=$((PASS + 1))
else
echo "✗ explicit public host missing from startup hint"
sed -n '1,80p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
IDLE_READY="$STATE_DIR/idle.ready"
IDLE_HOLD=$((DURATION + 20))
cat >"$STATE_DIR/idle.expect" <<EOF
set timeout [expr {$IDLE_HOLD + 20}]
spawn ssh $SSH_TTY_OPTS idle@127.0.0.1
sleep 1
send -- "soakidle\r"
expect ""
exec touch "$IDLE_READY"
sleep $IDLE_HOLD
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
expect "$STATE_DIR/idle.expect" >"$STATE_DIR/idle.log" 2>&1 &
IDLE_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -f "$IDLE_READY" ] && break
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
break
fi
sleep 1
done
if [ -f "$IDLE_READY" ]; then
echo "✓ idle interactive session reached chat"
PASS=$((PASS + 1))
else
echo "✗ idle interactive session did not reach chat"
sed -n '1,160p' "$STATE_DIR/idle.log"
FAIL=$((FAIL + 1))
fi
control_failed=0
for i in $(seq 1 "$DURATION"); do
HEALTH=$(ssh $SSH_OPTS localhost health 2>/dev/null || true)
STATS=$(ssh $SSH_OPTS localhost stats --json 2>/dev/null || true)
USERS=$(ssh $SSH_OPTS localhost users --json 2>/dev/null || true)
if [ "$HEALTH" != "ok" ] ||
! printf '%s\n' "$STATS" | grep -q '"status":"ok"' ||
! printf '%s\n' "$USERS" | grep -q 'soakidle'; then
echo "✗ control interface failed during idle soak at ${i}s"
printf 'health=%s\nstats=%s\nusers=%s\n' "$HEALTH" "$STATS" "$USERS"
FAIL=$((FAIL + 1))
control_failed=1
break
fi
sleep 1
done
if [ "$control_failed" -eq 0 ]; then
echo "✓ control interface stayed available during idle soak"
PASS=$((PASS + 1))
fi
reconnected=0
for i in $(seq 1 "$RECONNECTS"); do
cat >"$STATE_DIR/reconnect-$i.expect" <<EOF
set timeout 10
spawn ssh $SSH_TTY_OPTS reconnect$i@127.0.0.1
sleep 1
send -- "reconnect$i\r"
expect ""
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$STATE_DIR/reconnect-$i.expect" \
>"$STATE_DIR/reconnect-$i.log" 2>&1; then
reconnected=$((reconnected + 1))
else
sed -n '1,120p' "$STATE_DIR/reconnect-$i.log"
break
fi
done
if [ "$reconnected" -eq "$RECONNECTS" ]; then
echo "✓ repeated reconnects completed"
PASS=$((PASS + 1))
else
echo "✗ repeated reconnects stopped at $reconnected/$RECONNECTS"
FAIL=$((FAIL + 1))
fi
LAST_MESSAGE="soak message $RECONNECTS"
POST=$(ssh $SSH_OPTS soakbot@localhost post "$LAST_MESSAGE" 2>/dev/null || true)
TAIL=$(ssh $SSH_OPTS localhost "tail -n 5" 2>/dev/null || true)
if [ "$POST" = "posted" ] &&
printf '%s\n' "$TAIL" | grep -q "$LAST_MESSAGE"; then
echo "✓ post/tail path stayed available after reconnect churn"
PASS=$((PASS + 1))
else
echo "✗ post/tail path failed after reconnect churn"
printf '%s\n' "$POST"
printf '%s\n' "$TAIL"
FAIL=$((FAIL + 1))
fi
wait "$IDLE_PID" 2>/dev/null || FAIL=$((FAIL + 1))
IDLE_PID=""
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server survived soak test"
PASS=$((PASS + 1))
else
echo "✗ server exited during soak test"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

103
tests/test_source_archive.sh Executable file
View file

@ -0,0 +1,103 @@
#!/bin/sh
# Release source-archive regression tests.
set -u
PASS=0
FAIL=0
SCRIPT="../scripts/package_source_archive.sh"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-source-archive-test.XXXXXX")
cleanup() {
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
pass() {
echo "$1"
PASS=$((PASS + 1))
}
fail() {
echo "$1"
if [ "${2:-}" ]; then
printf '%s\n' "$2"
fi
FAIL=$((FAIL + 1))
}
version() {
sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' ../include/common.h
}
listing_has_entry() {
entry=$1
printf '%s\n' "$ARCHIVE_LISTING" |
awk -v target="$entry" '$0 == target { found = 1 } END { exit found ? 0 : 1 }'
}
echo "=== TNT Source Archive Tests ==="
if [ ! -x "$SCRIPT" ]; then
echo "Error: script $SCRIPT not found or not executable."
exit 1
fi
VER=$(version)
OUT_DIR="$STATE_DIR/out"
OUTPUT=$("$SCRIPT" HEAD "$OUT_DIR" 2>&1)
STATUS=$?
ARCHIVE="$OUT_DIR/tnt-chat-v$VER-source.tar.gz"
if [ "$STATUS" -eq 0 ] &&
[ "$OUTPUT" = "$ARCHIVE" ] &&
[ -s "$ARCHIVE" ]; then
pass "HEAD source archive is built"
else
fail "HEAD source archive build" "$OUTPUT"
fi
ARCHIVE_LISTING=$(tar -tzf "$ARCHIVE" 2>&1)
if listing_has_entry "TNT-$VER/LICENSE" &&
listing_has_entry "TNT-$VER/src/tntctl.c" &&
listing_has_entry "TNT-$VER/packaging/README.md" &&
listing_has_entry "TNT-$VER/tnt.1" &&
listing_has_entry "TNT-$VER/tntctl.1"; then
pass "source archive contains required release files"
else
fail "source archive required files" "$(printf '%s\n' "$ARCHIVE_LISTING" | sed -n '1,40p')"
fi
DUP_OUTPUT=$("$SCRIPT" HEAD "$OUT_DIR" 2>&1)
DUP_STATUS=$?
if [ "$DUP_STATUS" -ne 0 ] &&
printf '%s\n' "$DUP_OUTPUT" | grep -q 'output already exists'; then
pass "existing archive is not overwritten"
else
fail "existing archive handling" "$DUP_OUTPUT"
fi
BAD_OUTPUT=$("$SCRIPT" refs/heads/does-not-exist "$STATE_DIR/bad" 2>&1)
BAD_STATUS=$?
if [ "$BAD_STATUS" -ne 0 ] &&
printf '%s\n' "$BAD_OUTPUT" | grep -q 'could not resolve git ref'; then
pass "missing git ref is rejected"
else
fail "missing git ref handling" "$BAD_OUTPUT"
fi
HELP_OUTPUT=$("$SCRIPT" --help 2>&1)
HELP_STATUS=$?
if [ "$HELP_STATUS" -eq 0 ] &&
printf '%s\n' "$HELP_OUTPUT" | grep -q 'Usage: scripts/package_source_archive.sh REF'; then
pass "help output is available"
else
fail "help output handling" "$HELP_OUTPUT"
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

182
tests/test_tntctl_cli.sh Executable file
View file

@ -0,0 +1,182 @@
#!/bin/sh
# Local CLI-shape tests for tntctl. Uses a fake ssh in PATH.
set -u
PASS=0
FAIL=0
BIN="../tntctl"
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tntctl-cli-test.XXXXXX")
FAKE_BIN="${STATE_DIR}/bin"
SSH_LOG="${STATE_DIR}/ssh.argv"
cleanup() {
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
mkdir -p "$FAKE_BIN"
cat >"$FAKE_BIN/ssh" <<'FAKESSH'
#!/bin/sh
printf '%s\n' "$#" > "$TNTCTL_SSH_LOG"
for arg in "$@"; do
printf '%s\n' "$arg" >> "$TNTCTL_SSH_LOG"
done
case "$*" in
*" users --xml") exit 64 ;;
*) printf 'fake-ok\n'; exit 0 ;;
esac
FAKESSH
chmod +x "$FAKE_BIN/ssh"
run_ok() {
label=$1
shift
: > "$SSH_LOG"
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$@" >/dev/null 2>&1
status=$?
if [ "$status" -eq 0 ]; then
echo "$label"
PASS=$((PASS + 1))
else
echo "$label (exit $status)"
FAIL=$((FAIL + 1))
fi
}
run_usage() {
label=$1
shift
rm -f "$SSH_LOG"
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$@" >/dev/null 2>&1
status=$?
if [ "$status" -eq 64 ] && [ ! -f "$SSH_LOG" ]; then
echo "$label"
PASS=$((PASS + 1))
else
echo "$label (exit $status)"
[ -f "$SSH_LOG" ] && echo "fake ssh was invoked"
FAIL=$((FAIL + 1))
fi
}
echo "=== TNTCTL CLI Tests ==="
if [ ! -x "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
VERSION_OUTPUT=$("$BIN" --version 2>/dev/null || true)
case "$VERSION_OUTPUT" in
"tntctl "*) echo "✓ version prints"; PASS=$((PASS + 1)) ;;
*) echo "✗ version output unexpected: $VERSION_OUTPUT"; FAIL=$((FAIL + 1)) ;;
esac
HELP_ZH=$(TNT_LANG=zh "$BIN" --help 2>/dev/null || true)
printf '%s\n' "$HELP_ZH" | grep -q '^用法: tntctl \[options\] host command \[args...\]' &&
printf '%s\n' "$HELP_ZH" | grep -q '^选项:$'
if [ $? -eq 0 ]; then
echo "✓ local help follows TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized help output unexpected"
printf '%s\n' "$HELP_ZH"
FAIL=$((FAIL + 1))
fi
rm -f "$SSH_LOG"
BAD_PORT_ZH=$(PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" TNT_LANG=zh "$BIN" -p nope example.com health 2>&1)
BAD_PORT_STATUS=$?
if [ "$BAD_PORT_STATUS" -eq 64 ] &&
[ ! -f "$SSH_LOG" ] &&
printf '%s\n' "$BAD_PORT_ZH" | grep -q '^tntctl: 端口无效$'; then
echo "✓ local diagnostics follow TNT_LANG"
PASS=$((PASS + 1))
else
echo "✗ localized diagnostic unexpected"
printf '%s\n' "$BAD_PORT_ZH"
[ -f "$SSH_LOG" ] && echo "fake ssh was invoked"
FAIL=$((FAIL + 1))
fi
run_ok "basic argv shape" "$BIN" -p 2222 example.com health
grep -q '^example.com$' "$SSH_LOG" &&
grep -q '^health$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ fake ssh receives host and command as separate argv"
PASS=$((PASS + 1))
else
echo "✗ fake ssh argv unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
run_ok "bounded host-key options are passed safely" "$BIN" --host-key-checking accept-new --known-hosts "$STATE_DIR/known_hosts" example.com health
grep -q '^StrictHostKeyChecking=accept-new$' "$SSH_LOG" &&
grep -q "^UserKnownHostsFile=$STATE_DIR/known_hosts$" "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ bounded host-key options are explicit"
PASS=$((PASS + 1))
else
echo "✗ bounded host-key options missing"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
run_ok "login builds user@host destination" "$BIN" -l operator example.com post "hello"
grep -q '^operator@example.com$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ login destination is explicit"
PASS=$((PASS + 1))
else
echo "✗ login destination unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
run_ok "dump command is accepted" "$BIN" example.com dump -n 1
grep -q '^dump -n 1$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ dump argv is forwarded as one remote command"
PASS=$((PASS + 1))
else
echo "✗ dump argv unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
run_ok "remote help alias is accepted" "$BIN" example.com --help
grep -q '^--help$' "$SSH_LOG"
if [ $? -eq 0 ]; then
echo "✓ --help after host is forwarded as exec help"
PASS=$((PASS + 1))
else
echo "✗ remote --help command unexpected"
cat "$SSH_LOG"
FAIL=$((FAIL + 1))
fi
PATH="$FAKE_BIN:$PATH" TNTCTL_SSH_LOG="$SSH_LOG" "$BIN" example.com users --xml >/dev/null 2>&1
REMOTE_STATUS=$?
if [ "$REMOTE_STATUS" -eq 64 ]; then
echo "✓ remote usage status is preserved"
PASS=$((PASS + 1))
else
echo "✗ remote usage status unexpected: $REMOTE_STATUS"
FAIL=$((FAIL + 1))
fi
run_usage "rejects login starting with dash" "$BIN" -l -V example.com health
run_usage "rejects host starting with dash" "$BIN" -bad health
run_usage "rejects unknown command locally" "$BIN" example.com 'health;id'
run_usage "rejects newline command arg locally" "$BIN" example.com post "hello
world"
run_usage "rejects arbitrary ssh option" "$BIN" --ssh-option ProxyCommand=id example.com health
run_usage "rejects invalid host-key mode" "$BIN" --host-key-checking maybe example.com health
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

284
tests/test_user_lifecycle.sh Executable file
View file

@ -0,0 +1,284 @@
#!/bin/sh
# End-to-end user lifecycle test for TNT's interactive TUI.
PORT=${PORT:-2222}
BIN="../tnt"
PASS=0
FAIL=0
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-lifecycle-test.XXXXXX")
SERVER_PID=""
BOB_PID=""
cleanup() {
if [ -n "$BOB_PID" ]; then
kill "$BOB_PID" 2>/dev/null || true
wait "$BOB_PID" 2>/dev/null || true
fi
if [ -n "$SERVER_PID" ]; then
kill "$SERVER_PID" 2>/dev/null || true
wait "$SERVER_PID" 2>/dev/null || true
fi
rm -rf "$STATE_DIR"
}
trap cleanup EXIT
if ! command -v expect >/dev/null 2>&1; then
echo "expect not installed; skipping user lifecycle test"
exit 0
fi
if [ ! -f "$BIN" ]; then
echo "Error: Binary $BIN not found. Run make first."
exit 1
fi
SSH_OPTS="-e none -tt -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p $PORT"
SSH_EXEC_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
BOB_READY="$STATE_DIR/bob.ready"
PRIVATE_SENT="$STATE_DIR/private.sent"
wait_for_health() {
out=""
for _ in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
if [ -n "$SERVER_PID" ] && ! kill -0 "$SERVER_PID" 2>/dev/null; then
return 1
fi
out=$(ssh $SSH_EXEC_OPTS localhost health 2>/dev/null || true)
[ "$out" = "ok" ] && return 0
sleep 1
done
return 1
}
echo "=== TNT User Lifecycle Test ==="
TNT_LANG=zh "$BIN" \
--bind 127.0.0.1 \
--public-host lifecycle.local \
--max-connections 32 \
--max-conn-per-ip 32 \
--max-conn-rate-per-ip 64 \
--rate-limit 0 \
--idle-timeout 0 \
-p "$PORT" \
-d "$STATE_DIR" >"$STATE_DIR/server.log" 2>&1 &
SERVER_PID=$!
if wait_for_health; then
echo "✓ server started"
PASS=$((PASS + 1))
else
echo "✗ server failed to start"
sed -n '1,160p' "$STATE_DIR/server.log"
exit 1
fi
cat >"$STATE_DIR/bob.expect" <<EOF
set timeout 30
spawn ssh $SSH_OPTS bob@127.0.0.1
sleep 1
send -- "bob\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- ":"
expect ":"
send -- "inbox\r"
expect "私信"
expect "(空)"
expect "r:刷新"
exec touch "$BOB_READY"
exec sh -c "while \[ ! -f '$PRIVATE_SENT' \]; do sleep 1; done"
expect "私信"
expect "alice"
expect "private lifecycle ping"
expect "q:关闭"
send -- "q"
expect "NORMAL"
sleep 0.2
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
expect "$STATE_DIR/bob.expect" >"$STATE_DIR/bob.log" 2>&1 &
BOB_PID=$!
for _ in 1 2 3 4 5 6 7 8 9 10; do
[ -f "$BOB_READY" ] && break
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
break
fi
sleep 1
done
if [ -f "$BOB_READY" ]; then
echo "✓ second user reached chat"
PASS=$((PASS + 1))
else
echo "✗ second user did not reach chat"
sed -n '1,180p' "$STATE_DIR/bob.log"
FAIL=$((FAIL + 1))
fi
USERS_JSON=""
for _ in 1 2 3 4 5; do
USERS_JSON=$(ssh $SSH_EXEC_OPTS localhost users --json 2>/dev/null || true)
printf '%s\n' "$USERS_JSON" | grep -q '"bob"' && break
sleep 1
done
if printf '%s\n' "$USERS_JSON" | grep -q '"bob"'; then
echo "✓ exec users sees active TUI user"
PASS=$((PASS + 1))
else
echo "✗ exec users did not see active TUI user"
printf '%s\n' "$USERS_JSON"
FAIL=$((FAIL + 1))
fi
cat >"$STATE_DIR/alice.expect" <<EOF
set timeout 30
spawn ssh $SSH_OPTS alice@127.0.0.1
sleep 1
send -- "alice\r"
expect "Esc NORMAL"
send -- "\033"
expect "NORMAL"
send -- "?"
expect "TNT 按键参考"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "users\r"
expect "在线用户"
expect "alice"
expect "bob"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "i"
expect "Esc NORMAL"
send -- "hello lifecycle alpha\r"
sleep 1
send -- "\033"
expect "NORMAL"
send -- "k"
sleep 0.2
send -- "G"
expect "NORMAL"
send -- ":"
expect ":"
send -- "last 5\r"
expect "最近"
expect "hello lifecycle alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "search alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "/alpha\r"
expect "搜索"
expect "alpha"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "mute-joins\r"
expect "加入/离开提示"
expect "已静音"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "msg bob private lifecycle ping\r"
expect "私信已发送给 bob"
exec touch "$PRIVATE_SENT"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- ":"
expect ":"
send -- "nick alice2\r"
expect "昵称已修改: alice -> alice2"
expect "q:关闭"
send -- "q"
expect "NORMAL"
send -- "i"
expect "Esc NORMAL"
send -- "/me ships lifecycle\r"
sleep 1
send -- "\003"
sleep 0.2
send -- "\003"
expect eof
EOF
if expect "$STATE_DIR/alice.expect" >"$STATE_DIR/alice.log" 2>&1; then
echo "✓ primary user lifecycle completed"
PASS=$((PASS + 1))
else
echo "✗ primary user lifecycle failed"
sed -n '1,240p' "$STATE_DIR/alice.log"
FAIL=$((FAIL + 1))
touch "$PRIVATE_SENT"
fi
if wait "$BOB_PID" 2>/dev/null; then
echo "✓ recipient inbox auto-refreshed after private message"
PASS=$((PASS + 1))
else
echo "✗ recipient inbox journey failed"
sed -n '1,240p' "$STATE_DIR/bob.log"
FAIL=$((FAIL + 1))
fi
BOB_PID=""
TAIL_OUTPUT=$(ssh $SSH_EXEC_OPTS localhost "tail -n 10" 2>/dev/null || true)
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'hello lifecycle alpha' &&
printf '%s\n' "$TAIL_OUTPUT" | grep -q 'alice2 ships lifecycle'
if [ $? -eq 0 ]; then
echo "✓ exec tail sees public lifecycle messages"
PASS=$((PASS + 1))
else
echo "✗ exec tail missing lifecycle messages"
printf '%s\n' "$TAIL_OUTPUT"
FAIL=$((FAIL + 1))
fi
if grep -q 'alice|hello lifecycle alpha' "$STATE_DIR/messages.log" &&
grep -q '系统|alice 更名为 alice2' "$STATE_DIR/messages.log" &&
grep -q '*|alice2 ships lifecycle' "$STATE_DIR/messages.log" &&
! grep -q 'private lifecycle ping' "$STATE_DIR/messages.log"; then
echo "✓ persisted history matches public/private boundary"
PASS=$((PASS + 1))
else
echo "✗ persisted history boundary unexpected"
cat "$STATE_DIR/messages.log" 2>/dev/null || true
FAIL=$((FAIL + 1))
fi
if kill -0 "$SERVER_PID" 2>/dev/null; then
echo "✓ server survived user lifecycle"
PASS=$((PASS + 1))
else
echo "✗ server exited during user lifecycle"
sed -n '1,160p' "$STATE_DIR/server.log"
FAIL=$((FAIL + 1))
fi
echo ""
echo "PASSED: $PASS"
echo "FAILED: $FAIL"
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
exit "$FAIL"

View file

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

View file

@ -159,8 +159,9 @@ TEST(room_remove_nonexistent_client) {
TEST(room_add_client_full) {
chat_room_t *room = room_create();
client_t clients[MAX_CLIENTS + 1];
memset(clients, 0, sizeof(clients));
client_t *clients = calloc((size_t)room->client_capacity + 1,
sizeof(*clients));
assert(clients != NULL);
for (int i = 0; i < room->client_capacity; i++) {
assert(room_add_client(room, &clients[i]) == 0);
@ -169,6 +170,23 @@ TEST(room_add_client_full) {
assert(room_add_client(room, &clients[room->client_capacity]) == -1);
assert(room_get_client_count(room) == room->client_capacity);
free(clients);
room_destroy(room);
}
TEST(room_capacity_follows_tnt_max_connections) {
setenv("TNT_MAX_CONNECTIONS", "3", 1);
chat_room_t *room = room_create();
unsetenv("TNT_MAX_CONNECTIONS");
client_t clients[4];
memset(clients, 0, sizeof(clients));
assert(room->client_capacity == 3);
assert(room_add_client(room, &clients[0]) == 0);
assert(room_add_client(room, &clients[1]) == 0);
assert(room_add_client(room, &clients[2]) == 0);
assert(room_add_client(room, &clients[3]) == -1);
room_destroy(room);
}
@ -201,6 +219,7 @@ int main(void) {
RUN_TEST(room_client_count);
RUN_TEST(room_remove_nonexistent_client);
RUN_TEST(room_add_client_full);
RUN_TEST(room_capacity_follows_tnt_max_connections);
RUN_TEST(room_message_count_threadsafe);
printf("\nAll %d tests passed!\n", tests_passed);

View file

@ -22,6 +22,10 @@ TEST(help_matches_language) {
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN);
assert(strstr(output, "anonymous SSH chat server") != NULL);
assert(strstr(output, "Usage: tnt [options]") != NULL);
assert(strstr(output, "--bind ADDR") != NULL);
assert(strstr(output, "--max-connections N") != NULL);
assert(strstr(output, "--log-check FILE") != NULL);
assert(strstr(output, "--log-recover FILE") != NULL);
assert(strstr(output, "TNT_LANG") != NULL);
memset(output, 0, sizeof(output));
@ -35,6 +39,9 @@ TEST(help_matches_language) {
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
assert(strstr(output, "用法: tnt [options]") != NULL);
assert(strstr(output, "[选项]") == NULL);
assert(strstr(output, "--public-host HOST") != NULL);
assert(strstr(output, "--idle-timeout SECONDS") != NULL);
assert(strstr(output, "--log-check FILE") != NULL);
assert(strstr(output, "TNT_LANG") != NULL);
}
@ -43,14 +50,22 @@ TEST(error_formats_match_language) {
"Invalid port: %s\n") == 0);
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
"端口无效: %s\n") == 0);
assert(strcmp(cli_text_invalid_value_format(UI_LANG_EN),
"Invalid %s: %s\n") == 0);
assert(strcmp(cli_text_invalid_value_format(UI_LANG_ZH),
"%s 无效: %s\n") == 0);
assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_EN),
"Option requires argument: %s\n") == 0);
assert(strcmp(cli_text_option_requires_arg_format(UI_LANG_ZH),
"选项需要参数: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
"Unknown option: %s\n") == 0);
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
"未知选项: %s\n") == 0);
assert(strcmp(cli_text_short_usage_format(UI_LANG_EN),
"Usage: %s [-p PORT] [-d DIR] [-h]\n") == 0);
"Usage: %s [options]\n") == 0);
assert(strcmp(cli_text_short_usage_format(UI_LANG_ZH),
"用法: %s [-p PORT] [-d DIR] [-h]\n") == 0);
"用法: %s [options]\n") == 0);
assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99),
"Invalid port: %s\n") == 0);
}

View file

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

View file

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

View file

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

View file

@ -80,10 +80,21 @@ TEST(default_uses_locale_when_no_tnt_lang) {
TEST(text_lookup_matches_language) {
i18n_string_t sample = I18N_STRING("fallback", "替代");
i18n_string_t mapped = I18N_STRING_MAP(
I18N_EN("mapped fallback"),
I18N_ZH("映射替代")
);
i18n_string_t english_only = I18N_STRING_MAP(
I18N_EN("english only")
);
assert(strcmp(i18n_string(sample, UI_LANG_EN), "fallback") == 0);
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 0);
assert(strcmp(i18n_string(sample, (ui_lang_t)99), "fallback") == 0);
assert(strcmp(i18n_string(mapped, UI_LANG_EN), "mapped fallback") == 0);
assert(strcmp(i18n_string(mapped, UI_LANG_ZH), "映射替代") == 0);
assert(strcmp(i18n_string(english_only, UI_LANG_ZH),
"english only") == 0);
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
"display name") != NULL);
@ -111,6 +122,12 @@ TEST(text_lookup_matches_language) {
"q:close") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
"q:关闭") != NULL);
assert(strstr(i18n_text(UI_LANG_EN,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT),
"r:refresh") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH,
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT),
"r:刷新") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
"Press any key") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),
@ -147,6 +164,10 @@ TEST(text_lookup_matches_language) {
"message cannot be empty") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
"消息不能为空") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_COMMAND_TOO_LONG),
"command too long") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_COMMAND_TOO_LONG),
"命令过长") != NULL);
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
"Unknown command") != NULL);
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),

View file

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

View file

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

165
tnt.1
View file

@ -8,8 +8,32 @@ tnt \- anonymous SSH chat server with Vim\-style TUI
.IR port ]
.RB [ \-d | \-\-state\-dir
.IR dir ]
.RB [ \-\-bind
.IR addr ]
.RB [ \-\-public\-host
.IR host ]
.RB [ \-\-max\-connections
.IR n ]
.RB [ \-\-max\-conn\-per\-ip
.IR n ]
.RB [ \-\-max\-conn\-rate\-per\-ip
.IR n ]
.RB [ \-\-rate\-limit
.IR 0|1 ]
.RB [ \-\-idle\-timeout
.IR seconds ]
.RB [ \-\-ssh\-log\-level
.IR level ]
.RB [ \-V | \-\-version ]
.RB [ \-h | \-\-help ]
.br
.B tnt
.B \-\-log\-check
.I file
.br
.B tnt
.B \-\-log\-recover
.I file
.SH DESCRIPTION
.B tnt
is a multi\-user anonymous chat server accessed over SSH.
@ -18,6 +42,13 @@ COMMAND modes.
Users connect with any standard SSH client; no account or registration is
needed.
.PP
In the 1.x series,
.B tnt
is the stable server process name.
Use
.BR tntctl (1)
for local control commands against a running server.
.PP
Messages are persisted to a log file and restored on server restart.
The server supports CJK and emoji input, rate limiting, access tokens, and
a non\-interactive exec interface for scripting.
@ -39,6 +70,73 @@ Overrides the
environment variable.
Defaults to the current working directory.
.TP
.BR \-\-bind " " \fIaddr\fR
Bind the SSH listener to
.IR addr .
Overrides the
.B TNT_BIND_ADDR
environment variable.
The default is 0.0.0.0.
.TP
.BR \-\-public\-host " " \fIhost\fR
Show
.I host
in the startup connection hint.
Overrides the
.B TNT_PUBLIC_HOST
environment variable.
.TP
.BR \-\-max\-connections " " \fIn\fR
Set the global connection limit.
Overrides the
.B TNT_MAX_CONNECTIONS
environment variable.
.TP
.BR \-\-max\-conn\-per\-ip " " \fIn\fR
Set the concurrent session limit per source IP.
Overrides the
.B TNT_MAX_CONN_PER_IP
environment variable.
.TP
.BR \-\-max\-conn\-rate\-per\-ip " " \fIn\fR
Set the connection-rate limit per source IP per 60-second window.
Overrides the
.B TNT_MAX_CONN_RATE_PER_IP
environment variable.
.TP
.BR \-\-rate\-limit " " \fI0|1\fR
Disable or enable rate-based blocking and auth-failure IP blocking.
Explicit capacity limits still apply.
Overrides the
.B TNT_RATE_LIMIT
environment variable.
.TP
.BR \-\-idle\-timeout " " \fIseconds\fR
Disconnect inactive interactive sessions after
.I seconds
seconds. Use 0 to disable.
Overrides the
.B TNT_IDLE_TIMEOUT
environment variable.
.TP
.BR \-\-ssh\-log\-level " " \fIlevel\fR
Set libssh log verbosity from 0 to 4.
Overrides the
.B TNT_SSH_LOG_LEVEL
environment variable.
.TP
.BR \-\-log\-check " " \fIfile\fR
Check a
.I messages.log
v1 file and print record counts.
Exits non-zero when invalid records are found or the file cannot be read.
.TP
.BR \-\-log\-recover " " \fIfile\fR
Write valid
.I messages.log
v1 records to standard output and print a recovery summary to standard error.
The source file is not modified.
.TP
.BR \-V ", " \-\-version
Print version and exit.
.TP
@ -69,6 +167,8 @@ Press
to return to INSERT,
.B :
to enter COMMAND mode,
.B /
to search message history,
.B ?
to open the full key reference.
.TP
@ -84,6 +184,8 @@ ESC Switch to NORMAL
Ctrl+W Delete last word
Ctrl+U Clear input line
Ctrl+C Switch to NORMAL
Up/Down Browse sent message history
Tab Complete @mention
Paste Keep multi-line paste in the input buffer
/me \fIaction\fR Send action message (e.g. /me waves)
@\fIusername\fR Mention user (bell notification + highlight)
@ -100,6 +202,7 @@ Ctrl+F/Ctrl+B Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
/ Search message history
i Switch to INSERT
: Enter COMMAND mode
? Open full key reference
@ -119,8 +222,9 @@ l l.
:w \fIuser text\fR Short alias for :msg
:inbox Show private messages
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
:search \fIkeyword\fR Case\-insensitive search across full message history
:search \fIkeyword\fR Case\-insensitive search; shows the last 15 matches
:mute\-joins Toggle join/leave system notifications on/off
:lang Show current UI language
:lang \fIen|zh\fR Switch UI language for this session
:help Show concise manual
:clear Clear command output
@ -128,6 +232,25 @@ l l.
Up/Down Browse command history
ESC Cancel and return to NORMAL
.TE
.PP
Command output pages use the same paging keys as the help screen.
.TS
l l.
q, ESC Close output
j/k, arrows Scroll down/up
Ctrl+D/Ctrl+U Scroll half page down/up
Ctrl+F/Ctrl+B Scroll full page down/up
Space/b Scroll full page down/up
PageDown/PageUp Scroll full page down/up
End/Home Jump to bottom/top
g/G Jump to top/bottom
r Refresh live output (:inbox)
.TE
.PP
The
.B :inbox
page refreshes automatically when a new private message arrives while it is
open.
.SH EXEC INTERFACE
Commands can be run non\-interactively for scripting:
.PP
@ -136,6 +259,7 @@ ssh host \-p 2222 help
ssh host \-p 2222 users \-\-json
ssh host \-p 2222 stats \-\-json
ssh host \-p 2222 tail 20
ssh host \-p 2222 dump \-n 100
ssh host \-p 2222 post "Hello from a script"
ssh host \-p 2222 post "/me deploys v2.0"
ssh host \-p 2222 health
@ -144,6 +268,30 @@ ssh host \-p 2222 health
Exit codes follow
.BR sysexits (3)
conventions.
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
Runtime error, such as I/O failure, allocation failure, or persistence failure.
.TP
.B 64
Usage error, such as an unknown command, invalid option, or invalid argument
shape.
.TP
.B 69
Reserved for the local
.BR tntctl (1)
wrapper when SSH transport is unavailable.
.TP
.B 78
Reserved for future local
.BR tntctl (1)
configuration errors.
.PP
The SSH exec JSON field contract is documented in
.IR docs/INTERFACE.md .
.SH ENVIRONMENT
.TP
.B PORT
@ -152,6 +300,12 @@ Default listening port (default: 2222).
.B TNT_STATE_DIR
Directory for host key and message log (default: current directory).
.TP
.B TNT_BIND_ADDR
Address to bind (default: 0.0.0.0).
.TP
.B TNT_PUBLIC_HOST
Host name shown in startup connection hints (default: localhost).
.TP
.B TNT_ACCESS_TOKEN
If set, clients must supply this string as their SSH password.
Compared in constant time.
@ -180,12 +334,19 @@ Explicit capacity limits still apply (default: 1).
.B TNT_IDLE_TIMEOUT
Disconnect clients after this many seconds of inactivity.
Set to 0 to disable (default: 1800, i.e. 30 minutes).
.TP
.B TNT_SSH_LOG_LEVEL
libssh log verbosity from 0 to 4 (default: 1).
.SH FILES
.TP
.I messages.log
Chat history in RFC\ 3339 pipe\-delimited format
Chat history in the TNT message log v1 format:
RFC\ 3339 UTC pipe\-delimited records
.RI ( timestamp | username | content ).
Stored in the state directory.
See
.I docs/MESSAGE_LOG.md
in the source distribution for parser and recovery rules.
.TP
.I host_key
RSA 4096\-bit host key, auto\-generated on first run.

126
tntctl.1 Normal file
View file

@ -0,0 +1,126 @@
.\" tntctl(1) - TNT control client
.TH TNTCTL 1 "2026-05-24" "TNT 1.0.1" "User Commands"
.SH NAME
tntctl \- thin control client for a TNT server
.SH SYNOPSIS
.B tntctl
.RB [ \-p | \-\-port
.IR port ]
.RB [ \-l | \-\-login
.IR user ]
.RB [ \-\-host\-key\-checking
.IR mode ]
.RB [ \-\-known\-hosts
.IR file ]
.I host
.I command
.RI [ args ...]
.SH DESCRIPTION
.B tntctl
runs TNT's documented SSH exec commands through the local
.BR ssh (1)
client.
It is intentionally a thin wrapper: it does not introduce a second control
protocol and does not bypass SSH host-key checking or authentication.
.PP
The command names, exit statuses, and JSON fields are shared with the SSH exec
interface documented in
.IR docs/INTERFACE.md .
.SH OPTIONS
.TP
.BR \-p ", " \-\-port " " \fIport\fR
Connect to
.I port
instead of the default 2222.
.TP
.BR \-l ", " \-\-login " " \fIuser\fR
Use
.I user
as the SSH login name.
For
.B post
commands, TNT uses this login name as the exec message identity.
.TP
.BR \-\-host\-key\-checking " " \fIyes|accept-new|no\fR
Set OpenSSH
.B StrictHostKeyChecking
to one of the listed modes.
.TP
.BR \-\-known\-hosts " " \fIfile\fR
Set the OpenSSH
.B UserKnownHostsFile
path.
.TP
.BR \-V ", " \-\-version
Print version and exit.
.TP
.BR \-h ", " \-\-help
Print a short usage summary and exit.
.SH COMMANDS
.TP
.B health
Print service health.
.TP
.B stats [--json]
Print room statistics.
.TP
.B users [--json]
List online users.
.TP
.B tail [N]
Print recent messages.
.TP
.B tail -n N
Print recent messages.
.TP
.B dump [N]
Export persisted messages.
.TP
.B dump -n N
Export persisted messages.
.TP
.B post MESSAGE
Post a message non-interactively.
.TP
.B help
Print the server exec help.
.SH EXAMPLES
.nf
tntctl chat.example.com health
tntctl -p 2222 chat.example.com stats --json
tntctl -p 2222 chat.example.com dump -n 100
tntctl -l operator chat.example.com post "service notice"
tntctl --host-key-checking accept-new chat.example.com users
.fi
.SH EXIT STATUS
.TP
.B 0
Success.
.TP
.B 1
Runtime error in the local wrapper.
.TP
.B 64
Usage error, either from
.B tntctl
or the remote TNT exec command.
.TP
.B 69
The local
.BR ssh (1)
client could not be executed or exited with OpenSSH's transport-failure status.
.TP
.B 78
Reserved for future local configuration errors.
.SH SECURITY
.B tntctl
passes arguments directly to
.BR ssh (1)
without invoking a local shell.
It does not accept arbitrary SSH options or a password option.
Only the bounded host-key options above are exposed. Use normal SSH
configuration for jump hosts, identity files, and authentication. If the server
requires an access token, enter it through the normal SSH password prompt.
.SH SEE ALSO
.BR tnt (1),
.BR ssh (1)