mirror of
https://oauth2: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@github.com/m1ngsama/TNT.git
synced 2026-06-26 05:44:38 +08:00
Merge pull request #48 from m1ngsama/release/public-readiness-foundation
Public readiness foundation
This commit is contained in:
commit
c7ee5cf0df
94 changed files with 6260 additions and 600 deletions
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal file
64
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
Normal 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
5
.github/ISSUE_TEMPLATE/config.yml
vendored
Normal 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.
|
||||
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal file
37
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
Normal 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.
|
||||
3
.github/workflows/ci.yml
vendored
3
.github/workflows/ci.yml
vendored
|
|
@ -6,6 +6,9 @@ on:
|
|||
pull_request:
|
||||
branches: [ main ]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build-and-test:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
|
|
|||
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
|||
tags:
|
||||
- 'v*'
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build ${{ matrix.target }}
|
||||
|
|
@ -15,19 +18,26 @@ jobs:
|
|||
- 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
|
||||
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Install dependencies (Ubuntu)
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
|
|
@ -48,29 +58,38 @@ 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 }}
|
||||
|
||||
release:
|
||||
needs: build
|
||||
|
|
@ -81,6 +100,9 @@ jobs:
|
|||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Verify release tag matches source version
|
||||
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||
|
||||
- name: Download all artifacts
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
|
|
@ -90,7 +112,8 @@ jobs:
|
|||
run: |
|
||||
cd artifacts
|
||||
: > checksums.txt
|
||||
for artifact in */tnt-*; do
|
||||
for artifact in */tnt-* */tntctl-*; do
|
||||
[ -f "$artifact" ] || continue
|
||||
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||
done
|
||||
cat checksums.txt
|
||||
|
|
@ -100,43 +123,76 @@ jobs:
|
|||
with:
|
||||
files: |
|
||||
artifacts/*/tnt-*
|
||||
artifacts/*/tntctl-*
|
||||
artifacts/checksums.txt
|
||||
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-*; do
|
||||
grep " $f$" checksums.txt | shasum -a 256 -c -
|
||||
done
|
||||
```
|
||||
|
||||
## What's Changed
|
||||
|
|
|
|||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
64
Makefile
64
Makefile
|
|
@ -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,20 @@ 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
|
||||
|
||||
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 +159,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))
|
||||
|
|
|
|||
94
README.md
94
README.md
|
|
@ -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_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
|
||||
```
|
||||
|
||||
Before publishing package recipes, download the final GitHub source archive,
|
||||
replace placeholder checksums, and run:
|
||||
|
||||
```sh
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
## Files
|
||||
|
|
@ -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
61
SECURITY.md
Normal 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
59
demos/tnt-lifecycle.tape
Normal 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
|
||||
|
|
@ -2,7 +2,149 @@
|
|||
|
||||
## 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 final GitHub source archive after a tag exists.
|
||||
- Added a `config_defaults` module and unit coverage for runtime default
|
||||
values, env keys, and accepted numeric ranges.
|
||||
- Added a dedicated `tntctl_text` module with unit coverage for local
|
||||
`tntctl` help and validation diagnostics.
|
||||
- Documented the stable SSH exec interface contract, including exit statuses
|
||||
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 final GitHub source archive exists.
|
||||
- `tntctl --help` now gets its exec command list from `exec_catalog`, reducing
|
||||
duplicate command metadata between the local wrapper and SSH exec mode.
|
||||
- Updated `tnt(1)` to document the current TUI search and pager keys, and
|
||||
added script coverage to keep active help surfaces free of removed support
|
||||
commands.
|
||||
- `make install-systemd` now rewrites the installed unit's `ExecStart` to match
|
||||
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 +159,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
|
||||
|
|
|
|||
76
docs/CICD.md
76
docs/CICD.md
|
|
@ -19,37 +19,93 @@ into production or restart services on push.
|
|||
|
||||
CREATING RELEASES
|
||||
-----------------
|
||||
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 draft release review manual. Do not auto-publish releases.
|
||||
- Keep production deployment manual. Do not SSH into production from CI.
|
||||
|
||||
1. Update version metadata:
|
||||
- include/common.h
|
||||
- tnt.1
|
||||
- docs/CHANGELOG.md
|
||||
- packaging/arch/PKGBUILD
|
||||
- packaging/homebrew/tnt-chat.rb
|
||||
- packaging/debian/debian/changelog
|
||||
- maintainer metadata, when preparing public package recipes
|
||||
|
||||
2. Run the local preflight:
|
||||
make release-check
|
||||
|
||||
3. Replace package checksum placeholders and run:
|
||||
For a longer local runtime gate before publishing or production rollout:
|
||||
RUN_SOAK=1 RUN_SLOW_CLIENT=1 make release-check
|
||||
|
||||
3. Commit the release changes and create a local tag. Do not push the tag
|
||||
until strict checks pass:
|
||||
git tag vX.Y.Z
|
||||
|
||||
4. Run strict release checks:
|
||||
make release-check-strict
|
||||
|
||||
4. Create and push tag:
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also
|
||||
builds from the tagged source archive, so it catches files that were left
|
||||
untracked and would be missing from GitHub's source archive.
|
||||
|
||||
5. GitHub Actions automatically:
|
||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
||||
5. Push the tag:
|
||||
git push origin vX.Y.Z
|
||||
|
||||
6. GitHub Actions automatically:
|
||||
- Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
|
||||
- Creates a draft release
|
||||
- Uploads binaries
|
||||
- Generates one `checksums.txt` file
|
||||
- Verifies that artifact architecture matches the asset name
|
||||
|
||||
6. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
7. Review the draft release, smoke-test downloaded assets, then publish it
|
||||
manually from GitHub.
|
||||
|
||||
7. Release appears at:
|
||||
8. Release appears at:
|
||||
https://github.com/m1ngsama/TNT/releases
|
||||
|
||||
|
||||
RELEASE REVIEW CHECKLIST
|
||||
------------------------
|
||||
Before publishing a draft release:
|
||||
- Confirm `git tag` points at the intended commit.
|
||||
- Download every release asset from GitHub, not from the local workspace.
|
||||
- Verify downloaded assets against `checksums.txt` (`sha256sum -c
|
||||
checksums.txt --ignore-missing` on Linux, or `shasum -a 256 -c` for each
|
||||
downloaded asset on macOS).
|
||||
- Run downloaded `tnt --version` and `tntctl --version`.
|
||||
- Start a temporary server and check:
|
||||
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 (`ldd` on Linux, `otool -L` on macOS) and make
|
||||
sure `libssh` is documented for the target install path.
|
||||
- Confirm `make release-check-strict` passed before pushing the tag.
|
||||
- For package-manager recipes, download the final GitHub source archive,
|
||||
replace Arch/Homebrew source checksums, then run:
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
|
||||
|
||||
|
||||
ROLLBACK
|
||||
--------
|
||||
Production rollback stays manual:
|
||||
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.
|
||||
|
||||
Do not overwrite `TNT_STATE_DIR` during rollback. If a future release changes
|
||||
the message log format, its release notes must include the downgrade behavior.
|
||||
|
||||
|
||||
DEPLOYING TO SERVERS
|
||||
--------------------
|
||||
Deployments are operator-driven:
|
||||
|
|
@ -105,8 +161,8 @@ make && make asan && make release-check
|
|||
./tnt
|
||||
|
||||
# Create release
|
||||
git tag v1.0.1
|
||||
git push origin v1.0.1
|
||||
git tag vX.Y.Z
|
||||
git push origin vX.Y.Z
|
||||
# Wait 5 minutes for builds
|
||||
|
||||
# Deploy to production manually after validation
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
177
docs/INTERFACE.md
Normal 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
106
docs/MESSAGE_LOG.md
Normal 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.
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 final
|
||||
GitHub 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
58
docs/USER_LIFECYCLE.md
Normal 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.
|
||||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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
47
include/config_defaults.h
Normal 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 */
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
21
include/message_log.h
Normal 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 */
|
||||
9
include/message_log_tool.h
Normal file
9
include/message_log_tool.h
Normal 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 */
|
||||
|
|
@ -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
29
include/tntctl_text.h
Normal 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 */
|
||||
92
install.sh
92
install.sh
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -10,11 +10,14 @@ 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.
|
||||
|
||||
## 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`.
|
||||
2. Create a GitHub release tag such as `vX.Y.Z`.
|
||||
3. Build and upload release tarballs or rely on GitHub source archives.
|
||||
4. Replace placeholder checksums in package drafts.
|
||||
5. Verify package contents in an isolated directory:
|
||||
|
|
@ -23,13 +26,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 final GitHub source archive,
|
||||
replace checksum placeholders, and run:
|
||||
|
||||
```sh
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
8. Submit packages manually:
|
||||
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
|
||||
- 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.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ pkgbase = tnt-chat
|
|||
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.sysusers
|
||||
sha256sums = SKIP
|
||||
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
|
||||
|
||||
pkgname = tnt-chat
|
||||
|
|
|
|||
|
|
@ -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}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,11 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`:
|
|||
makepkg --printsrcinfo > .SRCINFO
|
||||
```
|
||||
|
||||
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
|
||||
archive checksum, then run the project-level strict check:
|
||||
Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub
|
||||
source archive checksum, regenerate `.SRCINFO`, then run the package publish
|
||||
check:
|
||||
|
||||
```sh
|
||||
make release-check-strict
|
||||
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.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
|
||||
```
|
||||
|
||||
|
|
|
|||
1
packaging/arch/tnt-chat.sysusers
Normal file
1
packaging/arch/tnt-chat.sysusers
Normal file
|
|
@ -0,0 +1 @@
|
|||
u tnt - "TNT chat server" /var/lib/tnt -
|
||||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
10
packaging/debian/debian/postinst
Executable file
10
packaging/debian/debian/postinst
Executable 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
|
||||
|
|
@ -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.tar.gz \
|
||||
https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz
|
||||
shasum -a 256 dist/tnt-chat-vX.Y.Z.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.tar.gz make package-publish-check
|
||||
```
|
||||
|
||||
5. Copy the formula into the tap repository and open a normal review PR.
|
||||
|
|
|
|||
|
|
@ -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
31
scripts/check_release_ref.sh
Executable 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"
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
79
scripts/package_debian_source.sh
Executable file
79
scripts/package_debian_source.sh
Executable 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
|
||||
68
scripts/package_publish_check.sh
Executable file
68
scripts/package_publish_check.sh
Executable file
|
|
@ -0,0 +1,68 @@
|
|||
#!/bin/sh
|
||||
# Verify package-manager recipes against a final release source archive.
|
||||
|
||||
set -eu
|
||||
|
||||
ROOT=$(CDPATH= cd -- "$(dirname -- "$0")/.." && pwd)
|
||||
cd "$ROOT"
|
||||
|
||||
fail() {
|
||||
echo "package-publish-check: $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
sha256_of() {
|
||||
if command -v sha256sum >/dev/null 2>&1; then
|
||||
sha256sum "$1" | awk '{print $1}'
|
||||
elif command -v shasum >/dev/null 2>&1; then
|
||||
shasum -a 256 "$1" | awk '{print $1}'
|
||||
else
|
||||
fail "sha256sum or shasum is required"
|
||||
fi
|
||||
}
|
||||
|
||||
version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||
[ -n "$version" ] || fail "could not read TNT_VERSION from include/common.h"
|
||||
|
||||
source_tarball=${SOURCE_TARBALL:-${RELEASE_SOURCE_TARBALL:-}}
|
||||
[ -n "$source_tarball" ] ||
|
||||
fail "set SOURCE_TARBALL to the final GitHub source archive"
|
||||
[ -f "$source_tarball" ] ||
|
||||
fail "SOURCE_TARBALL does not exist: $source_tarball"
|
||||
|
||||
! grep -R "REPLACE_WITH_EMAIL" packaging/arch packaging/debian >/dev/null ||
|
||||
fail "replace maintainer email placeholders before package publishing"
|
||||
|
||||
arch_sha=$(sed -n "s/^[[:space:]]*sha256sums=('\([^']*\)'.*/\1/p" \
|
||||
packaging/arch/PKGBUILD | head -n 1)
|
||||
srcinfo_sha=$(sed -n 's/^[[:space:]]*sha256sums = \([^[:space:]]*\).*/\1/p' \
|
||||
packaging/arch/.SRCINFO | head -n 1)
|
||||
brew_sha=$(sed -n 's/^[[:space:]]*sha256 "\([^"]*\)".*/\1/p' \
|
||||
packaging/homebrew/tnt-chat.rb | head -n 1)
|
||||
|
||||
[ -n "$arch_sha" ] || fail "could not read PKGBUILD source checksum"
|
||||
[ -n "$srcinfo_sha" ] || fail "could not read .SRCINFO source checksum"
|
||||
[ -n "$brew_sha" ] || fail "could not read Homebrew source checksum"
|
||||
[ "$arch_sha" != "SKIP" ] || fail "replace PKGBUILD sha256sums before publishing"
|
||||
[ "$srcinfo_sha" != "SKIP" ] || fail "replace .SRCINFO sha256sums before publishing"
|
||||
[ "$brew_sha" != "REPLACE_WITH_RELEASE_TARBALL_SHA256" ] ||
|
||||
fail "replace Homebrew sha256 before publishing"
|
||||
|
||||
expected_sha=$(sha256_of "$source_tarball")
|
||||
[ "$arch_sha" = "$expected_sha" ] ||
|
||||
fail "PKGBUILD source checksum does not match SOURCE_TARBALL"
|
||||
[ "$srcinfo_sha" = "$expected_sha" ] ||
|
||||
fail ".SRCINFO source checksum does not match SOURCE_TARBALL"
|
||||
[ "$brew_sha" = "$expected_sha" ] ||
|
||||
fail "Homebrew source checksum does not match SOURCE_TARBALL"
|
||||
|
||||
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||
fail "PKGBUILD pkgver does not match $version"
|
||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||
fail ".SRCINFO pkgver does not match $version"
|
||||
grep -q "v${version}.tar.gz" packaging/homebrew/tnt-chat.rb ||
|
||||
fail "Homebrew URL does not match v$version"
|
||||
grep -q "^tnt-chat (${version}-1)" packaging/debian/debian/changelog ||
|
||||
fail "Debian changelog version does not match $version"
|
||||
|
||||
echo "package recipes match SOURCE_TARBALL for $version: $expected_sha"
|
||||
|
|
@ -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 final GitHub 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 ||
|
||||
|
|
@ -88,16 +96,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 +151,88 @@ 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
|
||||
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 +249,55 @@ 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-$version-source.tar.gz"
|
||||
archive_extract="$tmpdir/source"
|
||||
archive_install="$tmpdir/source-install"
|
||||
archive_root="$archive_extract/TNT-$version"
|
||||
|
||||
git archive --format=tar.gz --prefix="TNT-$version/" \
|
||||
-o "$archive" "refs/tags/v$version"
|
||||
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"
|
||||
|
|
|
|||
|
|
@ -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,9 +295,14 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
|
|||
|
||||
/* Store exec command */
|
||||
if (command) {
|
||||
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 */
|
||||
ctx->channel_ready = true;
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
@ -10,6 +11,16 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
|||
"Options:\n"
|
||||
" -p, --port PORT Listen on PORT (default: %d)\n"
|
||||
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
||||
" --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
|
||||
" --public-host HOST Show HOST in startup connection hints\n"
|
||||
" --max-connections N Global connection limit (default: %d)\n"
|
||||
" --max-conn-per-ip N Per-IP concurrent session limit\n"
|
||||
" --max-conn-rate-per-ip N Per-IP connection-rate limit\n"
|
||||
" --rate-limit 0|1 Disable/enable rate-based blocking\n"
|
||||
" --idle-timeout SECONDS Idle disconnect timeout\n"
|
||||
" --ssh-log-level LEVEL libssh log level 0..4\n"
|
||||
" --log-check FILE Check messages.log v1 records\n"
|
||||
" --log-recover FILE Write valid records to stdout\n"
|
||||
" -V, --version Show version\n"
|
||||
" -h, --help Show this help\n"
|
||||
"\n"
|
||||
|
|
@ -18,14 +29,24 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
|||
" 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"
|
||||
" --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"
|
||||
|
|
@ -34,16 +55,19 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
|||
" 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);
|
||||
}
|
||||
|
|
|
|||
216
src/client.c
216
src/client.c
|
|
@ -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) {
|
||||
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);
|
||||
}
|
||||
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 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;
|
||||
}
|
||||
total += (size_t)sent;
|
||||
}
|
||||
|
||||
if (client->exec_command[0] != '\0') {
|
||||
ssh_blocking_flush(client->session, 1000);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
80
src/config_defaults.c
Normal 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;
|
||||
}
|
||||
130
src/exec.c
130
src/exec.c
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
288
src/input.c
288
src/input.c
|
|
@ -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 (action == PAGER_ACTION_REFRESH) {
|
||||
if (commands_refresh_active_output(client)) {
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'k') {
|
||||
client->command_output_scroll--;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
||||
client->command_output_scroll += half;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 21) { /* Ctrl+U: half page up */
|
||||
client->command_output_scroll -= half;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 6) { /* Ctrl+F: full page down */
|
||||
client->command_output_scroll += page;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 2) { /* Ctrl+B: full page up */
|
||||
client->command_output_scroll -= page;
|
||||
if (client->command_output_scroll < 0) {
|
||||
client->command_output_scroll = 0;
|
||||
}
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'g') {
|
||||
client->command_output_scroll = 0;
|
||||
tui_render_command_output(client);
|
||||
} else if (key == 'G') {
|
||||
client->command_output_scroll = 999;
|
||||
tui_render_command_output(client);
|
||||
}
|
||||
return true; /* Key consumed */
|
||||
}
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
213
src/main.c
213
src/main.c
|
|
@ -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) */
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
);
|
||||
|
|
|
|||
285
src/message.c
285
src/message.c
|
|
@ -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
129
src/message_log.c
Normal 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
111
src/message_log_tool.c
Normal 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);
|
||||
}
|
||||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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
298
src/tntctl.c
Normal 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
101
src/tntctl_text.c
Normal 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);
|
||||
}
|
||||
11
src/tui.c
11
src/tui.c
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
82
tests/test_cli_options.sh
Executable 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
76
tests/test_docs_help_surface.sh
Executable 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"
|
||||
|
|
@ -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=""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
140
tests/test_logrotate.sh
Executable 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
134
tests/test_message_log_tool.sh
Executable file
|
|
@ -0,0 +1,134 @@
|
|||
#!/bin/sh
|
||||
# Offline messages.log check/recover regression tests.
|
||||
|
||||
set -u
|
||||
|
||||
PASS=0
|
||||
FAIL=0
|
||||
BIN="../tnt"
|
||||
STATE_DIR=$(mktemp -d "${TMPDIR:-/tmp}/tnt-log-tool-test.XXXXXX")
|
||||
|
||||
cleanup() {
|
||||
rm -rf "$STATE_DIR"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
pass() {
|
||||
echo "✓ $1"
|
||||
PASS=$((PASS + 1))
|
||||
}
|
||||
|
||||
fail() {
|
||||
echo "✗ $1"
|
||||
FAIL=$((FAIL + 1))
|
||||
}
|
||||
|
||||
ts_now() {
|
||||
date -u +%Y-%m-%dT%H:%M:%SZ
|
||||
}
|
||||
|
||||
echo "=== TNT Message Log Tool Tests ==="
|
||||
|
||||
if [ ! -x "$BIN" ]; then
|
||||
echo "Error: binary $BIN not found. Run make first."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
TS=$(ts_now)
|
||||
CLEAN_LOG="$STATE_DIR/clean.log"
|
||||
cat > "$CLEAN_LOG" <<EOF
|
||||
$TS|alice|one
|
||||
$TS|bob|two
|
||||
EOF
|
||||
|
||||
CHECK_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" 2>&1)
|
||||
CHECK_STATUS=$?
|
||||
printf '%s\n' "$CHECK_OUTPUT" | grep -q '^valid_records 2$' &&
|
||||
printf '%s\n' "$CHECK_OUTPUT" | grep -q '^invalid_records 0$'
|
||||
if [ "$CHECK_STATUS" -eq 0 ] && [ $? -eq 0 ]; then
|
||||
pass "clean log check exits 0"
|
||||
else
|
||||
fail "clean log check"
|
||||
printf '%s\n' "$CHECK_OUTPUT"
|
||||
echo "exit status: $CHECK_STATUS"
|
||||
fi
|
||||
|
||||
BAD_LOG="$STATE_DIR/bad.log"
|
||||
cat > "$BAD_LOG" <<EOF
|
||||
$TS|alice|one
|
||||
$TS|mallory|extra|pipe
|
||||
$TS|bob|two
|
||||
EOF
|
||||
printf '%s|partial|unterminated' "$TS" >> "$BAD_LOG"
|
||||
|
||||
BAD_CHECK_OUTPUT=$("$BIN" --log-check "$BAD_LOG" 2>&1)
|
||||
BAD_CHECK_STATUS=$?
|
||||
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^records_seen 4$' &&
|
||||
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^valid_records 2$' &&
|
||||
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^invalid_records 2$' &&
|
||||
printf '%s\n' "$BAD_CHECK_OUTPUT" | grep -q '^first_invalid_line 2$'
|
||||
if [ "$BAD_CHECK_STATUS" -eq 1 ] && [ $? -eq 0 ]; then
|
||||
pass "bad log check reports skipped records"
|
||||
else
|
||||
fail "bad log check"
|
||||
printf '%s\n' "$BAD_CHECK_OUTPUT"
|
||||
echo "exit status: $BAD_CHECK_STATUS"
|
||||
fi
|
||||
|
||||
RECOVERED="$STATE_DIR/recovered.log"
|
||||
RECOVER_REPORT="$STATE_DIR/recover.report"
|
||||
"$BIN" --log-recover "$BAD_LOG" > "$RECOVERED" 2> "$RECOVER_REPORT"
|
||||
RECOVER_STATUS=$?
|
||||
if [ "$RECOVER_STATUS" -eq 1 ] &&
|
||||
grep -q '^valid_records 2$' "$RECOVER_REPORT" &&
|
||||
grep -q '^invalid_records 2$' "$RECOVER_REPORT" &&
|
||||
grep -q "$TS|alice|one" "$RECOVERED" &&
|
||||
grep -q "$TS|bob|two" "$RECOVERED" &&
|
||||
! grep -q 'mallory' "$RECOVERED" &&
|
||||
! grep -q 'partial' "$RECOVERED"; then
|
||||
pass "recover writes valid records and reports skipped records"
|
||||
else
|
||||
fail "bad log recovery"
|
||||
cat "$RECOVERED" 2>/dev/null
|
||||
cat "$RECOVER_REPORT" 2>/dev/null
|
||||
echo "exit status: $RECOVER_STATUS"
|
||||
fi
|
||||
|
||||
MISSING_OUTPUT=$("$BIN" --log-check "$STATE_DIR/missing.log" 2>&1)
|
||||
MISSING_STATUS=$?
|
||||
if [ "$MISSING_STATUS" -eq 1 ] &&
|
||||
printf '%s\n' "$MISSING_OUTPUT" | grep -q 'No such file'; then
|
||||
pass "missing log exits 1"
|
||||
else
|
||||
fail "missing log handling"
|
||||
printf '%s\n' "$MISSING_OUTPUT"
|
||||
echo "exit status: $MISSING_STATUS"
|
||||
fi
|
||||
|
||||
USAGE_OUTPUT=$("$BIN" --log-check 2>&1)
|
||||
USAGE_STATUS=$?
|
||||
if [ "$USAGE_STATUS" -eq 64 ] &&
|
||||
printf '%s\n' "$USAGE_OUTPUT" | grep -q 'Option requires argument: --log-check'; then
|
||||
pass "missing log-check argument exits 64"
|
||||
else
|
||||
fail "missing log-check argument"
|
||||
printf '%s\n' "$USAGE_OUTPUT"
|
||||
echo "exit status: $USAGE_STATUS"
|
||||
fi
|
||||
|
||||
CONFLICT_OUTPUT=$("$BIN" --log-check "$CLEAN_LOG" --log-recover "$CLEAN_LOG" 2>&1)
|
||||
CONFLICT_STATUS=$?
|
||||
if [ "$CONFLICT_STATUS" -eq 64 ] &&
|
||||
printf '%s\n' "$CONFLICT_OUTPUT" | grep -q 'Invalid --log-check: --log-recover'; then
|
||||
pass "conflicting log modes exit 64"
|
||||
else
|
||||
fail "conflicting log modes"
|
||||
printf '%s\n' "$CONFLICT_OUTPUT"
|
||||
echo "exit status: $CONFLICT_STATUS"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "PASSED: $PASS"
|
||||
echo "FAILED: $FAIL"
|
||||
[ "$FAIL" -eq 0 ] && echo "All tests passed" || echo "Some tests failed"
|
||||
exit "$FAIL"
|
||||
223
tests/test_slow_client.sh
Executable file
223
tests/test_slow_client.sh
Executable 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
227
tests/test_soak.sh
Executable 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 + 2))
|
||||
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"
|
||||
182
tests/test_tntctl_cli.sh
Executable file
182
tests/test_tntctl_cli.sh
Executable 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
284
tests/test_user_lifecycle.sh
Executable 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
66
tests/unit/test_config_defaults.c
Normal file
66
tests/unit/test_config_defaults.c
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
60
tests/unit/test_tntctl_text.c
Normal file
60
tests/unit/test_tntctl_text.c
Normal 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
165
tnt.1
|
|
@ -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
126
tntctl.1
Normal 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)
|
||||
Loading…
Reference in a new issue