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:
|
pull_request:
|
||||||
branches: [ main ]
|
branches: [ main ]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-and-test:
|
build-and-test:
|
||||||
runs-on: ${{ matrix.os }}
|
runs-on: ${{ matrix.os }}
|
||||||
|
|
|
||||||
72
.github/workflows/release.yml
vendored
72
.github/workflows/release.yml
vendored
|
|
@ -5,6 +5,9 @@ on:
|
||||||
tags:
|
tags:
|
||||||
- 'v*'
|
- 'v*'
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build:
|
build:
|
||||||
name: Build ${{ matrix.target }}
|
name: Build ${{ matrix.target }}
|
||||||
|
|
@ -15,19 +18,26 @@ jobs:
|
||||||
- os: ubuntu-24.04
|
- os: ubuntu-24.04
|
||||||
target: linux-amd64
|
target: linux-amd64
|
||||||
artifact: tnt-linux-amd64
|
artifact: tnt-linux-amd64
|
||||||
|
ctl_artifact: tntctl-linux-amd64
|
||||||
- os: ubuntu-24.04-arm
|
- os: ubuntu-24.04-arm
|
||||||
target: linux-arm64
|
target: linux-arm64
|
||||||
artifact: tnt-linux-arm64
|
artifact: tnt-linux-arm64
|
||||||
|
ctl_artifact: tntctl-linux-arm64
|
||||||
- os: macos-15-intel
|
- os: macos-15-intel
|
||||||
target: darwin-amd64
|
target: darwin-amd64
|
||||||
artifact: tnt-darwin-amd64
|
artifact: tnt-darwin-amd64
|
||||||
|
ctl_artifact: tntctl-darwin-amd64
|
||||||
- os: macos-15
|
- os: macos-15
|
||||||
target: darwin-arm64
|
target: darwin-arm64
|
||||||
artifact: tnt-darwin-arm64
|
artifact: tnt-darwin-arm64
|
||||||
|
ctl_artifact: tntctl-darwin-arm64
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Verify release tag matches source version
|
||||||
|
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
- name: Install dependencies (Ubuntu)
|
- name: Install dependencies (Ubuntu)
|
||||||
if: runner.os == 'Linux'
|
if: runner.os == 'Linux'
|
||||||
run: |
|
run: |
|
||||||
|
|
@ -48,29 +58,38 @@ jobs:
|
||||||
- name: Verify artifact architecture
|
- name: Verify artifact architecture
|
||||||
run: |
|
run: |
|
||||||
file tnt
|
file tnt
|
||||||
|
file tntctl
|
||||||
case "${{ matrix.target }}" in
|
case "${{ matrix.target }}" in
|
||||||
linux-amd64)
|
linux-amd64)
|
||||||
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
file tnt | grep -E 'ELF 64-bit.*x86-64'
|
||||||
|
file tntctl | grep -E 'ELF 64-bit.*x86-64'
|
||||||
;;
|
;;
|
||||||
linux-arm64)
|
linux-arm64)
|
||||||
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
file tnt | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||||
|
file tntctl | grep -E 'ELF 64-bit.*(aarch64|ARM aarch64)'
|
||||||
;;
|
;;
|
||||||
darwin-amd64)
|
darwin-amd64)
|
||||||
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
file tnt | grep -E 'Mach-O 64-bit.*x86_64'
|
||||||
|
file tntctl | grep -E 'Mach-O 64-bit.*x86_64'
|
||||||
;;
|
;;
|
||||||
darwin-arm64)
|
darwin-arm64)
|
||||||
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
file tnt | grep -E 'Mach-O 64-bit.*arm64'
|
||||||
|
file tntctl | grep -E 'Mach-O 64-bit.*arm64'
|
||||||
;;
|
;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
- name: Rename binary
|
- name: Rename binary
|
||||||
run: mv tnt ${{ matrix.artifact }}
|
run: |
|
||||||
|
mv tnt ${{ matrix.artifact }}
|
||||||
|
mv tntctl ${{ matrix.ctl_artifact }}
|
||||||
|
|
||||||
- name: Upload artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-artifact@v4
|
uses: actions/upload-artifact@v4
|
||||||
with:
|
with:
|
||||||
name: ${{ matrix.artifact }}
|
name: ${{ matrix.artifact }}
|
||||||
path: ${{ matrix.artifact }}
|
path: |
|
||||||
|
${{ matrix.artifact }}
|
||||||
|
${{ matrix.ctl_artifact }}
|
||||||
|
|
||||||
release:
|
release:
|
||||||
needs: build
|
needs: build
|
||||||
|
|
@ -81,6 +100,9 @@ jobs:
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Verify release tag matches source version
|
||||||
|
run: scripts/check_release_ref.sh "${GITHUB_REF_NAME}"
|
||||||
|
|
||||||
- name: Download all artifacts
|
- name: Download all artifacts
|
||||||
uses: actions/download-artifact@v4
|
uses: actions/download-artifact@v4
|
||||||
with:
|
with:
|
||||||
|
|
@ -90,7 +112,8 @@ jobs:
|
||||||
run: |
|
run: |
|
||||||
cd artifacts
|
cd artifacts
|
||||||
: > checksums.txt
|
: > 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
|
sha256sum "$artifact" | sed "s# $artifact# $(basename "$artifact")#" >> checksums.txt
|
||||||
done
|
done
|
||||||
cat checksums.txt
|
cat checksums.txt
|
||||||
|
|
@ -100,43 +123,76 @@ jobs:
|
||||||
with:
|
with:
|
||||||
files: |
|
files: |
|
||||||
artifacts/*/tnt-*
|
artifacts/*/tnt-*
|
||||||
|
artifacts/*/tntctl-*
|
||||||
artifacts/checksums.txt
|
artifacts/checksums.txt
|
||||||
body: |
|
body: |
|
||||||
## Installation
|
## 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:
|
Download the binary for your platform:
|
||||||
|
|
||||||
**Linux AMD64:**
|
**Linux AMD64:**
|
||||||
```bash
|
```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 tnt-linux-amd64
|
||||||
|
chmod +x tntctl-linux-amd64
|
||||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-linux-amd64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Linux ARM64:**
|
**Linux ARM64:**
|
||||||
```bash
|
```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 tnt-linux-arm64
|
||||||
|
chmod +x tntctl-linux-arm64
|
||||||
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
sudo mv tnt-linux-arm64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-linux-arm64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS Intel:**
|
**macOS Intel:**
|
||||||
```bash
|
```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 tnt-darwin-amd64
|
||||||
|
chmod +x tntctl-darwin-amd64
|
||||||
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
sudo mv tnt-darwin-amd64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-darwin-amd64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**macOS Apple Silicon:**
|
**macOS Apple Silicon:**
|
||||||
```bash
|
```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 tnt-darwin-arm64
|
||||||
|
chmod +x tntctl-darwin-arm64
|
||||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
||||||
|
sudo mv tntctl-darwin-arm64 /usr/local/bin/tntctl
|
||||||
```
|
```
|
||||||
|
|
||||||
**Verify checksums:**
|
**Verify checksums:**
|
||||||
```bash
|
```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
|
## What's Changed
|
||||||
|
|
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
|
|
@ -1,6 +1,7 @@
|
||||||
*.o
|
*.o
|
||||||
obj/
|
obj/
|
||||||
tnt
|
tnt
|
||||||
|
tntctl
|
||||||
messages.log
|
messages.log
|
||||||
host_key
|
host_key
|
||||||
host_key.pub
|
host_key.pub
|
||||||
|
|
@ -8,6 +9,9 @@ host_key.pub
|
||||||
.DS_Store
|
.DS_Store
|
||||||
test.log
|
test.log
|
||||||
*.dSYM/
|
*.dSYM/
|
||||||
|
demos/*.gif
|
||||||
|
demos/*.mp4
|
||||||
|
demos/*.webm
|
||||||
tests/unit/test_utf8
|
tests/unit/test_utf8
|
||||||
tests/unit/test_message
|
tests/unit/test_message
|
||||||
tests/unit/test_chat_room
|
tests/unit/test_chat_room
|
||||||
|
|
@ -20,4 +24,5 @@ tests/unit/test_help_text
|
||||||
tests/unit/test_manual_text
|
tests/unit/test_manual_text
|
||||||
tests/unit/test_support_text
|
tests/unit/test_support_text
|
||||||
tests/unit/test_cli_text
|
tests/unit/test_cli_text
|
||||||
|
tests/unit/test_tntctl_text
|
||||||
tests/unit/test_ratelimit
|
tests/unit/test_ratelimit
|
||||||
|
|
|
||||||
64
Makefile
64
Makefile
|
|
@ -4,6 +4,7 @@
|
||||||
CC = gcc
|
CC = gcc
|
||||||
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
|
CFLAGS = -Wall -Wextra -O2 -std=c11 -D_XOPEN_SOURCE=700
|
||||||
LDFLAGS = -pthread -lssh
|
LDFLAGS = -pthread -lssh
|
||||||
|
CTL_LDFLAGS =
|
||||||
INCLUDES = -Iinclude
|
INCLUDES = -Iinclude
|
||||||
DEPFLAGS = -MMD -MP
|
DEPFLAGS = -MMD -MP
|
||||||
|
|
||||||
|
|
@ -20,10 +21,13 @@ SRC_DIR = src
|
||||||
INC_DIR = include
|
INC_DIR = include
|
||||||
OBJ_DIR = obj
|
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)
|
OBJECTS = $(SOURCES:$(SRC_DIR)/%.c=$(OBJ_DIR)/%.o)
|
||||||
DEPS = $(OBJECTS:.o=.d)
|
DEPS = $(OBJECTS:.o=.d) $(CTL_OBJECTS:.o=.d)
|
||||||
TARGET = tnt
|
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
|
PREFIX ?= /usr/local
|
||||||
BINDIR ?= $(PREFIX)/bin
|
BINDIR ?= $(PREFIX)/bin
|
||||||
|
|
@ -31,14 +35,18 @@ MANDIR ?= $(PREFIX)/share/man
|
||||||
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
SYSTEMD_UNIT_DIR ?= $(PREFIX)/lib/systemd/system
|
||||||
CI_TEST_PORT ?= $(if $(PORT),$(PORT),2222)
|
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)
|
$(TARGET): $(OBJECTS)
|
||||||
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
$(CC) $(OBJECTS) -o $@ $(LDFLAGS)
|
||||||
@echo "Build complete: $(TARGET)"
|
@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)
|
$(OBJ_DIR)/%.o: $(SRC_DIR)/%.c | $(OBJ_DIR)
|
||||||
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
$(CC) $(CFLAGS) $(DEPFLAGS) $(INCLUDES) -c $< -o $@
|
||||||
|
|
||||||
|
|
@ -46,34 +54,40 @@ $(OBJ_DIR):
|
||||||
mkdir -p $(OBJ_DIR)
|
mkdir -p $(OBJ_DIR)
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -rf $(OBJ_DIR) $(TARGET)
|
rm -rf $(OBJ_DIR) $(TARGETS)
|
||||||
rm -f tests/*.log tests/host_key* tests/messages.log
|
rm -f tests/*.log tests/host_key* tests/messages.log
|
||||||
@echo "Clean complete"
|
@echo "Clean complete"
|
||||||
|
|
||||||
install: $(TARGET)
|
install: $(TARGETS)
|
||||||
install -d $(DESTDIR)$(BINDIR)
|
install -d $(DESTDIR)$(BINDIR)
|
||||||
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
install -m 755 $(TARGET) $(DESTDIR)$(BINDIR)/
|
||||||
|
install -m 755 $(CTL_TARGET) $(DESTDIR)$(BINDIR)/
|
||||||
install -d $(DESTDIR)$(MANDIR)/man1
|
install -d $(DESTDIR)$(MANDIR)/man1
|
||||||
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
install -m 644 tnt.1 $(DESTDIR)$(MANDIR)/man1/
|
||||||
|
install -m 644 tntctl.1 $(DESTDIR)$(MANDIR)/man1/
|
||||||
|
|
||||||
install-systemd:
|
install-systemd:
|
||||||
install -d $(DESTDIR)$(SYSTEMD_UNIT_DIR)
|
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:
|
uninstall:
|
||||||
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
rm -f $(DESTDIR)$(BINDIR)/$(TARGET)
|
||||||
|
rm -f $(DESTDIR)$(BINDIR)/$(CTL_TARGET)
|
||||||
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
rm -f $(DESTDIR)$(MANDIR)/man1/tnt.1
|
||||||
|
rm -f $(DESTDIR)$(MANDIR)/man1/tntctl.1
|
||||||
|
|
||||||
uninstall-systemd:
|
uninstall-systemd:
|
||||||
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
rm -f $(DESTDIR)$(SYSTEMD_UNIT_DIR)/tnt.service
|
||||||
|
|
||||||
# Development targets
|
# Development targets
|
||||||
debug: CFLAGS += -g -DDEBUG
|
debug: CFLAGS += -g -DDEBUG
|
||||||
debug: clean $(TARGET)
|
debug: clean $(TARGETS)
|
||||||
|
|
||||||
release: CFLAGS += -O3 -DNDEBUG
|
release: CFLAGS += -O3 -DNDEBUG
|
||||||
release: clean $(TARGET)
|
release: clean $(TARGETS)
|
||||||
strip $(TARGET)
|
strip $(TARGET)
|
||||||
|
strip $(CTL_TARGET)
|
||||||
|
|
||||||
release-check:
|
release-check:
|
||||||
./scripts/release_check.sh
|
./scripts/release_check.sh
|
||||||
|
|
@ -81,9 +95,16 @@ release-check:
|
||||||
release-check-strict:
|
release-check-strict:
|
||||||
./scripts/release_check.sh --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: CFLAGS += -g -fsanitize=address -fno-omit-frame-pointer
|
||||||
asan: LDFLAGS += -fsanitize=address
|
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"
|
@echo "AddressSanitizer build complete. Run with: ASAN_OPTIONS=detect_leaks=1 ./tnt"
|
||||||
|
|
||||||
valgrind: debug
|
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"
|
@command -v clang-tidy >/dev/null 2>&1 && clang-tidy src/*.c -- -Iinclude $(INCLUDES) || echo "clang-tidy not installed"
|
||||||
|
|
||||||
# Test
|
# Test
|
||||||
test: all unit-test integration-test
|
test: all unit-test script-test integration-test
|
||||||
|
|
||||||
test-advisory: all unit-test
|
test-advisory: all unit-test
|
||||||
@echo "Running integration tests..."
|
@echo "Running integration tests..."
|
||||||
|
|
@ -107,11 +128,20 @@ unit-test:
|
||||||
@echo "Running unit tests..."
|
@echo "Running unit tests..."
|
||||||
@$(MAKE) -C tests/unit run
|
@$(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
|
integration-test: all
|
||||||
@echo "Running integration tests..."
|
@echo "Running integration tests..."
|
||||||
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
@cd tests && PORT=$${PORT:-2222} ./test_basic.sh
|
||||||
@cd tests && PORT=$$(($${PORT:-2222} + 1)) ./test_exec_mode.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} + 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
|
anonymous-access-test: all
|
||||||
@echo "Running anonymous access tests..."
|
@echo "Running anonymous access tests..."
|
||||||
|
|
@ -129,6 +159,18 @@ stress-test: all
|
||||||
@echo "Running stress tests..."
|
@echo "Running stress tests..."
|
||||||
@cd tests && PORT=$${PORT:-2222} ./test_stress.sh $${CLIENTS:-10} $${DURATION:-30}
|
@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:
|
ci-test:
|
||||||
@$(MAKE) test PORT=$(CI_TEST_PORT)
|
@$(MAKE) test PORT=$(CI_TEST_PORT)
|
||||||
@$(MAKE) anonymous-access-test PORT=$$(($(CI_TEST_PORT) + 5))
|
@$(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
|
```sh
|
||||||
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
curl -sSL https://raw.githubusercontent.com/m1ngsama/TNT/main/install.sh | sh
|
||||||
```
|
```
|
||||||
The installer verifies the downloaded release binary against `checksums.txt`
|
The installer verifies downloaded release binaries against `checksums.txt`
|
||||||
before installing it.
|
before installing them. Older releases may provide only `tnt`; newer releases
|
||||||
|
also install `tntctl`.
|
||||||
|
|
||||||
**From source:**
|
**From source:**
|
||||||
```sh
|
```sh
|
||||||
|
|
@ -47,9 +48,12 @@ PORT=3333 tnt # via env var
|
||||||
### Connecting
|
### Connecting
|
||||||
|
|
||||||
```sh
|
```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.
|
**Anonymous access by default**: Users can connect with ANY username/password (or empty password). No SSH keys required. Perfect for public chat servers.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
@ -94,7 +98,7 @@ Ctrl+C - Exit chat
|
||||||
:w <user> <text> - Short alias for :msg
|
:w <user> <text> - Short alias for :msg
|
||||||
:inbox - Show private messages
|
:inbox - Show private messages
|
||||||
:last [N] - Show last N messages from history (max 50, default 10)
|
: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
|
:mute-joins - Toggle join/leave system notifications
|
||||||
:lang <en|zh> - Switch UI language for this session
|
:lang <en|zh> - Switch UI language for this session
|
||||||
:help - Show concise manual
|
:help - Show concise manual
|
||||||
|
|
@ -104,6 +108,10 @@ Up/Down - Browse command history
|
||||||
ESC - Return to NORMAL mode
|
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)**
|
**Special messages (INSERT mode)**
|
||||||
```
|
```
|
||||||
/me <action> - Send action (e.g. /me waves)
|
/me <action> - Send action (e.g. /me waves)
|
||||||
|
|
@ -133,6 +141,21 @@ TNT_PUBLIC_HOST=chat.example.com tnt
|
||||||
TNT_LANG=zh 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:**
|
**Rate limiting:**
|
||||||
```sh
|
```sh
|
||||||
# Max total connections (default 64)
|
# 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 stats --json
|
||||||
ssh -p 2222 chat.example.com users
|
ssh -p 2222 chat.example.com users
|
||||||
ssh -p 2222 chat.example.com "tail -n 20"
|
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 operator@chat.example.com post "service notice"
|
||||||
ssh -p 2222 chat.example.com post "/me deploys v2.0"
|
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.
|
**`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
|
## Development
|
||||||
|
|
||||||
### Building
|
### 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 connection-limit-test # verify per-IP concurrency and rate limits
|
||||||
make security-test # run security feature checks
|
make security-test # run security feature checks
|
||||||
make stress-test # run configurable concurrent-client stress test
|
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
|
make ci-test # run the same checks as GitHub Actions
|
||||||
|
|
||||||
# Individual tests
|
# Individual tests
|
||||||
|
|
@ -214,6 +278,9 @@ cd tests
|
||||||
./test_anonymous_access.sh # anonymous access
|
./test_anonymous_access.sh # anonymous access
|
||||||
./test_connection_limits.sh # per-IP concurrency and rate limits
|
./test_connection_limits.sh # per-IP concurrency and rate limits
|
||||||
./test_stress.sh # stress test
|
./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:**
|
**Test coverage:**
|
||||||
|
|
@ -221,6 +288,8 @@ cd tests
|
||||||
- Anonymous access: 2 tests
|
- Anonymous access: 2 tests
|
||||||
- Security features: 12 tests
|
- Security features: 12 tests
|
||||||
- Stress test: configurable concurrent clients (`CLIENTS=20 DURATION=60 make stress-test`)
|
- 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
|
### Dependencies
|
||||||
|
|
||||||
|
|
@ -254,6 +323,8 @@ TNT/
|
||||||
│ ├── commands.c # COMMAND-mode command dispatch
|
│ ├── commands.c # COMMAND-mode command dispatch
|
||||||
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
│ ├── exec_catalog.c # SSH exec command matching, usage, and argument shape
|
||||||
│ ├── exec.c # SSH exec command dispatch
|
│ ├── 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
|
│ ├── ssh_server.c # SSH server implementation
|
||||||
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
│ ├── bootstrap.c # SSH authentication and session bootstrap
|
||||||
│ ├── chat_room.c # chat room logic
|
│ ├── chat_room.c # chat room logic
|
||||||
|
|
@ -324,10 +395,17 @@ Before preparing a release locally:
|
||||||
make release-check
|
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
|
```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
|
## Files
|
||||||
|
|
@ -339,6 +417,9 @@ motd.txt - Message of the Day (optional, shown to users on connect)
|
||||||
tnt.service - systemd service unit
|
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)
|
### 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.
|
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
|
- [Development Guide](https://github.com/m1ngsama/TNT/wiki/Development-Guide) - Complete development manual
|
||||||
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
- [Quick Setup](docs/EASY_SETUP.md) - 5-minute deployment guide
|
||||||
- [Roadmap](docs/ROADMAP.md) - Long-term Unix/GNU direction and next stages
|
- [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
|
- [Security Reference](docs/SECURITY_QUICKREF.md) - Security config quick reference
|
||||||
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
- [Contributing](docs/CONTRIBUTING.md) - How to contribute
|
||||||
- [Changelog](docs/CHANGELOG.md) - Version history
|
- [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
|
## 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
|
### 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
|
- 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
|
locale/code parsing, while `src/i18n_text.c` owns the table-driven text
|
||||||
catalog with coverage checks for every message ID.
|
catalog with coverage checks for every message ID.
|
||||||
|
|
@ -17,10 +159,15 @@
|
||||||
- Refreshed contributor and development guidance so new commands are added
|
- Refreshed contributor and development guidance so new commands are added
|
||||||
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
|
through `command_catalog`, `exec_catalog`, and `i18n_text` instead of stale
|
||||||
`ssh_server.c` / inline-`strcmp` instructions.
|
`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,
|
- `exec_catalog` now owns SSH exec command matching as well as help metadata,
|
||||||
reducing duplicate command knowledge in `src/exec.c`.
|
reducing duplicate command knowledge in `src/exec.c`.
|
||||||
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
|
- Replaced hard-coded `chat.m1ng.space` examples with `chat.example.com` so
|
||||||
public documentation does not imply a specific production host.
|
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
|
- Moved SSH exec usage text and argument-shape checks into `exec_catalog`, so
|
||||||
`src/exec.c` no longer duplicates `--json` and required-message validation.
|
`src/exec.c` no longer duplicates `--json` and required-message validation.
|
||||||
- Moved interactive command usage text and first-pass argument-shape checks
|
- 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
|
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:
|
1. Update version metadata:
|
||||||
- include/common.h
|
- include/common.h
|
||||||
- tnt.1
|
- tnt.1
|
||||||
- docs/CHANGELOG.md
|
- docs/CHANGELOG.md
|
||||||
- packaging/arch/PKGBUILD
|
- packaging/arch/PKGBUILD
|
||||||
- packaging/homebrew/tnt-chat.rb
|
- packaging/homebrew/tnt-chat.rb
|
||||||
|
- packaging/debian/debian/changelog
|
||||||
|
- maintainer metadata, when preparing public package recipes
|
||||||
|
|
||||||
2. Run the local preflight:
|
2. Run the local preflight:
|
||||||
make release-check
|
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
|
make release-check-strict
|
||||||
|
|
||||||
4. Create and push tag:
|
Strict mode requires the local `vX.Y.Z` tag to point at HEAD. It also
|
||||||
git tag v1.0.1
|
builds from the tagged source archive, so it catches files that were left
|
||||||
git push origin v1.0.1
|
untracked and would be missing from GitHub's source archive.
|
||||||
|
|
||||||
5. GitHub Actions automatically:
|
5. Push the tag:
|
||||||
- Builds binaries (Linux/macOS, AMD64/ARM64)
|
git push origin vX.Y.Z
|
||||||
|
|
||||||
|
6. GitHub Actions automatically:
|
||||||
|
- Builds `tnt` and `tntctl` binaries (Linux/macOS, AMD64/ARM64)
|
||||||
- Creates a draft release
|
- Creates a draft release
|
||||||
- Uploads binaries
|
- Uploads binaries
|
||||||
- Generates one `checksums.txt` file
|
- Generates one `checksums.txt` file
|
||||||
- Verifies that artifact architecture matches the asset name
|
- 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.
|
manually from GitHub.
|
||||||
|
|
||||||
7. Release appears at:
|
8. Release appears at:
|
||||||
https://github.com/m1ngsama/TNT/releases
|
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
|
DEPLOYING TO SERVERS
|
||||||
--------------------
|
--------------------
|
||||||
Deployments are operator-driven:
|
Deployments are operator-driven:
|
||||||
|
|
@ -105,8 +161,8 @@ make && make asan && make release-check
|
||||||
./tnt
|
./tnt
|
||||||
|
|
||||||
# Create release
|
# Create release
|
||||||
git tag v1.0.1
|
git tag vX.Y.Z
|
||||||
git push origin v1.0.1
|
git push origin vX.Y.Z
|
||||||
# Wait 5 minutes for builds
|
# Wait 5 minutes for builds
|
||||||
|
|
||||||
# Deploy to production manually after validation
|
# Deploy to production manually after validation
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@ make release-check # release preflight
|
||||||
make test # unit + integration tests
|
make test # unit + integration tests
|
||||||
make ci-test # local CI-equivalent checks
|
make ci-test # local CI-equivalent checks
|
||||||
make stress-test # concurrent-client stress test
|
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
|
## Debug
|
||||||
|
|
@ -37,10 +40,12 @@ make check
|
||||||
```
|
```
|
||||||
main.c → entry point, signal handling
|
main.c → entry point, signal handling
|
||||||
cli_text.c → startup CLI text
|
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
|
command_catalog.c → COMMAND-mode command metadata, usage, and argument shape
|
||||||
commands.c → COMMAND-mode command dispatch
|
commands.c → COMMAND-mode command dispatch
|
||||||
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
exec_catalog.c → SSH exec command matching, usage, and argument shape
|
||||||
exec.c → SSH exec command dispatch
|
exec.c → SSH exec command dispatch
|
||||||
|
tntctl.c → local wrapper around the SSH exec interface
|
||||||
ssh_server.c → SSH listener setup
|
ssh_server.c → SSH listener setup
|
||||||
bootstrap.c → SSH authentication/session bootstrap
|
bootstrap.c → SSH authentication/session bootstrap
|
||||||
input.c → interactive session loop
|
input.c → interactive session loop
|
||||||
|
|
@ -69,7 +74,7 @@ utf8.c → UTF-8 string handling
|
||||||
|
|
||||||
## Known Limits
|
## Known Limits
|
||||||
|
|
||||||
- Max 64 clients (MAX_CLIENTS)
|
- Default 64 clients, configurable with `TNT_MAX_CONNECTIONS`
|
||||||
- Max 100 messages in memory (MAX_MESSAGES)
|
- Max 100 messages in memory (MAX_MESSAGES)
|
||||||
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
- Max 1024 bytes per message (MAX_MESSAGE_LEN)
|
||||||
- Max 64 bytes username (MAX_USERNAME_LEN)
|
- Max 64 bytes username (MAX_USERNAME_LEN)
|
||||||
|
|
@ -77,7 +82,8 @@ utf8.c → UTF-8 string handling
|
||||||
## Common Bugs to Avoid
|
## Common Bugs to Avoid
|
||||||
|
|
||||||
1. Don't use `strtok()` on client data - use `strtok_r()` or copy first
|
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)
|
3. Check SSH API return values (can be SSH_ERROR, SSH_AGAIN, or negative)
|
||||||
4. UTF-8 chars are multi-byte - use utf8_* functions
|
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:
|
Specific version:
|
||||||
```bash
|
```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
|
## Manual Install
|
||||||
|
|
@ -18,12 +18,12 @@ Download binary for your platform from [releases](https://github.com/m1ngsama/TN
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Linux AMD64
|
# 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
|
chmod +x tnt-linux-amd64
|
||||||
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
sudo mv tnt-linux-amd64 /usr/local/bin/tnt
|
||||||
|
|
||||||
# macOS ARM64 (Apple Silicon)
|
# 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
|
chmod +x tnt-darwin-arm64
|
||||||
sudo mv tnt-darwin-arm64 /usr/local/bin/tnt
|
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.
|
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
|
## Firewall
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -55,10 +55,13 @@ TNT uses a multi-threaded architecture with a main accept loop and per-client th
|
||||||
|
|
||||||
### Key Design Principles
|
### Key Design Principles
|
||||||
|
|
||||||
1. **Fixed-size buffers** - No dynamic allocation in hot paths
|
1. **Fixed-size buffers** - Keep message, command, and UI buffers bounded
|
||||||
2. **Reader-writer locks** - Multiple readers, single writer
|
2. **Reader-writer locks** - Multiple readers, single writer for room state
|
||||||
3. **Reference counting** - Prevent use-after-free
|
3. **Per-client output ownership** - Each interactive session writes only to
|
||||||
4. **Ring buffer** - Fixed-size message history (last 100 messages)
|
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/
|
src/
|
||||||
├── main.c - CLI entry point and startup option parsing
|
├── 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
|
├── ssh_server.c - SSH listener setup and connection accept loop
|
||||||
├── bootstrap.c - SSH authentication/session bootstrap
|
├── bootstrap.c - SSH authentication/session bootstrap
|
||||||
├── input.c - Interactive session loop and key handling
|
├── input.c - Interactive session loop and key handling
|
||||||
|
|
@ -76,8 +80,12 @@ src/
|
||||||
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
├── command_catalog.c - COMMAND-mode names, aliases, and help summaries
|
||||||
├── exec_catalog.c - SSH exec command matching and help metadata
|
├── exec_catalog.c - SSH exec command matching and help metadata
|
||||||
├── exec.c - SSH exec command dispatch
|
├── 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.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
|
├── history_view.c - NORMAL-mode scroll window rules
|
||||||
├── tui.c - Terminal UI rendering (ANSI escape codes)
|
├── tui.c - Terminal UI rendering (ANSI escape codes)
|
||||||
├── tui_status.c - Mode/status/input-line rendering
|
├── tui_status.c - Mode/status/input-line rendering
|
||||||
|
|
@ -100,13 +108,20 @@ include/
|
||||||
├── bootstrap.h - SSH session bootstrap interface
|
├── bootstrap.h - SSH session bootstrap interface
|
||||||
├── chat_room.h - Chat room interface
|
├── chat_room.h - Chat room interface
|
||||||
├── message.h - Message structure and persistence
|
├── 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
|
├── 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
|
├── history_view.h - Scroll-state helpers
|
||||||
├── tui.h - TUI rendering functions
|
├── tui.h - TUI rendering functions
|
||||||
|
├── tui_status.h - TUI status/input-line rendering interface
|
||||||
├── i18n.h - Language and shared text IDs
|
├── i18n.h - Language and shared text IDs
|
||||||
├── help_text.h - Key reference text interface
|
├── help_text.h - Key reference text interface
|
||||||
├── manual.h - Concise manual panel interface
|
├── manual.h - Concise manual panel interface
|
||||||
├── manual_text.h - Concise manual text interface
|
├── manual_text.h - Concise manual text interface
|
||||||
|
├── system_message.h - Localized system message builders
|
||||||
├── ratelimit.h - Connection limit interface
|
├── ratelimit.h - Connection limit interface
|
||||||
└── utf8.h - UTF-8 utilities
|
└── utf8.h - UTF-8 utilities
|
||||||
```
|
```
|
||||||
|
|
@ -119,12 +134,16 @@ typedef struct client {
|
||||||
ssh_session session;
|
ssh_session session;
|
||||||
ssh_channel channel;
|
ssh_channel channel;
|
||||||
char username[MAX_USERNAME_LEN];
|
char username[MAX_USERNAME_LEN];
|
||||||
int width, height; // Terminal dimensions
|
_Atomic int width, height; // Terminal dimensions
|
||||||
client_mode_t mode; // INSERT/NORMAL/COMMAND
|
client_mode_t mode; // INSERT/NORMAL/COMMAND
|
||||||
int scroll_pos;
|
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
|
int ref_count; // Reference counting
|
||||||
pthread_mutex_t ref_lock;
|
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;
|
} client_t;
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -134,6 +153,7 @@ typedef struct {
|
||||||
pthread_rwlock_t lock; // Reader-writer lock
|
pthread_rwlock_t lock; // Reader-writer lock
|
||||||
struct client **clients; // Dynamic array
|
struct client **clients; // Dynamic array
|
||||||
int client_count;
|
int client_count;
|
||||||
|
uint64_t update_seq; // Bumped when message history changes
|
||||||
message_t *messages; // Ring buffer
|
message_t *messages; // Ring buffer
|
||||||
int message_count;
|
int message_count;
|
||||||
} chat_room_t;
|
} 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 connection-limit-test # Verify per-IP concurrency and rate limits
|
||||||
make security-test # Run security feature checks
|
make security-test # Run security feature checks
|
||||||
make stress-test # Run configurable concurrent-client stress test
|
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
|
make ci-test # Run the same checks as GitHub Actions
|
||||||
|
|
||||||
# Individual tests
|
# Individual tests
|
||||||
|
|
@ -197,6 +220,9 @@ cd tests
|
||||||
./test_security_features.sh # Security checks
|
./test_security_features.sh # Security checks
|
||||||
./test_anonymous_access.sh # Anonymous access
|
./test_anonymous_access.sh # Anonymous access
|
||||||
./test_stress.sh # Concurrent connections
|
./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
|
### Test Coverage
|
||||||
|
|
@ -205,6 +231,10 @@ cd tests
|
||||||
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
|
- **Security**: RSA keys, env vars, UTF-8 validation, buffer overflow protection
|
||||||
- **Anonymous**: Passwordless access, any username
|
- **Anonymous**: Passwordless access, any username
|
||||||
- **Stress**: 10 concurrent clients for 30 seconds
|
- **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)
|
### 2. Chat Room (chat_room.c)
|
||||||
|
|
||||||
**Thread-safe broadcasting:**
|
**Thread-safe message publication:**
|
||||||
```c
|
```c
|
||||||
void room_broadcast(chat_room_t *room, const message_t *msg) {
|
void room_broadcast(chat_room_t *room, const message_t *msg) {
|
||||||
pthread_rwlock_wrlock(&room->lock);
|
pthread_rwlock_wrlock(&room->lock);
|
||||||
|
|
||||||
/* Copy client list with ref counting */
|
room_add_message(room, msg);
|
||||||
client_t **clients_copy = calloc(...);
|
room->update_seq++;
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
clients_copy[i]->ref_count++;
|
|
||||||
}
|
|
||||||
|
|
||||||
pthread_rwlock_unlock(&room->lock); // Release lock early
|
pthread_rwlock_unlock(&room->lock);
|
||||||
|
|
||||||
/* Render outside lock (avoid deadlock) */
|
|
||||||
for (int i = 0; i < count; i++) {
|
|
||||||
tui_render_screen(clients_copy[i]);
|
|
||||||
client_release(clients_copy[i]);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
**Why this works:**
|
**Why this works:**
|
||||||
- Copy client list while holding write lock
|
- Broadcast updates shared room state only; it does not render or write to
|
||||||
- Increment reference counts
|
any SSH channel.
|
||||||
- Release lock BEFORE rendering
|
- Each interactive session tracks `room_get_update_seq()` in its own
|
||||||
- Render to all clients outside lock
|
`input_run_session()` loop.
|
||||||
- Decrement reference counts (may free clients)
|
- 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)
|
### 3. Message Persistence (message.c)
|
||||||
|
|
||||||
|
See [MESSAGE_LOG.md](MESSAGE_LOG.md) for the stable TNT 1.x on-disk record
|
||||||
|
contract.
|
||||||
|
|
||||||
**Log format:**
|
**Log format:**
|
||||||
```
|
```
|
||||||
2024-01-13T10:30:45Z|username|message content
|
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):
|
**Optimized loading** (backward scan):
|
||||||
```c
|
```c
|
||||||
/* Scan backwards from file end */
|
/* Scan backwards from file end */
|
||||||
|
|
@ -380,9 +417,13 @@ void utf8_remove_last_word(char *str) {
|
||||||
```sh
|
```sh
|
||||||
tests/test_exec_mode.sh # exec command behavior
|
tests/test_exec_mode.sh # exec command behavior
|
||||||
tests/test_interactive_input.sh # COMMAND-mode/TUI 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_i18n.c # localized shared text
|
||||||
tests/unit/test_command_catalog.c # interactive command metadata
|
tests/unit/test_command_catalog.c # interactive command metadata
|
||||||
tests/unit/test_exec_catalog.c # exec command help 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
|
### Adding a New Keybinding
|
||||||
|
|
@ -449,6 +490,10 @@ keys.
|
||||||
fragments.
|
fragments.
|
||||||
- Keep placeholders visible and stable, for example `%s`, `%d`,
|
- Keep placeholders visible and stable, for example `%s`, `%d`,
|
||||||
`<user>`, and `<message>`.
|
`<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
|
- Every new user-facing string needs tests for at least English fallback
|
||||||
and Chinese output while this project has two UI languages.
|
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
|
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
|
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
|
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.
|
storage instead of adding ad hoc branches for every locale.
|
||||||
|
|
||||||
Relevant conventions:
|
Relevant conventions:
|
||||||
|
|
|
||||||
|
|
@ -37,9 +37,11 @@ tnt -p 2222 -d /var/lib/tnt
|
||||||
## Connect
|
## Connect
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh -p 2222 chat.example.com
|
ssh -p 2222 localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a deployed server, replace `localhost` with your public host.
|
||||||
|
|
||||||
Default access rules:
|
Default access rules:
|
||||||
|
|
||||||
- Any SSH username is accepted.
|
- Any SSH username is accepted.
|
||||||
|
|
@ -64,7 +66,10 @@ Esc enter NORMAL mode
|
||||||
i return to INSERT mode
|
i return to INSERT mode
|
||||||
: enter COMMAND mode
|
: enter COMMAND mode
|
||||||
? open the full key reference
|
? open the full key reference
|
||||||
|
/ search message history
|
||||||
G or End jump to latest messages
|
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
|
Ctrl+C disconnect from NORMAL mode
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -196,9 +201,11 @@ tnt
|
||||||
### 连接
|
### 连接
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
ssh -p 2222 chat.example.com
|
ssh -p 2222 localhost
|
||||||
```
|
```
|
||||||
|
|
||||||
|
部署到公网后,将 `localhost` 替换为你的域名。
|
||||||
|
|
||||||
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
|
默认情况下,任意 SSH 用户名和空密码都可以连接。进入后 TNT 会询问显示名称。
|
||||||
|
|
||||||
### 常用操作
|
### 常用操作
|
||||||
|
|
@ -209,7 +216,10 @@ Esc 进入 NORMAL 模式
|
||||||
i 回到 INSERT 模式
|
i 回到 INSERT 模式
|
||||||
: 输入命令
|
: 输入命令
|
||||||
? 查看完整按键参考
|
? 查看完整按键参考
|
||||||
|
/ 搜索消息历史
|
||||||
G 或 End 回到最新消息
|
G 或 End 回到最新消息
|
||||||
|
Up/Down 在 INSERT 模式调出已发送消息
|
||||||
|
Tab 在 INSERT 模式补全 @mention
|
||||||
:help 查看简明手册
|
:help 查看简明手册
|
||||||
:lang en|zh 切换界面语言
|
:lang en|zh 切换界面语言
|
||||||
:q 断开连接
|
: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 connection-limit-test per-IP concurrency/rate-limit checks
|
||||||
make security-test security feature checks
|
make security-test security feature checks
|
||||||
make stress-test concurrent-client stress test
|
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
|
make ci-test same checks as GitHub Actions
|
||||||
|
|
||||||
DEBUG
|
DEBUG
|
||||||
|
|
@ -43,9 +46,27 @@ INSERT MODE
|
||||||
limit 1023 bytes/message; over-limit input rings bell
|
limit 1023 bytes/message; over-limit input rings bell
|
||||||
normal opens/follows latest; k/PgUp older, j/PgDn newer
|
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
|
STRUCTURE
|
||||||
src/main.c entry, signals
|
src/main.c entry, signals
|
||||||
src/cli_text.c startup CLI text
|
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/command_catalog.c command metadata, usage, argument shape
|
||||||
src/ssh_server.c SSH listener and server setup
|
src/ssh_server.c SSH listener and server setup
|
||||||
src/bootstrap.c SSH auth/session bootstrap
|
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_catalog.c SSH exec command matching, usage, argument shape
|
||||||
src/exec.c SSH exec command dispatch
|
src/exec.c SSH exec command dispatch
|
||||||
src/message.c persistence, search
|
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/history_view.c message viewport / scroll state
|
||||||
src/help_text.c full-screen key reference text
|
src/help_text.c full-screen key reference text
|
||||||
src/manual.c concise manual panel rendering
|
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.
|
Goal: make TNT predictable for operators, scripts, and package maintainers.
|
||||||
|
|
||||||
- split the current surface into `tntd` (daemon) and `tntctl` (control client)
|
- ✅ 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 the primary API shape
|
- keep SSH exec support, but treat it as a transport for stable commands rather
|
||||||
- define stable subcommands and exit codes for:
|
than an ad hoc command surface
|
||||||
|
- ✅ define stable subcommands and exit codes for:
|
||||||
- `health`
|
- `health`
|
||||||
- `stats`
|
- `stats`
|
||||||
- `users`
|
- `users`
|
||||||
- `tail`
|
- `tail`
|
||||||
|
- `dump`
|
||||||
- `post`
|
- `post`
|
||||||
- support text and JSON output modes where machine use is likely
|
- ✅ support text and JSON output modes where machine use is likely
|
||||||
- normalize command parsing, help text, and error reporting
|
- ✅ normalize command parsing, help text, and error reporting
|
||||||
- add `--bind`, `--port`, `--state-dir`, `--public-host`, `--max-clients`, and related long options consistently
|
- ✅ keep `tnt` as the 1.x server binary; reserve any future `tntd` split for a
|
||||||
- add a man page for `tntd` and `tntctl`
|
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
|
## Stage 2: Runtime Model
|
||||||
|
|
||||||
Goal: make long-running operation boring and reliable.
|
Goal: make long-running operation boring and reliable.
|
||||||
|
|
||||||
- move client state to a clearer ownership model with one release path
|
- ✅ move session callback ownership into `client.c` and release sessions
|
||||||
- finish replacing ad hoc cross-thread UI mutation with per-client event delivery
|
through one `client_release_session()` path
|
||||||
- add bounded outbound queues so slow clients cannot stall other users
|
- ✅ 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
|
- 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
|
- ✅ make room/client capacity fully runtime-configurable with no hidden
|
||||||
- document hard guarantees and soft limits
|
compile-time ceiling
|
||||||
|
- ✅ document hard guarantees and soft limits
|
||||||
|
|
||||||
## Stage 3: Data and Persistence
|
## Stage 3: Data and Persistence
|
||||||
|
|
||||||
Goal: make stored history durable, inspectable, and recoverable.
|
Goal: make stored history durable, inspectable, and recoverable.
|
||||||
|
|
||||||
- formalize the message log format and version it
|
- ✅ formalize the message log v1 format
|
||||||
- keep timestamps in a timezone-safe format throughout write and replay
|
- ✅ keep persisted timestamps in UTC throughout write and replay
|
||||||
- validate persisted UTF-8 and record structure before replay
|
- ✅ validate persisted UTF-8 and record structure before replay/search
|
||||||
- add log rotation and compaction tooling
|
- ✅ provide an inspection/export command for persisted records
|
||||||
- provide an offline inspection/export command
|
- ✅ add log rotation and compaction tooling
|
||||||
- define recovery behavior for truncated or partially corrupted logs
|
- ✅ define broader recovery tooling for truncated or partially corrupted logs
|
||||||
|
|
||||||
## Stage 4: Interactive UX
|
## Stage 4: Interactive UX
|
||||||
|
|
||||||
Goal: keep the interface efficient for terminal users without sacrificing simplicity.
|
Goal: keep the interface efficient for terminal users without sacrificing simplicity.
|
||||||
|
|
||||||
- keep the current modal editing model, but make its behavior precise and documented
|
- ✅ keep the current modal editing model precise and documented
|
||||||
- support resize, cursor movement, command history, and predictable paste behavior
|
- ✅ 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:
|
- add useful chat commands with clear semantics:
|
||||||
- ✅ `:nick` / `:name` — nickname change with broadcast
|
- ✅ `:nick` / `:name` — nickname change with broadcast
|
||||||
- ✅ `/me` — action messages
|
- ✅ `/me` — action messages
|
||||||
- ✅ `:last N` — show last N messages from disk history
|
- ✅ `:last N` — show last N messages from disk history
|
||||||
- ✅ `:search <keyword>` — case-insensitive full-text search
|
- ✅ `:search <keyword>` — case-insensitive full-text search
|
||||||
- ✅ `:mute-joins` — per-client join/leave notification toggle
|
- ✅ `:mute-joins` — per-client join/leave notification toggle
|
||||||
- improve discoverability of NORMAL and COMMAND mode actions
|
- ✅ improve discoverability of NORMAL and COMMAND mode actions
|
||||||
- make status lines and help output concise enough for small terminals
|
- ✅ make status lines and help output concise enough for small terminals
|
||||||
|
|
||||||
## Stage 5: Operations and Security
|
## Stage 5: Operations and Security
|
||||||
|
|
||||||
Goal: make public deployment manageable.
|
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
|
- 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
|
- 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
|
- tighten CI around authentication, limits, and restart behavior
|
||||||
|
|
||||||
## Stage 6: Release Quality
|
## 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
|
- 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 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
|
- 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
|
- 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.
|
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.
|
1. Replace remaining source-archive checksum placeholders only after the final
|
||||||
2. Define exit codes and JSON schemas for `health`, `stats`, `users`, `tail`, and `post`.
|
GitHub source archive exists, then run `make package-publish-check`.
|
||||||
3. Add per-client outbound queues and finish untangling client-state ownership.
|
2. Create or move the `vX.Y.Z` tag only when the release commit is final, then
|
||||||
4. Remove the remaining hidden runtime limits and make them explicit configuration.
|
run `make release-check-strict` before pushing it.
|
||||||
5. Add a long-running soak test that exercises idle sessions, reconnects, and slow consumers.
|
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,
|
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
const char *program_name, ui_lang_t lang);
|
const char *program_name, ui_lang_t lang);
|
||||||
const char *cli_text_invalid_port_format(ui_lang_t lang);
|
const char *cli_text_invalid_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_unknown_option_format(ui_lang_t lang);
|
||||||
const char *cli_text_short_usage_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 */
|
#include "ssh_server.h" /* for client_t */
|
||||||
|
|
||||||
/* Send `len` bytes to the client over its SSH channel. Serialised on
|
/* Send `len` bytes to the client over its SSH channel.
|
||||||
* client->io_lock so concurrent senders don't interleave. Returns 0 on
|
*
|
||||||
* success, -1 if the channel is gone or a partial write fails. */
|
* 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);
|
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
|
/* printf-style wrapper around client_send(). The formatted string must
|
||||||
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
* fit in 2048 bytes; truncation or encoding errors return -1. */
|
||||||
int client_printf(client_t *client, const char *fmt, ...);
|
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.
|
/* Reference counting for safe cross-thread cleanup.
|
||||||
*
|
*
|
||||||
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
|
* Lifecycle: bootstrap_run() creates the client_t with ref_count = 1
|
||||||
* (the "main" ref), then adds a second ref before installing the channel
|
* (the "main" ref). client_install_channel_callbacks() takes a second
|
||||||
* callbacks (the "callback" ref) so the client outlives any in-flight
|
* ref owned by client.c while channel callbacks are installed, so the
|
||||||
* eof / close / window-change callback invocation. The interactive
|
* client outlives in-flight eof / close / window-change callbacks.
|
||||||
* session releases both refs in its cleanup path; the final release
|
* input_run_session() ends ownership with client_release_session(). */
|
||||||
* frees the SSH session, channel, callback struct, and the client_t. */
|
|
||||||
void client_addref(client_t *client);
|
void client_addref(client_t *client);
|
||||||
void client_release(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)
|
/* Install the post-bootstrap channel callbacks (window-change, eof, close).
|
||||||
* that target this client_t. Caller MUST have already added one
|
* On success this function takes the callback reference described above.
|
||||||
* client_addref() to keep the client alive across in-flight callback
|
* On failure no callback reference remains and the caller still owns only
|
||||||
* invocations; the matching client_release() happens during cleanup in
|
* its original main reference. */
|
||||||
* input_run_session(). Returns 0 on success, -1 on failure (in which
|
|
||||||
* case the caller still owns both refs and must release them). */
|
|
||||||
int client_install_channel_callbacks(client_t *client);
|
int client_install_channel_callbacks(client_t *client);
|
||||||
|
|
||||||
#endif /* CLIENT_H */
|
#endif /* CLIENT_H */
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,13 @@
|
||||||
* - Toggles client->mute_joins on `:mute-joins`
|
* - Toggles client->mute_joins on `:mute-joins`
|
||||||
* - May broadcast a system rename message on `:nick`
|
* - May broadcast a system rename message on `:nick`
|
||||||
*
|
*
|
||||||
* Reads g_room. Caller must already hold the channel I/O serialisation
|
* Reads g_room. Renders command output through the normal client_send()
|
||||||
* established by handle_key() — this function calls back into client_send
|
* path; callers must not hold client->io_lock before dispatching. */
|
||||||
* (via tui_render_command_output) which acquires client->io_lock. */
|
|
||||||
void commands_dispatch(client_t *client);
|
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 */
|
#endif /* COMMANDS_H */
|
||||||
|
|
|
||||||
|
|
@ -11,22 +11,38 @@
|
||||||
#include <limits.h>
|
#include <limits.h>
|
||||||
#include <pthread.h>
|
#include <pthread.h>
|
||||||
|
|
||||||
|
#include "config_defaults.h"
|
||||||
|
|
||||||
/* Project Metadata */
|
/* Project Metadata */
|
||||||
#define TNT_VERSION "1.0.1"
|
#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 */
|
/* Configuration constants */
|
||||||
#define DEFAULT_PORT 2222
|
|
||||||
#define MAX_MESSAGES 100
|
#define MAX_MESSAGES 100
|
||||||
#define MAX_USERNAME_LEN 64
|
#define MAX_USERNAME_LEN 64
|
||||||
#define MAX_MESSAGE_LEN 1024
|
#define MAX_MESSAGE_LEN 1024
|
||||||
#define MAX_EXEC_COMMAND_LEN 1024
|
#define MAX_EXEC_COMMAND_LEN 1024
|
||||||
#define MAX_COMMAND_OUTPUT_LEN 8192
|
#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 LOG_FILE "messages.log"
|
||||||
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
#define MAX_LOG_SIZE (10 * 1024 * 1024) /* 10 MiB */
|
||||||
#define HOST_KEY_FILE "host_key"
|
#define HOST_KEY_FILE "host_key"
|
||||||
#define TNT_DEFAULT_STATE_DIR "."
|
#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 */
|
/* ANSI color codes */
|
||||||
#define ANSI_RESET "\033[0m"
|
#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
|
/* Dispatch the non-interactive SSH exec command stored in
|
||||||
* client->exec_command. Returns the exit status to send back to the
|
* client->exec_command. Returns the exit status to send back to the
|
||||||
* SSH client:
|
* SSH client:
|
||||||
* 0 = success
|
* TNT_EXIT_OK = success
|
||||||
* 1 = runtime error (I/O, OOM, persistence failure)
|
* TNT_EXIT_ERROR = runtime error (I/O, OOM, persistence failure)
|
||||||
* 64 = usage error (unknown command, bad args)
|
* TNT_EXIT_USAGE = usage error (unknown command, bad args)
|
||||||
*
|
*
|
||||||
* Reads g_room and shared client state. Safe to call once per
|
* Reads g_room and shared client state. Safe to call once per
|
||||||
* exec-mode session before the channel is closed. */
|
* exec-mode session before the channel is closed. */
|
||||||
|
|
|
||||||
|
|
@ -9,8 +9,10 @@ typedef enum {
|
||||||
TNT_EXEC_COMMAND_USERS,
|
TNT_EXEC_COMMAND_USERS,
|
||||||
TNT_EXEC_COMMAND_STATS,
|
TNT_EXEC_COMMAND_STATS,
|
||||||
TNT_EXEC_COMMAND_TAIL,
|
TNT_EXEC_COMMAND_TAIL,
|
||||||
|
TNT_EXEC_COMMAND_DUMP,
|
||||||
TNT_EXEC_COMMAND_POST,
|
TNT_EXEC_COMMAND_POST,
|
||||||
TNT_EXEC_COMMAND_EXIT
|
TNT_EXEC_COMMAND_EXIT,
|
||||||
|
TNT_EXEC_COMMAND_COUNT
|
||||||
} tnt_exec_command_id_t;
|
} tnt_exec_command_id_t;
|
||||||
|
|
||||||
bool exec_catalog_match(const char *line, tnt_exec_command_id_t *id,
|
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);
|
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,
|
void exec_catalog_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
ui_lang_t lang);
|
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,
|
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||||
tnt_exec_command_id_t id, ui_lang_t lang);
|
tnt_exec_command_id_t id, ui_lang_t lang);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,12 @@ typedef struct {
|
||||||
const char *text[UI_LANG_COUNT];
|
const char *text[UI_LANG_COUNT];
|
||||||
} i18n_string_t;
|
} 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) \
|
#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 {
|
typedef enum {
|
||||||
I18N_USERNAME_PROMPT,
|
I18N_USERNAME_PROMPT,
|
||||||
|
|
@ -25,6 +29,7 @@ typedef enum {
|
||||||
I18N_HELP_STATUS_FORMAT,
|
I18N_HELP_STATUS_FORMAT,
|
||||||
I18N_COMMAND_OUTPUT_TITLE,
|
I18N_COMMAND_OUTPUT_TITLE,
|
||||||
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
|
I18N_COMMAND_OUTPUT_STATUS_FORMAT,
|
||||||
|
I18N_COMMAND_OUTPUT_REFRESH_STATUS_FORMAT,
|
||||||
I18N_MOTD_TITLE,
|
I18N_MOTD_TITLE,
|
||||||
I18N_MOTD_CONTINUE_HINT,
|
I18N_MOTD_CONTINUE_HINT,
|
||||||
I18N_TITLE_ONLINE_FORMAT,
|
I18N_TITLE_ONLINE_FORMAT,
|
||||||
|
|
@ -58,6 +63,9 @@ typedef enum {
|
||||||
I18N_UNKNOWN_GUIDANCE,
|
I18N_UNKNOWN_GUIDANCE,
|
||||||
I18N_EXEC_POST_EMPTY,
|
I18N_EXEC_POST_EMPTY,
|
||||||
I18N_EXEC_POST_INVALID_UTF8,
|
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_EXEC_UNKNOWN_COMMAND_FORMAT,
|
||||||
I18N_TEXT_COUNT
|
I18N_TEXT_COUNT
|
||||||
} i18n_text_id_t;
|
} 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. */
|
* 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);
|
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 */
|
#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];
|
char content[MAX_MESSAGE_LEN];
|
||||||
} whisper_t;
|
} whisper_t;
|
||||||
|
|
||||||
|
typedef enum {
|
||||||
|
TNT_COMMAND_OUTPUT_NONE,
|
||||||
|
TNT_COMMAND_OUTPUT_GENERIC,
|
||||||
|
TNT_COMMAND_OUTPUT_INBOX
|
||||||
|
} tnt_command_output_kind_t;
|
||||||
|
|
||||||
/* Client connection structure */
|
/* Client connection structure */
|
||||||
typedef struct client {
|
typedef struct client {
|
||||||
ssh_session session; /* SSH session */
|
ssh_session session; /* SSH session */
|
||||||
|
|
@ -42,16 +48,23 @@ typedef struct client {
|
||||||
int insert_history_pos;
|
int insert_history_pos;
|
||||||
char command_output[MAX_COMMAND_OUTPUT_LEN];
|
char command_output[MAX_COMMAND_OUTPUT_LEN];
|
||||||
int command_output_scroll;
|
int command_output_scroll;
|
||||||
|
tnt_command_output_kind_t command_output_kind;
|
||||||
bool show_motd; /* command_output holds MOTD text */
|
bool show_motd; /* command_output holds MOTD text */
|
||||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||||
|
bool exec_command_too_long;
|
||||||
char ssh_login[MAX_USERNAME_LEN];
|
char ssh_login[MAX_USERNAME_LEN];
|
||||||
time_t connect_time;
|
time_t connect_time;
|
||||||
time_t last_active;
|
time_t last_active;
|
||||||
atomic_bool redraw_pending;
|
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_mentions; /* @-mentions received since last reset */
|
||||||
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
_Atomic int unread_whispers; /* whispers received since last :inbox view */
|
||||||
/* Per-client whisper inbox. Pushes serialise on io_lock; readers are
|
char *outbox; /* Bounded queued output for interactive writes */
|
||||||
* the client's own thread inside :inbox handling. */
|
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];
|
whisper_t whisper_inbox[WHISPER_INBOX_SIZE];
|
||||||
int whisper_inbox_count;
|
int whisper_inbox_count;
|
||||||
bool mute_joins;
|
bool mute_joins;
|
||||||
|
|
@ -60,6 +73,8 @@ typedef struct client {
|
||||||
int ref_count; /* Reference count for safe cleanup */
|
int ref_count; /* Reference count for safe cleanup */
|
||||||
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
pthread_mutex_t ref_lock; /* Lock for ref_count */
|
||||||
pthread_mutex_t io_lock; /* Serialize SSH channel writes */
|
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;
|
struct ssh_channel_callbacks_struct *channel_cb;
|
||||||
} client_t;
|
} 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
|
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 curl
|
||||||
need_cmd awk
|
need_cmd awk
|
||||||
|
|
||||||
|
|
@ -45,13 +73,15 @@ case "$ARCH" in
|
||||||
*) fail "Unsupported architecture: $ARCH" ;;
|
*) fail "Unsupported architecture: $ARCH" ;;
|
||||||
esac
|
esac
|
||||||
|
|
||||||
BINARY="tnt-${OS}-${ARCH}"
|
SERVER_BINARY="tnt-${OS}-${ARCH}"
|
||||||
|
CTL_BINARY="tntctl-${OS}-${ARCH}"
|
||||||
|
|
||||||
echo "=== TNT Installer ==="
|
echo "=== TNT Installer ==="
|
||||||
echo "OS: $OS"
|
echo "OS: $OS"
|
||||||
echo "Arch: $ARCH"
|
echo "Arch: $ARCH"
|
||||||
echo "Version: $VERSION"
|
echo "Version: $VERSION"
|
||||||
echo ""
|
echo ""
|
||||||
|
warn_missing_libssh
|
||||||
|
|
||||||
# Get latest version if not specified
|
# Get latest version if not specified
|
||||||
if [ "$VERSION" = "latest" ]; then
|
if [ "$VERSION" = "latest" ]; then
|
||||||
|
|
@ -65,51 +95,81 @@ fi
|
||||||
echo "Installing version: $VERSION"
|
echo "Installing version: $VERSION"
|
||||||
|
|
||||||
# Download
|
# 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"
|
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")
|
CHECKSUM_FILE=$(mktemp "${TMPDIR:-/tmp}/tnt-checksums.XXXXXX")
|
||||||
|
INSTALL_CTL=0
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -f "$TMP_FILE" "$CHECKSUM_FILE"
|
rm -f "$SERVER_TMP_FILE" "$CTL_TMP_FILE" "$CHECKSUM_FILE"
|
||||||
}
|
}
|
||||||
trap cleanup EXIT INT TERM
|
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"
|
echo "Downloading checksums from: $CHECKSUM_URL"
|
||||||
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
curl -fsSL -o "$CHECKSUM_FILE" "$CHECKSUM_URL" ||
|
||||||
fail "Failed to download checksums.txt"
|
fail "Failed to download checksums.txt"
|
||||||
|
|
||||||
EXPECTED_SHA=$(awk -v name="$BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
EXPECTED_SERVER_SHA=$(awk -v name="$SERVER_BINARY" '$2 == name { print $1; exit }' "$CHECKSUM_FILE")
|
||||||
[ -n "$EXPECTED_SHA" ] || fail "No checksum entry found for $BINARY"
|
[ -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"
|
fail "sha256sum or shasum is required for checksum verification"
|
||||||
|
|
||||||
[ "$ACTUAL_SHA" = "$EXPECTED_SHA" ] ||
|
[ "$ACTUAL_SERVER_SHA" = "$EXPECTED_SERVER_SHA" ] ||
|
||||||
fail "Checksum mismatch for $BINARY"
|
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
|
# 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
|
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
|
else
|
||||||
echo "Need sudo for installation to $INSTALL_DIR"
|
echo "Need sudo for installation to $INSTALL_DIR"
|
||||||
need_cmd sudo
|
need_cmd sudo
|
||||||
sudo mkdir -p "$INSTALL_DIR"
|
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
|
fi
|
||||||
|
|
||||||
echo ""
|
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 ""
|
||||||
echo "Run with:"
|
echo "Run with:"
|
||||||
echo " tnt"
|
echo " tnt"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Or specify port:"
|
echo "Or specify port:"
|
||||||
echo " PORT=3333 tnt"
|
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.
|
- `homebrew/` - Homebrew tap formula draft and maintainer notes.
|
||||||
- `debian/` - Ubuntu PPA / Debian packaging notes and draft metadata.
|
- `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
|
## Release checklist
|
||||||
|
|
||||||
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
1. Confirm `TNT_VERSION` in `include/common.h` and the manpage version match.
|
||||||
Also update package versions in Arch, Homebrew, and Debian drafts.
|
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.
|
3. Build and upload release tarballs or rely on GitHub source archives.
|
||||||
4. Replace placeholder checksums in package drafts.
|
4. Replace placeholder checksums in package drafts.
|
||||||
5. Verify package contents in an isolated directory:
|
5. Verify package contents in an isolated directory:
|
||||||
|
|
@ -23,13 +26,23 @@ any public registry.
|
||||||
make release-check
|
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
|
```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.
|
- Arch: upload `PKGBUILD` and generated `.SRCINFO` to AUR.
|
||||||
- Homebrew: open a PR to the project tap, or later Homebrew core if eligible.
|
- 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.
|
- Ubuntu: build Debian source packages and upload to a Launchpad PPA.
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,8 @@ pkgbase = tnt-chat
|
||||||
makedepends = make
|
makedepends = make
|
||||||
depends = libssh
|
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-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 = SKIP
|
||||||
|
sha256sums = 8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed
|
||||||
|
|
||||||
pkgname = tnt-chat
|
pkgname = tnt-chat
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
# Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
# Maintainer: M1ng <contact@m1ng.space>
|
||||||
|
|
||||||
pkgname=tnt-chat
|
pkgname=tnt-chat
|
||||||
pkgver=1.0.1
|
pkgver=1.0.1
|
||||||
|
|
@ -9,8 +9,10 @@ url='https://github.com/m1ngsama/TNT'
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
depends=('libssh')
|
depends=('libssh')
|
||||||
makedepends=('gcc' 'make')
|
makedepends=('gcc' 'make')
|
||||||
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz")
|
source=("${pkgname}-${pkgver}.tar.gz::${url}/archive/refs/tags/v${pkgver}.tar.gz"
|
||||||
sha256sums=('SKIP')
|
"${pkgname}.sysusers")
|
||||||
|
sha256sums=('SKIP'
|
||||||
|
'8a1f7dfbdc9f1305c4ed50d80e89f91333ffdf937890c497f93e41abaf76e3ed')
|
||||||
|
|
||||||
build() {
|
build() {
|
||||||
cd "TNT-${pkgver}"
|
cd "TNT-${pkgver}"
|
||||||
|
|
@ -21,5 +23,7 @@ package() {
|
||||||
cd "TNT-${pkgver}"
|
cd "TNT-${pkgver}"
|
||||||
make DESTDIR="${pkgdir}" PREFIX=/usr install
|
make DESTDIR="${pkgdir}" PREFIX=/usr install
|
||||||
make DESTDIR="${pkgdir}" PREFIX=/usr install-systemd
|
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"
|
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,11 +26,12 @@ After editing `PKGBUILD`, regenerate `.SRCINFO`:
|
||||||
makepkg --printsrcinfo > .SRCINFO
|
makepkg --printsrcinfo > .SRCINFO
|
||||||
```
|
```
|
||||||
|
|
||||||
Before AUR submission, replace `sha256sums=('SKIP')` with the real release
|
Before AUR submission, replace `sha256sums=('SKIP')` with the real GitHub
|
||||||
archive checksum, then run the project-level strict check:
|
source archive checksum, regenerate `.SRCINFO`, then run the package publish
|
||||||
|
check:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
make release-check-strict
|
SOURCE_TARBALL=dist/tnt-chat-vX.Y.Z.tar.gz make package-publish-check
|
||||||
```
|
```
|
||||||
|
|
||||||
## Manual AUR submission
|
## 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/
|
cp PKGBUILD .SRCINFO aur-tnt-chat/
|
||||||
cd aur-tnt-chat
|
cd aur-tnt-chat
|
||||||
git add PKGBUILD .SRCINFO
|
git add PKGBUILD .SRCINFO
|
||||||
git commit -m "Update to 1.0.1"
|
git commit -m "Update to X.Y.Z"
|
||||||
git push
|
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
|
## Draft metadata
|
||||||
|
|
||||||
The `debian/` directory in this folder is a packaging draft. To test it against
|
The `debian/` directory in this folder is a packaging draft. To assemble it
|
||||||
an upstream release tree, copy it to the root of a clean source checkout:
|
against a clean source tree:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
cp -a packaging/debian/debian ./debian
|
make debian-source-package
|
||||||
dpkg-buildpackage -us -uc
|
|
||||||
```
|
```
|
||||||
|
|
||||||
For PPA uploads, build a signed source package instead:
|
For PPA uploads, build a source package on Debian/Ubuntu:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
debuild -S
|
scripts/package_debian_source.sh --build
|
||||||
```
|
```
|
||||||
|
|
||||||
## Recommended path
|
## Recommended path
|
||||||
|
|
@ -44,6 +43,8 @@ debuild -S
|
||||||
## Package shape
|
## Package shape
|
||||||
|
|
||||||
- Binary package name: `tnt-chat`
|
- Binary package name: `tnt-chat`
|
||||||
- Installed command: `/usr/bin/tnt`
|
- Installed commands: `/usr/bin/tnt`, `/usr/bin/tntctl`
|
||||||
- Runtime dependency: `libssh`
|
- Runtime dependency: `libssh`
|
||||||
- Optional systemd unit: `/usr/lib/systemd/system/tnt.service`
|
- 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.
|
* 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
|
Source: tnt-chat
|
||||||
Section: net
|
Section: net
|
||||||
Priority: optional
|
Priority: optional
|
||||||
Maintainer: M1ng <REPLACE_WITH_EMAIL>
|
Maintainer: M1ng <contact@m1ng.space>
|
||||||
Build-Depends:
|
Build-Depends:
|
||||||
debhelper-compat (= 13),
|
debhelper-compat (= 13),
|
||||||
libssh-dev,
|
libssh-dev,
|
||||||
|
|
@ -15,7 +15,8 @@ Package: tnt-chat
|
||||||
Architecture: any
|
Architecture: any
|
||||||
Depends:
|
Depends:
|
||||||
${misc:Depends},
|
${misc:Depends},
|
||||||
${shlibs:Depends}
|
${shlibs:Depends},
|
||||||
|
adduser
|
||||||
Description: SSH-native terminal chat server
|
Description: SSH-native terminal chat server
|
||||||
TNT is a minimalist terminal chat server accessed over SSH. It provides a
|
TNT is a minimalist terminal chat server accessed over SSH. It provides a
|
||||||
Vim-style terminal interface, anonymous access by default, persistent message
|
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
|
```sh
|
||||||
brew tap m1ngsama/tnt
|
brew tap m1ngsama/tnt
|
||||||
brew install tnt-chat
|
brew install tnt-chat
|
||||||
|
brew services start tnt-chat
|
||||||
```
|
```
|
||||||
|
|
||||||
Homebrew core should wait until TNT has stable releases and broader usage.
|
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 audit --strict --online tnt-chat
|
||||||
brew install --build-from-source ./Formula/tnt-chat.rb
|
brew install --build-from-source ./Formula/tnt-chat.rb
|
||||||
brew test tnt-chat
|
brew test tnt-chat
|
||||||
|
brew services run tnt-chat
|
||||||
```
|
```
|
||||||
|
|
||||||
For local syntax-only validation from this repository:
|
For local syntax-only validation from this repository:
|
||||||
|
|
@ -28,20 +30,20 @@ ruby -c packaging/homebrew/tnt-chat.rb
|
||||||
|
|
||||||
## Updating the formula
|
## 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:
|
2. Download or hash the release source archive:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
curl -L -o 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/v1.0.1.tar.gz
|
https://github.com/m1ngsama/TNT/archive/refs/tags/vX.Y.Z.tar.gz
|
||||||
shasum -a 256 tnt-chat-1.0.1.tar.gz
|
shasum -a 256 dist/tnt-chat-vX.Y.Z.tar.gz
|
||||||
```
|
```
|
||||||
|
|
||||||
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
|
3. Replace `REPLACE_WITH_RELEASE_TARBALL_SHA256` in `tnt-chat.rb`.
|
||||||
4. Run:
|
4. Run:
|
||||||
|
|
||||||
```sh
|
```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.
|
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}"
|
system "make", "install", "DESTDIR=#{buildpath}/stage", "PREFIX=#{prefix}"
|
||||||
|
|
||||||
bin.install "#{buildpath}/stage#{prefix}/bin/tnt"
|
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/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
|
end
|
||||||
|
|
||||||
test do
|
test do
|
||||||
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
assert_match version.to_s, shell_output("#{bin}/tnt --version")
|
||||||
|
assert_match version.to_s, shell_output("#{bin}/tntctl --version")
|
||||||
end
|
end
|
||||||
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
|
#!/bin/sh
|
||||||
# TNT Log Rotation Script
|
# Compact and archive a TNT messages.log file.
|
||||||
# Keeps chat history manageable and prevents disk space issues
|
#
|
||||||
|
# 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}"
|
set -eu
|
||||||
MAX_SIZE_MB="${2:-100}"
|
|
||||||
KEEP_LINES="${3:-10000}"
|
|
||||||
|
|
||||||
# Check if log file exists
|
DRY_RUN=0
|
||||||
if [ ! -f "$LOG_FILE" ]; then
|
KEEP_ARCHIVES=5
|
||||||
echo "Log file $LOG_FILE does not exist"
|
|
||||||
|
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
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
[ -f "$LOG_FILE" ] || fail "$LOG_FILE is not a regular file"
|
||||||
|
|
||||||
# Get file size in MB
|
MAX_BYTES=$((MAX_SIZE_MB * 1024 * 1024))
|
||||||
FILE_SIZE=$(du -m "$LOG_FILE" | cut -f1)
|
FILE_SIZE=$(wc -c < "$LOG_FILE" | tr -d ' ')
|
||||||
|
[ -n "$FILE_SIZE" ] || fail "could not read log size"
|
||||||
|
|
||||||
# Rotate if file is too large
|
compact_log() {
|
||||||
if [ "$FILE_SIZE" -gt "$MAX_SIZE_MB" ]; then
|
timestamp=$(date -u +%Y%m%dT%H%M%SZ)
|
||||||
echo "Log file size: ${FILE_SIZE}MB, rotating..."
|
backup="${LOG_FILE}.${timestamp}"
|
||||||
|
suffix=1
|
||||||
|
|
||||||
# Create backup
|
while [ -e "$backup" ] || [ -e "${backup}.gz" ]; do
|
||||||
BACKUP="${LOG_FILE}.$(date +%Y%m%d_%H%M%S)"
|
backup="${LOG_FILE}.${timestamp}.${suffix}"
|
||||||
cp "$LOG_FILE" "$BACKUP"
|
suffix=$((suffix + 1))
|
||||||
|
done
|
||||||
|
|
||||||
# Keep only last N lines
|
if [ "$DRY_RUN" -eq 1 ]; then
|
||||||
tail -n "$KEEP_LINES" "$LOG_FILE" > "${LOG_FILE}.tmp"
|
echo "logrotate: would archive $LOG_FILE to $backup"
|
||||||
mv "${LOG_FILE}.tmp" "$LOG_FILE"
|
echo "logrotate: would keep last $KEEP_LINES lines"
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
|
||||||
# Compress old backup
|
tmp="${LOG_FILE}.tmp.$$"
|
||||||
gzip "$BACKUP"
|
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"
|
if command -v gzip >/dev/null 2>&1; then
|
||||||
echo "Kept last $KEEP_LINES lines"
|
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
|
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
|
fi
|
||||||
|
|
||||||
# Clean up old compressed logs (keep last 5)
|
cleanup_archives
|
||||||
LOG_DIR=$(dirname "$LOG_FILE")
|
echo "logrotate: complete"
|
||||||
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"
|
|
||||||
|
|
|
||||||
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
|
- version metadata alignment
|
||||||
- clean build
|
- clean build
|
||||||
- unit tests
|
- unit tests
|
||||||
|
- script tests
|
||||||
- staged install layout with PREFIX=/usr and DESTDIR
|
- staged install layout with PREFIX=/usr and DESTDIR
|
||||||
- installer shell syntax
|
- installer shell syntax
|
||||||
- Debian packaging metadata
|
- Debian packaging metadata
|
||||||
|
|
@ -20,9 +21,14 @@ Default checks:
|
||||||
|
|
||||||
Environment:
|
Environment:
|
||||||
RUN_INTEGRATION=1 also run full make test
|
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
|
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
|
USAGE
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -62,6 +68,8 @@ version=$(sed -n 's/^#define TNT_VERSION "\([^"]*\)".*/\1/p' include/common.h)
|
||||||
step "checking version metadata for $version"
|
step "checking version metadata for $version"
|
||||||
grep -q "\"TNT $version\"" tnt.1 ||
|
grep -q "\"TNT $version\"" tnt.1 ||
|
||||||
fail "tnt.1 does not mention TNT $version"
|
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 ||
|
grep -q "^pkgver=$version$" packaging/arch/PKGBUILD ||
|
||||||
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
fail "packaging/arch/PKGBUILD pkgver does not match $version"
|
||||||
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
grep -q "pkgver = $version" packaging/arch/.SRCINFO ||
|
||||||
|
|
@ -88,16 +96,50 @@ make
|
||||||
actual_version=$(./tnt --version)
|
actual_version=$(./tnt --version)
|
||||||
[ "$actual_version" = "tnt $version" ] ||
|
[ "$actual_version" = "tnt $version" ] ||
|
||||||
fail "binary version mismatch: expected 'tnt $version', got '$actual_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"
|
step "running unit tests"
|
||||||
make -C tests/unit clean
|
make -C tests/unit clean
|
||||||
make -C tests/unit run
|
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
|
if [ "${RUN_INTEGRATION:-0}" = "1" ]; then
|
||||||
step "running full integration tests"
|
step "running full integration tests"
|
||||||
make test PORT="${PORT:-12720}"
|
make test PORT="${PORT:-12720}"
|
||||||
fi
|
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")
|
tmpdir=$(mktemp -d "${TMPDIR:-/tmp}/tnt-release-check.XXXXXX")
|
||||||
cleanup() {
|
cleanup() {
|
||||||
rm -rf "$tmpdir"
|
rm -rf "$tmpdir"
|
||||||
|
|
@ -109,18 +151,88 @@ make DESTDIR="$tmpdir" PREFIX=/usr install
|
||||||
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
make DESTDIR="$tmpdir" PREFIX=/usr install-systemd
|
||||||
|
|
||||||
[ -x "$tmpdir/usr/bin/tnt" ] || fail "missing executable: /usr/bin/tnt"
|
[ -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/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" ] ||
|
[ -f "$tmpdir/usr/lib/systemd/system/tnt.service" ] ||
|
||||||
fail "missing systemd unit: /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"
|
step "checking installer syntax"
|
||||||
sh -n install.sh
|
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"
|
step "checking Debian packaging metadata"
|
||||||
[ -x packaging/debian/debian/rules ] ||
|
[ -x packaging/debian/debian/rules ] ||
|
||||||
fail "packaging/debian/debian/rules must be executable"
|
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 ||
|
grep -q "^3.0 (quilt)$" packaging/debian/debian/source/format ||
|
||||||
fail "unsupported 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"
|
step "checking packaging syntax"
|
||||||
if command -v bash >/dev/null 2>&1; then
|
if command -v bash >/dev/null 2>&1; then
|
||||||
|
|
@ -137,14 +249,55 @@ fi
|
||||||
|
|
||||||
if [ "$STRICT" -eq 1 ]; then
|
if [ "$STRICT" -eq 1 ]; then
|
||||||
step "checking strict release gates"
|
step "checking strict release gates"
|
||||||
! grep -q "sha256sums=('SKIP')" packaging/arch/PKGBUILD ||
|
[ -z "$(git status --short)" ] ||
|
||||||
fail "replace PKGBUILD sha256sums before strict release"
|
fail "working tree must be clean for strict release"
|
||||||
! grep -q "sha256sums = SKIP" packaging/arch/.SRCINFO ||
|
|
||||||
fail "replace .SRCINFO sha256sums before strict release"
|
|
||||||
! grep -q "REPLACE_WITH_RELEASE_TARBALL_SHA256" packaging/homebrew/tnt-chat.rb ||
|
|
||||||
fail "replace Homebrew sha256 before strict release"
|
|
||||||
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
git rev-parse -q --verify "refs/tags/v$version" >/dev/null ||
|
||||||
fail "missing local tag v$version"
|
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
|
fi
|
||||||
|
|
||||||
step "release preflight passed"
|
step "release preflight passed"
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,7 @@ typedef struct {
|
||||||
int pty_width;
|
int pty_width;
|
||||||
int pty_height;
|
int pty_height;
|
||||||
char exec_command[MAX_EXEC_COMMAND_LEN];
|
char exec_command[MAX_EXEC_COMMAND_LEN];
|
||||||
|
bool exec_command_too_long;
|
||||||
bool auth_success;
|
bool auth_success;
|
||||||
int auth_attempts;
|
int auth_attempts;
|
||||||
bool channel_ready; /* Set when shell/exec request received */
|
bool channel_ready; /* Set when shell/exec request received */
|
||||||
|
|
@ -294,8 +295,13 @@ static int channel_exec_request(ssh_session session, ssh_channel channel,
|
||||||
|
|
||||||
/* Store exec command */
|
/* Store exec command */
|
||||||
if (command) {
|
if (command) {
|
||||||
strncpy(ctx->exec_command, command, sizeof(ctx->exec_command) - 1);
|
if (strlen(command) >= sizeof(ctx->exec_command)) {
|
||||||
ctx->exec_command[sizeof(ctx->exec_command) - 1] = '\0';
|
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 */
|
/* Mark channel as ready */
|
||||||
|
|
@ -363,6 +369,7 @@ void *bootstrap_run(void *arg) {
|
||||||
ctx->pty_width = 80;
|
ctx->pty_width = 80;
|
||||||
ctx->pty_height = 24;
|
ctx->pty_height = 24;
|
||||||
ctx->exec_command[0] = '\0';
|
ctx->exec_command[0] = '\0';
|
||||||
|
ctx->exec_command_too_long = false;
|
||||||
ctx->requested_user[0] = '\0';
|
ctx->requested_user[0] = '\0';
|
||||||
ctx->auth_success = false;
|
ctx->auth_success = false;
|
||||||
ctx->auth_attempts = 0;
|
ctx->auth_attempts = 0;
|
||||||
|
|
@ -451,6 +458,7 @@ void *bootstrap_run(void *arg) {
|
||||||
client->ref_count = 1;
|
client->ref_count = 1;
|
||||||
pthread_mutex_init(&client->ref_lock, NULL);
|
pthread_mutex_init(&client->ref_lock, NULL);
|
||||||
pthread_mutex_init(&client->io_lock, NULL);
|
pthread_mutex_init(&client->io_lock, NULL);
|
||||||
|
pthread_mutex_init(&client->whisper_lock, NULL);
|
||||||
|
|
||||||
if (ctx->requested_user[0] != '\0') {
|
if (ctx->requested_user[0] != '\0') {
|
||||||
strncpy(client->ssh_login, ctx->requested_user,
|
strncpy(client->ssh_login, ctx->requested_user,
|
||||||
|
|
@ -466,18 +474,14 @@ void *bootstrap_run(void *arg) {
|
||||||
sizeof(client->exec_command) - 1);
|
sizeof(client->exec_command) - 1);
|
||||||
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
client->exec_command[sizeof(client->exec_command) - 1] = '\0';
|
||||||
}
|
}
|
||||||
|
client->exec_command_too_long = ctx->exec_command_too_long;
|
||||||
/* Add a ref for the channel callbacks (eof/close/window_change) so the
|
|
||||||
* client_t outlives any in-flight callback invocation. */
|
|
||||||
client_addref(client);
|
|
||||||
|
|
||||||
if (client_install_channel_callbacks(client) < 0) {
|
if (client_install_channel_callbacks(client) < 0) {
|
||||||
/* Nullify session/channel ownership so client_release won't
|
/* Nullify session/channel ownership so client_release won't
|
||||||
* double-free what cleanup_failed_session is about to free. */
|
* double-free what cleanup_failed_session is about to free. */
|
||||||
client->session = NULL;
|
client->session = NULL;
|
||||||
client->channel = NULL;
|
client->channel = NULL;
|
||||||
client_release(client); /* drop the callback ref (2 → 1) */
|
client_release(client);
|
||||||
client_release(client); /* drop the main ref (1 → 0, frees client) */
|
|
||||||
cleanup_failed_session(session, ctx);
|
cleanup_failed_session(session, ctx);
|
||||||
return NULL;
|
return NULL;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,22 +1,11 @@
|
||||||
#include "chat_room.h"
|
#include "chat_room.h"
|
||||||
|
#include "config_defaults.h"
|
||||||
|
|
||||||
/* Global chat room instance */
|
/* Global chat room instance */
|
||||||
chat_room_t *g_room = NULL;
|
chat_room_t *g_room = NULL;
|
||||||
|
|
||||||
static int room_capacity_from_env(void) {
|
static int room_capacity_from_env(void) {
|
||||||
const char *env = getenv("TNT_MAX_CONNECTIONS");
|
return tnt_config_env_int(&TNT_CONFIG_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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize chat room */
|
/* Initialize chat room */
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
#include "cli_text.h"
|
#include "cli_text.h"
|
||||||
|
|
||||||
|
#include "config_defaults.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
|
|
||||||
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
|
|
@ -8,42 +9,65 @@ void cli_text_append_help(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"tnt %s - anonymous SSH chat server\n\n"
|
"tnt %s - anonymous SSH chat server\n\n"
|
||||||
"Usage: %s [options]\n\n"
|
"Usage: %s [options]\n\n"
|
||||||
"Options:\n"
|
"Options:\n"
|
||||||
" -p, --port PORT Listen on PORT (default: %d)\n"
|
" -p, --port PORT Listen on PORT (default: %d)\n"
|
||||||
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
" -d, --state-dir DIR Store host key and logs in DIR\n"
|
||||||
" -V, --version Show version\n"
|
" --bind ADDR Bind to ADDR (default: 0.0.0.0)\n"
|
||||||
" -h, --help Show this help\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"
|
"\n"
|
||||||
"Environment:\n"
|
"Environment:\n"
|
||||||
" PORT Default listening port\n"
|
" PORT Default listening port\n"
|
||||||
" TNT_STATE_DIR State directory\n"
|
" TNT_STATE_DIR State directory\n"
|
||||||
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
|
" TNT_ACCESS_TOKEN Require this password for SSH auth\n"
|
||||||
" TNT_LANG UI language: en or zh (default: locale)\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_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"
|
"tnt %s - 匿名 SSH 聊天服务器\n\n"
|
||||||
"用法: %s [options]\n\n"
|
"用法: %s [options]\n\n"
|
||||||
"选项:\n"
|
"选项:\n"
|
||||||
" -p, --port PORT 监听 PORT (默认: %d)\n"
|
" -p, --port PORT 监听 PORT (默认: %d)\n"
|
||||||
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
|
" -d, --state-dir DIR 将主机密钥和日志存放在 DIR\n"
|
||||||
" -V, --version 显示版本\n"
|
" --bind ADDR 绑定到 ADDR (默认: 0.0.0.0)\n"
|
||||||
" -h, --help 显示此帮助\n"
|
" --public-host HOST 在启动提示中显示 HOST\n"
|
||||||
|
" --max-connections N 全局连接数限制 (默认: %d)\n"
|
||||||
|
" --max-conn-per-ip N 单 IP 并发会话限制\n"
|
||||||
|
" --max-conn-rate-per-ip N 单 IP 连接速率限制\n"
|
||||||
|
" --rate-limit 0|1 禁用/启用速率封禁\n"
|
||||||
|
" --idle-timeout SECONDS 空闲断开时间\n"
|
||||||
|
" --ssh-log-level LEVEL libssh 日志级别 0..4\n"
|
||||||
|
" --log-check FILE 检查 messages.log v1 记录\n"
|
||||||
|
" --log-recover FILE 将有效记录写入 stdout\n"
|
||||||
|
" -V, --version 显示版本\n"
|
||||||
|
" -h, --help 显示此帮助\n"
|
||||||
"\n"
|
"\n"
|
||||||
"环境变量:\n"
|
"环境变量:\n"
|
||||||
" PORT 默认监听端口\n"
|
" PORT 默认监听端口\n"
|
||||||
" TNT_STATE_DIR 状态目录\n"
|
" TNT_STATE_DIR 状态目录\n"
|
||||||
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
|
" TNT_ACCESS_TOKEN 要求 SSH 认证使用此密码\n"
|
||||||
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
|
" TNT_LANG UI 语言: en 或 zh (默认跟随 locale)\n"
|
||||||
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: 64)\n"
|
" TNT_MAX_CONNECTIONS 全局连接数限制 (默认: %d)\n"
|
||||||
" TNT_RATE_LIMIT 设为 0 可禁用速率限制\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')
|
const char *program = (program_name && program_name[0] != '\0')
|
||||||
? program_name
|
? program_name
|
||||||
: "tnt";
|
: "tnt";
|
||||||
|
|
||||||
buffer_appendf(buffer, buf_size, pos, i18n_string(help_format, lang),
|
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) {
|
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);
|
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) {
|
const char *cli_text_unknown_option_format(ui_lang_t lang) {
|
||||||
static const i18n_string_t text =
|
static const i18n_string_t text =
|
||||||
I18N_STRING("Unknown option: %s\n", "未知选项: %s\n");
|
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) {
|
const char *cli_text_short_usage_format(ui_lang_t lang) {
|
||||||
static const i18n_string_t text =
|
static const i18n_string_t text =
|
||||||
I18N_STRING("Usage: %s [-p PORT] [-d DIR] [-h]\n",
|
I18N_STRING("Usage: %s [options]\n",
|
||||||
"用法: %s [-p PORT] [-d DIR] [-h]\n");
|
"用法: %s [options]\n");
|
||||||
return i18n_string(text, lang);
|
return i18n_string(text, lang);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
218
src/client.c
218
src/client.c
|
|
@ -9,11 +9,139 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <stdlib.h>
|
#include <stdlib.h>
|
||||||
|
|
||||||
/* Send data to client via SSH channel */
|
static int client_send_fail(client_t *client) {
|
||||||
int client_send(client_t *client, const char *data, size_t len) {
|
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;
|
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 (!client || !data) return -1;
|
||||||
|
if (len == 0) return 0;
|
||||||
|
|
||||||
pthread_mutex_lock(&client->io_lock);
|
pthread_mutex_lock(&client->io_lock);
|
||||||
|
|
||||||
|
|
@ -22,23 +150,57 @@ int client_send(client_t *client, const char *data, size_t len) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
while (total < len) {
|
if (client_is_exec(client)) {
|
||||||
size_t remaining = len - total;
|
rc = client_write_direct_locked(client, data, len, 0, true);
|
||||||
uint32_t chunk = (remaining > 32768) ? 32768 : (uint32_t)remaining;
|
if (rc >= 0 && (size_t)rc == len) {
|
||||||
int sent = ssh_channel_write(client->channel, data + total, chunk);
|
rc = 0;
|
||||||
if (sent <= 0) {
|
} else if (rc >= 0) {
|
||||||
pthread_mutex_unlock(&client->io_lock);
|
rc = client_send_fail(client);
|
||||||
return -1;
|
|
||||||
}
|
}
|
||||||
total += (size_t)sent;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (client->exec_command[0] != '\0') {
|
|
||||||
ssh_blocking_flush(client->session, 1000);
|
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);
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
return 0;
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
int client_flush_output(client_t *client) {
|
||||||
|
int rc;
|
||||||
|
|
||||||
|
if (!client) return 0;
|
||||||
|
|
||||||
|
pthread_mutex_lock(&client->io_lock);
|
||||||
|
|
||||||
|
if (!client->connected || !client->channel) {
|
||||||
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rc = client_flush_output_locked(client, CLIENT_OUTBOX_FLUSH_BUDGET);
|
||||||
|
pthread_mutex_unlock(&client->io_lock);
|
||||||
|
return rc;
|
||||||
|
}
|
||||||
|
|
||||||
|
void client_queue_bell(client_t *client) {
|
||||||
|
if (!client) return;
|
||||||
|
|
||||||
|
atomic_store(&client->pending_bells, 1);
|
||||||
|
client->redraw_pending = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
int client_flush_pending_bells(client_t *client) {
|
||||||
|
if (!client) return 0;
|
||||||
|
|
||||||
|
if (atomic_exchange(&client->pending_bells, 0) <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
return client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
void client_addref(client_t *client) {
|
void client_addref(client_t *client) {
|
||||||
|
|
@ -74,12 +236,33 @@ void client_release(client_t *client) {
|
||||||
if (client->channel_cb) {
|
if (client->channel_cb) {
|
||||||
free(client->channel_cb);
|
free(client->channel_cb);
|
||||||
}
|
}
|
||||||
|
free(client->outbox);
|
||||||
pthread_mutex_destroy(&client->io_lock);
|
pthread_mutex_destroy(&client->io_lock);
|
||||||
|
pthread_mutex_destroy(&client->whisper_lock);
|
||||||
pthread_mutex_destroy(&client->ref_lock);
|
pthread_mutex_destroy(&client->ref_lock);
|
||||||
free(client);
|
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 */
|
/* Send formatted string to client */
|
||||||
int client_printf(client_t *client, const char *fmt, ...) {
|
int client_printf(client_t *client, const char *fmt, ...) {
|
||||||
char buffer[2048];
|
char buffer[2048];
|
||||||
|
|
@ -151,8 +334,13 @@ int client_install_channel_callbacks(client_t *client) {
|
||||||
return -1;
|
return -1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
client_addref(client);
|
||||||
|
client->channel_callback_ref = true;
|
||||||
|
|
||||||
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
|
client->channel_cb = calloc(1, sizeof(struct ssh_channel_callbacks_struct));
|
||||||
if (!client->channel_cb) {
|
if (!client->channel_cb) {
|
||||||
|
client->channel_callback_ref = false;
|
||||||
|
client_release(client);
|
||||||
return -1;
|
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) {
|
if (ssh_set_channel_callbacks(client->channel, client->channel_cb) != SSH_OK) {
|
||||||
free(client->channel_cb);
|
free(client->channel_cb);
|
||||||
client->channel_cb = NULL;
|
client->channel_cb = NULL;
|
||||||
|
client->channel_callback_ref = false;
|
||||||
|
client_release(client);
|
||||||
return -1;
|
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);
|
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) {
|
void commands_dispatch(client_t *client) {
|
||||||
char cmd_buf[256];
|
char cmd_buf[256];
|
||||||
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
|
strncpy(cmd_buf, client->command_input, sizeof(cmd_buf) - 1);
|
||||||
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
|
cmd_buf[sizeof(cmd_buf) - 1] = '\0';
|
||||||
char *cmd = cmd_buf;
|
char *cmd = cmd_buf;
|
||||||
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
char output[MAX_COMMAND_OUTPUT_LEN] = {0};
|
||||||
|
tnt_command_output_kind_t output_kind = TNT_COMMAND_OUTPUT_GENERIC;
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
||||||
/* Trim whitespace */
|
/* Trim whitespace */
|
||||||
|
|
@ -70,6 +118,10 @@ void commands_dispatch(client_t *client) {
|
||||||
end--;
|
end--;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if (cmd[0] == ':') {
|
||||||
|
cmd++;
|
||||||
|
while (*cmd == ' ') cmd++;
|
||||||
|
}
|
||||||
|
|
||||||
/* Save to command history */
|
/* Save to command history */
|
||||||
if (cmd[0] != '\0') {
|
if (cmd[0] != '\0') {
|
||||||
|
|
@ -199,9 +251,9 @@ void commands_dispatch(client_t *client) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
|
||||||
if (target) {
|
if (target) {
|
||||||
/* Push into recipient's inbox. io_lock serialises so two
|
/* Push into recipient's inbox. whisper_lock serialises so
|
||||||
* senders to the same recipient don't tear the ring. */
|
* two senders to the same recipient don't tear the ring. */
|
||||||
pthread_mutex_lock(&target->io_lock);
|
pthread_mutex_lock(&target->whisper_lock);
|
||||||
int slot;
|
int slot;
|
||||||
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
if (target->whisper_inbox_count < WHISPER_INBOX_SIZE) {
|
||||||
slot = target->whisper_inbox_count++;
|
slot = target->whisper_inbox_count++;
|
||||||
|
|
@ -219,13 +271,12 @@ void commands_dispatch(client_t *client) {
|
||||||
snprintf(target->whisper_inbox[slot].content,
|
snprintf(target->whisper_inbox[slot].content,
|
||||||
sizeof(target->whisper_inbox[slot].content),
|
sizeof(target->whisper_inbox[slot].content),
|
||||||
"%s", rest);
|
"%s", rest);
|
||||||
pthread_mutex_unlock(&target->io_lock);
|
|
||||||
|
|
||||||
target->unread_whispers++;
|
target->unread_whispers++;
|
||||||
target->redraw_pending = true;
|
pthread_mutex_unlock(&target->whisper_lock);
|
||||||
|
|
||||||
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
/* Audible nudge — the title bar ✉ counter (UX-11 style)
|
||||||
* carries the persistent signal. */
|
* carries the persistent signal. */
|
||||||
client_send(target, "\a", 1);
|
client_queue_bell(target);
|
||||||
client_release(target);
|
client_release(target);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -243,35 +294,8 @@ void commands_dispatch(client_t *client) {
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if (command_id == TNT_COMMAND_INBOX) {
|
} else if (command_id == TNT_COMMAND_INBOX) {
|
||||||
/* Snapshot the inbox under io_lock so a concurrent sender doesn't
|
output_kind = TNT_COMMAND_OUTPUT_INBOX;
|
||||||
* tear what we're rendering. Counter reset happens after copy. */
|
append_inbox_output(client, output, sizeof(output), &pos);
|
||||||
whisper_t snapshot[WHISPER_INBOX_SIZE];
|
|
||||||
int snap_count;
|
|
||||||
pthread_mutex_lock(&client->io_lock);
|
|
||||||
snap_count = client->whisper_inbox_count;
|
|
||||||
memcpy(snapshot, client->whisper_inbox,
|
|
||||||
snap_count * sizeof(whisper_t));
|
|
||||||
pthread_mutex_unlock(&client->io_lock);
|
|
||||||
client->unread_whispers = 0;
|
|
||||||
|
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
|
||||||
"\033[1;36m%s\033[0m \033[2;37m· %d\033[0m\n",
|
|
||||||
i18n_text(client->ui_lang, I18N_INBOX_TITLE),
|
|
||||||
snap_count);
|
|
||||||
if (snap_count == 0) {
|
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
|
||||||
" \033[2;37m%s\033[0m\n",
|
|
||||||
i18n_text(client->ui_lang, I18N_INBOX_EMPTY));
|
|
||||||
}
|
|
||||||
for (int i = 0; i < snap_count; i++) {
|
|
||||||
char ts[20];
|
|
||||||
struct tm tmi;
|
|
||||||
localtime_r(&snapshot[i].timestamp, &tmi);
|
|
||||||
strftime(ts, sizeof(ts), "%m-%d %H:%M", &tmi);
|
|
||||||
buffer_appendf(output, sizeof(output), &pos,
|
|
||||||
" \033[90m%s\033[0m \033[35m%s\033[0m: %s\n",
|
|
||||||
ts, snapshot[i].from, snapshot[i].content);
|
|
||||||
}
|
|
||||||
|
|
||||||
} else if (command_id == TNT_COMMAND_NICK) {
|
} else if (command_id == TNT_COMMAND_NICK) {
|
||||||
const char *new_name = arg;
|
const char *new_name = arg;
|
||||||
|
|
@ -415,6 +439,7 @@ void commands_dispatch(client_t *client) {
|
||||||
cmd_done:
|
cmd_done:
|
||||||
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
|
snprintf(client->command_output, sizeof(client->command_output), "%s", output);
|
||||||
client->command_output_scroll = 0;
|
client->command_output_scroll = 0;
|
||||||
|
client->command_output_kind = output_kind;
|
||||||
client->command_input[0] = '\0';
|
client->command_input[0] = '\0';
|
||||||
tui_render_command_output(client);
|
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';
|
help_text[0] = '\0';
|
||||||
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
exec_catalog_append_help(help_text, sizeof(help_text), &pos,
|
||||||
client->ui_lang);
|
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) {
|
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,
|
exec_catalog_append_usage(usage, sizeof(usage), &pos, id,
|
||||||
client->ui_lang);
|
client->ui_lang);
|
||||||
client_printf(client, "%s", usage);
|
client_printf(client, "%s", usage);
|
||||||
return 64;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
static int exec_command_health(client_t *client) {
|
static int exec_command_health(client_t *client) {
|
||||||
static const char ok[] = "ok\n";
|
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) {
|
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) {
|
if (!usernames) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
client_printf(client, "users: out of memory\n");
|
client_printf(client, "users: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
for (int i = 0; i < count; i++) {
|
||||||
|
|
@ -177,7 +179,7 @@ static int exec_command_users(client_t *client, bool json) {
|
||||||
if (!output) {
|
if (!output) {
|
||||||
free(usernames);
|
free(usernames);
|
||||||
client_printf(client, "users: out of memory\n");
|
client_printf(client, "users: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (json) {
|
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(output);
|
||||||
free(usernames);
|
free(usernames);
|
||||||
return rc;
|
return rc;
|
||||||
|
|
@ -243,10 +245,11 @@ static int exec_command_stats(client_t *client, bool json) {
|
||||||
|
|
||||||
if (len < 0 || len >= (int)sizeof(buffer)) {
|
if (len < 0 || len >= (int)sizeof(buffer)) {
|
||||||
client_printf(client, "stats: output overflow\n");
|
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) {
|
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;
|
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) {
|
static int exec_command_tail(client_t *client, const char *args) {
|
||||||
int requested = 20;
|
int requested = 20;
|
||||||
int total_messages;
|
int total_messages;
|
||||||
|
|
@ -316,7 +358,7 @@ static int exec_command_tail(client_t *client, const char *args) {
|
||||||
if (!snapshot) {
|
if (!snapshot) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
client_printf(client, "tail: out of memory\n");
|
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));
|
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) {
|
if (!output) {
|
||||||
free(snapshot);
|
free(snapshot);
|
||||||
client_printf(client, "tail: out of memory\n");
|
client_printf(client, "tail: out of memory\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (int i = 0; i < count; i++) {
|
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);
|
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(output);
|
||||||
free(snapshot);
|
free(snapshot);
|
||||||
return rc;
|
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) {
|
static int exec_command_post(client_t *client, const char *args) {
|
||||||
char content[MAX_MESSAGE_LEN];
|
char content[MAX_MESSAGE_LEN];
|
||||||
char username[MAX_USERNAME_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);
|
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);
|
strncpy(content, args, sizeof(content) - 1);
|
||||||
content[sizeof(content) - 1] = '\0';
|
content[sizeof(content) - 1] = '\0';
|
||||||
trim_ascii_whitespace(content);
|
trim_ascii_whitespace(content);
|
||||||
|
|
@ -362,14 +431,14 @@ static int exec_command_post(client_t *client, const char *args) {
|
||||||
if (content[0] == '\0') {
|
if (content[0] == '\0') {
|
||||||
client_printf(client, "%s",
|
client_printf(client, "%s",
|
||||||
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
i18n_text(client->ui_lang, I18N_EXEC_POST_EMPTY));
|
||||||
return 64;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!utf8_is_valid_string(content)) {
|
if (!utf8_is_valid_string(content)) {
|
||||||
client_printf(client, "%s",
|
client_printf(client, "%s",
|
||||||
i18n_text(client->ui_lang,
|
i18n_text(client->ui_lang,
|
||||||
I18N_EXEC_POST_INVALID_UTF8));
|
I18N_EXEC_POST_INVALID_UTF8));
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
resolve_exec_username(client, username, sizeof(username));
|
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';
|
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) {
|
if (message_save(&msg) < 0) {
|
||||||
fprintf(stderr, "post: failed to persist message\n");
|
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) {
|
int exec_dispatch(client_t *client) {
|
||||||
|
|
@ -407,6 +480,13 @@ int exec_dispatch(client_t *client) {
|
||||||
tnt_exec_command_id_t command_id;
|
tnt_exec_command_id_t command_id;
|
||||||
const char *args = NULL;
|
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);
|
strncpy(command_copy, client->exec_command, sizeof(command_copy) - 1);
|
||||||
command_copy[sizeof(command_copy) - 1] = '\0';
|
command_copy[sizeof(command_copy) - 1] = '\0';
|
||||||
trim_ascii_whitespace(command_copy);
|
trim_ascii_whitespace(command_copy);
|
||||||
|
|
@ -431,10 +511,14 @@ int exec_dispatch(client_t *client) {
|
||||||
return exec_command_stats(client, args != NULL);
|
return exec_command_stats(client, args != NULL);
|
||||||
case TNT_EXEC_COMMAND_TAIL:
|
case TNT_EXEC_COMMAND_TAIL:
|
||||||
return exec_command_tail(client, args);
|
return exec_command_tail(client, args);
|
||||||
|
case TNT_EXEC_COMMAND_DUMP:
|
||||||
|
return exec_command_dump(client, args);
|
||||||
case TNT_EXEC_COMMAND_POST:
|
case TNT_EXEC_COMMAND_POST:
|
||||||
return exec_command_post(client, args);
|
return exec_command_post(client, args);
|
||||||
case TNT_EXEC_COMMAND_EXIT:
|
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_text(client->ui_lang,
|
||||||
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
command_copy);
|
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",
|
"tail -n N", "tail [N] | tail -n N",
|
||||||
I18N_STRING("Print recent messages", "输出最近消息"),
|
I18N_STRING("Print recent messages", "输出最近消息"),
|
||||||
false, false, false},
|
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,
|
{TNT_EXEC_COMMAND_POST, "post", NULL,
|
||||||
"post MESSAGE", "post MESSAGE",
|
"post MESSAGE", "post MESSAGE",
|
||||||
I18N_STRING("Post a message non-interactively", "非交互发送消息"),
|
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,
|
void exec_catalog_append_usage(char *buffer, size_t buf_size, size_t *pos,
|
||||||
tnt_exec_command_id_t id, ui_lang_t lang) {
|
tnt_exec_command_id_t id, ui_lang_t lang) {
|
||||||
const exec_catalog_entry_t *entry = entry_for_id(id);
|
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"
|
" Backspace - Delete character\n"
|
||||||
" Ctrl+W - Delete last word\n"
|
" Ctrl+W - Delete last word\n"
|
||||||
" Ctrl+U - Delete line\n"
|
" Ctrl+U - Delete line\n"
|
||||||
|
" Up/Down - Recall sent messages\n"
|
||||||
|
" Tab - Complete @mention\n"
|
||||||
" Ctrl+C - Enter NORMAL mode\n"
|
" Ctrl+C - Enter NORMAL mode\n"
|
||||||
"\n"
|
"\n"
|
||||||
"NORMAL MODE KEYS:\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"
|
" Follows latest until you scroll up\n"
|
||||||
" i - Return to INSERT mode\n"
|
" i - Return to INSERT mode\n"
|
||||||
" : - Enter COMMAND mode\n"
|
" : - Enter COMMAND mode\n"
|
||||||
|
" / - Search message history\n"
|
||||||
" j/k - Scroll down/up one line\n"
|
" j/k - Scroll down/up one line\n"
|
||||||
" Ctrl+D/U - Scroll half page down/up\n"
|
" Ctrl+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full 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"
|
" Backspace - 删除字符\n"
|
||||||
" Ctrl+W - 删除上个单词\n"
|
" Ctrl+W - 删除上个单词\n"
|
||||||
" Ctrl+U - 删除整行\n"
|
" Ctrl+U - 删除整行\n"
|
||||||
|
" Up/Down - 调出已发送消息\n"
|
||||||
|
" Tab - 补全 @mention\n"
|
||||||
" Ctrl+C - 进入 NORMAL 模式\n"
|
" Ctrl+C - 进入 NORMAL 模式\n"
|
||||||
"\n"
|
"\n"
|
||||||
"NORMAL 模式按键:\n"
|
"NORMAL 模式按键:\n"
|
||||||
|
|
@ -56,6 +61,7 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
" 未向上翻阅时自动跟随最新消息\n"
|
" 未向上翻阅时自动跟随最新消息\n"
|
||||||
" i - 返回 INSERT 模式\n"
|
" i - 返回 INSERT 模式\n"
|
||||||
" : - 进入 COMMAND 模式\n"
|
" : - 进入 COMMAND 模式\n"
|
||||||
|
" / - 搜索消息历史\n"
|
||||||
" j/k - 向下/上滚动一行\n"
|
" j/k - 向下/上滚动一行\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
|
@ -71,10 +77,14 @@ void help_text_append_full(char *buffer, size_t buf_size, size_t *pos,
|
||||||
"\n"
|
"\n"
|
||||||
"COMMAND OUTPUT KEYS:\n"
|
"COMMAND OUTPUT KEYS:\n"
|
||||||
" q, ESC - Close output\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+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full 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"
|
" g/G - Jump to top/bottom\n"
|
||||||
|
" r - Refresh live output (:inbox)\n"
|
||||||
"\n"
|
"\n"
|
||||||
"SPECIAL MESSAGES:\n"
|
"SPECIAL MESSAGES:\n"
|
||||||
" /me <action> - Send action (e.g. /me waves)\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"
|
"\n"
|
||||||
"HELP SCREEN KEYS:\n"
|
"HELP SCREEN KEYS:\n"
|
||||||
" q, ESC - Close help\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+D/U - Scroll half page down/up\n"
|
||||||
" Ctrl+F/B - Scroll full 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"
|
" g/G - Jump to top/bottom\n"
|
||||||
" l - Cycle UI language\n",
|
" l - Cycle UI language\n",
|
||||||
"\n"
|
"\n"
|
||||||
"命令输出按键:\n"
|
"命令输出按键:\n"
|
||||||
" q, ESC - 关闭输出\n"
|
" q, ESC - 关闭输出\n"
|
||||||
" j/k - 向下/上滚动\n"
|
" j/k, arrows - 向下/上滚动\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
" Space/b - 向下/上滚动整页\n"
|
||||||
|
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||||
|
" End/Home - 跳到底部/顶部\n"
|
||||||
" g/G - 跳到顶部/底部\n"
|
" g/G - 跳到顶部/底部\n"
|
||||||
|
" r - 刷新动态输出 (:inbox)\n"
|
||||||
"\n"
|
"\n"
|
||||||
"特殊消息:\n"
|
"特殊消息:\n"
|
||||||
" /me <action> - 发送动作 (如 /me waves)\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"
|
||||||
"帮助界面按键:\n"
|
"帮助界面按键:\n"
|
||||||
" q, ESC - 关闭帮助\n"
|
" q, ESC - 关闭帮助\n"
|
||||||
" j/k - 向下/上滚动\n"
|
" j/k, arrows - 向下/上滚动\n"
|
||||||
" Ctrl+D/U - 向下/上滚动半页\n"
|
" Ctrl+D/U - 向下/上滚动半页\n"
|
||||||
" Ctrl+F/B - 向下/上滚动整页\n"
|
" Ctrl+F/B - 向下/上滚动整页\n"
|
||||||
|
" Space/b - 向下/上滚动整页\n"
|
||||||
|
" PgDn/PgUp - 向下/上滚动整页\n"
|
||||||
|
" End/Home - 跳到底部/顶部\n"
|
||||||
" g/G - 跳到顶部/底部\n"
|
" g/G - 跳到顶部/底部\n"
|
||||||
" l - 切换界面语言\n"
|
" l - 切换界面语言\n"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -26,12 +26,12 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"TNT %s - SSH 匿名聊天室\r\n\r\n"
|
"TNT %s - SSH 匿名聊天室\r\n\r\n"
|
||||||
),
|
),
|
||||||
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
|
[I18N_INSERT_HINT_WIDE] = I18N_STRING(
|
||||||
"Enter send · Esc browse · :help",
|
"Enter send · Esc NORMAL",
|
||||||
"Enter 发送 · Esc 浏览 · :help"
|
"Enter 发送 · Esc NORMAL"
|
||||||
),
|
),
|
||||||
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
|
[I18N_INSERT_HINT_NARROW] = I18N_STRING(
|
||||||
"Enter · Esc · :help",
|
"Enter · Esc",
|
||||||
"Enter · Esc · :help"
|
"Enter · Esc"
|
||||||
),
|
),
|
||||||
[I18N_NORMAL_LATEST] = I18N_STRING(
|
[I18N_NORMAL_LATEST] = I18N_STRING(
|
||||||
"G latest",
|
"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",
|
"-- 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:关闭"
|
"-- 命令输出 -- (%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(
|
[I18N_MOTD_TITLE] = I18N_STRING(
|
||||||
" NOTICE ",
|
" NOTICE ",
|
||||||
" 公告 "
|
" 公告 "
|
||||||
|
|
@ -138,8 +142,8 @@ static const i18n_string_t text_catalog[I18N_TEXT_COUNT] = {
|
||||||
"--- 最近 %d 条消息 ---\n"
|
"--- 最近 %d 条消息 ---\n"
|
||||||
),
|
),
|
||||||
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
[I18N_SEARCH_HEADER_FORMAT] = I18N_STRING(
|
||||||
"--- Search: \"%s\" (%d match(es)) ---\n",
|
"--- Search: \"%s\" (showing last %d match(es)) ---\n",
|
||||||
"--- 搜索: \"%s\" (%d 条匹配) ---\n"
|
"--- 搜索: \"%s\" (显示最近 %d 条匹配) ---\n"
|
||||||
),
|
),
|
||||||
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
[I18N_MUTE_JOINS_FORMAT] = I18N_STRING(
|
||||||
"Join/leave notifications: %s\n",
|
"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: invalid UTF-8 input\n",
|
||||||
"post: 输入不是有效 UTF-8\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(
|
[I18N_EXEC_UNKNOWN_COMMAND_FORMAT] = I18N_STRING(
|
||||||
"Unknown command: %s\n",
|
"Unknown command: %s\n",
|
||||||
"未知命令: %s\n"
|
"未知命令: %s\n"
|
||||||
|
|
|
||||||
288
src/input.c
288
src/input.c
|
|
@ -2,6 +2,7 @@
|
||||||
#include "chat_room.h"
|
#include "chat_room.h"
|
||||||
#include "client.h"
|
#include "client.h"
|
||||||
#include "commands.h"
|
#include "commands.h"
|
||||||
|
#include "config_defaults.h"
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
#include "exec.h"
|
#include "exec.h"
|
||||||
#include "history_view.h"
|
#include "history_view.h"
|
||||||
|
|
@ -20,11 +21,11 @@
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <time.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;
|
static ui_lang_t g_default_ui_lang = UI_LANG_EN;
|
||||||
|
|
||||||
void input_init(void) {
|
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();
|
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};
|
char username[MAX_USERNAME_LEN] = {0};
|
||||||
int pos = 0;
|
int pos = 0;
|
||||||
char buf[4];
|
char buf[4];
|
||||||
|
const char *prompt = i18n_text(client->ui_lang, I18N_USERNAME_PROMPT);
|
||||||
|
|
||||||
tui_render_welcome(client);
|
tui_render_welcome(client);
|
||||||
client_printf(client, "%s", i18n_text(client->ui_lang,
|
client_printf(client, "%s", prompt);
|
||||||
I18N_USERNAME_PROMPT));
|
|
||||||
|
|
||||||
while (1) {
|
while (1) {
|
||||||
int n = ssh_channel_read_timeout(client->channel, buf, 1, 0, 60000); /* 60 sec timeout */
|
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') {
|
if (b == '\r' || b == '\n') {
|
||||||
break;
|
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 */
|
} else if (b == 127 || b == 8) { /* Backspace */
|
||||||
if (pos > 0) {
|
if (pos > 0) {
|
||||||
/* Compute width of the last character before removing it */
|
/* 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) {
|
void notify_mentions(const char *content, const client_t *sender) {
|
||||||
pthread_rwlock_rdlock(&g_room->lock);
|
pthread_rwlock_rdlock(&g_room->lock);
|
||||||
int count = g_room->client_count;
|
int count = g_room->client_count;
|
||||||
client_t *targets[MAX_CLIENTS];
|
client_t **targets = NULL;
|
||||||
int target_count = 0;
|
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++) {
|
for (int i = 0; i < count; i++) {
|
||||||
client_t *c = g_room->clients[i];
|
client_t *c = g_room->clients[i];
|
||||||
if (c == sender) continue;
|
if (c == sender) continue;
|
||||||
|
|
@ -150,11 +171,11 @@ void notify_mentions(const char *content, const client_t *sender) {
|
||||||
pthread_rwlock_unlock(&g_room->lock);
|
pthread_rwlock_unlock(&g_room->lock);
|
||||||
|
|
||||||
for (int i = 0; i < target_count; i++) {
|
for (int i = 0; i < target_count; i++) {
|
||||||
client_send(targets[i], "\a", 1);
|
|
||||||
targets[i]->unread_mentions++;
|
targets[i]->unread_mentions++;
|
||||||
targets[i]->redraw_pending = true;
|
client_queue_bell(targets[i]);
|
||||||
client_release(targets[i]);
|
client_release(targets[i]);
|
||||||
}
|
}
|
||||||
|
free(targets);
|
||||||
}
|
}
|
||||||
|
|
||||||
static int read_channel_exact(client_t *client, char *buf, size_t len,
|
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;
|
was_motd = client->show_motd;
|
||||||
client->command_output[0] = '\0';
|
client->command_output[0] = '\0';
|
||||||
client->command_output_scroll = 0;
|
client->command_output_scroll = 0;
|
||||||
|
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||||
client->show_motd = false;
|
client->show_motd = false;
|
||||||
client->mode = MODE_NORMAL;
|
|
||||||
if (was_motd) {
|
if (was_motd) {
|
||||||
|
client->mode = MODE_INSERT;
|
||||||
|
client->follow_tail = true;
|
||||||
|
client->unread_mentions = 0;
|
||||||
normal_scroll_to_latest(client);
|
normal_scroll_to_latest(client);
|
||||||
|
} else {
|
||||||
|
client->mode = MODE_NORMAL;
|
||||||
}
|
}
|
||||||
tui_render_screen(client);
|
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
|
/* Handle a single key press. Returns true if the key was fully consumed
|
||||||
* (no further character buffering needed). */
|
* (no further character buffering needed). */
|
||||||
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
/* Handle Ctrl+C (Exit or switch to NORMAL) */
|
/* Handle Ctrl+C (Exit or switch to NORMAL) */
|
||||||
if (key == 3) {
|
if (key == 3) {
|
||||||
client_mode_t previous_mode = client->mode;
|
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') {
|
if (client->command_output[0] != '\0') {
|
||||||
dismiss_command_output(client);
|
dismiss_command_output(client);
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -248,44 +383,20 @@ static bool handle_key(client_t *client, unsigned char key, char *input) {
|
||||||
|
|
||||||
/* Handle help screen */
|
/* Handle help screen */
|
||||||
if (client->show_help) {
|
if (client->show_help) {
|
||||||
/* Page size: roughly the visible help body region. */
|
pager_action_t action;
|
||||||
int page = client->height - 2;
|
|
||||||
if (page < 1) page = 1;
|
|
||||||
int half = page / 2;
|
|
||||||
if (half < 1) half = 1;
|
|
||||||
|
|
||||||
if (key == 'q' || key == 27) {
|
if (key == 'l' || key == 'L') {
|
||||||
client->show_help = false;
|
|
||||||
tui_render_screen(client);
|
|
||||||
} else if (key == 'l' || key == 'L') {
|
|
||||||
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
client->ui_lang = i18n_next_ui_lang(client->ui_lang);
|
||||||
client->help_scroll_pos = 0;
|
client->help_scroll_pos = 0;
|
||||||
tui_render_help(client);
|
tui_render_help(client);
|
||||||
} else if (key == 'j') {
|
return true;
|
||||||
client->help_scroll_pos++;
|
}
|
||||||
tui_render_help(client);
|
|
||||||
} else if (key == 'k' && client->help_scroll_pos > 0) {
|
action = pager_apply_key(client, key, &client->help_scroll_pos, false);
|
||||||
client->help_scroll_pos--;
|
if (action == PAGER_ACTION_CLOSE) {
|
||||||
tui_render_help(client);
|
client->show_help = false;
|
||||||
} else if (key == 4) { /* Ctrl+D: half page down */
|
tui_render_screen(client);
|
||||||
client->help_scroll_pos += half;
|
} else if (action == PAGER_ACTION_SCROLL) {
|
||||||
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 */
|
|
||||||
tui_render_help(client);
|
tui_render_help(client);
|
||||||
}
|
}
|
||||||
return true; /* Key consumed */
|
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;
|
/* Handle command output / MOTD display. MOTD remains a simple notice;
|
||||||
* command output behaves like a small pager so long results can be read. */
|
* command output behaves like a small pager so long results can be read. */
|
||||||
if (client->command_output[0] != '\0') {
|
if (client->command_output[0] != '\0') {
|
||||||
int page = client->height - 2;
|
pager_action_t action;
|
||||||
int half;
|
|
||||||
|
|
||||||
if (client->show_motd) {
|
if (client->show_motd) {
|
||||||
dismiss_command_output(client);
|
dismiss_command_output(client);
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (page < 1) page = 1;
|
action = pager_apply_key(client, key, &client->command_output_scroll,
|
||||||
half = page / 2;
|
true);
|
||||||
if (half < 1) half = 1;
|
if (action == PAGER_ACTION_CLOSE) {
|
||||||
|
|
||||||
if (key == 'q' || key == 27) {
|
|
||||||
dismiss_command_output(client);
|
dismiss_command_output(client);
|
||||||
} else if (key == 'j') {
|
} else if (action == PAGER_ACTION_SCROLL) {
|
||||||
client->command_output_scroll++;
|
|
||||||
tui_render_command_output(client);
|
tui_render_command_output(client);
|
||||||
} else if (key == 'k') {
|
} else if (action == PAGER_ACTION_REFRESH) {
|
||||||
client->command_output_scroll--;
|
if (commands_refresh_active_output(client)) {
|
||||||
if (client->command_output_scroll < 0) {
|
tui_render_command_output(client);
|
||||||
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 */
|
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';
|
client->command_input[0] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
return true;
|
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') {
|
} else if (key == 'j') {
|
||||||
normal_scroll_by(client, 1);
|
normal_scroll_by(client, 1);
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
|
@ -727,11 +814,12 @@ void input_run_session(client_t *client) {
|
||||||
client->command_history_count = 0;
|
client->command_history_count = 0;
|
||||||
client->command_history_pos = 0;
|
client->command_history_pos = 0;
|
||||||
client->command_output_scroll = 0;
|
client->command_output_scroll = 0;
|
||||||
|
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||||
client->connect_time = time(NULL);
|
client->connect_time = time(NULL);
|
||||||
client->last_active = time(NULL);
|
client->last_active = time(NULL);
|
||||||
|
|
||||||
/* Check for exec command */
|
/* 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);
|
int exit_status = exec_dispatch(client);
|
||||||
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
ssh_channel_request_send_exit_status(client->channel, exit_status);
|
||||||
ssh_channel_send_eof(client->channel);
|
ssh_channel_send_eof(client->channel);
|
||||||
|
|
@ -780,6 +868,7 @@ void input_run_session(client_t *client) {
|
||||||
sizeof(client->command_output),
|
sizeof(client->command_output),
|
||||||
"%s", motd_buf);
|
"%s", motd_buf);
|
||||||
client->command_output_scroll = 0;
|
client->command_output_scroll = 0;
|
||||||
|
client->command_output_kind = TNT_COMMAND_OUTPUT_NONE;
|
||||||
client->show_motd = true;
|
client->show_motd = true;
|
||||||
tui_render_motd(client);
|
tui_render_motd(client);
|
||||||
seen_update_seq = room_get_update_seq(g_room);
|
seen_update_seq = room_get_update_seq(g_room);
|
||||||
|
|
@ -797,6 +886,10 @@ main_loop:
|
||||||
|
|
||||||
/* Main input loop */
|
/* Main input loop */
|
||||||
while (client->connected && ssh_channel_is_open(client->channel)) {
|
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);
|
int ready = ssh_channel_poll_timeout(client->channel, 1000, 0);
|
||||||
|
|
||||||
if (ready == SSH_ERROR) {
|
if (ready == SSH_ERROR) {
|
||||||
|
|
@ -811,11 +904,26 @@ main_loop:
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (client_flush_output(client) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (client_flush_pending_bells(client) != 0) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (current_update_seq != seen_update_seq) {
|
if (current_update_seq != seen_update_seq) {
|
||||||
seen_update_seq = current_update_seq;
|
seen_update_seq = current_update_seq;
|
||||||
room_updated = true;
|
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 ||
|
if (client->redraw_pending ||
|
||||||
(room_updated && !client->show_help &&
|
(room_updated && !client->show_help &&
|
||||||
client->command_output[0] == '\0')) {
|
client->command_output[0] == '\0')) {
|
||||||
|
|
@ -920,6 +1028,8 @@ main_loop:
|
||||||
client->command_input[len] = b;
|
client->command_input[len] = b;
|
||||||
client->command_input[len + 1] = '\0';
|
client->command_input[len + 1] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
} else {
|
||||||
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
} else if (b >= 128) { /* UTF-8 multi-byte */
|
} else if (b >= 128) { /* UTF-8 multi-byte */
|
||||||
int char_len = utf8_byte_length(b);
|
int char_len = utf8_byte_length(b);
|
||||||
|
|
@ -932,10 +1042,12 @@ main_loop:
|
||||||
}
|
}
|
||||||
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
if (!utf8_is_valid_sequence(buf, char_len)) continue;
|
||||||
size_t len = strlen(client->command_input);
|
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);
|
memcpy(client->command_input + len, buf, char_len);
|
||||||
client->command_input[len + char_len] = '\0';
|
client->command_input[len + char_len] = '\0';
|
||||||
tui_render_screen(client);
|
tui_render_screen(client);
|
||||||
|
} else {
|
||||||
|
client_send(client, "\a", 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -962,17 +1074,7 @@ cleanup:
|
||||||
|
|
||||||
ratelimit_release_ip(client->client_ip);
|
ratelimit_release_ip(client->client_ip);
|
||||||
|
|
||||||
/* Remove channel callbacks before releasing refs to prevent use-after-free
|
client_release_session(client);
|
||||||
* 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);
|
|
||||||
|
|
||||||
/* Decrement connection count */
|
/* Decrement connection count */
|
||||||
ratelimit_decrement_total();
|
ratelimit_decrement_total();
|
||||||
|
|
|
||||||
215
src/main.c
215
src/main.c
|
|
@ -1,8 +1,10 @@
|
||||||
#include "chat_room.h"
|
#include "chat_room.h"
|
||||||
#include "cli_text.h"
|
#include "cli_text.h"
|
||||||
|
#include "config_defaults.h"
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
#include "message_log_tool.h"
|
||||||
#include "ssh_server.h"
|
#include "ssh_server.h"
|
||||||
#include <signal.h>
|
#include <signal.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
|
|
@ -18,57 +20,212 @@ static void signal_handler(int sig) {
|
||||||
_exit(0);
|
_exit(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
int main(int argc, char **argv) {
|
static bool is_config_token(const char *value) {
|
||||||
int port = DEFAULT_PORT;
|
const unsigned char *p = (const unsigned char *)value;
|
||||||
ui_lang_t lang = i18n_default_ui_lang();
|
|
||||||
|
|
||||||
/* Environment provides defaults; command-line flags override it. */
|
if (!value || value[0] == '\0') {
|
||||||
const char *port_env = getenv("PORT");
|
return false;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
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 */
|
/* Parse command line arguments */
|
||||||
for (int i = 1; i < argc; i++) {
|
for (int i = 1; i < argc; i++) {
|
||||||
if ((strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) &&
|
if (strcmp(argv[i], "-p") == 0 || strcmp(argv[i], "--port") == 0) {
|
||||||
i + 1 < argc) {
|
int val;
|
||||||
char *end;
|
if (!require_option_arg(argc, argv, i, lang)) {
|
||||||
long val = strtol(argv[i + 1], &end, 10);
|
return TNT_EXIT_USAGE;
|
||||||
if (*end != '\0' || val <= 0 || val > 65535) {
|
}
|
||||||
|
if (!tnt_config_parse_int(argv[i + 1], &TNT_CONFIG_PORT, &val)) {
|
||||||
fprintf(stderr, cli_text_invalid_port_format(lang),
|
fprintf(stderr, cli_text_invalid_port_format(lang),
|
||||||
argv[i + 1]);
|
argv[i + 1]);
|
||||||
return 1;
|
return TNT_EXIT_USAGE;
|
||||||
}
|
}
|
||||||
port = (int)val;
|
port = val;
|
||||||
i++;
|
i++;
|
||||||
} else if ((strcmp(argv[i], "-d") == 0 ||
|
} else if (strcmp(argv[i], "-d") == 0 ||
|
||||||
strcmp(argv[i], "--state-dir") == 0) && i + 1 < argc) {
|
strcmp(argv[i], "--state-dir") == 0) {
|
||||||
if (setenv("TNT_STATE_DIR", argv[i + 1], 1) != 0) {
|
if (!require_option_arg(argc, argv, i, lang)) {
|
||||||
perror("setenv TNT_STATE_DIR");
|
return TNT_EXIT_USAGE;
|
||||||
return 1;
|
}
|
||||||
|
if (set_env_option("TNT_STATE_DIR", argv[i + 1]) != 0) {
|
||||||
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
i++;
|
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) {
|
} else if (strcmp(argv[i], "-V") == 0 || strcmp(argv[i], "--version") == 0) {
|
||||||
printf("tnt %s\n", TNT_VERSION);
|
printf("tnt %s\n", TNT_VERSION);
|
||||||
return 0;
|
return TNT_EXIT_OK;
|
||||||
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
} else if (strcmp(argv[i], "-h") == 0 || strcmp(argv[i], "--help") == 0) {
|
||||||
char output[2048] = {0};
|
char output[2048] = {0};
|
||||||
size_t pos = 0;
|
size_t pos = 0;
|
||||||
|
|
||||||
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
cli_text_append_help(output, sizeof(output), &pos, argv[0], lang);
|
||||||
fputs(output, stdout);
|
fputs(output, stdout);
|
||||||
return 0;
|
return TNT_EXIT_OK;
|
||||||
} else {
|
} else {
|
||||||
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
fprintf(stderr, cli_text_unknown_option_format(lang), argv[i]);
|
||||||
fprintf(stderr, cli_text_short_usage_format(lang), argv[0]);
|
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 */
|
/* Setup signal handlers */
|
||||||
signal(SIGINT, signal_handler);
|
signal(SIGINT, signal_handler);
|
||||||
signal(SIGTERM, signal_handler);
|
signal(SIGTERM, signal_handler);
|
||||||
|
|
@ -77,7 +234,7 @@ int main(int argc, char **argv) {
|
||||||
/* Initialize subsystems */
|
/* Initialize subsystems */
|
||||||
if (tnt_ensure_state_dir() < 0) {
|
if (tnt_ensure_state_dir() < 0) {
|
||||||
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
fprintf(stderr, "Failed to create state directory: %s\n", tnt_state_dir());
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
message_init();
|
message_init();
|
||||||
|
|
@ -86,14 +243,14 @@ int main(int argc, char **argv) {
|
||||||
g_room = room_create();
|
g_room = room_create();
|
||||||
if (!g_room) {
|
if (!g_room) {
|
||||||
fprintf(stderr, "Failed to create chat room\n");
|
fprintf(stderr, "Failed to create chat room\n");
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Initialize server */
|
/* Initialize server */
|
||||||
if (ssh_server_init(port) < 0) {
|
if (ssh_server_init(port) < 0) {
|
||||||
fprintf(stderr, "Failed to initialize server\n");
|
fprintf(stderr, "Failed to initialize server\n");
|
||||||
room_destroy(g_room);
|
room_destroy(g_room);
|
||||||
return 1;
|
return TNT_EXIT_ERROR;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Start server (blocking) */
|
/* Start server (blocking) */
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,8 @@ void manual_text_append_interactive(char *buffer, size_t buf_size,
|
||||||
" TNT - SSH terminal chat room\n"
|
" TNT - SSH terminal chat room\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37mUse\033[0m\n"
|
"\033[1;37mUse\033[0m\n"
|
||||||
" Type a message and press Enter; Esc browses; G latest; i types\n"
|
" Type, Enter sends; Up/Down recalls; Tab completes @mentions\n"
|
||||||
" : runs commands; ? opens the full key reference\n"
|
" Esc browses; / searches; G latest; i types; : commands; ? keys\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37mCommands\033[0m\n",
|
"\033[1;37mCommands\033[0m\n",
|
||||||
"\033[1;36mTNT(1) 帮助\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"
|
" TNT - SSH 终端聊天室\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37m使用\033[0m\n"
|
"\033[1;37m使用\033[0m\n"
|
||||||
" 输入消息并 Enter 发送;Esc 浏览历史;G 最新;i 输入\n"
|
" 输入并 Enter 发送;Up/Down 调出消息;Tab 补全 @mention\n"
|
||||||
" : 运行命令;? 打开完整按键参考\n"
|
" Esc 浏览;/ 搜索;G 最新;i 输入;: 命令;? 按键\n"
|
||||||
"\n"
|
"\n"
|
||||||
"\033[1;37m命令\033[0m\n"
|
"\033[1;37m命令\033[0m\n"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
285
src/message.c
285
src/message.c
|
|
@ -1,29 +1,63 @@
|
||||||
#ifndef _DEFAULT_SOURCE
|
#ifndef _DEFAULT_SOURCE
|
||||||
#define _DEFAULT_SOURCE /* for timegm() on glibc */
|
#define _DEFAULT_SOURCE /* for strcasestr() on glibc */
|
||||||
#endif
|
#endif
|
||||||
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
#if defined(__APPLE__) && !defined(_DARWIN_C_SOURCE)
|
||||||
#define _DARWIN_C_SOURCE /* for timegm() on macOS */
|
#define _DARWIN_C_SOURCE /* for strcasestr() on macOS */
|
||||||
#endif
|
#endif
|
||||||
|
|
||||||
#include "message.h"
|
#include "message.h"
|
||||||
|
#include "message_log.h"
|
||||||
#include "utf8.h"
|
#include "utf8.h"
|
||||||
|
#include <errno.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <fcntl.h>
|
#include <fcntl.h>
|
||||||
|
|
||||||
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_message_file_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
|
||||||
static time_t parse_rfc3339_utc(const char *timestamp_str) {
|
static void discard_line_remainder(FILE *fp) {
|
||||||
struct tm tm = {0};
|
int c;
|
||||||
|
|
||||||
if (!timestamp_str) {
|
while ((c = fgetc(fp)) != '\n' && c != EOF) {
|
||||||
return (time_t)-1;
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (message_log_format_record(msg, NULL, 0, &needed) < 0) {
|
||||||
if (!result || *result != '\0') {
|
return -1;
|
||||||
return (time_t)-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 */
|
/* Initialize message subsystem */
|
||||||
|
|
@ -118,67 +152,25 @@ int message_load(message_t **messages, int max_messages) {
|
||||||
fseek(fp, 0, SEEK_SET);
|
fseek(fp, 0, SEEK_SET);
|
||||||
|
|
||||||
read_messages:;
|
read_messages:;
|
||||||
char line[2048];
|
char line[MESSAGE_LOG_MAX_LINE];
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
time_t now = time(NULL);
|
||||||
|
|
||||||
/* Now read forward */
|
/* Now read forward */
|
||||||
while (fgets(line, sizeof(line), fp) && count < max_messages) {
|
while (fgets(line, sizeof(line), fp) && count < max_messages) {
|
||||||
/* Check for oversized lines */
|
/* Check for oversized lines */
|
||||||
size_t line_len = strlen(line);
|
size_t line_len = strlen(line);
|
||||||
if (line_len >= sizeof(line) - 1) {
|
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||||
/* Skip remainder of line */
|
discard_line_remainder(fp);
|
||||||
int c;
|
|
||||||
while ((c = fgetc(fp)) != '\n' && c != EOF);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Format: RFC3339_timestamp|username|content */
|
message_t parsed;
|
||||||
char line_copy[2048];
|
if (!message_log_parse_record(line, &parsed, now)) {
|
||||||
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') {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Validate field lengths */
|
msg_array[count++] = parsed;
|
||||||
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++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fclose(fp);
|
fclose(fp);
|
||||||
|
|
@ -190,6 +182,9 @@ read_messages:;
|
||||||
/* Save a message to log file */
|
/* Save a message to log file */
|
||||||
int message_save(const message_t *msg) {
|
int message_save(const message_t *msg) {
|
||||||
char log_path[PATH_MAX];
|
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;
|
int rc = 0;
|
||||||
|
|
||||||
if (tnt_state_path(log_path, sizeof(log_path), LOG_FILE) < 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;
|
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 */
|
/* Sanitize username and content to prevent log injection */
|
||||||
char safe_username[MAX_USERNAME_LEN];
|
safe_msg.timestamp = msg->timestamp;
|
||||||
char safe_content[MAX_MESSAGE_LEN];
|
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);
|
strncpy(safe_msg.content, msg->content, sizeof(safe_msg.content) - 1);
|
||||||
safe_username[sizeof(safe_username) - 1] = '\0';
|
safe_msg.content[sizeof(safe_msg.content) - 1] = '\0';
|
||||||
|
|
||||||
strncpy(safe_content, msg->content, sizeof(safe_content) - 1);
|
|
||||||
safe_content[sizeof(safe_content) - 1] = '\0';
|
|
||||||
|
|
||||||
/* Replace pipe characters and newlines to prevent log format corruption */
|
/* 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') {
|
if (*p == '|' || *p == '\n' || *p == '\r') {
|
||||||
*p = '_';
|
*p = '_';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (char *p = safe_content; *p; p++) {
|
for (char *p = safe_msg.content; *p; p++) {
|
||||||
if (*p == '|' || *p == '\n' || *p == '\r') {
|
if (*p == '|' || *p == '\n' || *p == '\r') {
|
||||||
*p = ' ';
|
*p = ' ';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Write to file: timestamp|username|content */
|
if (message_log_format_record(&safe_msg, record, sizeof(record),
|
||||||
if (fprintf(fp, "%s|%s|%s\n", timestamp, safe_username, safe_content) < 0 ||
|
&record_len) < 0 ||
|
||||||
|
fwrite(record, 1, record_len, fp) != record_len ||
|
||||||
fflush(fp) != 0) {
|
fflush(fp) != 0) {
|
||||||
rc = -1;
|
rc = -1;
|
||||||
}
|
}
|
||||||
|
|
@ -274,40 +262,21 @@ int message_search(const char *query, message_t **results, int max_results) {
|
||||||
return 0;
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
char line[2048];
|
char line[MESSAGE_LOG_MAX_LINE];
|
||||||
int count = 0;
|
int count = 0;
|
||||||
|
time_t now = time(NULL);
|
||||||
|
|
||||||
while (fgets(line, sizeof(line), fp)) {
|
while (fgets(line, sizeof(line), fp)) {
|
||||||
size_t line_len = strlen(line);
|
size_t line_len = strlen(line);
|
||||||
if (line_len >= sizeof(line) - 1) {
|
if (line_len >= sizeof(line) - 1 && line[line_len - 1] != '\n') {
|
||||||
int c;
|
discard_line_remainder(fp);
|
||||||
while ((c = fgetc(fp)) != '\n' && c != EOF);
|
|
||||||
continue;
|
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;
|
message_t m;
|
||||||
m.timestamp = msg_time;
|
if (!message_log_parse_record(line, &m, now)) continue;
|
||||||
strncpy(m.username, username, MAX_USERNAME_LEN - 1);
|
if (strcasestr(m.username, query) == NULL &&
|
||||||
m.username[MAX_USERNAME_LEN - 1] = '\0';
|
strcasestr(m.content, query) == NULL) continue;
|
||||||
strncpy(m.content, content, MAX_MESSAGE_LEN - 1);
|
|
||||||
m.content[MAX_MESSAGE_LEN - 1] = '\0';
|
|
||||||
|
|
||||||
if (count < max_results) {
|
if (count < max_results) {
|
||||||
res[count++] = m;
|
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;
|
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 */
|
/* Format a message for display */
|
||||||
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
|
void message_format(const message_t *msg, char *buffer, size_t buf_size, int width) {
|
||||||
struct tm tm_info;
|
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 "ratelimit.h"
|
||||||
|
#include "config_defaults.h"
|
||||||
#include "common.h"
|
#include "common.h"
|
||||||
#include <arpa/inet.h>
|
#include <arpa/inet.h>
|
||||||
#include <pthread.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 int g_total_connections = 0;
|
||||||
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
|
static pthread_mutex_t g_conn_count_lock = PTHREAD_MUTEX_INITIALIZER;
|
||||||
|
|
||||||
static int g_max_connections = 64;
|
static int g_max_connections = TNT_DEFAULT_MAX_CONNECTIONS;
|
||||||
static int g_max_conn_per_ip = 5;
|
static int g_max_conn_per_ip = TNT_DEFAULT_MAX_CONN_PER_IP;
|
||||||
static int g_max_conn_rate_per_ip = 10;
|
static int g_max_conn_rate_per_ip = TNT_DEFAULT_MAX_CONN_RATE_PER_IP;
|
||||||
static int g_rate_limit_enabled = 1;
|
static int g_rate_limit_enabled = TNT_DEFAULT_RATE_LIMIT_ENABLED;
|
||||||
|
|
||||||
void ratelimit_init(void) {
|
void ratelimit_init(void) {
|
||||||
g_max_connections = env_int("TNT_MAX_CONNECTIONS", 64, 1, 1024);
|
g_max_connections =
|
||||||
g_max_conn_per_ip = env_int("TNT_MAX_CONN_PER_IP", 5, 1, 1024);
|
tnt_config_env_int(&TNT_CONFIG_MAX_CONNECTIONS);
|
||||||
g_max_conn_rate_per_ip = env_int("TNT_MAX_CONN_RATE_PER_IP", 10, 1, 1024);
|
g_max_conn_per_ip =
|
||||||
g_rate_limit_enabled = env_int("TNT_RATE_LIMIT", 1, 0, 1);
|
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. */
|
/* Caller MUST hold g_rate_limit_lock. */
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
#include "ssh_server.h"
|
#include "ssh_server.h"
|
||||||
#include "bootstrap.h"
|
#include "bootstrap.h"
|
||||||
#include "commands.h"
|
#include "commands.h"
|
||||||
|
#include "config_defaults.h"
|
||||||
#include "exec.h"
|
#include "exec.h"
|
||||||
#include "input.h"
|
#include "input.h"
|
||||||
#include "ratelimit.h"
|
#include "ratelimit.h"
|
||||||
|
|
@ -23,7 +24,7 @@
|
||||||
|
|
||||||
/* Global SSH bind instance */
|
/* Global SSH bind instance */
|
||||||
static ssh_bind g_sshbind = NULL;
|
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;
|
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;
|
chips[chip_count].value_color = mode_color;
|
||||||
chip_count++;
|
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);
|
int hint_width = utf8_string_width(hint);
|
||||||
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
|
const char *mute_label = i18n_text(client->ui_lang, I18N_TITLE_MUTED);
|
||||||
int mute_width = client->mute_joins ? utf8_string_width(mute_label) + 2 : 0;
|
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
|
/* Decide what fits. Reserve at least 1 col of gap between left and
|
||||||
* right halves so they never visually touch. */
|
* 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_mute = client->mute_joins ? 1 : 0;
|
||||||
int show_unread = unread_count > 0 ? 1 : 0;
|
int show_unread = unread_count > 0 ? 1 : 0;
|
||||||
int show_whisper = whisper_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,
|
buffer_appendf(buffer, sizeof(buffer), &pos,
|
||||||
i18n_text(client->ui_lang,
|
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);
|
start + 1, max_scroll + 1);
|
||||||
|
|
||||||
client_send(client, buffer, pos);
|
client_send(client, buffer, pos);
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,54 @@
|
||||||
#include "tui_status.h"
|
#include "tui_status.h"
|
||||||
#include "i18n.h"
|
#include "i18n.h"
|
||||||
#include "ssh_server.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,
|
void tui_status_append(char *buffer, size_t buf_size, size_t *pos,
|
||||||
const struct client *client, int msg_count,
|
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));
|
i18n_text(client->ui_lang, I18N_NORMAL_LATEST));
|
||||||
}
|
}
|
||||||
} else if (client->mode == MODE_COMMAND) {
|
} 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,
|
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
|
fi
|
||||||
|
|
||||||
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
SSH_OPTS="-n -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o BatchMode=yes -p $PORT"
|
||||||
|
TNTCTL_OPTS="--host-key-checking no --known-hosts /dev/null"
|
||||||
|
|
||||||
echo "=== TNT Exec Mode Tests ==="
|
echo "=== TNT Exec Mode Tests ==="
|
||||||
|
|
||||||
|
|
@ -51,14 +52,16 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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$'
|
printf '%s\n' "$HEALTH_USAGE" | grep -q '^health: 用法: health$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$HEALTH_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ no-arg exec usage follows TNT_LANG"
|
echo "✓ no-arg exec usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ no-arg exec usage output unexpected"
|
echo "✗ no-arg exec usage output unexpected"
|
||||||
printf '%s\n' "$HEALTH_USAGE"
|
printf '%s\n' "$HEALTH_USAGE"
|
||||||
|
echo "exit status: $HEALTH_USAGE_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -98,36 +101,55 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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$'
|
printf '%s\n' "$UNKNOWN_OUTPUT" | grep -q '^未知命令: nope$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$UNKNOWN_STATUS" -eq 64 ]; then
|
||||||
echo "✓ unknown command follows TNT_LANG"
|
echo "✓ unknown command follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ unknown command output unexpected"
|
echo "✗ unknown command output unexpected"
|
||||||
printf '%s\n' "$UNKNOWN_OUTPUT"
|
printf '%s\n' "$UNKNOWN_OUTPUT"
|
||||||
|
echo "exit status: $UNKNOWN_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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$'
|
printf '%s\n' "$POST_USAGE" | grep -q '^post: 用法: post MESSAGE$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$POST_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ post usage follows TNT_LANG"
|
echo "✓ post usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ post usage output unexpected"
|
echo "✗ post usage output unexpected"
|
||||||
printf '%s\n' "$POST_USAGE"
|
printf '%s\n' "$POST_USAGE"
|
||||||
|
echo "exit status: $POST_USAGE_STATUS"
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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\]$'
|
printf '%s\n' "$USERS_USAGE" | grep -q '^users: 用法: users \[--json\]$'
|
||||||
if [ $? -eq 0 ]; then
|
if [ $? -eq 0 ] && [ "$USERS_USAGE_STATUS" -eq 64 ]; then
|
||||||
echo "✓ users usage follows TNT_LANG"
|
echo "✓ users usage follows TNT_LANG and exits 64"
|
||||||
PASS=$((PASS + 1))
|
PASS=$((PASS + 1))
|
||||||
else
|
else
|
||||||
echo "✗ users usage output unexpected"
|
echo "✗ users usage output unexpected"
|
||||||
printf '%s\n' "$USERS_USAGE"
|
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))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|
@ -152,6 +174,128 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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"
|
EXPECT_SCRIPT="${STATE_DIR}/watcher.expect"
|
||||||
WATCHER_READY="${STATE_DIR}/watcher.ready"
|
WATCHER_READY="${STATE_DIR}/watcher.ready"
|
||||||
cat >"$EXPECT_SCRIPT" <<EOF
|
cat >"$EXPECT_SCRIPT" <<EOF
|
||||||
|
|
@ -160,7 +304,7 @@ spawn ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -p $PORT w
|
||||||
expect "请输入用户名"
|
expect "请输入用户名"
|
||||||
send "watcher\r"
|
send "watcher\r"
|
||||||
exec touch "$WATCHER_READY"
|
exec touch "$WATCHER_READY"
|
||||||
sleep 8
|
sleep 12
|
||||||
send "\003"
|
send "\003"
|
||||||
expect eof
|
expect eof
|
||||||
EOF
|
EOF
|
||||||
|
|
@ -213,6 +357,45 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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
|
wait "${INTERACTIVE_PID}" 2>/dev/null || true
|
||||||
INTERACTIVE_PID=""
|
INTERACTIVE_PID=""
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,58 @@ else
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
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"
|
EXPECT_SCRIPT="$STATE_DIR/bracketed-paste.expect"
|
||||||
cat >"$EXPECT_SCRIPT" <<EOF
|
cat >"$EXPECT_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "tester\r"
|
send -- "tester\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033\[200~"
|
send -- "\033\[200~"
|
||||||
send -- "line1\nline2\nline3"
|
send -- "line1\nline2\nline3"
|
||||||
send -- "\033\[201~"
|
send -- "\033\[201~"
|
||||||
|
|
@ -139,21 +184,28 @@ set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "helper\r"
|
send -- "helper\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
expect ":"
|
expect ":"
|
||||||
send -- "help\r"
|
send -- ":help\r"
|
||||||
expect "TNT\\(1\\) 帮助"
|
expect "TNT\\(1\\) 帮助"
|
||||||
|
expect "Tab 补全 @mention"
|
||||||
expect "q:关闭"
|
expect "q:关闭"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- "?"
|
send -- "?"
|
||||||
expect "TNT 按键参考"
|
expect "TNT 按键参考"
|
||||||
|
expect "Tab - 补全 @mention"
|
||||||
expect "l:语言"
|
expect "l:语言"
|
||||||
|
send -- "\003"
|
||||||
|
expect "NORMAL"
|
||||||
|
send -- "?"
|
||||||
|
expect "TNT 按键参考"
|
||||||
send -- "l"
|
send -- "l"
|
||||||
expect "TNT KEY REFERENCE"
|
expect "TNT KEY REFERENCE"
|
||||||
|
expect "Complete @mention"
|
||||||
expect "l:lang"
|
expect "l:lang"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
|
|
@ -180,13 +232,52 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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"
|
UNKNOWN_SCRIPT="$STATE_DIR/unknown-command.expect"
|
||||||
cat >"$UNKNOWN_SCRIPT" <<EOF
|
cat >"$UNKNOWN_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "mistype\r"
|
send -- "mistype\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
|
|
@ -218,7 +309,7 @@ set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "localized\r"
|
send -- "localized\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
|
|
@ -268,7 +359,7 @@ set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "usageuser\r"
|
send -- "usageuser\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
|
|
@ -304,6 +395,9 @@ expect ":"
|
||||||
send -- "inbox\r"
|
send -- "inbox\r"
|
||||||
expect "Private messages"
|
expect "Private messages"
|
||||||
expect "(empty)"
|
expect "(empty)"
|
||||||
|
expect "r:refresh"
|
||||||
|
send -- "r"
|
||||||
|
expect "Private messages"
|
||||||
expect "q:close"
|
expect "q:close"
|
||||||
send -- "q"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
|
|
@ -358,7 +452,7 @@ stty rows 8 columns 80
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "pageruser\r"
|
send -- "pageruser\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
|
|
@ -368,6 +462,14 @@ expect "j/k:滚动"
|
||||||
expect -re {\(1/[2-9][0-9]*\)}
|
expect -re {\(1/[2-9][0-9]*\)}
|
||||||
send -- "j"
|
send -- "j"
|
||||||
expect -re {\(2/[2-9][0-9]*\)}
|
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"
|
send -- "q"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
|
|
@ -387,13 +489,44 @@ else
|
||||||
FAIL=$((FAIL + 1))
|
FAIL=$((FAIL + 1))
|
||||||
fi
|
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"
|
SYSTEM_MESSAGES_SCRIPT="$STATE_DIR/system-messages.expect"
|
||||||
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
|
cat >"$SYSTEM_MESSAGES_SCRIPT" <<EOF
|
||||||
set timeout 10
|
set timeout 10
|
||||||
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
spawn ssh $SSH_OPTS anonymous@127.0.0.1
|
||||||
sleep 1
|
sleep 1
|
||||||
send -- "systemuser\r"
|
send -- "systemuser\r"
|
||||||
expect ":help"
|
expect "Esc NORMAL"
|
||||||
send -- "\033"
|
send -- "\033"
|
||||||
expect "NORMAL"
|
expect "NORMAL"
|
||||||
send -- ":"
|
send -- ":"
|
||||||
|
|
@ -440,7 +573,7 @@ expect "公告"
|
||||||
expect "维护窗口"
|
expect "维护窗口"
|
||||||
expect "按任意键继续"
|
expect "按任意键继续"
|
||||||
send -- "x"
|
send -- "x"
|
||||||
expect "NORMAL"
|
expect "INSERT"
|
||||||
sleep 0.2
|
sleep 0.2
|
||||||
send -- "\003"
|
send -- "\003"
|
||||||
sleep 0.2
|
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
|
# Source files
|
||||||
UTF8_SRC = ../../src/utf8.c
|
UTF8_SRC = ../../src/utf8.c
|
||||||
MESSAGE_SRC = ../../src/message.c
|
MESSAGE_SRC = ../../src/message.c
|
||||||
|
MESSAGE_LOG_SRC = ../../src/message_log.c
|
||||||
COMMON_SRC = ../../src/common.c
|
COMMON_SRC = ../../src/common.c
|
||||||
|
CONFIG_DEFAULTS_SRC = ../../src/config_defaults.c
|
||||||
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
|
COMMAND_CATALOG_SRC = ../../src/command_catalog.c
|
||||||
CLI_TEXT_SRC = ../../src/cli_text.c
|
CLI_TEXT_SRC = ../../src/cli_text.c
|
||||||
|
TNTCTL_TEXT_SRC = ../../src/tntctl_text.c
|
||||||
CHAT_ROOM_SRC = ../../src/chat_room.c
|
CHAT_ROOM_SRC = ../../src/chat_room.c
|
||||||
HISTORY_VIEW_SRC = ../../src/history_view.c
|
HISTORY_VIEW_SRC = ../../src/history_view.c
|
||||||
I18N_SRC = ../../src/i18n.c
|
I18N_SRC = ../../src/i18n.c
|
||||||
|
|
@ -25,7 +28,7 @@ HELP_TEXT_SRC = ../../src/help_text.c
|
||||||
MANUAL_TEXT_SRC = ../../src/manual_text.c
|
MANUAL_TEXT_SRC = ../../src/manual_text.c
|
||||||
RATELIMIT_SRC = ../../src/ratelimit.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
|
.PHONY: all clean run
|
||||||
|
|
||||||
|
|
@ -34,10 +37,10 @@ all: $(TESTS)
|
||||||
test_utf8: test_utf8.c $(UTF8_SRC)
|
test_utf8: test_utf8.c $(UTF8_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(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)
|
$(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)
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
test_history_view: test_history_view.c $(HISTORY_VIEW_SRC)
|
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)
|
test_cli_text: test_cli_text.c $(CLI_TEXT_SRC) $(COMMON_SRC)
|
||||||
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
$(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)
|
$(CC) $(CFLAGS) -o $@ $^ $(LDFLAGS)
|
||||||
|
|
||||||
run: all
|
run: all
|
||||||
|
|
@ -101,8 +110,14 @@ run: all
|
||||||
@echo "=== Running CLI Text Tests ==="
|
@echo "=== Running CLI Text Tests ==="
|
||||||
./test_cli_text
|
./test_cli_text
|
||||||
@echo ""
|
@echo ""
|
||||||
|
@echo "=== Running tntctl Text Tests ==="
|
||||||
|
./test_tntctl_text
|
||||||
|
@echo ""
|
||||||
@echo "=== Running Rate Limit Tests ==="
|
@echo "=== Running Rate Limit Tests ==="
|
||||||
./test_ratelimit
|
./test_ratelimit
|
||||||
|
@echo ""
|
||||||
|
@echo "=== Running Config Defaults Tests ==="
|
||||||
|
./test_config_defaults
|
||||||
|
|
||||||
clean:
|
clean:
|
||||||
rm -f $(TESTS) *.o test_messages.log
|
rm -f $(TESTS) *.o test_messages.log
|
||||||
|
|
|
||||||
|
|
@ -159,8 +159,9 @@ TEST(room_remove_nonexistent_client) {
|
||||||
|
|
||||||
TEST(room_add_client_full) {
|
TEST(room_add_client_full) {
|
||||||
chat_room_t *room = room_create();
|
chat_room_t *room = room_create();
|
||||||
client_t clients[MAX_CLIENTS + 1];
|
client_t *clients = calloc((size_t)room->client_capacity + 1,
|
||||||
memset(clients, 0, sizeof(clients));
|
sizeof(*clients));
|
||||||
|
assert(clients != NULL);
|
||||||
|
|
||||||
for (int i = 0; i < room->client_capacity; i++) {
|
for (int i = 0; i < room->client_capacity; i++) {
|
||||||
assert(room_add_client(room, &clients[i]) == 0);
|
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_add_client(room, &clients[room->client_capacity]) == -1);
|
||||||
assert(room_get_client_count(room) == room->client_capacity);
|
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);
|
room_destroy(room);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -201,6 +219,7 @@ int main(void) {
|
||||||
RUN_TEST(room_client_count);
|
RUN_TEST(room_client_count);
|
||||||
RUN_TEST(room_remove_nonexistent_client);
|
RUN_TEST(room_remove_nonexistent_client);
|
||||||
RUN_TEST(room_add_client_full);
|
RUN_TEST(room_add_client_full);
|
||||||
|
RUN_TEST(room_capacity_follows_tnt_max_connections);
|
||||||
RUN_TEST(room_message_count_threadsafe);
|
RUN_TEST(room_message_count_threadsafe);
|
||||||
|
|
||||||
printf("\nAll %d tests passed!\n", tests_passed);
|
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);
|
cli_text_append_help(output, sizeof(output), &pos, "tnt", UI_LANG_EN);
|
||||||
assert(strstr(output, "anonymous SSH chat server") != NULL);
|
assert(strstr(output, "anonymous SSH chat server") != NULL);
|
||||||
assert(strstr(output, "Usage: tnt [options]") != 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);
|
assert(strstr(output, "TNT_LANG") != NULL);
|
||||||
|
|
||||||
memset(output, 0, sizeof(output));
|
memset(output, 0, sizeof(output));
|
||||||
|
|
@ -35,6 +39,9 @@ TEST(help_matches_language) {
|
||||||
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
|
assert(strstr(output, "匿名 SSH 聊天服务器") != NULL);
|
||||||
assert(strstr(output, "用法: tnt [options]") != NULL);
|
assert(strstr(output, "用法: tnt [options]") != NULL);
|
||||||
assert(strstr(output, "[选项]") == 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);
|
assert(strstr(output, "TNT_LANG") != NULL);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -43,14 +50,22 @@ TEST(error_formats_match_language) {
|
||||||
"Invalid port: %s\n") == 0);
|
"Invalid port: %s\n") == 0);
|
||||||
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
|
assert(strcmp(cli_text_invalid_port_format(UI_LANG_ZH),
|
||||||
"端口无效: %s\n") == 0);
|
"端口无效: %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),
|
assert(strcmp(cli_text_unknown_option_format(UI_LANG_EN),
|
||||||
"Unknown option: %s\n") == 0);
|
"Unknown option: %s\n") == 0);
|
||||||
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
|
assert(strcmp(cli_text_unknown_option_format(UI_LANG_ZH),
|
||||||
"未知选项: %s\n") == 0);
|
"未知选项: %s\n") == 0);
|
||||||
assert(strcmp(cli_text_short_usage_format(UI_LANG_EN),
|
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),
|
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),
|
assert(strcmp(cli_text_invalid_port_format((ui_lang_t)99),
|
||||||
"Invalid port: %s\n") == 0);
|
"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, "TNT exec interface") != NULL);
|
||||||
assert(strstr(en, "Commands:") != NULL);
|
assert(strstr(en, "Commands:") != NULL);
|
||||||
assert(strstr(en, "users [--json]") != NULL);
|
assert(strstr(en, "users [--json]") != NULL);
|
||||||
|
assert(strstr(en, "dump [N]") != NULL);
|
||||||
assert(strstr(en, "post MESSAGE") != NULL);
|
assert(strstr(en, "post MESSAGE") != NULL);
|
||||||
assert(strstr(en, "support") == NULL);
|
assert(strstr(en, "support") == NULL);
|
||||||
|
|
||||||
assert(strstr(zh, "TNT exec 接口") != NULL);
|
assert(strstr(zh, "TNT exec 接口") != NULL);
|
||||||
assert(strstr(zh, "命令:") != NULL);
|
assert(strstr(zh, "命令:") != NULL);
|
||||||
assert(strstr(zh, "users [--json]") != NULL);
|
assert(strstr(zh, "users [--json]") != NULL);
|
||||||
|
assert(strstr(zh, "dump [N]") != NULL);
|
||||||
assert(strstr(zh, "post MESSAGE") != NULL);
|
assert(strstr(zh, "post MESSAGE") != NULL);
|
||||||
assert(strstr(zh, "support") == NULL);
|
assert(strstr(zh, "support") == NULL);
|
||||||
assert_ascii_angle_placeholders(zh);
|
assert_ascii_angle_placeholders(zh);
|
||||||
|
|
@ -65,6 +67,10 @@ TEST(matches_exec_commands_and_args) {
|
||||||
assert(id == TNT_EXEC_COMMAND_TAIL);
|
assert(id == TNT_EXEC_COMMAND_TAIL);
|
||||||
assert(strcmp(args, "-n 20") == 0);
|
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(exec_catalog_match("post hello world", &id, &args));
|
||||||
assert(id == TNT_EXEC_COMMAND_POST);
|
assert(id == TNT_EXEC_COMMAND_POST);
|
||||||
assert(strcmp(args, "hello world") == 0);
|
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, NULL));
|
||||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_TAIL, "-n 20"));
|
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, NULL));
|
||||||
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
|
assert(exec_catalog_args_valid(TNT_EXEC_COMMAND_POST, "hello"));
|
||||||
}
|
}
|
||||||
|
|
@ -111,8 +120,18 @@ TEST(generates_localized_usage) {
|
||||||
memset(en, 0, sizeof(en));
|
memset(en, 0, sizeof(en));
|
||||||
en_pos = 0;
|
en_pos = 0;
|
||||||
exec_catalog_append_usage(en, sizeof(en), &en_pos,
|
exec_catalog_append_usage(en, sizeof(en), &en_pos,
|
||||||
TNT_EXEC_COMMAND_TAIL, (ui_lang_t)99);
|
TNT_EXEC_COMMAND_DUMP, (ui_lang_t)99);
|
||||||
assert(strcmp(en, "tail: usage: tail [N] | tail -n N\n") == 0);
|
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) {
|
int main(void) {
|
||||||
|
|
@ -122,6 +141,7 @@ int main(void) {
|
||||||
RUN_TEST(matches_exec_commands_and_args);
|
RUN_TEST(matches_exec_commands_and_args);
|
||||||
RUN_TEST(validates_argument_shapes);
|
RUN_TEST(validates_argument_shapes);
|
||||||
RUN_TEST(generates_localized_usage);
|
RUN_TEST(generates_localized_usage);
|
||||||
|
RUN_TEST(generates_unique_command_list);
|
||||||
|
|
||||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||||
return 0;
|
return 0;
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ TEST(full_help_matches_language) {
|
||||||
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
|
assert(strstr(en, "AVAILABLE COMMANDS") != NULL);
|
||||||
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
|
assert(strstr(en, "COMMAND OUTPUT KEYS") != NULL);
|
||||||
assert(strstr(en, ":inbox") != NULL);
|
assert(strstr(en, ":inbox") != NULL);
|
||||||
|
assert(strstr(en, "Refresh live output") != NULL);
|
||||||
assert(strstr(en, ":support") == NULL);
|
assert(strstr(en, ":support") == NULL);
|
||||||
assert(strstr(en, ":commands") == NULL);
|
assert(strstr(en, ":commands") == NULL);
|
||||||
assert(strstr(en, "Cycle UI language") != 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, "命令输出按键") != NULL);
|
assert(strstr(zh, "命令输出按键") != NULL);
|
||||||
assert(strstr(zh, ":inbox") != NULL);
|
assert(strstr(zh, ":inbox") != NULL);
|
||||||
|
assert(strstr(zh, "刷新动态输出") != NULL);
|
||||||
assert(strstr(zh, "/me <action>") != NULL);
|
assert(strstr(zh, "/me <action>") != NULL);
|
||||||
assert(strstr(zh, "@username") != NULL);
|
assert(strstr(zh, "@username") != NULL);
|
||||||
assert(strstr(zh, "<动作>") == NULL);
|
assert(strstr(zh, "<动作>") == NULL);
|
||||||
|
|
|
||||||
|
|
@ -80,10 +80,21 @@ TEST(default_uses_locale_when_no_tnt_lang) {
|
||||||
|
|
||||||
TEST(text_lookup_matches_language) {
|
TEST(text_lookup_matches_language) {
|
||||||
i18n_string_t sample = I18N_STRING("fallback", "替代");
|
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_EN), "fallback") == 0);
|
||||||
assert(strcmp(i18n_string(sample, UI_LANG_ZH), "替代") == 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(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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_USERNAME_PROMPT),
|
||||||
"display name") != NULL);
|
"display name") != NULL);
|
||||||
|
|
@ -111,6 +122,12 @@ TEST(text_lookup_matches_language) {
|
||||||
"q:close") != NULL);
|
"q:close") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_COMMAND_OUTPUT_STATUS_FORMAT),
|
||||||
"q:关闭") != NULL);
|
"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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_MOTD_CONTINUE_HINT),
|
||||||
"Press any key") != NULL);
|
"Press any key") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_MOTD_CONTINUE_HINT),
|
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);
|
"message cannot be empty") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_POST_EMPTY),
|
||||||
"消息不能为空") != NULL);
|
"消息不能为空") != 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),
|
assert(strstr(i18n_text(UI_LANG_EN, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
"Unknown command") != NULL);
|
"Unknown command") != NULL);
|
||||||
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
assert(strstr(i18n_text(UI_LANG_ZH, I18N_EXEC_UNKNOWN_COMMAND_FORMAT),
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,10 @@
|
||||||
#include <stdio.h>
|
#include <stdio.h>
|
||||||
#include <string.h>
|
#include <string.h>
|
||||||
#include <assert.h>
|
#include <assert.h>
|
||||||
|
#include <stdlib.h>
|
||||||
#include <unistd.h>
|
#include <unistd.h>
|
||||||
#include <sys/stat.h>
|
#include <sys/stat.h>
|
||||||
|
#include <limits.h>
|
||||||
|
|
||||||
#define TEST(name) static void test_##name()
|
#define TEST(name) static void test_##name()
|
||||||
#define RUN_TEST(name) do { \
|
#define RUN_TEST(name) do { \
|
||||||
|
|
@ -16,12 +18,45 @@
|
||||||
|
|
||||||
static int tests_passed = 0;
|
static int tests_passed = 0;
|
||||||
static const char *test_log = "test_messages.log";
|
static const char *test_log = "test_messages.log";
|
||||||
|
static char test_state_dir[PATH_MAX];
|
||||||
|
|
||||||
/* Helper: Clean up test log file */
|
/* Helper: Clean up test log file */
|
||||||
static void cleanup_test_log(void) {
|
static void cleanup_test_log(void) {
|
||||||
unlink(test_log);
|
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 initialization */
|
||||||
TEST(message_init) {
|
TEST(message_init) {
|
||||||
message_init();
|
message_init();
|
||||||
|
|
@ -122,6 +157,104 @@ TEST(message_save_basic) {
|
||||||
cleanup_test_log();
|
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 edge cases */
|
||||||
TEST(message_edge_cases) {
|
TEST(message_edge_cases) {
|
||||||
message_t msg;
|
message_t msg;
|
||||||
|
|
@ -215,12 +348,16 @@ int main(void) {
|
||||||
RUN_TEST(message_format_unicode);
|
RUN_TEST(message_format_unicode);
|
||||||
RUN_TEST(message_format_width_limits);
|
RUN_TEST(message_format_width_limits);
|
||||||
RUN_TEST(message_save_basic);
|
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_edge_cases);
|
||||||
RUN_TEST(message_special_characters);
|
RUN_TEST(message_special_characters);
|
||||||
RUN_TEST(message_buffer_safety);
|
RUN_TEST(message_buffer_safety);
|
||||||
RUN_TEST(message_timestamp_formats);
|
RUN_TEST(message_timestamp_formats);
|
||||||
|
|
||||||
cleanup_test_log();
|
cleanup_test_log();
|
||||||
|
cleanup_state_dir();
|
||||||
|
|
||||||
printf("\n✓ All %d tests passed!\n", tests_passed);
|
printf("\n✓ All %d tests passed!\n", tests_passed);
|
||||||
return 0;
|
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 ]
|
.IR port ]
|
||||||
.RB [ \-d | \-\-state\-dir
|
.RB [ \-d | \-\-state\-dir
|
||||||
.IR 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 [ \-V | \-\-version ]
|
||||||
.RB [ \-h | \-\-help ]
|
.RB [ \-h | \-\-help ]
|
||||||
|
.br
|
||||||
|
.B tnt
|
||||||
|
.B \-\-log\-check
|
||||||
|
.I file
|
||||||
|
.br
|
||||||
|
.B tnt
|
||||||
|
.B \-\-log\-recover
|
||||||
|
.I file
|
||||||
.SH DESCRIPTION
|
.SH DESCRIPTION
|
||||||
.B tnt
|
.B tnt
|
||||||
is a multi\-user anonymous chat server accessed over SSH.
|
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
|
Users connect with any standard SSH client; no account or registration is
|
||||||
needed.
|
needed.
|
||||||
.PP
|
.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.
|
Messages are persisted to a log file and restored on server restart.
|
||||||
The server supports CJK and emoji input, rate limiting, access tokens, and
|
The server supports CJK and emoji input, rate limiting, access tokens, and
|
||||||
a non\-interactive exec interface for scripting.
|
a non\-interactive exec interface for scripting.
|
||||||
|
|
@ -39,6 +70,73 @@ Overrides the
|
||||||
environment variable.
|
environment variable.
|
||||||
Defaults to the current working directory.
|
Defaults to the current working directory.
|
||||||
.TP
|
.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
|
.BR \-V ", " \-\-version
|
||||||
Print version and exit.
|
Print version and exit.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -69,6 +167,8 @@ Press
|
||||||
to return to INSERT,
|
to return to INSERT,
|
||||||
.B :
|
.B :
|
||||||
to enter COMMAND mode,
|
to enter COMMAND mode,
|
||||||
|
.B /
|
||||||
|
to search message history,
|
||||||
.B ?
|
.B ?
|
||||||
to open the full key reference.
|
to open the full key reference.
|
||||||
.TP
|
.TP
|
||||||
|
|
@ -84,6 +184,8 @@ ESC Switch to NORMAL
|
||||||
Ctrl+W Delete last word
|
Ctrl+W Delete last word
|
||||||
Ctrl+U Clear input line
|
Ctrl+U Clear input line
|
||||||
Ctrl+C Switch to NORMAL
|
Ctrl+C Switch to NORMAL
|
||||||
|
Up/Down Browse sent message history
|
||||||
|
Tab Complete @mention
|
||||||
Paste Keep multi-line paste in the input buffer
|
Paste Keep multi-line paste in the input buffer
|
||||||
/me \fIaction\fR Send action message (e.g. /me waves)
|
/me \fIaction\fR Send action message (e.g. /me waves)
|
||||||
@\fIusername\fR Mention user (bell notification + highlight)
|
@\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
|
PageDown/PageUp Scroll full page down/up
|
||||||
End/Home Jump to bottom/top
|
End/Home Jump to bottom/top
|
||||||
g/G Jump to top/bottom
|
g/G Jump to top/bottom
|
||||||
|
/ Search message history
|
||||||
i Switch to INSERT
|
i Switch to INSERT
|
||||||
: Enter COMMAND mode
|
: Enter COMMAND mode
|
||||||
? Open full key reference
|
? Open full key reference
|
||||||
|
|
@ -119,8 +222,9 @@ l l.
|
||||||
:w \fIuser text\fR Short alias for :msg
|
:w \fIuser text\fR Short alias for :msg
|
||||||
:inbox Show private messages
|
:inbox Show private messages
|
||||||
:last [\fIN\fR] Show last N messages from history (1\-50, default 10)
|
: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
|
:mute\-joins Toggle join/leave system notifications on/off
|
||||||
|
:lang Show current UI language
|
||||||
:lang \fIen|zh\fR Switch UI language for this session
|
:lang \fIen|zh\fR Switch UI language for this session
|
||||||
:help Show concise manual
|
:help Show concise manual
|
||||||
:clear Clear command output
|
:clear Clear command output
|
||||||
|
|
@ -128,6 +232,25 @@ l l.
|
||||||
Up/Down Browse command history
|
Up/Down Browse command history
|
||||||
ESC Cancel and return to NORMAL
|
ESC Cancel and return to NORMAL
|
||||||
.TE
|
.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
|
.SH EXEC INTERFACE
|
||||||
Commands can be run non\-interactively for scripting:
|
Commands can be run non\-interactively for scripting:
|
||||||
.PP
|
.PP
|
||||||
|
|
@ -136,6 +259,7 @@ ssh host \-p 2222 help
|
||||||
ssh host \-p 2222 users \-\-json
|
ssh host \-p 2222 users \-\-json
|
||||||
ssh host \-p 2222 stats \-\-json
|
ssh host \-p 2222 stats \-\-json
|
||||||
ssh host \-p 2222 tail 20
|
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 "Hello from a script"
|
||||||
ssh host \-p 2222 post "/me deploys v2.0"
|
ssh host \-p 2222 post "/me deploys v2.0"
|
||||||
ssh host \-p 2222 health
|
ssh host \-p 2222 health
|
||||||
|
|
@ -144,6 +268,30 @@ ssh host \-p 2222 health
|
||||||
Exit codes follow
|
Exit codes follow
|
||||||
.BR sysexits (3)
|
.BR sysexits (3)
|
||||||
conventions.
|
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
|
.SH ENVIRONMENT
|
||||||
.TP
|
.TP
|
||||||
.B PORT
|
.B PORT
|
||||||
|
|
@ -152,6 +300,12 @@ Default listening port (default: 2222).
|
||||||
.B TNT_STATE_DIR
|
.B TNT_STATE_DIR
|
||||||
Directory for host key and message log (default: current directory).
|
Directory for host key and message log (default: current directory).
|
||||||
.TP
|
.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
|
.B TNT_ACCESS_TOKEN
|
||||||
If set, clients must supply this string as their SSH password.
|
If set, clients must supply this string as their SSH password.
|
||||||
Compared in constant time.
|
Compared in constant time.
|
||||||
|
|
@ -180,12 +334,19 @@ Explicit capacity limits still apply (default: 1).
|
||||||
.B TNT_IDLE_TIMEOUT
|
.B TNT_IDLE_TIMEOUT
|
||||||
Disconnect clients after this many seconds of inactivity.
|
Disconnect clients after this many seconds of inactivity.
|
||||||
Set to 0 to disable (default: 1800, i.e. 30 minutes).
|
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
|
.SH FILES
|
||||||
.TP
|
.TP
|
||||||
.I messages.log
|
.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 ).
|
.RI ( timestamp | username | content ).
|
||||||
Stored in the state directory.
|
Stored in the state directory.
|
||||||
|
See
|
||||||
|
.I docs/MESSAGE_LOG.md
|
||||||
|
in the source distribution for parser and recovery rules.
|
||||||
.TP
|
.TP
|
||||||
.I host_key
|
.I host_key
|
||||||
RSA 4096\-bit host key, auto\-generated on first run.
|
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